mirpy-lib 3.1.0__py3-none-any.whl
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.
- mir/__init__.py +52 -0
- mir/aliases.py +62 -0
- mir/alleles.py +56 -0
- mir/bench/__init__.py +22 -0
- mir/bench/metrics.py +118 -0
- mir/bench/theory.py +352 -0
- mir/bench/vdjdb.py +46 -0
- mir/density.py +603 -0
- mir/distances/__init__.py +5 -0
- mir/distances/germline.py +142 -0
- mir/distances/junction.py +111 -0
- mir/embedding/__init__.py +21 -0
- mir/embedding/pca.py +48 -0
- mir/embedding/presets.py +72 -0
- mir/embedding/prototypes.py +108 -0
- mir/embedding/tcremp.py +270 -0
- mir/ml/__init__.py +64 -0
- mir/ml/bundle.py +97 -0
- mir/ml/codec.py +140 -0
- mir/ml/decoder.py +64 -0
- mir/ml/encoder.py +56 -0
- mir/ml/set_encoder.py +220 -0
- mir/ml/tokenize.py +68 -0
- mir/ml/train.py +386 -0
- mir/py.typed +0 -0
- mir/repertoire.py +447 -0
- mir/resources/__init__.py +1 -0
- mir/resources/gene_library/README.md +79 -0
- mir/resources/gene_library/__init__.py +1 -0
- mir/resources/gene_library/build_gene_library.py +550 -0
- mir/resources/gene_library/build_region_annotations.py +94 -0
- mir/resources/gene_library/imgt_gene_library.txt +2775 -0
- mir/resources/gene_library/olga_gene_library.txt +880 -0
- mir/resources/gene_library/region_annotations.txt +2110 -0
- mir/resources/germline_dist/build_germline_dist.py +120 -0
- mir/resources/germline_dist/human_IGH.npz +0 -0
- mir/resources/germline_dist/human_IGK.npz +0 -0
- mir/resources/germline_dist/human_IGL.npz +0 -0
- mir/resources/germline_dist/human_TRA.npz +0 -0
- mir/resources/germline_dist/human_TRB.npz +0 -0
- mir/resources/germline_dist/human_TRD.npz +0 -0
- mir/resources/germline_dist/human_TRG.npz +0 -0
- mir/resources/germline_dist/mouse_IGH.npz +0 -0
- mir/resources/germline_dist/mouse_IGK.npz +0 -0
- mir/resources/germline_dist/mouse_IGL.npz +0 -0
- mir/resources/germline_dist/mouse_TRA.npz +0 -0
- mir/resources/germline_dist/mouse_TRB.npz +0 -0
- mir/resources/germline_dist/mouse_TRD.npz +0 -0
- mir/resources/germline_dist/mouse_TRG.npz +0 -0
- mir/resources/prototypes/.gitkeep +0 -0
- mir/resources/prototypes/generate_prototypes.py +109 -0
- mir/resources/prototypes/human_IGH.tsv +10001 -0
- mir/resources/prototypes/human_IGK.tsv +10001 -0
- mir/resources/prototypes/human_IGL.tsv +10001 -0
- mir/resources/prototypes/human_TRA.tsv +10001 -0
- mir/resources/prototypes/human_TRB.tsv +10001 -0
- mir/resources/prototypes/human_TRD.tsv +10001 -0
- mir/resources/prototypes/human_TRG.tsv +10001 -0
- mir/resources/prototypes/manifest.json +74 -0
- mir/resources/prototypes/mouse_TRA.tsv +10001 -0
- mir/resources/prototypes/mouse_TRB.tsv +10001 -0
- mirpy_lib-3.1.0.dist-info/METADATA +210 -0
- mirpy_lib-3.1.0.dist-info/RECORD +65 -0
- mirpy_lib-3.1.0.dist-info/WHEEL +4 -0
- mirpy_lib-3.1.0.dist-info/licenses/LICENSE +674 -0
mir/__init__.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""mirpy — ML-oriented embeddings for immune receptor repertoires (TCR/BCR).
|
|
2
|
+
|
|
3
|
+
Version 3 is a slim, embedding-focused rewrite. Heavy machinery is delegated to
|
|
4
|
+
sibling packages rather than duplicated:
|
|
5
|
+
|
|
6
|
+
* alignment → ``seqtree`` (``seqtree.gapblock``),
|
|
7
|
+
* VDJ-rearrangement / Pgen model + sampling → ``vdjtools.model`` (core dependency),
|
|
8
|
+
* VDJdb annotation / E-values → ``vdjmatch`` (extra ``[annotate]``),
|
|
9
|
+
* build-time germline region annotation → ``arda`` (extra ``[build]``).
|
|
10
|
+
|
|
11
|
+
The classical repertoire toolkit lives on the ``legacy-v2`` branch (``mirpy-lib`` 2.x).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
|
|
18
|
+
__version__ = "3.1.0"
|
|
19
|
+
|
|
20
|
+
__all__ = ["__version__", "get_resource_path", "TCREmp", "PairedTCREmp"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_resource_path(name: str | None = None):
|
|
24
|
+
"""Return the absolute path to a bundled resource under ``mir/resources``.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
name: Resource file or directory name. When ``None``, return the sorted
|
|
28
|
+
list of top-level resource names instead of a path.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
The absolute path as a string, or the sorted list of resource names
|
|
32
|
+
when ``name`` is ``None``.
|
|
33
|
+
|
|
34
|
+
Raises:
|
|
35
|
+
FileNotFoundError: If ``name`` does not resolve to a bundled resource.
|
|
36
|
+
"""
|
|
37
|
+
resources = os.path.join(os.path.dirname(os.path.realpath(__file__)), "resources")
|
|
38
|
+
if name is None:
|
|
39
|
+
return sorted(os.listdir(resources))
|
|
40
|
+
path = os.path.join(resources, name)
|
|
41
|
+
if not os.path.exists(path):
|
|
42
|
+
raise FileNotFoundError(f"Missing resource: {name!r}")
|
|
43
|
+
return path
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def __getattr__(name: str):
|
|
47
|
+
# Lazy top-level re-exports so ``import mir`` stays light.
|
|
48
|
+
if name in ("TCREmp", "PairedTCREmp"):
|
|
49
|
+
from mir.embedding.tcremp import PairedTCREmp, TCREmp
|
|
50
|
+
|
|
51
|
+
return {"TCREmp": TCREmp, "PairedTCREmp": PairedTCREmp}[name]
|
|
52
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
mir/aliases.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Species and locus alias normalization for user-facing entry points.
|
|
2
|
+
|
|
3
|
+
Users, notebooks, and other tools spell species and loci many ways (``beta``,
|
|
4
|
+
``T_beta``, ``hsa``, ``Homo sapiens``). Prototype resource files and germline
|
|
5
|
+
lookups are keyed by the canonical IMGT locus (``TRB``) and canonical species
|
|
6
|
+
(``human`` / ``mouse``), so normalize once at the boundary.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
_SPECIES_ALIASES: dict[str, str] = {
|
|
12
|
+
"human": "human", "hsa": "human", "homosapiens": "human",
|
|
13
|
+
"homo_sapiens": "human", "homo sapiens": "human",
|
|
14
|
+
"mouse": "mouse", "mmu": "mouse", "musmusculus": "mouse",
|
|
15
|
+
"mus_musculus": "mouse", "mus musculus": "mouse",
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
_LOCUS_ALIASES: dict[str, str] = {
|
|
19
|
+
"TRA": "TRA", "TALPHA": "TRA", "T_ALPHA": "TRA", "ALPHA": "TRA",
|
|
20
|
+
"TRB": "TRB", "TBETA": "TRB", "T_BETA": "TRB", "BETA": "TRB",
|
|
21
|
+
"TRG": "TRG", "TGAMMA": "TRG", "T_GAMMA": "TRG", "GAMMA": "TRG",
|
|
22
|
+
"TRD": "TRD", "TDELTA": "TRD", "T_DELTA": "TRD", "DELTA": "TRD",
|
|
23
|
+
"IGH": "IGH", "BHEAVY": "IGH", "B_HEAVY": "IGH", "HEAVY": "IGH",
|
|
24
|
+
"IGK": "IGK", "BKAPPA": "IGK", "B_KAPPA": "IGK", "KAPPA": "IGK",
|
|
25
|
+
"IGL": "IGL", "BLAMBDA": "IGL", "B_LAMBDA": "IGL", "LAMBDA": "IGL",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def normalize_species_alias(species: str) -> str:
|
|
30
|
+
"""Return the canonical species (``'human'`` / ``'mouse'``) for an alias."""
|
|
31
|
+
key = (species or "").strip().lower().replace("-", "_")
|
|
32
|
+
if key not in _SPECIES_ALIASES:
|
|
33
|
+
raise ValueError(
|
|
34
|
+
f"Unknown species {species!r}; expected one of "
|
|
35
|
+
f"{sorted(set(_SPECIES_ALIASES.values()))}"
|
|
36
|
+
)
|
|
37
|
+
return _SPECIES_ALIASES[key]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def normalize_locus_alias(locus: str) -> str:
|
|
41
|
+
"""Return the canonical IMGT locus (e.g. ``'TRB'``) for an alias."""
|
|
42
|
+
key = (locus or "").strip().upper().replace("-", "_").replace(" ", "")
|
|
43
|
+
if key not in _LOCUS_ALIASES:
|
|
44
|
+
raise ValueError(
|
|
45
|
+
f"Unknown locus {locus!r}; expected one of "
|
|
46
|
+
f"{sorted(set(_LOCUS_ALIASES.values()))}"
|
|
47
|
+
)
|
|
48
|
+
return _LOCUS_ALIASES[key]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
if __name__ == "__main__":
|
|
52
|
+
assert normalize_species_alias("Homo sapiens") == "human"
|
|
53
|
+
assert normalize_locus_alias("beta") == "TRB"
|
|
54
|
+
assert normalize_locus_alias("T-alpha") == "TRA"
|
|
55
|
+
for bad in ("frog", ""):
|
|
56
|
+
try:
|
|
57
|
+
normalize_species_alias(bad)
|
|
58
|
+
except ValueError:
|
|
59
|
+
pass
|
|
60
|
+
else:
|
|
61
|
+
raise AssertionError(f"expected ValueError for {bad!r}")
|
|
62
|
+
print("mir.aliases self-check OK")
|
mir/alleles.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Scalar V/D/J/C allele-notation helpers used by the germline distance lookup.
|
|
2
|
+
|
|
3
|
+
For frame-level allele operations use ``vdjtools.io.schema.strip_allele`` (a polars
|
|
4
|
+
expression). These string helpers drive the per-clonotype resolution cascade
|
|
5
|
+
(exact allele → ``*01`` → bare gene → fallback) when gathering baked germline
|
|
6
|
+
distances.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
|
|
13
|
+
_GENE_BASE_RE = re.compile(r"^\s*([^*\s]+)")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def strip_allele(gene_name: str | None) -> str:
|
|
17
|
+
"""Return the gene base without allele suffix (``TRBV6-5*02`` → ``TRBV6-5``)."""
|
|
18
|
+
s = str(gene_name or "").strip()
|
|
19
|
+
if not s:
|
|
20
|
+
return ""
|
|
21
|
+
m = _GENE_BASE_RE.match(s)
|
|
22
|
+
return m.group(1) if m else ""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def allele_with_default(gene_name: str | None, default_allele: str = "01") -> str:
|
|
26
|
+
"""Append ``*01`` when no allele is given; preserve an explicit allele.
|
|
27
|
+
|
|
28
|
+
``TRBV6-5`` → ``TRBV6-5*01``; ``TRBV6-5*02`` → ``TRBV6-5*02``.
|
|
29
|
+
"""
|
|
30
|
+
s = str(gene_name or "").strip()
|
|
31
|
+
if not s:
|
|
32
|
+
return ""
|
|
33
|
+
if "*" in s:
|
|
34
|
+
base, allele = s.split("*", 1)
|
|
35
|
+
base = strip_allele(base)
|
|
36
|
+
allele = allele.strip()
|
|
37
|
+
if not base:
|
|
38
|
+
return ""
|
|
39
|
+
return f"{base}*{allele or default_allele}"
|
|
40
|
+
base = strip_allele(s)
|
|
41
|
+
return f"{base}*{default_allele}" if base else ""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def allele_to_major(gene_name: str | None) -> str:
|
|
45
|
+
"""Normalize to major-allele form ``*01`` (``TRBV6-5*02`` → ``TRBV6-5*01``)."""
|
|
46
|
+
base = strip_allele(gene_name)
|
|
47
|
+
return f"{base}*01" if base else ""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
if __name__ == "__main__":
|
|
51
|
+
assert strip_allele("TRBV6-5*02") == "TRBV6-5"
|
|
52
|
+
assert allele_with_default("TRBV6-5") == "TRBV6-5*01"
|
|
53
|
+
assert allele_with_default("TRBV6-5*02") == "TRBV6-5*02"
|
|
54
|
+
assert allele_to_major("TRBV6-5*02") == "TRBV6-5*01"
|
|
55
|
+
assert strip_allele("") == "" and allele_with_default(None) == ""
|
|
56
|
+
print("mir.alleles self-check OK")
|
mir/bench/__init__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""VDJdb / 10X benchmark harness, clustering metrics, and theory-validation experiments.
|
|
2
|
+
|
|
3
|
+
* :mod:`mir.bench.vdjdb` — load VDJdb dumps into AIRR polars frames.
|
|
4
|
+
* :mod:`mir.bench.metrics` — DBSCAN (+ kneedle eps) and per-antigen F1 / retention
|
|
5
|
+
(reproduces the paper's Table 1 / S1).
|
|
6
|
+
* :mod:`mir.bench.theory` — reproduce supplementary S1–S3 (distribution laws,
|
|
7
|
+
dissimilarity↔distance correlation, real-vs-model prototype robustness).
|
|
8
|
+
|
|
9
|
+
Requires the ``[bench]`` extra (kneed, matplotlib, BioPython for the SW baseline).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from mir.bench.metrics import AntigenMetric, cluster, cluster_metrics, estimate_dbscan_eps
|
|
13
|
+
from mir.bench.vdjdb import antigen_subset, load_vdjdb
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"load_vdjdb",
|
|
17
|
+
"antigen_subset",
|
|
18
|
+
"cluster",
|
|
19
|
+
"cluster_metrics",
|
|
20
|
+
"estimate_dbscan_eps",
|
|
21
|
+
"AntigenMetric",
|
|
22
|
+
]
|
mir/bench/metrics.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Antigen-cluster metrics for the TCREMP benchmark (paper Table 1 / S1).
|
|
2
|
+
|
|
3
|
+
DBSCAN clustering with a kneedle-selected ``eps`` on embedded TCRs, then per-antigen
|
|
4
|
+
**F1** (on clustered TCRs, via cluster→majority-label assignment) and **retention**
|
|
5
|
+
(fraction of an antigen's TCRs that land in a cluster), following the decoupling used
|
|
6
|
+
in the paper: F1 measures clustering *quality*, retention measures *coverage*.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections import Counter
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
from sklearn.cluster import DBSCAN
|
|
16
|
+
from sklearn.neighbors import NearestNeighbors
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def estimate_dbscan_eps(X: np.ndarray, k: int = 4) -> float:
|
|
20
|
+
"""Kneedle ``eps``: the knee of the sorted k-th nearest-neighbour distance curve."""
|
|
21
|
+
from kneed import KneeLocator
|
|
22
|
+
|
|
23
|
+
nn = NearestNeighbors(n_neighbors=k).fit(X)
|
|
24
|
+
kdist = np.sort(nn.kneighbors(X)[0][:, k - 1])
|
|
25
|
+
kl = KneeLocator(
|
|
26
|
+
np.arange(kdist.size), kdist, curve="convex", direction="increasing",
|
|
27
|
+
interp_method="polynomial", polynomial_degree=7,
|
|
28
|
+
)
|
|
29
|
+
if kl.knee is None:
|
|
30
|
+
return float(np.median(kdist))
|
|
31
|
+
return float(kdist[int(kl.knee)])
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def cluster(X: np.ndarray, eps: float | None = None, min_samples: int = 3,
|
|
35
|
+
k: int = 4, eps_factor: float = 0.4, method: str = "dbscan", **kwargs) -> np.ndarray:
|
|
36
|
+
"""Density-cluster an embedding; noise = ``-1`` for every method.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
method: ``"dbscan"`` (default), ``"hdbscan"``, or ``"optics"``. All three are density
|
|
40
|
+
estimators that emit ``-1`` noise, so :func:`cluster_metrics` and
|
|
41
|
+
:func:`mir.density.denoise_and_cluster` work unchanged across them. ``"dbscan"`` is
|
|
42
|
+
the default for reproducibility of the published Table-S1 numbers.
|
|
43
|
+
eps / eps_factor / k: DBSCAN only. When ``eps is None`` it is ``kneedle_knee × eps_factor``
|
|
44
|
+
— the paper's ``eps := (distance at knee) × (dataset-specific factor)`` (Fig 1); the
|
|
45
|
+
raw knee over-merges in the PCA embedding, so ~0.3–0.4 recovers tight antigen clusters.
|
|
46
|
+
min_samples: core-point / density threshold, shared by all three methods.
|
|
47
|
+
**kwargs: passed to the underlying estimator (e.g. ``min_cluster_size`` for HDBSCAN,
|
|
48
|
+
``cluster_method`` / ``xi`` for OPTICS).
|
|
49
|
+
|
|
50
|
+
HDBSCAN is a variable-density estimator — the persistent cluster tree (Hartigan level sets)
|
|
51
|
+
rather than DBSCAN's single global ``eps`` slice — so it is the natural choice when local
|
|
52
|
+
density spans orders of magnitude across antigen ridges (appendix ``sec:dens-depth``).
|
|
53
|
+
"""
|
|
54
|
+
if method == "dbscan":
|
|
55
|
+
if eps is None:
|
|
56
|
+
eps = estimate_dbscan_eps(X, k=k) * eps_factor
|
|
57
|
+
return DBSCAN(eps=eps, min_samples=min_samples, **kwargs).fit_predict(X)
|
|
58
|
+
if method == "hdbscan":
|
|
59
|
+
from sklearn.cluster import HDBSCAN
|
|
60
|
+
|
|
61
|
+
kwargs.setdefault("min_cluster_size", max(min_samples, 2))
|
|
62
|
+
kwargs.setdefault("copy", True) # don't mutate the caller's array (silences FutureWarning)
|
|
63
|
+
return HDBSCAN(min_samples=min_samples, **kwargs).fit_predict(X)
|
|
64
|
+
if method == "optics":
|
|
65
|
+
from sklearn.cluster import OPTICS
|
|
66
|
+
|
|
67
|
+
return OPTICS(min_samples=min_samples, **kwargs).fit_predict(X)
|
|
68
|
+
raise ValueError(f"method must be 'dbscan', 'hdbscan', or 'optics', got {method!r}")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class AntigenMetric:
|
|
73
|
+
epitope: str
|
|
74
|
+
n: int
|
|
75
|
+
f1: float
|
|
76
|
+
precision: float
|
|
77
|
+
recall: float
|
|
78
|
+
retention: float
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def cluster_metrics(labels: np.ndarray, antigens) -> dict[str, AntigenMetric]:
|
|
82
|
+
"""Per-antigen F1 (on clustered TCRs) and retention.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
labels: DBSCAN cluster labels (``-1`` = noise/unclustered).
|
|
86
|
+
antigens: True antigen (epitope) label per TCR, same length as *labels*.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
``epitope -> AntigenMetric``.
|
|
90
|
+
"""
|
|
91
|
+
labels = np.asarray(labels)
|
|
92
|
+
antigens = np.asarray(antigens, dtype=object)
|
|
93
|
+
clustered = labels >= 0
|
|
94
|
+
|
|
95
|
+
# cluster -> majority antigen (over clustered members)
|
|
96
|
+
majority: dict[int, str] = {}
|
|
97
|
+
for lab in np.unique(labels[clustered]):
|
|
98
|
+
members = antigens[clustered & (labels == lab)]
|
|
99
|
+
majority[int(lab)] = Counter(members).most_common(1)[0][0]
|
|
100
|
+
|
|
101
|
+
predicted = np.array(
|
|
102
|
+
[majority.get(int(l), None) if l >= 0 else None for l in labels], dtype=object
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
out: dict[str, AntigenMetric] = {}
|
|
106
|
+
for ag in sorted(set(antigens.tolist())):
|
|
107
|
+
true_ag = antigens == ag
|
|
108
|
+
n = int(true_ag.sum())
|
|
109
|
+
pred_ag = predicted == ag
|
|
110
|
+
tp = int((true_ag & pred_ag).sum())
|
|
111
|
+
n_pred = int(pred_ag.sum())
|
|
112
|
+
n_true_clustered = int((true_ag & clustered).sum())
|
|
113
|
+
precision = tp / n_pred if n_pred else 0.0
|
|
114
|
+
recall = tp / n_true_clustered if n_true_clustered else 0.0
|
|
115
|
+
f1 = 2 * precision * recall / (precision + recall) if (precision + recall) else 0.0
|
|
116
|
+
retention = n_true_clustered / n if n else 0.0
|
|
117
|
+
out[ag] = AntigenMetric(ag, n, f1, precision, recall, retention)
|
|
118
|
+
return out
|
mir/bench/theory.py
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
"""Theory-validation experiments (reproduce TCREMP supplementary S1–S3).
|
|
2
|
+
|
|
3
|
+
These numerically check the properties the embedding is claimed to have, using the
|
|
4
|
+
actual v3 pipeline distances (``seqtree.gapblock`` junction dissimilarity):
|
|
5
|
+
|
|
6
|
+
* **S2 / Theory T1** — Euclidean distance ``D_ij`` in embedding space tracks the
|
|
7
|
+
pairwise junction dissimilarity ``d_ij`` (:func:`s2_dissimilarity_distance_correlation`;
|
|
8
|
+
paper Pearson R ≈ 0.56). Using the sequences as their own prototypes, the embedding
|
|
9
|
+
of sequence *i* is row *i* of the dissimilarity matrix and ``D_ij = ‖d_i − d_j‖₂``.
|
|
10
|
+
* **S1 / Theory T4** — ``d_ij`` follows a Gamma law (Gaussian fails); ``D_ij`` follows
|
|
11
|
+
a Fréchet (GEV, shape ξ>0) law (:func:`fit_distributions`).
|
|
12
|
+
* **S3** — distances from *real* vs *model* prototypes agree (:func:`prototype_source_correlation`;
|
|
13
|
+
paper Pearson R ≈ 0.96), i.e. the prototype source barely matters.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
from scipy import stats
|
|
22
|
+
from scipy.spatial.distance import pdist
|
|
23
|
+
|
|
24
|
+
from mir.distances.junction import junction_distance_matrix
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def junction_dissimilarity(cdr3s, threads: int = 0) -> np.ndarray:
|
|
28
|
+
"""Symmetric ``(n, n)`` junction dissimilarity ``d_ij`` (v3 gapblock; a
|
|
29
|
+
negative-type semimetric / squared Hilbert distance, not a metric — ρ=√d is)."""
|
|
30
|
+
return junction_distance_matrix(cdr3s, cdr3s, threads=threads)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def junction_dissimilarity_sw(cdr3s) -> np.ndarray:
|
|
34
|
+
"""Paper-exact ``d_ij`` via Smith-Waterman BLOSUM62 (supplementary S1/S2).
|
|
35
|
+
|
|
36
|
+
``d_ij = s_ii + s_jj − 2·s_ij`` from a local BLOSUM62 alignment (linear gap).
|
|
37
|
+
Requires BioPython (``[bench]``/``[build]`` extra). O(n²) alignments — use for
|
|
38
|
+
the theory validation on a few thousand sequences, not at scale.
|
|
39
|
+
"""
|
|
40
|
+
from Bio.Align import PairwiseAligner, substitution_matrices
|
|
41
|
+
|
|
42
|
+
aligner = PairwiseAligner()
|
|
43
|
+
aligner.mode = "local"
|
|
44
|
+
aligner.substitution_matrix = substitution_matrices.load("BLOSUM62")
|
|
45
|
+
aligner.open_gap_score = -1.0
|
|
46
|
+
aligner.extend_gap_score = -1.0 # linear gap penalty
|
|
47
|
+
seqs = list(cdr3s)
|
|
48
|
+
n = len(seqs)
|
|
49
|
+
s = np.empty((n, n), dtype=np.float64)
|
|
50
|
+
for i in range(n):
|
|
51
|
+
s[i, i] = aligner.score(seqs[i], seqs[i])
|
|
52
|
+
for i in range(n):
|
|
53
|
+
for j in range(i + 1, n):
|
|
54
|
+
s[i, j] = s[j, i] = aligner.score(seqs[i], seqs[j])
|
|
55
|
+
return s.diagonal()[:, None] + s.diagonal()[None, :] - 2.0 * s
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class CorrelationResult:
|
|
60
|
+
n: int
|
|
61
|
+
n_pairs: int
|
|
62
|
+
pearson: float
|
|
63
|
+
d: np.ndarray # flat i>j dissimilarities
|
|
64
|
+
D: np.ndarray # flat i>j embedding-space Euclidean distances
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def s2_dissimilarity_distance_correlation(
|
|
68
|
+
cdr3s, threads: int = 0, dissimilarity: str = "gapblock"
|
|
69
|
+
) -> CorrelationResult:
|
|
70
|
+
"""Correlate junction dissimilarity ``d_ij`` with embedding distance ``D_ij``.
|
|
71
|
+
|
|
72
|
+
Uses the *self-prototype* construction from supplementary Fig. S2: the embedding
|
|
73
|
+
of each sequence is its row of the dissimilarity matrix, and ``D_ij`` is the
|
|
74
|
+
Euclidean distance between rows. Returns the flat i>j vectors and Pearson R.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
dissimilarity: ``"gapblock"`` (the v3 pipeline metric) or ``"sw"``
|
|
78
|
+
(paper-exact Smith-Waterman BLOSUM62).
|
|
79
|
+
"""
|
|
80
|
+
if dissimilarity == "sw":
|
|
81
|
+
d = junction_dissimilarity_sw(cdr3s).astype(np.float64)
|
|
82
|
+
else:
|
|
83
|
+
d = junction_dissimilarity(cdr3s, threads=threads).astype(np.float64)
|
|
84
|
+
n = d.shape[0]
|
|
85
|
+
iu = np.triu_indices(n, k=1)
|
|
86
|
+
d_flat = d[iu] # dissimilarities, i>j order
|
|
87
|
+
D_flat = pdist(d, metric="euclidean") # same i>j order as triu_indices(n,1)
|
|
88
|
+
r = float(np.corrcoef(d_flat, D_flat)[0, 1])
|
|
89
|
+
return CorrelationResult(n=n, n_pairs=d_flat.size, pearson=r, d=d_flat, D=D_flat)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _aic(logpdf_sum: float, k: int) -> float:
|
|
93
|
+
return 2 * k - 2 * logpdf_sum
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _fit_one(data: np.ndarray, dist, floc: bool, init: tuple = ()) -> dict:
|
|
97
|
+
if init:
|
|
98
|
+
params = dist.fit(data, *init, loc=float(np.median(data)), scale=float(data.std()))
|
|
99
|
+
elif floc:
|
|
100
|
+
params = dist.fit(data, floc=0)
|
|
101
|
+
else:
|
|
102
|
+
params = dist.fit(data)
|
|
103
|
+
logL = float(np.sum(dist.logpdf(data, *params)))
|
|
104
|
+
ks = float(stats.kstest(data, dist.cdf, args=params).statistic)
|
|
105
|
+
return {"params": params, "aic": _aic(logL, len(params)), "ks": ks}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def fit_distributions(
|
|
109
|
+
d_flat: np.ndarray, D_flat: np.ndarray, sample: int = 200_000, seed: int = 0
|
|
110
|
+
) -> dict:
|
|
111
|
+
"""Fit S1 distributions: Gamma vs Normal for ``d``, GEV/Fréchet vs Normal for ``D``.
|
|
112
|
+
|
|
113
|
+
Returns a nested dict of ``{ks, aic, params}`` per fit plus the GEV shape ``xi``
|
|
114
|
+
(``xi > 0`` ⇒ Fréchet). Large inputs are subsampled to *sample* points.
|
|
115
|
+
"""
|
|
116
|
+
rng = np.random.default_rng(seed)
|
|
117
|
+
|
|
118
|
+
def _sub(x):
|
|
119
|
+
return x if x.size <= sample else rng.choice(x, sample, replace=False)
|
|
120
|
+
|
|
121
|
+
d, D = _sub(np.asarray(d_flat, float)), _sub(np.asarray(D_flat, float))
|
|
122
|
+
d = d[d > 0]
|
|
123
|
+
# genextreme MLE is unstable without a good shape init; c=-xi, seed near Frechet
|
|
124
|
+
gev = _fit_one(D, stats.genextreme, floc=False, init=(-0.1,))
|
|
125
|
+
return {
|
|
126
|
+
# free location: pairwise d is concentrated far from 0, so forcing loc=0 misfits
|
|
127
|
+
"d_gamma": _fit_one(d, stats.gamma, floc=False),
|
|
128
|
+
"d_normal": _fit_one(d, stats.norm, floc=False),
|
|
129
|
+
"D_gev": gev,
|
|
130
|
+
"D_normal": _fit_one(D, stats.norm, floc=False),
|
|
131
|
+
"D_gev_xi": float(-gev["params"][0]), # scipy genextreme c = -xi
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
_AA = "ACDEFGHIKLMNPQRSTVWY"
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _mutate(seq: str, k: int, rng) -> str:
|
|
139
|
+
"""Apply *k* random interior amino-acid substitutions (an SHM proxy)."""
|
|
140
|
+
s = list(seq)
|
|
141
|
+
interior = list(range(1, len(s) - 1)) # keep the conserved C…[FW] ends
|
|
142
|
+
if not interior:
|
|
143
|
+
return seq
|
|
144
|
+
for p in rng.choice(interior, min(k, len(interior)), replace=False):
|
|
145
|
+
s[p] = _AA[int(rng.integers(20))]
|
|
146
|
+
return "".join(s)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def shm_embedding_drift(
|
|
150
|
+
cdr3s, prototypes, max_mut: int = 8, n_rep: int = 3, seed: int = 0, threads: int = 0
|
|
151
|
+
) -> dict[int, tuple[float, float]]:
|
|
152
|
+
"""Embedding drift vs mutation load (Theory T5, the IGH/SHM case).
|
|
153
|
+
|
|
154
|
+
Applies ``k`` random interior substitutions (a somatic-hypermutation proxy) to each
|
|
155
|
+
CDR3 and measures the junction-embedding Euclidean distance to the original,
|
|
156
|
+
``D_k = ‖φ(mutated) − φ(original)‖``. Returns ``k -> (mean D_k, std)``. The claim: the
|
|
157
|
+
drift is *bounded by the mutation load* — ``D_k`` grows ~linearly in ``k`` — so heavily
|
|
158
|
+
hypermutated IGH sequences sit controllably far from germline in embedding space.
|
|
159
|
+
"""
|
|
160
|
+
rng = np.random.default_rng(seed)
|
|
161
|
+
seqs = list(cdr3s)
|
|
162
|
+
base = junction_distance_matrix(seqs, prototypes, threads=threads).astype(np.float64)
|
|
163
|
+
out: dict[int, tuple[float, float]] = {0: (0.0, 0.0)}
|
|
164
|
+
for k in range(1, max_mut + 1):
|
|
165
|
+
drift: list[float] = []
|
|
166
|
+
for _ in range(n_rep):
|
|
167
|
+
emb = junction_distance_matrix([_mutate(s, k, rng) for s in seqs],
|
|
168
|
+
prototypes, threads=threads).astype(np.float64)
|
|
169
|
+
drift.extend(np.linalg.norm(emb - base, axis=1).tolist())
|
|
170
|
+
out[k] = (float(np.mean(drift)), float(np.std(drift)))
|
|
171
|
+
return out
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def prototype_source_correlation(
|
|
175
|
+
query_cdr3s, real_prototypes, model_prototypes, threads: int = 0
|
|
176
|
+
) -> dict:
|
|
177
|
+
"""Correlate embedding distances built from *real* vs *model* prototypes (S3).
|
|
178
|
+
|
|
179
|
+
Embeds ``query_cdr3s`` against each prototype set (junction only), takes pairwise
|
|
180
|
+
Euclidean distances under each embedding, and returns their Pearson R (paper ≈ 0.96).
|
|
181
|
+
"""
|
|
182
|
+
Xr = junction_distance_matrix(query_cdr3s, real_prototypes, threads=threads).astype(np.float64)
|
|
183
|
+
Xm = junction_distance_matrix(query_cdr3s, model_prototypes, threads=threads).astype(np.float64)
|
|
184
|
+
Dr, Dm = pdist(Xr), pdist(Xm)
|
|
185
|
+
return {"pearson": float(np.corrcoef(Dr, Dm)[0, 1]), "n_pairs": Dr.size}
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _hamming1_counts(seqs) -> np.ndarray:
|
|
189
|
+
"""Discrete Hamming-1 neighbour count per sequence (equal-length, exactly one mismatch)."""
|
|
190
|
+
seqs = list(seqs)
|
|
191
|
+
out = np.zeros(len(seqs), dtype=np.int64)
|
|
192
|
+
for i, a in enumerate(seqs):
|
|
193
|
+
la, c = len(a), 0
|
|
194
|
+
for j, b in enumerate(seqs):
|
|
195
|
+
if i == j or len(b) != la:
|
|
196
|
+
continue
|
|
197
|
+
m = 0
|
|
198
|
+
for x, y in zip(a, b):
|
|
199
|
+
if x != y:
|
|
200
|
+
m += 1
|
|
201
|
+
if m > 1:
|
|
202
|
+
break
|
|
203
|
+
if m == 1:
|
|
204
|
+
c += 1
|
|
205
|
+
out[i] = c
|
|
206
|
+
return out
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def tcrnet_convergence(
|
|
210
|
+
obs_cdr3s, bg_cdr3s, prototypes, *, n_components: int = 25,
|
|
211
|
+
scales=(0.5, 1.0, 1.5, 2.0, 3.0), seed: int = 0, threads: int = 0
|
|
212
|
+
) -> dict:
|
|
213
|
+
"""T6: continuous embedding enrichment converges to discrete Hamming-1 enrichment.
|
|
214
|
+
|
|
215
|
+
Embeds observed and background CDR3s against the prototype set (junction only), reduces
|
|
216
|
+
with one shared ``StandardScaler → PCA``, and at radius ``scale × r₁`` (``r₁`` = the
|
|
217
|
+
median one-substitution embedding drift) counts each observed clonotype's neighbours in
|
|
218
|
+
embedding space. Returns the Spearman correlation between those continuous counts and the
|
|
219
|
+
discrete Hamming-1 neighbour counts. The correlation is high at small radii and fades as
|
|
220
|
+
the radius grows past one substitution — numerically confirming that graph
|
|
221
|
+
neighbour-enrichment (TCRNET/ALICE) is the ``r→0`` limit of the density ratio ``E(z)``.
|
|
222
|
+
|
|
223
|
+
Args:
|
|
224
|
+
obs_cdr3s: Observed junction sequences.
|
|
225
|
+
bg_cdr3s: Background junction sequences (P_gen sample / control).
|
|
226
|
+
prototypes: Prototype junctions defining the embedding.
|
|
227
|
+
n_components: PCA dimensionality of the shared coordinate system.
|
|
228
|
+
scales: Radius multiples of the one-substitution scale ``r₁`` to evaluate.
|
|
229
|
+
seed: RNG seed for the calibration mutations and PCA solver.
|
|
230
|
+
|
|
231
|
+
Returns:
|
|
232
|
+
``{radius_1sub, hamming1_mean, spearman_by_scale, spearman_at_1sub}``.
|
|
233
|
+
"""
|
|
234
|
+
from sklearn.decomposition import PCA
|
|
235
|
+
from sklearn.neighbors import BallTree
|
|
236
|
+
from sklearn.preprocessing import StandardScaler
|
|
237
|
+
|
|
238
|
+
obs = junction_distance_matrix(obs_cdr3s, prototypes, threads=threads).astype(np.float64)
|
|
239
|
+
bg = junction_distance_matrix(bg_cdr3s, prototypes, threads=threads).astype(np.float64)
|
|
240
|
+
scaler = StandardScaler().fit(np.vstack([obs, bg]))
|
|
241
|
+
k = min(n_components, obs.shape[0] + bg.shape[0], obs.shape[1])
|
|
242
|
+
pca = PCA(n_components=k, random_state=seed).fit(scaler.transform(np.vstack([obs, bg])))
|
|
243
|
+
obs_emb = pca.transform(scaler.transform(obs))
|
|
244
|
+
|
|
245
|
+
rng = np.random.default_rng(seed)
|
|
246
|
+
mut = [_mutate(s, 1, rng) for s in obs_cdr3s]
|
|
247
|
+
mut_emb = pca.transform(scaler.transform(
|
|
248
|
+
junction_distance_matrix(mut, prototypes, threads=threads).astype(np.float64)))
|
|
249
|
+
r1 = float(np.median(np.linalg.norm(obs_emb - mut_emb, axis=1)))
|
|
250
|
+
|
|
251
|
+
discrete = _hamming1_counts(obs_cdr3s)
|
|
252
|
+
tree = BallTree(obs_emb)
|
|
253
|
+
corr = {}
|
|
254
|
+
for f in scales:
|
|
255
|
+
n_obs = tree.query_radius(obs_emb, r1 * f, count_only=True) - 1
|
|
256
|
+
corr[round(float(f), 2)] = float(stats.spearmanr(n_obs, discrete).statistic)
|
|
257
|
+
return {
|
|
258
|
+
"radius_1sub": r1,
|
|
259
|
+
"hamming1_mean": float(discrete.mean()),
|
|
260
|
+
"spearman_by_scale": corr,
|
|
261
|
+
"spearman_at_1sub": corr[1.0],
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
# ---------------------------------------------------------------------------
|
|
266
|
+
# Codec losslessness (appendix draft codec_losslessness.tex): the three-level hierarchy
|
|
267
|
+
# ---------------------------------------------------------------------------
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _levenshtein(a: str, b: str) -> int:
|
|
271
|
+
"""Levenshtein edit distance (iterative two-row DP)."""
|
|
272
|
+
if a == b:
|
|
273
|
+
return 0
|
|
274
|
+
if not a or not b:
|
|
275
|
+
return len(a) or len(b)
|
|
276
|
+
prev = list(range(len(b) + 1))
|
|
277
|
+
for i, ca in enumerate(a, 1):
|
|
278
|
+
cur = [i]
|
|
279
|
+
for j, cb in enumerate(b, 1):
|
|
280
|
+
cur.append(min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (ca != cb)))
|
|
281
|
+
prev = cur
|
|
282
|
+
return prev[-1]
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def codec_losslessness(codes, seqs, recon=None, *, eps: float = 1e-6) -> dict:
|
|
286
|
+
"""Measurable losslessness of a codec, split into decoder-independent vs -dependent parts.
|
|
287
|
+
|
|
288
|
+
**Informational ceiling** (decoder-independent, from ``codes`` alone). The compact code is
|
|
289
|
+
injective iff distinct sequences map to distinct codes; two distinct sequences whose codes
|
|
290
|
+
coincide can never *both* be recovered, so ``exact_ceiling = 1 − collision_rate`` upper-bounds
|
|
291
|
+
any decoder's exact-match — the information the code retains, separate from how well a decoder
|
|
292
|
+
reads it. Encoding is deterministic (identical sequences ⇒ identical codes), so duplicate
|
|
293
|
+
sequences are collapsed first and a *collision* is a near-coincident code pair between
|
|
294
|
+
**distinct** sequences (nearest-code distance < ``eps``). ``nn_dist_{median,min}`` report the
|
|
295
|
+
code-resolution scale so ``eps`` can be read in context.
|
|
296
|
+
|
|
297
|
+
**Reconstructive loss** (needs ``recon``, the decoded strings row-aligned to ``seqs``):
|
|
298
|
+
``exact_match``, mean **edit distance** (a *graded* distortion, unlike binary exact-match), and
|
|
299
|
+
**per-position** token accuracy over the length-40 frame — localizing *where* loss falls
|
|
300
|
+
(conserved C…[FW] anchors vs the specificity-bearing variable middle). Per-position accuracy
|
|
301
|
+
re-encodes ``recon`` into the canonical frame, so it measures positional token agreement.
|
|
302
|
+
|
|
303
|
+
Args:
|
|
304
|
+
codes: ``(n, m)`` compact codes, one per sequence (e.g. whitened-PCA junction code).
|
|
305
|
+
seqs: the ``n`` true junction strings.
|
|
306
|
+
recon: optional ``n`` decoded strings (e.g. ``InverseDecoder.decode(codes)``). When
|
|
307
|
+
``None``, only the decoder-independent ceiling is returned.
|
|
308
|
+
eps: code-distance below which two distinct sequences count as colliding.
|
|
309
|
+
|
|
310
|
+
Returns:
|
|
311
|
+
Always ``{n, n_unique, collision_rate, exact_ceiling, nn_dist_median, nn_dist_min}``; and
|
|
312
|
+
when ``recon`` is given, additionally ``{exact_match, mean_edit, token_acc, anchor_acc,
|
|
313
|
+
middle_acc, pos_acc}`` (``pos_acc`` a length-40 array).
|
|
314
|
+
"""
|
|
315
|
+
from sklearn.neighbors import BallTree
|
|
316
|
+
|
|
317
|
+
seqs = list(seqs)
|
|
318
|
+
codes = np.asarray(codes, dtype=np.float64)
|
|
319
|
+
n = len(seqs)
|
|
320
|
+
|
|
321
|
+
# informational ceiling: dedup sequences, then nearest *distinct*-code distance
|
|
322
|
+
uniq = list({s: i for i, s in enumerate(seqs)}.values())
|
|
323
|
+
C = codes[uniq]
|
|
324
|
+
n_unique = len(uniq)
|
|
325
|
+
if n_unique >= 2:
|
|
326
|
+
nn = BallTree(C).query(C, k=2)[0][:, 1] # distance to nearest distinct code
|
|
327
|
+
collision_rate = float((nn < eps).mean())
|
|
328
|
+
nn_median, nn_min = float(np.median(nn)), float(nn.min())
|
|
329
|
+
else:
|
|
330
|
+
collision_rate, nn_median, nn_min = 0.0, float("nan"), float("nan")
|
|
331
|
+
out = {
|
|
332
|
+
"n": n, "n_unique": n_unique, "collision_rate": collision_rate,
|
|
333
|
+
"exact_ceiling": 1.0 - collision_rate,
|
|
334
|
+
"nn_dist_median": nn_median, "nn_dist_min": nn_min,
|
|
335
|
+
}
|
|
336
|
+
if recon is None:
|
|
337
|
+
return out
|
|
338
|
+
|
|
339
|
+
from mir.ml.tokenize import FIXED_LEN, _LEFT_ANCHOR, _RIGHT_ANCHOR, encode_indices
|
|
340
|
+
|
|
341
|
+
recon = list(recon)
|
|
342
|
+
exact = float(np.mean([r == s for r, s in zip(recon, seqs)]))
|
|
343
|
+
mean_edit = float(np.mean([_levenshtein(r, s) for r, s in zip(recon, seqs)]))
|
|
344
|
+
pos_acc = (encode_indices(seqs) == encode_indices(recon)).mean(axis=0) # (40,)
|
|
345
|
+
anchor = np.zeros(FIXED_LEN, dtype=bool)
|
|
346
|
+
anchor[:_LEFT_ANCHOR] = anchor[FIXED_LEN - _RIGHT_ANCHOR:] = True
|
|
347
|
+
out.update({
|
|
348
|
+
"exact_match": exact, "mean_edit": mean_edit, "token_acc": float(pos_acc.mean()),
|
|
349
|
+
"anchor_acc": float(pos_acc[anchor].mean()), "middle_acc": float(pos_acc[~anchor].mean()),
|
|
350
|
+
"pos_acc": pos_acc,
|
|
351
|
+
})
|
|
352
|
+
return out
|