---
title: "PACE package manual"
author: "Elijah Willie"
date: "`r Sys.Date()`"
output:
    BiocStyle::html_document:
        toc: true
        toc_float: true
vignette: >
  %\VignetteIndexEntry{PACE package manual}
  %\VignetteEngine{knitr::rmarkdown}
  %\VignetteEncoding{UTF-8}
---

```{r setup, include = FALSE}
knitr::opts_chunk$set(
  collapse = TRUE, comment = "#>", message = FALSE, warning = FALSE,
  fig.width = 6, fig.height = 4, dpi = 90)
```

```{css scrollable-output, echo = FALSE}
/* Long console output (the shrinkage progress log, sessionInfo) scrolls in
   place rather than pushing the prose off the page. Short blocks are
   unaffected: max-height only engages once the block is taller. */
pre {
  max-height: 340px;
  overflow-y: auto;
}
```

# Installation

```r
if (!requireNamespace("BiocManager", quietly = TRUE))
    install.packages("BiocManager")
BiocManager::install("PACE")
```

# Introduction

`r Biocpkg("PACE")` (Proximity-Associated Changes in Expression) is a framework
for imaging-based spatial transcriptomics (such as Xenium and CosMx) that
quantifies how a cell's gene expression changes with proximity to *specific*
neighbouring cell types. Rather than asking only which genes are spatially
variable, PACE asks a directed question: when a macrophage sits next to tumour
cells, which of its genes go up or down, and by how much?

PACE fits a hierarchical negative binomial mixed model with partial pooling
across cell types, so that each focal-neighbour relationship borrows strength
from the others and noisy pairs are regularised towards the consensus. A per-cell
contamination term absorbs the short-range ambient signal that leaks between
adjacent cells through segmentation error, so the estimated neighbour effect
reflects biology rather than transcript misassignment.

# Overview of the PACE workflow

A PACE analysis has three stages, all driven by the single call `paceFit()`:

1. **Neighbourhood construction.** For every cell, a Gaussian biological kernel
   summarises the abundance of each neighbouring cell type, and a short-range
   exponential technical kernel captures the ambient contamination field.
2. **Model fitting.** A streaming penalised quasi-likelihood fit estimates, for
   every gene and every focal cell type, a partially pooled slope on each
   neighbour type's abundance, jointly with a per-cell contamination loading and
   gene-wise overdispersions. The neighbour slopes are then stabilised by
   multivariate adaptive shrinkage.
3. **Interpretation.** The fit is read out three ways: shrunken neighbour slopes
   with a local false sign rate (`neighbourSlopes()`), a per-gene variance
   decomposition (`varianceDecomposition()`), and per-pair driver-score tables
   ranking the genes that mediate each relationship (`topDrivers()`).

# The breast cancer dataset

The package ships a small worked example: a spatial crop of a human breast cancer
Xenium section (10x Genomics; nucleus segmentation, cell types annotated with
scClassify), centred on a tumour-macrophage interface. It contains `r "7,898"`
cells across eight cell types (B cell, dendritic cell, endothelial, macrophage,
myoepithelial, stromal, T cell, and tumour) and a 278-gene panel, packaged as a
`r Biocpkg("SpatialExperiment")`. This region reproduces the macrophage
reprogramming that PACE identifies on the full section.

```{r load, message = FALSE}
library(PACE)
library(SpatialExperiment)
library(ggplot2)
library(dplyr)
library(tidyr)

spe <- readRDS(system.file("extdata", "bc_xenium_subset.rds", package = "PACE"))
spe
table(spe$cellType)
```

# Fitting the model

`paceFit()` reads the counts, spatial coordinates, and cell-type labels from the
`SpatialExperiment` and runs the whole pipeline. On this crop it takes about half
a minute.

```{r fit}
fit <- paceFit(spe,
               celltype_col  = "cellType",
               contamination = "percell_hc",   # per-cell contamination correction
               dispersion    = "nb1",
               verbose       = FALSE)
fit
```

