Skip to content

Multiallelic-correct statistics: integrate nsp-multiallelic-trunk into main - #154

Closed
andrewkern wants to merge 46 commits into
mainfrom
nsp-multiallelic-trunk
Closed

Multiallelic-correct statistics: integrate nsp-multiallelic-trunk into main#154
andrewkern wants to merge 46 commits into
mainfrom
nsp-multiallelic-trunk

Conversation

@andrewkern

Copy link
Copy Markdown
Member

Summary

Integrates the multiallelic-correctness effort (nsp-multiallelic-trunk) into main. Previously most statistics assumed a biallelic 0/1 encoding, so a site with an allele index >= 2 gave wrong results — folded/collapsed derived alleles, dosages that overflowed the [0, 2] range, PCs that depended on arbitrary allele numbering, and mis-scoped diploid estimators. This branch reframes each family around per-allele counting (matching tskit / scikit-allel where an oracle exists) and pins the behavior with new tests.

main is a strict ancestor of this branch (46 commits ahead, 0 behind), so it merges cleanly. 42 files, ~+5,000/−1,500.

What changed, by family

Testing

  • Cross-implementation parity suite (Pin statistics across implementations in a parity test suite #137, tests/test_implementation_parity.py) — asserts the same statistic agrees across the scalar / FrequencySpectrum / scatter / fused / python-loop paths over clean, missing/include, missing/exclude, and multiallelic data; remaining known divergences are recorded as strict xfails (the follow-up punch list).
  • Multiallelic correctness (tests/test_multiallelic.py, test_decomposition_multiallelic.py, test_selection_multiallelic.py) — biallelic reduction against tskit / scikit-allel oracles, brute-force numpy references for genuinely multiallelic sites, index-independence, and missing-data handling.
  • Streaming parity — eager vs streaming equivalence for relatedness/grm/ibs including accessible masks and chunk-boundary invariance.

Known follow-up

nspope and others added 30 commits July 8, 2026 09:09
- add per-allele allele_counts primitive
- scalar diversity uses per-allele primitive
- scalar theta uses per-allele primitive
- up test coverage for missing-data branches of segregating_sites/pi
- guard that the achaz test fixture stays biallelic
- per-allele SFS + FrequencySpectrum, matching tskit
- SFS: exclude both fixed classes (bins 0 and n)
- route sfs.sfs() through the per-allele allele_counts primitive
- per-allele folded/scaled SFS (matches tskit polarised=False)
- change _stream_sum to preserve the kernel's own dtype instead of forcing int64
- per-allele joint_sfs + project_joint_sfs
- _joint_global_k / _joint_per_allele_counts helpers
- rewrite joint_sfs_folded / joint_sfs_folded_scaled to match tskit's
  polarised=False 2-sample-set AFS
- NB: this implies a behaviour change with biallelic only-data (weird
  behaviour on part of allel)
- add _joint_folded_cells helper
Multiallelic handling for scalar statistics and SFS
…s primitive

- add _aligned_pop_counts helper
- add TestDivergenceVsTskit in test_multiallelic.py
Multiallelic handling in count-based divergence stats
- rewrite _pairwise_diffs_matrix_gpu
- additionally fixes missing-data handling bug
- add TestPairwiseDistanceKernel
- add TestDistanceStatsConsumers
Correct pairwise distances for multiallelic data
- per-allele patterson_f2 / patterson_f3 (match tskit)
- _aligned_allele_counts helper
- add patterson_f4 and biallelic-restrict patterson_d
- remove dead index-inflated helpers and bump consumer test coverage
- each window matches the per-allele scalar diversity functions on its subset, biallelic output is unchanged
- in _fused_windowed_kernel_v2 replace the single `dac` accumulator with a per-allele cnt[MAX_ALLELES]
- MAX_ALLELES cap drops sites with a greater allele index before launch
- refresh stale single-pop docstrings/comments
- rewrite _twopop_site_components to per-allele counting
- replace formulas to use per allele sums
- rewrite _fused_windowed_twopop_kernel to per-allele counting
- fst_wc left unchanged as there seems to be a mismatch with scalar path
- equivalence testing between scalar path and windowed statistics path
consistent with scalar path (with some behaviour changes):

- singletons were folded (dac==1 | dac==n-1) now are unfolded (dac==1)
  matching scalar
- theta_w / tajimas_d numerator: per-site a1(n_valid) instead of a1(n_hap)
- Tajima variance still uses n_hap and doesn't match scalar path
- add TestMultiallelicSinglePop.test_per_variant_matches_scalar
Multiallelic admixture statistics
Fix windowed analysis for multiallelic data (with some exceptions)
Add tests/test_implementation_parity.py, which asserts that the same
population-genetic statistic agrees across the code paths that compute it:
scalar functions, FrequencySpectrum, and the windowed scatter, fused-CUDA, and
python-loop engines. Each statistic is described once in a declarative _STATS
registry; the parametrized test drives every supported path across four data
conditions (clean, missing/include, missing/exclude, multiallelic) and asserts
it matches the scalar reference. GPU-only, skipped without CUDA.

16 statistics, ~180 cases. Divergences between paths are recorded as strict
xfails in _XFAILS with a mechanism-level reason; a (stat, path) may carry
several condition-scoped rules. The remaining divergences are:

- fst_wc (fused, all conditions): the fused kernel was not converted to the
  per-allele one-vs-rest Weir-Cockerham ANOVA the scalar/python-loop path uses
- theta_w, tajimas_d (fused, missing/include): the fused kernel's per-site
  sample-size handling under missing data still diverges from the scalar
- tajimas_d, normalized_fay_wu_h, zeng_e (fs and scatter, missing/include): the
  Achaz neutrality-test variance uses a modal / full-sample size rather than
  per-site valid counts
- pi, theta_w, tajimas_d, segregating_sites, fay_wu_h, normalized_fay_wu_h,
  zeng_e (fs, multiallelic): FrequencySpectrum builds a per-allele SFS whose
  segregating-class count and pi differ from the scalar per-allele counts
- da (scatter, missing/exclude): the within-population pi terms use a different
  site set than the scalar (dxy agrees)

Refs #135
Pin statistics across implementations in a parity test suite
andrewkern and others added 16 commits July 14, 2026 12:37
iHS and nSL assumed a biallelic focal SNP: the ancestral/derived split
dropped (iHS) or merged (nSL) haplotypes carrying an allele index >= 2, and
the allele-count/MAF filter counted only alleles 0 and 1, hiding a common
high-index allele from min_maf.

The shared-haplotype homozygosity core is already allele-agnostic (it decays on
h1 != h2), so only the focal grouping changes: score each derived allele
one-vs-ancestral. Both focal CUDA kernels gain a focal_allele parameter
(class 0 = ancestral, class 1 = the focal derived allele; a homozygous pair of
any other allele extends the shared run but scores neither), and the wrappers
loop over derived alleles. nsl/ihs return the same (n_variants,) array as
before on biallelic data and (n_variants, K-1) when the matrix has K > 2
alleles, with column j the score for derived allele j+1 (NaN where that allele
or the ancestral allele is absent, or the allele is MAF-filtered). This reduces
exactly to the classic biallelic result.

The MAF filter now counts per allele via _memutil.allele_counts (replacing the
0/1-only _count_alleles_kernel): min_maf gates on each derived allele's own
frequency min(freq_a, 1-freq_a), reducing to min(n0,n1)/(n0+n1) on biallelic
sites. Windowed mean_nsl averages every finite (site, allele) score in a window.
xpEHH, XP-nSL, EHH decay, and Garud H are allele-agnostic and untouched.

There is no external oracle (scikit-allel ihs/nsl are biallelic-only; tskit has
no iHS/nSL), so tests/test_selection_multiallelic.py validates via biallelic
reduction against scikit-allel, relabel invariance (recoding derived 1 -> 2
shifts the score to column 1 and matches bit-for-bit), a brute-force pure-numpy
nSL reference on triallelic data, the per-allele MAF fix, reference-absent
sites, and first-ever mean_nsl coverage.

Follow-up: the per-allele wrapper reruns the full SSL scan once per derived
allele; a single-pass kernel depositing into all allele classes at once would
remove that factor when multiallelic scans become a hot path. The biallelic
path (one iteration) is unaffected.

Closes #140
nSL and iHS scan the shared-haplotype length across every variant, so their
scores depend on the full region. Subsetting or chunking the variants truncates
the scan and changes the values. In particular, windowed mean_nsl over a
StreamingHaplotypeMatrix runs per chunk and truncates the scan at chunk
boundaries, so the streaming result approximates rather than reproduces the
eager whole-region computation.

Document this in the nsl/ihs docstrings, at the mean_nsl computation site, and
in the _stream_windowed_analysis caveats (alongside the existing local_pca and
sliding-window notes). No behavior change.
Multiallelic support for iHS and nSL selection statistics
- previously for multiallelic site the PCA input was the arbitrary integer allele label
- rewrite so that biallelic sites go through the existing path, multiallelic sites
  go through a new one-hot encoded path
- the two contributions are summed (covariance is additive)
- the frequencies are sourced from allele_counts not the old dac_and_n
- some finagling with host-device syncing
- tests against an independent numpy host reference
- validate pca/randomized_pca on multiallelic in test suite
Add _prepare_centered: the all-allele centered one-hot standardization (all
alleles including the reference, centered by allele frequency, no variance
scaling, missing imputed to p_a so it contributes 0). Its Gram X @ X.T is the
site-mode genetic_relatedness matrix at haplotype granularity.

Rewrite eager pca around it: drop the scaler argument, reject a GenotypeMatrix
(pointing to pca_dosage), and normalize the Gram by the segregating-site count
(proportion=True), then eigendecompose. This makes pca the tskit PCA of the
GRM, pinned to eigh(ts.genetic_relatedness(mode='site', centre=True,
polarised=False, proportion=True)).

randomized_pca, local_pca, lostruct, and the old Patterson standardization
helpers are untouched here and move to the new convention in a later change.
Migrate the windowed PCA family -- local_pca (dense and streaming engines),
lostruct, and the jackknife paths -- to _prepare_centered, the all-allele
centered (tskit) standardization, matching the eager pca. Drop the scaler menu
from their signatures and from LocalPCAResult, and stop the windowed_analysis
local_pca dispatch from forwarding a scaler. lostruct keeps its own per-window
covariance /(ncol-1) (Li & Ralph); only the standardization changed.

Add _sign_align_windows: each window's top-k eigenvectors are sign-aligned to
the previous valid window (flip when the dot product is negative), so the
returned per-window PC coordinates are continuous along the genome instead of
flipping arbitrarily. lostruct's distance is sign-invariant and unaffected.

Keep the per-window sync footprint at the pre-reframe level: _prepare_centered
gains a need_segregating flag (windowed callers skip the segregating-site
sync) and an n_alleles arg, and the streaming dispatcher computes the allele
width once and passes it, avoiding a per-window int(hap.max()) sync.

randomized_pca and the old Patterson standardization helpers are untouched here
(a later change migrates randomized_pca and deletes them).
randomized_pca now uses the same all-allele centered standardization as pca:
it rejects GenotypeMatrix (pointing to randomized_pca_dosage), drops the scaler
argument, builds X via _prepare_centered, and scales coords by
1/sqrt(n_segregating) so the randomized approximation matches the
proportion-normalized Gram that pca decomposes. The explained-variance ratio is
scale-invariant and unchanged. Removes the memory-constrained _DeferredPCA
branch.

Deletes the now-unused Patterson-standardization helpers: _prepare_matrix,
_multiallelic_onehot_dense, _biallelic_standardize_dense, _DeferredPCA,
_DeferredPCATranspose, _materialize_prepared. The multiallelic per-allele
one-hot Patterson standardization no longer exists; the diploid biallelic
Patterson/GCTA path returns as pca_dosage on a GenotypeMatrix.
Adds the Patterson/GCTA PCA of biallelic diploid dosages as pca_dosage and
randomized_pca_dosage on GenotypeMatrix, complementing the tskit
genetic_relatedness PCA (pca / randomized_pca on HaplotypeMatrix). _prepare_dosage
standardizes each variant's alt-allele dosage (0/1/2) by centering on its mean m
and scaling by sqrt(p (1 - p)), p = m / 2, imputing missing to m so it contributes
zero after centering; monomorphic sites are scale-guarded. pca_dosage
eigendecomposes the individual Gram; randomized_pca_dosage uses a randomized SVD.
Both reject the wrong matrix type (pointing to the tskit functions) and reject
dosages > 2 (non-biallelic).

This reproduces scikit-allel's diploid Patterson PCA: explained-variance ratios
match to 1e-8 and coordinates up to a per-component sign. Population subsetting is
done inline (the shared population helper is haplotype granularity on this branch);
this should be unified in the type-domain-consistency follow-up.
Make the PCA standardization multiallelic-correct.
- add _relatedness_columns(hap, sample_sets, n_alleles) that is substrate for tskit genetic_relatedness
- add TestRelatednessColumns
- genetic_relatedness(haplotype_matrix, sample_sets, indexes, *, centre, polarised, span_normalize, missing_data)
- add proportion option to genetic_relatedness
- add streaming genetic_relatedness
- add TestGeneticRelatednessDispatch
…Matrix

Fix a pre-existing gap this surfaced: grm(GenotypeMatrix, population=...) failed
because get_population_matrix only indexed .haplotypes.
Multiallelic support for grm and ibs; new genetic_relatedness that is general haplotype form
…umers)

