One window grid and one membership rule across the windowed engines - #182
One window grid and one membership rule across the windowed engines#182andrewkern wants to merge 4 commits into
Conversation
WindowIterator._iter_bp_windows started the window grid at the first variant and ignored chrom_start / chrom_end, while every fused windowed path anchors at the matrix bounds when they are set. The two engines tiled different grids for the same matrix whenever chrom_start sits before the first variant: a from_ts matrix (chrom_start 0), a from_vcf region load, or a get_subset_from_range subset. compute_region was the loudest symptom: its windows started at the first variant in the region and could run past the requested end, so neighbouring region calls did not tile. Use the matrix bounds when set, with the first and last variant as the fallback, in both _iter_bp_windows and count_windows. count_windows now counts the exact candidate starts the loop walks; the old formula under-counted whenever the step and window sizes differed. Tests: the iterator anchors at chrom_start, its grid matches the fused engine on the non-empty windows, the fallback without bounds is unchanged, and two consecutive compute_region calls tile without gaps or overlap.
The fused engine assigned each variant to a window with searchsorted on the window ENDS. Two failures follow. A variant sitting exactly on a boundary (pos == a window end == the next window's start) lands in the lower window, while n_variants, the theta / divergence kernels and the per-window slice stats all treat a window as right-open [start, end) -- so daf_hist / mu_sfs / mean_nsl counted a boundary variant in one window and everything else counted it in the next. And a single searchsorted assigns exactly one window per variant, so with overlapping windows (step < window) these stats dropped the variant from every other window that covers it. Replace the assignment with the member run [k_lo, k_hi]: k_hi is the last window whose start is <= pos, k_lo the first whose end is > pos. Both window families the engine receives (contiguous bp_bins and uniform overlapping start/stop arrays) have nondecreasing starts and ends, so the member windows of a variant are exactly that consecutive run. For contiguous windows the run has length one and the arrays keep their old shape and cost. The chunked engine delegates these stats to the fused function, so both paths are covered. Tests: every window's daf_hist / mu_sfs / mean_nsl equals the statistic recomputed from that window's own [start, end) variant slice, on boundary-heavy and overlapping grids, through both the windowed_analysis path and the direct bp_bins branch.
The grid-anchoring rule and the arange grid construction each existed in four copies across the engines; the two fixes in this branch had added the fourth. Extract _bp_grid_bounds and _bp_window_grid and call them from the iterator, both scatter paths, _build_scatter_indices, and the fused dispatcher, so the engines agree by construction rather than by test. count_windows returns the grid length instead of re-deriving it. The iterator now finds each window's variants with one searchsorted pair instead of a full boolean scan per candidate window. The scan cost was O(n_windows * n_var) and grew badly for a subset matrix whose chromosome bounds are much wider than its variant span. In the fused per-site block, drop the always-true upper-bound clause (site_win <= k_hi < n_windows by construction), and replace the tiling closure with zero-copy broadcast views: the consumers mask and scatter straight through the (n_per_var, n_var) views, which removes up to three tiled copies per call on overlapping grids. The per-site stat list and the daf bin count become module constants shared with the chunked delegation set and the tests. Tests: one shared leading-gap matrix builder, the four membership scenarios as one parametrized test, and expected values built once in the fixture from the engine's own binning constant.
The candidate-row count came from a reduction over the per-variant run lengths, which forced a device-to-host sync and needed an empty-input guard because CuPy's max rejects empty arrays. Coverage depth is a property of the window grid alone: it is maximized at a window start, and the window arrays are already on the host. Two numpy searchsorted calls give the depth with no device work and no guard. The depth can exceed what the variants need when they avoid the deep-coverage spots; site_ok masks the unused rows, so an upper bound is what the block wants anyway, and the allocation shape no longer depends on where the variants fall.
nspope
left a comment
There was a problem hiding this comment.
Looks great, left a couple comments on edge cases, and spotting one inclusive/exclusive endpoint bug (I think this is a bug, depends how window interval is defined)
| win_starts, win_stops = _bp_window_grid( | ||
| chrom_start, chrom_end, window_size, step_size) | ||
| # Build equivalent bp_bins for _compute_window_ranges | ||
| bp_bins = np.concatenate([win_starts, [win_stops[-1]]]) |
There was a problem hiding this comment.
Very minor edge case, but this'll error out ambiguously on a size-0 grid (which is reachable if chrom_start == chrom_end). Might be worth adding a if n_windows == 0: guard.
Here's a MWE:
import numpy as np
from pg_gpu import HaplotypeMatrix, windowed_analysis
pos = np.array([0, 50, 100])
hap = np.tile(np.array([0, 1, 0, 1], dtype=np.int8)[:, None], (1, 3))
hm = HaplotypeMatrix(hap, pos)
# a one-base span should not crash (but currently throws IndexError)
windowed_analysis(hm.get_subset_from_range(50, 51), window_size=100,
step_size=100, statistics=['pi'])| chrom_end; each window spans window_size. Shared by every windowed | ||
| engine so the grids agree by construction. | ||
| """ | ||
| win_starts = np.arange(int(chrom_start), int(chrom_end), step_size, |
There was a problem hiding this comment.
So I think chrom_end is intended to be inclusive, but the arange here treats it as exclusive. This allows SNPs to vanish on a window boundary. For example:
import numpy as np
from pg_gpu import HaplotypeMatrix, windowed_analysis
pos = np.array([0, 50, 100])
hap = np.tile(np.array([0, 1, 0, 1], dtype=np.int8)[:, None], (1, 3))
hm = HaplotypeMatrix(hap, pos)
# every variant should land in some window
df = windowed_analysis(hm, window_size=100, step_size=100, statistics=['pi'])
assert df['n_variants'].sum() == hm.num_variants, f"expected {hm.num_variants}, got {df['n_variants'].sum()}"| assert int(it['start'].iloc[0]) == 0 | ||
| assert all(int(s) % 10_000 == 0 for s in it['start']) | ||
|
|
||
| def test_iterator_grid_matches_fused(self): |
There was a problem hiding this comment.
I think it'd be worth: (a) checking multiple stats here, (b) doing overlapping windows
| err_msg=f"H2H1 window {w}") | ||
|
|
||
|
|
||
| class TestFusedPerSiteWindowMembership: |
There was a problem hiding this comment.
This might by a bit orthogonal to the point of this PR, but I think we need a more through two-pop scatter-vs-fused comparison. The existing test_fused_twopop_missing_matches_scatter rebuilds windows by hand under a specific missing-data setup. So I think it'd be worth adding some comparisons on an overlapping grid here (i.e. for da/dxy/fst/fst_hudson).
| atol=1e-12) | ||
|
|
||
|
|
||
| class TestEngineGridAgreement: |
There was a problem hiding this comment.
just brainstorming some edge cases to check with tests (feel free to skip):
- single variant windows
- zero-window grid (that's the index error I commented on above)
- that different loaders->windowed_analysis result in the same output
Closes #179. Closes #166.
The two issues sit on different axes of the same module. A windowed engine makes two
decisions: where the window grid starts, and which window each variant belongs to.
#179 was a grid problem in the iterator engine; #166 was a membership problem in the
fused engine. One commit per issue.
#179: iterator grid anchoring
WindowIterator._iter_bp_windowsstarted the grid at the first variant and ignoredchrom_start/chrom_end, while every fused path anchors at the matrix bounds whenthey are set. The engines tiled different grids for the same matrix whenever
chrom_startsits before the first variant:from_ts(chrom_start 0), afrom_vcfregion load, or a
get_subset_from_rangesubset.compute_regionwas the loudestsymptom -- its windows started at the first variant in the region, so neighbouring
region calls did not tile.
Fix:
_bp_window_boundsapplies the fused rule (matrix bounds when set, variants asfallback) in both
_iter_bp_windowsandcount_windows.count_windowsnow countsthe exact candidate starts the loop walks; the old formula under-counted whenever
step and window size differed (progress-bar total only).
#166: fused per-site window membership
bin_idx = searchsorted(we_gpu, positions)assigned each variant to one window, witha boundary variant (pos == a window end == the next window's start) going to the
lower window. Everything else in the same call --
n_variants, the theta anddivergence kernels, the per-window slice stats -- is right-open
[start, end). Andone window per variant means overlapping windows (step < window) silently dropped
each variant from every other window covering it. Affected:
daf_hist,mu_sfs,mean_nsl. The audit the issue asked for confirms the rest of the scatter set(
snp_dist_*,mu_var,zns,omega,mu_ld,dist_*) iterateswin_start[wi]:win_stop[wi]slices and was already correct.Fix: membership is the consecutive run
[k_lo, k_hi]--k_hithe last window whosestart is <= pos,
k_lothe first whose end is > pos. Both window families the enginereceives (contiguous
bp_bins, uniform overlapping start/stop arrays) havenondecreasing starts and ends, so the run is exact. Contiguous grids keep length-one
runs, so the common case keeps its old shape and cost. The chunked engine delegates
these stats to the fused function, so both paths are covered. This restores the
approach of the reverted df9716b and extends it to overlapping windows.
Tests
Nine new tests; eight fail on main (the ninth pins the unchanged no-bounds fallback):
daf_hist/mu_sfs/mean_nslequal the statistic recomputed fromthat window's own
[start, end)slice, on a boundary-heavy grid (every variant ona window edge), on two- and three-deep overlapping grids, and through the direct
bp_binsbranch;chrom_start, its grid matches the fused engine, and twoconsecutive
compute_regioncalls tile without gaps or overlap.pytest tests/ -n 10: 895 passed, 64 skipped.ruff: clean.Notes
difference, unchanged here; the cross-engine test compares the non-empty rows.
exact multiple of the step) falls outside all windows. Both engines agree on this
and did before; unchanged.
mu_sfs internals next to the membership block (and where df9716b was reverted to
keep that branch scoped). The membership logic itself is orthogonal to those
reworks.