## The pipeline step by step

`paceFit()` is a convenience wrapper. The same result is produced by calling each
step explicitly, which lets you inspect the fitted model and re-run the
downstream layers (shrinkage, decomposition, drivers) without refitting:

```{r staged}
staged <- paceModel(spe, celltype_col = "cellType", verbose = FALSE) |>
          paceShrink()            |>   # shrink the neighbour slopes
          paceDecompose(spe)      |>   # variance decomposition
          paceDrivers(spe)             # per-pair driver scores

# the same object, by either route
all.equal(varianceDecomposition(staged), varianceDecomposition(fit))
```

The fit-construction primitives expose the neighbourhood and contamination field
directly, for inspection independently of a fit:

```{r primitives}
kern <- buildNeighbourhood(spe, "cellType")   # Gaussian K_bio + technical K_tech
W    <- ambientField(spe, "cellType")         # sparse E^tech contamination field

vapply(kern, function(x) paste(dim(x), collapse = " x "), character(1))
dim(W)
head(anchorGenes(fit, spe), 3)                # per-cell-type contamination anchors
```

# Variance decomposition

`plotDecomposition()` shows, for each focal cell type, the share of expression
variance attributable to cell-type identity, spatial cell state (proximity
effects), and contamination (spillover), pooled over genes. The left panel is the
full stacked bar; the right zooms in on the two small spatial components.

```{r decomp, fig.width = 11, fig.height = 4.5}
plotDecomposition(fit)
```

Beyond the dominant cell-type identity component, B cells, stromal cells and
macrophages carry the largest spatial cell-state contributions, while dendritic
and myoepithelial cells carry none on this crop. The underlying per-gene table
is available with `varianceDecomposition(fit)`.

# Per-cell contamination

The spillover component above is a variance share per gene. `cellContamination()`
gives the per-cell view of the same term: the loading `rho_i` and, more usefully,
`contamFraction`, the share of a cell's expected counts attributed to its local
ambient field. A high fraction marks a cell whose profile is largely explained by
its neighbours rather than by its own type, which is what a segmentation or
transcript-assignment error looks like.

```{r contamination}
cc <- cellContamination(fit)
head(cc, 3)
round(tapply(cc$contamFraction, cc$celltype, median), 3)
```

Read the fraction rather than the loading. The ambient field is cross-cell-type,
so a cell with no differently-typed neighbour inside the technical kernel has no
ambient signal at all; its `rho_i` is unidentified and shrinks to the empirical
Bayes prior mean, giving every such cell the same apparently middling loading.
On this crop that is most of the tumour interior:

```{r contamination-zero}
zero <- cc$contamFraction == 0
c(cells = sum(zero), distinct_rho = length(unique(round(cc$rho[zero], 12))))
round(tapply(zero, cc$celltype, mean), 2)
```

`contamFraction` reports those cells as zero, which is correct; `rho` alone does
not.

# Pairwise spatial interactions

`plotPairHeatmap()` gives the percentage of each focal type's total variance
contributed by spatial interaction with each neighbour, computed as the focal's
spatial share split across neighbours by a normalised Pratt attribution.
**Tumour as a neighbour** acts the most widely across the microenvironment,
reaching four focal types where no other neighbour reaches more than two. The
single strongest pair is B cells next to T cells.

```{r pairs, fig.width = 6.5, fig.height = 5}
plotPairHeatmap(fit)
```

`pairVariance()` returns those same numbers as a long table. The heatmap is
drawn from it, so the two cannot disagree, and the table is the easier thing to
rank or pass on:

```{r pairvariance}
pairVariance(fit) |>
  arrange(desc(val)) |>
  head(5)
```

For a cohort with a condition, `block = "responder"` gives the same attribution
for the responder-by-proximity block rather than the baseline spatial one.

# Macrophage–tumour drivers

