mirpy-lib 3.4.0__tar.gz → 3.6.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 (101) hide show
  1. mirpy_lib-3.6.0/CHANGELOG.md +285 -0
  2. {mirpy_lib-3.4.0 → mirpy_lib-3.6.0}/PKG-INFO +80 -22
  3. {mirpy_lib-3.4.0 → mirpy_lib-3.6.0}/README.md +79 -17
  4. {mirpy_lib-3.4.0 → mirpy_lib-3.6.0}/SOURCES.md +11 -9
  5. mirpy_lib-3.6.0/assets/mirpy_dark.png +0 -0
  6. {mirpy_lib-3.4.0 → mirpy_lib-3.6.0}/assets/mirpy_dark.svg +1 -1
  7. mirpy_lib-3.6.0/assets/mirpy_dark_bg.svg +1519 -0
  8. mirpy_lib-3.6.0/assets/mirpy_light.png +0 -0
  9. {mirpy_lib-3.4.0 → mirpy_lib-3.6.0}/assets/mirpy_light.svg +1 -1
  10. mirpy_lib-3.6.0/assets/mirpy_light_bg.svg +1519 -0
  11. mirpy_lib-3.6.0/docs/_static/mirpy_dark.png +0 -0
  12. {mirpy_lib-3.4.0 → mirpy_lib-3.6.0}/docs/_static/mirpy_dark.svg +1 -1
  13. mirpy_lib-3.6.0/docs/_static/mirpy_light.png +0 -0
  14. {mirpy_lib-3.4.0 → mirpy_lib-3.6.0}/docs/_static/mirpy_light.svg +1 -1
  15. {mirpy_lib-3.4.0 → mirpy_lib-3.6.0}/docs/api.rst +12 -5
  16. {mirpy_lib-3.4.0 → mirpy_lib-3.6.0}/docs/conf.py +4 -8
  17. {mirpy_lib-3.4.0 → mirpy_lib-3.6.0}/docs/examples.rst +4 -1
  18. {mirpy_lib-3.4.0 → mirpy_lib-3.6.0}/docs/usage.rst +66 -12
  19. {mirpy_lib-3.4.0 → mirpy_lib-3.6.0}/pyproject.toml +4 -5
  20. {mirpy_lib-3.4.0 → mirpy_lib-3.6.0}/requirements.txt +1 -1
  21. {mirpy_lib-3.4.0 → mirpy_lib-3.6.0}/setup.sh +36 -17
  22. {mirpy_lib-3.4.0 → mirpy_lib-3.6.0}/src/mir/__init__.py +1 -1
  23. {mirpy_lib-3.4.0 → mirpy_lib-3.6.0}/src/mir/bench/__init__.py +2 -1
  24. {mirpy_lib-3.4.0 → mirpy_lib-3.6.0}/src/mir/bench/theory.py +3 -2
  25. {mirpy_lib-3.4.0 → mirpy_lib-3.6.0}/src/mir/bench/vdjdb.py +3 -1
  26. {mirpy_lib-3.4.0 → mirpy_lib-3.6.0}/src/mir/cli.py +47 -5
  27. {mirpy_lib-3.4.0 → mirpy_lib-3.6.0}/src/mir/cohort.py +22 -2
  28. {mirpy_lib-3.4.0 → mirpy_lib-3.6.0}/src/mir/density.py +48 -13
  29. {mirpy_lib-3.4.0 → mirpy_lib-3.6.0}/src/mir/distances/germline.py +21 -3
  30. {mirpy_lib-3.4.0 → mirpy_lib-3.6.0}/src/mir/distances/junction.py +18 -7
  31. {mirpy_lib-3.4.0 → mirpy_lib-3.6.0}/src/mir/embedding/presets.py +2 -1
  32. mirpy_lib-3.6.0/src/mir/embedding/prototypes.py +173 -0
  33. {mirpy_lib-3.4.0 → mirpy_lib-3.6.0}/src/mir/embedding/tcremp.py +45 -12
  34. {mirpy_lib-3.4.0 → mirpy_lib-3.6.0}/src/mir/explain.py +6 -4
  35. {mirpy_lib-3.4.0 → mirpy_lib-3.6.0}/src/mir/ml/bundle.py +16 -7
  36. {mirpy_lib-3.4.0 → mirpy_lib-3.6.0}/src/mir/ml/set_encoder.py +2 -1
  37. {mirpy_lib-3.4.0 → mirpy_lib-3.6.0}/src/mir/repertoire.py +70 -25
  38. {mirpy_lib-3.4.0 → mirpy_lib-3.6.0}/src/mir/resources/prototypes/generate_prototypes.py +3 -1
  39. mirpy_lib-3.4.0/CHANGELOG.md +0 -147
  40. mirpy_lib-3.4.0/assets/mirpy_dark.png +0 -0
  41. mirpy_lib-3.4.0/assets/mirpy_light.png +0 -0
  42. mirpy_lib-3.4.0/docs/_static/mirpy_dark.png +0 -0
  43. mirpy_lib-3.4.0/docs/_static/mirpy_light.png +0 -0
  44. mirpy_lib-3.4.0/docs/requirements.txt +0 -3
  45. mirpy_lib-3.4.0/src/mir/embedding/prototypes.py +0 -109
  46. {mirpy_lib-3.4.0 → mirpy_lib-3.6.0}/.gitignore +0 -0
  47. {mirpy_lib-3.4.0 → mirpy_lib-3.6.0}/CLAUDE.md +0 -0
  48. {mirpy_lib-3.4.0 → mirpy_lib-3.6.0}/LICENSE +0 -0
  49. {mirpy_lib-3.4.0 → mirpy_lib-3.6.0}/docs/Makefile +0 -0
  50. {mirpy_lib-3.4.0 → mirpy_lib-3.6.0}/docs/_static/custom.css +0 -0
  51. {mirpy_lib-3.4.0 → mirpy_lib-3.6.0}/docs/_static/depth_robustness.png +0 -0
  52. {mirpy_lib-3.4.0 → mirpy_lib-3.6.0}/docs/_static/sample_embedding_diagram.png +0 -0
  53. {mirpy_lib-3.4.0 → mirpy_lib-3.6.0}/docs/index.rst +0 -0
  54. {mirpy_lib-3.4.0 → mirpy_lib-3.6.0}/src/mir/aliases.py +0 -0
  55. {mirpy_lib-3.4.0 → mirpy_lib-3.6.0}/src/mir/alleles.py +0 -0
  56. {mirpy_lib-3.4.0 → mirpy_lib-3.6.0}/src/mir/bench/eval.py +0 -0
  57. {mirpy_lib-3.4.0 → mirpy_lib-3.6.0}/src/mir/bench/metrics.py +0 -0
  58. {mirpy_lib-3.4.0 → mirpy_lib-3.6.0}/src/mir/distances/__init__.py +0 -0
  59. {mirpy_lib-3.4.0 → mirpy_lib-3.6.0}/src/mir/embedding/__init__.py +0 -0
  60. {mirpy_lib-3.4.0 → mirpy_lib-3.6.0}/src/mir/embedding/pca.py +0 -0
  61. {mirpy_lib-3.4.0 → mirpy_lib-3.6.0}/src/mir/ml/__init__.py +0 -0
  62. {mirpy_lib-3.4.0 → mirpy_lib-3.6.0}/src/mir/ml/codec.py +0 -0
  63. {mirpy_lib-3.4.0 → mirpy_lib-3.6.0}/src/mir/ml/decoder.py +0 -0
  64. {mirpy_lib-3.4.0 → mirpy_lib-3.6.0}/src/mir/ml/encoder.py +0 -0
  65. {mirpy_lib-3.4.0 → mirpy_lib-3.6.0}/src/mir/ml/tokenize.py +0 -0
  66. {mirpy_lib-3.4.0 → mirpy_lib-3.6.0}/src/mir/ml/train.py +0 -0
  67. {mirpy_lib-3.4.0 → mirpy_lib-3.6.0}/src/mir/py.typed +0 -0
  68. {mirpy_lib-3.4.0 → mirpy_lib-3.6.0}/src/mir/resources/__init__.py +0 -0
  69. {mirpy_lib-3.4.0 → mirpy_lib-3.6.0}/src/mir/resources/gene_library/README.md +0 -0
  70. {mirpy_lib-3.4.0 → mirpy_lib-3.6.0}/src/mir/resources/gene_library/__init__.py +0 -0
  71. {mirpy_lib-3.4.0 → mirpy_lib-3.6.0}/src/mir/resources/gene_library/build_gene_library.py +0 -0
  72. {mirpy_lib-3.4.0 → mirpy_lib-3.6.0}/src/mir/resources/gene_library/build_region_annotations.py +0 -0
  73. {mirpy_lib-3.4.0 → mirpy_lib-3.6.0}/src/mir/resources/gene_library/imgt_gene_library.txt +0 -0
  74. {mirpy_lib-3.4.0 → mirpy_lib-3.6.0}/src/mir/resources/gene_library/olga_gene_library.txt +0 -0
  75. {mirpy_lib-3.4.0 → mirpy_lib-3.6.0}/src/mir/resources/gene_library/region_annotations.txt +0 -0
  76. {mirpy_lib-3.4.0 → mirpy_lib-3.6.0}/src/mir/resources/germline_dist/build_germline_dist.py +0 -0
  77. {mirpy_lib-3.4.0 → mirpy_lib-3.6.0}/src/mir/resources/germline_dist/human_IGH.npz +0 -0
  78. {mirpy_lib-3.4.0 → mirpy_lib-3.6.0}/src/mir/resources/germline_dist/human_IGK.npz +0 -0
  79. {mirpy_lib-3.4.0 → mirpy_lib-3.6.0}/src/mir/resources/germline_dist/human_IGL.npz +0 -0
  80. {mirpy_lib-3.4.0 → mirpy_lib-3.6.0}/src/mir/resources/germline_dist/human_TRA.npz +0 -0
  81. {mirpy_lib-3.4.0 → mirpy_lib-3.6.0}/src/mir/resources/germline_dist/human_TRB.npz +0 -0
  82. {mirpy_lib-3.4.0 → mirpy_lib-3.6.0}/src/mir/resources/germline_dist/human_TRD.npz +0 -0
  83. {mirpy_lib-3.4.0 → mirpy_lib-3.6.0}/src/mir/resources/germline_dist/human_TRG.npz +0 -0
  84. {mirpy_lib-3.4.0 → mirpy_lib-3.6.0}/src/mir/resources/germline_dist/mouse_IGH.npz +0 -0
  85. {mirpy_lib-3.4.0 → mirpy_lib-3.6.0}/src/mir/resources/germline_dist/mouse_IGK.npz +0 -0
  86. {mirpy_lib-3.4.0 → mirpy_lib-3.6.0}/src/mir/resources/germline_dist/mouse_IGL.npz +0 -0
  87. {mirpy_lib-3.4.0 → mirpy_lib-3.6.0}/src/mir/resources/germline_dist/mouse_TRA.npz +0 -0
  88. {mirpy_lib-3.4.0 → mirpy_lib-3.6.0}/src/mir/resources/germline_dist/mouse_TRB.npz +0 -0
  89. {mirpy_lib-3.4.0 → mirpy_lib-3.6.0}/src/mir/resources/germline_dist/mouse_TRD.npz +0 -0
  90. {mirpy_lib-3.4.0 → mirpy_lib-3.6.0}/src/mir/resources/germline_dist/mouse_TRG.npz +0 -0
  91. {mirpy_lib-3.4.0 → mirpy_lib-3.6.0}/src/mir/resources/prototypes/.gitkeep +0 -0
  92. {mirpy_lib-3.4.0 → mirpy_lib-3.6.0}/src/mir/resources/prototypes/human_IGH.tsv +0 -0
  93. {mirpy_lib-3.4.0 → mirpy_lib-3.6.0}/src/mir/resources/prototypes/human_IGK.tsv +0 -0
  94. {mirpy_lib-3.4.0 → mirpy_lib-3.6.0}/src/mir/resources/prototypes/human_IGL.tsv +0 -0
  95. {mirpy_lib-3.4.0 → mirpy_lib-3.6.0}/src/mir/resources/prototypes/human_TRA.tsv +0 -0
  96. {mirpy_lib-3.4.0 → mirpy_lib-3.6.0}/src/mir/resources/prototypes/human_TRB.tsv +0 -0
  97. {mirpy_lib-3.4.0 → mirpy_lib-3.6.0}/src/mir/resources/prototypes/human_TRD.tsv +0 -0
  98. {mirpy_lib-3.4.0 → mirpy_lib-3.6.0}/src/mir/resources/prototypes/human_TRG.tsv +0 -0
  99. {mirpy_lib-3.4.0 → mirpy_lib-3.6.0}/src/mir/resources/prototypes/manifest.json +0 -0
  100. {mirpy_lib-3.4.0 → mirpy_lib-3.6.0}/src/mir/resources/prototypes/mouse_TRA.tsv +0 -0
  101. {mirpy_lib-3.4.0 → mirpy_lib-3.6.0}/src/mir/resources/prototypes/mouse_TRB.tsv +0 -0
