m3-sc 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- m3/__init__.py +29 -0
- m3/_bridge.py +77 -0
- m3/_concat.py +72 -0
- m3/_dataset.py +63 -0
- m3/_engine/__init__.py +4 -0
- m3/_engine/feature_selection.py +300 -0
- m3/_engine/model.py +221 -0
- m3/_engine/nn.py +115 -0
- m3/_engine/patient_prediction.py +344 -0
- m3/_engine/reclassify.py +233 -0
- m3/_engine/run_M3.py +1518 -0
- m3/_engine/simulation.py +178 -0
- m3/_engine/train.py +368 -0
- m3/_engine/util.py +507 -0
- m3/_model.py +915 -0
- m3/datasets/__init__.py +13 -0
- m3/datasets/_data/liu_demo.h5ad +0 -0
- m3/datasets/_loaders.py +108 -0
- m3/io.py +173 -0
- m3_sc-0.3.0.dist-info/METADATA +98 -0
- m3_sc-0.3.0.dist-info/RECORD +23 -0
- m3_sc-0.3.0.dist-info/WHEEL +4 -0
- m3_sc-0.3.0.dist-info/licenses/LICENSE +21 -0
m3/__init__.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""m3 -- multimodal, multi-batch, condition-aware single-cell model."""
|
|
2
|
+
|
|
3
|
+
from m3.io import read_matrix, from_anndata, read_h5ad, read_h5
|
|
4
|
+
from m3._concat import concat
|
|
5
|
+
from m3 import datasets
|
|
6
|
+
|
|
7
|
+
__version__ = "0.3.0"
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"read_matrix", "from_anndata", "read_h5ad", "read_h5", "concat",
|
|
11
|
+
"M3", "Attribution", "M3CapabilityError",
|
|
12
|
+
"datasets",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
# The model and its readouts need torch; import them lazily so that `import m3`
|
|
16
|
+
# and the torch-free readers (io) and bundled datasets stay usable without a
|
|
17
|
+
# torch install. Accessing m3.M3 (etc.) triggers the torch-backed import.
|
|
18
|
+
_LAZY = {"M3", "Attribution", "M3CapabilityError"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def __getattr__(name):
|
|
22
|
+
if name in _LAZY:
|
|
23
|
+
from m3 import _model
|
|
24
|
+
return getattr(_model, name)
|
|
25
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def __dir__():
|
|
29
|
+
return sorted(__all__)
|
m3/_bridge.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Bridge: marshal a :class:`m3.Dataset` to the engine's file-based inputs.
|
|
2
|
+
|
|
3
|
+
The engine (``m3._engine.run_M3``) consumes per-modality lists of
|
|
4
|
+
paper-format HDF5 files (one element per batch, ``None`` where a batch lacks a
|
|
5
|
+
modality) plus a per-batch metadata CSV list. This module writes those temp
|
|
6
|
+
files from an in-memory ``Dataset`` so the proven engine path runs unchanged.
|
|
7
|
+
|
|
8
|
+
Paper HDF5 layout (what ``m3._engine.util.read_h5_data`` expects):
|
|
9
|
+
``matrix/data`` is genes x cells (transpose of our cells x genes), and
|
|
10
|
+
``matrix/features`` holds the feature names.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import tempfile
|
|
17
|
+
|
|
18
|
+
import h5py
|
|
19
|
+
import numpy as np
|
|
20
|
+
|
|
21
|
+
_MODALITY_ORDER = ("rna", "adt", "atac") # maps to engine modality1/2/3_path
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _write_paper_h5(path: str, counts_cells_by_features, feature_names) -> None:
|
|
25
|
+
dense = np.asarray(counts_cells_by_features.todense()
|
|
26
|
+
if hasattr(counts_cells_by_features, "todense")
|
|
27
|
+
else counts_cells_by_features, dtype=np.float32)
|
|
28
|
+
# Store cells x features (the Liu/Stephenson layout). The engine's
|
|
29
|
+
# read_h5_data + load_data_from_list double-transpose nets back to cells x features.
|
|
30
|
+
with h5py.File(path, "w") as f:
|
|
31
|
+
g = f.create_group("matrix")
|
|
32
|
+
g.create_dataset("data", data=dense)
|
|
33
|
+
g.create_dataset("features", data=np.array([str(x) for x in feature_names], dtype="S"))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def marshal(dataset, tmpdir: str | None = None) -> dict:
|
|
37
|
+
"""Write a Dataset to temp paper-format h5 + metadata CSVs.
|
|
38
|
+
|
|
39
|
+
Returns a dict with:
|
|
40
|
+
modality_paths: {"rna": [path|None per batch], "adt": [...], "atac": [...]}
|
|
41
|
+
metadata_paths: [csv path per batch]
|
|
42
|
+
batches: ordered batch labels (row order of the engine's concatenation)
|
|
43
|
+
tmpdir: the directory holding the temp files (caller may clean up)
|
|
44
|
+
"""
|
|
45
|
+
if tmpdir is None:
|
|
46
|
+
tmpdir = tempfile.mkdtemp(prefix="m3_marshal_")
|
|
47
|
+
os.makedirs(tmpdir, exist_ok=True)
|
|
48
|
+
|
|
49
|
+
obs = dataset.obs
|
|
50
|
+
batches = list(dataset.batches)
|
|
51
|
+
batch_rows = {c: (obs["batch"].to_numpy() == c) for c in batches}
|
|
52
|
+
|
|
53
|
+
modality_paths: dict[str, list] = {m: [] for m in _MODALITY_ORDER}
|
|
54
|
+
metadata_paths: list[str] = []
|
|
55
|
+
|
|
56
|
+
for ci, c in enumerate(batches):
|
|
57
|
+
rows = batch_rows[c]
|
|
58
|
+
# metadata CSV for this batch (engine reads with pandas; needs the role columns)
|
|
59
|
+
meta_path = os.path.join(tmpdir, f"metadata_{ci}.csv")
|
|
60
|
+
obs.loc[rows].reset_index(drop=True).to_csv(meta_path, index=False)
|
|
61
|
+
metadata_paths.append(meta_path)
|
|
62
|
+
|
|
63
|
+
for m in _MODALITY_ORDER:
|
|
64
|
+
if m in dataset.modalities and bool(dataset.present[m][rows].any()):
|
|
65
|
+
mat = dataset.modalities[m][rows]
|
|
66
|
+
path = os.path.join(tmpdir, f"{m}_{ci}.h5")
|
|
67
|
+
_write_paper_h5(path, mat, dataset.var[m])
|
|
68
|
+
modality_paths[m].append(path)
|
|
69
|
+
else:
|
|
70
|
+
modality_paths[m].append(None)
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
"modality_paths": modality_paths,
|
|
74
|
+
"metadata_paths": metadata_paths,
|
|
75
|
+
"batches": batches,
|
|
76
|
+
"tmpdir": tmpdir,
|
|
77
|
+
}
|
m3/_concat.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Multi-batch assembly: stack single-batch Datasets into one."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import warnings
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
import pandas as pd
|
|
9
|
+
import scipy.sparse as sp
|
|
10
|
+
|
|
11
|
+
from m3._dataset import Dataset
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _intersect_ordered(indices: list[pd.Index]) -> pd.Index:
|
|
15
|
+
common = set(indices[0])
|
|
16
|
+
for idx in indices[1:]:
|
|
17
|
+
common &= set(idx)
|
|
18
|
+
# stable order: follow the first batch's ordering
|
|
19
|
+
return pd.Index([g for g in indices[0] if g in common])
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def concat(datasets: list[Dataset]) -> Dataset:
|
|
23
|
+
"""Combine single-batch Datasets into one multi-batch Dataset.
|
|
24
|
+
|
|
25
|
+
- features per modality are harmonized by NAME intersection (first batch's order)
|
|
26
|
+
- a batch missing a whole modality is zero-filled with present[modality]=False
|
|
27
|
+
- batch labels must be unique across inputs
|
|
28
|
+
"""
|
|
29
|
+
if not datasets:
|
|
30
|
+
raise ValueError("concat requires at least one Dataset.")
|
|
31
|
+
if len(datasets) == 1:
|
|
32
|
+
return datasets[0]
|
|
33
|
+
|
|
34
|
+
labels = [c for d in datasets for c in d.batches]
|
|
35
|
+
if len(labels) != len(set(labels)):
|
|
36
|
+
raise ValueError(f"batch labels must be unique across inputs; got {labels}.")
|
|
37
|
+
|
|
38
|
+
all_mods = sorted({m for d in datasets for m in d.modality_names})
|
|
39
|
+
n_total = sum(d.n_cells for d in datasets)
|
|
40
|
+
|
|
41
|
+
out_mods: dict[str, sp.csr_matrix] = {}
|
|
42
|
+
out_var: dict[str, pd.Index] = {}
|
|
43
|
+
out_present: dict[str, np.ndarray] = {}
|
|
44
|
+
|
|
45
|
+
for mod in all_mods:
|
|
46
|
+
carriers = [d for d in datasets if mod in d.modalities]
|
|
47
|
+
common = _intersect_ordered([d.var[mod] for d in carriers])
|
|
48
|
+
if len(common) == 0:
|
|
49
|
+
raise ValueError(f"modality '{mod}': empty feature intersection across batches.")
|
|
50
|
+
if any(len(common) < len(d.var[mod]) for d in carriers):
|
|
51
|
+
warnings.warn(
|
|
52
|
+
f"modality '{mod}': intersection kept {len(common)} features "
|
|
53
|
+
f"(batches had {[len(d.var[mod]) for d in carriers]}).",
|
|
54
|
+
stacklevel=2,
|
|
55
|
+
)
|
|
56
|
+
blocks = []
|
|
57
|
+
present = []
|
|
58
|
+
for d in datasets:
|
|
59
|
+
if mod in d.modalities:
|
|
60
|
+
col_pos = [d.var[mod].get_loc(g) for g in common]
|
|
61
|
+
blocks.append(d.modalities[mod][:, col_pos])
|
|
62
|
+
present.append(np.ones(d.n_cells, dtype=bool))
|
|
63
|
+
else:
|
|
64
|
+
blocks.append(sp.csr_matrix((d.n_cells, len(common)), dtype=np.float32))
|
|
65
|
+
present.append(np.zeros(d.n_cells, dtype=bool))
|
|
66
|
+
out_mods[mod] = sp.vstack(blocks).tocsr()
|
|
67
|
+
out_var[mod] = common
|
|
68
|
+
out_present[mod] = np.concatenate(present)
|
|
69
|
+
|
|
70
|
+
obs = pd.concat([d.obs for d in datasets], ignore_index=True)
|
|
71
|
+
assert obs.shape[0] == n_total
|
|
72
|
+
return Dataset(modalities=out_mods, obs=obs, var=out_var, present=out_present)
|
m3/_dataset.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Canonical m3 input container."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
import pandas as pd
|
|
9
|
+
import scipy.sparse as sp
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class Dataset:
|
|
14
|
+
"""Multimodal, multi-batch raw-count container.
|
|
15
|
+
|
|
16
|
+
modalities: {name -> csr_matrix [n_cells, n_features]} raw counts
|
|
17
|
+
obs: DataFrame [n_cells, ...]; must contain a 'batch' column
|
|
18
|
+
var: {name -> pd.Index of feature names}, aligned to each modality's columns
|
|
19
|
+
present: {name -> bool ndarray [n_cells]} per-cell modality presence
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
modalities: dict[str, sp.csr_matrix]
|
|
23
|
+
obs: pd.DataFrame
|
|
24
|
+
var: dict[str, pd.Index]
|
|
25
|
+
present: dict[str, np.ndarray]
|
|
26
|
+
|
|
27
|
+
def __post_init__(self) -> None:
|
|
28
|
+
if not self.modalities:
|
|
29
|
+
raise ValueError("Dataset requires at least one modality.")
|
|
30
|
+
n = self.obs.shape[0]
|
|
31
|
+
if "batch" not in self.obs.columns:
|
|
32
|
+
raise ValueError("obs must contain a 'batch' column.")
|
|
33
|
+
for name, mat in self.modalities.items():
|
|
34
|
+
if mat.shape[0] != n:
|
|
35
|
+
raise ValueError(
|
|
36
|
+
f"modality '{name}' has {mat.shape[0]} rows but obs has {n} (n_cells)."
|
|
37
|
+
)
|
|
38
|
+
if name not in self.var or len(self.var[name]) != mat.shape[1]:
|
|
39
|
+
raise ValueError(
|
|
40
|
+
f"var['{name}'] length must equal modality '{name}' column count."
|
|
41
|
+
)
|
|
42
|
+
if pd.Index(self.var[name]).has_duplicates:
|
|
43
|
+
raise ValueError(
|
|
44
|
+
f"var['{name}'] has duplicate feature names; names must be unique."
|
|
45
|
+
)
|
|
46
|
+
if name not in self.present or self.present[name].shape[0] != n:
|
|
47
|
+
raise ValueError(f"present['{name}'] must be a length-{n} boolean array.")
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def n_cells(self) -> int:
|
|
51
|
+
return self.obs.shape[0]
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def modality_names(self) -> list[str]:
|
|
55
|
+
return list(self.modalities.keys())
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def batches(self) -> list[str]:
|
|
59
|
+
return list(pd.unique(self.obs["batch"]))
|
|
60
|
+
|
|
61
|
+
def __repr__(self) -> str:
|
|
62
|
+
mods = ", ".join(f"{k}:{v.shape[1]}" for k, v in self.modalities.items())
|
|
63
|
+
return f"Dataset(n_cells={self.n_cells}, batches={self.batches}, modalities=[{mods}])"
|
m3/_engine/__init__.py
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"""
|
|
2
|
+
End-to-end multi-resolution feature attribution for M3.
|
|
3
|
+
|
|
4
|
+
Connects the cell-level generator and patient-level corrector into one
|
|
5
|
+
differentiable forward pass, then runs Integrated Gradients from patient
|
|
6
|
+
disease prediction all the way back to raw gene expression.
|
|
7
|
+
|
|
8
|
+
Two levels of attribution:
|
|
9
|
+
1. Input-level IG: gene -> disease prediction (for gene and cell importance)
|
|
10
|
+
2. Patient vector-level IG: patient vector -> disease prediction (for cell type and donor importance)
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import torch
|
|
14
|
+
import torch.nn as nn
|
|
15
|
+
import torch.nn.functional as F
|
|
16
|
+
import numpy as np
|
|
17
|
+
import pandas as pd
|
|
18
|
+
from captum.attr import IntegratedGradients
|
|
19
|
+
from .util import poe
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class EndToEndWrapper(nn.Module):
|
|
23
|
+
|
|
24
|
+
def __init__(self, generator, corrector,
|
|
25
|
+
batch_labels, mask_poe,
|
|
26
|
+
donor_codes, celltype_codes,
|
|
27
|
+
n_celltypes, cond_index=0):
|
|
28
|
+
super().__init__()
|
|
29
|
+
self.generator = generator
|
|
30
|
+
self.corrector = corrector
|
|
31
|
+
|
|
32
|
+
self.register_buffer('batch_labels', batch_labels.long())
|
|
33
|
+
self.register_buffer('mask_poe', mask_poe.float())
|
|
34
|
+
self.register_buffer('donor_codes', donor_codes.long())
|
|
35
|
+
self.register_buffer('celltype_codes', celltype_codes.long())
|
|
36
|
+
|
|
37
|
+
self.n_celltypes = n_celltypes
|
|
38
|
+
self.cond_index = cond_index
|
|
39
|
+
self.n_conds = len(generator.condition_dim)
|
|
40
|
+
self.batch_classify_dim = generator.batch_classify_dim
|
|
41
|
+
self.n_cells = batch_labels.shape[0]
|
|
42
|
+
|
|
43
|
+
def _get_z_slices(self, mu):
|
|
44
|
+
z_dim = mu.shape[1]
|
|
45
|
+
n_conds = self.n_conds
|
|
46
|
+
emb_dim = z_dim - 2 - 2 * n_conds
|
|
47
|
+
|
|
48
|
+
z_embedding = mu[:, :emb_dim]
|
|
49
|
+
z_conds = []
|
|
50
|
+
start = emb_dim
|
|
51
|
+
for _ in range(n_conds):
|
|
52
|
+
z_conds.append(mu[:, start:start + 2])
|
|
53
|
+
start += 2
|
|
54
|
+
|
|
55
|
+
return z_embedding, z_conds
|
|
56
|
+
|
|
57
|
+
def _differentiable_aggregate(self, z, donor_codes, celltype_codes,
|
|
58
|
+
n_celltypes, min_cells=5):
|
|
59
|
+
feat_dim = z.shape[1]
|
|
60
|
+
unique_donors = donor_codes.unique()
|
|
61
|
+
n_donors = unique_donors.shape[0]
|
|
62
|
+
|
|
63
|
+
patient_vectors = torch.zeros(n_donors, n_celltypes * feat_dim,
|
|
64
|
+
device=z.device, dtype=z.dtype)
|
|
65
|
+
|
|
66
|
+
for d_idx, d in enumerate(unique_donors):
|
|
67
|
+
for c in range(n_celltypes):
|
|
68
|
+
mask = (donor_codes == d) & (celltype_codes == c)
|
|
69
|
+
if mask.sum() >= min_cells:
|
|
70
|
+
patient_vectors[d_idx, c * feat_dim:(c + 1) * feat_dim] = z[mask].mean(dim=0)
|
|
71
|
+
|
|
72
|
+
return patient_vectors, unique_donors
|
|
73
|
+
|
|
74
|
+
def _core_forward(self, x):
|
|
75
|
+
mu_list, logvar_list = self.generator.encoder(x)
|
|
76
|
+
b_onehot = F.one_hot(self.batch_labels,
|
|
77
|
+
num_classes=self.batch_classify_dim).float()
|
|
78
|
+
batch_mu, batch_logvar = self.generator.batch_encoder(b_onehot)
|
|
79
|
+
|
|
80
|
+
mu, logvar = poe(mu_list + [batch_mu],
|
|
81
|
+
logvar_list + [batch_logvar],
|
|
82
|
+
self.mask_poe)
|
|
83
|
+
|
|
84
|
+
z_embedding, z_conds = self._get_z_slices(mu)
|
|
85
|
+
z_for_agg = torch.cat([z_embedding, z_conds[self.cond_index]], dim=1)
|
|
86
|
+
|
|
87
|
+
patient_vectors, unique_donors = self._differentiable_aggregate(
|
|
88
|
+
z_for_agg, self.donor_codes, self.celltype_codes,
|
|
89
|
+
self.n_celltypes
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
z_corrected = self.corrector.correct(patient_vectors)
|
|
93
|
+
logits = self.corrector.status_head(z_corrected)
|
|
94
|
+
return logits, patient_vectors, unique_donors
|
|
95
|
+
|
|
96
|
+
def forward(self, x):
|
|
97
|
+
logits, _, _ = self._core_forward(x)
|
|
98
|
+
return logits
|
|
99
|
+
|
|
100
|
+
def forward_flat(self, x_flat):
|
|
101
|
+
x = x_flat.view(self.n_cells, -1)
|
|
102
|
+
logits = self.forward(x)
|
|
103
|
+
return logits.sum(dim=0, keepdim=True)
|
|
104
|
+
|
|
105
|
+
def forward_full(self, x):
|
|
106
|
+
logits, patient_vectors, unique_donors = self._core_forward(x)
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
'logits': logits,
|
|
110
|
+
'patient_vectors': patient_vectors,
|
|
111
|
+
'unique_donors': unique_donors,
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def run_captum_ig(wrapper, x, target_class=1, n_steps=50, baseline=None):
|
|
116
|
+
n_cells, n_genes = x.shape
|
|
117
|
+
x_flat = x.detach().view(1, -1)
|
|
118
|
+
if baseline is None:
|
|
119
|
+
baseline_flat = torch.zeros_like(x_flat)
|
|
120
|
+
else:
|
|
121
|
+
baseline_flat = baseline.detach().reshape(1, -1)
|
|
122
|
+
|
|
123
|
+
ig = IntegratedGradients(wrapper.forward_flat)
|
|
124
|
+
|
|
125
|
+
attr_flat = ig.attribute(
|
|
126
|
+
x_flat,
|
|
127
|
+
baselines=baseline_flat,
|
|
128
|
+
target=target_class,
|
|
129
|
+
n_steps=n_steps,
|
|
130
|
+
internal_batch_size=1,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
attribution = attr_flat.view(n_cells, n_genes)
|
|
134
|
+
return attribution
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def run_attribution(generator, corrector,
|
|
138
|
+
ref_data, ref_b, ref_mask_poe,
|
|
139
|
+
ref_metadata, donor_name, cty_name,
|
|
140
|
+
n_celltypes, target_class=1,
|
|
141
|
+
cond_index=0, n_steps=50, device='cpu',
|
|
142
|
+
baseline=None, condition_col=None, reference_labels=None,
|
|
143
|
+
skip_patient_level=False):
|
|
144
|
+
"""
|
|
145
|
+
Run end-to-end attribution on reference data using Captum IG.
|
|
146
|
+
|
|
147
|
+
Two levels of IG:
|
|
148
|
+
1. Input-level: gene expression -> disease prediction (for gene & cell importance)
|
|
149
|
+
2. Patient vector-level: patient vector -> disease prediction (for cell type & donor importance)
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
condition_col: str, column name for condition in ref_metadata (e.g. 'cond_group').
|
|
153
|
+
reference_labels: list of str, labels considered as reference/healthy
|
|
154
|
+
(e.g. ['HC'] for COVID, ['Healthy'] for Stephenson).
|
|
155
|
+
If condition_col and reference_labels are not provided, uses zero baseline
|
|
156
|
+
for patient vector level IG.
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
generator = generator.to(device).eval()
|
|
160
|
+
corrector = corrector.to(device).eval()
|
|
161
|
+
|
|
162
|
+
ref_data = ref_data.to(device).float()
|
|
163
|
+
ref_b = ref_b.to(device)
|
|
164
|
+
ref_mask_poe = ref_mask_poe.to(device)
|
|
165
|
+
|
|
166
|
+
cty_categories = pd.Categorical(ref_metadata[cty_name])
|
|
167
|
+
celltype_codes = torch.tensor(cty_categories.codes, dtype=torch.long, device=device)
|
|
168
|
+
celltype_names = list(cty_categories.categories)
|
|
169
|
+
|
|
170
|
+
donor_categories = pd.Categorical(ref_metadata[donor_name])
|
|
171
|
+
donor_codes = torch.tensor(donor_categories.codes, dtype=torch.long, device=device)
|
|
172
|
+
donor_names = list(donor_categories.categories)
|
|
173
|
+
|
|
174
|
+
# build wrapper
|
|
175
|
+
wrapper = EndToEndWrapper(
|
|
176
|
+
generator, corrector,
|
|
177
|
+
batch_labels=ref_b,
|
|
178
|
+
mask_poe=ref_mask_poe,
|
|
179
|
+
donor_codes=donor_codes,
|
|
180
|
+
celltype_codes=celltype_codes,
|
|
181
|
+
n_celltypes=n_celltypes,
|
|
182
|
+
cond_index=cond_index
|
|
183
|
+
).to(device).eval()
|
|
184
|
+
|
|
185
|
+
for p in wrapper.parameters():
|
|
186
|
+
p.requires_grad_(False)
|
|
187
|
+
|
|
188
|
+
# --- Patient-level info (no grad needed); skipped entirely when skip_patient_level ---
|
|
189
|
+
if not skip_patient_level:
|
|
190
|
+
print("Computing patient-level predictions...")
|
|
191
|
+
with torch.no_grad():
|
|
192
|
+
full_out = wrapper.forward_full(ref_data)
|
|
193
|
+
logits = full_out['logits']
|
|
194
|
+
patient_vectors = full_out['patient_vectors']
|
|
195
|
+
unique_donors = full_out['unique_donors']
|
|
196
|
+
disease_prob = F.softmax(logits, dim=1)[:, target_class]
|
|
197
|
+
|
|
198
|
+
# =============================================
|
|
199
|
+
# Level 1: Input-level IG (gene & cell importance)
|
|
200
|
+
# =============================================
|
|
201
|
+
print(f"Running Input-level Captum IG with {n_steps} steps...")
|
|
202
|
+
if baseline is not None:
|
|
203
|
+
baseline = baseline.to(device).float()
|
|
204
|
+
attribution = run_captum_ig(
|
|
205
|
+
wrapper, ref_data, target_class=target_class, n_steps=n_steps,
|
|
206
|
+
baseline=baseline
|
|
207
|
+
)
|
|
208
|
+
print("Done.")
|
|
209
|
+
|
|
210
|
+
# Gene importance and cell importance from input-level attribution
|
|
211
|
+
attr_abs = attribution.abs()
|
|
212
|
+
n_genes = attribution.shape[1]
|
|
213
|
+
K = len(celltype_names)
|
|
214
|
+
|
|
215
|
+
gene_celltype_matrix = torch.zeros(K, n_genes)
|
|
216
|
+
for c in range(K):
|
|
217
|
+
mask = (celltype_codes == c)
|
|
218
|
+
if mask.sum() > 0:
|
|
219
|
+
gene_celltype_matrix[c] = attribution[mask].mean(dim=0)
|
|
220
|
+
|
|
221
|
+
gene_importance = attr_abs.mean(dim=0) # absolute: gene involvement strength
|
|
222
|
+
cell_importance = attribution.sum(dim=1) # SIGNED: net per-cell push (+ -> target_class/Severe, - -> HC)
|
|
223
|
+
|
|
224
|
+
# When patient-level attribution is skipped, derive cell-type importance from the
|
|
225
|
+
# cell-level attribution (mean |cell push| per cell type) and return early -- no
|
|
226
|
+
# patient-vector IG, no per-donor outputs are computed at all.
|
|
227
|
+
if skip_patient_level:
|
|
228
|
+
celltype_importance = torch.zeros(K, device=cell_importance.device)
|
|
229
|
+
for c in range(K):
|
|
230
|
+
mask = (celltype_codes == c)
|
|
231
|
+
if mask.any():
|
|
232
|
+
celltype_importance[c] = cell_importance[mask].abs().mean()
|
|
233
|
+
return {
|
|
234
|
+
'attribution': attribution.detach().cpu(),
|
|
235
|
+
'gene_celltype_matrix': gene_celltype_matrix.detach().cpu(),
|
|
236
|
+
'gene_importance': gene_importance.detach().cpu(),
|
|
237
|
+
'cell_importance': cell_importance.detach().cpu(),
|
|
238
|
+
'celltype_importance': celltype_importance.detach().cpu(),
|
|
239
|
+
'celltype_names': celltype_names,
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
# =============================================
|
|
243
|
+
# Level 2: Patient vector-level IG (cell type & donor importance)
|
|
244
|
+
# =============================================
|
|
245
|
+
print(f"Running Patient vector-level Captum IG with {n_steps} steps...")
|
|
246
|
+
|
|
247
|
+
def corrector_forward(pv):
|
|
248
|
+
z = corrector.correct(pv)
|
|
249
|
+
logits = corrector.status_head(z)
|
|
250
|
+
return logits
|
|
251
|
+
|
|
252
|
+
# Healthy baseline for patient vector
|
|
253
|
+
if condition_col is not None and reference_labels is not None:
|
|
254
|
+
donor_cond = ref_metadata.groupby(donor_name)[condition_col].first()
|
|
255
|
+
healthy_donor_idx = [i for i, d in enumerate(donor_names)
|
|
256
|
+
if donor_cond.get(d, '') in reference_labels]
|
|
257
|
+
if len(healthy_donor_idx) > 0:
|
|
258
|
+
pv_baseline = patient_vectors[healthy_donor_idx].mean(dim=0, keepdim=True) \
|
|
259
|
+
.expand_as(patient_vectors).contiguous()
|
|
260
|
+
else:
|
|
261
|
+
pv_baseline = torch.zeros_like(patient_vectors)
|
|
262
|
+
else:
|
|
263
|
+
pv_baseline = torch.zeros_like(patient_vectors)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
ig_patient = IntegratedGradients(corrector_forward)
|
|
267
|
+
patient_level_attr = ig_patient.attribute(
|
|
268
|
+
patient_vectors.detach().to(device),
|
|
269
|
+
baselines=pv_baseline.detach().to(device),
|
|
270
|
+
target=target_class,
|
|
271
|
+
n_steps=n_steps
|
|
272
|
+
)
|
|
273
|
+
print("Done.")
|
|
274
|
+
|
|
275
|
+
# Cell type importance from patient vector attribution
|
|
276
|
+
feat_dim = patient_vectors.shape[1] // n_celltypes
|
|
277
|
+
patient_attr_reshaped = patient_level_attr.view(-1, n_celltypes, feat_dim)
|
|
278
|
+
celltype_importance = patient_attr_reshaped.abs().mean(dim=0).sum(dim=1) # [K]
|
|
279
|
+
|
|
280
|
+
# Donor importance from patient vector attribution
|
|
281
|
+
donor_attribution = patient_level_attr.abs().sum(dim=1) # [n_donors]
|
|
282
|
+
|
|
283
|
+
return {
|
|
284
|
+
# Input-level attribution (for gene & cell analysis)
|
|
285
|
+
'attribution': attribution.detach().cpu(),
|
|
286
|
+
'gene_celltype_matrix': gene_celltype_matrix.detach().cpu(),
|
|
287
|
+
'gene_importance': gene_importance.detach().cpu(),
|
|
288
|
+
'cell_importance': cell_importance.detach().cpu(),
|
|
289
|
+
|
|
290
|
+
# Patient vector-level attribution (for cell type & donor analysis)
|
|
291
|
+
'patient_level_attr': patient_level_attr.detach().cpu(),
|
|
292
|
+
'celltype_importance': celltype_importance.detach().cpu(),
|
|
293
|
+
'donor_attribution': donor_attribution.detach().cpu(),
|
|
294
|
+
|
|
295
|
+
# Patient info
|
|
296
|
+
'donor_disease_prob': disease_prob.detach().cpu(),
|
|
297
|
+
'donor_names': donor_names,
|
|
298
|
+
'patient_vectors': patient_vectors.detach().cpu(),
|
|
299
|
+
'celltype_names': celltype_names,
|
|
300
|
+
}
|