Skip to content

Make the PCA standardization multiallelic-correct.#143

Merged
nspope merged 6 commits into
nsp-multiallelic-trunkfrom
nsp-multiallelic-5
Jul 17, 2026
Merged

Make the PCA standardization multiallelic-correct.#143
nspope merged 6 commits into
nsp-multiallelic-trunkfrom
nsp-multiallelic-5

Conversation

@nspope

@nspope nspope commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator

EDIT: updated with the new tskit-analogous verison

decomposition's PCA standardization was wrong on multiallelic input: it derived per-site centering and scaling from a collapsed derived-allele count and centered the raw allele-index matrix, so a site with an allele index >= 2 fed the arbitrary integer allele label into the decomposition. Principal components, explained variance, and every local PCA window covariance became a function of arbitrary allele numbering, silently and non-reproducibly under relabeling.

Rather than patch the single standardization, this splits the family along the data type, because there are two established and genuinely different PCAs and they belong to different inputs:

  • pca / randomized_pca take a HaplotypeMatrix and compute the multi-allele haploid PCA: an eigendecomposition of the genetic relatedness matrix. Every present allele (including the reference) is one-hot encoded, each column centered by its allele frequency with no variance scaling, and the sample-by-sample Gram is normalized by the number of segregating sites. Each haplotype is its own sample, so this is multiallelic-correct at any ploidy.
  • pca_dosage / randomized_pca_dosage take a GenotypeMatrix and compute the diploid dosage PCA standardized by binomial variance: each biallelic variant's alt-allele dosage (0/1/2) is centered on its mean and scaled by the binomial standard deviation sqrt(p (1 - p)), p = mean / 2, then the individual-by-individual Gram is eigendecomposed. This is the classical dosage PCA and reproduces scikit-allel's diploid PCA exactly.

Each entry point rejects the other matrix type with a TypeError that names its counterpart. local_pca, lostruct, and the local-PCA jackknife move to the same all-allele centered standardization as pca (they remain HaplotypeMatrix-only). The scaler menu (patterson / standard / None) is dropped in favor of the two fixed conventions.

What changed

pca and randomized_pca now build the all-allele centered one-hot (all present alleles, centered by frequency, no scaling, missing imputed to the frequency so it contributes zero) and normalize the Gram by the segregating-site count. That Gram is the genetic relatedness matrix, so pca is pinned against a tree sequence's genetic_relatedness output (all sample pairs, centered, proportion-normalized).

pca_dosage and randomized_pca_dosage are new. They standardize the biallelic diploid dosage by centering and binomial-variance scaling, then eigendecompose the individual Gram with no segregating-site normalization, so the coordinates match scikit-allel's magnitude as well as its subspace. They reject dosages > 2 (which cannot arise from a biallelic diploid site) and reject a HaplotypeMatrix. Population subsetting is done inline over individuals; the shared population helper is haplotype granularity on this branch, and unifying genotype/haplotype subsetting is a logged follow-up.

local_pca, lostruct, and local_pca_jackknife route each window through the all-allele centered standardization instead of the old per-variant standardization; they keep their own per-window covariance X @ X.T / (ncol - 1). Adjacent windows' per-PC eigenvectors are sign-aligned (flip when the dot with the previous valid window is negative) so windowed PC tracks are continuous.

The obsolete standardization machinery is deleted: the split biallelic/multiallelic Patterson-scaled helpers and the memory-constrained deferred-PCA path. The four public functions are exported from the package.

Behaviour

pca and randomized_pca change on all input, not just multiallelic: the standardization is now all-allele centered with no variance scaling and a segregating-site denominator, where before it was a per-variant Patterson standardization on haplotypes. Explained-variance ratios and the top subspace change accordingly; the output is the eigendecomposition of the genetic relatedness matrix.

The diploid dosage PCA (the previous default's biallelic behaviour) is still available, now as pca_dosage on a GenotypeMatrix.

local_pca and lostruct change on all input for the same reason, and no longer bit-match the published R local-PCA on biallelic data: the all-allele one-hot keeps a column per present allele where R keeps one dosage column per site, and it is single-centered (variant only) where R's classical-MDS double-centers (variant and sample). The two agree for genealogical structure but diverge for structure that aligns with average distance-from-reference (a per-group frequency shift); the reasoning is in the centering rationale note. The strict R-parity tests are xfailed with that reason.

Verification

The all-allele Gram equals a tree sequence's genetic_relatedness matrix, and pca's explained variance equals the eigenvalues of that matrix. randomized_pca matches full pca (explained variance plus top subspace). pca_dosage reproduces scikit-allel's diploid PCA explained-variance ratios to 1e-8 and coordinates up to a per-component sign; randomized_pca_dosage matches pca_dosage on structured data via canonical correlations. Type guards and the biallelic requirement are covered. Per-window local_pca / lostruct / jackknife match a direct per-window computation, adjacent windows are sign-aligned, and the R-parity suite is xfailed where the conventions genuinely differ.

@nspope
nspope requested a review from andrewkern July 14, 2026 19:08
@nspope

nspope commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator Author

Looking at this again, I'm not sure matching scikit-allel is the right choice---that is, summing over all non-reference alleles. tskit doesn't have site-PCA implemented yet, but for the GRM it sums over all alleles including the reference. That'd be the more internally consistent option here (... this is unlikely to matter in practice, but might as well make the semantics all internally consistent)

@andrewkern

Copy link
Copy Markdown
Member

i think we should generally aim to match tskit behavior over scikit-allel here

@nspope

nspope commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator Author

Sounds good. So I think this also means returning an unstandardized (but optionally centered) GRM for the relatedness issue ... unlike GCTA, which uses a 2p(1-p) standardization. If/when we decide on a multiallelic standardization we can add a standardized=True option.

@andrewkern

Copy link
Copy Markdown
Member

sounds good to me

- 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
@nspope
nspope force-pushed the nsp-multiallelic-5 branch from bff75a1 to 50d4f80 Compare July 16, 2026 03:55
@nspope

nspope commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator Author

so downside here: if we make everything consistent, then lostruct will differ from the R implementation (which "double-centers", as it centers each sample as well as each site). That's an operation that only really makes sense on the dosage matrix. The general (multiallelic) construction (which one-hot encode alleles) doesn't really have an analogue.

I don't think this impacts the results much---it changes the eigenvalues by a scalar, eigenvectors are essentially the same, on the lostruct "example" we ship---but it does make the two forms numerically distinct. EDIT: that's not the full picture---it does look very similar on the genealogical test bed, but for the highly artificial test fixture with "frequency block structure" they look pretty different. Not sure what the best choice is here.

nspope added 5 commits July 16, 2026 20:36
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.
@nspope

nspope commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator Author

ok this is ready for another look @andrewkern --- I updated the PR description in the OP

@andrewkern andrewkern left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this looks good to go Nate-- we'll want to add a bit in the docs explaining the choice to diverge from the lostruct R implementation, but we could do that on a separate PR if you like

@nspope

nspope commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator Author

I think we'll also need to benchmark the two implementations on some real data, unfortunately---these changes have had a bigger blast radius than anticipated.

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