Using PACE#

The overview is the short version. This is the reference: what each readout gives you, the figures, and the handful of settings worth knowing about.

The readouts#

pace_model — the model. One slope per (focal type, neighbour type, gene), with a per-cell contamination loading and gene-wise overdispersions. Given an AnnData it reads coordinates from obsm["spatial"], labels and condition from obs, gene names from var_names and counts from X or a layer, and never modifies it.

pace_shrink — which slopes to believe. Raw slopes are mostly noise. Multivariate adaptive shrinkage pools them, one mixture per neighbour type with the focal types as its conditions, and returns every slope with a local false sign rate. slopes.calls() reports how many effects are called and how many of those are expected to have the wrong sign.

pace_decompose — where a gene’s variance comes from. Per focal type and gene, split into cell-type identity, the baseline spatial effect, the condition-by-space interaction, contamination, and dispersion. Useful for the question “is this gene’s variation about where the cell is at all?” single_frame_decomposition gives the observed version, where the magnitudes are measured from the data and only the split within a cell type comes from the model.

pace_drivers — which genes carry a relationship. Per pair, genes ranked by b² · spec² · focal_mean. Squaring the slope makes the score direction-blind, so a gene pushed down ranks alongside one pushed up; specificity and expression level keep a barely-expressed gene off the top on a noisy slope. Filtered at lfsr < 0.05.

pair_variance — which neighbours matter to which cell types. Each focal type’s spatial variance split across its neighbours, so a row sums to that type’s spatial share and the own-type pair is excluded.

plot_* — five figures over the above.

Three more: build_neighbourhood() and ambient_field() return the kernels and the contamination field without fitting, for checking what the model is being given; anchor_genes() lists the genes that mark each type and so anchor its contamination; stash_pace() copies the readouts into adata.uns, and compact_fit() drops the rebuildable bulk from a fit you want to keep.

Figures#

PACE figures

pace.plot_decomposition(frame)                 # full bar + the rescaled zoom
pace.plot_pair_heatmap(pairs, fit.cell_types)
pace.plot_drivers(drivers, "Macrophage", "Tumour", single_frame=frame)
pace.plot_proximity(fit, counts, ["MRC1", "APOC1"], "Macrophage", "Tumour")
pace.plot_shrinkage(slopes)

Two are worth knowing about. plot_decomposition leaves the residual block out, so its bars stop well short of 100%: the comparison is between the parts the model attributes, and a residual bar towering over the rest would tell you nothing. plot_drivers takes single_frame= to add a companion panel showing what each top gene’s variance is made of, which catches a gene that ranks highly because the focal type simply expresses a lot of it.

Every panel returns matplotlib axes, so saving is ordinary matplotlib. For publication, write PDF with Type 42 fonts, which keeps text editable and avoids the Type 3 fonts journals reject:

import matplotlib
matplotlib.rcParams["pdf.fonttype"] = 42
axis, zoom = pace.plot_decomposition(frame)
axis.figure.savefig("decomposition.pdf", bbox_inches="tight")

Things worth setting#

Give it counts. adata.X holds whatever the last preprocessing step left there, and a negative binomial fit of log-normalised values will converge and report slopes that mean nothing. Non-integer values are refused; point layer at the raw counts.

Name the reference arm of a condition. Its first level is what every other arm is contrasted against, so it decides what the baseline slopes mean and which way the interaction points — this is not a relabelling. Leaving condition_levels unset picks one for you and warns.

fit = pace.fit_anndata(adata, "cell_type", condition_key="Responder",
                       condition_levels=["non-responder", "Responder"])

kernel_per_image is False: the neighbour search runs across the whole object even when it has several images, which is what fields of view from one section want. Set it True when the images are genuinely separate samples.

types= fixes the cell type order, and so the row order of everything downstream. The default sorts the observed labels.

ambient_mode="stream" avoids materialising the cells-by-genes contamination field, which runs to several GB at a million cells. It is a memory setting and nothing else — the fit is identical either way.

On a condition fit, pace_shrink returns the responder layer, the condition-by-neighbour interaction, not the baseline proximity effect.

Tests#

pip install -e ".[test]"
python -m pytest tests

From a non-editable install, run them from outside the checkout (cd /tmp && python -m pytest /path/to/pace-python/tests): the repository root holds the source package pace/, which shadows the installed one but has no compiled _core, and the resulting ImportError blames a circular import rather than the real cause.

Building from source#

Two things are not free choices.

Eigen must be 3.4. Later majors vectorise and reduce in a different order, which moves the fitted effects by about 3e-4, so the build refuses them:

bash tools/fetch_eigen.sh && EIGEN_INCLUDE_DIR=$PWD/eigen-3.4.0 pip install -e .

PACE_ALLOW_ANY_EIGEN=1 builds anyway. Installing a released wheel avoids the question entirely.

On macOS, use Apple’s toolchain. A conda or Homebrew clang brings an old ld64 that links a shared object which loads nowhere — the build succeeds and it fails at import with mis-aligned LINKEDIT string pool:

CC=/usr/bin/clang CXX=/usr/bin/clang++ \
  LDSHARED="/usr/bin/clang -bundle -undefined dynamic_lookup" pip install -e .

ld -v should report PROJECT:ld-27037 or newer.