Add proportional subsampling and Shannon entropy calculation

Introduces `--subsample N` and `--shannon` CLI flags to cap retained variable families via proportional reservoir sampling and compute per-family Shannon entropy. Updates the family scanning API to support explicit selection filtering with early-exit optimization, resolving an indexing drift issue in monomorphic layers. Streams entropy metrics for 15-state and 4-nucleotide spaces directly to CSV while maintaining parallel processing across sibling layers.
This commit is contained in:
Eric Coissac
2026-08-16 21:31:06 +02:00
parent f5e4bbfc6b
commit 45b19503a1
17 changed files with 841 additions and 70 deletions
+182 -44
View File
@@ -148,6 +148,22 @@ Baseline: mostly one active core, with short multi-core bursts — average
Measured ~10% *slower*, wider dips, not narrower. Reverted to the
original chunk sizing.
**Known remaining limitation, not yet worth fixing:** within one layer, the
four stages (sequential `unitigs.bin` read → parallel generation →
parallel resolution → sequential annex write) never overlap — confirmed by
1s-interval sampling: generation alone occupies ~17 threads evenly, but the
next layer's read/generation never starts until the current layer's
resolution and write are both done. This produces a real, periodic (~layer
duration) alternation between "many cores" and "few cores" that neither of
the fixes above touches, since both operate *within* one layer's resolution
step. The only remaining lever is overlapping consecutive layers (e.g. a
depth-2 pipeline: start layer N+1's read/generation while layer N's
resolution/write is still running) — a real restructuring, not a parameter
tweak, and explicitly *not* to be combined with the reverted
budget-capping idea above (let each phase use however many cores it
naturally wants; only the *scheduling* needs to overlap). Deferred, not
started.
## Cross-partition batch resolution — current state vs. the batched-accumulator design (discussion, 2026-08-14)
`family_scan.rs::scan_layer_families` (shared by `snp_pseudo_alignment`,
@@ -201,7 +217,15 @@ writes instead of order-dependent appends (see `PseudoAlignment` idea
below) wherever possible, since `sibling_annex_stats`'s reduction (plain
counts) is already order-independent and needs nothing here.
## Pseudo-alignment at scale — pruning is unavoidable (discussion, 2026-08-14)
**Superseded 2026-08-15** by the `--subsample`/`--shannon` design below,
which sidesteps the accumulator redesign for now: bounding the number of
families actually resolved per layer (via sampling) keeps per-layer
resolution volume small enough that the batch-density problem above stops
mattering in practice for these two consumers. The accumulator redesign
remains relevant for a future *unsampled, full-index* run, but is not
required to ship `--subsample`/`--shannon`.
## Pseudo-alignment at scale — pruning is unavoidable (discussion, 2026-08-14/15)
The reference run (`phyloskims_sal_vac`-scale bacterial test set,
`iqtree.fasta`) produced a dense alignment for 13 genomes × 383,965 sites
@@ -219,49 +243,163 @@ already applied in `snp_pseudo_alignment`. Insufficient alone — per the
~80-85% mono-family estimate from earlier discussion, this only brings 9
billion down to roughly 1.3-1.8 billion, still unusable.
**Criterion under discussion**: fix a global cell budget
(`n_genomes × n_columns_retained ≤ threshold`) and retain the
highest-entropy families first until the budget is spent — column count
self-adjusts to genome count automatically. Entropy of a family: Shannon
entropy over the observed base distribution across genomes carrying that
family, **decided 2026-08-14: genomes where the family is absent are
excluded from the calculation** (denominator = genomes where present, not
all genomes) — measures signal purity where it exists, independent of
coverage rate.
**Entropy definition — settled 2026-08-15, correcting an earlier wrong
turn.** The project does **not** encode families as IUPAC ambiguity codes
interpreted the classical way (Fitch-parsimony subset-compatibility, or
ML's "one true state, uncertain which"); see
`docmd/theory/evolutionary_distances.md` ("Why the IUPAC/DNA encoding used
for the first `--snp` test was wrong") and the Sankoff resolution that
followed it. The real model is a genuine 16-state alphabet (the powerset of
`{A,C,G,T}`, `∅` included as a real state) scored with a *calibrated
pairwise cost matrix* (`obikphylo::cardcomp::pairwise_cost_matrix`,
`cmd/phylo/sankoff.rs`), not a compatibility/subset relation between
states. Under that model, each of the 16 states — including multi-bit ones
like `AC` — is a first-class, independently-costed state, not an
uncertainty encoding of a single true base. So: **entropy over the 15
non-empty states (`∅` excluded, matching the earlier decision to exclude
genomes where the family is absent) is the correct informativeness
measure** for this project — not a 4-symbol reduction, which would discard
exactly the cardinality/composition information the calibrated cost matrix
is built to exploit.
Key consequence for storage: entropy needs per-variant genome counts, which
cannot be derived from `FamilyMask`'s bits alone — it requires the same
cross-partition resolution work the alignment/stats pipeline already pays
for. This favors computing it once at annex-build time and persisting it in
an **auxiliary vector alongside the annex** (one value per retained
minorant) over encoding a fixed keep/discard decision as one of
`FamilyMask`'s 3 remaining free bits (bits 13-15, `siblingannex.rs:77`): the
bit approach bakes a single threshold in permanently (any different budget
needs a full annex rebuild), the vector approach pays the expensive
resolution once and lets budget/threshold be chosen freely per analysis
afterward.
## `--subsample` / `--shannon` — sampling strategy (decided 2026-08-15)
**Open, explicitly deferred**: scope of the top-K selection — global across
the whole index (needs a first pass computing/storing every family's
entropy, then a global threshold from the full distribution, e.g. a
quantile, before building the final alignment: two full passes, but the
size budget is honored precisely) vs. local per layer/partition (stays
within the current single-pass streaming model, but the global budget is
no longer exactly guaranteed — depends on how unevenly entropy is
distributed across layers).
Goal: make both the pseudo-alignment (`--snp`) and a Shannon-entropy
diagnostic usable at any index scale, from the 13-genome bacterial
reference run up to the 9-billion-family plant index, without requiring the
batched-accumulator redesign above.
**Known remaining limitation, not yet worth fixing:** within one layer, the
four stages (sequential `unitigs.bin` read → parallel generation →
parallel resolution → sequential annex write) never overlap — confirmed by
1s-interval sampling: generation alone occupies ~17 threads evenly, but the
next layer's read/generation never starts until the current layer's
resolution and write are both done. This produces a real, periodic (~layer
duration) alternation between "many cores" and "few cores" that neither of
the fixes above touches, since both operate *within* one layer's resolution
step. The only remaining lever is overlapping consecutive layers (e.g. a
depth-2 pipeline: start layer N+1's read/generation while layer N's
resolution/write is still running) — a real restructuring, not a parameter
tweak, and explicitly *not* to be combined with the reverted
budget-capping idea above (let each phase use however many cores it
naturally wants; only the *scheduling* needs to overlap). Deferred, not
started.
**`--subsample N`** (integer, families to retain): bounds the pseudo-alignment
to `N` minorant families, sampled **proportionally per layer** among
non-monomorphic minorants (`family_size >= 2`) — this sidesteps the need
for a true global reservoir merge across layers while still approximating a
uniform sample over the whole index, and directly answers the earlier open
question of global-vs-per-layer selection scope.
Three passes, in order:
1. **Global count** (cheap, structural, parallel across layers — same shape
as the existing `sibling_family_size_histogram`, extended to report a
**per-layer** breakdown rather than one index-wide aggregate): for each
layer, `count_layer` = number of non-monomorphic minorants. Gives
`total_count = Σ count_layer`.
2. **Per-layer proportional reservoir sampling** (cheap, structural, one
pass per layer, no cross-partition resolution): `N_layer = round(N ×
count_layer / total_count)`. Since `N_layer` is a proportion of
`count_layer`, it can never exceed it as long as `N <= total_count` — the
one edge case is `total_count <= N`, in which case sampling is skipped
entirely and *every* non-monomorphic minorant of every layer is kept
(no reservoir needed, `N` was never a real constraint). Otherwise:
Algorithm-R reservoir sampling over the layer's non-monomorphic minorant
indices, producing `N_layer` iteration-order indices directly, no
intermediate full list ever materialized.
3. **Filtered resolution** (the expensive step, the existing
`scan_layer_families` engine, unchanged): re-scan the layer, generating
and resolving cross-partition queries **only** for the indices selected
in step 2 (cheap membership test against a small per-layer index set) —
this is what keeps `--subsample` cheap even on an unsampled-scale index,
since the cross-partition resolution volume is bounded by `N`, not by
the layer's true size.
Steps 2 and 3 cannot be merged into one pass: true single-pass reservoir
sampling would waste step-3's expensive resolution work on candidates later
evicted by the reservoir. Step 1 must fully complete (every layer) before
step 2 can start for any layer, since `total_count` is a global quantity.
**`--shannon`** (no argument): emits a CSV of per-family Shannon entropy
(15 non-empty states, `∅`/absent genomes excluded from the denominator, per
the settled definition above). Independent of `--subsample` — entropy is
computed and written per family as soon as its `genome_mask` resolves,
O(1) memory per family, so it streams fine even unsampled at full index
scale (a time cost, not a memory one). Combined with `--subsample N`, it
delivers the original exploratory diagnostic (e.g. `--subsample 1000000
--shannon`) directly from this general machinery, rather than a
purpose-built one-off script.
Validated end-to-end (2026-08-15) against real data: `--sibling-hist` on
`phyloskims_sal_vac` (91 real genomes, k=31/m=11, 256 partitions × 2
layers) confirms the ~9-billion-family estimate almost exactly (8,925,068,238
total, 97.9% monomorphic — a sharper mono fraction than the ~80-85% earlier
guess, corrected here). `--subsample`/`--shannon` on the smaller 20-genome
bacterial reference (`benchmark/global_index_presence`) produced a sample
size within rounding of the request (99,742/100,000) and a
[0.8,1.2)-bucket share (40.7%) matching the full unsampled population
(41.6%) — the two histograms only diverged wildly (5‰ vs 41.6%) under a
real bug in `reservoir_sample_layer` (see next section), now fixed.
**Bug found and fixed (2026-08-15): `family_idx` numbering mismatch.**
`scan_layer_families`'s `family_idx` counts *every minorant* of a layer
(monomorphic ones included, since `iter_minorants_batch` filters only on
`is_minorant()`), not the raw annex slot (`SiblingAnnex::get(slot)` spans
every k-mer, minorant or not) and not a counter over non-monomorphic
minorants alone. `subsample.rs`'s `reservoir_sample_layer` originally
stored raw slot numbers in its `HashSet<usize>` selection, which drifts
away from `family_idx` as soon as *any* monomorphic minorant is seen —
i.e. almost immediately, since ~98% of minorants are monomorphic. Fixed by
tracking two separate counters: `family_idx` (every minorant, matching
`scan_layer_families`) and `seen` (non-monomorphic minorants only, what
Algorithm R actually samples over) — only `family_idx` values are ever
stored in the selection set. The existing unit test never exercised this
(its fixture has exactly one non-monomorphic family, always hitting the
"keep everything" shortcut) — a stronger fixture with several interleaved
monomorphic/non-monomorphic families would be needed to catch a regression
here automatically; not yet written.
## Cheap entropy pre-filtering — row-marginal sums (idea, not implemented, 2026-08-15)
Motivation: on real data (bacterial reference, full unsampled run), only
~5‰ of non-monomorphic minorants fall in the `[0.5, 1.5]` bit band judged
phylogenetically interesting (entropy too low = uninformative near-invariant
site; too high = saturated/noisy, see `family_entropy`'s 15-state
discussion) — roughly 1 in 10,000 minorants overall. Computing exact
entropy for every candidate just to discard 99.99% of them is wasteful at
the full 9-billion scale.
**The idea**: `PersistentBitMatrix::col_view(c)` gives a genome's whole
presence column as a `BitSliceView` (sequential, no MPHF, no cross-partition
routing — purely local to one layer's own matrix). Accumulating
`TempCompactIntVecBuilder::inc_present(col)` (already exists in
`obicompactvec/src/builder.rs:121`, along with `add`/`min`/`max`/`diff` on
`IntSliceView` — no new low-level API needed) over every column of a layer
produces `coverage[slot]`: how many genomes carry each exact k-mer, in one
sequential per-layer pass, entirely decoupled from family/sibling
structure. Persisted once per layer, a family's members' coverage could
then be looked up via a plain local MPHF `index()` (cheap) instead of a
full cross-partition presence resolution (`find_presence_batch`) — the
expensive part today is specifically the cross-partition/cross-layer
routing to a sibling's own matrix, not the bit-reading itself, and
`coverage[slot]` sidesteps that routing entirely by moving the cost into a
one-time, purely local, embarrassingly-parallel build step.
**Why not implemented**: `coverage[slot]` is a per-member marginal —
summing members' coverages to approximate a family's entropy silently
assumes no genome carries more than one member at once. It cannot
represent or detect joint co-occurrence (a genome carrying both `A` and
`C` at once, i.e. a combined 15-symbol state) at all, which is exactly the
phenomenon `family_entropy`'s 15-state definition exists to capture (see
`CardinalityTally`/`cardinality_transition_probs`, the project's own
existing machinery for this same co-occurrence structure, built for the
Sankoff matrix calibration). A family that is in truth uniformly `AC`
across every carrying genome would look like a well-balanced 2-state split
under the marginal approximation (entropy ≈ 1) while its true 15-state
entropy is 0 — i.e. the marginal proxy's failure mode lands on exactly the
"saturated, uninformative" tail this pre-filter would need to catch,
undermining the point. It stays plausible as a coarse filter for the
*low* tail only (a dominant single member's marginal share reliably
predicts low true entropy too), but not as a stand-in for the high tail —
not pursued further for now.
## Two entropy definitions kept side by side, for comparison (2026-08-15)
`--shannon`'s CSV carries both `entropy15` (`family_entropy` — the settled
15-non-empty-state definition, see above) and `entropy4` (`family_entropy_4`
— plain nucleotide reduction), computed from the same already-resolved
`genome_mask`, not from the marginal approximation above. A genome carrying
several bases at once contributes to *each* base's count (counted once per
base present, not fractionally split, not folded into one combined state)
— a genome polymorphic for the family is present at more than one base by
construction, so it is expected to count more than once; the denominator is
the total base-occurrence count, not the genome count (the two coincide
only when no genome carries more than one base). Kept side by side
specifically to measure, on real data, how much the two diverge — not yet
analyzed.