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.
Files changed (98) hide show
  1. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/CHANGELOG.md +32 -0
  2. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/CLAUDE.md +33 -4
  3. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/PKG-INFO +46 -2
  4. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/README.md +45 -1
  5. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/docs/api.rst +46 -0
  6. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/docs/examples.rst +7 -1
  7. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/__init__.py +1 -1
  8. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/density.py +63 -0
  9. mirpy_lib-3.7.0/src/mir/generate.py +233 -0
  10. mirpy_lib-3.7.0/src/mir/ml/diffusion.py +378 -0
  11. mirpy_lib-3.7.0/src/mir/track.py +331 -0
  12. mirpy_lib-3.7.0/src/mir/twin.py +179 -0
  13. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/.gitignore +0 -0
  14. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/LICENSE +0 -0
  15. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/SOURCES.md +0 -0
  16. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/assets/mirpy_dark.png +0 -0
  17. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/assets/mirpy_dark.svg +0 -0
  18. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/assets/mirpy_dark_bg.svg +0 -0
  19. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/assets/mirpy_light.png +0 -0
  20. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/assets/mirpy_light.svg +0 -0
  21. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/assets/mirpy_light_bg.svg +0 -0
  22. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/docs/Makefile +0 -0
  23. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/docs/_static/custom.css +0 -0
  24. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/docs/_static/depth_robustness.png +0 -0
  25. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/docs/_static/mirpy_dark.png +0 -0
  26. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/docs/_static/mirpy_dark.svg +0 -0
  27. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/docs/_static/mirpy_light.png +0 -0
  28. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/docs/_static/mirpy_light.svg +0 -0
  29. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/docs/_static/sample_embedding_diagram.png +0 -0
  30. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/docs/conf.py +0 -0
  31. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/docs/index.rst +0 -0
  32. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/docs/usage.rst +0 -0
  33. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/pyproject.toml +0 -0
  34. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/requirements.txt +0 -0
  35. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/setup.sh +0 -0
  36. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/aliases.py +0 -0
  37. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/alleles.py +0 -0
  38. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/bench/__init__.py +0 -0
  39. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/bench/eval.py +0 -0
  40. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/bench/metrics.py +0 -0
  41. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/bench/theory.py +0 -0
  42. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/bench/vdjdb.py +0 -0
  43. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/cli.py +0 -0
  44. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/cohort.py +0 -0
  45. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/distances/__init__.py +0 -0
  46. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/distances/germline.py +0 -0
  47. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/distances/junction.py +0 -0
  48. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/embedding/__init__.py +0 -0
  49. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/embedding/pca.py +0 -0
  50. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/embedding/presets.py +0 -0
  51. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/embedding/prototypes.py +0 -0
  52. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/embedding/tcremp.py +0 -0
  53. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/explain.py +0 -0
  54. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/ml/__init__.py +0 -0
  55. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/ml/bundle.py +0 -0
  56. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/ml/codec.py +0 -0
  57. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/ml/decoder.py +0 -0
  58. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/ml/encoder.py +0 -0
  59. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/ml/set_encoder.py +0 -0
  60. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/ml/tokenize.py +0 -0
  61. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/ml/train.py +0 -0
  62. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/py.typed +0 -0
  63. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/repertoire.py +0 -0
  64. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/resources/__init__.py +0 -0
  65. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/resources/gene_library/README.md +0 -0
  66. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/resources/gene_library/__init__.py +0 -0
  67. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/resources/gene_library/build_gene_library.py +0 -0
  68. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/resources/gene_library/build_region_annotations.py +0 -0
  69. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/resources/gene_library/imgt_gene_library.txt +0 -0
  70. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/resources/gene_library/olga_gene_library.txt +0 -0
  71. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/resources/gene_library/region_annotations.txt +0 -0
  72. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/resources/germline_dist/build_germline_dist.py +0 -0
  73. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/resources/germline_dist/human_IGH.npz +0 -0
  74. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/resources/germline_dist/human_IGK.npz +0 -0
  75. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/resources/germline_dist/human_IGL.npz +0 -0
  76. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/resources/germline_dist/human_TRA.npz +0 -0
  77. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/resources/germline_dist/human_TRB.npz +0 -0
  78. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/resources/germline_dist/human_TRD.npz +0 -0
  79. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/resources/germline_dist/human_TRG.npz +0 -0
  80. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/resources/germline_dist/mouse_IGH.npz +0 -0
  81. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/resources/germline_dist/mouse_IGK.npz +0 -0
  82. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/resources/germline_dist/mouse_IGL.npz +0 -0
  83. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/resources/germline_dist/mouse_TRA.npz +0 -0
  84. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/resources/germline_dist/mouse_TRB.npz +0 -0
  85. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/resources/germline_dist/mouse_TRD.npz +0 -0
  86. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/resources/germline_dist/mouse_TRG.npz +0 -0
  87. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/resources/prototypes/.gitkeep +0 -0
  88. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/resources/prototypes/generate_prototypes.py +0 -0
  89. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/resources/prototypes/human_IGH.tsv +0 -0
  90. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/resources/prototypes/human_IGK.tsv +0 -0
  91. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/resources/prototypes/human_IGL.tsv +0 -0
  92. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/resources/prototypes/human_TRA.tsv +0 -0
  93. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/resources/prototypes/human_TRB.tsv +0 -0
  94. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/resources/prototypes/human_TRD.tsv +0 -0
  95. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/resources/prototypes/human_TRG.tsv +0 -0
  96. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/resources/prototypes/manifest.json +0 -0
  97. {mirpy_lib-3.6.0 → mirpy_lib-3.7.0}/src/mir/resources/prototypes/mouse_TRA.tsv +0 -0
  98. {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**. Next: **Phase 2** generative loop (`generate.py` `DescriptorDensity`
159
- + `evolve`/`sample`, `CodecBundle.from_unified/from_decoder`), then multimodal encoders + embedding
160
- trajectory. **Analysis-repo follow-up:** refactor `_tcga_embedding.build_embedding` onto
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.6.0
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.ml` | neural codecs (forward/inverse/Pgen/unified) + learned repertoire `set_encoder` (Set-Transformer/DeepRC) Part 2, experimental; `[ml]` extra |
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.ml` | neural codecs (forward/inverse/Pgen/unified) + learned repertoire `set_encoder` (Set-Transformer/DeepRC) Part 2, experimental; `[ml]` extra |
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 three runnable `marimo <https://marimo.io>`_ notebooks under ``examples/``. They are
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
@@ -15,7 +15,7 @@ from __future__ import annotations
15
15
 
16
16
  import os
17
17
 
18
- __version__ = "3.6.0"
18
+ __version__ = "3.7.0"
19
19
 
20
20
  __all__ = ["__version__", "get_resource_path", "TCREmp", "PairedTCREmp"]
21
21
 
@@ -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()