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.
Files changed (65) hide show
  1. mir/__init__.py +52 -0
  2. mir/aliases.py +62 -0
  3. mir/alleles.py +56 -0
  4. mir/bench/__init__.py +22 -0
  5. mir/bench/metrics.py +118 -0
  6. mir/bench/theory.py +352 -0
  7. mir/bench/vdjdb.py +46 -0
  8. mir/density.py +603 -0
  9. mir/distances/__init__.py +5 -0
  10. mir/distances/germline.py +142 -0
  11. mir/distances/junction.py +111 -0
  12. mir/embedding/__init__.py +21 -0
  13. mir/embedding/pca.py +48 -0
  14. mir/embedding/presets.py +72 -0
  15. mir/embedding/prototypes.py +108 -0
  16. mir/embedding/tcremp.py +270 -0
  17. mir/ml/__init__.py +64 -0
  18. mir/ml/bundle.py +97 -0
  19. mir/ml/codec.py +140 -0
  20. mir/ml/decoder.py +64 -0
  21. mir/ml/encoder.py +56 -0
  22. mir/ml/set_encoder.py +220 -0
  23. mir/ml/tokenize.py +68 -0
  24. mir/ml/train.py +386 -0
  25. mir/py.typed +0 -0
  26. mir/repertoire.py +447 -0
  27. mir/resources/__init__.py +1 -0
  28. mir/resources/gene_library/README.md +79 -0
  29. mir/resources/gene_library/__init__.py +1 -0
  30. mir/resources/gene_library/build_gene_library.py +550 -0
  31. mir/resources/gene_library/build_region_annotations.py +94 -0
  32. mir/resources/gene_library/imgt_gene_library.txt +2775 -0
  33. mir/resources/gene_library/olga_gene_library.txt +880 -0
  34. mir/resources/gene_library/region_annotations.txt +2110 -0
  35. mir/resources/germline_dist/build_germline_dist.py +120 -0
  36. mir/resources/germline_dist/human_IGH.npz +0 -0
  37. mir/resources/germline_dist/human_IGK.npz +0 -0
  38. mir/resources/germline_dist/human_IGL.npz +0 -0
  39. mir/resources/germline_dist/human_TRA.npz +0 -0
  40. mir/resources/germline_dist/human_TRB.npz +0 -0
  41. mir/resources/germline_dist/human_TRD.npz +0 -0
  42. mir/resources/germline_dist/human_TRG.npz +0 -0
  43. mir/resources/germline_dist/mouse_IGH.npz +0 -0
  44. mir/resources/germline_dist/mouse_IGK.npz +0 -0
  45. mir/resources/germline_dist/mouse_IGL.npz +0 -0
  46. mir/resources/germline_dist/mouse_TRA.npz +0 -0
  47. mir/resources/germline_dist/mouse_TRB.npz +0 -0
  48. mir/resources/germline_dist/mouse_TRD.npz +0 -0
  49. mir/resources/germline_dist/mouse_TRG.npz +0 -0
  50. mir/resources/prototypes/.gitkeep +0 -0
  51. mir/resources/prototypes/generate_prototypes.py +109 -0
  52. mir/resources/prototypes/human_IGH.tsv +10001 -0
  53. mir/resources/prototypes/human_IGK.tsv +10001 -0
  54. mir/resources/prototypes/human_IGL.tsv +10001 -0
  55. mir/resources/prototypes/human_TRA.tsv +10001 -0
  56. mir/resources/prototypes/human_TRB.tsv +10001 -0
  57. mir/resources/prototypes/human_TRD.tsv +10001 -0
  58. mir/resources/prototypes/human_TRG.tsv +10001 -0
  59. mir/resources/prototypes/manifest.json +74 -0
  60. mir/resources/prototypes/mouse_TRA.tsv +10001 -0
  61. mir/resources/prototypes/mouse_TRB.tsv +10001 -0
  62. mirpy_lib-3.1.0.dist-info/METADATA +210 -0
  63. mirpy_lib-3.1.0.dist-info/RECORD +65 -0
  64. mirpy_lib-3.1.0.dist-info/WHEEL +4 -0
  65. mirpy_lib-3.1.0.dist-info/licenses/LICENSE +674 -0
