Validation and benchmark record¶
Results of scripts/validate_streme.py (DESIGNDOC.md §4 L6 and L8), kept
here rather than in scrollback. Every run below is on the first 2000 peaks
of the SP1 ChEC-seq set (SP1FULL_peaks.bed, mm10, 200 bp windows, widths
6-15, n_motifs=3, dinucleotide-shuffle control on both sides), run through
bsub on WEXAC. STREME is MEME 5.5.0, given the identical FASTA that
pystreme scanned, building its own shuffled control as pystreme does.
L1 -- scanner vs FIMO (2026-09-07)¶
scripts/validate_fimo.py, 2000 SP1 peaks x 200 bp, JASPAR SP1 (MA0079.1)
and KLF4 (MA0039.1) smoothed with a 0.01 pseudocount and handed to both
tools, one order-0 background (complement-averaged, as MEME tools do when
scanning both strands), FIMO --text --thresh 1.0 --motif-pseudo 0:
| motif | kernel | Pearson r (best score per peak) | identical argmax | identical or tied within FIMO's 0.01-bit resolution |
|---|---|---|---|---|
| MA0039.1 KLF4 | gather / conv | 1.00000 | 99.45% | 100.00% |
| MA0079.1 SP1 | gather / conv | 1.00000 | 98.75% | 99.95% |
Pass (r > 0.999; >= 99% identical-or-tied). The remaining
disagreements are peaks whose top two windows differ by less than
FIMO's output resolution. Before the background was complement-averaged
the argmax agreement was 98.3% with r = 0.9997 -- the difference between
the two tools' backgrounds, not the scanners. FIMO scores with order-0
frequencies whatever the background file's order, so the order-2 cumsum
term (§2.3) is validated by the naive-reference tests in
tests/test_sequence_store.py, not by FIMO.
Scan kernels (§3.1) -- scripts/bench_scan.py (2026-09-07)¶
40 PWMs (widths 6-15) x 7232 x 200 bp, both strands, mean of 5 runs:
| device | gather (fp32) | conv (one-hot; fp16 on CUDA) |
|---|---|---|
CPU (one short slot) |
6953 ms | 980 ms |
| NVIDIA L40S | 14 ms | 7 ms |
Hence the defaults: conv on CPU (7x, and fp32 there), gather on CUDA --
per scan conv is 2x faster at 40 PWMs, but fit scans a few PWMs at a
time and its wall-clock was the same with either kernel (9.3 s gather vs
10.2 s conv, 2000 peaks, H100), so the fp32 kernel wins on
determinism. Both CUDA kernels match the CPU fp32 reference
(tests/test_scanner.py::test_cuda_kernels_match_cpu_reference).
Dinucleotide shuffle (2026-09-07)¶
control.DinucShuffle, 2000 x 200 bp: pure-Python reference 1.08 s,
numba engine (default when numba imports; same algorithm, same
property tests) 0.02 s. On the full 7232-peak run this was ~7 s of the
87 s.
Extraction (2026-09-07)¶
SequenceStore.from_bed reads the genome in sorted, merged blocks
(windows on one chromosome closer than 1 Mb share one pyfaidx slice).
All 7232 SP1 peaks from mm10: 7.5 s on a short node (the per-peak
version took 69 s cold on a GPU node earlier that day, 0.4 s with the
genome already in the page cache -- I/O, not CPU). Output identical.
L6 -- same motifs as STREME? (2026-09-07)¶
Yes, all three, in the same order, on both kernels and on CPU. Tomtom
(pearson, -thresh 10) pystreme -> STREME, GPU conv run:
| pystreme (round, consensus) | STREME | Tomtom E |
|---|---|---|
1 GCCCCGCCCCC (w=11, 1536 sites, hold-out log p -83) |
1 HRGCCCCGCCCCYN |
1.6e-13 |
2 GCCAATCA (w=8, 493 sites, hold-out log p -14) |
2 RRCCAATCAGMR |
3.1e-09 |
3 GTGACTCA (w=8, 373 sites, hold-out log p -11) |
3 RTGASTCAY |
4.7e-05 |
Biology: the SP1 GC-box (Tomtom to JASPAR MA0079.1 SP1, E = 8e-4, on both
tools), the NF-Y CCAAT box (SP1's classic co-factor), and AP-1. Every
pystreme motif is significant on the held-out 10% (Motif.holdout_logp),
and the GC-box is strongly centrally enriched (central log p -120), the
AP-1 site only mildly (-10) -- the §2.7 output distinguishing the bound
factor from co-occurring ones, as intended.
Run-to-run differences (CPU vs GPU, conv vs gather) are in the flank
columns of motif 1 (GCCCCGCCCCC vs GGCCCCGCCCC) and in site counts by
a few percent -- the width-settling tolerance (_discover_on) and fp16
storage in the conv kernel, not in which motifs are found.
The width-6 to width-15 STREME motif 1 is 14 wide with degenerate flanks
(HRGCCCCGCCCCYN); pystreme trims flanks below 0.3 bits, hence 11.
L8 -- wall-clock (2026-09-07)¶
Same 2000 peaks, fit(n_motifs=3) in-process, timed after extraction.
STREME's number is the subprocess wall-clock including its startup.
| run | pystreme fit | STREME | ratio |
|---|---|---|---|
| CPU, gather kernel, before profiling (unbatched refinement, exact hypergeometric on every candidate seed word) | 1673 s | 17 s | 0.01x |
| H100, gather kernel, batched refinement + binomial pre-ranking of seed words | 90 s | 15 s | 0.17x |
| H100, conv kernel (fp16 one-hot), same | 74 s | -- | 0.2x |
| H100, gather kernel, + bounded Fisher threshold search | 9.3 s | 14.2 s | 1.5x |
| H100, conv kernel, same | 10.2 s | -- | 1.4x |
CPU (one short-queue slot), gather kernel, same code |
331 s | -- | 0.04x |
| CPU, conv kernel (now the CPU default) + numba shuffle | 51 s | -- | 0.28x |
The CPU rows are the same code path; the gap to the GPU rows is the scans themselves (the design is GPU-first, §"Device"; CPU exists so the correctness suite runs anywhere, and at 51 s it is usable in a pinch).
Full peak set: 7232 peaks, n_motifs=5, H100, gather kernel¶
| pystreme | STREME | |
|---|---|---|
| wall-clock | 86.9 s (fit, in-process) | 132.0 s (subprocess) |
| 1 | GCCCCGCCCC (5870 sites, hold-out log p -266, central -371) |
RGGGGCGGGGCYDG -- Tomtom E 3.9e-11 |
| 2 | AGCCAATCA (1748, -62, central -139) |
YRRCCAATCRGMRV -- E 4.8e-11 |
| 3 | GTGACTCA (1195, -47, central -26) |
RTGASTCAY -- E 2.9e-05 |
| 4 | GCGCAGGCGCA (745, -44, central -9) |
= STREME's 5 WSTGCGCABGCGCRS -- E 9.6e-10 |
| 5 | GTGACGTCA (923, -31, central -16) -- CRE/ATF |
STREME's 4 is CACTTCCGGKT (ETS), not in pystreme's top 5 |
Same primary motif, four of five shared, one differing secondary on each side (both real TF families) -- §4 L6's "same primary motif, overlapping secondaries", at 1.5x STREME's speed. Central enrichment orders them as one would hope: the targeted factor's own site far more central than any co-factor.
Extraction (pyfaidx, per interval) took 69 s on the GPU node for 7232 peaks vs 6.5 s for 2000 on a CPU node -- I/O bound and outside the timings above, but user-visible; fixed by the block reads above.
Full peak set, n_motifs=10, NVIDIA A40 (2026-09-07)¶
scripts/validate_streme.py --n-motifs 10 --report --annotate H12CORE_meme_format.meme
Superseded 2026-09-10. This is the pre-review record, kept as it was measured. The 62.0 s was taken on a shared card; the same code on a reserved A40 takes 84.9 s. The current code fits in 61.7 s reserved, and its motifs differ from the table below. See "Post-review correctness fixes" below and
docs/runs/sp1_full10/.
| pystreme | STREME | |
|---|---|---|
| wall-clock | 62.0 s (fit, in-process) | 191.3 s (subprocess) |
| pystreme | sites | hold-out log p | central log p | HOCOMOCO (in-process, mean r) | STREME match (Tomtom E) |
|---|---|---|---|---|---|
1 GCCCCGCCCC |
5716 | -280.5 | -370.7 | SP1, SP3 (1.00) | 1 RGGGGCGGGGCYDG (1.5e-11) |
2 GCCAATCA |
1804 | -82.3 | -128.0 | NFYA/B/C (1.00) | 2 YRRCCAATCRGMRV (3.6e-09) |
3 GCGCNTGCGCA |
838 | -38.8 | -11.9 | NRF1 (0.95) | 6 WSTGCGCABGCGCRS (1.6e-09) |
4 GTGACTCA |
1265 | -48.5 | -26.4 | JUN, FOSL2, JUNB (0.99) | 3 RTGASTCAY (9.4e-05) |
5 GTGACGTCA |
970 | -26.8 | -16.4 | ATF6A, ATF1, JDP2 (0.92) | 7 GTSACGTSAC (8.9e-07) |
6 CCCGCCC |
2449 | -20.4 | -99.5 | KLF11, SP3, KLF9 (1.00) | 5 CCCGCCCMC (8.4e-04) |
7 AAANAAACA |
709 | -9.4 | 0.0 | FOXL1, FOXC2 (0.90) | -- |
8 AGGAGGAGG |
3418 | -14.7 | -18.2 | ZN263, MAZ (0.91) | -- |
9 CACTTCCGG |
973 | -19.9 | 0.0 | FLI1, ETV5, ERG (1.00) | 4 CACTTCCGGKT (1.4e-07) |
10 GAACTACAANTCCCA |
213 | -13.3 | 0.0 | ZN143, ZNF76 (0.99) | 8 RACTACAAYTCCCAG (4.2e-10) |
Eight of ten shared; pystreme's two extras (a FOX site and a GA-repeat /
ZNF263-MAZ site) and STREME's two (TGATTGACA, an octamer ATTTGCATAN)
are all at the weak end of both lists. Central enrichment again orders
the list by plausibility of direct binding: the GC-box and the second
KLF/SP variant far more central than any co-factor, the FOX, ETS and
ZNF143 sites not central at all. Report figure: docs/img/report_sp1_10.png (sorted by hold-out p, HOCOMOCO match per row);
the run's summary.md, annotation.csv and pystreme.meme are kept
under docs/runs/sp1_full10/.
Same three motifs in every row. What the profiles showed and what changed:
scipy.stats.hypergeom.logsfis a per-element Python loop -- 95% of the synthetic-test runtime. Replaced bystatistics.hypergeom_logsf.- Even scipy's C
hypergeom.sfcosts O(draws) per element; called on every candidate seed word (1e5-1e6 words against a 4e5-window pool) it was 91% of the real-data runtime -- not scanning, which was 30 s for ~1000 scans on CPU. Now pre-ranked with the O(1) binomial tail, exact hypergeometric only for the top 2000 words per width. - Refinement batched per width (one scan per store per iteration for all seeds of that width, §2.1) instead of per seed.
- The threshold search (§2.5) called the C
hypergeom.sfon every candidate cut -- O(n_seqs) each, O(n_seqs^2) per search, 1700 searches per fit: 85% of the GPU wall-clock, against 6% for all the scans. Now every cut gets O(1) bounds on its Fisher tail and only cuts that can still be the optimum are evaluated exactly (statistics. _fisher_logsf_min_candidates; the argmin is provably exact).
The remaining ~9 s on 2000 peaks is dominated by per-iteration Python and device-sync overhead in the refinement loop, not by GPU work (~3 s of conv/gather time). The GPU's advantage should grow with input size; the full 7232-peak comparison is below.
Post-review correctness fixes (2026-09-09/10)¶
The scoring changes from the 2026-09-08 external review -- fit now
re-fits the reporting store's background on the same training control the
threshold was learned from (it kept the setup background, fitted on the
whole unerased control), background contexts containing an N or an
erased base back off one Markov order at a time instead of reading the N
as a T, and extend_flanks re-fits its threshold per width -- change
the numbers. Same 2000 peaks, same seed, CPU, --skip-streme
(validation/sp1_2000_postreview/):
| before | after | |
|---|---|---|
| 1 | GCCCCGCCCCC w11, 1534 sites, E 1.5e-37 |
GCCCCGCCCCC w11, 1525 sites, E 2.7e-42 |
| 2 | CTGATTGGC w9, 480 sites, E 2.0e-07 |
AGCCAATCA w9, 248 sites, E 1.7e-04 |
| 3 | GTGACTCA w8, 335 sites, E 4.9e-06 |
GTGACTCA w8, 355 sites, E 2.4e-03 |
Same three motifs (SP1/KLF, the NF-Y CCAAT box -- reported on the other
strand -- and AP-1), so this is a change in the numbers attached to them,
not in what is found. Rounds 2 and 3 move most because they scan erased
stores, which is exactly where the fabricated T contexts were. On the
full 7232-peak set the graded back-off also recovers ZNF143
(GAACTACAANTCCCA, 214 sites, hold-out log p -14.0), which a flat
order-0 fallback had lost in the erased A-rich neighbourhoods.
L1 (scanner vs FIMO) is unchanged, byte for byte -- it is order-0, where there is no context to fabricate.
The 4.8x regression, and where the time went (2026-09-10)¶
Timed on one A40 reserved with j_exclusive=yes (earlier figures for
this section, 62 / 130 / 202 s, were taken on shared cards and are
withdrawn -- one of them was measured while another job held the same
GPU). Full 7232-peak SP1 set, 200 bp, widths 6-15,
n_motifs=10, fit only:
| code | fit | motifs |
|---|---|---|
pre-review b6b5f3c |
84.9 s | 10 |
| review fixes, exact Fisher argmin (finding #6 as first implemented) | 412.8 s | 10 |
| + noise-floor bail-out | 286.4 s | 10, identical |
| + below-mode lower bound (current) | 61.9 s | 10, identical |
Finding #6 turned _fisher_logsf_min_candidates's max_exact from a
hard cap into a doubling batch so the argmin was always exact. That is
free when a cut is significant (the bounds prune to a handful) and
ruinous when none is (11.5k of 14.5k candidates survive, all resolved
exactly), and refine asks for a threshold once per seed per iteration.
The exact search cost 4.8x the whole fit and did not change one reported
motif. Two changes, both keeping every returned value a valid upper
bound on the true tail:
- noise floor: once a truncated batch fails to bring the ceiling
under a Bonferroni bar over the candidates searched
(
log(0.05 / n_candidates)), stop -- the argmin is exact whenever any cut clears that bar, approximate only among cuts that don't; - below-mode bound: the
pmf(k)lower bound is tiny far down the left tail, so cuts at or below the mode -- no enrichment at all, tail near 1 -- survived every ceiling and were 99% of all exact evaluations. Fork <= modethe tail containspmf(mode) >= 1/|support|, which removes them for nothing.
The result is under the pre-review time because the old cap still resolved 256 candidates per call; the median call now resolves 3. The graded background and its fit-once refactor (one model per round applied to five stores, 0.71 s -> 0.006 s per round) are inside these numbers.