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 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,4 @@
1
+ """Not part of the public API. The user-facing :class:`m3.M3` orchestrates these
2
+ modules. Modules keep their original relative imports (``from .util import ...``)
3
+ and run unchanged; only their location moved into the ``m3._engine`` package.
4
+ """
@@ -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
+ }