mirpy-lib 4.3.0__tar.gz → 4.4.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 (111) hide show
  1. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/CHANGELOG.md +93 -0
  2. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/PKG-INFO +3 -3
  3. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/docs/usage.rst +1 -1
  4. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/pyproject.toml +12 -2
  5. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/skills/mirpy/SKILL.md +2 -0
  6. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/__init__.py +1 -1
  7. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/distances/shm.py +107 -5
  8. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/resources/prototypes/generate_prototypes.py +2 -1
  9. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/signature/features.py +22 -13
  10. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/.gitignore +0 -0
  11. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/LICENSE +0 -0
  12. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/README.md +0 -0
  13. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/assets/mirpy_dark.png +0 -0
  14. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/assets/mirpy_dark.svg +0 -0
  15. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/assets/mirpy_dark_bg.svg +0 -0
  16. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/assets/mirpy_light.png +0 -0
  17. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/assets/mirpy_light.svg +0 -0
  18. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/assets/mirpy_light_bg.svg +0 -0
  19. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/data/signature_reference_studies.tsv +0 -0
  20. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/docs/Makefile +0 -0
  21. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/docs/_ext/themed_graphviz.py +0 -0
  22. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/docs/_static/custom.css +0 -0
  23. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/docs/_static/depth_robustness.png +0 -0
  24. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/docs/_static/mirpy_dark.png +0 -0
  25. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/docs/_static/mirpy_dark.svg +0 -0
  26. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/docs/_static/mirpy_light.png +0 -0
  27. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/docs/_static/mirpy_light.svg +0 -0
  28. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/docs/_static/sample_embedding_diagram.png +0 -0
  29. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/docs/_templates/site-nav.html +0 -0
  30. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/docs/api.rst +0 -0
  31. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/docs/channels.rst +0 -0
  32. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/docs/cli.rst +0 -0
  33. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/docs/conf.py +0 -0
  34. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/docs/getting-started.rst +0 -0
  35. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/docs/glossary.rst +0 -0
  36. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/docs/index.rst +0 -0
  37. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/docs/math.rst +0 -0
  38. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/docs/notebooks.rst +0 -0
  39. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/docs/preprocessing.rst +0 -0
  40. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/docs/signature.rst +0 -0
  41. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/setup.sh +0 -0
  42. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/aliases.py +0 -0
  43. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/alleles.py +0 -0
  44. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/bench/__init__.py +0 -0
  45. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/bench/eval.py +0 -0
  46. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/bench/metrics.py +0 -0
  47. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/bench/theory.py +0 -0
  48. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/bench/vdjdb.py +0 -0
  49. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/cli.py +0 -0
  50. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/cohort.py +0 -0
  51. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/density.py +0 -0
  52. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/distances/__init__.py +0 -0
  53. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/distances/germline.py +0 -0
  54. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/distances/junction.py +0 -0
  55. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/embedding/__init__.py +0 -0
  56. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/embedding/pca.py +0 -0
  57. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/embedding/presets.py +0 -0
  58. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/embedding/prototypes.py +0 -0
  59. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/embedding/tcremp.py +0 -0
  60. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/explain.py +0 -0
  61. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/generate.py +0 -0
  62. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/ml/__init__.py +0 -0
  63. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/ml/bundle.py +0 -0
  64. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/ml/codec.py +0 -0
  65. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/ml/decoder.py +0 -0
  66. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/ml/diffusion.py +0 -0
  67. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/ml/encoder.py +0 -0
  68. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/ml/set_encoder.py +0 -0
  69. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/ml/tokenize.py +0 -0
  70. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/ml/train.py +0 -0
  71. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/py.typed +0 -0
  72. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/repertoire.py +0 -0
  73. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/resources/__init__.py +0 -0
  74. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/resources/gene_library/README.md +0 -0
  75. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/resources/gene_library/__init__.py +0 -0
  76. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/resources/gene_library/build_gene_library.py +0 -0
  77. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/resources/gene_library/build_region_annotations.py +0 -0
  78. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/resources/gene_library/imgt_gene_library.txt +0 -0
  79. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/resources/gene_library/olga_gene_library.txt +0 -0
  80. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/resources/gene_library/region_annotations.txt +0 -0
  81. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/resources/germline_dist/build_germline_dist.py +0 -0
  82. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/resources/germline_dist/human_IGH.npz +0 -0
  83. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/resources/germline_dist/human_IGK.npz +0 -0
  84. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/resources/germline_dist/human_IGL.npz +0 -0
  85. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/resources/germline_dist/human_TRA.npz +0 -0
  86. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/resources/germline_dist/human_TRB.npz +0 -0
  87. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/resources/germline_dist/human_TRD.npz +0 -0
  88. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/resources/germline_dist/human_TRG.npz +0 -0
  89. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/resources/germline_dist/mouse_IGH.npz +0 -0
  90. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/resources/germline_dist/mouse_IGK.npz +0 -0
  91. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/resources/germline_dist/mouse_IGL.npz +0 -0
  92. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/resources/germline_dist/mouse_TRA.npz +0 -0
  93. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/resources/germline_dist/mouse_TRB.npz +0 -0
  94. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/resources/germline_dist/mouse_TRD.npz +0 -0
  95. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/resources/germline_dist/mouse_TRG.npz +0 -0
  96. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/resources/prototypes/.gitkeep +0 -0
  97. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/resources/prototypes/human_IGH.tsv +0 -0
  98. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/resources/prototypes/human_IGK.tsv +0 -0
  99. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/resources/prototypes/human_IGL.tsv +0 -0
  100. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/resources/prototypes/human_TRA.tsv +0 -0
  101. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/resources/prototypes/human_TRB.tsv +0 -0
  102. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/resources/prototypes/human_TRD.tsv +0 -0
  103. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/resources/prototypes/human_TRG.tsv +0 -0
  104. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/resources/prototypes/manifest.json +0 -0
  105. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/resources/prototypes/mouse_TRA.tsv +0 -0
  106. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/resources/prototypes/mouse_TRB.tsv +0 -0
  107. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/resources/signature/corpora.json +0 -0
  108. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/signature/__init__.py +0 -0
  109. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/signature/signature.py +0 -0
  110. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/track.py +0 -0
  111. {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/twin.py +0 -0
@@ -3,6 +3,99 @@
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
+ ## 4.4.0 — 2026-09-29
7
+
8
+ Every module read for one pattern: Python iterating over *data* where a batched call, a polars
9
+ expression or a table gather does the same work. Requires vdjtools >= 4.6.0, which is a **hard
10
+ floor** — `strip_allele_values` does not exist before it.
11
+
12
+ mirpy is pure Python by design (hatchling, no extension), so "make it native" here means reaching
13
+ into seqtree's or vdjtools' C++ rather than adding a build. Both fixes below did the opposite of
14
+ what that suggests, and the measurements say why.
15
+
16
+ ### Fixed: `shm_penalty_batch` said "vectorised" and was a per-row loop
17
+
18
+ It called the scalar `shm_penalty` once per clonotype, which re-parsed the mutation string and
19
+ made **one seqtree `penalty` call per substitution** — so a BCR repertoire of 10^5 clonotypes
20
+ carrying ~15 mutations each made ~1.5 million calls to answer 676 distinct questions. It sits on
21
+ `TCREmp.embed`, so it ran for every frame carrying `v_mutations` or `v_identity`.
22
+
23
+ The parse is one polars pass and the penalties one gather from a `(26, 26)` table built once:
24
+
25
+ | cohort | was | now | gain |
26
+ |---|---|---|---|
27
+ | 10,000 x 15 mutations | 32.7 ms | 10.0 ms | 3.3x |
28
+ | 100,000 x 15 mutations | 324.3 ms | 83.2 ms | **3.9x** |
29
+ | 200,000 x 18 mutations | 748.2 ms | 210.0 ms | 3.6x |
30
+ | 200,000, identity only | 87.2 ms | 2.0 ms | **43.4x** |
31
+
32
+ `shm_penalty` stays the scalar reference and `tests/test_shm.py` asserts the two agree with `==`,
33
+ not a tolerance: every BLOSUM62 penalty is a small integer, so a float64 sum of them is exact
34
+ however it is ordered, and a tolerance would hide a mis-indexed table — the one bug this rewrite
35
+ could plausibly introduce. **This module had no tests at all**, which is the other half of why the
36
+ docstring and the code disagreed for as long as they did.
37
+
38
+ Three things the rewrite had to keep, each now pinned:
39
+
40
+ - **J, O and U are letters and are not in BLOSUM62.** The scalar path raised because seqtree
41
+ rejected the pair; a lookup table with a silent default would return a finite, plausible penalty
42
+ instead. `nan` marks them and both paths raise together.
43
+ - **A non-letter token is dropped in silence** (`23V`, `A23`, `AB`) because `parse_mutations`
44
+ requires the first and last characters to be letters. Telling that case from the one above is
45
+ the only reason the alpha check survives — and it now runs on the **null rows alone**, which is
46
+ worth 31.3 ms of a 113.5 ms pass, because a well-formed spec has none.
47
+ - **Truthiness of the raw value, never of its string form.** The scalar path branches on
48
+ `if mutations:`, so an int `0` takes the identity path while `3` does not. Casting to string
49
+ first would make `"0"` truthy and score that row from the wrong branch — plausibly, and with
50
+ nothing about the number to show it had.
51
+
52
+ ### Fixed: the isotype channels spent their time stripping allele suffixes, not matching bands
53
+
54
+ `isotype_masks` and `isotype_shares` each materialised the whole allele-stripped `c_call` column
55
+ into a Python list and ran one `in` test per clonotype per band. **The membership tests were not
56
+ the cost.** Profiled on 200,000 rows: `strip_allele` 100.7 ms, the three `is_in` tests 3.7 ms, the
57
+ `to_list()` 5.0 ms, the Python `in` tests 25.9 ms. Replacing only the comprehensions moved
58
+ 132.2 ms to 134.8 — nothing, which is how the real cause surfaced.
59
+
60
+ `strip_allele` is a polars *expression*, so it resolves the allele suffix once per row, and a
61
+ `c_call` column carries a few dozen distinct values however deep the repertoire is. vdjtools 4.6.0
62
+ adds `strip_allele_values`, which resolves it once per distinct call; both functions now share one
63
+ pass through it:
64
+
65
+ | clonotypes | `isotype_masks` | `isotype_shares` |
66
+ |---|---|---|
67
+ | 10,000 | 8.63 -> 1.20 ms (7.2x) | 7.66 -> 1.32 ms (5.8x) |
68
+ | 50,000 | 30.85 -> 2.29 ms (13.5x) | 32.80 -> 2.88 ms (11.4x) |
69
+ | 200,000 | 132.23 -> 6.10 ms (**21.7x**) | 124.89 -> 8.39 ms (**14.9x**) |
70
+
71
+ Output is identical on plain calls, nulls, comma ambiguity, surrounding whitespace, a single
72
+ occupied band, a band under the floor, and a frame with no `c_call`. `isotype_shares` takes its
73
+ masks from `isotype_masks` rather than from a second copy of the same comprehension — two copies
74
+ is how the two came to be able to disagree about a null.
75
+
76
+ ### Measured and deliberately left alone, with the numbers
77
+
78
+ So nobody re-derives this. Every other module iterates over **structure** — loci, samples,
79
+ donors, prototypes, PCs, channels, columns, epochs, minibatches — which is not the same thing as
80
+ iterating over data, and each step is a real numpy or torch operation.
81
+
82
+ | site | measured | why it stays |
83
+ |---|---|---|
84
+ | `signature.features.dispersion_pass` | chunked running sums, `(n, 3K)` never held | already the target shape: BLAS inside, linear in depth |
85
+ | `embedding.TCREmp.embed` | preallocated output, one batched call per block | polars-vectorised validation, seqtree threads released |
86
+ | `distances.germline.matrix` | resolves distinct alleles, `proto_idx` hoisted | previously audited; `np.unique` measured at 1.12x and rejected |
87
+ | `density` neighbour lists | flatten + one `bincount` | the ragged lists are scipy/pynndescent's return shape |
88
+ | `bench.metrics` purity map | 20.7 ms at 200,000 | one dict lookup per clonotype, once per benchmark |
89
+ | `bench.eval` kmer matrix fill | 467.9 ms at 900 samples x 8,000 kmers | over the **vocabulary**, and the 900 `kmer_profile` calls dominate |
90
+ | `bench.theory._hamming1_counts` | length-blocked around a batched `hamming_matrix` | already audited, and carries its own ceiling note |
91
+
92
+ **Two paths iterate per pair because no batch exists upstream**, not because nobody looked:
93
+ `distances.junction`'s `alignment="sw"` backend and `bench.theory.junction_dissimilarity_sw` both
94
+ call BioPython's `PairwiseAligner` per pair, which has no batched API. Both are opt-in validation
95
+ paths against the paper's exact Smith-Waterman; the default `gapblock` backend is one seqtree call
96
+ with the GIL released. `resources/*/build_*.py` keep their `iter_rows` and their O(n^2) alignments
97
+ because they are offline artifact builders run once.
98
+
6
99
  ## 4.3.0 — 2026-09-28
7
100
 
8
101
  The geometry half emits embedding **diversity**, not only Rao — and stops emitting a presence mask
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: mirpy-lib
3
- Version: 4.3.0
3
+ Version: 4.4.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,7 @@ Requires-Dist: polars
24
24
  Requires-Dist: scikit-learn
25
25
  Requires-Dist: scipy
26
26
  Requires-Dist: seqtree>=1.0.0
27
- Requires-Dist: vdjtools>=4.3.0
27
+ Requires-Dist: vdjtools>=4.6.0
28
28
  Provides-Extra: ann
29
29
  Requires-Dist: numba>=0.60; extra == 'ann'
30
30
  Requires-Dist: pynndescent; extra == 'ann'
@@ -38,7 +38,7 @@ Requires-Dist: matplotlib; extra == 'bench'
38
38
  Requires-Dist: seaborn; extra == 'bench'
39
39
  Provides-Extra: build
40
40
  Requires-Dist: biopython; extra == 'build'
41
- Requires-Dist: vdjtools[model]>=4.2.0; extra == 'build'
41
+ Requires-Dist: vdjtools[model]>=4.6.0; extra == 'build'
42
42
  Provides-Extra: dev
43
43
  Requires-Dist: psutil>=5; extra == 'dev'
44
44
  Requires-Dist: pytest-cov; extra == 'dev'
@@ -16,7 +16,7 @@ Install & the data model
16
16
  pip install "mirpy-lib[ml]" # + neural codecs (torch)
17
17
  pip install "mirpy-lib[examples]" # + marimo notebooks (marimo, matplotlib, umap-learn)
18
18
 
19
- Requires ``vdjtools>=4.3.0`` and ``seqtree>=1.0.0``, both installed for you; ``mir`` itself is a
19
+ Requires ``vdjtools>=4.6.0`` and ``seqtree>=1.0.0``, both installed for you; ``mir`` itself is a
20
20
  pure-Python ``py3-none-any`` wheel. A clonotype table is a polars frame; clone sizes live in
21
21
  ``duplicate_count`` (repertoire-level embeddings weight by them):
22
22
 
@@ -36,7 +36,7 @@ dependencies = [
36
36
  # `vdjtools.signature.corpus`, which does not exist before 4.0.0, and 4.0.0 never
37
37
  # reached PyPI. On the old `>=3.18.0` pip would have resolved vdjtools 3.18.1 beside
38
38
  # mirpy 4.0.0 and every signature entry point would have failed on import.
39
- "vdjtools>=4.3.0",
39
+ "vdjtools>=4.6.0",
40
40
  ]
41
41
  license = { text = "GPL-3.0-or-later" }
42
42
 
@@ -46,7 +46,7 @@ annotate = ["vdjmatch"]
46
46
  # Build-time only: regenerate region_annotations.txt (arda) and the baked
47
47
  # germline distance matrices (BioPython). Runtime needs neither. arda comes
48
48
  # via vdjtools[model] (single arda version across the ecosystem), not pinned directly.
49
- build = ["vdjtools[model]>=4.2.0", "biopython"]
49
+ build = ["vdjtools[model]>=4.6.0", "biopython"]
50
50
  # Part 2: neural codecs and density methods.
51
51
  ml = ["torch"]
52
52
  # Benchmark harness: DBSCAN eps selection + plotting + HuggingFace dataset fetch +
@@ -95,7 +95,17 @@ exclude = [
95
95
  "tests/**",
96
96
  "tmp/**",
97
97
  "*.log",
98
+ # Working notes: internal by design (cluster paths, private dataset names, cohort detail).
99
+ # .gitignore is what actually keeps them out -- hatchling builds from the VCS file list -- and
100
+ # these are the second line, for a tree where an untracked copy is sitting on disk.
98
101
  "SOURCES.md",
102
+ "CLAUDE.md",
103
+ "TODO.md",
104
+ "NOTES.md",
105
+ "ROADMAP.md",
106
+ "STATUS.md",
107
+ "ISSUES.md",
108
+ "ISSUES_ext.md",
99
109
  ]
100
110
 
101
111
  [tool.ruff]
@@ -149,6 +149,8 @@ with phenotype.
149
149
 
150
150
  ### `mir.signature` — the portable signature, geometry half
151
151
 
152
+ **4.4.0 — the per-item-loop audit.** No API change. `shm_penalty_batch` was a per-row loop behind a docstring that said "vectorised" (3.3x-3.9x on the mutation path, 43.4x on the identity path), and the isotype channels spent 76% of their time in `strip_allele` rather than matching bands (`isotype_masks` 21.7x, `isotype_shares` 14.9x at 200,000 clonotypes). Requires **vdjtools >= 4.6.0** as a hard floor: `strip_allele_values` does not exist before it. Measurements and the seven sites deliberately left alone are in CLAUDE.md.
153
+
152
154
  **4.3.0 — the `div`/`disp`/`mask` channel families.** `rsig` emits embedding diversity, not only
153
155
  Rao: `q_v`/`q_j`/`q_c` (Rao per `Phi` stride — **`[0::3]`, `[1::3]`, `[2::3]`, never `[0:K]` blocks**;
154
156
  reading them as blocks is the likeliest single bug here and produces plausible numbers, caught only
@@ -15,7 +15,7 @@ from __future__ import annotations
15
15
 
16
16
  import os
17
17
 
18
- __version__ = "4.3.0"
18
+ __version__ = "4.4.0"
19
19
 
20
20
  __all__ = ["__version__", "get_resource_path", "TCREmp", "PairedTCREmp"]
21
21
 
@@ -61,6 +61,7 @@ from dataclasses import dataclass
61
61
  from functools import lru_cache
62
62
 
63
63
  import numpy as np
64
+ import polars as pl
64
65
 
65
66
  #: Typical IMGT V-region length in amino acids, used to turn a scalar identity into a mutation
66
67
  #: count. Real V regions run ~95-100 aa; the constant only sets the scale of the scalar path and
@@ -154,6 +155,33 @@ def shm_penalty(mutations: str | None = None, *, identity: float | None = None,
154
155
  return float(max(1.0 - identity, 0.0) * v_length * lam)
155
156
 
156
157
 
158
+ @lru_cache(maxsize=1)
159
+ def _penalty_table() -> np.ndarray:
160
+ """The whole BLOSUM62 penalty domain as a ``(26, 26)`` table, indexed by ``ord(aa) - 65``.
161
+
162
+ :func:`substitution_penalty` is a seqtree call per substitution, and the batch path made one
163
+ per mutation per clonotype: a real BCR repertoire carries ~15 mutations on each of ~10^5
164
+ clonotypes, so ~1.5 million calls to answer 676 distinct questions. ``nan`` marks the
165
+ upper-case codes BLOSUM62 does not define (J, O and U, and no others), so an unsupported
166
+ residue stays *detectable* rather than silently scoring 0 — the scalar path raises on
167
+ those, so this one must too.
168
+ """
169
+ m = _matrix()
170
+ t = np.full((26, 26), np.nan)
171
+ for i in range(26):
172
+ for j in range(26):
173
+ try:
174
+ t[i, j] = m.penalty(chr(65 + i), chr(65 + j))
175
+ except ValueError:
176
+ pass # BLOSUM62 does not define this pair
177
+ return t
178
+
179
+
180
+ #: Upper-case residue to its row in :func:`_penalty_table`, built from the table's own index so
181
+ #: the two cannot disagree about what is defined.
182
+ _AA_INDEX = {chr(65 + i): i for i in range(26)}
183
+
184
+
157
185
  def shm_penalty_batch(mutations=None, identity=None, *, v_length: int = V_REGION_AA,
158
186
  lambda_scalar: float | None = None) -> np.ndarray:
159
187
  """Vectorised :func:`shm_penalty` over a batch of clonotypes.
@@ -161,17 +189,91 @@ def shm_penalty_batch(mutations=None, identity=None, *, v_length: int = V_REGION
161
189
  Either argument may be ``None``; per row, a mutation spec wins over an identity. Rows with
162
190
  neither are ``nan``, which :class:`MutatedGermlineDistances` turns into "no SHM adjustment"
163
191
  rather than into a distance — see there for why those are different.
192
+
193
+ This was a ``for`` loop calling :func:`shm_penalty` per row while its own first line said
194
+ "vectorised", which put a string parse and one seqtree call per substitution on the embedding
195
+ path of every frame carrying mutation evidence: **3.10 microseconds per clonotype** at 15
196
+ mutations, 309.9 ms for 100,000. The parse is one polars pass now and the penalties one
197
+ gather from :func:`_penalty_table`. :func:`shm_penalty` stays the scalar reference, and
198
+ ``test_shm.py`` asserts the two agree — exactly, not within a tolerance, because every
199
+ penalty is a small integer and a float64 sum of those is exact however it is ordered.
164
200
  """
165
201
  n = len(mutations) if mutations is not None else len(identity)
166
- out = np.empty(n, dtype=np.float64)
167
202
  lam = mean_shm_penalty() if lambda_scalar is None else lambda_scalar
168
- for i in range(n):
169
- m = mutations[i] if mutations is not None else None
170
- d = identity[i] if identity is not None else None
171
- out[i] = shm_penalty(m, identity=d, v_length=v_length, lambda_scalar=lam)
203
+ if mutations is not None and identity is not None and len(identity) != n:
204
+ raise ValueError(f"mutations has {n} entries and identity {len(identity)}")
205
+ out = np.full(n, np.nan, dtype=np.float64)
206
+
207
+ if identity is not None:
208
+ # `pl.Series` turns a None into a null and `to_numpy` a null into nan, which is the same
209
+ # answer the scalar path's `identity is None or not np.isfinite(identity)` gives.
210
+ idt = pl.Series(values=list(identity), dtype=pl.Float64, strict=False).to_numpy()
211
+ ok = np.isfinite(idt)
212
+ out[ok] = np.maximum(1.0 - idt[ok], 0.0) * v_length * lam
213
+
214
+ if mutations is not None:
215
+ # Truthiness of the RAW value, never of its string form: the scalar path branches on
216
+ # `if mutations:`, so an int 0 takes the identity path while an int 3 does not. Casting
217
+ # to string first would make "0" truthy and score that row from the wrong branch —
218
+ # plausibly, and with nothing about the number to show it had. One `bool()` per row is
219
+ # ~0.03 us against the 3.10 us this function used to spend on each of them.
220
+ has = np.fromiter((bool(m) for m in mutations), dtype=bool, count=n)
221
+ if has.any():
222
+ out[has] = _spec_penalties([m for m, h in zip(mutations, has) if h])
172
223
  return out
173
224
 
174
225
 
226
+ def _spec_penalties(specs: list) -> np.ndarray:
227
+ """Summed BLOSUM62 penalty per mutation spec, for specs already known to be truthy.
228
+
229
+ Reproduces :func:`parse_mutations` as one polars pass: separators normalised, split, trimmed,
230
+ tokens under three characters dropped, then the first and last characters upper-cased and
231
+ required to be letters. A token that survives all that while naming a residue BLOSUM62 does
232
+ not define raises, exactly as the per-row path did when seqtree rejected it.
233
+ """
234
+ n = len(specs)
235
+ tok = (
236
+ pl.DataFrame({"s": pl.Series(values=specs, strict=False).cast(pl.String, strict=False)})
237
+ .with_row_index("row")
238
+ .with_columns(pl.col("s").str.replace_all("[; ]", ",").str.split(",").alias("t"))
239
+ .drop("s")
240
+ # `empty_as_null` is pinned, not inherited: polars 2.0 flips the default to False,
241
+ # and an empty token between two separators ("A23V,,S31N") is exactly what this
242
+ # parser meets. Either value is dropped by the length filter below, so pinning it
243
+ # keeps that true across the version change rather than relying on it.
244
+ .explode("t", empty_as_null=True)
245
+ .with_columns(pl.col("t").str.strip_chars())
246
+ .filter(pl.col("t").str.len_chars() >= 3)
247
+ .with_columns(a=pl.col("t").str.slice(0, 1).str.to_uppercase(),
248
+ b=pl.col("t").str.slice(-1, 1).str.to_uppercase())
249
+ .with_columns(
250
+ ai=pl.col("a").replace_strict(_AA_INDEX, default=None, return_dtype=pl.Int64),
251
+ bi=pl.col("b").replace_strict(_AA_INDEX, default=None, return_dtype=pl.Int64))
252
+ )
253
+ if tok.height == 0:
254
+ return np.zeros(n, dtype=np.float64)
255
+ # A null index is either a first/last character that is not a letter, which
256
+ # `parse_mutations` skips in silence, or a letter outside A-Z, which seqtree rejected. Only
257
+ # the second is an error, and telling them apart needs `str.isalpha()`'s own domain
258
+ # (`\p{L}`) — so that regex runs on the null rows ALONE. It was 31.3 ms of a
259
+ # 113.5 ms pass when applied to all 1.5M tokens, and a well-formed spec has no null rows at
260
+ # all.
261
+ null = pl.col("ai").is_null() | pl.col("bi").is_null()
262
+ undefined = tok.filter(null).filter(
263
+ pl.col("a").str.contains(r"^\p{L}$") & pl.col("b").str.contains(r"^\p{L}$"))
264
+ if undefined.height == 0:
265
+ tok = tok.filter(~null) if tok.select(null.any()).item() else tok
266
+ pen = _penalty_table()[tok["ai"].to_numpy(), tok["bi"].to_numpy()]
267
+ if np.isfinite(pen).all():
268
+ return np.bincount(tok["row"].to_numpy(), weights=pen, minlength=n)
269
+ undefined = tok.filter(~np.isfinite(pen))
270
+ raise ValueError(
271
+ f"mutation spec names a residue BLOSUM62 does not define, e.g. "
272
+ f"{undefined['t'].unique().head(5).to_list()}. The defined upper-case codes are "
273
+ f"ABCDEFGHIKLMNPQRSTVWXYZ; J, O and U are not among them."
274
+ )
275
+
276
+
175
277
  @dataclass
176
278
  class MutatedGermlineDistances:
177
279
  """A :class:`~mir.distances.germline.GermlineDistances` that can see somatic mutation.
@@ -3,7 +3,8 @@
3
3
 
4
4
  Populates ``mir/resources/prototypes/`` with one TSV per (species, locus): ``N_PROTOTYPES`` rows
5
5
  of ``v_call, j_call, junction_aa`` sampled from **productive real reads annotated by arda**
6
- (``vdjtools.model.data.prepare(..., "functional")`` over the ``isalgo/airr_model_read`` FASTQs →
6
+ (``vdjtools.model.data.prepare(..., "functional")`` over the FASTQs of the **private**
7
+ HuggingFace dataset ``isalgo/airr_model_read`` →
7
8
  ``arda rnaseq map``). This gives arda IMGT allele names (one coordinate frame with arda-annotated
8
9
  query data → no germline-distance fallback) **and** the real CDR3 junction manifold, which embeds
9
10
  far better than synthetic P_gen junctions: EM-learned generative models produce degenerate
@@ -416,15 +416,26 @@ def isotype_masks(df: pl.DataFrame) -> dict[str, np.ndarray]:
416
416
 
417
417
  Allele-stripped for the same reason :func:`isotype_shares` strips: the classes are gene names
418
418
  matched by equality, so ``IGHG1*01`` would match nothing and every read would come back
419
- uncalled -- a composition rather than an error.
419
+ uncalled — a composition rather than an error.
420
+
421
+ One polars pass, three `is_in` expressions. It used to `to_list()` the whole column and run a
422
+ Python `in` test per clonotype per band: 8.63 ms at 10,000 clonotypes and **132.2 ms at
423
+ 200,000**, to answer three questions about eight gene names. A null `c_call` is `False` in
424
+ every band, as `None in names` was.
420
425
  """
421
- from vdjtools.io.schema import strip_allele
426
+ from vdjtools.io.schema import strip_allele_values
422
427
 
423
428
  if "c_call" not in df.columns:
424
429
  return {}
425
- calls = df.select(strip_allele(pl.col("c_call").cast(pl.Utf8)).alias("c"))["c"].to_list()
426
- return {name: np.array([c in names for c in calls], dtype=bool)
427
- for name, names in ISOTYPE_BANDS.items()}
430
+ # `strip_allele_values` resolves the allele suffix once per DISTINCT call rather than once
431
+ # per row, which is where this function's time actually went: on 200,000 rows the three
432
+ # `is_in` tests are 3.7 ms and the stripping was 100.7 ms. A c_call column carries a few
433
+ # dozen distinct values however deep the repertoire is.
434
+ c = strip_allele_values(df["c_call"])
435
+ got = pl.DataFrame({"c": c}).select(
436
+ [pl.col("c").is_in(names).fill_null(False).alias(name)
437
+ for name, names in ISOTYPE_BANDS.items()])
438
+ return {name: got[name].to_numpy() for name in ISOTYPE_BANDS}
428
439
 
429
440
 
430
441
  def isotype_shares(df: pl.DataFrame, w: np.ndarray, *,
@@ -433,18 +444,16 @@ def isotype_shares(df: pl.DataFrame, w: np.ndarray, *,
433
444
 
434
445
  A *share of the geometry*, which is a different quantity from the read fraction the statistics
435
446
  half reports. The signature carries both rather than picking the flattering one.
436
- """
437
- from vdjtools.io.schema import strip_allele
438
447
 
439
- if "c_call" not in df.columns:
448
+ The masks come from :func:`isotype_masks` rather than from a second copy of the same
449
+ allele-stripping comprehension — two copies is how the two came to disagree about a null.
450
+ """
451
+ masks = isotype_masks(df)
452
+ if not masks:
440
453
  return {}
441
- # Allele-stripped: the classes are gene names matched by equality, so ``IGHG1*01`` would match
442
- # nothing and the whole repertoire would come back uncalled -- a composition, not an error.
443
- calls = df.select(strip_allele(pl.col("c_call").cast(pl.Utf8)).alias("c"))["c"].to_list()
444
454
  out: dict[str, float] = {}
445
455
  claimed = 0.0
446
- for name, names in ISOTYPE_BANDS.items():
447
- m = np.array([c in names for c in calls], dtype=bool)
456
+ for name, m in masks.items():
448
457
  if int(m.sum()) < min_clonotypes:
449
458
  continue
450
459
  out[name] = float(w[m].sum())
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes