The changelog of the R package SpaNorm, from NEWS.md. spanormpy follows the R version it documents (see R vs Python).
SpaNorm 1.8.0
New Features
SpaNorm()gains aBPPARAMargument to parallelise normalisation across workers viaBiocParallel, accelerating large datasets. It defaults toBiocParallel::SerialParam()(no parallelisation), and results are identical regardless of the backend used.SpaNorm()now normalisesDelayedArray-backed count assays (e.g. disk-backed viaHDF5Array) block-wise, so out-of-core datasets are processed without ever loading the full matrix into memory. Results match the in-memory path.Exported
fitNB(), which fits a per-gene negative binomial GLM over an arbitrary design matrix using SpaNorm’s IRLS engine (with optional ridge regularisation and adjustable outlier winsorisation). This exposes the model-fitting machinery for reuse independently of SpaNorm’s spatial model.fitNB()andcalculateMu()gain anoffsetargument: a genes x cells matrix added to the linear predictor with its coefficient fixed at 1, solog(mu) = gmean + tcrossprod(alpha, W) + offset. Use it for an effect that is already known rather than adding a column to the design, whose coefficient would float and could absorb signal correlated with that effect. The offset is subset alongside the counts, applied to the dispersion estimation as well as the mean, and sliced in step with the counts when the fit is carved into gene-blocks. It defaults toNULL, which is a strict no-op, so all existing behaviour is unchanged. The GPU path is implemented backend-agnostically but has not yet been executed on an accelerator.fitNB()also gains apsiargument for supplying per-gene NB dispersions instead of estimating them. Supplied dispersions are used as-is (no re-estimation, no winsorisation) and the outer dispersion loop is bypassed: the coefficients come from a single IRLS fit at the givenpsi, soedgeR::estimateDispis never called and the returnedsamplingfactor carries no"dispersion"level. The dispersions need not come from an identical design – values estimated on a design nested in (or equal to) the fitting design are appropriate, e.g. pooled across a coarser model, which errs conservative.psicomposes withoffset, andpsi = NULL(the default) is a strict no-op.Exported the quasi-likelihood dispersion machinery of edgeR v4 as generic NB-GLM functions with a CPU and a torch backend:
nbUnitDeviance()(the NB unit deviance, elementwise over genes x cells),nbDevianceMoments()(the unit deviance’s first two moments under the fitted NB by direct summation over the pmf, the definition edgeR’s Chebyshev tables approximate) andqlDispersion()(per-gene adjusted deviance, effective residual df and their ratio). The moments depend only on (mu, phi), soqlDispersion()evaluates them once on a shared (log mu, log phi) table and interpolates onto every gene and cell; the per-gene work is then elementwise and runs on the accelerator when the counts and means are torch tensors. Oracle-tested againstedgeR::glmQLFit(); used by spiDE for the standard-error scale of its niche tests.Exported
polishNB(),nbProfilePsi()and the Newton solvers behind them (nbNewtonSolver(),nbNewtonSolverBatch(),nbGramBatch(),nbAbsorbGramBatch(),nbMuFloor()), moved here from spiDE’s per-gene convergence stage.polishNB()takes an already-fittedfitNB()-style model (design, coefficients, dispersion) and converges every gene separately to its own penalised negative binomial optimum by damped Newton, gene-blocked and dispatched viaBiocParallel;nbProfilePsi()re-estimates just the dispersion by profile maximum likelihood at fixed coefficients. Both take anoffset(a known effect added to the linear predictor with its coefficient fixed at 1, rather than a floating design column that could absorb correlated signal);polishNB()also takesabsorb/absorb.batch(a wide indicator block – such as per-sample random-effect columns – folded into the per-gene solve by a Schur complement, at the cost of one dense-column gram per iteration regardless of block width). spiDE calls these for its own per-gene convergence stage.Added
polishSpaNorm()andisPolished().polishSpaNorm()maps a fittedSpaNorm()(orSpaNormSVG()null) model ontopolishNB()‘s generic problem – the shared library-size coefficienta1pulled out of the design as a per-cell offset, the per-gene meangmeanan unpenalised intercept column, and the biology/library-size/batch columns ridge-penalised asSpaNorm()penalises them (lambda.atimes the number of cells/spots, by columnwtype) – converges every gene, and rewrites the normalised assay from the polished fit.psi.methodsets how each gene’s dispersion is handled at the converged mean ("fixed", the default, keepsSpaNorm()’s value;"profile"re-estimates it by profile ML and re-polishes);lssets whether the shareda1is held fixed ("fixed", the default) or re-estimated jointly with the per-gene coefficients at the optimum of the total penalised likelihood over the polished genes ("joint");cellschooses all cells/spots or only the onesSpaNorm()sampled to fit; a gene with no counts in the polished cells, or with none in the polished cells of one batch level (a level of the unpenalised 0/1 batch columns), is held out and keeps its input fit (polished = FALSE, the reason in the diagnostics’held_outcolumn, and a warning counting the batch-level cases) rather than driven to an unbounded coefficient; a warning also counts any gene the Newton engine could not polish, and an unpenalised block that is collinear on the polished cells (a batch matrix given with every level’s indicator) is caught up front by one QR, so that no gene is polished along its null direction and the outcome does not depend on the platform’s rounding; andoverwriteallows re-polishing an already-polished fit from the unpolished one kept alongside it.isPolished()reports whether a stored fit has been polished. Measured on four real cores (YTMA CosMx WTA, 948-16,350 genes x 395-2,645 cells,psi.method = "fixed"only):ls = "joint"moved the shareda1by 7.9-79.0 of its own profiled standard error and took the SVG calls (FDR < 0.05) from 137 to 123 on the 395-cell core, and from 5 to 5, 180 to 178 and 545 to 551 on the other three. On two of the cores (10,738 x 395 and 948 x 646), over a random 50 genes each, it shifted the genes’ fitted log-means by a median 0.09 and 0.03 of their own standard error (max 0.68 and 0.17), concentrated in the lowest-library-size cells. The joint polish took 4-7x the wall time of a fixed one in that comparison, which ran with an earlier stop ona1(tol.ls = 1e-6) under which every joint fit took all 10 steps; the shipped stop (1e-3) ends sooner, and the cost has not been re-measured.ls = "fixed"is the default becauseSpaNorm()’s owna1is an unweighted mean over genes where the joint estimate is information-weighted and so dominated by the brightest ones, changing the estimand rather than only its precision. That comparison covers four cores only, with no ground truth for whicha1is correct and no independent validation of the resulting SVG-call changes.Added
nbBlockDesign(), a compact form for a design with a per-group block –[X | Z_1 | ... | Z_G], eachZ_gnon-zero only on groupg’s cells, such as a per-patient intercept plus a per-patient spatial library-size spline – given as the dense columnsX, one cells x q matrixZof each cell’s own block values, and the group of every cell.polishNB(),nbProfilePsi()andnbNewtonSolver()accept it asWand never build the dense n x (p_x + G q) matrix: the linear predictor and the score cost O(n (p_x + q)), the per-gene gram O(n (p_x + q)^2), and every group’s q x q block is absorbed by a Schur complement, whatever the number of groups. The result is the dense grouped result (as.matrix()of the design withabsorbgrouping the block columns by group), tested to 1e-8 on coefficients, log-likelihoods, dispersions, the Schur complement, the Newton step andxcov(). A group whose block is rank-deficient (fewer cells than columns, a level with no cells, an unpenalised column constant over the group; detected on the unit-diagonal scale atrank.tol = 1e-10) is solved by a generalised inverse, which fits exactly as the design with the aliased columns removed. CPU only. Measured on one dedicated core with one BLAS thread (benchmarks/block_design_timing.R, simulated data, 45 patients, 40 dense columns, a 10-column block per patient – intercept plus a 3 x 3 spatial library-size spline – 3 genes, profile dispersion, SLURM job 29107530, results inbenchmarks/block_design_timing_29107530.csv): against the dense grouped path the per-gene factorisation was 8.0-13.2x faster and the whole per-gene polish 3.8-5.6x faster over 20,000-300,000 cells (13.9 s against 3.7 s per gene at 300,000 cells), with the same coefficients, log-likelihoods, dispersions and Newton iteration counts; and at every size the compact spline model took 0.85-0.97x the per-gene time of the intercept-only model (the patient intercepts alone, absorbed as 1x1 blocks from a dense design), although it took more Newton iterations (10.7-19 against 6-7 per gene). The intercept-only model itself, passed asnbBlockDesign(X, matrix(1, n, 1), patient), gave the same fit in 0.62-0.93x the time of the dense design with a logicalabsorb. Peak resident memory of the process running all four arms at 300,000 cells was 12.5 GiB, set by the dense grouped arm (its design matrix alone is 1.1 GiB, against 0.26 GiB for the compact design object). At the shape of spiDE’sdepth = "spatial_spline"design for one index type (benchmarks/block_design_spide_shape.R, built exactly as spiDE’s own design functions build it: 30,000 cells, 50 patients with 1-3 sections each, a per-patient block of an intercept plus[l | l * B]per section – 759 block columns, 6-31 per patient – and 10 dense columns; 1,000 genes, profile dispersion, one dedicated core, SLURM job 29111836, results inbenchmarks/block_design_spide_shape_29111836.csv), the compact design (each patient’s block padded with zero columns to the widest, which spiDE’s 1e-3 ridge holds at 0) took 0.48 s per gene against 1.57 s on the dense grouped path (3.3x; 8.0 against 26.2 minutes for the 1,000 genes), and the intercept-only model 0.35 s; the two spline fits agreed to 2.3e-13 on every coefficient of every gene.Exported
tpsBasis(), the tensor-product natural-spline basis over spatial coordinates that SpaNorm’s fit is built from (identical to the internal basis on its own coordinates). It can be built on reference coordinates (such as a whole section) and evaluated at any subset, with the columns centred by the reference means, so every subset of a section shares one basis;dfmay be given per axis.
Improvements
The optional GPU backend now uses the
torchpackage instead of TensorFlow, adding native support for NVIDIA CUDA and Apple Silicon (Metal/MPS) devices and removing the Python/reticulate dependency. Users ofbackend = "gpu"should installtorchin place oftensorflow.The GPU backend now automatically detects available accelerator memory and fits large datasets in gene-blocks so peak GPU memory stays bounded, avoiding out-of-memory failures on GPUs with limited VRAM. This requires no additional arguments; the detected budget can be overridden via the new
gpu.mem.budgetparameter, and results match the CPU backend within a small numerical tolerance.The dispersion winsorisation used during normalisation now clamps at 4 MAD (previously 3), matching the coefficient and mean winsorisation, and is configurable via the winsorisation controls on the fitting/normalisation helpers.
Exported the generic GPU device/memory-budget/tensor-conversion layer (
checkGPU(),getBackendDevice(),getBackendDtype(),gpuDtypeBytes(),getGPUMemoryBudget(),resetGPUCache(),is_torch_tensor(),toGPUMatrix(),toGPUVector(),toRMatrix(),diag_mat(),tcrossprod_gpu(),matmul_gpu(),add_vec_mat_gpu(),mult_vec_mat_gpu(),rowSums_gpu(),dnbinom_gpu(),hasBadValues()) and added a newinvert_mat_batched(), so downstream packages (e.g. spiDE) can build their own GPU-blocked per-gene computation on the same device/dtype/memory-budget machinery, without depending on unexported internals.calculateMu()now accepts abackendargument ("cpu"by default, unchanged behaviour) and dispatches to the accelerator when requested.Fixed
getGPUMemoryBudget()’s CUDA auto-detection reporting the whole physical GPU’s free memory rather than the process’s assigned MIG instance’s, causing a many-fold budget overestimate (and downstream out-of-memory errors) on MIG-partitioned GPUs; it now resolves the correct device viaCUDA_VISIBLE_DEVICES. AddedsetGPUMemoryBudget()to explicitly set (and cache) the budget for the session, for cases where auto-detection remains unreliable.SpaNormSVG()now fits, retrieves or re-polishes the null (technical-only) model so that it is paired with the full fit’s polish state AND settings (psi.method,ls,cells, and underpsi.method = "profile"the dispersion’s search intervalpsi.range), not only whether each isisPolished(): two fits can both be “polished” at different settings, which changes the objective just as much as one being unpolished.svgTest()carries the same check and refuses (rather than silently scoring) a full/null pair that is not paired this way. Either polish the full model before callingSpaNormSVG()(which then polishes the null to match) or callSpaNormSVG()directly and let it pair the null itself.
Bug Fixes
plotSpatial()andplotCovariate()now find genes with non-syntactic names such asHLA-B. The feature names taken from the aesthetics carried backticks, so no feature matched:plotSpatial()failed with “object ‘HLA-B’ not found” andplotCovariate()with “no genes selected for the plot”.- The dispersion winsorisation threshold (
exp(median(log(psi)) + winsor * mad(log(psi)))) is now computed over the finite log-dispersions only. Previously a singleNaNdispersion made the thresholdNaN, sopmin()turned every gene’s logPAC, Pearson and median-bio output intoNaN; and a dispersion of exactly 0 (log-Inf) dragged the median to-Inf. Now only the gene with theNaNdispersion isNaN, genes withpsi = 0are excluded from the median/MAD, and if no dispersion is finite the threshold isInf(no winsorisation).winsorisePsi()andsubsetFitGenes()share one internal helper,.psiMax(). polishNB()no longer stops when the grouped (absorbwith multi-column blocks) solver cannot factor one gene’s block – an unpenalised block with no cells, or with fewer cells than columns. The per-gene engine stopped insqrt(NULL)and the batch engine on a subscript, taking every gene’s polish with it; the gene now falls back to its input fit, flaggedsingular.- Fixed the null (technical-only) model fitted by
SpaNormSVG()being penalised less than the full model.fitSpaNorm()scales the ridge penaltylambda.aby the number of cells/spots before fitting, butfitSpaNormTechnical()rebuilt the penalty from the stored, unscaledlambda.aand did not rescale it, so the nested null was under-penalised by a factor ofncol(Y)on the library-size terms. The two nested fits now use the same penalty, making the likelihood-ratio test consistent. Because the less-penalised null generally fitted slightly better,svg.Fwas slightly deflated for most genes; SVG statistics will typically increase slightly after refitting the null model, although individual genes can move in either direction (a cachedSpaNormNullfit inmetadata(spe)is reused as-is, so remove it to refit).fitSpaNormTechnical()now also stops if the data and the full fit have different numbers of cells.
SpaNorm 1.2.0
- Added model-based spatially variable gene (SVG) calling.
- Added spatial visualisation funciton
plotSpatialto visualise colData, gene expression, and reduced dimensions. - Added spatial visualisation function
plotCovariateto visualise the biolgy, batch, and library size functions estimated by SpaNorm. - Dynamic calculation of df.tps for rectangular tissue sections.
- Allow separate specification of df.tps for biology and library size.
- Added GLM-PCA approximation through the
SpaNormPCAfunction. The null model is considered to consist of the library size effects, batch effects, and the gene mean.
SpaNorm 1.0.0
- Initial Bioconductor submission.