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/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}")
|