@@ -0,0 +1,285 @@
1
+ # Changelog
2
+
3
+ All notable changes to `mirpy-lib` (import `mir`). This project follows semantic versioning; the v3 line is a
4
+ greenfield ML/embedding rewrite (the classical v1.x/v2 toolkit is frozen on branch `legacy-v2`).
5
+
6
+ ## 3.6.0 — 2026-07-30
7
+
8
+ Default-on functional filtering, a new default clone-size weight for repertoire embedding, and a
9
+ clearer crash message — a minor bump since the repertoire-embedding default changes the numeric
10
+ output for callers who didn't pass `weight=` explicitly.
11
+
12
+ ### Added
13
+
14
+ - **`mir embed clonotypes` / `mir embed repertoires` drop non-coding clonotypes by default**
15
+ (`--filter-functional`, default on; `--no-filter-functional` to disable) via
16
+ `vdjtools.preprocess.filter_functional` — a stop codon or legacy out-of-frame marker
17
+ (`[*atgc#~_?]`) in `junction_aa` otherwise either crashes (`_`) or silently produces a
18
+ numerically meaningless embedding (`*`). `mir embed repertoires` skips (with a warning) any
19
+ sample/locus left empty after filtering, instead of crashing the whole batch.
20
+ - **`"duplicate_count"` (linear, `g(a)=a`) and `"log2p1"` (`g(a)=log2(1+a)`) clone-size weights**
21
+ for repertoire embedding, alongside the existing `"distinct"` (`g≡1`) and `"log1p"`/`"anscombe"`.
22
+ `"log2p1"` is the **new default** — see Changed.
23
+
24
+ ### Fixed
25
+
26
+ - **`TCREmp.embed` raises a clear error when `junction_aa` contains `'_'`** (the legacy vdjtools
27
+ out-of-frame marker), naming the value count and pointing at `filter_functional`, instead of
28
+ letting seqtree's opaque `"symbol '_' is not in the alphabet"` propagate uncaught. `'*'` (stop
29
+ codon) is unaffected by this check — it doesn't crash, so filter it via `filter_functional` if
30
+ you don't want it silently embedded.
31
+
32
+ ### Changed
33
+
34
+ - **Default clone-size weight for repertoire embedding is now `"log2p1"`** (`g=log2(1+a)`),
35
+ changed from `"log1p"` (natural log). Affects `mir.repertoire.sample_embedding`,
36
+ `RepertoireSpace.sample_cloud`, `sample_descriptor`, `class_witness`, `mir.explain.channel_drivers`,
37
+ and `mir embed repertoires --weight`. This changes the numeric values of `Φ(S)`'s mean/second
38
+ blocks (and any MMD computed from them) for callers who relied on the implicit default — pass
39
+ `weight="log1p"` to reproduce the old default exactly.
40
+
41
+ ## 3.5.0 — 2026-07-28
42
+
43
+ A documentation-accuracy pass plus the fixes found by the accompanying code audit. No public API
44
+ removed; two new keyword arguments, one runtime dependency dropped, and three paths that used to
45
+ fail silently now fail loudly — hence a minor bump rather than a patch.
46
+
47
+ ### Added
48
+
49
+ - **Prototype replicates — `load_prototypes(..., replicate=r)`, `n_replicates()`, and
50
+ `replicate=` on `TCREmp`/`PairedTCREmp.from_defaults` and both `mir embed` commands.** Each
51
+ bundled chain ships 10 000 real receptors whose row order is already a uniform shuffle, so a
52
+ disjoint block of `n` rows is an independent draw from the same pool: `replicate=r` returns block
53
+ `r`, giving `10000 // n` replicates (**10** at the common `n=1000`) with no new data and no RNG.
54
+ This answers "is my result an artefact of *which* prototypes I drew?" — a question a nested
55
+ `n_prototypes` sweep cannot answer, since those draws are prefixes of one another.
56
+
57
+ `replicate=0` is unchanged and remains *the* prototype set behind every preset, bundled codec and
58
+ published number. `prototype_hash` now covers the replicate index, so `CodecBundle`,
59
+ `RepertoireSpace`, `DonorCohort` and `SetEncoderBundle` refuse to mix draws (artifacts written
60
+ before this release read as draw 0). README and the user guide state the default, the provenance
61
+ (real repertoires, `seed=42`), and that cross-replicate embeddings are incomparable.
62
+ - **`generate_background(..., species=, source="arda")`** — the P_gen background can now be drawn
63
+ in the **arda IMGT allele namespace** (the same frame as the prototypes and baked germline
64
+ distances, so generated V/J calls resolve exactly instead of taking the allele cascade) and for
65
+ **mouse**. Both need a `vdjtools` shipping the bundled `arda` model set: those 9 models were
66
+ already in the wheel but unreachable, fixed upstream in `vdjtools.model.load_bundled`. The human
67
+ `learned`/`olga` path is unchanged and still works on older vdjtools; asking for arda/mouse
68
+ without it raises a message naming the requirement.
69
+ - **`neighbor_enrichment(..., k_max=, seed=)`** — forwarded to the `backend="ann"` engine, which
70
+ already warned "raise `k_max`" for a saturated neighbour ball but gave no way to do it.
71
+
72
+ ### Fixed
73
+
74
+ - **Germline distances silently maxed out for 42 human and 71 mouse alleles**, including
75
+ **`IGHV3-23*01` and `IGHV1-69*01` — the two most-used human IGHV genes.** arda emits an
76
+ *ambiguity group* (`"IGHV1-69*01,IGHV1-69D*01"`) as one row when the alleles share an identical
77
+ germline region, and the allele index registered only the joined string, so a query naming a
78
+ member matched nothing and took the max-distance fallback — quietly, since a fallback is also the
79
+ legitimate answer for a genuinely unknown gene. Members are now indexed to their group's row (a
80
+ standalone row still wins). Across every bundled locus, 333 of 457 group-member names resolved to
81
+ the fallback before; now none do.
82
+ - **`mir embed repertoires --blocks diversity` crashed** with `ValueError: zero-dimensional arrays
83
+ cannot be concatenated`: `SampleEmbedding.vector` assumed the kernel-mean block was always
84
+ present. A mean-less Φ is now a valid vector, and `mmd_distance` / `mmd_matrix` say *why* MMD
85
+ needs the mean block instead of raising a `NoneType` `TypeError`.
86
+
87
+ ### Changed
88
+
89
+ - **`GermlineDistances.matrix` resolves each *distinct* allele once** instead of once per row, then
90
+ gathers a small `(n_distinct, K)` table and expands it. A repertoire has ~10⁵–10⁶ rows but ~10²
91
+ distinct alleles, so this is the dominant cost of `TCREmp.embed`: measured **2.32 s → 1.04 s** for
92
+ 200k clonotypes × 1000 prototypes (the germline block itself 0.765 s → 0.072 s), bit-identical
93
+ output, nulls still take the allele fallback.
94
+ - **`RandomFourierFeatures.transform` computes in place** after the matmul — peak memory for a deep
95
+ sample drops ~3× (measured 9.2 → 3.2 GiB at n=200k, D=2048), output identical.
96
+ - **`RepertoireSpace.save` / `DonorCohort.save` refuse a model built with a custom `matrix=` or
97
+ `alignment=`.** Neither knob is recorded in `meta`, so `load` rebuilt the default
98
+ gapblock/BLOSUM62 space — a *different* coordinate system that still passed the prototype-hash
99
+ check. Failing at save time keeps the comparability invariant honest.
100
+ - **`TCREmp.embed` rejects null `junction_aa`** with a message naming the column and the row count,
101
+ instead of an opaque `TypeError: object of type 'NoneType' has no len()` from inside seqtree.
102
+ - **`fit_donor_embeddings` warns when a locus has too few donors to fit the identity PCA** — that
103
+ block was silently emitted as all-NaN, imputed to a constant, information-free channel.
104
+ - **`alignment="sw"` raises a directed `ImportError`** ("pip install biopython") instead of a bare
105
+ `ModuleNotFoundError`; `python -m mir.embedding.tcremp` now skips the SW leg when BioPython is
106
+ absent, so the self-check passes in the default `[dev,bench]` environment.
107
+ - **Determinism**: `bench.theory._mutate` no longer "mutates" a residue to itself (5.3% of `k=1`
108
+ substitutions were no-ops, and it disagreed with `density._mutate1`); `load_vdjdb` and
109
+ `generate_prototypes` no longer depend on `.unique()`'s arbitrary row order.
110
+ - **Single-sourced the version**: `pyproject.toml` takes it from `src/mir/__init__.py`
111
+ (`[tool.hatch.version]`), `docs/conf.py` imports `mir.__version__`, and `publish.yml` validates
112
+ the release tag against the built wheel — one copy instead of three.
113
+ - **Corrected the documented gap-block ↔ Smith-Waterman relationship.** `mir.distances.junction`
114
+ claimed the two "agree for equal-length CDR3s and rank-correlate ≥0.99 on gapped pairs". Measured
115
+ over all 44 850 pairs of 300 bundled human-TRB prototypes: close pairs do agree exactly, but SW is
116
+ a *local* alignment, so distant pairs diverge (equal-length outliers 181 vs 77) and the overall
117
+ rank correlation is ρ≈0.78. The docstring now says that, and that the two are different coordinate
118
+ systems rather than interchangeable scales.
119
+ - **Tests**: the survival-scorer test is no longer marked `integration` (lifelines ships in
120
+ `[bench]`, which CI installs), lifting `mir.bench.eval` from 23% to 64% coverage in CI;
121
+ `test_set_encoder.py` skips instead of failing on a torch-free install, so the documented
122
+ `pytest tests/` is green again; the VDJdb loader test no longer depends on a gitignored fixture it
123
+ could never find in CI (`mir.bench.vdjdb` 44% → 100%); and `TCREmp`'s public coordinate knobs
124
+ (`metric="sqrt"`, custom `matrix=`, `alignment="sw"`, null rejection) have real tests instead of
125
+ assertions buried in a `__main__` block. Fast-tier coverage 64% → 66%.
126
+ - **CI gained an `optional-tiers` job** (`[dev,bench,ml]` + BioPython, `-m "not benchmark"`): all of
127
+ `mir.ml` — ~20% of the library, including both prototype-hash comparability guards — previously
128
+ ran in no CI configuration, because the only job installs `[dev,bench]` and every torch test skips.
129
+ - Docs corrections: the density `backend=` default is `"kdtree"` (all-core exact), not `"exact"`;
130
+ the `cv_cindex` snippets in the user guide and in `mir.explain` now match the real
131
+ `(durations, events, *, base, block)` signature (the old form scored every channel `NaN`);
132
+ `CodecBundle.load` is documented as *not* verifying (its `forward_encoder` does); `encoder.code`
133
+ vs `encoder.encode`; the `mir embed repertoires` flag list is complete; `SOURCES.md` records the
134
+ prototypes as `arda-real` (experimental) with `src/`-prefixed regenerate commands; the examples'
135
+ run lines point at `examples/`, not the old `notebooks/`.
136
+
137
+ ### Removed
138
+
139
+ - **`tqdm`** from the runtime dependencies — nothing in the package imports it.
140
+ - **`nbsphinx` / `nbsphinx-link` / `ipython`** from the `[docs]` extra, and the unreferenced
141
+ `docs/requirements.txt`: the docs have contained no notebooks since the examples became marimo
142
+ scripts. The Sphinx build stays zero-warning under `-W`.
143
+
144
+ ## 3.4.0 — 2026-07-18
145
+
146
+ Minor: a command-line interface, a uv-based dev setup, and a documentation overhaul. No public
147
+ Python API removed; one optional-dependency group split out.
148
+
149
+ ### Added
150
+
151
+ - **`mir` command-line interface** (`[project.scripts]`, also `python -m mir.cli`) — the two
152
+ embedding scales without writing Python:
153
+ - `mir embed clonotypes SAMPLE` → a per-clonotype TCREMP embedding table (`e0…`).
154
+ - `mir embed repertoires SAMPLE…` → one repertoire vector `Φ(S)` per sample **per chain** on one
155
+ shared basis (`phi0…`), with optional `--mmd` pairwise-distance output.
156
+
157
+ Inputs are any format `vdjtools.io` reads (AIRR/vdjtools/MiXCR/immunoSEQ/parquet); output is TSV
158
+ or Parquet. See `mir embed <cmd> -h`. Tests in `tests/test_cli.py`.
159
+ - **`mir.repertoire.correct_batch`** — Harmony-like cluster-aware batch correction on a stacked
160
+ sample×feature Φ matrix. Removes the batch offset *per soft cluster* (batch-diversity-penalised),
161
+ so a batch confounded with a biological cluster is corrected without erasing that biology; reduces
162
+ exactly to `mir.cohort.residualize` at `n_clusters=1` / `theta=0` (`prop:batch`).
163
+ - **`[ann]` optional-dependency group** for the approximate-NN density backend (`pynndescent`).
164
+
165
+ ### Changed
166
+
167
+ - **Development now uses a repo-local `.venv` via [uv](https://docs.astral.sh/uv/)** instead of
168
+ conda. `setup.sh` is rewritten (bash/zsh portable; `--dev-parents`, `--docs`, `--tests`); the
169
+ conda `environment.yml` is removed. Runtime is unchanged — still a pure-Python `py3-none-any` wheel.
170
+ - **`pynndescent` moved from `[bench]` to the new `[ann]` extra.** `[bench]` is now all pure-wheel
171
+ (no numba/llvmlite), so `pip install "mirpy-lib[bench]"` resolves cleanly on any Python. Users of
172
+ `density.neighbor_enrichment(backend="ann")` should install `"mirpy-lib[ann]"`.
173
+ - **`vdjtools>=3.0.0`** (was `>=2.3.0`).
174
+ - Documentation overhaul: a use-case-driven user guide, the two CLI commands documented, an
175
+ examples/notebooks page, `mir.cohort` and `mir.bench.eval` added to the API reference, a logo, and
176
+ the sample-embedding schematic + real depth-robustness figure. Zero-warning Sphinx build.
177
+ - **Repo layout** (no effect on the installed package): adopted the **src-layout** (`mir/` →
178
+ `src/mir/`); renamed `notebooks/` → `examples/`; and moved the working result/plan markdown out of
179
+ the repo root — `THEORY.md` to the manuscript repo, `BENCHMARKS.md` / `REPERTOIRE_{EMBEDDING,LESSONS}.md`
180
+ / `SQRT_D_MIGRATION.md` / `ROADMAP.md` to `2026-mirpy-analysis/benchmarks/`. Root keeps
181
+ README / CHANGELOG / CLAUDE / SOURCES.
182
+
183
+ ### Fixed
184
+
185
+ - `DEFAULT_GAP_POSITIONS = (3, 4, -4, -3)` had three independent definitions
186
+ (`distances/junction`, `embedding/tcremp`, `ml/bundle`); now defined once in `distances.junction`
187
+ and imported, so the coordinate constant cannot drift.
188
+ - `cohort.cluster_samples` docstring described itself; `AntigenMetric` and `mir.bench.eval` gained
189
+ the docstrings/module-map entries they were missing.
190
+
191
+ ## 3.3.0 — 2026-07-17
192
+
193
+ Minor: one new public parameter, nothing removed or changed.
194
+
195
+ ### Added
196
+
197
+ - **`fit_density_space(chunk_size=)`** — embed and project in batches so the full raw matrix is never
198
+ resident. Peak memory becomes `max(pca_fit_cap, chunk_size) × n_features` instead of
199
+ `len(df) × n_features`: measured **10.60 GB → 1.81 GB** at 450k pooled clonotypes, and flat in `N`
200
+ (vs linear), at no wall-clock cost. This is what makes whole-cohort density arms runnable on a
201
+ laptop — the 4.2M-clonotype pooled arm is ~51 GB raw and ~102 GB once `scaler.transform` upcasts to
202
+ float64. Chunking is bit-exact at the embedding level (`_embed` of a slice == the slice of
203
+ `_embed`); the projected coordinates agree to float noise (~1e-7 relative), since BLAS summation
204
+ order depends on batch shape.
205
+
206
+ ### Fixed
207
+
208
+ - `fit_density_space`'s `pca_fit_cap` docstring claimed it "lets whole repertoires be embedded without
209
+ a full-matrix PCA". It caps the **fit**, not the memory — both raw matrices were already
210
+ materialized before the PCA was fitted. Documented, and `chunk_size=` is the actual remedy.
211
+ - **`mir.__version__` was stale** — it read `3.1.1` on the published 3.2.0, because the release bump
212
+ moved `pyproject.toml` but not `mir/__init__.py`, and `publish.yml` only validates *pyproject ==
213
+ tag*. Both now read 3.3.0. (`__version__` is still hand-maintained; deriving it from
214
+ `importlib.metadata` would retire this failure mode for good.)
215
+ - `tests/assets/olga_humanTRB_1000.txt.gz` was a slice of the alphabetically sorted VDJdb TRB dump,
216
+ not OLGA output as its name and `SOURCES.md` claimed — so it was **not** an antigen-naive null (12%
217
+ of rows had a Hamming-1 neighbour vs 0.2% for real OLGA). Regenerated from `olga-generate_sequences`;
218
+ provenance and a byte-reproducible regenerate command recorded in `SOURCES.md`. No test was
219
+ invalidated (they use it only as a generic TRB junction pool), but external calibrations that treated
220
+ it as a synthetic negative control were comparing VDJdb against itself. Not shipped (tests are
221
+ excluded from the sdist); listed here because it invalidates results, not code.
222
+
223
+ ## 3.2.0 — 2026-07-17
224
+
225
+ Minor: one new public module, nothing removed or changed.
226
+
227
+ ### Added
228
+
229
+ - **`mir.explain`** (T7) — explainable readouts over any repertoire feature matrix.
230
+ `ChannelSpec` / `ChannelBuilder` / `stack_embeddings` attach the name→column map that `Φ.vector`
231
+ does not carry (`stack_embeddings` is exact: `X[i] == embs[i].vector`, names only, no transform);
232
+ `channel_report` ablates each named channel under a caller-supplied scorer (leave-one-in by
233
+ default; `mode="both"` adds the conditional half that exposes *redundant* channels — high `delta`,
234
+ `delta_out≈0`; optional row-permutation p-values); `channel_drivers` hops from a winning
235
+ **kernel-mean** channel to the clonotypes driving it via `class_witness`, and refuses channels with
236
+ no clonotype pre-image (a Hill number's "drivers" are a category error, not an open question).
237
+ Scorer-agnostic by design — the library never sees the labels and ships no scorers, so a Cox
238
+ C-index and a CV AUC both plug in. **No existing module changed.**
239
+
240
+ ## 3.1.1 — 2026-07-14
241
+
242
+ Maintenance re-release of 3.1.0 with no functional changes — 3.1.0 was withdrawn from PyPI, and
243
+ this version restores the package under a fresh, clean version. The API, coordinates, and behaviour
244
+ are identical to 3.1.0 (see below).
245
+
246
+ ## 3.1.0 — 2026-07-14
247
+
248
+ The Part-2 feature tier on top of the 3.0 consolidated embedding core: neural codecs, continuous-density
249
+ methods, and the sample-level (repertoire) embedding, all on arda-native coordinates.
250
+
251
+ ### Added
252
+ - **`mir.density`** (T6) — graph-free continuous-density TCRNET/ALICE: balloon adaptive-radius enrichment
253
+ (Poisson/binomial + BH q, water-level calibration), abundance-aware weighted mass, backends
254
+ `exact`(BallTree) / `kdtree`(multicore) / `ann`(pynndescent).
255
+ - **`mir.repertoire`** (T7) — sample-level embedding `Φ(S)` = RFF kernel mean ‖ coverage-standardized Hill ‖
256
+ second-moment Fisher; `mmd_distance`/`mmd_matrix` (now with **`unbiased=True`** diagonal-removed MMD²),
257
+ `hla_stratified_mmd`, `class_witness` motif finder.
258
+ - **`mir.ml`** (Part 2, `[ml]` extra) — forward/inverse/pgen/unified neural codecs + `CodecBundle`
259
+ (prototype-hash-verified shipping); learned repertoire track `set_encoder` (Set-Transformer/DeepRC).
260
+ - **Bench** — clustering `method=` (dbscan/hdbscan/optics); `bench.theory` T6 `tcrnet_convergence`,
261
+ `codec_losslessness`.
262
+ - **Repertoire benchmarks** — `experiments/benchmark_repertoire_{aging,depth,cmvhla,hla,yfv,spikein,
263
+ agediverge}.py` and the COVID cohort suite `{covidbatch,covidhla,covidstatus,covidpaired}.py`
264
+ (`airr_covid19`, local-first + HF fallback). Recorded baselines in `BENCHMARKS.md`.
265
+
266
+ ### Changed
267
+ - **Coordinate system re-pinned to arda-native** germline + real-repertoire prototypes (versioned; any model
268
+ trained on the old coords must be retrained).
269
+ - **`mir.ml` device selection** now **CUDA → MPS → CPU** (was MPS-only) with a `MIR_DEVICE` env override and
270
+ CUDA seeding — GPU support beyond Apple silicon.
271
+ - Documented all parallelism knobs (README "Performance & parallelism"): `TCREmp(threads=0)` all-core,
272
+ density `backend="kdtree"` multicore-exact, `cluster(n_jobs=…)`, BLAS env.
273
+
274
+ ### Fixed
275
+ - **Unbiased repertoire MMD** — the biased V-statistic's `1/n_eff` self-term inflated low-diversity samples and
276
+ faked a divergence signal; `unbiased=True` removes it. Aging-divergence re-evaluated at depth: real but
277
+ diversity-coupled, not an independent axis.
278
+
279
+ ### Notes
280
+ - New lessons for the theory appendix in `REPERTOIRE_LESSONS.md`; full findings in `THEORY.md` T7.
281
+ - Pure-Python `py3-none-any` wheel; native code comes from `seqtree`/`vdjtools` wheels.
282
+
283
+ ## 3.0.0 — greenfield v3 embedding core (unreleased)
284
+ - Prototype (TCREMP) embedding on `seqtree.gapblock` + baked arda germline distances; vdjtools reuse (no AIRR
285
+ data-model layer of its own); bench harness (VDJdb Table S1); theory scaffold (`THEORY.md` S1–S3, T1–T5).
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mirpy-lib
3
- Version: 3.4.0
3
+ Version: 3.6.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
@@ -24,7 +24,6 @@ Requires-Dist: polars
24
24
  Requires-Dist: scikit-learn
25
25
  Requires-Dist: scipy
26
26
  Requires-Dist: seqtree>=0.3.0
27
- Requires-Dist: tqdm
28
27
  Requires-Dist: vdjtools>=3.0.0
29
28
  Provides-Extra: ann
30
29
  Requires-Dist: pynndescent; extra == 'ann'
@@ -44,9 +43,6 @@ Requires-Dist: psutil>=5; extra == 'dev'
44
43
  Requires-Dist: pytest-cov; extra == 'dev'
45
44
  Requires-Dist: pytest>=8; extra == 'dev'
46
45
  Provides-Extra: docs
47
- Requires-Dist: ipython<10,>=8; extra == 'docs'
48
- Requires-Dist: nbsphinx-link<2,>=1.3; extra == 'docs'
49
- Requires-Dist: nbsphinx<1,>=0.9; extra == 'docs'
50
46
  Requires-Dist: pydata-sphinx-theme<1,>=0.15; extra == 'docs'
51
47
  Requires-Dist: sphinx<9,>=7.2; extra == 'docs'
52
48
  Provides-Extra: examples
@@ -108,12 +104,13 @@ df = pl.DataFrame({
108
104
  X = model.embed(df) # (2, 9000) float32 — 3 distances × 3000 prototypes
109
105
  ```
110
106
 
111
- Downstream (cluster antigen-specific TCRs):
107
+ Downstream (cluster antigen-specific TCRs — needs `[bench]` for the kneedle `eps`, and a real
108
+ set of clonotypes rather than the two above):
112
109
 
113
110
  ```python
114
111
  from mir.embedding.pca import pca_denoise
115
112
  from mir.bench.metrics import cluster, cluster_metrics
116
- labels = cluster(pca_denoise(X, n_components=50))
113
+ labels = cluster(pca_denoise(model.embed(vdjdb_df), n_components=50))
117
114
  ```
118
115
 
119
116
  Paired chains concatenate per-chain embeddings via `PairedTCREmp`. Input/output are AIRR polars
@@ -137,6 +134,11 @@ mir embed repertoires cohort/*.tsv.gz -o phi.tsv --mmd mmd.tsv
137
134
  prototype count, weight, Φ blocks, …). Sample id defaults to the filename stem; the locus is
138
135
  inferred per file (or restrict with `--locus`).
139
136
 
137
+ Both commands drop non-coding clonotypes (stop codon / legacy out-of-frame markers in
138
+ `junction_aa`) before embedding by default — pass `--no-filter-functional` to skip this. Without
139
+ it, a stop codon silently produces a numerically meaningless embedding and an out-of-frame `_`
140
+ marker crashes the run outright (neither is a valid amino acid).
141
+
140
142
  ## Recommended presets
141
143
 
142
144
  `TCREmp.from_defaults(species, locus)` uses the per-chain preset when `n_prototypes` is
@@ -167,6 +169,53 @@ Xc = pca_denoise(X, n_components=p.n_components) # clustering
167
169
  Xr = pca_denoise(X, n_components=p.n_components_recon) # codec reconstruction
168
170
  ```
169
171
 
172
+ ## Prototypes — which receptors, and how much do they matter?
173
+
174
+ Every embedding is *distances to prototypes*, so the prototype set **is** the coordinate system.
175
+ mirpy ships one per chain, and you get them without downloading anything:
176
+
177
+ | | |
178
+ |---|---|
179
+ | What | **10 000 real receptors** per chain — a uniform random sample (fixed `seed=42`) of unique, productive, germline-resolvable clonotypes from arda-annotated real repertoires |
180
+ | Why real | Model-generated junctions have degenerate lengths and embed measurably worse (negative self-prototype distance correlation); real repertoires give a tight, well-behaved manifold |
181
+ | The default | `replicate=0` — the first `n` rows. This is *the* set: every preset, bundled codec, and published number uses it. Don't change it unless you're deliberately testing sensitivity |
182
+ | Chains | human TRA/TRB/TRG/TRD/IGH/IGK/IGL, mouse TRA/TRB (`list_available_prototypes()`) |
183
+
184
+ **Is my result an artefact of which prototypes I drew?** Take a replicate. The file order is itself a
185
+ uniform shuffle, so each disjoint block of `n` rows is an independent draw from the same pool —
186
+ `n_replicates()` of them, **10 at `n=1000`**, 5 at `n=2000`:
187
+
188
+ ```python
189
+ from mir.embedding.prototypes import n_replicates
190
+ from mir.embedding.tcremp import TCREmp
191
+
192
+ scores = [my_metric(TCREmp.from_defaults("human", "TRB", 1000, replicate=r).embed(df))
193
+ for r in range(n_replicates("human", "TRB", 1000))] # 10 draws; spread = sensitivity
194
+ ```
195
+
196
+ Same from the shell: `mir embed clonotypes sample.tsv --n-prototypes 1000 --replicate 3`.
197
+
198
+ **How much *does* it matter?** Usually very little, and you can check for yourself —
199
+ `bench.theory.prototype_source_correlation(queries, protos_a, protos_b)` correlates the pairwise
200
+ junction-distance geometry under two prototype sets. Two independent draws, 400 held-out human-TRB
201
+ queries:
202
+
203
+ | prototypes `n` | 100 | 250 | 500 | 1000 (default) | 2000 |
204
+ |---|--:|--:|--:|--:|--:|
205
+ | R between two draws | 0.922 | 0.971 | 0.990 | 0.993 | **0.997** |
206
+
207
+ So the geometry is essentially draw-independent from `n≈500` up: at the default counts, *which*
208
+ prototypes you drew is not what your result rests on. Below `n≈250` it starts to be.
209
+
210
+ > Each replicate is a **different coordinate system**. Distances *within* one are comparable;
211
+ > distances *across* two are not. The prototype hash covers the replicate index, so codecs,
212
+ > `RepertoireSpace` and `DonorCohort` all refuse to mix them — compare summary statistics across
213
+ > replicates (AUC, F1, cluster counts), never raw embeddings. Sweeping `n_prototypes` instead is a
214
+ > *nested* comparison (draw `r=0` at `n=500` is a prefix of `n=1000`), which answers "how many do I
215
+ > need", not "does it matter which".
216
+
217
+ Provenance and the regenerate command are in [`SOURCES.md`](SOURCES.md).
218
+
170
219
  ## What's inside
171
220
 
172
221
  | Module | Purpose |
@@ -203,24 +252,32 @@ labels, mask = denoise_and_cluster(obs_emb, res) # noise-filter +
203
252
 
204
253
  Use a **biological control** as the background when you have one (e.g. pre- vs post-vaccination,
205
254
  patient vs healthy) — differential enrichment cancels generic public convergence and isolates the
206
- antigen-specific response. With no control, `generate_background(locus, n)` samples the vdjtools
207
- P_gen model (the ALICE regime); the "water level" of a naive repertoire is handled by the
208
- empirical-null calibration. The density benchmarks (YFV, ankylosing-spondylitis B27, TCRNET)
255
+ antigen-specific response. No control of your own? Pooled healthy-donor repertoires are one fetch
256
+ away (HF `isalgo/airr_control`, read with `vdjtools.io.read` see [`SOURCES.md`](SOURCES.md)).
257
+ Failing that, `generate_background(locus, n)` samples the vdjtools P_gen model (the ALICE regime);
258
+ the "water level" of a naive repertoire is handled by the empirical-null calibration. Pass
259
+ `source="arda"` there when your data is arda-annotated (same allele namespace as the prototypes),
260
+ and `species="mouse"` for mouse — both need a vdjtools shipping the bundled `arda` model set. The density benchmarks (YFV, ankylosing-spondylitis B27, TCRNET)
209
261
  live in the companion [`2026-mirpy-analysis`](https://github.com/antigenomics) repo.
210
262
 
211
- At whole-repertoire scale, pass `neighbor_enrichment(..., backend="kdtree")` (exact scipy cKDTree,
212
- 5–9× faster than the default BallTree) or `backend="ann"` (approximate pynndescent, ~30× faster
213
- past ~10⁵ clones, trading a small conservative undercount; `pip install "mirpy-lib[ann]"`).
263
+ The default backend is `"kdtree"` (exact scipy cKDTree, all cores). At whole-repertoire scale pass
264
+ `backend="ann"` (approximate pynndescent, ~30× faster past ~10⁵ clones, trading a small conservative
265
+ undercount; `pip install "mirpy-lib[ann]"`).
214
266
 
215
267
  ## Sample-level (repertoire) embedding (`mir.repertoire`)
216
268
 
217
269
  One fixed vector `Φ(S)` per **repertoire** — an order-invariant multiset of clonotypes with clone
218
270
  sizes — depth-robust into the low-coverage bulk-RNA-seq regime (Theory §T.7). `Φ(S)` sketches the
219
- empirical measure `ρ_S = Σ_σ w_σ δ_{φ(σ)}` (concave frequency weights, so one hyperexpanded clone
220
- can't dominate) in three blocks: an RFF **kernel mean** (depth-robust, codebook-free — no `K`, no
221
- clustering), a coverage-standardized **Hill diversity** profile, and a **second-moment** Fisher
222
- vector carrying clonotype co-occurrence (HLA-linked public structure). Repertoire distance is the
223
- **MMD** `‖Φ₁(S) − Φ₁(S')‖`.
271
+ empirical measure `ρ_S = Σ_σ w_σ δ_{φ(σ)}` in three blocks: an RFF **kernel mean** (depth-robust,
272
+ codebook-free no `K`, no clustering), a coverage-standardized **Hill diversity** profile, and a
273
+ **second-moment** Fisher vector carrying clonotype co-occurrence (HLA-linked public structure).
274
+ Repertoire distance is the **MMD** `‖Φ₁(S) Φ₁(S')‖`.
275
+
276
+ The per-clonotype weights `w_σ = g(a_σ)/Σ_τ g(a_τ)` come from a clone-size transform `g` (`weight=`
277
+ on `sample_embedding`/`fit_repertoire_space`/`mir embed repertoires --weight`): `"log2p1"` —
278
+ `g=log2(1+a)` — is the **default**, concave so one hyperexpanded clone can't dominate;
279
+ `"duplicate_count"` weights linearly by clone size (`g=a`); `"distinct"` ignores size entirely
280
+ (`g≡1`, presence only). `"log1p"` (natural log) and `"anscombe"` remain available.
224
281
 
225
282
  ```python
226
283
  from mir.repertoire import fit_repertoire_space, sample_embedding, mmd_matrix, class_witness
@@ -256,7 +313,7 @@ The self-contained theory notebooks run on bundled data:
256
313
  ```bash
257
314
  pip install "mirpy-lib[examples]"
258
315
  marimo edit examples/theory.py # supplementary S1–S3 (distance laws, D↔d, prototype robustness)
259
- marimo edit examples/quickstart.py # embed + cluster VDJdb antigens
316
+ marimo edit examples/quickstart.py # embed + cluster (epitope colours need a local VDJdb dump)
260
317
  ```
261
318
 
262
319
  The full benchmark suite (VDJdb Table S1, density, repertoire/TCGA) and result docs live in the
@@ -273,13 +330,14 @@ mirpy is CPU-parallel by default and uses the GPU for the neural codecs. Knobs,
273
330
  | Stage | Knob | Default | Notes |
274
331
  |---|---|---|---|
275
332
  | Embedding (junction distance) | `TCREmp(..., threads=N)` | `0` = **all cores** | The C++ `seqtree.gapblock` scorer; releases the GIL, ~530 M pairs/s @16 cores. `threads=1` for a serial run. |
276
- | Density kNN / balloon | `neighbor_enrichment(..., backend=…)` | `"exact"` (BallTree, **1 core**) | `backend="kdtree"` = exact scipy cKDTree, **all cores** (`workers=-1`), 5–9× faster; `backend="ann"` = pynndescent, auto all-core, ~30× at ≥1e5. Prefer `kdtree` for multicore exact. |
333
+ | Density kNN / balloon | `neighbor_enrichment(..., backend=…)` | `"kdtree"` (scipy cKDTree, **all cores**) | Exact and multithreaded (`workers=-1`), 5–9× faster than the BallTree baseline. `backend="ann"` = pynndescent, auto all-core, ~30× at ≥1e5; `backend="exact"` = the 1-core BallTree baseline, for reproducing older runs. |
277
334
  | Clustering | `cluster(..., n_jobs=-1)` | sklearn default (1) | forwarded to DBSCAN/OPTICS/HDBSCAN via `**kwargs`; parallelizes the neighbour search. |
278
335
  | BLAS (PCA, RFF, matmul) | `OMP_NUM_THREADS` / `OPENBLAS_NUM_THREADS` env | all cores | numpy/sklearn use the platform BLAS; cap via env if oversubscribed. |
279
336
  | Neural codecs (`mir.ml`) | `pick_device()` / `device=` / `MIR_DEVICE` env | **CUDA → MPS → CPU**, auto | every `train_*` / codec / bundle takes `device=`; e.g. `MIR_DEVICE=cuda:1` pins the second GPU. Torch-free paths (`density`, `repertoire`) never touch the GPU. |
280
337
 
281
- Rule of thumb: leave `threads=0` (all cores) for embedding; switch density to `backend="kdtree"`
282
- for exact multicore or `"ann"` at whole-repertoire scale; the GPU is used only by `mir.ml`.
338
+ Rule of thumb: leave `threads=0` (all cores) for embedding; leave density on the default
339
+ `backend="kdtree"` (exact, multicore) and switch to `"ann"` only at whole-repertoire scale; the GPU
340
+ is used only by `mir.ml`.
283
341
 
284
342
  ## Development
285
343
 
@@ -49,12 +49,13 @@ df = pl.DataFrame({
49
49
  X = model.embed(df) # (2, 9000) float32 — 3 distances × 3000 prototypes
50
50
  ```
51
51
 
52
- Downstream (cluster antigen-specific TCRs):
52
+ Downstream (cluster antigen-specific TCRs — needs `[bench]` for the kneedle `eps`, and a real
53
+ set of clonotypes rather than the two above):
53
54
 
54
55
  ```python
55
56
  from mir.embedding.pca import pca_denoise
56
57
  from mir.bench.metrics import cluster, cluster_metrics
57
- labels = cluster(pca_denoise(X, n_components=50))
58
+ labels = cluster(pca_denoise(model.embed(vdjdb_df), n_components=50))
58
59
  ```
59
60
 
60
61
  Paired chains concatenate per-chain embeddings via `PairedTCREmp`. Input/output are AIRR polars
@@ -78,6 +79,11 @@ mir embed repertoires cohort/*.tsv.gz -o phi.tsv --mmd mmd.tsv
78
79
  prototype count, weight, Φ blocks, …). Sample id defaults to the filename stem; the locus is
79
80
  inferred per file (or restrict with `--locus`).
80
81
 
82
+ Both commands drop non-coding clonotypes (stop codon / legacy out-of-frame markers in
83
+ `junction_aa`) before embedding by default — pass `--no-filter-functional` to skip this. Without
84
+ it, a stop codon silently produces a numerically meaningless embedding and an out-of-frame `_`
85
+ marker crashes the run outright (neither is a valid amino acid).
86
+
81
87
  ## Recommended presets
82
88
 
83
89
  `TCREmp.from_defaults(species, locus)` uses the per-chain preset when `n_prototypes` is
@@ -108,6 +114,53 @@ Xc = pca_denoise(X, n_components=p.n_components) # clustering
108
114
  Xr = pca_denoise(X, n_components=p.n_components_recon) # codec reconstruction
109
115
  ```
110
116
 
117
+ ## Prototypes — which receptors, and how much do they matter?
118
+
119
+ Every embedding is *distances to prototypes*, so the prototype set **is** the coordinate system.
120
+ mirpy ships one per chain, and you get them without downloading anything:
121
+
122
+ | | |
123
+ |---|---|
124
+ | What | **10 000 real receptors** per chain — a uniform random sample (fixed `seed=42`) of unique, productive, germline-resolvable clonotypes from arda-annotated real repertoires |
125
+ | Why real | Model-generated junctions have degenerate lengths and embed measurably worse (negative self-prototype distance correlation); real repertoires give a tight, well-behaved manifold |
126
+ | The default | `replicate=0` — the first `n` rows. This is *the* set: every preset, bundled codec, and published number uses it. Don't change it unless you're deliberately testing sensitivity |
127
+ | Chains | human TRA/TRB/TRG/TRD/IGH/IGK/IGL, mouse TRA/TRB (`list_available_prototypes()`) |
128
+
129
+ **Is my result an artefact of which prototypes I drew?** Take a replicate. The file order is itself a
130
+ uniform shuffle, so each disjoint block of `n` rows is an independent draw from the same pool —
131
+ `n_replicates()` of them, **10 at `n=1000`**, 5 at `n=2000`:
132
+
133
+ ```python
134
+ from mir.embedding.prototypes import n_replicates
135
+ from mir.embedding.tcremp import TCREmp
136
+
137
+ scores = [my_metric(TCREmp.from_defaults("human", "TRB", 1000, replicate=r).embed(df))
138
+ for r in range(n_replicates("human", "TRB", 1000))] # 10 draws; spread = sensitivity
139
+ ```
140
+
141
+ Same from the shell: `mir embed clonotypes sample.tsv --n-prototypes 1000 --replicate 3`.
142
+
143
+ **How much *does* it matter?** Usually very little, and you can check for yourself —
144
+ `bench.theory.prototype_source_correlation(queries, protos_a, protos_b)` correlates the pairwise
145
+ junction-distance geometry under two prototype sets. Two independent draws, 400 held-out human-TRB
146
+ queries:
147
+
148
+ | prototypes `n` | 100 | 250 | 500 | 1000 (default) | 2000 |
149
+ |---|--:|--:|--:|--:|--:|
150
+ | R between two draws | 0.922 | 0.971 | 0.990 | 0.993 | **0.997** |
151
+
152
+ So the geometry is essentially draw-independent from `n≈500` up: at the default counts, *which*
153
+ prototypes you drew is not what your result rests on. Below `n≈250` it starts to be.
154
+
155
+ > Each replicate is a **different coordinate system**. Distances *within* one are comparable;
156
+ > distances *across* two are not. The prototype hash covers the replicate index, so codecs,
157
+ > `RepertoireSpace` and `DonorCohort` all refuse to mix them — compare summary statistics across
158
+ > replicates (AUC, F1, cluster counts), never raw embeddings. Sweeping `n_prototypes` instead is a
159
+ > *nested* comparison (draw `r=0` at `n=500` is a prefix of `n=1000`), which answers "how many do I
160
+ > need", not "does it matter which".
161
+
162
+ Provenance and the regenerate command are in [`SOURCES.md`](SOURCES.md).
163
+
111
164
  ## What's inside
112
165
 
113
166
  | Module | Purpose |
@@ -144,24 +197,32 @@ labels, mask = denoise_and_cluster(obs_emb, res) # noise-filter +
144
197
 
145
198
  Use a **biological control** as the background when you have one (e.g. pre- vs post-vaccination,
146
199
  patient vs healthy) — differential enrichment cancels generic public convergence and isolates the
147
- antigen-specific response. With no control, `generate_background(locus, n)` samples the vdjtools
148
- P_gen model (the ALICE regime); the "water level" of a naive repertoire is handled by the
149
- empirical-null calibration. The density benchmarks (YFV, ankylosing-spondylitis B27, TCRNET)
200
+ antigen-specific response. No control of your own? Pooled healthy-donor repertoires are one fetch
201
+ away (HF `isalgo/airr_control`, read with `vdjtools.io.read` see [`SOURCES.md`](SOURCES.md)).
202
+ Failing that, `generate_background(locus, n)` samples the vdjtools P_gen model (the ALICE regime);
203
+ the "water level" of a naive repertoire is handled by the empirical-null calibration. Pass
204
+ `source="arda"` there when your data is arda-annotated (same allele namespace as the prototypes),
205
+ and `species="mouse"` for mouse — both need a vdjtools shipping the bundled `arda` model set. The density benchmarks (YFV, ankylosing-spondylitis B27, TCRNET)
150
206
  live in the companion [`2026-mirpy-analysis`](https://github.com/antigenomics) repo.
151
207
 
152
- At whole-repertoire scale, pass `neighbor_enrichment(..., backend="kdtree")` (exact scipy cKDTree,
153
- 5–9× faster than the default BallTree) or `backend="ann"` (approximate pynndescent, ~30× faster
154
- past ~10⁵ clones, trading a small conservative undercount; `pip install "mirpy-lib[ann]"`).
208
+ The default backend is `"kdtree"` (exact scipy cKDTree, all cores). At whole-repertoire scale pass
209
+ `backend="ann"` (approximate pynndescent, ~30× faster past ~10⁵ clones, trading a small conservative
210
+ undercount; `pip install "mirpy-lib[ann]"`).
155
211
 
156
212
  ## Sample-level (repertoire) embedding (`mir.repertoire`)
157
213
 
158
214
  One fixed vector `Φ(S)` per **repertoire** — an order-invariant multiset of clonotypes with clone
159
215
  sizes — depth-robust into the low-coverage bulk-RNA-seq regime (Theory §T.7). `Φ(S)` sketches the
160
- empirical measure `ρ_S = Σ_σ w_σ δ_{φ(σ)}` (concave frequency weights, so one hyperexpanded clone
161
- can't dominate) in three blocks: an RFF **kernel mean** (depth-robust, codebook-free — no `K`, no
162
- clustering), a coverage-standardized **Hill diversity** profile, and a **second-moment** Fisher
163
- vector carrying clonotype co-occurrence (HLA-linked public structure). Repertoire distance is the
164
- **MMD** `‖Φ₁(S) − Φ₁(S')‖`.
216
+ empirical measure `ρ_S = Σ_σ w_σ δ_{φ(σ)}` in three blocks: an RFF **kernel mean** (depth-robust,
217
+ codebook-free no `K`, no clustering), a coverage-standardized **Hill diversity** profile, and a
218
+ **second-moment** Fisher vector carrying clonotype co-occurrence (HLA-linked public structure).
219
+ Repertoire distance is the **MMD** `‖Φ₁(S) Φ₁(S')‖`.
220
+
221
+ The per-clonotype weights `w_σ = g(a_σ)/Σ_τ g(a_τ)` come from a clone-size transform `g` (`weight=`
222
+ on `sample_embedding`/`fit_repertoire_space`/`mir embed repertoires --weight`): `"log2p1"` —
223
+ `g=log2(1+a)` — is the **default**, concave so one hyperexpanded clone can't dominate;
224
+ `"duplicate_count"` weights linearly by clone size (`g=a`); `"distinct"` ignores size entirely
225
+ (`g≡1`, presence only). `"log1p"` (natural log) and `"anscombe"` remain available.
165
226
 
166
227
  ```python
167
228
  from mir.repertoire import fit_repertoire_space, sample_embedding, mmd_matrix, class_witness
@@ -197,7 +258,7 @@ The self-contained theory notebooks run on bundled data:
197
258
  ```bash
198
259
  pip install "mirpy-lib[examples]"
199
260
  marimo edit examples/theory.py # supplementary S1–S3 (distance laws, D↔d, prototype robustness)
200
- marimo edit examples/quickstart.py # embed + cluster VDJdb antigens
261
+ marimo edit examples/quickstart.py # embed + cluster (epitope colours need a local VDJdb dump)
201
262
  ```
202
263
 
203
264
  The full benchmark suite (VDJdb Table S1, density, repertoire/TCGA) and result docs live in the
@@ -214,13 +275,14 @@ mirpy is CPU-parallel by default and uses the GPU for the neural codecs. Knobs,
214
275
  | Stage | Knob | Default | Notes |
215
276
  |---|---|---|---|
216
277
  | Embedding (junction distance) | `TCREmp(..., threads=N)` | `0` = **all cores** | The C++ `seqtree.gapblock` scorer; releases the GIL, ~530 M pairs/s @16 cores. `threads=1` for a serial run. |
217
- | Density kNN / balloon | `neighbor_enrichment(..., backend=…)` | `"exact"` (BallTree, **1 core**) | `backend="kdtree"` = exact scipy cKDTree, **all cores** (`workers=-1`), 5–9× faster; `backend="ann"` = pynndescent, auto all-core, ~30× at ≥1e5. Prefer `kdtree` for multicore exact. |
278
+ | Density kNN / balloon | `neighbor_enrichment(..., backend=…)` | `"kdtree"` (scipy cKDTree, **all cores**) | Exact and multithreaded (`workers=-1`), 5–9× faster than the BallTree baseline. `backend="ann"` = pynndescent, auto all-core, ~30× at ≥1e5; `backend="exact"` = the 1-core BallTree baseline, for reproducing older runs. |
218
279
  | Clustering | `cluster(..., n_jobs=-1)` | sklearn default (1) | forwarded to DBSCAN/OPTICS/HDBSCAN via `**kwargs`; parallelizes the neighbour search. |
219
280
  | BLAS (PCA, RFF, matmul) | `OMP_NUM_THREADS` / `OPENBLAS_NUM_THREADS` env | all cores | numpy/sklearn use the platform BLAS; cap via env if oversubscribed. |
220
281
  | Neural codecs (`mir.ml`) | `pick_device()` / `device=` / `MIR_DEVICE` env | **CUDA → MPS → CPU**, auto | every `train_*` / codec / bundle takes `device=`; e.g. `MIR_DEVICE=cuda:1` pins the second GPU. Torch-free paths (`density`, `repertoire`) never touch the GPU. |
221
282
 
222
- Rule of thumb: leave `threads=0` (all cores) for embedding; switch density to `backend="kdtree"`
223
- for exact multicore or `"ann"` at whole-repertoire scale; the GPU is used only by `mir.ml`.
283
+ Rule of thumb: leave `threads=0` (all cores) for embedding; leave density on the default
284
+ `backend="kdtree"` (exact, multicore) and switch to `"ann"` only at whole-repertoire scale; the GPU
285
+ is used only by `mir.ml`.
224
286
 
225
287
  ## Development
226
288