Skip to content

Statistics, seeds, refinement

The building blocks fit is made of. Stable enough to call directly, but the round loop is the supported surface.

pystreme.statistics

pystreme.statistics

Vectorized enrichment statistics.

See DESIGNDOC.md §2.5. Phase 2. The vectorized optimizer is validated (L2) in tests/test_statistics.py against a brute-force per-threshold scipy.stats.fisher_exact loop -- that reference loop is written as a test fixture, not thrown away, since it's the oracle for this file for good.

ThresholdResult dataclass

Result of optimal_threshold: the best cut point and its significance.

log_pvalue is a natural-log p-value (not log10), computed via scipy's logsf rather than log(sf(...)) so it stays finite deep into underflow territory instead of collapsing to -inf -- the whole point of working in log-p space here (§2.5's "single most likely way to end up slower" concern is about speed, but STREME-style refinement also needs p-values sharp enough to rank candidates once sf itself would return machine zero).

table property

table: tuple[int, int, int, int]

2x2 contingency table as (primary_above, primary_below, control_above, control_below).

CentralEnrichment dataclass

Result of central_enrichment: log_pvalue is Bonferroni-adjusted (natural log) over the candidate central-window half-widths tried; half_width is the winning window (sites with |offset| <= half_width), n_central how many of the n_sites fell inside it, and expected how many would have under a uniform positional null.

optimal_threshold

optimal_threshold(scores_primary, scores_control) -> ThresholdResult

Sort-once, all-cut-points-at-once threshold optimization (§2.5).

scores_primary/scores_control are 1-D best-site scores per sequence (e.g. ScanResult.scores, one motif's row) -- -inf entries (no valid window in that sequence, §2.2) are kept as real population members that simply never clear any threshold, not dropped.

Candidate thresholds are every distinct finite score value observed in either set: any other real cut point would partition the data exactly like the nearest such value, so restricting to observed values loses nothing and keeps the search over one array instead of a continuum. For each candidate t, "above" means score >= t.

Fisher's exact test at every candidate (hypergeometric tail, with the bounded search of _fisher_logsf_min_candidates), whatever the two set sizes -- fixed-width windows make every sequence the same length, which is the condition STREME's Fisher branch actually needs (§8.2; see _log_enrichment_pvalues for why the old size-based Binomial branch is gone). One-sided for enrichment (more primary sequences above threshold than expected), matching STREME/SEA's convention.

Returns the ThresholdResult for whichever candidate threshold gives the smallest (most significant) p-value.

enrichment_at_threshold

enrichment_at_threshold(scores_primary, scores_control, threshold: float) -> ThresholdResult

Enrichment test at one fixed score cut (score >= threshold).

The hold-out half of STREME's significance story: optimal_threshold picks the cut on the training sequences (and so its p-value is optimistic -- it was selected), then this re-tests that same cut on sequences the search never saw, giving Motif.holdout_logp. Same test conventions as optimal_threshold (Fisher's exact test whatever the two set sizes, -inf scores never count as "above").

central_enrichment

central_enrichment(offsets, n_windows: int) -> CentralEnrichment | None

CentriMo-style central enrichment (§2.7) of best-site positions.

offsets are site centers relative to the sequence center, in bases (as Motif.positions reports them -- half-integers when the motif width and sequence length have different parity); n_windows is the number of possible window starts per sequence (L - w + 1), which fixes the null: a site is equally likely at any of them, so the chance it lands within h of center is (number of window starts whose center is within h) / n_windows.

For every candidate half-width h (each distinct |offset| a window center can take, out to the sequence edge) the number of sites within h is tested against that null with a one-sided Binomial test, and the best h is reported with its log p-value Bonferroni-multiplied by the number of candidates tried -- the same max-over-widths-then-correct shape CentriMo uses. Returns None when there are no sites.

hypergeom_logsf

hypergeom_logsf(k, M, n, N, n_terms: int = 512) -> np.ndarray

log P(X > k) for X ~ Hypergeometric(M, n, N), vectorized and fast.

scipy.stats.hypergeom.logsf is a Python loop that logsumexps the whole upper tail per element (this was 95% of fit's runtime, and the seed ranking hands it every candidate word at once -- millions on a real peak set). hypergeom.sf is a C ufunc but underflows to 0 for the strongly enriched words that matter most, so: log(sf) wherever sf is representable, and for the rest the tail series summed directly in log space -- pmf(k+1) (vectorized logpmf) times the geometric-like sum of successive pmf ratios, which by then (far above the mode, or sf wouldn't have underflowed) decay fast enough that n_terms terms are exact to double precision.

binom_logsf

binom_logsf(k, n, p, n_terms: int = 512) -> np.ndarray