Eager genetic_relatedness normalizes by get_span('auto') (accessible-mask count
> n_total_sites > raw per-base span) and, because an accessible mask filters the
HaplotypeMatrix's variant view, computes over accessible sites only. The
streaming path did neither: accessible_bed was dropped by from_zarr's streaming
branches, StreamingHaplotypeMatrix carried no mask, and _stream_genetic_relatedness
hardcoded the raw chunk-grid span. So the same data loaded eagerly vs streamed
diverged on span_normalize=True whenever a mask was present.

Give the streaming matrix the same accessible-span contract as the eager one:

- _StreamingMatrixBase gains accessible_mask / n_total_sites and a get_span
  mirroring HaplotypeMatrix.get_span, using the source's variant-position bounds
  (mappable_lo/hi) so the span matches an eager matrix from the same store.
- from_zarr forwards accessible_bed to _build_streaming, which resolves the mask
  once and attaches it.
- iter_gpu_chunks attaches the mask to every chunk it yields, at the single
  chunk-production point, so every streaming consumer (genetic_relatedness now,
  and windowed_analysis) sees accessible-filtered variants by construction --
  rather than each reduction re-applying the mask itself.
- _stream_genetic_relatedness takes its span from get_span and drops its own
  per-chunk attach.

Tests: eager/streaming genetic_relatedness parity with and without an
accessible_bed (filter + span), and that iter_gpu_chunks filters chunk variants.

The sibling StreamingGenotypeMatrix path still drops accessible_bed, so streaming
grm/ibs ignore a mask; tracked separately.

Closes #151
Streaming genetic_relatedness: honor the accessible mask (span + variant filtering)
@andrewkern

Copy link
Copy Markdown
Member Author

we're 46 commits aheads of main here @nspope -- should we merge this in? or do you want to keep the campaign going on this branch? i opened a PR just to offer it - feel free to close

@nspope

nspope commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

there's still LD stats and a couple other things to get in ... though if you'd rather work off main, that's fine too

@nspope

nspope commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

I also wanted to do a general docs/cleanup pass, so actually let's wait until then. I think it's better to be sure the docs are lined up with the implementation. I can get back to this Wed, I think it's just 2-3 more PRs

@andrewkern

andrewkern commented Jul 21, 2026

Copy link
Copy Markdown
Member Author

nah it's fine to keep working off this branch. i'll close this PR. I'm going to keep working on the accessibility mask / span stuff on this branch then. #152 is another similar class of bug.

@andrewkern andrewkern closed this Jul 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants