seqtree 0.2.0__tar.gz → 0.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {seqtree-0.2.0 → seqtree-0.4.0}/.gitignore +5 -0
- seqtree-0.4.0/CHANGELOG.md +287 -0
- {seqtree-0.2.0 → seqtree-0.4.0}/CMakeLists.txt +2 -0
- {seqtree-0.2.0 → seqtree-0.4.0}/PKG-INFO +72 -8
- {seqtree-0.2.0 → seqtree-0.4.0}/README.md +69 -6
- {seqtree-0.2.0 → seqtree-0.4.0}/ROADMAP.md +11 -5
- seqtree-0.4.0/SOURCES.md +97 -0
- {seqtree-0.2.0 → seqtree-0.4.0}/appendix/evalue.pdf +0 -0
- {seqtree-0.2.0 → seqtree-0.4.0}/appendix/evalue.tex +206 -85
- {seqtree-0.2.0 → seqtree-0.4.0}/appendix/refs.bib +3 -1
- seqtree-0.4.0/examples/01_gapped_search.py +118 -0
- seqtree-0.4.0/examples/02_sequence_dendrogram.py +144 -0
- seqtree-0.4.0/examples/03_indel_positions.py +169 -0
- seqtree-0.4.0/examples/04_island_profile.py +151 -0
- {seqtree-0.2.0 → seqtree-0.4.0}/include/seqtree/seqtree.hpp +79 -2
- {seqtree-0.2.0 → seqtree-0.4.0}/include/seqtree/types.hpp +16 -7
- {seqtree-0.2.0 → seqtree-0.4.0}/pyproject.toml +5 -3
- seqtree-0.4.0/python/seqtree/__init__.py +70 -0
- seqtree-0.4.0/python/seqtree/control.py +321 -0
- seqtree-0.4.0/python/seqtree/data/control_human_trb_aa.txt.gz +0 -0
- seqtree-0.4.0/python/seqtree/evalue.py +178 -0
- seqtree-0.4.0/python/seqtree/gapblock.py +673 -0
- {seqtree-0.2.0 → seqtree-0.4.0}/python/seqtree/layout.py +12 -0
- seqtree-0.4.0/python/seqtree/pairwise.py +167 -0
- {seqtree-0.2.0 → seqtree-0.4.0}/python/seqtree/pmhc.py +2 -0
- seqtree-0.4.0/python/seqtree/seeds.py +149 -0
- seqtree-0.4.0/skills/seqtree/SKILL.md +246 -0
- {seqtree-0.2.0 → seqtree-0.4.0}/src/_bindings.cpp +186 -8
- seqtree-0.4.0/src/atomic_write.hpp +75 -0
- seqtree-0.4.0/src/blosum45.inc +32 -0
- seqtree-0.4.0/src/blosum80.inc +32 -0
- {seqtree-0.2.0 → seqtree-0.4.0}/src/engine_seqtm.cpp +2 -4
- {seqtree-0.2.0 → seqtree-0.4.0}/src/engines.hpp +1 -1
- seqtree-0.4.0/src/gapblock.cpp +135 -0
- {seqtree-0.2.0 → seqtree-0.4.0}/src/index.cpp +71 -43
- {seqtree-0.2.0 → seqtree-0.4.0}/src/kmer_index.cpp +26 -21
- seqtree-0.4.0/src/pairwise.cpp +354 -0
- {seqtree-0.2.0 → seqtree-0.4.0}/src/searcher.cpp +17 -7
- {seqtree-0.2.0 → seqtree-0.4.0}/src/substitution_matrix.cpp +37 -0
- seqtree-0.2.0/python/seqtree/__init__.py +0 -44
- seqtree-0.2.0/python/seqtree/control.py +0 -96
- seqtree-0.2.0/python/seqtree/data/control_human_trb_aa.txt.gz +0 -0
- seqtree-0.2.0/python/seqtree/evalue.py +0 -79
- {seqtree-0.2.0 → seqtree-0.4.0}/.gitattributes +0 -0
- {seqtree-0.2.0 → seqtree-0.4.0}/LICENSE +0 -0
- {seqtree-0.2.0 → seqtree-0.4.0}/appendix/.gitignore +0 -0
- {seqtree-0.2.0 → seqtree-0.4.0}/appendix/.latexmkrc +0 -0
- {seqtree-0.2.0 → seqtree-0.4.0}/appendix/Makefile +0 -0
- {seqtree-0.2.0 → seqtree-0.4.0}/appendix/epitope_detection.pdf +0 -0
- {seqtree-0.2.0 → seqtree-0.4.0}/appendix/evalue_matrix.pdf +0 -0
- {seqtree-0.2.0 → seqtree-0.4.0}/appendix/mhc1_rocpr.pdf +0 -0
- {seqtree-0.2.0 → seqtree-0.4.0}/appendix/mhc2_rocpr.pdf +0 -0
- {seqtree-0.2.0 → seqtree-0.4.0}/include/seqtree/kmer_index.hpp +0 -0
- {seqtree-0.2.0 → seqtree-0.4.0}/python/seqtree/pmhc_evalue.py +0 -0
- {seqtree-0.2.0 → seqtree-0.4.0}/python/seqtree/py.typed +0 -0
- {seqtree-0.2.0 → seqtree-0.4.0}/src/blosum62.inc +0 -0
- {seqtree-0.2.0 → seqtree-0.4.0}/src/codec.cpp +0 -0
- {seqtree-0.2.0 → seqtree-0.4.0}/src/engine_seqtrie.cpp +0 -0
- {seqtree-0.2.0 → seqtree-0.4.0}/src/pam100.inc +0 -0
- {seqtree-0.2.0 → seqtree-0.4.0}/src/pam250.inc +0 -0
- {seqtree-0.2.0 → seqtree-0.4.0}/src/positional_matrix.cpp +0 -0
- {seqtree-0.2.0 → seqtree-0.4.0}/src/structural.inc +0 -0
- {seqtree-0.2.0 → seqtree-0.4.0}/src/trie.cpp +0 -0
- {seqtree-0.2.0 → seqtree-0.4.0}/src/trie.hpp +0 -0
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `seqtree`. Dates are release dates; the project is pre-1.0, so a **minor**
|
|
4
|
+
bump may carry breaking changes.
|
|
5
|
+
|
|
6
|
+
## [0.4.0] — 2026-07-11
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- **`seqtree.pairwise` — Needleman-Wunsch and Smith-Waterman, so ordinary protein alignment no
|
|
11
|
+
longer needs BioPython.** Everything else in seqtree *minimises a non-negative penalty*, which
|
|
12
|
+
is what a search ball and an E-value need. These **maximise a raw log-odds similarity**, the way
|
|
13
|
+
BLAST and BioPython do, because that is what a pairwise alignment means.
|
|
14
|
+
|
|
15
|
+
| | |
|
|
16
|
+
|---|---|
|
|
17
|
+
| `pairwise.score(q, r, matrix, mode=...)` | optimal score, `O(min(m,n))` memory |
|
|
18
|
+
| `pairwise.align(...)` | plus the aligned strings and ops |
|
|
19
|
+
| `pairwise.score_matrix(queries, refs, ...)` | dense `n × K`, GIL released, zero-copy numpy |
|
|
20
|
+
| `pairwise.dist_matrix(...)` | `d = s(a,a) + s(b,b) − 2·s(a,b)`: non-negative, zero on the diagonal |
|
|
21
|
+
|
|
22
|
+
`mode="global"` is Needleman-Wunsch, `mode="local"` Smith-Waterman, and **`gap_open == gap_extend`
|
|
23
|
+
gives linear gaps** — no separate mode. A gap run of length `L` costs `gap_open + (L-1)·gap_extend`,
|
|
24
|
+
and global charges end gaps (true NW, not semi-global).
|
|
25
|
+
|
|
26
|
+
**It is a drop-in.** `tests/python/test_pairwise.py` runs it against `Bio.Align.PairwiseAligner`
|
|
27
|
+
as an oracle over three matrices × ten gap/mode settings × sixty sequence shapes — **zero
|
|
28
|
+
disagreements**, including on real germline V genes. BioPython is a *test-only* dependency;
|
|
29
|
+
seqtree still has **zero required runtime dependencies** and never imports it.
|
|
30
|
+
|
|
31
|
+
Measured on an M3 (all-against-all, BLOSUM62, global, 11/1):
|
|
32
|
+
|
|
33
|
+
| sequence length | seqtree, 1 thread | seqtree, 16 threads | BioPython | speedup |
|
|
34
|
+
|---|---|---|---|---|
|
|
35
|
+
| 15 (a junction) | 1.7 M pairs/s | **20.1 M pairs/s** | 0.31 M pairs/s | **65×** |
|
|
36
|
+
| 90 (a germline V gene) | 72 k pairs/s | **893 k pairs/s** | 10 k pairs/s | **87×** |
|
|
37
|
+
|
|
38
|
+
- **`SubstitutionMatrix.similarity(a, b)`** — the raw signed log-odds, alongside the existing
|
|
39
|
+
non-negative `penalty(a, b)`. The Gram transform `pen = s(a,a) + s(b,b) − 2·s(a,b)` is **lossy**:
|
|
40
|
+
it forces the diagonal to zero and destroys `s(a,a)`, so a similarity cannot be recovered from a
|
|
41
|
+
penalty. Both views are now stored.
|
|
42
|
+
|
|
43
|
+
- **`SubstitutionMatrix.blosum45()` and `.blosum80()`**, and the names `"BLOSUM45"` / `"BLOSUM80"`
|
|
44
|
+
wherever a matrix name is accepted. Shallower and deeper than BLOSUM62 — for remote and close
|
|
45
|
+
homologs respectively.
|
|
46
|
+
|
|
47
|
+
## [0.3.1] — 2026-07-10 (never published; folded into 0.4.0)
|
|
48
|
+
|
|
49
|
+
> Tagged but not released to PyPI. Everything below ships in **0.4.0**, so upgrading from 0.3.0
|
|
50
|
+
> straight to 0.4.0 picks it all up. Kept as its own section because it is a distinct set of fixes.
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
### Fixed
|
|
54
|
+
|
|
55
|
+
- **A cold cache shared by concurrent processes could hand back a half-written index.**
|
|
56
|
+
`Index::save` wrote straight into the destination, so for the whole duration of the write the
|
|
57
|
+
file existed but was truncated. A second process that checked `os.path.exists(cache)` in that
|
|
58
|
+
window loaded a stub and raised `RuntimeError: truncated or corrupt index`. On a 45 MB control
|
|
59
|
+
index the window is ~55 ms, and a reader racing a writer hit it **10 times out of 10**.
|
|
60
|
+
|
|
61
|
+
This is the first-use-only failure of any multi-process fan-out sharing `~/.cache`: pytest-xdist,
|
|
62
|
+
a Snakemake or Nextflow pipeline calling `load_control` in parallel, a `multiprocessing` pool.
|
|
63
|
+
Once the cache is warm it is read-only and was always safe. CI matrix jobs were never affected —
|
|
64
|
+
separate runners, separate caches.
|
|
65
|
+
|
|
66
|
+
`Index::save` and `KmerIndex::save` now serialize into a uniquely-named temporary beside the
|
|
67
|
+
destination and `rename` it into place. Rename is atomic on the same filesystem, on POSIX and
|
|
68
|
+
Windows alike, so a reader sees either the previous complete file or the new complete file and
|
|
69
|
+
never a partial one. A failed save cleans up its temporary and leaves any pre-existing index
|
|
70
|
+
intact.
|
|
71
|
+
|
|
72
|
+
- **A corrupt or stale cache now rebuilds instead of raising.** A file truncated by a full disk,
|
|
73
|
+
left by a killed process, or written by an older seqtree sent `load_control` into an exception;
|
|
74
|
+
it now falls back to rebuilding. The cache was always best-effort and now behaves that way.
|
|
75
|
+
|
|
76
|
+
- **The control cache is content-addressed, so a stale cache can no longer be served silently.**
|
|
77
|
+
The key was `control_{name}_{size}.sqtree`, which named neither the **alphabet**, nor the
|
|
78
|
+
**seed**, nor the **source data**. Three consequences, all live:
|
|
79
|
+
|
|
80
|
+
- Two calls differing only in `seed` — which must draw *different* reservoir samples — shared one
|
|
81
|
+
cache file, so the second silently received the first's sequences. Same for `alphabet`.
|
|
82
|
+
- An upgrade that changed the bundled control kept the same filename, so a warm cache served the
|
|
83
|
+
**previous release's** control. This is exactly how 0.3.0's corrected (uniform) control could be
|
|
84
|
+
masked by a stale 0.2.0 (abundance-head) cache — and why 0.3.0's notes had to ask people to
|
|
85
|
+
`rm ~/.cache/seqtree/control_*.sqtree` by hand.
|
|
86
|
+
|
|
87
|
+
The key now carries a fingerprint of the bundled asset's own bytes (or, on the download path, the
|
|
88
|
+
source and seed), so a control that changed simply misses the old cache. Superseded caches,
|
|
89
|
+
including pre-fingerprint ones from earlier releases, are deleted on the next build.
|
|
90
|
+
|
|
91
|
+
**You no longer need to clear `~/.cache/seqtree` when upgrading.** Doing so is harmless.
|
|
92
|
+
|
|
93
|
+
### Added
|
|
94
|
+
|
|
95
|
+
- **`load_control` takes an inter-process lock around build-and-save when `filelock` is available**
|
|
96
|
+
(it arrives with `huggingface_hub`). This is an optimisation, not the fix: correctness comes from
|
|
97
|
+
the atomic rename and holds with no lock at all. What the lock saves is work — without it, a cold
|
|
98
|
+
fan-out of N workers has every worker build the same 250k-clonotype index and discard N−1 of them.
|
|
99
|
+
seqtree still has **zero required runtime dependencies**; the import is guarded.
|
|
100
|
+
|
|
101
|
+
## [0.3.0] — 2026-07-10
|
|
102
|
+
|
|
103
|
+
Gap-block alignment, calibrated cutoffs, seed significance — the removal of several engine paths
|
|
104
|
+
that returned confident wrong answers, and a corrected background control that changes every
|
|
105
|
+
E-value.
|
|
106
|
+
|
|
107
|
+
### Breaking
|
|
108
|
+
|
|
109
|
+
- **The bundled control is a different set of sequences.** It was the *abundance head* of the
|
|
110
|
+
upstream repertoire — the 250,000 most expanded clonotypes — because both `gen_control.py` and
|
|
111
|
+
`_download` took the first `size` unique rows of a count-descending table. `appendix/evalue.tex`
|
|
112
|
+
(`ass:indep`) assumes the control's unique clonotypes are i.i.d. from `P₀`. Measured against a
|
|
113
|
+
uniform sample of the same size, the head is **25.8× more self-similar** (P(Hamming≤2 | equal
|
|
114
|
+
length) 3.11×10⁻³ vs 1.20×10⁻⁴) and carries **3.1× the ball mass** at a BLOSUM62 budget of 40
|
|
115
|
+
(mean n_C 110.1 vs 35.5). Both are now uniform reservoir samples over unique **productive**
|
|
116
|
+
clonotypes, seeded and shuffled so any prefix is itself a valid sub-sample.
|
|
117
|
+
|
|
118
|
+
**Every E-value moves.** Delete `~/.cache/seqtree/control_*.sqtree` after upgrading — a warm
|
|
119
|
+
cache from 0.2.0 would otherwise be served in place of the corrected control. (Fixed in 0.3.1:
|
|
120
|
+
the cache is now content-addressed and a stale one simply misses. Upgrading straight from 0.2.0
|
|
121
|
+
to ≥0.3.1 needs no manual step.) Numbers derived from the control are corrected throughout this
|
|
122
|
+
file, `seeds.py`, `SKILL.md` and the appendix.
|
|
123
|
+
- **Controls are filtered to productive clonotypes.** VDJtools marks out-of-frame rearrangements
|
|
124
|
+
with `_` and in-frame stops with `*`; 13.7% of the mouse TRB table is out of frame. `_` cannot be
|
|
125
|
+
repaired at the amino-acid level — VDJtools collapses a *run* of untranslatable positions into one
|
|
126
|
+
character, so the residue count is already gone — and out-of-frame junctions escape thymic
|
|
127
|
+
selection, making them an estimator of `P_gen`, which `lem:hierarchy` says is not `P₀`.
|
|
128
|
+
`load_control("mouse_trb_aa")` previously raised on `_`; it now yields 694,241 clonotypes.
|
|
129
|
+
- **`engine="auto"` now always resolves to `seqtm`.** It previously routed matrix-plus-indel
|
|
130
|
+
searches to `seqtrie`, whose budget defaults to `INT_MAX/4`, so
|
|
131
|
+
`SearchParams(max_subs=1, max_ins=1, matrix="BLOSUM62")` silently returned **every reference in
|
|
132
|
+
the index**. `seqtrie` ignores per-type edit caps and can never honour them; `auto` will not pick
|
|
133
|
+
it. Passing a matrix to `seqtrie` without an explicit `max_penalty` now raises.
|
|
134
|
+
- **`Mode::Local` / `mode="local"` deleted.** It was a no-op: the field was stored and never read,
|
|
135
|
+
with zero call sites across `seqtree`, `vdjmatch` and `mhcmatch`.
|
|
136
|
+
- **`GapPrior` takes `(block_start, block_length, longer_length)`** instead of
|
|
137
|
+
`(block_start, shorter_length)`. `central_prior`'s output is bit-identical
|
|
138
|
+
(`|2i − (m−d)| == |2i + d − m|`), and a frozen table pins that.
|
|
139
|
+
- **`gap_extend` is now honoured.** `Index.align` is a real Gotoh affine alignment; previously
|
|
140
|
+
`gap_extend=1` and `gap_extend=99` produced identical scores and ops.
|
|
141
|
+
|
|
142
|
+
### Fixed
|
|
143
|
+
|
|
144
|
+
- `pairwise_batch` silently inverted `n_ins`/`n_dels` when transposing, but only when
|
|
145
|
+
`len(a) >= len(b)` — a size-dependent inversion.
|
|
146
|
+
- `Index.align` did not validate the query alphabet (unlike `search_into`), and accepted negative
|
|
147
|
+
gap costs.
|
|
148
|
+
- `Limits::max_hits` was set and never read; in `mode="all"` it truncated an **unsorted** list.
|
|
149
|
+
- Stale docstring on `SubstitutionMatrix.from_similarity` (claimed `max(s_aa, s_bb) − s_ab`; the
|
|
150
|
+
code is the Gram form `s_aa + s_bb − 2·s_ab`).
|
|
151
|
+
|
|
152
|
+
### Added
|
|
153
|
+
|
|
154
|
+
- **`SubstitutionMatrix.scale()`** — the median mismatch penalty (BLOSUM62 → 14). Gap costs must
|
|
155
|
+
live on the matrix's scale; the old `gap_open=1` default made gaps ~14× cheaper than
|
|
156
|
+
substitutions, so `align()` would gap an equal-length pair rather than substitute. Use
|
|
157
|
+
`gap_open = 2 * matrix.scale()`.
|
|
158
|
+
- **`seqtree.gapblock`** — single-contiguous-gap-block alignment for anchored junction loops.
|
|
159
|
+
`gapblock_score` is the exact `O(min(m,n))` optimum (0 mismatches in 55,727 pairs against
|
|
160
|
+
brute-force layout enumeration). `GapBlockIndex` reuses the existing Hamming engine over
|
|
161
|
+
deletion variants; no new C++.
|
|
162
|
+
- **Gap priors** — `central_prior(lam)`, `profile_prior(lam, w)`, `frame_prior(lam, c)` and
|
|
163
|
+
`embed_in_frame(seq, width, c)`. A sequence score alone cannot place the block: a hard central
|
|
164
|
+
pin agrees with the flat, score-only choice on only 10.6% of pairs.
|
|
165
|
+
- **`gapblock.score_matrix` and `ScoreMatrix`** — the dense `n × K` counterpart of
|
|
166
|
+
`GapBlockIndex.search`, for prototype-distance embeddings, where nothing can be pruned because
|
|
167
|
+
the distance to every reference *is* the output. C++, GIL released, one thread per core. On an
|
|
168
|
+
M3 against 3,000 prototypes: **51.3 M pairs/s** single-threaded, **532.7 M** on 16 cores, versus
|
|
169
|
+
0.41 M for pure-Python `gapblock_score` — while evaluating all `L+1` block positions, not a
|
|
170
|
+
fixed shortlist. The prior is flattened once into an `[m][d][i]` cube, so the kernel never
|
|
171
|
+
re-enters Python. `ScoreMatrix` carries the CPython buffer protocol: `numpy.asarray` wraps it
|
|
172
|
+
without copying, and seqtree keeps its zero runtime dependencies.
|
|
173
|
+
- **`gapblock.positions_prior(starts)`** — restrict the block to a fixed set of starts, negative
|
|
174
|
+
values counting from the end, reproducing the `gap_positions=(3, 4, -4, -3)` convention that
|
|
175
|
+
other junction aligners hardcode. Shipped for interoperability, not as a recommendation: at a
|
|
176
|
+
matched false-positive rate on human TRB, candidate starts reach precision 0.156 against 0.414
|
|
177
|
+
for a single hard-pinned centre.
|
|
178
|
+
- **`gapblock.IslandProfile`** — a per-island position weight matrix whose column penalty is
|
|
179
|
+
measured against the column's own consensus, `pen(j, a) = round(lam·log(p_max_j / p_j(a)))`. A
|
|
180
|
+
textbook log-odds score is signed and therefore not a ball; this one is `>= 0`, zero on the
|
|
181
|
+
consensus, and flows through `thetas_from_scores` unchanged. The frame column defaults to the
|
|
182
|
+
entropy-optimal one, which is modal at `c = 6` on real islands — where crystal structures put
|
|
183
|
+
the block.
|
|
184
|
+
|
|
185
|
+
Whether it beats scoring against every member **depends entirely on the cutoff**, which moves
|
|
186
|
+
with `N`: the E-value's `k = floor(e_target·M/N)` is how many control neighbours the cutoff may
|
|
187
|
+
admit, so the FPR is `k/M`. Over 108 calibrated VDJdb islands of ≥10 members (human TRB, three
|
|
188
|
+
held-out splits, paired bootstrap over islands, 250k control negatives):
|
|
189
|
+
|
|
190
|
+
| regime | FPR | min-over-members | `IslandProfile` | difference [95% CI] |
|
|
191
|
+
|---|---|---|---|---|
|
|
192
|
+
| loose reference | 1% | **99.5%** | 99.1% | −0.40 [−1.09, +0.14] |
|
|
193
|
+
| per-epitope islands (`N`= group, median 88) | 0.0568% | 88.3% | **89.3%** | +0.93 [−0.80, +2.79] |
|
|
194
|
+
| repertoire annotation (`N`≈20k) | 0.0012% | 37.6% | **48.5%** | +10.90 [+7.69, +14.21] |
|
|
195
|
+
|
|
196
|
+
So: **no significant difference while building the islands**, a large one when using them to
|
|
197
|
+
annotate a repertoire (on islands ≥50 members, 9.8% vs 22.6%). At `N≈20k` and `e_target=0.05`,
|
|
198
|
+
`k=0` and `thetas_from_scores` returns `-1` — the rule of three certifies no `E` below
|
|
199
|
+
`3N/M = 0.236`, and that is the cutoff the third row uses.
|
|
200
|
+
|
|
201
|
+
It does **not** generalise: same-epitope junctions in a different island are recovered by
|
|
202
|
+
neither representation. Nor is it a compression — 1,176 B against 182 B of member strings,
|
|
203
|
+
break-even at 84 members.
|
|
204
|
+
- **`threshold_for_evalue` / `thetas_from_scores`** — invert `Ê = (N/M)·n_C` into the score cutoff
|
|
205
|
+
that achieves a target E, **per query**. Exact rather than a root-find, because scores are
|
|
206
|
+
integers. Returns `-1` where `e_target < 3N/M`, i.e. where the control is too small to certify
|
|
207
|
+
the bar.
|
|
208
|
+
- **`seqtree.seeds`** — `core_kmers` and `SeedIndex` give control-calibrated E-values for shared
|
|
209
|
+
core k-mers. A shared rare central k-mer is ~4× enriched among co-specific pairs, but covers only
|
|
210
|
+
~0.5% of them: seeds buy precision, not recall.
|
|
211
|
+
- **`bench/bench_gapblock.py`** — the gap-freedom ladder, from a hard central pin through priors to
|
|
212
|
+
unrestricted affine.
|
|
213
|
+
|
|
214
|
+
### Measured
|
|
215
|
+
|
|
216
|
+
Numbers that constrain the API, all reproducible from `bench/` and the downstream repos:
|
|
217
|
+
|
|
218
|
+
- **One gap block is enough.** Against a model-independent structural oracle (iterative
|
|
219
|
+
superposition + unrestricted affine DP) over 3,049 crystal junction pairs from 199 unique
|
|
220
|
+
sequences, the true correspondence is a single contiguous block in **95.2–100%** of cases for
|
|
221
|
+
every `d = 1..4`. Forcing one block costs no median CA-RMSD.
|
|
222
|
+
- **The restriction is free where it applies, and protective where it does not.** At
|
|
223
|
+
`gap_open = 2*scale`, gap-block equals unrestricted affine on **98.8%** of related pairs (one
|
|
224
|
+
indel + 0–2 substitutions). On *unrelated* pairs affine undercuts it by a median of **106**
|
|
225
|
+
penalty units — affine inventing an alignment that does not exist. Extra gap freedom buys
|
|
226
|
+
manufactured similarity.
|
|
227
|
+
- **A fixed score cutoff is not a calibrated cutoff.** Building islands on human TRB by union-find
|
|
228
|
+
at `gapblock_score ≤ 60`, **31.7%** of size-matched *random control* junctions land in a component
|
|
229
|
+
of ≥5 — structure invented by the threshold. Per-query E-value edges at `E* = 0.05` cut that to
|
|
230
|
+
**0.000** while raising the real signal: 2.334 edges per node against 0.021 for the control, which
|
|
231
|
+
forms 19,248 singletons, 223 pairs, three components of size 3–4, and nothing larger. The control
|
|
232
|
+
arm's realised edge rate lands on `E*`, which is the check that the calibration is honest.
|
|
233
|
+
- **Mouse replicates it.** Against the mouse TRB control (694,241 productive clonotypes), 5 epitopes
|
|
234
|
+
and 1,692 TCRs give 5.856 calibrated edges per node against 0.019 for the control, which again
|
|
235
|
+
forms nothing larger than a pair. At a fixed θ=40 the control still lands 18.3% of its nodes in
|
|
236
|
+
components of ≥5.
|
|
237
|
+
- **Constraining the block is what buys precision.** Compared at a *matched* false-positive rate —
|
|
238
|
+
each rung given the cutoff at which its own ball admits `E*` chance neighbours, since a freer rung
|
|
239
|
+
finds lower scores and a fixed budget would reward it for that — retrieval precision on the
|
|
240
|
+
length-different fraction of VDJdb human TRB same-epitope pairs (2,000 queries, `E* = 0.1`):
|
|
241
|
+
|
|
242
|
+
| rung | layouts | precision |
|
|
243
|
+
|---|---|---|
|
|
244
|
+
| fixed centre | 1 | **0.414** |
|
|
245
|
+
| central prior λ=21 | ~1–2 effective | 0.336 |
|
|
246
|
+
| flat (score alone) | L+1 | 0.176 |
|
|
247
|
+
| candidates (after 3–4, before last 3–4, centre) | 5 | 0.156 |
|
|
248
|
+
|
|
249
|
+
Trying several plausible positions and keeping the best score is worse than not trying: the score
|
|
250
|
+
picks the structurally correct layout about a tenth of the time, so each extra candidate is mostly
|
|
251
|
+
an opportunity to be wrong. Mouse replicates the ordering at `E* = 1.0` but its length-different
|
|
252
|
+
stratum holds only 24–79 true positives, too few to separate the rungs.
|
|
253
|
+
- **Performance.** 91% of `GapBlockIndex.search` time is the query-deletion-variant branch, 9% the
|
|
254
|
+
9.8M-entry auxiliary indices. Netting the prior out of each variant's budget cuts that branch
|
|
255
|
+
from ~15 sub-searches to 2.5. Variant dedup (7–10% of variants before pruning, fewer after) and
|
|
256
|
+
length-bucketing are **not** built. At budget 40 over 250k references, `d_max=2` gap-block search
|
|
257
|
+
costs 2,562 µs/query — less than the plain Hamming ball at the same budget (3,051 µs/query).
|
|
258
|
+
|
|
259
|
+
### Docs
|
|
260
|
+
|
|
261
|
+
- `skills/seqtree/SKILL.md` — public API surface, invariants, and the gotchas that have bitten.
|
|
262
|
+
- `docs/gapblock.rst` — a worked guide: why one gap block, how to choose its position, why a
|
|
263
|
+
placement rule is a column frame, and why a fixed score cutoff is not a calibrated one.
|
|
264
|
+
- README corrected: `seqtrie` is a full-width DP that ignores per-type caps, not a banded one, and
|
|
265
|
+
`auto` does not choose between engines.
|
|
266
|
+
- `appendix/evalue.tex` gains §"The score model: one gap block, placed by a prior" (the appendix
|
|
267
|
+
derived a theory of balls without ever saying what the score was) and a remark inverting the
|
|
268
|
+
E-value into a per-query cutoff. The pMHC section is compacted from ~110 lines of prose to ~50,
|
|
269
|
+
deferring to the `mhcmatch` appendix, which specialises this one rather than repeating it. Its
|
|
270
|
+
empirical tables stay: they are this repo's own `bench/bench_mhc_guess.py` output.
|
|
271
|
+
- Test coverage: `gapblock.py`, `evalue.py` and `seeds.py` at **100%**; package total 88%. A new
|
|
272
|
+
`tests/python/test_doc_coverage.py` fails the build if a public symbol is undocumented, missing
|
|
273
|
+
from `__all__`, or unreachable from any docs page.
|
|
274
|
+
|
|
275
|
+
## [0.2.0]
|
|
276
|
+
|
|
277
|
+
- `structural` substitution matrix: Miyazawa–Jernigan interaction-strength similarity.
|
|
278
|
+
- Built-in matrix list: `identity`, `BLOSUM62`, `PAM250`, `PAM100`, `structural` (dropped PAM50).
|
|
279
|
+
|
|
280
|
+
## [0.1.0]
|
|
281
|
+
|
|
282
|
+
- `SubstitutionMatrix.penalty(a, b)` exposed to Python.
|
|
283
|
+
|
|
284
|
+
## [0.0.3]
|
|
285
|
+
|
|
286
|
+
- Reproducible table→plot benchmark pipeline with oracle + perf regression.
|
|
287
|
+
- pMHC non-binder E-value filter; class-II promiscuity notes.
|
|
@@ -27,6 +27,8 @@ add_library(seqtree_core STATIC
|
|
|
27
27
|
src/engine_seqtm.cpp
|
|
28
28
|
src/engine_seqtrie.cpp
|
|
29
29
|
src/searcher.cpp
|
|
30
|
+
src/gapblock.cpp
|
|
31
|
+
src/pairwise.cpp
|
|
30
32
|
)
|
|
31
33
|
target_include_directories(seqtree_core PUBLIC include PRIVATE src)
|
|
32
34
|
target_link_libraries(seqtree_core PUBLIC Threads::Threads)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.2
|
|
2
2
|
Name: seqtree
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Fast fuzzy search over biological sequences (C++ core, Python bindings)
|
|
5
5
|
Keywords: sequence-search,fuzzy-matching,CDR3,immunology,bioinformatics,trie
|
|
6
6
|
Author-Email: ISALGO laboratory <mikhail.shugay@gmail.com>
|
|
@@ -17,9 +17,10 @@ Project-URL: Documentation, https://antigenomics.github.io/seqtree/
|
|
|
17
17
|
Requires-Python: >=3.10
|
|
18
18
|
Provides-Extra: test
|
|
19
19
|
Requires-Dist: pytest>=7.4; extra == "test"
|
|
20
|
+
Requires-Dist: pytest-cov>=4.1; extra == "test"
|
|
21
|
+
Requires-Dist: biopython>=1.81; extra == "test"
|
|
20
22
|
Provides-Extra: bench
|
|
21
23
|
Requires-Dist: huggingface_hub; extra == "bench"
|
|
22
|
-
Requires-Dist: pandas; extra == "bench"
|
|
23
24
|
Requires-Dist: psutil; extra == "bench"
|
|
24
25
|
Provides-Extra: docs
|
|
25
26
|
Requires-Dist: sphinx; extra == "docs"
|
|
@@ -48,17 +49,18 @@ Two search engines over one trie:
|
|
|
48
49
|
- **`seqtm`** — branch-and-bound enumeration. Exact per-type edit caps
|
|
49
50
|
(`max_subs` / `max_ins` / `max_dels`) and a fast Hamming-only path. Best for
|
|
50
51
|
small edit distances (UMI collapse, error correction, CDR3/epitope matching).
|
|
51
|
-
- **`seqtrie`** —
|
|
52
|
-
|
|
53
|
-
|
|
52
|
+
- **`seqtrie`** — full-width edit-distance DP carried down the trie. Honours the
|
|
53
|
+
`max_penalty` score budget only; it **ignores the per-type edit caps**. Use it
|
|
54
|
+
when the budget is the whole specification.
|
|
54
55
|
|
|
55
|
-
`engine="auto"` picks
|
|
56
|
+
`engine="auto"` always picks `seqtm`, because it is the only engine that enforces
|
|
57
|
+
the caps you asked for. Results are payload-agnostic:
|
|
56
58
|
`(ref_id, score, n_subs, n_ins, n_dels)`. Downstream libraries map `ref_id` back
|
|
57
59
|
to their own payloads (V gene, MHC, counts) and filter.
|
|
58
60
|
|
|
59
61
|
Beyond search, seqtree ships:
|
|
60
62
|
|
|
61
|
-
- **Substitution matrices** — built-in `identity`, `BLOSUM62`, `PAM250`, `PAM100`, and `structural`
|
|
63
|
+
- **Substitution matrices** — built-in `identity`, `BLOSUM45`, `BLOSUM62`, `BLOSUM80`, `PAM250`, `PAM100`, and `structural`
|
|
62
64
|
— a **Miyazawa–Jernigan interaction-strength** matrix: each residue's strength `q(a)=mean_b e(a,b)`
|
|
63
65
|
is read off the MJ contact potential, so substitutions between residues of like interaction strength
|
|
64
66
|
are cheap. It separates strong (hydrophobic `F W C L Y M I V`) from weak (polar/charged
|
|
@@ -69,6 +71,30 @@ Beyond search, seqtree ships:
|
|
|
69
71
|
- **E-values / significance** — calibrate hit counts against a background control repertoire
|
|
70
72
|
(`load_control` + `evalues`), the TCRNET approach on a finite-sample footing. See the
|
|
71
73
|
[E-value guide](https://antigenomics.github.io/seqtree/evalue.html).
|
|
74
|
+
- **Calibrated cutoffs** — `threshold_for_evalue` inverts the E-value into the score cutoff that
|
|
75
|
+
achieves it, **per query**. A fixed cutoff is not a calibrated one: a control repertoire is
|
|
76
|
+
dense near germline and sparse among rare junctions, so the same threshold buys a common query
|
|
77
|
+
far more chance neighbours than a rare one.
|
|
78
|
+
- **Gap-block alignment** — `gapblock` restricts alignment to one contiguous indel, which is the
|
|
79
|
+
right model for a V(D)J junction and, measured against unrestricted affine alignment, is
|
|
80
|
+
exactly optimal on **98.8%** of genuinely related pairs at a calibrated `gap_open`. A gap
|
|
81
|
+
prior (`central_prior`, `profile_prior`, `frame_prior`) chooses where the block goes — a
|
|
82
|
+
sequence score alone cannot. `score_matrix` scores a whole query set against a whole reference
|
|
83
|
+
set in one GIL-released C++ call (**532 M pairs/s** on 16 cores; `numpy.asarray` wraps the
|
|
84
|
+
result with no copy), the shape a prototype-distance embedding needs.
|
|
85
|
+
- **Pairwise alignment without BioPython** — `seqtree.pairwise` is Needleman–Wunsch
|
|
86
|
+
(`mode="global"`) and Smith–Waterman (`mode="local"`) with affine or linear gaps, on the raw
|
|
87
|
+
log-odds scale. It is a **drop-in for `Bio.Align.PairwiseAligner`** — verified against it as an
|
|
88
|
+
oracle across three matrices, ten gap/mode settings and sixty sequence shapes with **zero
|
|
89
|
+
disagreements** — and **65–87× faster**, since there is no Python in the per-pair loop.
|
|
90
|
+
`dist_matrix` gives `d = s(a,a) + s(b,b) − 2·s(a,b)` directly. BioPython is a *test-only*
|
|
91
|
+
dependency; seqtree still needs nothing at runtime.
|
|
92
|
+
- **Island profiles** — `IslandProfile.fit` builds a position weight matrix over a set of
|
|
93
|
+
frame-aligned junctions (an *island*) and scores a query column by column against the island
|
|
94
|
+
consensus, as a non-negative penalty that flows through `threshold_for_evalue` unchanged. At a
|
|
95
|
+
repertoire-scale cutoff it recovers **48.5%** of held-out members against **37.6%** for
|
|
96
|
+
min-over-members; at a loose cutoff the two are indistinguishable, so it earns its keep only
|
|
97
|
+
where the cutoff is strict.
|
|
72
98
|
|
|
73
99
|
## Install
|
|
74
100
|
|
|
@@ -85,7 +111,7 @@ needs a C++17 compiler and CMake (pulled in automatically by the build).
|
|
|
85
111
|
```fish
|
|
86
112
|
bash setup.sh # repo-local .venv + editable install
|
|
87
113
|
bash setup.sh --tests # + pytest
|
|
88
|
-
bash setup.sh --bench # + benchmark deps (huggingface_hub,
|
|
114
|
+
bash setup.sh --bench # + benchmark deps (huggingface_hub, psutil)
|
|
89
115
|
```
|
|
90
116
|
|
|
91
117
|
## Quickstart
|
|
@@ -119,6 +145,42 @@ target = seqtree.Index.build(vdjdb_cdr3s, alphabet="aa")
|
|
|
119
145
|
for q, r in zip(queries, seqtree.evalues(target, control, queries, p)):
|
|
120
146
|
if r["p_enrichment"] < 1e-3:
|
|
121
147
|
print(q, r["E"], r["n_target"], r["n_control"])
|
|
148
|
+
|
|
149
|
+
# ...and the cutoff that achieves a target E, per query (-1 = unreachable at this control size)
|
|
150
|
+
ceiling = seqtree.SearchParams(max_subs=14, max_penalty=50, matrix="BLOSUM62", engine="seqtm")
|
|
151
|
+
thetas = seqtree.threshold_for_evalue(target, control, queries, ceiling, e_target=0.05)
|
|
152
|
+
|
|
153
|
+
# one contiguous gap block, placed by a prior rather than by the score alone
|
|
154
|
+
from seqtree.gapblock import GapBlockIndex, central_prior, embed_in_frame
|
|
155
|
+
|
|
156
|
+
gbi = GapBlockIndex(cdr3s, "aa", d_max=2)
|
|
157
|
+
mat = seqtree.SubstitutionMatrix.blosum62()
|
|
158
|
+
for ref_id, score, block_len, block_pos in gbi.search(
|
|
159
|
+
"CASSLGQAYEQYF", 40, mat, gap_open=2 * mat.scale(),
|
|
160
|
+
gap_prior=central_prior(int(1.5 * mat.scale()))):
|
|
161
|
+
...
|
|
162
|
+
|
|
163
|
+
# a fixed frame column makes gap placement transitive -- and a column index, hence a PWM, possible
|
|
164
|
+
embed_in_frame("CASSGQAYEQYF", width=14, c=4) # 'CASS--GQAYEQYF'
|
|
165
|
+
|
|
166
|
+
# a whole query set vs a whole reference set, in one GIL-released C++ call
|
|
167
|
+
from seqtree.gapblock import score_matrix, IslandProfile
|
|
168
|
+
sm = score_matrix(clonotypes, prototypes, mat, gap_open=2 * mat.scale(), threads=0)
|
|
169
|
+
import numpy as np
|
|
170
|
+
distances = np.asarray(sm) # (len(clonotypes), len(prototypes)) int32, zero-copy
|
|
171
|
+
|
|
172
|
+
# a position weight matrix over an island, still a non-negative penalty (feeds threshold_for_evalue)
|
|
173
|
+
profile = IslandProfile.fit(island_members)
|
|
174
|
+
profile.score("CASSLGQAYEQYF") # 0 on the consensus, > 0 for deviations
|
|
175
|
+
|
|
176
|
+
# ordinary pairwise alignment -- Needleman-Wunsch / Smith-Waterman, no BioPython
|
|
177
|
+
from seqtree.pairwise import align, score, dist_matrix
|
|
178
|
+
score("CASSLGQAYEQYF", "CASSPGQAYEQF", mat) # global, BLAST defaults (11/1)
|
|
179
|
+
score("WWWAAAWWW", "KKKAAAKKK", mat, mode="local") # Smith-Waterman
|
|
180
|
+
score("AAA", "AAAAA", mat, gap_open=5, gap_extend=5) # linear gaps: open == extend
|
|
181
|
+
aln = align("CASSLGQAYEQYF", "CASSPGQAYEQF", mat) # + aligned strings and ops
|
|
182
|
+
|
|
183
|
+
d = np.asarray(dist_matrix(v_genes, v_genes, mat, threads=0)) # s(a,a)+s(b,b)-2s(a,b), zero diagonal
|
|
122
184
|
```
|
|
123
185
|
|
|
124
186
|
## Tests
|
|
@@ -138,6 +200,8 @@ python bench/bench.py # recall vs ground truth (real VDJdb data)
|
|
|
138
200
|
python bench/bench_evalue.py # true E-value benchmark (target vs background control)
|
|
139
201
|
python bench/bench_evalue_matrix.py # significance across reference/control/query/scope grid
|
|
140
202
|
python bench/bench_epitope.py # epitope detection-complexity (GIL vs NLV)
|
|
203
|
+
python bench/bench_gapblock.py # the gap-freedom ladder: fixed centre → prior → flat → affine
|
|
204
|
+
python bench/bench_score_matrix.py # dense batch gap-block throughput (µs/pair, M pairs/s, RSS)
|
|
141
205
|
```
|
|
142
206
|
|
|
143
207
|
Figures (throughput, scaling, matrix-scoring overhead, collisions, E-value matrix, epitope
|
|
@@ -15,17 +15,18 @@ Two search engines over one trie:
|
|
|
15
15
|
- **`seqtm`** — branch-and-bound enumeration. Exact per-type edit caps
|
|
16
16
|
(`max_subs` / `max_ins` / `max_dels`) and a fast Hamming-only path. Best for
|
|
17
17
|
small edit distances (UMI collapse, error correction, CDR3/epitope matching).
|
|
18
|
-
- **`seqtrie`** —
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
- **`seqtrie`** — full-width edit-distance DP carried down the trie. Honours the
|
|
19
|
+
`max_penalty` score budget only; it **ignores the per-type edit caps**. Use it
|
|
20
|
+
when the budget is the whole specification.
|
|
21
21
|
|
|
22
|
-
`engine="auto"` picks
|
|
22
|
+
`engine="auto"` always picks `seqtm`, because it is the only engine that enforces
|
|
23
|
+
the caps you asked for. Results are payload-agnostic:
|
|
23
24
|
`(ref_id, score, n_subs, n_ins, n_dels)`. Downstream libraries map `ref_id` back
|
|
24
25
|
to their own payloads (V gene, MHC, counts) and filter.
|
|
25
26
|
|
|
26
27
|
Beyond search, seqtree ships:
|
|
27
28
|
|
|
28
|
-
- **Substitution matrices** — built-in `identity`, `BLOSUM62`, `PAM250`, `PAM100`, and `structural`
|
|
29
|
+
- **Substitution matrices** — built-in `identity`, `BLOSUM45`, `BLOSUM62`, `BLOSUM80`, `PAM250`, `PAM100`, and `structural`
|
|
29
30
|
— a **Miyazawa–Jernigan interaction-strength** matrix: each residue's strength `q(a)=mean_b e(a,b)`
|
|
30
31
|
is read off the MJ contact potential, so substitutions between residues of like interaction strength
|
|
31
32
|
are cheap. It separates strong (hydrophobic `F W C L Y M I V`) from weak (polar/charged
|
|
@@ -36,6 +37,30 @@ Beyond search, seqtree ships:
|
|
|
36
37
|
- **E-values / significance** — calibrate hit counts against a background control repertoire
|
|
37
38
|
(`load_control` + `evalues`), the TCRNET approach on a finite-sample footing. See the
|
|
38
39
|
[E-value guide](https://antigenomics.github.io/seqtree/evalue.html).
|
|
40
|
+
- **Calibrated cutoffs** — `threshold_for_evalue` inverts the E-value into the score cutoff that
|
|
41
|
+
achieves it, **per query**. A fixed cutoff is not a calibrated one: a control repertoire is
|
|
42
|
+
dense near germline and sparse among rare junctions, so the same threshold buys a common query
|
|
43
|
+
far more chance neighbours than a rare one.
|
|
44
|
+
- **Gap-block alignment** — `gapblock` restricts alignment to one contiguous indel, which is the
|
|
45
|
+
right model for a V(D)J junction and, measured against unrestricted affine alignment, is
|
|
46
|
+
exactly optimal on **98.8%** of genuinely related pairs at a calibrated `gap_open`. A gap
|
|
47
|
+
prior (`central_prior`, `profile_prior`, `frame_prior`) chooses where the block goes — a
|
|
48
|
+
sequence score alone cannot. `score_matrix` scores a whole query set against a whole reference
|
|
49
|
+
set in one GIL-released C++ call (**532 M pairs/s** on 16 cores; `numpy.asarray` wraps the
|
|
50
|
+
result with no copy), the shape a prototype-distance embedding needs.
|
|
51
|
+
- **Pairwise alignment without BioPython** — `seqtree.pairwise` is Needleman–Wunsch
|
|
52
|
+
(`mode="global"`) and Smith–Waterman (`mode="local"`) with affine or linear gaps, on the raw
|
|
53
|
+
log-odds scale. It is a **drop-in for `Bio.Align.PairwiseAligner`** — verified against it as an
|
|
54
|
+
oracle across three matrices, ten gap/mode settings and sixty sequence shapes with **zero
|
|
55
|
+
disagreements** — and **65–87× faster**, since there is no Python in the per-pair loop.
|
|
56
|
+
`dist_matrix` gives `d = s(a,a) + s(b,b) − 2·s(a,b)` directly. BioPython is a *test-only*
|
|
57
|
+
dependency; seqtree still needs nothing at runtime.
|
|
58
|
+
- **Island profiles** — `IslandProfile.fit` builds a position weight matrix over a set of
|
|
59
|
+
frame-aligned junctions (an *island*) and scores a query column by column against the island
|
|
60
|
+
consensus, as a non-negative penalty that flows through `threshold_for_evalue` unchanged. At a
|
|
61
|
+
repertoire-scale cutoff it recovers **48.5%** of held-out members against **37.6%** for
|
|
62
|
+
min-over-members; at a loose cutoff the two are indistinguishable, so it earns its keep only
|
|
63
|
+
where the cutoff is strict.
|
|
39
64
|
|
|
40
65
|
## Install
|
|
41
66
|
|
|
@@ -52,7 +77,7 @@ needs a C++17 compiler and CMake (pulled in automatically by the build).
|
|
|
52
77
|
```fish
|
|
53
78
|
bash setup.sh # repo-local .venv + editable install
|
|
54
79
|
bash setup.sh --tests # + pytest
|
|
55
|
-
bash setup.sh --bench # + benchmark deps (huggingface_hub,
|
|
80
|
+
bash setup.sh --bench # + benchmark deps (huggingface_hub, psutil)
|
|
56
81
|
```
|
|
57
82
|
|
|
58
83
|
## Quickstart
|
|
@@ -86,6 +111,42 @@ target = seqtree.Index.build(vdjdb_cdr3s, alphabet="aa")
|
|
|
86
111
|
for q, r in zip(queries, seqtree.evalues(target, control, queries, p)):
|
|
87
112
|
if r["p_enrichment"] < 1e-3:
|
|
88
113
|
print(q, r["E"], r["n_target"], r["n_control"])
|
|
114
|
+
|
|
115
|
+
# ...and the cutoff that achieves a target E, per query (-1 = unreachable at this control size)
|
|
116
|
+
ceiling = seqtree.SearchParams(max_subs=14, max_penalty=50, matrix="BLOSUM62", engine="seqtm")
|
|
117
|
+
thetas = seqtree.threshold_for_evalue(target, control, queries, ceiling, e_target=0.05)
|
|
118
|
+
|
|
119
|
+
# one contiguous gap block, placed by a prior rather than by the score alone
|
|
120
|
+
from seqtree.gapblock import GapBlockIndex, central_prior, embed_in_frame
|
|
121
|
+
|
|
122
|
+
gbi = GapBlockIndex(cdr3s, "aa", d_max=2)
|
|
123
|
+
mat = seqtree.SubstitutionMatrix.blosum62()
|
|
124
|
+
for ref_id, score, block_len, block_pos in gbi.search(
|
|
125
|
+
"CASSLGQAYEQYF", 40, mat, gap_open=2 * mat.scale(),
|
|
126
|
+
gap_prior=central_prior(int(1.5 * mat.scale()))):
|
|
127
|
+
...
|
|
128
|
+
|
|
129
|
+
# a fixed frame column makes gap placement transitive -- and a column index, hence a PWM, possible
|
|
130
|
+
embed_in_frame("CASSGQAYEQYF", width=14, c=4) # 'CASS--GQAYEQYF'
|
|
131
|
+
|
|
132
|
+
# a whole query set vs a whole reference set, in one GIL-released C++ call
|
|
133
|
+
from seqtree.gapblock import score_matrix, IslandProfile
|
|
134
|
+
sm = score_matrix(clonotypes, prototypes, mat, gap_open=2 * mat.scale(), threads=0)
|
|
135
|
+
import numpy as np
|
|
136
|
+
distances = np.asarray(sm) # (len(clonotypes), len(prototypes)) int32, zero-copy
|
|
137
|
+
|
|
138
|
+
# a position weight matrix over an island, still a non-negative penalty (feeds threshold_for_evalue)
|
|
139
|
+
profile = IslandProfile.fit(island_members)
|
|
140
|
+
profile.score("CASSLGQAYEQYF") # 0 on the consensus, > 0 for deviations
|
|
141
|
+
|
|
142
|
+
# ordinary pairwise alignment -- Needleman-Wunsch / Smith-Waterman, no BioPython
|
|
143
|
+
from seqtree.pairwise import align, score, dist_matrix
|
|
144
|
+
score("CASSLGQAYEQYF", "CASSPGQAYEQF", mat) # global, BLAST defaults (11/1)
|
|
145
|
+
score("WWWAAAWWW", "KKKAAAKKK", mat, mode="local") # Smith-Waterman
|
|
146
|
+
score("AAA", "AAAAA", mat, gap_open=5, gap_extend=5) # linear gaps: open == extend
|
|
147
|
+
aln = align("CASSLGQAYEQYF", "CASSPGQAYEQF", mat) # + aligned strings and ops
|
|
148
|
+
|
|
149
|
+
d = np.asarray(dist_matrix(v_genes, v_genes, mat, threads=0)) # s(a,a)+s(b,b)-2s(a,b), zero diagonal
|
|
89
150
|
```
|
|
90
151
|
|
|
91
152
|
## Tests
|
|
@@ -105,6 +166,8 @@ python bench/bench.py # recall vs ground truth (real VDJdb data)
|
|
|
105
166
|
python bench/bench_evalue.py # true E-value benchmark (target vs background control)
|
|
106
167
|
python bench/bench_evalue_matrix.py # significance across reference/control/query/scope grid
|
|
107
168
|
python bench/bench_epitope.py # epitope detection-complexity (GIL vs NLV)
|
|
169
|
+
python bench/bench_gapblock.py # the gap-freedom ladder: fixed centre → prior → flat → affine
|
|
170
|
+
python bench/bench_score_matrix.py # dense batch gap-block throughput (µs/pair, M pairs/s, RSS)
|
|
108
171
|
```
|
|
109
172
|
|
|
110
173
|
Figures (throughput, scaling, matrix-scoring overhead, collisions, E-value matrix, epitope
|
|
@@ -24,12 +24,18 @@ between them, written so an agent developing either package can pick it up cold:
|
|
|
24
24
|
|
|
25
25
|
| Capability | Module / symbol | Notes |
|
|
26
26
|
|---|---|---|
|
|
27
|
-
| Fuzzy fixed-length search | `seqtree.Index`, engines `seqtm` / `seqtrie` | per-type edit caps + Hamming fast path
|
|
28
|
-
|
|
|
27
|
+
| Fuzzy fixed-length search | `seqtree.Index`, engines `seqtm` / `seqtrie` | seqtm: per-type edit caps + Hamming fast path. seqtrie: budget-only full-width DP over the trie (not banded), ignores per-type caps |
|
|
28
|
+
| Single-gap-block loop alignment | `seqtree.gapblock` (Python) | anchored CDR3/junction alignment: one contiguous indel + positional gap prior |
|
|
29
|
+
| Dense batch gap-block scoring | `seqtree.gapblock.score_matrix` → `ScoreMatrix` | every query × every reference in one GIL-released C++ call (~532 M pairs/s on 16 cores); `numpy.asarray` is zero-copy. The shape a prototype-distance embedding needs, where nothing can be pruned |
|
|
30
|
+
| Per-island profile (PWM) | `seqtree.gapblock.IslandProfile` | position weight matrix over a frame-aligned island; `pen(j,a) = round(lam·log(p_max_j/p_j(a)))` is `≥ 0` and `0` on the consensus, so it stays a ball and flows through `thetas_from_scores` unchanged. Beats min-over-members only at a strict cutoff |
|
|
29
31
|
| Substitution scoring | `SubstitutionMatrix` (Gram → squared-distance penalty `s_aa+s_bb−2·s_ab`) | payload-agnostic |
|
|
30
32
|
| Per-position scoring | `PositionalMatrix` (`penalty(pos,a,b)` = base × per-position weight; weight 0 = free/anchor) | the hook for PSSMs and anchor masking |
|
|
31
33
|
| k-mer seed index | `KmerIndex` (C++): unique-k-mer trie + CSR postings + per-peptide allele tag; `seed_and_gather` (GIL-released, parallel) | million-scale candidate generation |
|
|
32
34
|
| Control-calibrated E-values | `seqtree.evalues`, `seqtree.load_control` | `Ê = (N/M)·n_C`, Poisson tail, `exclude_exact` |
|
|
35
|
+
| Calibrated score cutoffs | `seqtree.threshold_for_evalue`, `thetas_from_scores` | inverts `Ê` to a **per-query** θ. A fixed θ is not calibrated: at `gapblock_score ≤ 60`, 31.7% of *random control* junctions land in a component of ≥5 — structure invented by the threshold, which per-query cutoffs remove entirely |
|
|
36
|
+
| Gap-placement priors | `central_prior`, `profile_prior`, `frame_prior`, `positions_prior`, `embed_in_frame` | `prior(i, d, m) ≥ 0` and `= 0` at `d = 0`. Only a **constant-`i`** rule (`frame_prior`) makes a frame transitive — a column index, hence a PWM, which `IslandProfile` now ships |
|
|
37
|
+
| Seed significance | `seqtree.seeds`: `core_kmers`, `SeedIndex` | precision, not recall: ~0.5% cross-island coverage |
|
|
38
|
+
| Background control | `seqtree.load_control`, `sanitize` | uniform reservoir sample over unique **productive** clonotypes. *Planned:* an out-of-frame control as an empirical `P_gen` sample for the `π̂_gen` fallback — needs the `*.ntvj` tables, since the `.aa` table's `_` has already collapsed a run of positions |
|
|
33
39
|
| Anchor / layout model | `seqtree.layout`: `AnchorSpec`, `DEFAULTS`, `mask_anchors`, `kmers`, `presentation_features`, `weight_profile` | parametrized anchors; class-II register trick |
|
|
34
40
|
| pMHC homology + reverse | `seqtree.pmhc`: `PMHCStore`, `search_homologs`, `assign_allele`, `find_mimics`, `build_kmer_index` | reference impl; mhcmatch productionizes |
|
|
35
41
|
| Presentation-aware E-values | `seqtree.pmhc_evalue.homolog_evalue` | per-allele null |
|
|
@@ -37,7 +43,7 @@ between them, written so an agent developing either package can pick it up cold:
|
|
|
37
43
|
**E-value theory** lives in `appendix/evalue.tex`: the empirical-control null (§Setup, §Null), the
|
|
38
44
|
Poisson/Chen–Stein bound (§Poisson), multiple testing (§E-value), the closest-hit Gumbel law
|
|
39
45
|
(§Gumbel), Karlin–Altschul as the i.i.d. special case (§KA), the pMHC presentation-aware extension
|
|
40
|
-
(§Epitopes
|
|
46
|
+
(§Epitopes), and elementary applications — UMI/barcode birthday-collision and
|
|
41
47
|
CDR3-nt error clustering (§Related applications).
|
|
42
48
|
|
|
43
49
|
**Datasets.** `isalgo/pmhc_data` ships two tiers (see `appendix` Table "Scope of the two pmhc_data
|
|
@@ -115,8 +121,8 @@ additions below.
|
|
|
115
121
|
presentation-aware E-values via `pmhc_evalue.homolog_evalue`). Positive control: the Dolton et al.
|
|
116
122
|
A\*02:01 cross-reactive trio.
|
|
117
123
|
- Allele guessing (reverse problem): `assign_allele` + the vote-fraction ranking / register trick
|
|
118
|
-
validated in `bench/bench_mhc_guess.py` (appendix §
|
|
119
|
-
- **Non-binder filter** (appendix §
|
|
124
|
+
validated in `bench/bench_mhc_guess.py` (appendix §Epitopes). ROC-AUC 0.90–0.98.
|
|
125
|
+
- **Non-binder filter** (appendix §Epitopes, "Non-binders and class-II promiscuity"): _binds no MHC_ →
|
|
120
126
|
best-over-panel E-value high / no allele scores above background; _doesn't bind allele a_ →
|
|
121
127
|
per-allele `E_a > α`. mhcmatch exposes both thresholds.
|
|
122
128
|
- Tier choice (full vs shortlist, §1 Datasets).
|