log P(X > k) for X ~ Binomial(n, p): log(binom.sf) (a C ufunc) with the same log-space tail-series fallback as hypergeom_logsf where sf underflows (scipy's binom.logsf is just log(sf), so it returns -inf there).

pystreme.seeds

pystreme.seeds

Seed finding: k-mer counting and ranking.

See DESIGNDOC.md §2.1 (direct counting replaces the suffix tree for this job) and §5 (memory: 4^w count arrays, sort-based fallback above w=12). Phase 3.

Words are counted pooled with their reverse complement -- canonicalized to whichever of {word, revcomp(word)} sorts first as a packed integer -- since the scanner searches both strands (§2.4): a word and its RC are the same candidate motif, not two different ones splitting the same signal.

That holds only while the scan really is double-stranded. Under revcomp=False the two orientations are not the same candidate, and emitting the lexicographically smaller one hands refinement a seed the forward-only scanner can never match, so words are then counted in the orientation they were observed in.

Seed dataclass

top_seeds

top_seeds(primary_store: SequenceStore, control_store: SequenceStore, widths, n_per_width: int = 4, n_exact: int = 2000, revcomp: bool = True) -> dict[int, list[Seed]]

Rank candidate seed words per width by primary-vs-control enrichment.

One vectorized counting pass per width (§2.1): direct dense bincount into a 4**w-sized array when that fits comfortably (w <= 12, the design doc's own cutoff -- 4**12 is ~16.7M entries), else a sort/unique -based count over just the words actually observed (packed ids fit an int64 up to w=31, far past the default 6-15 width range, so nothing changes about how words are represented -- only how they're counted).

revcomp must match the scan the seeds are refined under: with False, words are counted in their observed orientation rather than pooled with their reverse complements (see the module docstring).

Enrichment is a single vectorized statistics.hypergeom_logsf call across every candidate word at once (the same one-sided "pool of primary+control occurrences, how many landed in primary" test statistics.py uses for score thresholds, applied here to raw word-occurrence counts instead of a scan-score cutoff).

pystreme.refine

pystreme.refine

PWM refinement: the iterative loop that dominates STREME's runtime.

See DESIGNDOC.md §2.1 (best-site lookup as batched convolution -- the reason this is fast here) and §3 (Refiner). Phase 3.

Each iteration: scan primary+control with the current PWM, find the enrichment-optimal score threshold (§2.5), then re-estimate the PWM from the primary sites clearing that threshold (their actual best-matching window, on whichever strand won). This is the same NREF-seeds x NITER-iterations structure §2.1 costs out for STREME -- a handful of seeds per width, ~20 iterations each, each iteration one batched GPU scan rather than a suffix tree walk.

RefinedMotif dataclass

refine

refine(seed_words: list[str], primary_store: SequenceStore, control_store: SequenceStore, n_iter: int = 20, revcomp: bool = True, kernel: str = 'auto') -> RefinedMotif

Refine a batch of same-width seed words; return the single best result.

Both stores must already have a fitted background of the same order (§2.3's "fit on the control set" -- see MotifDiscovery's callers of this function for how that's set up).

seed_to_pwm

seed_to_pwm(word: str, sharpness: float = 0.8) -> np.ndarray

A discrete seed word -> a (4, w) PWM: sharpness probability at the observed base, the remainder split evenly across the other three -- concentrated enough to scan meaningfully, soft enough that refinement (rather than the seed itself) does the real work.

trim_flanks

trim_flanks(pwm: ndarray, min_ic: float, min_width: int = 1) -> tuple[np.ndarray, int, int]

Drop leading/trailing columns with information below min_ic bits, never going below min_width columns. Returns (trimmed_pwm, n_left, n_right) -- the counts trimmed from each end, so a site position for the original PWM can be shifted by n_left to point at the trimmed one.

A refined PWM one or two columns wider than the real site keeps its extra columns near-flat (they were estimated from whatever base happens to flank each site), and the enrichment objective is blind to them -- see MotifDiscovery._discover_on. Trimming them is how the reported width ends up meaning something.

extend_flanks

extend_flanks(pwm: ndarray, threshold: float, store: SequenceStore, min_ic: float, max_width: int, revcomp: bool = True, kernel: str = 'auto', control_store: SequenceStore | None = None) -> np.ndarray

Grow a refined PWM outward while the column just beyond either edge is informative: re-estimate a (4, w+2) matrix from the sequences whose best site clears threshold (their windows widened by one base on each side, in motif orientation), keep an edge column if its information is at least min_ic bits, and repeat until neither edge qualifies or max_width is reached.

The mirror image of trim_flanks: a seed word that sits one base off the real site refines into a motif missing a column at one end (the fixed-width refinement can't shift its window), and the extra column at the other end is flat. Trimming removes the flat one, this puts the missing one back. Sites too close to a sequence edge to widen are simply left out of the re-estimate. The caller re-scores the result (its threshold changes with width).

threshold is only the starting point: score scale moves with width, so a threshold fitted to the pre-trim PWM is the wrong cut for the trimmed one (systematically too strict -- fewer columns, lower scores) and for each widened one in turn. Given control_store, the enrichment-optimal threshold is refitted for the current PWM at every step, so sites are selected by a cut that belongs to the matrix doing the selecting. Without it the passed threshold is used throughout, as before.

column_information

column_information(pwm: ndarray) -> np.ndarray

Per-column information content in bits, relative to a uniform base distribution: sum_b p log2(4 p), 0 for a flat column, 2 for a fixed base.