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.
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/CHANGELOG.md +93 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/PKG-INFO +3 -3
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/docs/usage.rst +1 -1
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/pyproject.toml +12 -2
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/skills/mirpy/SKILL.md +2 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/__init__.py +1 -1
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/distances/shm.py +107 -5
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/resources/prototypes/generate_prototypes.py +2 -1
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/signature/features.py +22 -13
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/.gitignore +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/LICENSE +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/README.md +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/assets/mirpy_dark.png +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/assets/mirpy_dark.svg +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/assets/mirpy_dark_bg.svg +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/assets/mirpy_light.png +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/assets/mirpy_light.svg +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/assets/mirpy_light_bg.svg +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/data/signature_reference_studies.tsv +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/docs/Makefile +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/docs/_ext/themed_graphviz.py +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/docs/_static/custom.css +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/docs/_static/depth_robustness.png +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/docs/_static/mirpy_dark.png +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/docs/_static/mirpy_dark.svg +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/docs/_static/mirpy_light.png +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/docs/_static/mirpy_light.svg +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/docs/_static/sample_embedding_diagram.png +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/docs/_templates/site-nav.html +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/docs/api.rst +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/docs/channels.rst +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/docs/cli.rst +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/docs/conf.py +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/docs/getting-started.rst +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/docs/glossary.rst +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/docs/index.rst +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/docs/math.rst +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/docs/notebooks.rst +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/docs/preprocessing.rst +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/docs/signature.rst +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/setup.sh +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/aliases.py +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/alleles.py +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/bench/__init__.py +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/bench/eval.py +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/bench/metrics.py +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/bench/theory.py +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/bench/vdjdb.py +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/cli.py +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/cohort.py +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/density.py +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/distances/__init__.py +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/distances/germline.py +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/distances/junction.py +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/embedding/__init__.py +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/embedding/pca.py +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/embedding/presets.py +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/embedding/prototypes.py +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/embedding/tcremp.py +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/explain.py +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/generate.py +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/ml/__init__.py +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/ml/bundle.py +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/ml/codec.py +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/ml/decoder.py +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/ml/diffusion.py +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/ml/encoder.py +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/ml/set_encoder.py +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/ml/tokenize.py +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/ml/train.py +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/py.typed +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/repertoire.py +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/resources/__init__.py +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/resources/gene_library/README.md +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/resources/gene_library/__init__.py +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/resources/gene_library/build_gene_library.py +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/resources/gene_library/build_region_annotations.py +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/resources/gene_library/imgt_gene_library.txt +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/resources/gene_library/olga_gene_library.txt +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/resources/gene_library/region_annotations.txt +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/resources/germline_dist/build_germline_dist.py +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/resources/germline_dist/human_IGH.npz +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/resources/germline_dist/human_IGK.npz +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/resources/germline_dist/human_IGL.npz +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/resources/germline_dist/human_TRA.npz +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/resources/germline_dist/human_TRB.npz +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/resources/germline_dist/human_TRD.npz +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/resources/germline_dist/human_TRG.npz +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/resources/germline_dist/mouse_IGH.npz +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/resources/germline_dist/mouse_IGK.npz +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/resources/germline_dist/mouse_IGL.npz +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/resources/germline_dist/mouse_TRA.npz +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/resources/germline_dist/mouse_TRB.npz +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/resources/germline_dist/mouse_TRD.npz +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/resources/germline_dist/mouse_TRG.npz +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/resources/prototypes/.gitkeep +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/resources/prototypes/human_IGH.tsv +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/resources/prototypes/human_IGK.tsv +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/resources/prototypes/human_IGL.tsv +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/resources/prototypes/human_TRA.tsv +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/resources/prototypes/human_TRB.tsv +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/resources/prototypes/human_TRD.tsv +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/resources/prototypes/human_TRG.tsv +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/resources/prototypes/manifest.json +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/resources/prototypes/mouse_TRA.tsv +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/resources/prototypes/mouse_TRB.tsv +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/resources/signature/corpora.json +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/signature/__init__.py +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/signature/signature.py +0 -0
- {mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/track.py +0 -0
- {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
|
+
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
@@ -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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
|
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
|
|
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
|
|
426
|
+
from vdjtools.io.schema import strip_allele_values
|
|
422
427
|
|
|
423
428
|
if "c_call" not in df.columns:
|
|
424
429
|
return {}
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
|
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
|
{mirpy_lib-4.3.0 → mirpy_lib-4.4.0}/src/mir/resources/gene_library/build_region_annotations.py
RENAMED
|
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
|
|
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
|