mir/bench/vdjdb.py ADDED
@@ -0,0 +1,46 @@
1
+ """Load VDJdb dumps into the AIRR polars frame TCREmp expects.
2
+
3
+ VDJdb "slim" dumps use dotted column names (``cdr3``, ``v.segm``, ``j.segm``,
4
+ ``antigen.epitope``, ``gene``, ``complex.id``, ``mhc.class``). This maps them to
5
+ the ``vdjtools.io.schema`` names plus an ``epitope`` label column.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import polars as pl
11
+
12
+
13
+ def load_vdjdb(path: str) -> pl.DataFrame:
14
+ """Return an AIRR frame with ``v_call, j_call, junction_aa, locus, epitope``.
15
+
16
+ Args:
17
+ path: VDJdb slim TSV (optionally gzipped).
18
+ """
19
+ raw = pl.read_csv(path, separator="\t", infer_schema_length=0) # all Utf8
20
+ return (
21
+ raw.select(
22
+ junction_aa=pl.col("cdr3"),
23
+ v_call=pl.col("v.segm"),
24
+ j_call=pl.col("j.segm"),
25
+ locus=pl.col("gene"),
26
+ epitope=pl.col("antigen.epitope"),
27
+ mhc_class=pl.col("mhc.class"),
28
+ )
29
+ .filter(
30
+ pl.col("junction_aa").is_not_null()
31
+ & (pl.col("junction_aa").str.len_chars() >= 5)
32
+ & pl.col("epitope").is_not_null()
33
+ & pl.col("v_call").is_not_null()
34
+ & pl.col("j_call").is_not_null()
35
+ )
36
+ .unique(subset=["junction_aa", "v_call", "j_call", "locus", "epitope"])
37
+ )
38
+
39
+
40
+ def antigen_subset(df: pl.DataFrame, chain: str, min_records: int) -> pl.DataFrame:
41
+ """Rows for *chain* restricted to epitopes with ``>= min_records`` records."""
42
+ sub = df.filter(pl.col("locus") == chain)
43
+ keep = (
44
+ sub.group_by("epitope").len().filter(pl.col("len") >= min_records)["epitope"].to_list()
45
+ )
46
+ return sub.filter(pl.col("epitope").is_in(keep))
mir/density.py ADDED
@@ -0,0 +1,603 @@
1
+ """Continuous-density TCRNET / ALICE: enrichment in TCREMP embedding space (Theory T6).
2
+
3
+ TCRNET (Pogorelyy & Shugay, *Front Immunol* 2019) and ALICE (Pogorelyy et al.,
4
+ *PLoS Biol* 2019) flag antigen-driven / convergent-selected clonotypes by *neighbour
5
+ enrichment*: count a clonotype's near-identical (Hamming-1) neighbours and compare
6
+ against a background — OLGA generation probability for ALICE, a control repertoire for
7
+ TCRNET. Both are **graph** methods (a sequence trie).
8
+
9
+ This module reimplements the same enrichment test with neighbour counting in the
10
+ **TCREMP embedding space** instead of on a trie — the graph-free density ratio the
11
+ theory specifies (``THEORY.md`` T6, ``appendix/tcremp_theory.tex`` §Density):
12
+
13
+ E(z) = f_obs(z) / f_gen(z), f_gen = φ_# P_gen
14
+
15
+ For an observed clonotype embedded at ``z_i`` we count observed neighbours ``n_obs``
16
+ and background neighbours ``n_bg`` within a radius ``r`` (calibrated to one CDR3
17
+ substitution, so the continuous test approximates the discrete Hamming-1 one). Under
18
+ the null "the observed repertoire is a background sample", each of the other
19
+ ``N_obs − 1`` observed clonotypes lands within ``r`` of ``z_i`` with probability
20
+ ``p_bg = n_bg / N_bg``, so the expected count is ``(N_obs−1)·p_bg`` and
21
+
22
+ p-value = poisson.sf(n_obs − 1, expected) # ALICE-style (test="poisson")
23
+ = binom.sf(n_obs − 1, N_obs−1, p_bg) # TCRNET-style (test="binomial")
24
+
25
+ with Benjamini-Hochberg q-values. The fold enrichment ``fold = n_obs / expected`` is the
26
+ density ratio ``E(z)`` itself. The background is either **generated** from the vdjtools
27
+ P_gen model (:func:`generate_background`, the ALICE analog) or **any supplied control
28
+ repertoire** (the TCRNET analog) — both just become ``bg_df``.
29
+
30
+ Passing ``abundance=`` (clone sizes) to :func:`neighbor_enrichment` adds the **clonal-depth**
31
+ channel (appendix §T.6 ``sec:dens-abund``): the distinct in-ball count becomes a
32
+ variance-stabilised weighted mass ``S=Σ g(a_j)`` with concave ``g`` (default ``log(1+a)``) so a
33
+ hyperexpanded clone can't dominate, tested against a compound-Poisson Gamma tail, plus an orphan
34
+ size-test ``P(A≥a_j)`` combined with breadth by Fisher. Default (``abundance=None``) counts each
35
+ clonotype once — the shipped ``g≡1`` behaviour.
36
+
37
+ Observed and background embeddings are comparable *only* in one coordinate system (same
38
+ prototypes **and** same PCA rotation). :func:`fit_density_space` enforces that: it embeds
39
+ both through one :class:`~mir.embedding.tcremp.TCREmp` and fits one PCA on the pooled
40
+ matrix, returning a :class:`DensitySpace` that projects any further frame (a control, or
41
+ the radius-calibration mutants) into the *same* basis.
42
+
43
+ Typical usage::
44
+
45
+ from mir.density import fit_density_space, calibrate_radius, neighbor_enrichment, enriched_mask
46
+ from mir.embedding.tcremp import TCREmp
47
+ from mir.embedding.presets import get_preset
48
+
49
+ model = TCREmp.from_defaults("human", "TRB")
50
+ nc = get_preset("human", "TRB").n_components
51
+ space, obs_emb, bg_emb = fit_density_space(model, obs_df, bg_df, n_components=nc)
52
+ r = calibrate_radius(space) # radius ≈ one CDR3 substitution
53
+ res = neighbor_enrichment(obs_emb, bg_emb, r) # E(z), p, q per observed clonotype
54
+ hits = obs_df.filter(enriched_mask(res)) # significantly enriched clonotypes
55
+ """
56
+
57
+ from __future__ import annotations
58
+
59
+ from dataclasses import dataclass
60
+
61
+ import numpy as np
62
+ import polars as pl
63
+ from scipy import stats
64
+ from sklearn.decomposition import PCA
65
+ from sklearn.neighbors import BallTree
66
+ from sklearn.preprocessing import StandardScaler
67
+
68
+ from mir.distances.junction import junction_distance_matrix
69
+
70
+ _REQUIRED_COLS = ("v_call", "j_call", "junction_aa")
71
+ _AA = "ACDEFGHIKLMNPQRSTVWY"
72
+
73
+
74
+ def _slice(emb: np.ndarray, space: str) -> np.ndarray:
75
+ """Select the embedding coordinates that define a neighbourhood."""
76
+ if space == "full":
77
+ return emb
78
+ if space == "junction":
79
+ return emb[:, 2::3] # junction is slot 2 of every [slot0, slot1, junction] triple
80
+ raise ValueError(f"space must be 'full' or 'junction', got {space!r}")
81
+
82
+
83
+ def _embed(model, df: pl.DataFrame, space: str) -> np.ndarray:
84
+ """Raw (pre-PCA) embedding of *df* in the requested space.
85
+
86
+ ``space="junction"`` bypasses the full V+J+junction embedding and computes the
87
+ junction sub-block directly (``junction_distance_matrix`` against the model's
88
+ prototypes) — a third of the memory, which matters for whole-repertoire scale.
89
+ ``space="full"`` slices the full single-chain ``TCREmp`` embedding.
90
+ """
91
+ if space == "junction" and hasattr(model, "_proto_junction"):
92
+ return junction_distance_matrix(
93
+ df["junction_aa"].to_list(), model._proto_junction,
94
+ gap_positions=model._gap_positions, threads=model.threads,
95
+ metric=getattr(model, "metric", "squared"), # match the model's coordinate options
96
+ matrix=getattr(model, "_matrix", None),
97
+ alignment=getattr(model, "_alignment", "gapblock"),
98
+ ).astype(np.float32) # float32 halves the whole-repertoire memory footprint
99
+ return _slice(model.embed(df), space).astype(np.float32, copy=False)
100
+
101
+
102
+ @dataclass
103
+ class DensitySpace:
104
+ """A single fitted TCREMP → PCA coordinate system for density comparison.
105
+
106
+ Holds the embedding model plus the standardization + PCA fitted on the pooled
107
+ observed+background matrix. :meth:`transform` projects any frame into this *same*
108
+ basis so that observed, background, control and calibration points are all directly
109
+ comparable — the invariant that makes ``E(z)`` meaningful.
110
+ """
111
+
112
+ model: object # single-chain TCREmp
113
+ space: str # "full" | "junction"
114
+ scaler: StandardScaler
115
+ pca: PCA
116
+
117
+ def transform(self, df: pl.DataFrame) -> np.ndarray:
118
+ """Embed *df* and project it into this fitted coordinate system."""
119
+ return self.pca.transform(self.scaler.transform(_embed(self.model, df, self.space)))
120
+
121
+
122
+ @dataclass
123
+ class EnrichmentResult:
124
+ """Per-observed-clonotype neighbour enrichment (input order preserved)."""
125
+
126
+ n_obs: np.ndarray # observed neighbours within radius (self excluded)
127
+ n_bg: np.ndarray # background neighbours within radius
128
+ expected: np.ndarray # expected observed count/mass under the background null
129
+ fold: np.ndarray # observed / expected == the density ratio E(z)
130
+ pvalue: np.ndarray # one-sided enrichment p-value (combined breadth×depth if abundance-aware)
131
+ qvalue: np.ndarray # Benjamini-Hochberg adjusted p-value
132
+ radius: float
133
+ # abundance-aware channels (None unless `abundance` was supplied); appendix §T.6 sec:dens-abund
134
+ score: np.ndarray | None = None # weighted in-ball mass S(z) = Σ g(a_j), self excluded
135
+ pvalue_breadth: np.ndarray | None = None # breadth channel: S vs the compound-Poisson null
136
+ pvalue_size: np.ndarray | None = None # depth/orphan channel: P(A ≥ a_j) under the size law
137
+
138
+
139
+ _WEIGHTS = {
140
+ "distinct": lambda a: np.ones(a.shape, dtype=np.float64), # g≡1: the shipped distinct count
141
+ "log1p": lambda a: np.log1p(a), # concave, robust to the Zipf tail
142
+ "anscombe": lambda a: np.sqrt(a + 0.375), # variance-stabilising root
143
+ }
144
+
145
+
146
+ def _emp_survival(a: np.ndarray) -> np.ndarray:
147
+ """Empirical size-law survival ``P(A ≥ a_j)`` (the null clone-size law ν, read off the data)."""
148
+ s = np.sort(a)
149
+ return (a.size - np.searchsorted(s, a, side="left")) / a.size
150
+
151
+
152
+ def fit_density_space(
153
+ model,
154
+ obs_df: pl.DataFrame,
155
+ bg_df: pl.DataFrame,
156
+ *,
157
+ n_components: int,
158
+ space: str = "full",
159
+ seed: int = 0,
160
+ pca_fit_cap: int | None = None,
161
+ ) -> tuple[DensitySpace, np.ndarray, np.ndarray]:
162
+ """Embed observed + background frames into one shared PCA coordinate system.
163
+
164
+ Both frames are embedded through the *same* ``model`` and a single PCA (fit on the
165
+ pooled matrix) is applied to both, guaranteeing the two point clouds live in one
166
+ comparable basis — the invariant that makes ``E(z)`` meaningful.
167
+
168
+ Args:
169
+ model: A fitted single-chain :class:`~mir.embedding.tcremp.TCREmp`.
170
+ obs_df: Observed clonotypes (``v_call``, ``j_call``, ``junction_aa``).
171
+ bg_df: Background clonotypes (generated P_gen sample or a control repertoire).
172
+ n_components: PCA dimensionality (clamped to ``min(n_samples, n_features)``);
173
+ use ``get_preset(species, locus).n_components``.
174
+ space: ``"full"`` (whole V+J+junction vector — V/J restriction is soft/continuous)
175
+ or ``"junction"`` (CDR3 sub-block only, mimicking pure-CDR3 TCRNET/ALICE and a
176
+ third of the memory at whole-repertoire scale).
177
+ seed: RNG / PCA solver seed.
178
+ pca_fit_cap: Fit the ``StandardScaler`` + PCA on at most this many randomly-sampled
179
+ pooled rows (then transform *all* rows). Lets whole repertoires be embedded
180
+ without a full-matrix PCA; ``None`` fits on everything.
181
+
182
+ Returns:
183
+ ``(density_space, obs_emb, bg_emb)`` — the fitted :class:`DensitySpace` and the
184
+ two reduced ``float`` arrays, row-aligned to ``obs_df`` / ``bg_df``.
185
+ """
186
+ obs = _embed(model, obs_df, space)
187
+ bg = _embed(model, bg_df, space)
188
+ n_total, n_feat = obs.shape[0] + bg.shape[0], obs.shape[1]
189
+
190
+ if pca_fit_cap is not None and n_total > pca_fit_cap:
191
+ rng = np.random.default_rng(seed)
192
+ take_o = min(obs.shape[0], pca_fit_cap * obs.shape[0] // n_total)
193
+ take_b = min(bg.shape[0], pca_fit_cap - take_o)
194
+ fit_rows = np.vstack([obs[rng.choice(obs.shape[0], take_o, replace=False)],
195
+ bg[rng.choice(bg.shape[0], take_b, replace=False)]])
196
+ else:
197
+ fit_rows = np.vstack([obs, bg])
198
+
199
+ scaler = StandardScaler().fit(fit_rows)
200
+ k = min(n_components, fit_rows.shape[0], n_feat)
201
+ pca = PCA(n_components=k, random_state=seed).fit(scaler.transform(fit_rows))
202
+ ds = DensitySpace(model=model, space=space, scaler=scaler, pca=pca)
203
+ return ds, pca.transform(scaler.transform(obs)), pca.transform(scaler.transform(bg))
204
+
205
+
206
+ def _mutate1(seq: str, rng) -> str:
207
+ """Apply one interior amino-acid substitution to a *different* residue."""
208
+ if len(seq) <= 2:
209
+ return seq
210
+ p = int(rng.integers(1, len(seq) - 1)) # keep the conserved C…[FW] ends
211
+ choices = _AA.replace(seq[p], "")
212
+ return seq[:p] + choices[int(rng.integers(len(choices)))] + seq[p + 1:]
213
+
214
+
215
+ def calibrate_radius(
216
+ space: DensitySpace,
217
+ *,
218
+ sample_df: pl.DataFrame | None = None,
219
+ sample: int = 2000,
220
+ seed: int = 0,
221
+ quantile: float = 0.5,
222
+ ) -> float:
223
+ """Radius corresponding to one CDR3 substitution, in *space*'s coordinate system.
224
+
225
+ Mutates one interior residue of each sampled junction and measures the embedding
226
+ drift ``‖φ(seq) − φ(mutated)‖`` through the *same* fitted transform used for
227
+ enrichment (reusing the Theory-T5 SHM-drift idea). The requested ``quantile`` of that
228
+ drift is the neighbourhood radius, so the continuous test approximates the discrete
229
+ Hamming-1 one. V/J are held fixed, isolating the junction contribution.
230
+
231
+ Args:
232
+ space: A :class:`DensitySpace` from :func:`fit_density_space`.
233
+ sample_df: Frame to draw calibration sequences from; defaults to the model's own
234
+ prototype set (the coordinate anchor).
235
+ sample: Number of prototypes to use when ``sample_df`` is ``None``.
236
+ seed: RNG seed for the substitutions.
237
+ quantile: Drift quantile to return (``0.5`` = median).
238
+
239
+ Returns:
240
+ The calibrated radius (a positive float).
241
+ """
242
+ rng = np.random.default_rng(seed)
243
+ if sample_df is None:
244
+ m = space.model
245
+ k = min(sample, len(m._proto_junction))
246
+ sample_df = pl.DataFrame(
247
+ {"v_call": m._proto_v[:k], "j_call": m._proto_j[:k],
248
+ "junction_aa": m._proto_junction[:k]}
249
+ )
250
+ junc = sample_df["junction_aa"].to_list()
251
+ mutated_df = sample_df.with_columns(
252
+ pl.Series("junction_aa", [_mutate1(s, rng) for s in junc])
253
+ )
254
+ drift = np.linalg.norm(space.transform(sample_df) - space.transform(mutated_df), axis=1)
255
+ return float(np.quantile(drift, quantile))
256
+
257
+
258
+ def _ann_neighbors(obs, bg, radius, lambda0, n_ref, *, k_max: int = 96, seed: int = 0):
259
+ """Approximate neighbour queries (pynndescent) for whole-repertoire scale.
260
+
261
+ Returns ``(rad, radius_out, n_bg, count_obs, lists_obs)`` matching the exact BallTree path:
262
+ per-obs radius ``rad``, background occupancy ``n_bg``, and two closures giving the
263
+ self-excluded observed-neighbour count and index lists within ``rad``. Neighbours come from a
264
+ kNN graph (k=``k_max``) thresholded by radius — *approximate*: recall < 1 undercounts, biasing
265
+ enrichment **down** (conservative). A saturated ball (all ``k_max`` neighbours inside ``rad``)
266
+ is undercounted and warned. For large N where exact trees are slow; use ``backend='exact'`` for
267
+ small or reproducibility-critical runs. Needs ``pynndescent`` (the ``[bench]`` extra).
268
+ """
269
+ from pynndescent import NNDescent
270
+
271
+ obs = np.ascontiguousarray(obs, dtype=np.float32) # pynndescent prefers float32
272
+ bg = np.ascontiguousarray(bg, dtype=np.float32)
273
+ n_obs_total, n_bg_total = len(obs), len(bg)
274
+ bg_index = NNDescent(bg, metric="euclidean",
275
+ n_neighbors=min(max(k_max, 16), n_bg_total - 1), random_state=seed)
276
+ obs_index = NNDescent(obs, metric="euclidean",
277
+ n_neighbors=min(k_max, n_obs_total - 1), random_state=seed)
278
+ if radius is None: # balloon: radius = k-th bg-neighbour distance, occupancy == k
279
+ k = min(max(int(round(lambda0 * n_bg_total / n_ref)), 1), n_bg_total)
280
+ rad = bg_index.query(obs, k=k)[1][:, -1].astype(np.float64)
281
+ n_bg = np.full(n_obs_total, k, dtype=np.int64)
282
+ radius_out = float(np.median(rad))
283
+ else: # fixed global radius
284
+ rad = np.full(n_obs_total, float(radius), dtype=np.float64)
285
+ bd = bg_index.query(obs, k=min(k_max, n_bg_total))[1]
286
+ n_bg = (bd <= float(radius)).sum(1).astype(np.int64)
287
+ radius_out = float(radius)
288
+ oi, od = obs_index.query(obs, k=min(k_max, n_obs_total)) # self included (dist ~0)
289
+ within = od <= rad[:, None]
290
+ if bool(within.all(axis=1).any()):
291
+ import warnings
292
+
293
+ warnings.warn(f"ANN neighbour ball saturated at k_max={k_max} for some clonotypes; their "
294
+ "counts are undercounted — raise k_max or use backend='exact'.", stacklevel=2)
295
+
296
+ def _count_obs():
297
+ return (within.sum(1) - 1).astype(np.int64)
298
+
299
+ def _lists_obs():
300
+ return [oi[i][within[i]] for i in range(n_obs_total)]
301
+
302
+ return rad, radius_out, n_bg, _count_obs, _lists_obs
303
+
304
+
305
+ def neighbor_enrichment(
306
+ obs_emb: np.ndarray,
307
+ bg_emb: np.ndarray,
308
+ radius: float | None = None,
309
+ *,
310
+ lambda0: float = 3.0,
311
+ test: str = "poisson",
312
+ pseudocount: float = 1.0,
313
+ calibrate: str | None = "median",
314
+ abundance: np.ndarray | None = None,
315
+ weight: str = "log1p",
316
+ orphan: bool = True,
317
+ backend: str = "exact",
318
+ ) -> EnrichmentResult:
319
+ """Continuous neighbour-enrichment test in one embedding coordinate system.
320
+
321
+ Two neighbourhood-scale modes (appendix §T.6):
322
+
323
+ * **balloon** (default, ``radius=None``) — *adaptive* bandwidth. Each observed point's
324
+ radius is the distance to its ``k``-th background neighbour, with ``k`` chosen so the
325
+ expected background occupancy equals ``lambda0``. The adaptive radius encodes the P_gen
326
+ density *shape* (small where generation is dense, large where sparse); it is robust to
327
+ the distance concentration that makes a single global radius fragile in high dimensions,
328
+ and keeps the test well-powered everywhere (minimum detectable ``E ≳ 1 + c/√lambda0``).
329
+ * **fixed** (``radius`` given) — one global radius (e.g. from :func:`calibrate_radius`).
330
+
331
+ A real repertoire's background is systematically *denser* than a generative P_gen model
332
+ (gene usage, sampling structure, thymic selection) — the "water level" ``π`` of §T.6.
333
+ ``calibrate="median"`` rescales the null so the bulk of clones sits at ``fold ≈ 1`` and
334
+ only clones exceeding the repertoire's own typical local density are called. Signal
335
+ (< 5 % in naive repertoires) barely moves the median, so this is robust; pass
336
+ ``calibrate=None`` to test against the raw P_gen null instead.
337
+
338
+ **Clonal abundance** (appendix §T.6 ``sec:dens-abund``). By default each clonotype counts once
339
+ (``abundance=None``), scoring only convergence *breadth*. Supplying per-clonotype clone sizes
340
+ ``abundance`` adds the *depth* channel: the in-ball count is replaced by a variance-stabilised
341
+ weighted mass ``S(z) = Σ g(a_j)`` over neighbours, with ``g`` non-decreasing and **concave**
342
+ (``weight="log1p"`` ``g=log(1+a)`` or ``"anscombe"`` ``g=√(a+3/8)``) so a hyperexpanded clone
343
+ contributes a bounded increment, not a thousand-fold one. Under H0 the mass is compound-Poisson
344
+ with dispersion index ``φ=E[g²]/E[g]`` (Prop. *abund*), so ``S`` is tested against a
345
+ moment-matched ``Gamma(μ_S/φ, φ)`` upper tail (which collapses to the Poisson count test when
346
+ ``g≡1``). With ``orphan=True`` each clone additionally gets a size p-value
347
+ ``P(A≥a_j)`` and the breadth/depth channels are combined by Fisher (``χ²₄``), recovering a
348
+ hyperexpanded *orphan* (depth, no breadth) that the count test alone misses.
349
+
350
+ Args:
351
+ obs_emb: ``(N_obs, d)`` observed embedding.
352
+ bg_emb: ``(N_bg, d)`` background embedding, **same basis** as ``obs_emb``
353
+ (produce both with :func:`fit_density_space`). Use ``M ≥ 5N`` for a stable ratio.
354
+ radius: Fixed neighbourhood radius; ``None`` selects the balloon estimator.
355
+ lambda0: Target expected background occupancy for the balloon estimator (``[1, 5]``).
356
+ test: ``"poisson"`` (ALICE analog) or ``"binomial"`` (TCRNET control analog). Applies to
357
+ the distinct-count path; the abundance-weighted path always uses the Gamma tail.
358
+ pseudocount: Added to the background count to stabilize ``p_bg``.
359
+ calibrate: ``"median"`` empirical-null water-level calibration (default), or ``None``.
360
+ abundance: Optional ``(N_obs,)`` clone sizes (e.g. ``duplicate_count``), row-aligned to
361
+ ``obs_emb``; enables the weighted/orphan channels.
362
+ weight: Concave size transform ``g`` — ``"log1p"`` (default), ``"anscombe"``, or
363
+ ``"distinct"`` (``g≡1``, ignore sizes even if ``abundance`` is given).
364
+ orphan: When abundance-aware, combine the size p-value with the breadth p-value by Fisher.
365
+ backend: neighbour engine. ``"exact"`` (default, BallTree — exact, reproducible),
366
+ ``"kdtree"`` (scipy cKDTree — **also exact**, multithreaded, 5–9× faster; the
367
+ recommended speedup), or ``"ann"`` (approximate pynndescent kNN for whole-repertoire
368
+ scale — ~30× faster than BallTree at ≥40k but recall < 1 undercounts, biasing enrichment
369
+ conservatively; see :func:`_ann_neighbors`). ``"kdtree"`` is bit-identical to
370
+ ``"exact"`` at a *fixed* ``radius``; in balloon mode counts differ by at most ±1 at the
371
+ ball boundary (a float-epsilon difference in the computed k-th-neighbour distance),
372
+ which is negligible — prefer ``"kdtree"`` unless reproducing the BallTree baseline exactly.
373
+
374
+ Returns:
375
+ An :class:`EnrichmentResult`; ``fold`` is the (calibrated) density ratio ``E(z)``. In
376
+ balloon mode ``radius`` is the median adaptive radius used. When abundance-aware, ``score``
377
+ holds the weighted mass ``S`` and ``pvalue`` is the combined breadth×depth test.
378
+ """
379
+ obs = np.ascontiguousarray(obs_emb, dtype=np.float64)
380
+ bg = np.ascontiguousarray(bg_emb, dtype=np.float64)
381
+ n_obs_total, n_bg_total = obs.shape[0], bg.shape[0]
382
+ n_ref = max(n_obs_total - 1, 1)
383
+
384
+ if backend == "exact":
385
+ obs_tree, bg_tree = BallTree(obs), BallTree(bg)
386
+ if radius is None: # balloon: fix expected background occupancy at lambda0
387
+ k = int(round(lambda0 * n_bg_total / n_ref))
388
+ k = min(max(k, 1), n_bg_total)
389
+ rad = bg_tree.query(obs, k=k)[0][:, -1] # per-point radius = k-th bg-neighbour distance
390
+ radius_out = float(np.median(rad))
391
+ else: # fixed global radius
392
+ rad = radius
393
+ radius_out = float(radius)
394
+ # count the *actual* background occupancy in the ball (>= k under ties), not a hardcoded k.
395
+ n_bg = bg_tree.query_radius(obs, rad, count_only=True).astype(np.int64)
396
+
397
+ def _count_obs():
398
+ return (obs_tree.query_radius(obs, rad, count_only=True) - 1).astype(np.int64)
399
+
400
+ def _lists_obs():
401
+ return obs_tree.query_radius(obs, rad, return_distance=False)
402
+ elif backend == "kdtree": # exact, multithreaded scipy cKDTree — 5-9x faster than BallTree
403
+ from scipy.spatial import cKDTree
404
+
405
+ obs_tree, bg_tree = cKDTree(obs), cKDTree(bg)
406
+ if radius is None:
407
+ k = int(round(lambda0 * n_bg_total / n_ref))
408
+ k = min(max(k, 1), n_bg_total)
409
+ d = bg_tree.query(obs, k=k, workers=-1)[0]
410
+ rad = d[:, -1] if d.ndim == 2 else d # cKDTree returns 1-D for k==1
411
+ radius_out = float(np.median(rad))
412
+ else:
413
+ rad = np.full(n_obs_total, float(radius))
414
+ radius_out = float(radius)
415
+ n_bg = bg_tree.query_ball_point(obs, rad, return_length=True, workers=-1).astype(np.int64)
416
+
417
+ def _count_obs():
418
+ return (obs_tree.query_ball_point(obs, rad, return_length=True, workers=-1) - 1).astype(np.int64)
419
+
420
+ def _lists_obs():
421
+ return [np.asarray(x, dtype=np.intp)
422
+ for x in obs_tree.query_ball_point(obs, rad, workers=-1)]
423
+ elif backend == "ann": # approximate NN (pynndescent) for whole-repertoire scale
424
+ rad, radius_out, n_bg, _count_obs, _lists_obs = _ann_neighbors(
425
+ obs, bg, radius, lambda0, n_ref)
426
+ else:
427
+ raise ValueError(f"backend must be 'exact' or 'ann', got {backend!r}")
428
+ p_bg = (n_bg + pseudocount) / (n_bg_total + pseudocount)
429
+ expected = n_ref * p_bg
430
+
431
+ weighted = abundance is not None and weight != "distinct"
432
+ if not weighted:
433
+ n_obs = _count_obs()
434
+ if calibrate == "median":
435
+ pos = expected > 0
436
+ c = np.median(n_obs[pos]) / np.median(expected[pos]) if pos.any() else 1.0
437
+ expected = expected * max(float(c), 1.0) # water level: centre the bulk at fold ~ 1
438
+ elif calibrate is not None:
439
+ raise ValueError(f"calibrate must be 'median' or None, got {calibrate!r}")
440
+ if test == "poisson":
441
+ pvalue = stats.poisson.sf(n_obs - 1, expected)
442
+ elif test == "binomial":
443
+ pvalue = stats.binom.sf(n_obs - 1, n_ref, np.clip(expected / n_ref, 0.0, 1.0))
444
+ else:
445
+ raise ValueError(f"test must be 'poisson' or 'binomial', got {test!r}")
446
+ pvalue = np.clip(np.asarray(pvalue, dtype=np.float64), 0.0, 1.0)
447
+ qvalue = stats.false_discovery_control(pvalue, method="bh")
448
+ return EnrichmentResult(
449
+ n_obs=n_obs, n_bg=n_bg, expected=expected, fold=n_obs / expected,
450
+ pvalue=pvalue, qvalue=qvalue, radius=radius_out,
451
+ )
452
+
453
+ # --- abundance-aware weighted count (appendix §T.6 sec:dens-abund) ---
454
+ if weight not in _WEIGHTS:
455
+ raise ValueError(f"weight must be one of {sorted(_WEIGHTS)}, got {weight!r}")
456
+ a = np.asarray(abundance, dtype=np.float64)
457
+ if a.shape[0] != n_obs_total:
458
+ raise ValueError(f"abundance length {a.shape[0]} != N_obs {n_obs_total}")
459
+ g = _WEIGHTS[weight](a)
460
+ idx = _lists_obs() # neighbour index lists (exact BallTree or approximate ANN)
461
+ # vectorised weighted mass: flatten the ragged neighbour lists and one bincount, instead of a
462
+ # Python sum per point (the whole-repertoire hot path — ~400k points).
463
+ counts = np.fromiter((ix.size for ix in idx), dtype=np.int64, count=n_obs_total)
464
+ cols = np.concatenate(idx) if n_obs_total else np.empty(0, dtype=np.intp)
465
+ rows = np.repeat(np.arange(n_obs_total), counts)
466
+ n_obs = counts - 1 # exclude self
467
+ S = np.bincount(rows, weights=g[cols], minlength=n_obs_total) - g # weighted mass, excl self
468
+ mean_g = float(g.mean())
469
+ phi = float((g * g).mean() / mean_g) # dispersion index E[g²]/E[g] (=1 for g≡1)
470
+
471
+ if calibrate == "median":
472
+ pos = expected > 0
473
+ mu_raw = expected * mean_g
474
+ c = np.median(S[pos]) / np.median(mu_raw[pos]) if pos.any() else 1.0
475
+ expected = expected * max(float(c), 1.0)
476
+ elif calibrate is not None:
477
+ raise ValueError(f"calibrate must be 'median' or None, got {calibrate!r}")
478
+ mu_S = expected * mean_g # expected weighted neighbour mass under H0
479
+ # compound-Poisson null: Var = mu_S·φ -> moment-matched Gamma(shape=mu_S/φ, scale=φ) upper tail
480
+ p_breadth = np.clip(stats.gamma.sf(S, a=np.clip(mu_S / phi, 1e-6, None), scale=phi), 0.0, 1.0)
481
+
482
+ if orphan:
483
+ p_size = _emp_survival(a) # depth channel: P(A ≥ a_j) under the empirical size law
484
+ chi2 = -2.0 * (np.log(np.clip(p_breadth, 1e-300, 1.0)) + np.log(np.clip(p_size, 1e-300, 1.0)))
485
+ pvalue = np.clip(stats.chi2.sf(chi2, df=4), 0.0, 1.0)
486
+ else:
487
+ p_size = None
488
+ pvalue = p_breadth
489
+ qvalue = stats.false_discovery_control(pvalue, method="bh")
490
+ return EnrichmentResult(
491
+ n_obs=n_obs, n_bg=n_bg, expected=mu_S, fold=S / np.where(mu_S > 0, mu_S, 1.0),
492
+ pvalue=pvalue, qvalue=qvalue, radius=radius_out,
493
+ score=S, pvalue_breadth=p_breadth, pvalue_size=p_size,
494
+ )
495
+
496
+
497
+ def enriched_mask(
498
+ res: EnrichmentResult, *, alpha: float = 0.05, min_fold: float = 1.0, min_neighbors: int = 2
499
+ ) -> np.ndarray:
500
+ """Boolean hit mask: ``q < alpha`` & ``fold > min_fold`` & ``≥ min_neighbors`` (self + neighbours).
501
+
502
+ ``min_neighbors=2`` reproduces the legacy "self plus at least one neighbour" criterion
503
+ (``n_obs`` excludes self, so the threshold is ``n_obs ≥ min_neighbors − 1``).
504
+
505
+ For an abundance-aware result (``res.score`` set) the ``min_fold``/``min_neighbors`` breadth
506
+ gates are dropped: the combined breadth×depth ``q`` already governs, so a hyperexpanded orphan
507
+ (significant depth, no neighbours) is kept rather than filtered out.
508
+ """
509
+ if res.score is not None:
510
+ return res.qvalue < alpha
511
+ return (
512
+ (res.qvalue < alpha)
513
+ & (res.fold > min_fold)
514
+ & (res.n_obs >= min_neighbors - 1)
515
+ )
516
+
517
+
518
+ def denoise_and_cluster(
519
+ obs_emb: np.ndarray,
520
+ res: EnrichmentResult,
521
+ *,
522
+ alpha: float = 0.05,
523
+ min_fold: float = 1.0,
524
+ min_neighbors: int = 2,
525
+ **cluster_kw,
526
+ ) -> tuple[np.ndarray, np.ndarray]:
527
+ """Noise-filter then cluster: DBSCAN the enriched subset, non-enriched → label ``-1``.
528
+
529
+ Background subtraction (:func:`enriched_mask`) removes the naive-repertoire noise, then
530
+ :func:`mir.bench.metrics.cluster` groups the surviving hits into convergent motifs.
531
+
532
+ Returns:
533
+ ``(labels, mask)`` — ``labels`` is length ``N_obs`` (``-1`` for non-enriched or
534
+ DBSCAN-noise points); ``mask`` is the enriched-hit boolean.
535
+ """
536
+ from mir.bench.metrics import cluster
537
+
538
+ mask = enriched_mask(res, alpha=alpha, min_fold=min_fold, min_neighbors=min_neighbors)
539
+ labels = np.full(obs_emb.shape[0], -1, dtype=np.int64)
540
+ if int(mask.sum()) >= 5: # DBSCAN eps estimation needs a handful of points
541
+ labels[mask] = cluster(np.ascontiguousarray(obs_emb[mask]), **cluster_kw)
542
+ return labels, mask
543
+
544
+
545
+ def generate_background(
546
+ locus: str, n: int, *, source: str = "learned", seed: int = 0, productive_only: bool = True
547
+ ) -> pl.DataFrame:
548
+ """Draw ``n`` synthetic background clonotypes from the bundled vdjtools P_gen model.
549
+
550
+ Lazy ``vdjtools.model`` import (``vdjtools`` is a core dependency; imported here only to defer
551
+ the cost). Returns a frame with
552
+ ``junction_aa``, ``v_call``, ``j_call`` — ready for ``TCREmp.embed`` — sampled from the
553
+ vdjtools VDJ-rearrangement model, i.e. a Monte-Carlo estimate of the
554
+ ``f_gen = φ_# P_gen`` pushforward (the ALICE analog background).
555
+
556
+ Args:
557
+ locus: Bundled model locus (e.g. ``"TRB"``).
558
+ n: Number of sequences to generate.
559
+ source: Bundled model source — ``"learned"`` (vdjtools EM-inferred, the default) or
560
+ ``"olga"`` (legacy OLGA-parameter bootstrap, retained only for comparison).
561
+ seed: Sampling seed.
562
+ productive_only: Reject out-of-frame / stop-codon rearrangements.
563
+ """
564
+ from vdjtools.model import generate, load_bundled
565
+
566
+ model = load_bundled(locus, source)
567
+ df = generate.generate(model, n, seed=seed, productive_only=productive_only)
568
+ return df.select(list(_REQUIRED_COLS))
569
+
570
+
571
+ if __name__ == "__main__":
572
+ # Self-check: an injected tight cluster in an otherwise background-like observed set
573
+ # must come out enriched; background-scattered observed points must not.
574
+ rng = np.random.default_rng(0)
575
+ d = 10
576
+ bg = rng.standard_normal((5000, d))
577
+ obs = rng.standard_normal((1000, d))
578
+ center = np.full(d, 2.5) # a low-background region
579
+ obs[:50] = center + 0.05 * rng.standard_normal((50, d)) # 50-point convergent cluster
580
+ for label, res in (("fixed", neighbor_enrichment(obs, bg, radius=0.5)),
581
+ ("balloon", neighbor_enrichment(obs, bg, lambda0=3.0))):
582
+ mask = enriched_mask(res)
583
+ injected, background = mask[:50], mask[50:]
584
+ assert injected.mean() > 0.8, (label, injected.mean())
585
+ assert background.mean() < 0.05, (label, background.mean())
586
+ assert res.fold[:50].mean() > res.fold[50:].mean()
587
+ print(f"mir.density [{label}] OK; injected hit-rate {injected.mean():.2f}, "
588
+ f"background hit-rate {background.mean():.3f}, "
589
+ f"median injected fold {np.median(res.fold[:50]):.1f}")
590
+
591
+ # abundance-aware: sizes add the depth channel. The convergent cluster (breadth) stays enriched;
592
+ # a hyperexpanded clone gets the smallest size p-value (the orphan side-channel, for individual
593
+ # inspection — a lone orphan is BH-conservative among N clones); concavity bounds the Zipf tail.
594
+ a = np.ones(1000)
595
+ a[500] = 5000.0 # a hyperexpanded clone among background
596
+ res = neighbor_enrichment(obs, bg, lambda0=3.0, abundance=a, weight="log1p")
597
+ mask = enriched_mask(res)
598
+ assert res.score is not None and res.pvalue_size is not None
599
+ assert mask[:50].mean() > 0.8, mask[:50].mean() # breadth channel keeps the convergent cluster
600
+ assert res.pvalue_size[500] == res.pvalue_size.min() # depth channel flags the big clone
601
+ assert res.score.max() < 100 # concavity: O(log) mass, not O(size)
602
+ print(f"mir.density [abundance] OK; cluster hit-rate {mask[:50].mean():.2f}, "
603
+ f"orphan size-p {res.pvalue_size[500]:.4f}, max weighted mass {res.score.max():.1f}")
@@ -0,0 +1,5 @@
1
+ """Distances.
2
+
3
+ Pure-Python edit-distance helpers plus a resource-backed germline (V/J/CDR1/CDR2)
4
+ distance lookup. CDR3/junction scoring is delegated to ``seqtree.gapblock``.
5
+ """