`plotDrivers()` ranks the genes mediating a relationship by a driver score
(combining the shrunken slope, its cell-type specificity, and its expression
level), alongside their per-gene single-frame decomposition. For the
macrophage-tumour pair, the top drivers are the tissue-resident marker **MRC1**
(CD206), reduced near tumour, and the lipid-associated marker **APOC1**, elevated
near tumour.

```{r drivers, fig.width = 10, fig.height = 4.5}
plotDrivers(fit, "Macrophage", "Tumour")
```

The shrunken slopes confirm the opposing directions:

```{r flagships}
neighbourSlopes(fit) |>
  filter(focal == "Macrophage", neighbour == "Tumour",
         gene %in% c("MRC1", "APOC1")) |>
  select(gene, estimate_shrunk, lfsr)
```

# Visualising the proximity effect

`plotProximity()` bins each macrophage by its number of tumour neighbours
(within 30 um) and shows each gene's raw counts per bin. Because these genes are
zero-inflated the box median sits at zero in most bins, so the per-bin mean is
overlaid as a point to make the trend explicit. Macrophage MRC1 falls and APOC1
rises with tumour proximity.

```{r gradient, fig.width = 11, fig.height = 4.6}
plotProximity(fit, spe, c("MRC1", "APOC1"), "Macrophage", "Tumour")
```

# Biological interpretation

On this section PACE recovers a coordinated shift in macrophage state at the
tumour interface: the tissue-resident marker MRC1 (CD206) is reduced and the
lipid-associated, immunosuppressive marker APOC1 is elevated in macrophages near
tumour cells. Because PACE separates this proximity effect from the transcript
contamination that would otherwise inflate tumour-marker signal in the
macrophages, the drivers it promotes are genuine macrophage genes rather than
tumour genes bleeding across cell boundaries.

# Keeping results with the data

A `PACEFit` is its own object, because a fit on a real cohort is large and
usually outlives the session that made it. When you would rather the results
travelled with the data, `stashPACE()` copies the readouts into
`metadata(spe)`, leaving the solver state behind:

```{r stash}
spe <- stashPACE(spe, fit)
names(metadata(spe)$PACE)
```

The three reporting layers are there, along with enough provenance to say which
fit produced them. The solver state is not, which is why the stash is a
fraction of the size of the fit:

```{r stash-size}
c(fit = object.size(fit), stashed = object.size(metadata(spe)$PACE)) |>
  format(units = "MB")
```

# Saving a fit

A fit holds the fitted means and the contamination log-offset as full
`cells x genes` matrices. They make the object large: even this small crop is

```{r fit-size}
round(as.numeric(object.size(fit)) / 1e6, 1)   # MB
```

and a whole section runs to several hundred megabytes. They do not have to be
kept. `paceDecompose()` and `paceDrivers()` read only per-cell-type summaries of
the fitted means, which the fit also stores, so a fit saved without the
matrices can still be re-decomposed (from the counts in `spe`) and re-scored. (For an older fit saved
without those summaries, pass `spe` to both, and they are rebuilt exactly from
the fit and the counts.)

```{r fit-strip}
fit_small <- compactFit(fit)
round(as.numeric(object.size(fit_small)) / 1e6, 1)   # MB
```

```{r fit-save}
path <- file.path(tempdir(), "fit.rds")
saveRDS(fit_small, path, compress = "xz")
round(file.size(path) / 1e6, 1)                      # MB on disk
```

Every readout above still works on the stripped fit, and re-decomposing it
reproduces the table it already carries:

```{r fit-strip-check}
redone <- paceDecompose(fit_small, spe)
stored <- varianceDecomposition(fit)
num <- vapply(stored, is.numeric, logical(1))
max(abs(as.matrix(varianceDecomposition(redone)[num]) - as.matrix(stored[num])))
```

# Session info

```{r sessioninfo}
sessionInfo()
```
