Repository navigation
Add PCA and dot plots; make the incomplete beta fast at every argument - #62
Merged
Merged
Conversation
principal_components and plot_pca give the principal components of samples as DESeq2's plotPCA computes them, from a matrix, a voom result or a cube's cell type; plot_dotplot draws scanpy's dot plot from a cube's sums. The incomplete beta function no longer hangs or runs 10^7 steps at extreme arguments (Temme's uniform expansion near the centre of huge shapes, and forms that stay in range elsewhere); results elsewhere are unchanged to the bit, and the incomplete gamma function no longer hangs where a + x overflows. Checked against DESeq2 1.46.0, scanpy 1.12.4 and mpmath (validation/pca_and_dot_plots.py; validation/distributions.py).
parashardhapola
force-pushed
the
54-pca-dot-plots-and-incomplete-beta
branch
from
October 26, 2026 23:05
9202c3b to
558102d
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Two figures that users look for first: the samples on their principal components, as
DESeq2's
plotPCAcomputes and draws them, and the dot plot of genes by cell type(the fraction of cells that express each gene and their mean expression), as scanpy
draws it.
principal_componentsreturns the coordinates, shares of variance andloadings that
plot_pcadraws, so that a guide can use them;plot_dotplotdraws fromthe sums a cube keeps, without the cells, and gives scanpy's values on the cells the cube
was pooled from. Separately, the incomplete beta function, which hung or ran 10^7 steps
of its continued fraction at arguments near the edges of the double range, now takes
well under a millisecond at every argument, with values checked against mpmath; on the
existing validation grid every result is unchanged to the bit but one, which moved to
the exact value.
Changes
cytepool.principal_components.principal_components(data, *, group=None, top=500, min_cells=None):the samples on the principal components of their
topgenes of largest variance, fora samples x genes matrix, a
VoomResultor one group of aCube(its samples with atleast
min_cellscells and some counts, the genesfilter_by_expressionkeeps,log-CPM with TMM factors and an average count of 2, as
plot_mdsprepares a cube).cytepool.principal_components.PrincipalComponents:coordinates(samples xcomponents),
variance_explained(DESeq2'spercentVar),loadings(genes used xcomponents),
genes(positions of the genes used, by decreasing variance),samples(a cube's sample table rows, or ids from 1),
settings,n_components; read-only.cytepool.plotting.plot_pca(data, *, group, color_by, marker_by, labels, top, components=(1, 2), min_cells, ax):plotPCA's figure, from aPrincipalComponentsor from data: samples coloured andmarked by sample variables as
plot_mdsdraws them, axes labelled "PC1: 45%variance", equal scales.
cytepool.plotting.plot_dotplot(cube, genes, *, groups, samples, standard_scale, mean_only_expressed, ax):scanpy's dot plot from a cube's
detected,log_sumandn_cells, pooled over allsamples or some (ids or a mask), for genes by feature id or symbol, or in named groups
drawn under brackets;
standard_scale="gene"or"group";mean_only_expressed.The colour bar's label is written above the bar, as scanpy writes it, so that it
never runs into the key of the dots' areas below the bar; a test checks that they
do not overlap.
cytepool/stats/_scalar_special_functions.py): Temme'suniform expansion near the center of two shapes from 1e10; forms of the continued
fraction and its prefactor that stay in range for shapes from 2^500, for a + b beyond
the double range, for shapes below 1e-280, and for shapes more than 1e17 apart; exact
products of shapes from 1e290; the small-shape series of 1 - I divided by the shape
where its exponent would be absorbed.
deviance_termhalves arguments whose sumoverflows, which also stops the incomplete gamma function from hanging there. Normal
arguments take the old code path.
validation/pca_and_dot_plots.py(new);validation/distributions.pygains the incomplete beta function at the edges of the double range and near the
center of large shapes against mpmath, with the time per value.
tests/test_pca_and_dot_plots.py(new) with fixtures intests/data/pca_and_dot_plots/(49 KB);tests/test_special.pygains the extremearguments (fixture
tests/data/distributions/betainc_extreme.npz, every point), atime bound, closed forms at huge and tiny shapes, and the incomplete gamma function
where shape and argument overflow; the masked-input inventory gains the three
functions (for arrays and cubes) and the record.
cytepool.principal_componentsin the API index; twocapability matrix rows; a section "Samples and cells at a glance" in "The cube and its
additive layers"; Pearson (1901) and Temme (1982) in
references.bib(DOIs checkedagainst Crossref) and on the Bibliography page;
plot_pcaandplot_dotplotunder[planned]in the coverage exemptions, with the guides that will show them; theValidation page (a section and summary row for the new script, the distributions
section, totals, dates, timings, the command, and a note that scanpy's dot plot
conventions were read from its BSD-licensed source); a README status row.
Design notes
Principal components, as DESeq2's
plotPCAThe help page of
plotPCA(DESeq2 1.46.0) says: thentopgenes "selected by highestrow variance",
returnDatafor "the data.frame of PC1 and PC2 with intgroupcovariates", and
pcsToUse. What it leaves open was found by running it as a blackbox on
DESeqTransformobjects of chosen values:prcompon the centred top genes (U S of thesingular value decomposition), and
percentVaris each squared singular value overtheir sum.
prcomp's help page says the signs are arbitrary and may differ betweenbuilds of R. CytePool makes each component's largest absolute coordinate positive (of
near ties, within a relative 1e-9, the first), as
plot_mdsorients its dimensions;the validation compares up to sign (2 of 10 components differ from R's).
ntop = 20, the first two are kept.ntopbeyond the number of genes takes them all.has equal scales (
coord_fixed, ratio 1), and two variables ofintgroupcolour thesamples by their interaction ("a:x").
draws it. CytePool returns min(n_samples - 1, n_genes) components, the dimensions the
centred values have, and refuses to draw a second one (a deliberate difference). It
also refuses values that are not finite, where DESeq2 ranks a gene with NA last, and
values that vary only by rounding.
A cube's group is prepared as
plot_mdsprepares it, as the edgeR User's Guideprepares counts for
plotMDS; the validation has R prepare the same samples withedgeR (
filterByExpr,normLibSizes,cpm(log = TRUE, prior.count = 2)) beforeplotPCA.plot_pcadraws its points with a private_draw_samples, which repeatsplot_mds's drawing so that this change touches only new functions ofplotting.py;plot_mdscan move onto it later.Dot plots, as scanpy's
dotplotscanpy's documentation (1.12.4) gives the fraction of cells whose value exceeds
expression_cutoff(0) and the mean in each group,standard_scale("subtract theminimum and divide each by its maximum", per variable or per group),
mean_only_expressed,size_exponent1.5,largest_dot200 andvar_namesas a dictfor groups of genes. scanpy is BSD-licensed, and three conventions its documentation
leaves open were read from its source: the largest fraction shown is rounded up to a
tenth, the key of the areas steps by 0.2, or by 0.1 and 0.05 when the largest fraction
is at most 0.6 and 0.3, and a scaled row of equal means is 0. No code was copied; a
notice in
THIRD_PARTY_NOTICES.mdmay still be wanted (see below).From a cube: a cell counts as expressing a gene when its count is positive (
detected),which is scanpy's default cutoff on log-normalized values, since log(1 + x) > 0 exactly
when the count is; the fraction and mean over the cells of all samples pooled are sums
over samples divided by summed cell numbers, which equals scanpy's on the cells. The mean
over expressing cells,
log_sum / detected, needs a transform that maps a zero count tozero, so it is refused unless the cube's normalization records a pseudocount of 1, as
from_cellsrecords it. Options that need the cells (anotherexpression_cutoff, adendrogram of the cells) are not offered; styling options are left to the Axes, and the
colours follow CytePool's palette (blue from light to dark) rather than scanpy's reds.
The figure exposes its values through its artists: the dots' sizes are the areas and
their array the values the colours show.
The incomplete beta function
On a grid of 0, 5e-324, 1e-300, 1e-8, 0.5, 1 - 2^-53, 1, 2, 30, 1e8, 1e300 and the
largest double for a, b and x (1,728 points), the old code did not return at 32 points
within 5 s, took over 1 ms at 144 (up to 3.4 s), and gave NaN at 158 points of the
domain. The causes:
incomplete gamma function, beyond 1.8e308): the series' step became NaN and its stop
test, an update that changes nothing, never held. It now halves its arguments
(bd0 is homogeneous of degree one) where their sum reaches 2^1023.
(a + b) x and k (b - k) x), and with denominators beyond 2^1022 the reciprocal that
Lentz's method keeps was subnormal and inexact, so the ratio of steps never reached 1
within the tolerance: 10^7 steps (0.15 s, longer on subnormals), then NaN. For shapes
from 2^500 it now runs on products ordered to stay in range and keeps the denominators
themselves instead of their reciprocals; normal shapes run the old routine.
shapes in steps (counted: 2,500 at 1e8, 11,600 at 1e10, 55,000 at 1e12, 1.1 million at
1e16), beyond the 10^7 cap from about 1e20. Within one standard deviation of the
center (r eta^2 / 2 <= 1/2) of two shapes from 1e10, Temme's (1982) uniform expansion
replaces it: I = erfc(-eta sqrt(r/2)) / 2 - G exp(-r eta^2 / 2) c0(eta) / sqrt(2 pi r),
with c0's Taylor series (p - q) / (3 s) + (1 - s^2) / (12 s^2) eta + (p - q)(2 + s^2) /
(135 s^3) eta^2, derived by series reversion and checked against mpmath; the omitted
terms are of relative order min(a, b)^(-3/2). For such shapes outside the band the
fraction takes at most about 1,300 steps, and for shapes below 1e10 at most about
11,600, at the center.
1 - I (about a log x) was absorbed by Stirling corrections of order 0.003 that the sum
adds and subtracts, and subnormal shapes rounded the fraction's first elements. Below
1e-3, where |s| < 1e-4, s is now formed divided by a; below 1e-280 the fraction is
evaluated divided by a.
the deviance term of (a + b)(1 - x) = b + lam overflowed and was taken as 0 (1.7e-10
relative in log C at a = 1.8e308, b = 1e300), and for shapes more than 1e17 apart with
a tiny x the direct deviance form subtracted logarithms near 690 (2.1e-12 at a = 30,
b = 1e300, x = 1e-300). Both are now formed at half scale and from the rounded
product (a + b) x.
Now every one of the 1,728 values takes at most 0.33 ms per call from Python (0.21 ms,
best of three, in the validation), the grid evaluates as one array in 0.39 ms, no value
in the domain is NaN, and all 882 points of the extreme family agree with mpmath to
2.0e-13. Results on the 19,493 points of the existing validation grids were compared
bit for bit between the old and the new package: one point changed, F(x = 1; 10^12,
10^12), whose cdf and sf were 0.5000000000000071 and 0.49999999999999295 and are now
0.5, which mpmath (and the symmetry of I_1/2(a, a)) gives exactly. A first version of the
small-shape change moved the t quantile at p = 0.26, df = 0.001 from 9.7e-14 to 2.1e-13
of mpmath's value; the change was narrowed to where the old sum loses s, and that
quantile is again unchanged to the bit. The incomplete gamma function, which hung at
gammainc(1.8e308, 1.8e308) for the same reason, gives 1/2 there, and
log Q(1e300, 1.8e308) = -1.7976929347904657e308, as mpmath gives.
The reference at the edges of the double range needed care: where one tail is within
1e-30 of 1, the other is its complement, so the continued fraction is evaluated in
2,400 bits (enough for a + b with a subnormal and the largest double) to 400 digits;
near the center of large shapes, where the fraction converges too slowly, the beta
density is integrated numerically.
Validation
validation/pca_and_dot_plots.py(R 4.4.3, DESeq2 1.46.0, edgeR 4.4.0, ggplot2 4.0.3;scanpy 1.12.4, anndata 0.13.4, matplotlib 3.11.2):
plotPCA(returnData = TRUE)plotPCAplotPCA(returnData = TRUE)plotPCA(returnData = TRUE)scanpy.pl.dotplotscanpy.pl.dotplotvalidation/distributions.py(mpmath 1.3.0), the rows that changed (the other 46checks print the values they printed before):
Both exit without a failure (19.8 s, 671 MiB; 12 min 28 s, 174 MiB, of which mpmath
took 128 s at the edges of the double range).
Tests
tests/test_pca_and_dot_plots.py, 37 tests in 1.0 s warm: DESeq2's coordinates,shares, ties, cube and figure from the fixtures; scanpy's areas, colour values,
labels, key and brackets for the six cases; by hand, three samples on two genes whose
components are the genes, two samples, one gene, and a gene expressed in half the
cells of two samples (areas and means); invariants: the order of samples and genes,
negated, shifted and scaled values, orthonormal loadings, a merged group and pooled
samples against the same cells given one label, scaled means from 0 to 1; errors and
their messages.
tests/test_special.py, 9 new: the extreme fixture against mpmath (all 882 points,9.8 KB), 3,456 values at the grid within 2 s (under 1 ms here), I_1/2(a, a) = 1/2 up to the largest double, the
reflection I_x(a, b) = 1 - I_(1-x)(b, a) inside and outside the band, 1 - x^a at
shapes down to 5e-324, the incomplete gamma function where shape and argument overflow.
Checks
skipped; the same with bounds checks, and with the rounding changed
(
OPENBLAS_CORETYPE=Sandybridge, 4 threads,NUMBA_CPU_NAME=generic, a freshcache); Python 3.14: 4,922 passed; Python 3.15: 4,816 passed, 107 skipped.
validation/pca_and_dot_plots.pyandvalidation/distributions.pyrun again onthat tree: every check passes.
ruff checkandruff format --checkoncytepool tests validation docs: clean;mypy cytepool: no issues in 72 files.make -C docs apiandmake -C docs html(Node.js 24): built without warnings;tests/test_documentation.py: 74 passed.