Overview
This vignette states the model behind spiDE once, from
the top down: the quantity it estimates, the design that isolates that
quantity, why cells are not replicates and what the random effects do
about it, how each gene’s fit is converged, and how a Wald statistic
becomes a row of results(). It is the reference for the
other vignettes. The quickstart (vignette("spiDE")) shows
the calls; the calibration vignette
(vignette("spiDE-calibration")) holds every benchmark
number quoted in this package; the two-stage estimator, a different
route to the same question, has its own vignette
(vignette("spiDE-twostage")).
One idea organises the whole design and is used throughout: the Frisch–Waugh–Lovell theorem. Every nuisance block in the design removes one component from the tested slope, and the model is best read as a sequence of such removals. It is stated in full in the section What each block does to the tested slope.
library(spiDE)
library(SpatialExperiment)
library(ggplot2)
source(system.file("spide_palette.R", package = "spiDE"))The question, and the quantity estimated
Within an index cell type, does a gene’s response to an experimental condition change with the local density of a niche cell type? That is one hypothesis per (gene, index type, niche type) triplet, and the quantity behind it is a slope: how much the condition effect on log expression changes per unit of niche density, among cells of the index type.
Three quantities have to be kept apart, and the design gives each its own column so that the tested slope carries only the third:
-
A flat cell-type response. The gene is up in every
index cell of every Responder, regardless of neighbourhood. This is a
between-patient contrast on cells of one type;
results(type = "celltype")reports it. -
A condition-independent niche slope. Expression in
the index type tracks the niche’s density in every patient, Responder or
not. This is a nuisance in the conditioned analysis and the tested
quantity in niche-only mode (
condition = NULL). -
The niche-dependent response. The
difference in that slope between conditions — the three-way
cell type : condition : nicheterm.
Two more things are deliberately not part of the third
quantity, and the model’s random effects exist to keep them out.
Patients differ in baseline expression of every gene, and patients
differ in composition: a patient whose index cells sit, on average, in a
denser niche of some type may also express the gene differently in that
type for reasons that have nothing to do with the neighbourhood of any
single cell. Both are patient-level associations with one observation
per patient. The second is a legitimate question of its own and
compositionTest() asks it at the patient level; it is not
neighbourhood-dependent expression and the niche slope must not carry
it.
The effective niche
Every quantity in this section is computed within one sample at a time, entirely independently of every other sample. Let be the cells of sample . For each cell the local intensity of cell type is a kernel-smoothed count of that type’s cells around , with a Gaussian kernel of bandwidth :
where
is the planar distance between two cells of the same sample and
is Diggle’s edge correction for that sample’s own spatial window.
buildNiches() never forms a distance matrix across samples:
it splits the SpatialExperiment per sample and builds a
fresh spatstat point pattern, window and density surface
for each subset, so two cells from different samples that happen to
share coordinates never interact. The expected log-count of type
in a disc of radius
is then
and stacking over types gives the effective niche of
the cell,
,
a vector with one entry per cell type. Per-sample results are row-bound
into one cells × types matrix per bandwidth, stored as
reducedDim(spe, "Niche<sigma>"). Several bandwidths
can be built at once; inference combines them later.
The model
Notation
is the count of gene in cell ; its cell type and its sample. The condition is a property of the sample — constant across all cells of a sample, which drives everything below. indexes the (sample, cell type) group a cell belongs to. is the number of samples, the number of cells, the number of genes.
The negative binomial GLM, stated once
For one bandwidth , gene has a negative binomial distribution with dispersion , , and log-mean
with
| term | meaning | design tag |
|---|---|---|
cell-level covariates (covariates =), e.g. log library
size |
Other |
|
| baseline expression of the gene in cell type | CellType |
|
| condition-independent slope of expression in type on the density of type | Niche |
|
| the flat response of type : quantity 1 above | ResponseCellType |
|
| the niche-dependent response: quantity 3, the tested slope | ResponseNiche |
|
| random intercept per sample |
Random (SampleInt) |
|
random intercept per (sample, cell type) —
re.celltype = TRUE, the default |
Random (SampleCellTypeInt) |
|
random niche slopes per sample — random = "slope"
only |
Random (SampleSlope) |
The variance components
carry no gene index: one value of each is shared by every gene,
estimated from all of them. The realised deviations
,
vary by gene. random = "intercept" (the default) adds
and, with re.celltype = TRUE,
;
random = "slope" adds
as well; random = "none" adds nothing and should not be
used for inference (see Why cells are not replicates).
We test, per triplet,
Cell-means coding
nicheDesign() assembles the fixed part as
~ 0 + <covariates> + CellType + CellType:condition
+ CellType:(niches) + CellType:condition:(niches) + niches
There is no bare condition main effect: the response
contrast is carried by one CellType:condition column per
cell type, each readable directly as that type’s flat response, and the
three-way columns are one per (index type, niche type) with the index
type’s own density dropped (a type’s density of itself is not a niche).
The random-effect columns are ordinary design columns — an indicator per
sample, an indicator per (sample, cell type), and, for slopes, sample
indicators times the CellType:niche bases — tagged
Random and identified by the ridge penalty described under
Random effects as ridge. Every column carries a tag
(fits(res)[[1]]$covtype), and the tested tag is
ResponseNiche (or Niche in niche-only
mode).
What each block does to the tested slope: Frisch–Waugh–Lovell
Consider a weighted least-squares fit of on two blocks of columns, , with weights . The Frisch–Waugh–Lovell theorem says the coefficient on equals the coefficient from a regression of on , where
removes from any vector its weighted projection on . In words: the coefficient on a column is estimated only from the part of that column, and of the response, that the other columns cannot explain. Every nuisance block in the design above is a choice of , and the theorem says exactly what it removes from .
The (sample × cell type) indicators. When
is a set of group indicators,
subtracts each group’s weighted mean:
.
With the nested block in the design, the tested slope is therefore
estimated from the within-(sample, cell type) deviations of
niche density and of expression, and the between-group part — patients
whose index cells sit in denser niches also expressing the gene
differently in that type — is removed exactly and assigned to the
intercepts, where it belongs. Without the block, only a per-sample
intercept is present, shared across cell types; nothing removes the
between-sample covariance within an index type, so it loads
onto
and is reported with a standard error computed from
cells rather than
patients. A permutation of the niche rows within (sample, cell type)
cannot detect this, because it preserves every group’s mean niche
density and so preserves the confound. That is what the nested block was
added for, and it is why random = "slope" is not a
substitute: per-sample slopes on the niche bases leave the group means
untouched.
The CellType:condition column. Now let
be the flat response of the index type.
removes the condition’s mean effect in that type from both the response
and the niche column, so
is the gradient of the response along the niche and not its
level. Without the column the flat response has nowhere to go but the
uncentred slope, and the slope’s standard error is that of a regression
through the origin — smaller, because it is bought by assuming there is
no level to estimate. A design without this column reports more niche
calls; it is estimating a broader quantity under the niche label, not
testing the same one better. The measurement is in the calibration
vignette.
The IRLS fit. The theorem is about least squares, and the negative binomial fit is not least squares — but each step of the iteratively reweighted least-squares (and, later, Newton) iteration is a weighted least-squares problem in the working response, so the theorem holds step by step and hence at the converged fit, with the working weights .
One caveat, because it is honest. The nested block is not free: it is ridge-penalised (next section), and a penalised block does not centre completely. With penalty on the group indicators, group ’s mean is removed by the factor , where is the group’s total working weight — exact centring only as (large estimated between-group variance) or as groups become large. On the real cohort that this was built for, the shrunken centring reproduced the exact-centring null within measurement; the numbers are in the calibration vignette.
Why cells are not replicates
The design has one row per cell. That is fine for the within-sample
coefficients
(,
),
but
and
are contrasts on the patient-level label
:
there are
independent patients, yet a Wald information matrix that sums over
cells makes their standard errors shrink like
instead of
.
With
the statistics are badly inflated. This is what
random = "none" does, and the reason it is no longer the
default.
The demonstration below simulates a null: every cell of a patient shares a per-patient shift, and there is no response effect at all, so every response test should be null.
sim_clustered <- function(n_samples = 10, n_per = 90, n_genes = 300,
field = 500, sd_patient = 0.7, seed = 1) {
set.seed(seed)
cts <- c("A", "B", "C"); gn <- sprintf("G%d", seq_len(n_genes))
sids <- sprintf("S%d", seq_len(n_samples))
cond <- rep(c("Responder", "Non-responder"), length.out = n_samples)
names(cond) <- sids
cells <- lapply(sids, function(sid) {
x <- runif(n_per, 0, field); y <- runif(n_per, 0, field)
ct <- ifelse(x > 0.6 * field & runif(n_per) < 0.7, "B",
sample(c("A", "C"), n_per, replace = TRUE))
data.frame(sample_id = sid, condition = cond[[sid]],
x = x, y = y, cell_type = ct)
})
cd <- do.call(rbind, cells); n <- nrow(cd)
cd$cell_id <- sprintf("c%d", seq_len(n))
base <- matrix(rnorm(n_genes * 3, 1.5, 0.6), n_genes, dimnames = list(gn, cts))
lmu <- base[, cd$cell_type, drop = FALSE]
# per-(gene, patient) random intercept: real clustering, NO response effect
u <- matrix(rnorm(n_genes * n_samples, 0, sd_patient), n_genes,
dimnames = list(gn, sids))
lmu <- lmu + u[, cd$sample_id, drop = FALSE]
counts <- matrix(rnbinom(length(lmu), mu = as.vector(exp(lmu)), size = 5),
n_genes, dimnames = list(gn, cd$cell_id))
list(spe = SpatialExperiment(assays = list(counts = counts),
colData = S4Vectors::DataFrame(cd),
spatialCoords = as.matrix(cd[, c("x", "y")])),
u = u)
}
sim_null <- sim_clustered()
spe <- buildNiches(sim_null$spe, sigma = 30)300 genes rather than a handful, because is a single value shared across every gene: estimating a variance from a few genes is noisy in the same way as estimating it from a few observations.
A helper that fits one random mode and returns the response p-values and the fitted object:
fit_mode <- function(random) {
# df.method = "between" (the scalar S - 2) is pinned here: this section is
# about the correction itself, and the between reference keeps the demo
# cheap. The default "satterthwaite" per-coefficient df has its own section.
f <- fitSpiDE(spe, "condition", sigma = 30, random = random,
df.method = "between", verbose = FALSE)
res <- testSpiDE(f, spe = spe) # populates the Wald inference slots
ff <- fits(res)[[1]]
# the response contrast is carried by the CellType:condition columns; match
# on column NAMES -- `covtype` spans every design column, `t_stat` only the
# tested ones
ct <- as.character(ff$covtype)
rc <- intersect(colnames(ff$t_stat),
ff$coefmap$covariate[ct == "ResponseCellType"])
tt <- ff$t_stat[, rc, drop = FALSE]
df <- if (is.null(ff$df)) Inf else if (length(ff$df) == 1L) ff$df else ff$df[rc]
dfm <- matrix(rep(df, each = nrow(tt)), nrow = nrow(tt))
list(fit = ff, p = 2 * pt(-abs(tt), dfm))
}
fixed <- fit_mode("none")
mean(fixed$p < 0.05) # should be ~0.05 under the null
#> [1] 0.6233333The fixed-effects fit rejects a large fraction of null tests at .
Why not add sample as a fixed effect?
The instinct from fixed-effects modelling is to add
sample_id as a factor, the way one adds batch.
It cannot work here, and the reason motivates the random effect:
set.seed(42)
S <- 12; n_per <- 12
toy_sample <- factor(rep(seq_len(S), each = n_per))
toy_resp <- rep(rbinom(S, 1, 0.5), each = n_per) # constant within a sample
true_tau2 <- 0.49
u_true <- rnorm(S, 0, sqrt(true_tau2))
eta_true <- 0.3 * toy_resp + u_true[as.integer(toy_sample)]
toy <- data.frame(y = rpois(length(eta_true), exp(eta_true)),
resp = toy_resp, sample_id = toy_sample)
coef(glm(y ~ sample_id + resp, family = poisson, data = toy))["resp"]
#> resp
#> NAresp is NA because every cell of a sample
shares the response label: resp is an exact linear
combination of the sample dummies, and a design with a free column per
sample has nothing left to estimate the contrast from.
spiDE enforces the mirror image —
checkSample() errors if a sample-constant covariate is
passed with random != "none", since the random intercept
makes it exactly as inestimable. A free per-sample parameter would also
fit each sample perfectly and say nothing about how much patients differ
in general, which is precisely the quantity the corrected test
needs. A random effect sits between the two extremes: each patient gets
its own deviation
,
but the deviations are draws from a shared
,
with
estimated from the data and each
shrunk toward zero by an amount that reflects how reliable that
patient’s own data is.
Random effects as ridge
The identity
One IRLS step for a GLM is a weighted least-squares regression of the working response on the design, with weights . Write the expanded design as with the random-effect columns. On this working scale, a Gaussian random effect on a block of columns is exactly an (ridge) penalty on those columns — an identity, not an approximation. The penalised objective
has the closed-form solution with
Read it either way: a ridge penalty is the log-density of a Gaussian
prior
,
so the penalised fit is the posterior mode under the random-effect
distribution; or, the penalty pulls every
toward zero, which is what borrowing strength across patients means
numerically. The penalised estimates of the random columns are the
model’s BLUPs. This is the penalised quasi-likelihood
construction (Breslow & Clayton, 1993; Schall, 1991) that
lme4, mgcv and MASS::glmmPQL()
build on, and it needs no change to the fitting engine:
SpaNorm::fitNB accepts a per-column penalty
lambda.a, set to
on fixed columns and
on random ones.
Estimating the variance components
The one thing the identity does not supply is
.
.fitNBmixed() gets it with two nested loops. The
inner loop is fitNB at a fixed penalty:
the penalised estimate of every gene at once, BLUPs included. The
outer loop re-estimates each component by a
method-of-moments update and refits:
where
are gene
’s
BLUPs in block
,
the block’s number of columns,
a gene-averaged working-weight matrix (one representative information
matrix, since
is shared),
the block’s penalised covariance, and
its effective degrees of freedom — less than
because shrunken BLUPs use up less than a parameter each. That
denominator is what makes
an estimate of the true between-patient variance rather than
the variance of the shrunken BLUPs. The loop stops when
moves by less than re.tol, or after re.maxit
iterations (default 2: for the intercept model one iteration is
indistinguishable from ten, and a small cap avoids a two-cycle the
estimate can fall into; pass re.maxit = 10 for
random = "slope", whose slope component converges slowly).
re.prop < 1 runs the inner fits on a stratified cell
subsample; the coefficients inference uses always come from a final fit
on all cells, but the shared
is biased downward by subsampling, so the default is
re.prop = 1.
Watching it work
The same construction, on one gene of the toy data, with an
independent implementation — MASS::glmmPQL():
library(MASS)
fit_pql <- glmmPQL(y ~ resp, random = ~ 1 | sample_id, family = poisson,
data = toy, verbose = FALSE)
summary(fit_pql)$tTable
#> Value Std.Error DF t-value p-value
#> (Intercept) 0.33464279 0.6069414 132 0.55135935 0.5823195
#> resp 0.03611324 0.7018176 10 0.05145673 0.9599749resp is now estimable, and its reference DF
is
,
the between-patient degrees of freedom. glmmPQL() exposes
its outer-iteration cap as niter, so refitting with
niter = 1, 2, … replays the trajectory
.fitNBmixed()’s loop takes:
tau2_trace <- vapply(1:8, function(k) {
f <- suppressWarnings(glmmPQL(y ~ resp, random = ~ 1 | sample_id,
family = poisson, data = toy, niter = k,
verbose = FALSE))
as.numeric(nlme::VarCorr(f)[1, 1])
}, numeric(1))
ggplot(data.frame(iteration = seq_along(tau2_trace), tau2 = tau2_trace),
aes(iteration, tau2)) +
geom_hline(yintercept = true_tau2, linetype = 2, colour = spide_pal$ref) +
geom_line(colour = spide_pal$accent) + geom_point(colour = spide_pal$accent) +
labs(x = "outer iteration", y = expression(hat(tau)^2),
title = "One gene's variance-component estimate")
The outer loop converging on the patient-level variance component for one gene. The dashed line is the simulated value.
It converges in a handful of iterations and does not land on the true
value: with
patients, one gene’s
has real sampling noise. That is why spiDE pools the
numerator across every gene; on the 300-gene null from earlier the
pooled estimate sits close to the simulated
:
mixed <- fit_mode("intercept")
mixed$fit$tau2[["SampleInt"]]
#> [1] 4.644419Shrinkage is the other signature of a random effect.
Compare each sample’s BLUP with the fully unpooled per-sample estimate
from the saturated model y ~ sample_id - 1:
naive_u <- coef(glm(y ~ sample_id - 1, family = poisson, data = toy))
naive_u <- naive_u - mean(naive_u)
blup_u <- nlme::ranef(fit_pql)[[1]]
ord <- order(naive_u)
shrink_df <- data.frame(sample = factor(seq_len(S)[ord], levels = seq_len(S)[ord]),
naive = naive_u[ord], blup = blup_u[ord])
ggplot(shrink_df) +
geom_segment(aes(x = sample, xend = sample, y = naive, yend = blup),
arrow = arrow(length = unit(0.12, "cm")), colour = "grey55") +
geom_hline(yintercept = 0, linetype = 2, colour = spide_pal$ref) +
geom_point(aes(sample, naive, colour = "unpooled"), size = 2.2) +
geom_point(aes(sample, blup, colour = "BLUP"), size = 2.2) +
scale_colour_manual(
values = c("unpooled" = unname(spide_pal$method[["fixed"]]),
"BLUP" = unname(spide_pal$method[["intercept"]])), name = NULL) +
labs(x = "sample (sorted by unpooled deviation)",
y = "deviation from population mean",
title = sprintf("var(unpooled) = %.2f, var(BLUP) = %.2f",
var(naive_u), var(blup_u))) +
theme(axis.text.x = element_blank(), axis.ticks.x = element_blank())
Each sample’s unpooled deviation and its BLUP, connected by an arrow. Every arrow points toward zero, and the least reliable estimate is pulled in the most.
Shrinkage is strongest where a sample’s own data is least reliable relative to — the toy uses few cells and a low mean so that it is visible. A well-measured gene gets little help from the random effect and only a modest standard-error correction; a sparse gene, whose per-sample counts are noisy, is where the correction does most of its work.
Converging each gene
Why the shared fit is not at every gene’s optimum
SpaNorm::fitNB fits every gene in one IRLS loop. To make
a whole-transcriptome fit affordable it shares one gene-averaged
cell-weight vector across genes, judges step-halving and convergence on
the aggregate log-likelihood, and clamps coefficients across
genes. For most genes that is indistinguishable from the gene’s own
optimum. For a bright, cell-type-restricted gene, whose working weights
look nothing like the average, the aggregate criterion is met before
that gene’s own score reaches zero, and the dispersion estimated at that
point is off. converge = TRUE (the default) finishes the
job per gene, and polishSpiDE() applies the same stage to
an existing fit — made with converge = FALSE, or saved by
an older version — without refitting. The two routes share one
implementation.
Damped Newton on the gene’s own likelihood
For gene , with the design and penalty fixed, the objective is its own penalised negative binomial log-likelihood,
whose score and (expected) information are
Each Newton step solves
and halves the step until
increases; the iteration stops when the gain falls below
tol relative to
.
A gene whose starting fit is degenerate — a fitted mean below
somewhere — restarts from the per-cell-type log means, because Newton
from a degenerate point diverges. Each polished gene depends
only on its own counts, so the stage is blocked over genes and
parallelised (block.size, BPPARAM), exactly as
inference is.
Profile maximum likelihood for the dispersion
fitNB estimates
with edgeR’s empirical-Bayes moderation, which shrinks each
gene’s dispersion toward a cross-gene trend. The polish replaces it with
the gene’s own profile maximum-likelihood value.
“Profile” means: hold the coefficients at their converged value
,
so that
is fixed, and view the log-likelihood as a function of the dispersion
alone,
then maximise it over
on a bounded interval (psi.range, by default
)
with a one-dimensional search (stats::optimize). The
coefficients are then re-polished at the new
,
and the pair of steps is repeated once, so the returned
is a joint optimum to the stated tolerance.
Three consequences follow from giving up moderation.
- A gene that carries no information about overdispersion — all zero,
or effectively Poisson — has a profile likelihood that is monotone in
,
so the search returns a bound, not an estimate. Such a gene
keeps
fitNB’s moderated value and is flaggedpsi_boundin@polish. - An unmoderated is no longer a safe scale for the standard error, so inference uses the Pearson working dispersion (next section) on every path.
- With few samples the loss of shrinkage is measurable as a mild anti-conservatism that fades as samples accrue (calibration vignette). A moderated dispersion evaluated at the converged mean is the indicated refinement.
The polish also converges on the unclamped
likelihood: fitNB’s winsorisation is a robustness device
for a fit that shares one weight vector across genes; a per-gene
converged fit does not need it, and inference evaluates the same
unclamped mean the polish converged on. With
converge = TRUE the winsor argument therefore
only sets the Newton starting point. The negative binomial likelihood is
undefined off the integers, so the stage requires integer
counts and refuses a back-transformed assay rather than
silently collapsing every dispersion to a bound.
Absorbing the nested block: the Schur complement
The nested block adds one column per non-empty (sample, cell type) group — several hundred on a cohort — and a naive Newton step would factor an information matrix of that size for every gene at every iteration. It does not have to. Partition the design into the dense columns and the group indicators , so that the information is
with the working weights. Because every cell belongs to exactly one group, is diagonal — entry is the total working weight of group — and so is once the penalty is added. The Newton step for the dense columns is then the system
and the group steps follow by back-substitution, . is the Schur complement of . Forming it costs one dense gram plus group sums of the weighted columns, whatever the number of groups, which is what makes the per-gene polish affordable on a cohort.
The Schur complement is the Frisch–Waugh–Lovell theorem in matrix
form:
is precisely the information of the dense columns after the group
indicators have been partialled out — the centring described
earlier, done by algebra rather than by subtracting means.
.newtonSolver() verifies the partition (each row of
sums to one) before relying on it, and .blockedInference()
uses the same absorption to form each gene’s covariance.
What the stage reports
@polish is a per-gene table: iterations,
psi_fitnb (the dispersion before polishing),
restarted, capped, singular,
psi_bound and polished.
polished = FALSE marks a gene the Newton could not run on
at all, which keeps the multi-gene fit’s estimate rather than
substituting a worse one. Both flags are expected in small numbers; a
large count is worth investigating before trusting that bandwidth. On
bright genes the converged standard errors are larger than the
unconverged ones — the unconverged dispersion, estimated off the
optimum, was hiding inflation behind deflation — and the larger number
is the honest one.
Inference
Covariance and dispersion
With the random effects and the converged fit in place, the Wald statistic for a tested coefficient uses three things.
- The full penalised covariance, , with the tested block extracted. Because absorbs the within-patient correlation, this block is on a between-patient scale — larger than the fixed-effects standard error, which is the point.
-
The Pearson working dispersion,
the same quasi-likelihood device
summary(glm(..., family = quasipoisson))reports: trust the variance function, rescale it by the residual scatter this fit shows. - A reference distribution with a per-coefficient degrees of freedom, next.
Every per-gene matrix inversion is guarded: a gene whose information
matrix is singular drops out as NA rather than aborting the
run.
Reference degrees of freedom
The flat response
is a pure between-patient contrast, so its reference df is close to
,
the bookkeeping of a two-sample
-test
on patients. The niche slopes
also draw on within-patient variation in the niche covariate,
so they carry a larger effective sample size, and one
cannot be right for both. df.method = "satterthwaite" (the
default) gives each tested coefficient its own df,
with the denominator propagating the uncertainty of the variance
parameters
through the
standard error, from the reduced-form REML information of the working
model — a small matrix built from the
penalised inverse, never an
one. The ratio is invariant to
,
so one df vector per bandwidth serves every gene.
df.method = "between" uses the scalar
for every coefficient and is severely conservative for the slopes when
patients are few.
fs <- fitSpiDE(spe, "condition", sigma = 30, random = "intercept",
df.method = "satterthwaite", verbose = FALSE)
ff <- fits(fs)[[1]]
ct <- as.character(ff$covtype)
resp <- ff$coefmap$covariate[ct == "ResponseCellType"]
niche <- ff$coefmap$covariate[ct == "ResponseNiche"]
c(ResponseCellType.median = round(median(ff$df[resp]), 1),
ResponseNiche.median = round(median(ff$df[niche]), 1))
#> ResponseCellType.median ResponseNiche.median
#> 11.2 857.0The flat-response df sits near
and the slope df is much larger. @df is therefore a
named vector aligned to the columns of
@t_stat; index the column you are testing rather than
passing the vector to pt().
The null calibration of the corrected fit, against the fixed-effects one from earlier:
qq_points <- function(p, mode) {
data.frame(mode = mode, expected = sort(-log10(ppoints(length(p)))),
observed = sort(-log10(p)))
}
qq_df <- rbind(qq_points(fixed$p, "fixed"), qq_points(mixed$p, "mixed"))
ggplot(qq_df, aes(expected, observed, colour = mode)) +
geom_abline(slope = 1, intercept = 0, linetype = 2, colour = spide_pal$ref) +
geom_point(size = 1.5) +
scale_colour_manual(values = unname(spide_pal$method[c("fixed", "intercept")]),
name = NULL) +
labs(x = expression(-log[10]("expected p")), y = expression(-log[10]("observed p")))
Null response-effect p-values: the fixed-effects fit against the mixed fit.
From coefficients to results
A gene has one tested slope per (index type, niche type), and a
gene’s niche columns are correlated because niche densities are
spatially smooth. Three steps turn the per-coefficient statistics into
the results() table.
Within a gene, across niches. The two-sided p-values
of a gene’s slopes are combined by the Cauchy
combination (combine = "cauchy", the default):
,
whose null distribution is Cauchy whatever the correlation between the
,
so no correlation matrix has to be estimated. It is computed for the
gene as a whole and per index cell type. combine = "brown"
is the alternative: Brown’s extension of Fisher’s method, which consumes
the coefficient correlation matrix and combines one-sided
p-values, separately for the up and down directions. The sidedness
differs by necessity — the tangent transform diverges as a one-sided
,
so under Cauchy a gene up in one niche and down in another would cancel,
whereas Brown’s
is bounded and safe one-sided. Both populate the same slots, so
everything downstream is combiner-agnostic.
Across bandwidths. The gene-level and per-index combined p-values from each bandwidth are combined by a second Cauchy combination, weighted by the gene’s relative log-likelihood across bandwidths, with small weights thresholded, so the bandwidth that fits a gene best carries the most weight.
The hierarchical FDR. A three-level nested Benjamini–Hochberg cascade: gene level; then index cell type within gene; then (index, niche) within those — the outer levels gated at half the nominal rate per direction and merged, the innermost at the nominal rate. What survives is a row keyed by (gene, index type, niche type) with the best bandwidth, its direction and its three FDR values.
Niche-only mode
With condition = NULL the design drops the condition
terms and the bare niche main effects,
~ 0 + <covariates> + CellType + CellType:(niches)
and the CellType:niche slopes — nuisance in the
conditioned model — become the tested quantity, cell-means coded. The
main effects must go: a niche’s main effect equals the sum of its
per-type interactions, so keeping it would alias one interaction per
niche away and leave that cell type untestable. Everything downstream is
shared. The random effects apply as before; note that the tested slope
is now a within-sample contrast on a spatially autocorrelated covariate,
which spiDE does not model, so this mode is mildly anti-conservative and
borderline calls deserve corresponding caution.
Reading a fit
A fit with random != "none" carries these slots on each
SpiDEFit (fits(res)[[1]], or
$):
| slot | what it holds | how to read it |
|---|---|---|
@tau2 |
the variance components, named SampleInt,
SampleCellTypeInt (default re.celltype = TRUE)
and SampleSlope (random = "slope") |
on the log-mean scale; index by name, never by position |
@df |
the reference df: a named per-coefficient vector
(satterthwaite) or a scalar
(between) |
index the column you test; small values mean few patients and a wide-tailed , which is expected |
@penalty |
the per-column ridge penalty used, on fixed columns and on random ones | a very small penalty on a block means that source of between-patient variation converged large |
@polish |
the per-gene convergence diagnostics | see What the stage reports |
f <- fits(fs)[[1]]
f$tau2
#> SampleInt SampleCellTypeInt
#> 4.638875 1.322273
colSums(f$polish[, c("restarted", "capped", "singular", "psi_bound")])
#> restarted capped singular psi_bound
#> 0 0 0 0Three caveats govern use.
-
Sample-level covariates (Age, Sex, Stage) are
collinear with the per-sample random intercept and are rejected under
random != "none"; the intercept subsumes them. Adjust for cell-level covariates only — and do adjust for per-cell library size, which the cell-type intercepts cannot carry. - Few samples. The variance components and the reference df are only as good as the between-sample information; a warning is issued when samples are very few, and the trade-offs by sample size are in the calibration vignette.
-
The mixed fit is not “fixed with extras”. Even when
it does not reproduce
random = "none": it also replaces the sub-design covariance and the NB dispersion scaling with the full penalised covariance and the Pearson dispersion, so its standard errors are the correctly scaled ones and"none"’s apparent extra power is partly spurious.
The patient-level question
What the nested intercept removes from the niche slope is a real
association in the data. “Do patients whose tumour compartment is
fibroblast-rich express this gene differently in tumour cells?”
is a legitimate question with one observation per patient, and
compositionTest() asks it there: per (sample, index type),
a pseudobulk log-CPM profile of the index cells and the mean niche
density around them; then, per (index, niche) pair, a limma
moderated
across samples for the pooled association ("niche") and for
its difference between conditions ("condition:niche", the
patient-level counterpart of
),
with patient-level covariates allowed — exactly the ones
fitSpiDE() rejects. On a cohort it typically reports that
neighbour types’ own markers rise in the index cells’ pseudobulk with
the neighbours’ density, uniformly across conditions: composition,
plausibly with a segmentation-spillover component, and the reason the
niche test keeps it out. Report the two results side by side, and never
one as the other.
Choosing the estimator
fitSpiDE(random = "intercept") with the defaults is the
estimator for discovery: it fits all cells jointly, so thin cell types
borrow strength, and its hierarchical FDR is built for the full
hypothesis space. random = "none" is kept for reproducing
older results only. twoStageSpiDE() estimates each
patient’s niche slopes first and contrasts them across patients,
removing pseudo-replication by construction; it is the estimator for a
pre-specified hypothesis on well-populated index types, and it is
documented in vignette("spiDE-twostage"). The measured
operating characteristics of every choice named in this vignette are
collected in vignette("spiDE-calibration").
Session info
sessionInfo()
#> R version 4.6.1 (2026-06-24)
#> Platform: x86_64-pc-linux-gnu
#> Running under: Ubuntu 24.04.4 LTS
#>
#> Matrix products: default
#> BLAS: /usr/lib/x86_64-linux-gnu/openblas-pthread/libblas.so.3
#> LAPACK: /usr/lib/x86_64-linux-gnu/openblas-pthread/libopenblasp-r0.3.26.so; LAPACK version 3.12.0
#>
#> locale:
#> [1] LC_CTYPE=C.UTF-8 LC_NUMERIC=C LC_TIME=C.UTF-8
#> [4] LC_COLLATE=C.UTF-8 LC_MONETARY=C.UTF-8 LC_MESSAGES=C.UTF-8
#> [7] LC_PAPER=C.UTF-8 LC_NAME=C LC_ADDRESS=C
#> [10] LC_TELEPHONE=C LC_MEASUREMENT=C.UTF-8 LC_IDENTIFICATION=C
#>
#> time zone: UTC
#> tzcode source: system (glibc)
#>
#> attached base packages:
#> [1] stats4 stats graphics grDevices utils datasets methods
#> [8] base
#>
#> other attached packages:
#> [1] MASS_7.3-65 ggplot2_4.0.3
#> [3] SpatialExperiment_1.22.0 SingleCellExperiment_1.34.0
#> [5] SummarizedExperiment_1.42.0 Biobase_2.72.0
#> [7] GenomicRanges_1.64.0 Seqinfo_1.2.0
#> [9] IRanges_2.46.0 S4Vectors_0.50.2
#> [11] BiocGenerics_0.58.1 generics_0.1.4
#> [13] MatrixGenerics_1.24.0 matrixStats_1.5.0
#> [15] spiDE_0.99.17 BiocStyle_2.40.0
#>
#> loaded via a namespace (and not attached):
#> [1] deldir_2.0-4 rlang_1.3.0 magrittr_2.0.5
#> [4] otel_0.2.0 compiler_4.6.1 spatstat.geom_3.8-2
#> [7] systemfonts_1.3.2 callr_3.8.0 vctrs_0.7.3
#> [10] pkgconfig_2.0.3 fastmap_1.2.0 magick_2.9.1
#> [13] XVector_0.52.0 labeling_0.4.3 rmarkdown_2.32
#> [16] ps_1.9.3 ragg_1.5.2 torch_0.17.0
#> [19] bit_4.6.0 xfun_0.60 cachem_1.1.0
#> [22] beachmat_2.28.0 jsonlite_2.0.0 goftest_1.2-3
#> [25] DelayedArray_0.38.2 spatstat.utils_3.2-4 BiocParallel_1.46.0
#> [28] irlba_2.3.7 parallel_4.6.1 R6_2.6.1
#> [31] bslib_0.12.0 RColorBrewer_1.1-3 spatstat.data_3.1-9
#> [34] limma_3.68.5 spatstat.univar_3.2-0 jquerylib_0.1.4
#> [37] Rcpp_1.1.2 bookdown_0.48 knitr_1.52
#> [40] tensor_1.5.1 Matrix_1.7-5 splines_4.6.1
#> [43] tidyselect_1.2.1 abind_1.4-8 yaml_2.3.12
#> [46] codetools_0.2-20 spatstat.random_3.5-1 spatstat.explore_3.8-2
#> [49] processx_3.9.0 lattice_0.22-9 tibble_3.3.1
#> [52] withr_3.0.3 S7_0.2.2 evaluate_1.0.5
#> [55] desc_1.4.3 SpaNorm_1.7.9 polyclip_1.10-7
#> [58] pillar_1.11.1 BiocManager_1.30.27 scales_1.4.0
#> [61] coro_1.1.0 glue_1.8.1 tools_4.6.1
#> [64] ScaledMatrix_1.20.0 locfit_1.5-9.12 fs_2.1.0
#> [67] grid_4.6.1 edgeR_4.10.4 nlme_3.1-169
#> [70] BiocSingular_1.28.0 cli_3.6.6 rsvd_1.0.5
#> [73] spatstat.sparse_3.2-0 textshaping_1.0.5 S4Arrays_1.12.0
#> [76] dplyr_1.2.1 gtable_0.3.6 sass_0.4.10
#> [79] digest_0.6.39 SparseArray_1.12.2 rjson_0.2.23
#> [82] farver_2.1.2 htmltools_0.5.9 pkgdown_2.2.1
#> [85] lifecycle_1.0.5 statmod_1.5.2 bit64_4.8.6