mirpy-lib 3.6.0__tar.gz → 3.7.0__tar.gz
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.
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/CHANGELOG.md +32 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/CLAUDE.md +33 -4
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/PKG-INFO +46 -2
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/README.md +45 -1
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/docs/api.rst +46 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/docs/examples.rst +7 -1
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/__init__.py +1 -1
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/density.py +63 -0
- mirpy_lib-3.7.0/src/mir/generate.py +233 -0
- mirpy_lib-3.7.0/src/mir/ml/diffusion.py +378 -0
- mirpy_lib-3.7.0/src/mir/track.py +331 -0
- mirpy_lib-3.7.0/src/mir/twin.py +179 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/.gitignore +0 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/LICENSE +0 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/SOURCES.md +0 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/assets/mirpy_dark.png +0 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/assets/mirpy_dark.svg +0 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/assets/mirpy_dark_bg.svg +0 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/assets/mirpy_light.png +0 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/assets/mirpy_light.svg +0 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/assets/mirpy_light_bg.svg +0 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/docs/Makefile +0 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/docs/_static/custom.css +0 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/docs/_static/depth_robustness.png +0 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/docs/_static/mirpy_dark.png +0 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/docs/_static/mirpy_dark.svg +0 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/docs/_static/mirpy_light.png +0 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/docs/_static/mirpy_light.svg +0 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/docs/_static/sample_embedding_diagram.png +0 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/docs/conf.py +0 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/docs/index.rst +0 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/docs/usage.rst +0 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/pyproject.toml +0 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/requirements.txt +0 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/setup.sh +0 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/aliases.py +0 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/alleles.py +0 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/bench/__init__.py +0 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/bench/eval.py +0 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/bench/metrics.py +0 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/bench/theory.py +0 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/bench/vdjdb.py +0 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/cli.py +0 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/cohort.py +0 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/distances/__init__.py +0 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/distances/germline.py +0 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/distances/junction.py +0 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/embedding/__init__.py +0 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/embedding/pca.py +0 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/embedding/presets.py +0 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/embedding/prototypes.py +0 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/embedding/tcremp.py +0 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/explain.py +0 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/ml/__init__.py +0 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/ml/bundle.py +0 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/ml/codec.py +0 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/ml/decoder.py +0 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/ml/encoder.py +0 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/ml/set_encoder.py +0 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/ml/tokenize.py +0 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/ml/train.py +0 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/py.typed +0 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/repertoire.py +0 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/resources/__init__.py +0 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/resources/gene_library/README.md +0 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/resources/gene_library/__init__.py +0 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/resources/gene_library/build_gene_library.py +0 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/resources/gene_library/build_region_annotations.py +0 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/resources/gene_library/imgt_gene_library.txt +0 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/resources/gene_library/olga_gene_library.txt +0 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/resources/gene_library/region_annotations.txt +0 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/resources/germline_dist/build_germline_dist.py +0 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/resources/germline_dist/human_IGH.npz +0 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/resources/germline_dist/human_IGK.npz +0 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/resources/germline_dist/human_IGL.npz +0 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/resources/germline_dist/human_TRA.npz +0 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/resources/germline_dist/human_TRB.npz +0 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/resources/germline_dist/human_TRD.npz +0 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/resources/germline_dist/human_TRG.npz +0 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/resources/germline_dist/mouse_IGH.npz +0 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/resources/germline_dist/mouse_IGK.npz +0 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/resources/germline_dist/mouse_IGL.npz +0 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/resources/germline_dist/mouse_TRA.npz +0 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/resources/germline_dist/mouse_TRB.npz +0 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/resources/germline_dist/mouse_TRD.npz +0 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/resources/germline_dist/mouse_TRG.npz +0 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/resources/prototypes/.gitkeep +0 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/resources/prototypes/generate_prototypes.py +0 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/resources/prototypes/human_IGH.tsv +0 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/resources/prototypes/human_IGK.tsv +0 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/resources/prototypes/human_IGL.tsv +0 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/resources/prototypes/human_TRA.tsv +0 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/resources/prototypes/human_TRB.tsv +0 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/resources/prototypes/human_TRD.tsv +0 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/resources/prototypes/human_TRG.tsv +0 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/resources/prototypes/manifest.json +0 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/resources/prototypes/mouse_TRA.tsv +0 -0
- {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/resources/prototypes/mouse_TRB.tsv +0 -0
|
@@ -3,6 +3,38 @@
|
|
|
3
3
|
All notable changes to `mirpy-lib` (import `mir`). This project follows semantic versioning; the v3 line is a
|
|
4
4
|
greenfield ML/embedding rewrite (the classical v1.x/v2 toolkit is frozen on branch `legacy-v2`).
|
|
5
5
|
|
|
6
|
+
## 3.7.0 — 2026-07-30
|
|
7
|
+
|
|
8
|
+
Four new modules: a PhenoPath-style exposure trajectory, the generative loop's mechanical and
|
|
9
|
+
research halves, and the digital twin that glues them to the digital donor — plus a repertoire-level
|
|
10
|
+
exposure channel. No public API removed; a minor bump.
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **`mir.track.fit_exposure_trajectory`** — a covariate-disentangled latent trajectory over any
|
|
15
|
+
per-sample channel matrix (PhenoPath, Campbell & Yau 2018, *Nat. Commun.* 9:2442,
|
|
16
|
+
doi:10.1038/s41467-018-04696-6, adapted from genes×cells to repertoire-channels×samples): infers a
|
|
17
|
+
shared exposure/progression pseudotime `tau` while separating out which channels respond to it
|
|
18
|
+
differently by a known covariate (`TrajectoryFit.top_interactions`). A simplified closed-form
|
|
19
|
+
alternating fit (per-channel ridge + GLS trajectory update + iteratively-reweighted ARD shrinkage
|
|
20
|
+
on the interaction term), not a literal reimplementation of PhenoPath's CAVI engine. Torch-free.
|
|
21
|
+
- **`mir.generate`** — the generative loop's mechanical half: `DescriptorDensity` (optionally
|
|
22
|
+
class-conditional Gaussian, Ledoit-Wolf shrinkage) over `RepertoireDescriptor` vectors;
|
|
23
|
+
`sample`/`evolve` (perturb one coordinate, propagate the coupled shift via the fitted covariance's
|
|
24
|
+
conditional mean) promote the ad-hoc `benchmark_repertoire_tcga_insilico.py` `np.cov`-slope pattern
|
|
25
|
+
into a reusable library object. Torch-free.
|
|
26
|
+
- **`mir.ml.diffusion`** (needs `[ml]`) — the generative loop's research half: a compact conditional
|
|
27
|
+
DDPM/DDIM generator (classifier-free guidance) over a compact descriptor/code space, sharing
|
|
28
|
+
`DescriptorDensity`'s `sample(n, condition=, seed=)` call shape so either generator drops in
|
|
29
|
+
unchanged. `DiffusionModel.save`/`load` mirrors `CodecBundle`'s shape.
|
|
30
|
+
- **`mir.twin.DonorTwin`/`make_twins`** — the digital twin: glue one donor's `RepertoireDescriptor` +
|
|
31
|
+
an optional `mir.track` trajectory position + covariate into one object; `.perturb()` and
|
|
32
|
+
`.simulate()` accept either generator.
|
|
33
|
+
- **`mir.density.exposure_score`/`exposure_channel`** — aggregate a per-clonotype
|
|
34
|
+
`neighbor_enrichment` result into repertoire-level exposure scalars (breadth, abundance-weighted
|
|
35
|
+
mass fraction, mean log2 fold), ready for `ChannelBuilder`/`DonorCohort extra_channels` — exposure
|
|
36
|
+
detection promoted from clone-level to a first-class cohort channel.
|
|
37
|
+
|
|
6
38
|
## 3.6.0 — 2026-07-30
|
|
7
39
|
|
|
8
40
|
Default-on functional filtering, a new default clone-size weight for repertoire embedding, and a
|
|
@@ -114,8 +114,28 @@ fix were added to `vdjtools` under the owner's direction (this is that owner's e
|
|
|
114
114
|
high `delta` + `delta_out≈0` is the **redundancy** signature. `channel_drivers` hops channel→clonotypes
|
|
115
115
|
via `class_witness`, but **only** for a channel declared `attributable` (a kernel mean); a Hill number
|
|
116
116
|
has no clonotype pre-image and it raises. Depends one-way on `repertoire.py`; nothing there changed.
|
|
117
|
+
- `track.py` — **exposure trajectory** (repertoire-level exposure detection, complementing
|
|
118
|
+
`density.py`'s clone-level TCRNET/ALICE): `fit_exposure_trajectory(X, covariates, ...)`, a
|
|
119
|
+
PhenoPath-style (Campbell & Yau 2018) covariate-disentangled latent factor model —
|
|
120
|
+
`Y[n,g] = c_g + alpha_g.x_n + (kappa_g + gamma_g.x_n)*tau_n + eps` — fit by alternating closed-form
|
|
121
|
+
per-channel ridge regression and a GLS trajectory update (a simplified, non-CAVI approximation to
|
|
122
|
+
PhenoPath's inference; ARD-style iteratively-reweighted shrinkage on `gamma`, the covariate x
|
|
123
|
+
trajectory interaction). `TrajectoryFit.top_interactions()` ranks channels. Fills the
|
|
124
|
+
analysis-repo ROADMAP's Phase 5 (embedding trajectory). Torch-free.
|
|
125
|
+
- `generate.py` — the **generative loop, mechanical half** (ROADMAP Phase 2): `DescriptorDensity`
|
|
126
|
+
(optionally class-conditional Gaussian, Ledoit-Wolf shrinkage) over `RepertoireDescriptor` vectors;
|
|
127
|
+
`sample` draws new synthetic donor states, `evolve` perturbs one coordinate and propagates the
|
|
128
|
+
coupled shift via the fitted covariance's conditional mean — promotes the ad-hoc
|
|
129
|
+
`benchmark_repertoire_tcga_insilico.py` `np.cov`-slope pattern into a reusable library object.
|
|
130
|
+
Torch-free.
|
|
131
|
+
- `twin.py` — the **digital twin**: `DonorTwin`/`make_twins` glue one donor's `RepertoireDescriptor`
|
|
132
|
+
+ an optional `track.py` trajectory position + covariate into one object; `.perturb()` (via
|
|
133
|
+
`generate.evolve`) and `.simulate()` (via `generate.DescriptorDensity` or `ml.diffusion.DiffusionModel`
|
|
134
|
+
— both share the `sample(n, condition=, seed=)` shape, so either drops in). Torch-free itself.
|
|
117
135
|
- `ml/` — Part 2 (torch), neural codecs + `set_encoder.py` (learned repertoire track: Set-Transformer/DeepRC
|
|
118
|
-
attention pooling + `SetEncoderBundle`).
|
|
136
|
+
attention pooling + `SetEncoderBundle`) + `diffusion.py` (**generative loop, research half**: compact
|
|
137
|
+
conditional DDPM/DDIM with classifier-free guidance over a compact descriptor/code space, standardized
|
|
138
|
+
internally with `x0`-clipping for DDIM stability; `DiffusionModel.save`/`load` mirrors `CodecBundle`).
|
|
119
139
|
- `cli.py` — the `mir` console script (argparse, stdlib): `embed clonotypes` (TCREmp table) /
|
|
120
140
|
`embed repertoires` (per-locus `fit_repertoire_space`→`sample_embedding` Φ(S), optional `--mmd`).
|
|
121
141
|
Reads via `vdjtools.io.read`; writes TSV/parquet.
|
|
@@ -155,9 +175,18 @@ fix were added to `vdjtools` under the owner's direction (this is that owner's e
|
|
|
155
175
|
cleanup) — the "vdjtools at the embedding level" audit +
|
|
156
176
|
plan (three verbs: make / measure / generate-decode). **Phase 0** (robustness + optimization quick wins)
|
|
157
177
|
and **Phase 1** (cohort tier: `bench/eval.py`, `repertoire.{fit_repertoire_spaces,centroid_atypicality}`,
|
|
158
|
-
`cohort.py` digital donor) are **DONE**.
|
|
159
|
-
|
|
160
|
-
|
|
178
|
+
`cohort.py` digital donor) are **DONE**. **Phase 2 (generative loop, mechanical half) — DONE**:
|
|
179
|
+
`generate.py` `DescriptorDensity` (`sample`/`evolve`) ships, promoting the ad-hoc
|
|
180
|
+
`benchmark_repertoire_tcga_insilico.py` `np.cov`-slope pattern; `CodecBundle.from_unified/from_decoder`
|
|
181
|
+
is still open. **Phase 5 (embedding trajectory) — partially addressed**: `track.py`
|
|
182
|
+
`fit_exposure_trajectory` (a PhenoPath-style covariate-disentangled latent trajectory, Campbell &
|
|
183
|
+
Yau 2018) fills this rather than the originally-sketched `track.repertoire_trajectory`
|
|
184
|
+
Φ-velocity idea — a different, arguably more principled approach to the same phase; `clonotype_flux`
|
|
185
|
+
(differential enrichment) is still open. Also new: `mir.ml.diffusion` (generative loop, **research
|
|
186
|
+
half** — conditional DDPM/DDIM, complementing the linear `DescriptorDensity`) and `twin.py`
|
|
187
|
+
(`DonorTwin`/`make_twins` — glues descriptor + trajectory + generator into one perturb/simulate
|
|
188
|
+
object). Next: Phase 3 (embedding inversion / generation), Phase 4 (multimodal encoders).
|
|
189
|
+
**Analysis-repo follow-up:** refactor `_tcga_embedding.build_embedding` onto
|
|
161
190
|
`cohort.fit_donor_embeddings` (+ `extra_channels` for isotype/composition/atypicality) and re-verify the
|
|
162
191
|
pan-cancer ΔC numbers. NB Phase 0 flipped the density default to `backend="kdtree"` — re-verify any
|
|
163
192
|
recorded balloon-mode baselines (±1 boundary counts).
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mirpy-lib
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.7.0
|
|
4
4
|
Summary: ML-oriented embeddings for immune receptor repertoires (TCR/BCR)
|
|
5
5
|
Project-URL: Homepage, https://github.com/antigenomics/mirpy
|
|
6
6
|
Project-URL: Repository, https://github.com/antigenomics/mirpy
|
|
@@ -228,7 +228,10 @@ Provenance and the regenerate command are in [`SOURCES.md`](SOURCES.md).
|
|
|
228
228
|
| `mir.repertoire` | sample-level (repertoire) embedding — RFF kernel mean ‖ Hill diversity ‖ second moment; MMD / HLA-stratified distance; motif witness; `centroid_atypicality`, multi-locus `fit_repertoire_spaces` (Theory §T.7) |
|
|
229
229
|
| `mir.explain` | named-channel fusion (`ChannelBuilder`) + scorer-agnostic ablation (`channel_report`/`channel_drivers`) — which part of Φ carries the signal (§T.7) |
|
|
230
230
|
| `mir.cohort` | the **digital donor** — multi-chain `fit_donor_embeddings`/`DonorCohort` (+ `transform`/`save`/`load`) + `residualize` / `cluster_samples` / `incidence_biomarkers` (§T.7) |
|
|
231
|
-
| `mir.
|
|
231
|
+
| `mir.track` | **exposure trajectory** — PhenoPath-style covariate-disentangled latent progression axis (`fit_exposure_trajectory`) over any channel matrix; repertoire-level exposure detection |
|
|
232
|
+
| `mir.generate` | the **generative loop** (mechanical half) — `DescriptorDensity`: sample new synthetic donor states / `evolve` one along a coordinate, over `RepertoireDescriptor` |
|
|
233
|
+
| `mir.twin` | the **digital twin** — `DonorTwin`/`make_twins`: perturb or resample one donor's state through a `mir.generate`/`mir.ml.diffusion` generator |
|
|
234
|
+
| `mir.ml` | neural codecs (forward/inverse/Pgen/unified) + learned repertoire `set_encoder` (Set-Transformer/DeepRC) + conditional **diffusion generator** (`mir.ml.diffusion`, the generative loop's research half) — Part 2, experimental; `[ml]` extra |
|
|
232
235
|
|
|
233
236
|
## Background subtraction & clustering (`mir.density`)
|
|
234
237
|
|
|
@@ -306,6 +309,47 @@ TRA and class II) lives in the second moment / witness. A learned co-equal set e
|
|
|
306
309
|
(T7) live in the companion [`2026-mirpy-analysis`](https://github.com/antigenomics) repo
|
|
307
310
|
(`benchmarks/{BENCHMARKS,THEORY}.md`) alongside the benchmark scripts.
|
|
308
311
|
|
|
312
|
+
## Exposure trajectory, generative loop, digital twin
|
|
313
|
+
|
|
314
|
+
A repertoire cohort often has a **known covariate** (HLA, batch, vaccine arm) but an **unknown or
|
|
315
|
+
noisy progression axis** (days since exposure, response severity). `mir.track.fit_exposure_trajectory`
|
|
316
|
+
recovers that latent trajectory `tau` while disentangling it from the covariate — a PhenoPath-style
|
|
317
|
+
model (Campbell & Yau 2018, *Nat. Commun.*
|
|
318
|
+
[10.1038/s41467-018-04696-6](https://doi.org/10.1038/s41467-018-04696-6), adapted from genes×cells to
|
|
319
|
+
repertoire-channels×samples) over *any* per-sample channel matrix (a stacked `Φ`, a `ChannelBuilder`
|
|
320
|
+
build, or a raw embedding block):
|
|
321
|
+
|
|
322
|
+
```python
|
|
323
|
+
from mir.explain import stack_embeddings
|
|
324
|
+
from mir.track import fit_exposure_trajectory
|
|
325
|
+
|
|
326
|
+
X, spec = stack_embeddings(embs) # channels: mean ‖ diversity ‖ second
|
|
327
|
+
fit = fit_exposure_trajectory(X, hla_indicator, channel_names=spec.names)
|
|
328
|
+
fit.tau # inferred exposure/progression pseudotime, one per sample
|
|
329
|
+
fit.top_interactions(5) # which channels respond to progression differently by covariate
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
Complementing that, `mir.generate.DescriptorDensity` fits a (optionally class-conditional) density
|
|
333
|
+
over `RepertoireDescriptor` vectors — `sample` draws brand-new synthetic donor states, `evolve`
|
|
334
|
+
perturbs one donor along a coordinate ("what if hotter") and propagates the coupled shift through
|
|
335
|
+
every other coordinate via the fitted covariance's conditional mean. `mir.ml.diffusion` (`[ml]`
|
|
336
|
+
extra) is the non-linear alternative — a compact conditional DDPM/DDIM generator with
|
|
337
|
+
classifier-free guidance, sharing the same `sample(n, condition=…)` call shape so it drops in
|
|
338
|
+
unchanged. `mir.twin.DonorTwin` glues a donor's descriptor + trajectory position + covariate into
|
|
339
|
+
one object you perturb or resample through, instead of threading the three APIs together by hand:
|
|
340
|
+
|
|
341
|
+
```python
|
|
342
|
+
from mir.generate import fit_descriptor_density
|
|
343
|
+
from mir.twin import make_twins
|
|
344
|
+
|
|
345
|
+
density = fit_descriptor_density(descriptors, labels=tumor_type)
|
|
346
|
+
twins = make_twins(descriptors, conditions=tumor_type, donor_ids=sample_ids)
|
|
347
|
+
hotter = twins[0].perturb(density, coordinate="infiltration", delta=2.0)
|
|
348
|
+
synthetic = twins[0].simulate(density, n=20) # 20 new synthetic peers of donor 0
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
See `examples/trajectory_and_twin.py` for a runnable end-to-end demo.
|
|
352
|
+
|
|
309
353
|
## Reproduce the paper
|
|
310
354
|
|
|
311
355
|
The self-contained theory notebooks run on bundled data:
|
|
@@ -173,7 +173,10 @@ Provenance and the regenerate command are in [`SOURCES.md`](SOURCES.md).
|
|
|
173
173
|
| `mir.repertoire` | sample-level (repertoire) embedding — RFF kernel mean ‖ Hill diversity ‖ second moment; MMD / HLA-stratified distance; motif witness; `centroid_atypicality`, multi-locus `fit_repertoire_spaces` (Theory §T.7) |
|
|
174
174
|
| `mir.explain` | named-channel fusion (`ChannelBuilder`) + scorer-agnostic ablation (`channel_report`/`channel_drivers`) — which part of Φ carries the signal (§T.7) |
|
|
175
175
|
| `mir.cohort` | the **digital donor** — multi-chain `fit_donor_embeddings`/`DonorCohort` (+ `transform`/`save`/`load`) + `residualize` / `cluster_samples` / `incidence_biomarkers` (§T.7) |
|
|
176
|
-
| `mir.
|
|
176
|
+
| `mir.track` | **exposure trajectory** — PhenoPath-style covariate-disentangled latent progression axis (`fit_exposure_trajectory`) over any channel matrix; repertoire-level exposure detection |
|
|
177
|
+
| `mir.generate` | the **generative loop** (mechanical half) — `DescriptorDensity`: sample new synthetic donor states / `evolve` one along a coordinate, over `RepertoireDescriptor` |
|
|
178
|
+
| `mir.twin` | the **digital twin** — `DonorTwin`/`make_twins`: perturb or resample one donor's state through a `mir.generate`/`mir.ml.diffusion` generator |
|
|
179
|
+
| `mir.ml` | neural codecs (forward/inverse/Pgen/unified) + learned repertoire `set_encoder` (Set-Transformer/DeepRC) + conditional **diffusion generator** (`mir.ml.diffusion`, the generative loop's research half) — Part 2, experimental; `[ml]` extra |
|
|
177
180
|
|
|
178
181
|
## Background subtraction & clustering (`mir.density`)
|
|
179
182
|
|
|
@@ -251,6 +254,47 @@ TRA and class II) lives in the second moment / witness. A learned co-equal set e
|
|
|
251
254
|
(T7) live in the companion [`2026-mirpy-analysis`](https://github.com/antigenomics) repo
|
|
252
255
|
(`benchmarks/{BENCHMARKS,THEORY}.md`) alongside the benchmark scripts.
|
|
253
256
|
|
|
257
|
+
## Exposure trajectory, generative loop, digital twin
|
|
258
|
+
|
|
259
|
+
A repertoire cohort often has a **known covariate** (HLA, batch, vaccine arm) but an **unknown or
|
|
260
|
+
noisy progression axis** (days since exposure, response severity). `mir.track.fit_exposure_trajectory`
|
|
261
|
+
recovers that latent trajectory `tau` while disentangling it from the covariate — a PhenoPath-style
|
|
262
|
+
model (Campbell & Yau 2018, *Nat. Commun.*
|
|
263
|
+
[10.1038/s41467-018-04696-6](https://doi.org/10.1038/s41467-018-04696-6), adapted from genes×cells to
|
|
264
|
+
repertoire-channels×samples) over *any* per-sample channel matrix (a stacked `Φ`, a `ChannelBuilder`
|
|
265
|
+
build, or a raw embedding block):
|
|
266
|
+
|
|
267
|
+
```python
|
|
268
|
+
from mir.explain import stack_embeddings
|
|
269
|
+
from mir.track import fit_exposure_trajectory
|
|
270
|
+
|
|
271
|
+
X, spec = stack_embeddings(embs) # channels: mean ‖ diversity ‖ second
|
|
272
|
+
fit = fit_exposure_trajectory(X, hla_indicator, channel_names=spec.names)
|
|
273
|
+
fit.tau # inferred exposure/progression pseudotime, one per sample
|
|
274
|
+
fit.top_interactions(5) # which channels respond to progression differently by covariate
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
Complementing that, `mir.generate.DescriptorDensity` fits a (optionally class-conditional) density
|
|
278
|
+
over `RepertoireDescriptor` vectors — `sample` draws brand-new synthetic donor states, `evolve`
|
|
279
|
+
perturbs one donor along a coordinate ("what if hotter") and propagates the coupled shift through
|
|
280
|
+
every other coordinate via the fitted covariance's conditional mean. `mir.ml.diffusion` (`[ml]`
|
|
281
|
+
extra) is the non-linear alternative — a compact conditional DDPM/DDIM generator with
|
|
282
|
+
classifier-free guidance, sharing the same `sample(n, condition=…)` call shape so it drops in
|
|
283
|
+
unchanged. `mir.twin.DonorTwin` glues a donor's descriptor + trajectory position + covariate into
|
|
284
|
+
one object you perturb or resample through, instead of threading the three APIs together by hand:
|
|
285
|
+
|
|
286
|
+
```python
|
|
287
|
+
from mir.generate import fit_descriptor_density
|
|
288
|
+
from mir.twin import make_twins
|
|
289
|
+
|
|
290
|
+
density = fit_descriptor_density(descriptors, labels=tumor_type)
|
|
291
|
+
twins = make_twins(descriptors, conditions=tumor_type, donor_ids=sample_ids)
|
|
292
|
+
hotter = twins[0].perturb(density, coordinate="infiltration", delta=2.0)
|
|
293
|
+
synthetic = twins[0].simulate(density, n=20) # 20 new synthetic peers of donor 0
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
See `examples/trajectory_and_twin.py` for a runnable end-to-end demo.
|
|
297
|
+
|
|
254
298
|
## Reproduce the paper
|
|
255
299
|
|
|
256
300
|
The self-contained theory notebooks run on bundled data:
|
|
@@ -123,6 +123,41 @@ residualisation, sample clustering, and incidence biomarkers (Theory T7).
|
|
|
123
123
|
:undoc-members:
|
|
124
124
|
:show-inheritance:
|
|
125
125
|
|
|
126
|
+
Exposure trajectory (``mir.track``)
|
|
127
|
+
-----------------------------------
|
|
128
|
+
|
|
129
|
+
A PhenoPath-style (Campbell & Yau 2018) covariate-disentangled latent trajectory over a per-sample
|
|
130
|
+
channel matrix — repertoire-level exposure detection, complementing ``mir.density``'s clone-level
|
|
131
|
+
TCRNET/ALICE enrichment.
|
|
132
|
+
|
|
133
|
+
.. automodule:: mir.track
|
|
134
|
+
:members:
|
|
135
|
+
:undoc-members:
|
|
136
|
+
:show-inheritance:
|
|
137
|
+
|
|
138
|
+
Generative loop (``mir.generate``)
|
|
139
|
+
----------------------------------
|
|
140
|
+
|
|
141
|
+
A fitted (optionally class-conditional) density over ``RepertoireDescriptor`` vectors: sample new
|
|
142
|
+
synthetic donor states, or evolve one along a coordinate via the fitted covariance's conditional
|
|
143
|
+
mean — the mechanical half of the generative loop (ROADMAP Phase 2).
|
|
144
|
+
|
|
145
|
+
.. automodule:: mir.generate
|
|
146
|
+
:members:
|
|
147
|
+
:undoc-members:
|
|
148
|
+
:show-inheritance:
|
|
149
|
+
|
|
150
|
+
Digital twin (``mir.twin``)
|
|
151
|
+
---------------------------
|
|
152
|
+
|
|
153
|
+
One donor's perturbable, simulatable state — glues ``RepertoireDescriptor`` + an optional
|
|
154
|
+
``mir.track`` trajectory position + a ``mir.generate``/``mir.ml.diffusion`` generator into one object.
|
|
155
|
+
|
|
156
|
+
.. automodule:: mir.twin
|
|
157
|
+
:members:
|
|
158
|
+
:undoc-members:
|
|
159
|
+
:show-inheritance:
|
|
160
|
+
|
|
126
161
|
Benchmark harness (``mir.bench``)
|
|
127
162
|
---------------------------------
|
|
128
163
|
|
|
@@ -239,6 +274,17 @@ Learned repertoire track (Set-Transformer / DeepRC attention pooling).
|
|
|
239
274
|
:undoc-members:
|
|
240
275
|
:show-inheritance:
|
|
241
276
|
|
|
277
|
+
``mir.ml.diffusion``
|
|
278
|
+
~~~~~~~~~~~~~~~~~~~~
|
|
279
|
+
|
|
280
|
+
Conditional diffusion generator (DDPM/DDIM + classifier-free guidance) over a compact descriptor/code
|
|
281
|
+
space — the research half of the generative loop, complementing ``mir.generate``'s linear Gaussian.
|
|
282
|
+
|
|
283
|
+
.. automodule:: mir.ml.diffusion
|
|
284
|
+
:members:
|
|
285
|
+
:undoc-members:
|
|
286
|
+
:show-inheritance:
|
|
287
|
+
|
|
242
288
|
Utilities
|
|
243
289
|
---------
|
|
244
290
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Examples
|
|
2
2
|
========
|
|
3
3
|
|
|
4
|
-
mirpy ships
|
|
4
|
+
mirpy ships four runnable `marimo <https://marimo.io>`_ notebooks under ``examples/``. They are
|
|
5
5
|
self-contained (they run on the bundled prototypes / test assets — no downloads) and double as
|
|
6
6
|
living documentation for the three tiers. Install the extra and open one:
|
|
7
7
|
|
|
@@ -28,6 +28,12 @@ living documentation for the three tiers. Install the extra and open one:
|
|
|
28
28
|
* - ``examples/theory.py``
|
|
29
29
|
- Reproduces the supplementary results S1–S3 on bundled data — the distance laws
|
|
30
30
|
(Gamma / extreme-value), the D↔d correlation, and prototype-source robustness.
|
|
31
|
+
* - ``examples/trajectory_and_twin.py``
|
|
32
|
+
- Exposure trajectory, generative loop, digital twin: fit a PhenoPath-style covariate-
|
|
33
|
+
disentangled trajectory (``mir.track``) on a synthetic cohort with a known covariate and a
|
|
34
|
+
planted severity axis, fit a generative density over ``RepertoireDescriptor`` vectors
|
|
35
|
+
(``mir.generate``), perturb one donor ("what if hotter") and draw new synthetic donor states
|
|
36
|
+
via the digital-twin glue (``mir.twin``).
|
|
31
37
|
|
|
32
38
|
The full benchmark suite (VDJdb Table S1, density, repertoire / TCGA cohorts) and its result docs
|
|
33
39
|
live in the companion `2026-mirpy-analysis <https://github.com/antigenomics>`_ repository; this repo
|
|
@@ -622,6 +622,69 @@ def enriched_mask(
|
|
|
622
622
|
)
|
|
623
623
|
|
|
624
624
|
|
|
625
|
+
def exposure_score(
|
|
626
|
+
res: EnrichmentResult, *, abundance: np.ndarray | None = None,
|
|
627
|
+
alpha: float = 0.05, min_fold: float = 1.0, min_neighbors: int = 2,
|
|
628
|
+
) -> np.ndarray:
|
|
629
|
+
"""Aggregate one repertoire's per-clonotype :class:`EnrichmentResult` into 3 exposure scalars.
|
|
630
|
+
|
|
631
|
+
:func:`neighbor_enrichment` answers "which clones are enriched" (a per-clonotype hit list, the
|
|
632
|
+
TCRNET/ALICE readout); this answers "how exposed does the whole repertoire look" — a sample-level
|
|
633
|
+
summary suitable as an explainable channel (:mod:`mir.explain`) alongside identity/diversity/
|
|
634
|
+
coverage, rather than a clonotype list. Exposure detection moves from clone-level (density.py's
|
|
635
|
+
core job) to repertoire-level (a scalar a cohort model can score) with no new statistics: it is a
|
|
636
|
+
read-off of the enrichment already computed.
|
|
637
|
+
|
|
638
|
+
Args:
|
|
639
|
+
res: One donor's enrichment result (``obs_emb`` = that donor's repertoire, ``bg_emb`` shared
|
|
640
|
+
across the cohort so results are comparable).
|
|
641
|
+
abundance: Optional clone sizes (``duplicate_count``), row-aligned to ``res`` — enables the
|
|
642
|
+
mass-weighted column. ``None`` falls back to unweighted (distinct-count) breadth.
|
|
643
|
+
alpha, min_fold, min_neighbors: Forwarded to :func:`enriched_mask`.
|
|
644
|
+
|
|
645
|
+
Returns:
|
|
646
|
+
``[breadth, mass_fraction, mean_log2_fold]``:
|
|
647
|
+
|
|
648
|
+
* ``breadth`` — fraction of distinct clonotypes flagged enriched.
|
|
649
|
+
* ``mass_fraction`` — abundance-weighted fraction of repertoire mass flagged (equals
|
|
650
|
+
``breadth`` when ``abundance`` is ``None``).
|
|
651
|
+
* ``mean_log2_fold`` — mean ``log2(fold)`` among flagged clones (``0.0`` if none flagged).
|
|
652
|
+
"""
|
|
653
|
+
mask = enriched_mask(res, alpha=alpha, min_fold=min_fold, min_neighbors=min_neighbors)
|
|
654
|
+
breadth = float(mask.mean()) if mask.size else 0.0
|
|
655
|
+
if abundance is not None:
|
|
656
|
+
a = np.asarray(abundance, dtype=np.float64)
|
|
657
|
+
mass_fraction = float(a[mask].sum() / a.sum()) if a.sum() > 0 else 0.0
|
|
658
|
+
else:
|
|
659
|
+
mass_fraction = breadth
|
|
660
|
+
if mask.any():
|
|
661
|
+
mean_log2_fold = float(np.mean(np.log2(np.clip(res.fold[mask], 1e-12, None))))
|
|
662
|
+
else:
|
|
663
|
+
mean_log2_fold = 0.0
|
|
664
|
+
return np.array([breadth, mass_fraction, mean_log2_fold], dtype=np.float64)
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def exposure_channel(
|
|
668
|
+
results: list[EnrichmentResult], *, abundances: list[np.ndarray | None] | None = None, **kwargs,
|
|
669
|
+
) -> np.ndarray:
|
|
670
|
+
"""Stack :func:`exposure_score` across a cohort into one ready-to-fuse channel matrix.
|
|
671
|
+
|
|
672
|
+
Args:
|
|
673
|
+
results: One :class:`EnrichmentResult` per donor (same shared background embedding).
|
|
674
|
+
abundances: Optional per-donor clone-size arrays, row-aligned to ``results`` (``None`` per
|
|
675
|
+
donor falls back to unweighted breadth for that donor).
|
|
676
|
+
**kwargs: Forwarded to :func:`exposure_score` (``alpha``, ``min_fold``, ``min_neighbors``).
|
|
677
|
+
|
|
678
|
+
Returns:
|
|
679
|
+
``(n_donors, 3)`` array — columns ``[breadth, mass_fraction, mean_log2_fold]`` — pass
|
|
680
|
+
directly to :meth:`mir.explain.ChannelBuilder.add` (e.g.
|
|
681
|
+
``builder.add("exposure", exposure_channel(results))``) or return it from a
|
|
682
|
+
:func:`mir.cohort.fit_donor_embeddings` ``extra_channels`` closure.
|
|
683
|
+
"""
|
|
684
|
+
abundances = abundances or [None] * len(results)
|
|
685
|
+
return np.stack([exposure_score(r, abundance=a, **kwargs) for r, a in zip(results, abundances)])
|
|
686
|
+
|
|
687
|
+
|
|
625
688
|
def denoise_and_cluster(
|
|
626
689
|
obs_emb: np.ndarray,
|
|
627
690
|
res: EnrichmentResult,
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""The generative loop: a fitted density over repertoire descriptors, sample from it, evolve one.
|
|
2
|
+
|
|
3
|
+
Promotes the ad-hoc "in-silico evolution" pattern (a bare ``np.cov`` + a hardcoded conditional-mean
|
|
4
|
+
slope over a hand-picked metric list, prototyped analysis-side over TCGA) into a real, reusable
|
|
5
|
+
library object built on :class:`mir.repertoire.RepertoireDescriptor` — the mass-preserving,
|
|
6
|
+
decodable, smooth coordinate system :mod:`mir.repertoire` already ships (``[infiltration, log n_eff,
|
|
7
|
+
clonality] ‖ identity mean``). :class:`DescriptorDensity` is the (optionally class-conditional)
|
|
8
|
+
Gaussian fit over that coordinate; :meth:`~DescriptorDensity.sample` draws brand-new synthetic donor
|
|
9
|
+
states from it (a *digital twin* generator — see :mod:`mir.track` for the complementary trajectory
|
|
10
|
+
axis), and :meth:`~DescriptorDensity.evolve` perturbs one coordinate of a real descriptor and
|
|
11
|
+
propagates the coupled shift through every other coordinate via the fitted covariance's multivariate-
|
|
12
|
+
normal conditional mean — the same move as "hotter ⇒ diversity ↑ / switch ↑ / T-vs-B ↓" but as a
|
|
13
|
+
library primitive instead of a one-off script.
|
|
14
|
+
|
|
15
|
+
This is the *mechanical* half of the generative loop (ROADMAP Phase 2): a linear-Gaussian density is
|
|
16
|
+
what the shipped in-silico-evolution numbers were fit with, so it stays the default here. The
|
|
17
|
+
*research* half — a genuinely non-linear, learned generative model over the full embedding (not just
|
|
18
|
+
the compact descriptor) — is :mod:`mir.ml.diffusion`, which this module's ``sample``/``evolve`` API
|
|
19
|
+
deliberately mirrors so the two are drop-in alternatives.
|
|
20
|
+
|
|
21
|
+
What stays analysis-local (ROADMAP non-goals): fitting a survival/classification model on the
|
|
22
|
+
perturbed metrics (e.g. "does hotter predict better survival") is the study's scorer, not this
|
|
23
|
+
module's job — :mod:`mir.explain`'s ``channel_report``/the analysis's own CoxPH fit consume the
|
|
24
|
+
*output* of :meth:`~DescriptorDensity.evolve`, this module never sees an outcome ``y``.
|
|
25
|
+
|
|
26
|
+
Torch-free (numpy / sklearn).
|
|
27
|
+
|
|
28
|
+
Typical usage::
|
|
29
|
+
|
|
30
|
+
from mir.repertoire import sample_descriptor
|
|
31
|
+
from mir.generate import fit_descriptor_density, evolve
|
|
32
|
+
|
|
33
|
+
descriptors = [sample_descriptor(space, s) for s in samples]
|
|
34
|
+
density = fit_descriptor_density(descriptors, labels=tumor_type) # one Gaussian per label
|
|
35
|
+
|
|
36
|
+
synthetic = density.sample(50, condition="SKCM") # 50 new synthetic donor states
|
|
37
|
+
hotter = evolve(density, descriptors[0], coordinate="infiltration", delta=2.0, condition="SKCM")
|
|
38
|
+
hotter.metrics() # named metrics after the move
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
from dataclasses import dataclass, field
|
|
44
|
+
|
|
45
|
+
import numpy as np
|
|
46
|
+
|
|
47
|
+
from mir.repertoire import RepertoireDescriptor
|
|
48
|
+
|
|
49
|
+
_COORD_INDEX = {"infiltration": 0, "log_neff": 1, "clonality": 2}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _shrunk_cov(X: np.ndarray) -> np.ndarray:
|
|
53
|
+
"""Ledoit-Wolf shrinkage covariance — stable with modest cohort sizes vs. the raw sample covariance."""
|
|
54
|
+
from sklearn.covariance import LedoitWolf
|
|
55
|
+
|
|
56
|
+
if X.shape[0] < 2:
|
|
57
|
+
return np.eye(X.shape[1])
|
|
58
|
+
return LedoitWolf().fit(X).covariance_
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class DescriptorDensity:
|
|
63
|
+
"""A fitted (optionally class-conditional) Gaussian density over descriptor vectors.
|
|
64
|
+
|
|
65
|
+
Attributes:
|
|
66
|
+
mean: ``{label: (dim,) mean}`` — a single key ``None`` when unconditional.
|
|
67
|
+
cov: ``{label: (dim, dim) shrinkage covariance}``.
|
|
68
|
+
dim: Vector width (``len(RepertoireDescriptor.vector)``).
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
mean: dict = field(default_factory=dict)
|
|
72
|
+
cov: dict = field(default_factory=dict)
|
|
73
|
+
dim: int = 0
|
|
74
|
+
|
|
75
|
+
@classmethod
|
|
76
|
+
def fit(cls, X: np.ndarray, labels: list | None = None) -> "DescriptorDensity":
|
|
77
|
+
"""Fit a Gaussian (or, with ``labels``, one Gaussian per label) over descriptor vectors.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
X: ``(n_samples, dim)`` stacked descriptor vectors (e.g.
|
|
81
|
+
``np.stack([d.vector for d in descriptors])``).
|
|
82
|
+
labels: Optional length-``n_samples`` group labels (tumor type, batch, condition, …) —
|
|
83
|
+
fits one mean/covariance per distinct label instead of a single pooled Gaussian.
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
A fitted :class:`DescriptorDensity`.
|
|
87
|
+
|
|
88
|
+
Raises:
|
|
89
|
+
ValueError: If ``X`` has fewer than 2 rows.
|
|
90
|
+
"""
|
|
91
|
+
X = np.asarray(X, dtype=np.float64)
|
|
92
|
+
if X.shape[0] < 2:
|
|
93
|
+
raise ValueError(f"need >= 2 samples to fit a density, got {X.shape[0]}")
|
|
94
|
+
groups = {None: np.arange(X.shape[0])} if labels is None else {
|
|
95
|
+
lab: np.where(np.asarray(labels) == lab)[0] for lab in sorted(set(labels), key=str)
|
|
96
|
+
}
|
|
97
|
+
mean, cov = {}, {}
|
|
98
|
+
for lab, idx in groups.items():
|
|
99
|
+
Xg = X[idx]
|
|
100
|
+
mean[lab] = Xg.mean(axis=0)
|
|
101
|
+
cov[lab] = _shrunk_cov(Xg) if Xg.shape[0] >= 2 else np.eye(X.shape[1])
|
|
102
|
+
return cls(mean=mean, cov=cov, dim=X.shape[1])
|
|
103
|
+
|
|
104
|
+
def _resolve(self, condition):
|
|
105
|
+
if condition in self.mean:
|
|
106
|
+
return self.mean[condition], self.cov[condition]
|
|
107
|
+
if condition is None and len(self.mean) == 1:
|
|
108
|
+
return next(iter(self.mean.values())), next(iter(self.cov.values()))
|
|
109
|
+
raise ValueError(f"condition {condition!r} not in fitted labels {sorted(self.mean, key=str)}")
|
|
110
|
+
|
|
111
|
+
def sample(self, n: int = 1, *, condition=None, seed: int = 0) -> np.ndarray:
|
|
112
|
+
"""Draw ``n`` synthetic descriptor vectors from the fitted (conditional) Gaussian.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
n: Number of synthetic vectors to draw.
|
|
116
|
+
condition: Label to condition on (must be one of the labels ``fit`` saw); ``None`` for an
|
|
117
|
+
unconditional density, or the sole label when only one was fit.
|
|
118
|
+
seed: RNG seed.
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
``(n, dim)`` array — decode with :func:`mir.repertoire.decode_metrics` per row.
|
|
122
|
+
"""
|
|
123
|
+
mu, sigma = self._resolve(condition)
|
|
124
|
+
rng = np.random.default_rng(seed)
|
|
125
|
+
return rng.multivariate_normal(mu, sigma, size=n)
|
|
126
|
+
|
|
127
|
+
def evolve(self, vector: np.ndarray, *, coordinate: int | str, delta: float, condition=None) -> np.ndarray:
|
|
128
|
+
"""Shift ``vector`` by ``delta`` along one coordinate, propagating the coupled response.
|
|
129
|
+
|
|
130
|
+
The multivariate-normal conditional mean: fixing coordinate ``d`` to move by ``delta``
|
|
131
|
+
shifts every other coordinate ``j`` by ``Sigma[j,d]/Sigma[d,d] * delta`` in expectation —
|
|
132
|
+
this is the "hotter -> diversity up / switch up / T-vs-B down" move, generalized from a
|
|
133
|
+
hand-picked metric list to the full descriptor (and, via :attr:`RepertoireDescriptor.mean`,
|
|
134
|
+
the identity block too).
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
vector: A ``(dim,)`` descriptor vector to perturb (e.g. one real donor's).
|
|
138
|
+
coordinate: Vector index, or one of ``"infiltration"`` / ``"log_neff"`` / ``"clonality"``
|
|
139
|
+
for the named scalar coordinates.
|
|
140
|
+
delta: Amount to shift ``coordinate`` by (in the descriptor's own units — e.g. log-mass).
|
|
141
|
+
condition: Which fitted Gaussian's covariance defines the coupling (see :meth:`sample`).
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
The perturbed ``(dim,)`` vector — decode with :func:`mir.repertoire.decode_metrics`.
|
|
145
|
+
"""
|
|
146
|
+
d = _COORD_INDEX.get(coordinate, coordinate) if isinstance(coordinate, str) else coordinate
|
|
147
|
+
_, sigma = self._resolve(condition)
|
|
148
|
+
slope = sigma[:, d] / sigma[d, d]
|
|
149
|
+
out = np.asarray(vector, dtype=np.float64) + delta * slope
|
|
150
|
+
out[d] = vector[d] + delta # the perturbed coordinate itself moves by exactly delta
|
|
151
|
+
return out
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def fit_descriptor_density(descriptors: list[RepertoireDescriptor], labels: list | None = None) -> DescriptorDensity:
|
|
155
|
+
"""Fit a :class:`DescriptorDensity` directly from :class:`~mir.repertoire.RepertoireDescriptor` objects.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
descriptors: One :class:`~mir.repertoire.RepertoireDescriptor` per sample
|
|
159
|
+
(:func:`mir.repertoire.sample_descriptor`).
|
|
160
|
+
labels: Optional per-sample group labels — see :meth:`DescriptorDensity.fit`.
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
A fitted :class:`DescriptorDensity`.
|
|
164
|
+
"""
|
|
165
|
+
X = np.stack([d.vector for d in descriptors])
|
|
166
|
+
return DescriptorDensity.fit(X, labels=labels)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def evolve(
|
|
170
|
+
density: DescriptorDensity, descriptor: RepertoireDescriptor, *,
|
|
171
|
+
coordinate: int | str, delta: float, condition=None,
|
|
172
|
+
) -> RepertoireDescriptor:
|
|
173
|
+
"""Perturb one :class:`~mir.repertoire.RepertoireDescriptor` and rebuild the result as one.
|
|
174
|
+
|
|
175
|
+
Thin wrapper around :meth:`DescriptorDensity.evolve` that unpacks/repacks
|
|
176
|
+
:class:`~mir.repertoire.RepertoireDescriptor` instead of a bare vector, so the result's
|
|
177
|
+
``.metrics()`` reads off directly.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
density: A fitted :class:`DescriptorDensity` (the coupling comes from its covariance).
|
|
181
|
+
descriptor: The descriptor to perturb.
|
|
182
|
+
coordinate, delta, condition: See :meth:`DescriptorDensity.evolve`.
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
The perturbed :class:`~mir.repertoire.RepertoireDescriptor`.
|
|
186
|
+
"""
|
|
187
|
+
v = density.evolve(descriptor.vector, coordinate=coordinate, delta=delta, condition=condition)
|
|
188
|
+
return RepertoireDescriptor(log_mass=float(v[0]), log_neff=float(v[1]), simpson=float(v[2]), mean=v[3:])
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _demo() -> None:
|
|
192
|
+
"""Self-check on synthetic descriptors: sampling matches the fit moments; evolve moves the target
|
|
193
|
+
coordinate by exactly delta and couples a correlated coordinate in the expected direction."""
|
|
194
|
+
rng = np.random.default_rng(0)
|
|
195
|
+
n, dim = 300, 6
|
|
196
|
+
# a correlated synthetic cohort: coordinate 0 ("infiltration") positively drives coordinate 1
|
|
197
|
+
base = rng.standard_normal((n, dim))
|
|
198
|
+
base[:, 1] = 0.8 * base[:, 0] + 0.2 * rng.standard_normal(n) # coord 1 tracks coord 0
|
|
199
|
+
descriptors = [
|
|
200
|
+
RepertoireDescriptor(log_mass=float(r[0]), log_neff=float(r[1]), simpson=float(r[2]), mean=r[3:])
|
|
201
|
+
for r in base
|
|
202
|
+
]
|
|
203
|
+
density = fit_descriptor_density(descriptors)
|
|
204
|
+
|
|
205
|
+
synth = density.sample(2000, seed=1)
|
|
206
|
+
assert synth.shape == (2000, dim)
|
|
207
|
+
assert np.allclose(synth.mean(axis=0), base.mean(axis=0), atol=0.15)
|
|
208
|
+
|
|
209
|
+
d0 = descriptors[0]
|
|
210
|
+
moved = evolve(density, d0, coordinate="infiltration", delta=2.0)
|
|
211
|
+
assert abs(moved.log_mass - (d0.log_mass + 2.0)) < 1e-9 # exact move on the target coord
|
|
212
|
+
assert moved.log_neff > d0.log_neff # positive coupling recovered
|
|
213
|
+
|
|
214
|
+
# class-conditional fit: two well-separated labels sample from their own moments
|
|
215
|
+
labels = ["hot"] * (n // 2) + ["cold"] * (n - n // 2)
|
|
216
|
+
base2 = base.copy()
|
|
217
|
+
base2[n // 2:, 0] -= 5.0
|
|
218
|
+
descriptors2 = [
|
|
219
|
+
RepertoireDescriptor(log_mass=float(r[0]), log_neff=float(r[1]), simpson=float(r[2]), mean=r[3:])
|
|
220
|
+
for r in base2
|
|
221
|
+
]
|
|
222
|
+
cond_density = fit_descriptor_density(descriptors2, labels=labels)
|
|
223
|
+
hot_synth = cond_density.sample(500, condition="hot", seed=2)
|
|
224
|
+
cold_synth = cond_density.sample(500, condition="cold", seed=2)
|
|
225
|
+
assert hot_synth[:, 0].mean() - cold_synth[:, 0].mean() > 3.0
|
|
226
|
+
|
|
227
|
+
print(f"[ok] sample moments match (atol=0.15); evolve moved infiltration by exactly 2.0, "
|
|
228
|
+
f"coupled log_neff {d0.log_neff:.3f}->{moved.log_neff:.3f}; conditional sampling separates "
|
|
229
|
+
f"hot/cold by {hot_synth[:,0].mean()-cold_synth[:,0].mean():.2f}")
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
if __name__ == "__main__":
|
|
233
|
+
_demo()
|