Builds the neighbourhood-interaction design for each niche bandwidth and fits
a per-gene negative binomial GLM over all genes using the SpaNorm
fitNB engine. The heavy fit uses fitNB's compute
backend (CPU or GPU); the counts Y may be dense, sparse, or a
DelayedArray and is not densified up front. The returned object holds
one SpiDEFit per bandwidth. Neighbourhood effects are tested
separately with testSpiDE().
Usage
fitSpiDE(spe, condition = NULL, ...)
# S4 method for class 'ANY'
fitSpiDE(
spe,
condition = NULL,
index = NULL,
niche = NULL,
covariates = character(),
sigma = NULL,
assay = "counts",
cell_type = "cell_type",
sample_id = "sample_id",
random = c("intercept", "none", "slope"),
winsor = 4,
lambda.a = 0,
backend = c("auto", "cpu", "gpu"),
name = "Niche",
re.maxit = 2L,
re.tol = 0.001,
tau2.init = 1,
re.prop = 1,
re.maxit.psi = 1L,
re.min.cells = 100L,
df.method = c("satterthwaite", "between"),
re.celltype = TRUE,
converge = TRUE,
converge.maxit = 50L,
converge.tol = 1e-08,
block.size = NULL,
BPPARAM = BiocParallel::SerialParam(),
verbose = TRUE,
...
)Arguments
- spe
a SpatialExperiment with niche reducedDims (see
buildNiches()).- condition
a character, the colData column of the tested condition (must have exactly two levels), or
NULLfor a condition-free (niche-only) analysis. WithNULLthe condition terms are dropped from the design and the two-wayCellType:nicheinteractions become the tested effects: within index cell type c, how expression changes with the local density of niche cell type n. Theresults(type = "celltype")andresults(type = "patient")tables are empty in that mode, there being no condition to contrast.- ...
further arguments forwarded to
fitNB.- index, niche
character vectors restricting the index / niche cell types considered (NULL = all).
- covariates
a character vector of nuisance colData columns to adjust for (e.g. library size, age, sex).
- sigma
a numeric vector of bandwidths to fit; NULL (default) uses every
Niche<sigma>reducedDim present.- assay
a character, the counts assay to model.
- cell_type
a character, the colData column of cell type labels.
- sample_id
a character, the colData column identifying samples (patients); used only when
random != "none".- random
one of "intercept" (the default), "none" or "slope". Adds patient-level random effects (implemented as ridge-penalised design columns) to correct anti-conservative inference caused by cell-level pseudo-replication.
The default changed from "none" to "intercept". "none" forms Wald standard errors from cell-level information, which treats every cell as an independent replicate of a patient-level contrast. On a null with per-sample intercepts it rejects at \(\approx 0.71\) against a nominal 0.05, worsening as cell counts become imbalanced, where the random intercept holds \(\approx 0.04\) against a calibrated pseudobulk reference of \(\approx 0.04\). "none" is retained for back-compatibility and for reproducing pre-correction results, but it should not be used for inference. "intercept" adds a per-sample random intercept; "slope" additionally adds per-sample random slopes on the niche covariates, which protects the response x niche tests when the niche-slope varies between samples. Note that "slope" estimates an extra variance component (
tau2forSampleSlope) that is collinear with the tested fixed effect and, with few samples, is less stable than the intercept-only variance; prefer "intercept" at smallS, and "slope" when between-sample niche-slope variation is expected. The fit is also stochastic (fitNBsubsamples cells for the dispersion estimate), so set a seed for reproducible variance components. See the mixed-effects and simulation vignettes.In a condition-free analysis (
condition = NULL) the random-slope block sits on exactly theCellType:nichecolumns being tested, so"slope"is the natural correction there when between-sample variation in niche slopes is plausible. Be aware of a limitation specific to that mode: the tested slope is a within-sample contrast on a spatially autocorrelated covariate, and spiDE does not model spatial autocorrelation. Neighbouring cells are therefore not independent replicates of the slope, and neither random-effect structure can recover that – on a null fixture with a planted per-sample intercept, the fixed-effects fit made 37 calls,"intercept"5 and"slope"5 (seelongtests/testthat/test-nicheOnly-mixed.R). Random effects remove most of the inflation but niche mode remains mildly anti-conservative; treat borderline calls with corresponding caution.- winsor, lambda.a
fitting parameters forwarded to
fitNB(coefficient winsorisation and the base ridge penalty on the fixed columns).- backend
a character, the fitNB compute backend ("auto", "cpu", or "gpu").
- name
a character, the niche reducedDim prefix.
- re.maxit, re.tol
iteration cap and relative tolerance for the random-effect variance-component (Schall/PQL) loop (used when
random != "none"). The tolerance is a relative change onlog(tau2).The default was lowered from 10 to 2 on measurement: for
random = "intercept"one iteration is indistinguishable from ten on null type-I error (to three decimal places) and ontau2(to two), because the loop typically converges in a couple of steps. It also largely dissolves a hazard of the larger cap –tau2can enter a 2-cycle, making the result depend on the parity ofre.maxit.This evidence covers the intercept model only. Under
random = "slope"the slope variance component decays monotonically across all ten iterations without meetingre.tol, so a cap of 2 leaves it far from where 10 leaves it. Passre.maxit = 10for slope fits.- tau2.init
initial random-effect variance component.
- re.prop
the cell-subsampling proportion used to speed up the variance-component (PQL) loop, sampled per cell type within each sample (
random != "none"). For a stratum ofncells,min(n, max(ceil(re.prop * n), re.min.cells))are used.1(the default) disables subsampling (all cells, fully reproducible). The final fit that feeds inference always uses all cells; only the sharedtau2estimate is affected. No seed is set internally — set one externally for reproducibility ofre.prop < 1runs. Loweringre.proptrades accuracy for speed, and the trade is worse than it looks: a replicate study on real data (vignettes/spiDE-mixed- benchmark.Rmd) found that subsampling noise intau2does not shrink asre.proprises from 0.2 to 0.8 (it stays comparable to or larger than genuine between-patient variation), and — more importantly —tau2is systematically biased downward at everyre.prop < 1tested, an attenuation that averaging replicates cannot fix, only shrinking asre.propapproaches 1. Usingre.prop < 1is therefore rarely advised; only do so when the variance component's absolute scale doesn't matter (e.g. a quick feasibility check) and treat itstau2as a lower bound, not a point estimate.- re.maxit.psi
dispersion iterations for the inner PQL loop fits (the final all-cell fit always uses full dispersion).
1(default) skips the redundant re-estimation of the barely-moving dispersion each iteration.- re.min.cells
the per-stratum floor for
re.propsubsampling.- df.method
one of "satterthwaite" (default) or "between"; only used when
random != "none". "satterthwaite" derives a separate df per tested column from the shared variance-component fit, distinguishing between-sample contrasts (Response: small df, close to "between") from within-sample contrasts (ResponseNiche: larger df, more power) rather than applyingS - 2to both;@dfis then a named per-column vector. "between" tests every Response/ResponseNiche coefficient against the same scalar between-sample reference df (S - 2), the original back-compatible behaviour, and@dfis a scalar.The default changed to "satterthwaite" after the benchmark study (
research/) measured both arms on identically seeded data: "between" is severely over-conservative when samples are few (null type-I \(\approx 0.001\) at \(S = 4\) against a nominal 0.05, with correspondingly near-zero power), while "satterthwaite" holds type-I in \(0.042\)-\(0.065\) across the whole sampled range and gains \(\approx 0.10\) mean TPR. The trade is a mild liberal drift at larger \(S\) (worst measured \(\approx 0.065\)); use "between" when strict conservatism matters more than power, or for back-compatibility. Ignored whenrandom == "none".In a condition-free analysis (
condition = NULL) the"between"reference df changes, because the testedCellType:nicheslope is a within-sample contrast rather than a between-condition one: it isncells - p_fixedunderrandom = "intercept"(cells are the replicates) andS - 1underrandom = "slope"(the per-sample random slopes sit on the tested columns, moving the contrast into the between-sample stratum). The name"between"is therefore a misnomer in the intercept case; it is retained for back-compatibility."satterthwaite"computes this distinction from the fitted variance components and is preferred.- re.celltype
logical; when
random != "none", add a nested (sample x cell type) random intercept alongside the per-sample one. DefaultTRUE. Without it the tested niche slopes are estimated from the total covariance of niche density and expression within an index cell type, so they also carry the between-sample composition effect: samples whose index cells sit in denser niche surroundings also differ in mean expression there. That is a patient-level association with S units, not neighbourhood-dependent differential expression, and a shuffle null that permutes within (sample, cell type) preserves it – which is why real data and such a null were indistinguishable on the YTMA cohort. With the block present every niche slope is a within-group slope and the shuffle null is calibrated in every expression band. SetFALSEto reproduce pre-correction fits. Ignored whenrandom = "none".- converge
logical; after
fitNBreturns, converge each gene to its own penalised negative-binomial optimum and re-estimate its dispersion there. DefaultTRUE.fitNBfits every gene in one IRLS loop with a single gene-averaged cell weight vector and an aggregate convergence criterion, which is what makes a whole-transcriptome fit affordable; for bright, cell-type-restricted genes it stops 1-4 standard errors short of that gene's own optimum, with a dispersion about 1.6 times too large. The stage is per-gene, so it is blocked and parallelised overblock.size/BPPARAMexactly as inference is. Note that it replaces edgeR's cross-gene moderated dispersion with a per-gene profile maximum-likelihood dispersion at the converged mean: with many cells per gene that is well determined, but it is a deliberate departure fromfitNB's moderation. SetFALSEto reproduce pre-correction fits.- converge.maxit, converge.tol
iteration cap and relative log-likelihood tolerance for the per-gene convergence stage.
- block.size
genes per block in the per-gene convergence stage (see
testSpiDE()for the same argument at inference time). NULL fits every gene in one block.- BPPARAM
a BiocParallelParam. Used by the per-gene convergence stage (
converge = TRUE), which is blocked over genes and dispatched with it; each worker densifies its own gene block, so peak memory scales with the number of workers. Absent an explicitblock.size, one block per worker is used.- verbose
a logical, whether to print fitting progress.
Value
a SpiDEResults object (inference not yet computed).
Examples
data(toySpiDE)
spe <- toySpiDE
spe <- buildNiches(spe, sigma = 20)
fit <- fitSpiDE(spe, condition = "condition", sigma = 20, verbose = FALSE)
fit
#> SpiDEResults
#> Bandwidths (sigma): 20
#> Genes: 20
#> Condition: condition
#> Index cell types: A, B, C
#> Niche cell types: A, B, C
#> Tested: FALSE (0 rows in results table)
fit0 <- fitSpiDE(spe, condition = NULL, sigma = 20, random = "none",
verbose = FALSE)
fit0
#> SpiDEResults
#> Bandwidths (sigma): 20
#> Genes: 20
#> Mode: niche-only (no condition)
#> Index cell types: A, B, C
#> Niche cell types: A, B, C
#> Tested: FALSE (0 rows in results table)