Single-cell transcript quantification with Salmon/Alevin produces per-cell bootstrap replicates that capture inferential uncertainty from fragments mapping to multiple transcripts of the same gene1–3. This uncertainty appears as overdispersion in the estimated counts. If left uncorrected, it inflates variability estimates and distorts downstream analyses including differential expression, clustering, and trajectory inference4.
scAmbi estimates per-transcript overdispersion from
Alevin bootstraps, computes two additional exploratory components
(moment-based and entropy-based), and applies the bootstrap-based
estimate by building a corrected assay (RNA_corr) or by
supplying model offsets. A per-transcript meanInfRV
diagnostic enables distribution-level comparison of inferential
uncertainty across transcriptome assemblies quantified with identical
reads and parameters.
edgeR::catchSalmon()catchSalmon computes per-transcript technical
overdispersion from bootstraps pooled across samples, applies EB
moderation with limma::squeezeVar(), floors the estimate at
1, and analyzes scaled counts. scAmbi follows the same
approach but pools across cells within each sample. The package adds a
moment-based component, an annotation prior, and an integrated
overdispersion estimate for exploration, and supports offset-based
workflows in addition to the corrected assay.
Install from GitHub:
# install.packages("remotes")
remotes::install_github("sbresnahan/scAmbi")
Or from a local source tarball:
install.packages("scAmbi_0.3.2.tar.gz", repos = NULL, type = "source")
scAmbi reads Salmon Alevin’s EDS-format output: the
transcript-level count matrix (quants_mat.gz) plus per-cell
bootstrap replicates (quants_boot_mat.gz, written when
Alevin is run with --numCellBootstraps and
--dumpFeatures). alevin-fry, the
designated successor to Alevin, is not drop-in compatible today:
alevin-fry quant -b (--num-bootstraps)
resolves UMIs to the gene level and writes Matrix Market output with
bootstrap summary statistics (bootstraps_mean.mtx /
bootstraps_var.mtx), and full per-replicate bootstrap
output is not yet supported in its MTX output. The sufficient statistics
scAmbi pools across cells (per-cell bootstrap means and
variances) are exactly what alevin-fry’s --summary-stat
provides, so support may be added in a future release. In the meantime,
fishpond::loadFry() reads alevin-fry output into
R/Bioconductor.
Transcript-level quantifications exhibit increased technical variance due to read-to-transcript ambiguity. Reads can usually be assigned unambiguously to genes, but they are often compatible with multiple transcripts for the same gene, particularly for genes with many isoforms. This assignment uncertainty disrupts the mean-variance relationship normally observed in RNA-seq data and interferes with standard differential expression methods.
For gene (or transcript) \(g\) in cell \(i\): counts \(y_{gi}\), library size \(N_i\), underlying proportion \(\pi_{gi}\), mean \(\mu_{gi}=N_i\pi_{gi}\). Read-to-transcript ambiguity inflates technical variance beyond Poisson:
\[\mathbb{E}(y_{gi}\mid\pi_{gi})=\mu_{gi}, \qquad \mathrm{Var}(y_{gi}\mid\pi_{gi})=\sigma_g^2\,\mu_{gi}, \; \sigma_g^2>1\]
With biological variation across cells, \(\pi_{gi}\) varies and marginal variance becomes
\[\mathrm{Var}(y_{gi}) = \sigma_g^2\,\mu_{gi} + \phi_g\,\mu_{gi}^2,\]
with \(\phi_g\) the NB-like biological dispersion4. The technical overdispersion parameter \(\sigma_g^2\) can be estimated from bootstrap samples and divided out of the transcript counts, yielding scaled counts that follow the standard negative binomial mean-variance relationship and can be analyzed with established differential expression methods.
scAmbi uses a bootstrap-based overdispersion estimate
following Baldoni et al.4 for correction. For exploratory
purposes, it also computes two additional components per transcript and
an integrated estimate by combining the three components on the log
scale.
Let \(u_{tib}\) be the Alevin bootstrap count for transcript \(t\), cell \(i\), bootstrap \(b = 1..B\). For each cell we traverse the sparse bootstrap columns once, accumulating for each transcript:
For any transcript marked as seen in a cell, we set \(k = B\) (all replicates, zeros included), compute:
\[\bar{u}_{ti} = \frac{1}{B} \sum_{b=1}^B u_{tib}, \quad s^2_{ti} = \frac{1}{B - 1} \sum_{b=1}^B \left(u_{tib} - \bar{u}_{ti}\right)^2\]
and accumulate across cells the pooled sums \(\sum_i s^2_{ti}\) and \(\sum_i \bar{u}_{ti}\), together with the per-cell inferential relative variance (InfRV, in the sense of Zhu et al.5):
\[\mathrm{InfRV}_{ti} = \frac{\max\!\big(s^2_{ti} - \bar{u}_{ti},\, 0\big)}{\bar{u}_{ti} + pc},\]
where \(pc\) is a stabilizing pseudocount (default 1). Subtracting \(\bar{u}_{ti}\) removes the Poisson sampling component, so only excess variance attributable to read-to-transcript ambiguity is measured.
We pool the sums across cells, mirroring
edgeR::catchSalmon(). Pooling weights each cell in
proportion to its count; averaging per-cell ratios would weight a
1-count cell the same as a 100-count cell:
\[\mathrm{VMR}_t = \frac{\sum_i s^2_{ti}}{\sum_i \bar{u}_{ti} + pc}.\]
Under the variance model above, \(\mathrm{VMR}_t\) estimates the technical overdispersion \(\sigma^2_t\). A transcript whose quantification is purely Poisson has \(\mathrm{VMR} \approx 1\), and only ambiguity-driven excess variance pushes it above 1.
The pooled VMR is moderated with limma::squeezeVar(),
using per-transcript degrees of freedom \(\mathrm{DF}_t \times (B-1)\) (\(\mathrm{DF}_t\) = number of contributing
cells) and a prior whose degrees of freedom are estimated from the data,
the same strategy as catchSalmon(). If the prior fit fails
(e.g., degenerate input), we fall back to fixed-strength shrinkage
toward the median with prior df \(d_0 =
10\). The final estimate is floored at 1:
\[\mathrm{OD}^{\mathrm{boot}}_t = \max\!\big(\mathrm{VMR}^{\mathrm{moderated}}_t,\, 1\big) = 1 + \text{pooled Poisson-excess VMR}.\]
Transcripts with \(e_t\) below the
expression threshold (min_cells_expr) default to 1.
Notes:
feature_meta. meanInfRV is not used in the
correction (see Comparing transcriptome assemblies with
meanInfRV).The moment-based overdispersion estimate captures gene-specific variability by comparing the observed variance to what would be expected under a Poisson model. Sequencing and amplification introduce technical noise that scales with expression level, while biological processes create additional variance through cell-to-cell differences in transcriptional state, cell cycle progression, and stochastic gene expression. Low-abundance genes may also be captured inconsistently across cells due to dropout effects, inflating their apparent variability.
The raw moment-based estimate uses counts across expressing cells only (non-zeros):
\[\widehat{\mathrm{OD}}^{\mathrm{mom}}_g = \frac{\mathrm{Var}(x_g) + \epsilon}{\mathbb{E}(x_g) + \epsilon}\]
where \(\epsilon\) provides
numerical stabilization. When rel_eps > 0, both epsilon
terms equal rel_eps * mean. When rel_eps = 0
(default), both epsilon terms equal abs_eps (default \(10^{-8}\)). Using only expressing cells
avoids bias from technical zeros while capturing the biological
variability among cells that detectably express the gene.
Confidence-weighted shrinkage toward the null value of 1 accounts for estimation uncertainty with small sample sizes:
\[\mathrm{OD}^{\mathrm{mom}}_g = \max\!\left(1,\;1+\frac{n_g}{n_g+k}\big(\widehat{\mathrm{OD}}^{\mathrm{mom}}_g-1\big)\right)\]
where \(n_g\) is the number of expressing cells and \(k\) is the shrinkage parameter (default 30). Shrinkage is stronger for genes with fewer expressing cells, reflecting lower confidence in variance estimates from small samples.
This approach may reduce some genuine biological signal along with technical noise, particularly for genes undergoing continuous state transitions or exhibiting meaningful transcriptional heterogeneity. The complexity prior (Section 3) provides gene-specific adjustments that account for expected biological variability in structurally complex genes.
The complexity prior is motivated by the observation that genes with multiple actively expressed isoforms exhibit increased expression variability due to cell-to-cell differences in splicing regulation and quantification uncertainty when similar isoforms compete for read assignment. The prior combines isoform count with expression evenness to capture this variability.
For each gene \(g\), let \(k_g\) be the number of expressed isoforms (transcripts with mean expression above threshold) and \(E_g\) be the normalized Shannon entropy of isoform expression:
\[E_g = \frac{H_g}{\log(k_g)} = \frac{-\sum_{i} p_i \log(p_i)}{\log(k_g)}\]
where \(p_i\) is the proportion of total gene expression from isoform \(i\). The complexity prior for each transcript is then:
\[\mathrm{OD}^{\mathrm{prior}}_t = 1 + \alpha \cdot \log\big(1 + \max(k_g - 1, 0) \times \max(E_g, 0)\big)\]
where \(\alpha\) is a scaling parameter (default 0.6). This gives higher prior values to transcripts from genes with more expressed isoforms that have relatively even expression levels. Transcripts from single-isoform genes or unmapped transcripts receive a prior of 1.
With weights \(w_b=0.8\), \(w_m=0.1\), \(w_p=0.1\):
\[\log \mathrm{OD}^{\mathrm{int}}_t = w_b\log\mathrm{OD}^{\mathrm{boot}}_t + w_m\log\mathrm{OD}^{\mathrm{mom}}_t + w_p\log\mathrm{OD}^{\mathrm{prior}}_t.\]
For additional stability we shrink toward the median of well-observed transcripts (e.g., \(e_t \ge 50\)) with weight \(\alpha_t = \min(e_t/100,1)\):
\[\mathrm{OD}^{\mathrm{int,final}}_t = \alpha_t\,\mathrm{OD}^{\mathrm{int}}_t + (1-\alpha_t)\,\mathrm{median}\big\{\mathrm{OD}^{\mathrm{int}}_{e \ge 50}\big\},\]
then clamp to \([1,100]\).
\(\mathrm{OD}^{\mathrm{boot}}_t\) is the default OD estimate for correction. The package exposes two interchangeable ways to use the OD estimates.
RNA_corr)correct_seurat() forms a left-diagonal scaling with
entries \(1/\mathrm{OD}_t\):
\[Z = \mathrm{diag}\left(\frac{1}{\mathrm{OD}}\right)\,Y,\]
and stores it as a second assay RNA_corr alongside the
raw RNA counts.
The od_source argument selects which estimate drives the
correction:
od_source = "bootstrap" (default): \(\mathrm{OD}^{\mathrm{boot}}_t\), mirroring
Baldoni et al. and catchSalmon().od_source = "integrated": \(\mathrm{OD}^{\mathrm{int}}_t\), the
weighted geometric fusion of all three components (exploratory).od_source = "moments" or
od_source = "prior": the individual exploratory
components.The chosen vector is stored as scaling_factor in
feature_meta and can be retrieved for offset-based
workflows via get_od(). Feature-level metadata is stored in
both assays with columns:
OverDisp_integrated, OverDisp_bootstrap,
OverDisp_moments, OverDisp_priormeanInfRV (bootstrap diagnostic; NA for
transcripts below the expression threshold)expressing_cells, scaling_factor (the
vector selected by od_source), inv_scaling (1
/ scaling_factor)When fitting NB-GLMs (e.g., edgeR), you can supply transcript- and sample-specific offsets while leaving the count matrix unmodified.
RNA_corr exists): use
\(\log(\text{corrected}/\text{raw})\)
per transcript and sample, e.g., from the ratio of pseudobulked
corrected to raw counts.feature_meta
can be used. Interpret as vector_as = "od" for
OverDisp_* or scaling_factor, or
vector_as = "ratio" for inv_scaling (since
RNA_corr = RNA * inv_scaling).All of these offsets can be constructed from the
feature_meta columns retrieved with
get_feature() or get_od() and added to the
offset slot of your GLM of choice (e.g.,
edgeR::DGEList(...)$offset).
meanInfRV isolates the mapping-ambiguity-driven
component of inferential variance. Its distribution is a useful
diagnostic for comparing transcriptome assemblies on identical data, in
the spirit of the InfRV diagnostic of Zhu et al.5. The comparison is valid only when
the same reads, quantifier settings (including
--numCellBootstraps), and cell set are used for each
assembly. Under those conditions, differences in inferential uncertainty
are attributable to the reference.
Recommended practice:
meanInfRV (e.g., medians,
upper quantiles, or the fraction of transcripts above a threshold such
as 0.2).--numCellBootstraps 20, per-cell variance estimates are
noisy. Averaging over cells stabilizes meanInfRV, but small
differences (below about 10%) between assemblies should not be
over-interpreted.meanInfRV values are lower and differences
between assemblies are compressed relative to 5’-tag protocols, whose
reads reach further into the transcript body. The worked example below
quantifies both chemistries side by side.A downward-shifted meanInfRV distribution indicates a
reference whose quantifications are less affected by mapping ambiguity
for these data.
The worked example uses two public 10x Genomics PBMC libraries from healthy donors:
pbmc_10k_v3 (Chromium 3’ v3; 11,950 cells):
3’-tagged data, where most cDNA reads fall in terminal exons and mapping
ambiguity is dominated by isoforms sharing 3’ exons.5p_Citrate_CPT (Chromium 5’ v2; 5,355
cells): 5’-tagged data, whose cDNA reads reach further into the
transcript body and across splice junctions.Each library was quantified with Salmon Alevin
(--numCellBootstraps 20, --dumpFeatures;
library type -l ISR for 3’ and -l ISF for 5’)
against two transcriptome references:
Mitochondrial transcripts were excluded from the references before indexing. The TRAILS atlas contains no mitochondrial loci, and the 37 GENCODE MT-gene transcripts (13 mRNAs, 2 rRNAs, 22 tRNAs) were removed from the transcript FASTA, so no mitochondrial rows appear in the quantifications.
Because the same reads, quantifier settings, and bootstrap budget
were used for both references, differences in inferential uncertainty
are attributable to the reference (see Comparing transcriptome
assemblies with meanInfRV). Per-transcript statistics for all four
library x reference combinations ship with the package as the
pbmc_infrv data object.
| Library | Chemistry | Read pairs | Cells | Reference | Mapping rate | Assigned fragments | UMIs after dedup. |
|---|---|---|---|---|---|---|---|
3’ PBMC (pbmc_10k_v3) |
10x 3’ v3 | 638.9M | 11,950 | TRAILS | 41.2% | 263.2M | 81.7M |
| GENCODE v49 | 48.2% | 307.6M | 95.4M | ||||
5’ PBMC (5p_Citrate_CPT) |
10x 5’ v2 | 122.0M | 5,355 | TRAILS | 54.6% | 66.5M | 22.8M |
| GENCODE v49 | 62.0% | 75.6M | 25.4M |
Read pairs are implied by assigned fragments / mapping rate. Assigned fragments and mapping rate are from the Salmon logs; UMIs after deduplication are from the Alevin logs. GENCODE v49 gains roughly 7 percentage points of mapping rate over TRAILS on identical reads in both libraries, consistent with its broader transcript catalog. The comparison below asks how the two references differ in the inferential uncertainty of what is quantified.
Reference construction (TRAILS GTF to transcript FASTA against the hg38/GRCh38 primary assembly; the GENCODE transcript FASTA is used directly after removing MT-gene transcripts):
## TRAILS (the atlas contains no mitochondrial loci)
gffread -w trails_transcripts.fa -g hg38.fa TRAILS.gtf
salmon index -t trails_transcripts.fa -i salmon_idx_trails -p 16
## GENCODE v49: exclude mitochondrial-gene transcripts (GTF seqname chrM)
zcat gencode.v49.annotation.gtf.gz | awk '$1 == "chrM" && $3 == "transcript"' | \
grep -o 'transcript_id "[^"]*"' | cut -d'"' -f2 | sort -u > mt_tx.txt
seqkit grep -v -f mt_tx.txt gencode.v49.transcripts.fa.gz | \
gzip > gencode.v49.noMT.transcripts.fa.gz
salmon index -t gencode.v49.noMT.transcripts.fa.gz -i salmon_idx_gencode -p 16 --gencode
Alevin requires a transcript-to-gene map. Passing a
transcript-to-self identity table (tx2self.tsv, two
identical columns of transcript IDs) keeps quantification at the
transcript level, which scAmbi needs.
Quantification (shown for both libraries against TRAILS; the GENCODE runs differ only in the index):
# 3' v3 library (16 bp barcode + 12 bp UMI); ISR orientation
salmon alevin -l ISR \
-1 pbmc_10k_v3_R1.fastq.gz -2 pbmc_10k_v3_R2.fastq.gz \
--chromiumV3 -i salmon_idx_trails -p 8 \
--tgMap tx2self_trails.tsv \
--numCellBootstraps 20 --dumpFeatures --expectCells 10000 \
-o quants/3p_trails
# 5' v2 library (16 bp barcode + 10 bp UMI); ISF orientation
salmon alevin -l ISF \
-1 5p_Citrate_CPT_R1.fastq.gz -2 5p_Citrate_CPT_R2.fastq.gz \
--chromium -i salmon_idx_trails -p 8 \
--tgMap tx2self_trails.tsv \
--numCellBootstraps 20 --dumpFeatures --expectCells 5000 \
-o quants/5p_trails
pbmc_infrv objectThe shipped object is assembled from the four Alevin output
directories in three stages (full script:
data-raw/pbmc_infrv.R in the source package). None of this
runs at vignette build time. The finished object is loaded from package
data below.
Stage 1, per-cell collapse. For each library x reference, the EM
count matrix (quants_mat.gz) gives mean_count
(mean count per cell), expr_cells (number of detecting
cells), and pct_expr_cells_threshold (percent of those
cells with count >= 10). The bootstrap mean/variance matrices
(quants_mean_mat.gz, quants_var_mat.gz) give
the per-cell InfRV sums and the pooled VMR accumulators:
library(Matrix); library(eds)
read_eds <- function(f, G, C)
drop0(as(eds::readEDS(f, numOfGenes = G, numOfOriginalCells = C), "dgCMatrix"))
counts <- read_eds("quants/3p_trails/alevin/quants_mat.gz", G, C)
mean_count <- rowSums(counts) / C
expr_cells <- tabulate(counts@i + 1L, nbins = G) # detection count
n_ge_10 <- tabulate(counts@i[counts@x >= 10] + 1L, nbins = G)
mean_m <- read_eds("quants/3p_trails/alevin/quants_mean_mat.gz", G, C)
var_m <- read_eds("quants/3p_trails/alevin/quants_var_mat.gz", G, C)
V <- var_m + 0 * mean_m; M <- mean_m + 0 * var_m # union support
infrv <- pmax(V@x - M@x, 0) / (M@x + 1) # per-cell InfRV
Stage 2, component 1 (OD_boot). The pooled VMR is
EB-moderated with limma::squeezeVar() on transcripts
detected in >= 10 cells, per-transcript df =
expr_cells * (n_boot - 1), floored at 1. This is the same
fit od_boot() performs (see Integrated overdispersion
estimation, Section 1).
Stage 3, exploratory components 2/3 and the integrated fusion.
OD_moments and OD_prior are computed from the
EM counts with od_moments() and od_prior()
(package defaults). OD_integrated fuses the three
components exactly as od_integrated() does: weighted
geometric mean (weights 0.8/0.1/0.1), shrinkage toward the median of the
expr_cells >= 50 set with weight
pmin(expr_cells/100, 1), clamped to [1, 100]:
od_m <- od_moments(counts, min_cells = 10)
od_p <- od_prior(counts, transcript_info = tx2gene, features = tx_ids)
w <- c(bootstrap = 0.8, moments = 0.1, prior = 0.1); w <- w / sum(w)
odi <- exp(w["bootstrap"] * log(pmax(od_boot_vec, 1)) +
w["moments"] * log(od_m) +
w["prior"] * log(od_p))
hc <- which(expr_cells >= 50)
sw <- pmin(expr_cells / 100, 1)
odi <- pmin(pmax(sw * odi + (1 - sw) * median(odi[hc]), 1), 100)
pbmc_infrv data objectlibrary(scAmbi)
data("pbmc_infrv")
head(pbmc_infrv)
#> transcript_id library reference mean_count
#> 1 028da976-0c67-417e-bb9e-47ad0934e973-1 3p TRAILS 122.20
#> 2 ENST00000260379.11 3p TRAILS 53.69
#> 3 dc4ab983-60ca-4a52-bb2d-5a3512d97312 3p TRAILS 49.67
#> 4 ENST00000361575.4 3p TRAILS 38.68
#> 5 ENST00000521726.1 3p TRAILS 37.70
#> 6 5938151a-65fa-4f9b-a8f3-ac185c22f601 3p TRAILS 37.44
#> expr_cells pct_expr_cells_threshold meanInfRV OD_boot VMR_raw OD_moments
#> 1 11867 95.85 5.3310 5.941 5.9410 44.38
#> 2 11652 92.19 0.1219 1.000 0.9988 24.30
#> 3 11601 89.78 1.1540 2.040 2.0400 24.14
#> 4 11465 87.86 0.0961 1.000 0.9533 18.02
#> 5 11539 92.06 0.1264 1.004 1.0040 13.47
#> 6 11607 67.15 0.9965 2.154 2.1540 34.33
#> OD_prior OD_integrated gene_id gene_symbol
#> 1 2.323 6.613 chr11:65499000 <NA>
#> 2 1.224 1.404 ENSG00000137818 RPLP1
#> 3 1.609 2.550 ENSG00000133112 TPT1
#> 4 1.310 1.372 ENSG00000198918 RPL39
#> 5 1.034 1.306 ENSG00000156482 RPL30
#> 6 2.653 2.901 ENSG00000167526 RPL13
#> gene_biotype
#> 1 <NA>
#> 2 protein_coding
#> 3 protein_coding
#> 4 protein_coding
#> 5 protein_coding_CDS_not_defined
#> 6 protein_coding
Each row is one transcript in one library x reference combination. The object is unfiltered (every quantified transcript; references were built MT-free):
transcript_id, gene_id,
gene_symbol, gene_biotype: feature
identifiers. TRAILS transcript IDs are long-read assembly UUIDs; gene
IDs are version-stripped Ensembl gene IDs shared across both
references.library, reference:
3p/5p and
TRAILS/GENCODE.mean_count: mean Alevin EM count per cell.expr_cells: number of cells in which the transcript was
detected.pct_expr_cells_threshold: percent of
expr_cells with EM count >= 10 (NA when
expr_cells = 0).meanInfRV: mean per-cell inferential relative variance
(diagnostic; not used in the correction).OD_boot: EB-moderated pooled bootstrap overdispersion
(component 1, floored at 1); the default correction vector.
NA outside the fit set (expr_cells <
10).VMR_raw: unmoderated pooled variance-to-mean
ratio.OD_moments, OD_prior,
OD_integrated: exploratory components 2 and 3 and their
fusion (see Comparing the overdispersion components).The figure below compares the two references on identical reads, per
library. Transcripts first pass a labelKeep-style abundance filter
adapted from fishpond::labelKeep() (kept:
expr_cells > 10 and
pct_expr_cells_threshold > 0%, i.e., detected in more
than 10 cells with at least one cell clearing 10 UMIs). All abundance
plots use log10(meanInfRV + 0.01). The pseudocount is
required because the median meanInfRV is exactly 0 in every
combination.
suppressPackageStartupMessages({
library(dplyr); library(tidyr); library(ggplot2)
library(scales); library(patchwork); library(ggpointdensity)
})
PC <- 0.01 # pseudocount for the log10 transform
LK_MINN <- 10 # keep: expr_cells strictly greater than this
LK_PCT <- 0 # keep: pct_expr_cells_threshold strictly greater than this
cols <- c(TRAILS = "royalblue", GENCODE = "orange")
lib_labels <- c(`3p` = "3' PBMC (10x v3)", `5p` = "5' PBMC (10x v2)")
label_keep <- function(expr_cells, pct_expr_cells_threshold,
minN = LK_MINN, minPct = LK_PCT) {
expr_cells > minN & pct_expr_cells_threshold > minPct
}
## snapshot the pre-filter table: the filter-step panels need every
## transcript that entered the filter, including those later removed
prefilter_tx <- pbmc_infrv
pbmc_infrv_filtered <- pbmc_infrv %>%
filter(label_keep(expr_cells, pct_expr_cells_threshold))
## sequential filter funnel: step 1 removes transcripts failing
## expr_cells > LK_MINN; step 2 removes the survivors failing
## pct_expr_cells_threshold > LK_PCT (never-expressed transcripts,
## pct = NA, fail both)
step_data <- prefilter_tx %>%
mutate(fail_expr = !(expr_cells > LK_MINN),
fail_pct = is.na(pct_expr_cells_threshold) |
pct_expr_cells_threshold <= LK_PCT) %>%
group_by(library, reference) %>%
summarise(total = n(),
step1 = sum(fail_expr),
step2 = sum(!fail_expr & fail_pct),
kept = sum(!fail_expr & !fail_pct),
.groups = "drop")
step_data %>%
mutate(across(c(step1, step2, kept),
~ sprintf("%s (%.1f%%)", format(.x, big.mark = ","),
100 * .x / total))) %>%
as.data.frame()
#> library reference total step1 step2 kept
#> 1 3p TRAILS 159368 3,961 (2.5%) 151,024 (94.8%) 4,383 (2.8%)
#> 2 3p GENCODE 517001 236,374 (45.7%) 276,624 (53.5%) 4,003 (0.8%)
#> 3 5p TRAILS 159368 7,499 (4.7%) 149,331 (93.7%) 2,538 (1.6%)
#> 4 5p GENCODE 517001 272,417 (52.7%) 242,286 (46.9%) 2,298 (0.4%)
plot_all <- pbmc_infrv_filtered %>%
filter(!is.na(meanInfRV)) %>%
mutate(log10_InfRV = log10(meanInfRV + PC))
step_long <- step_data %>%
select(library, reference, total, step1, step2) %>%
pivot_longer(c(step1, step2), names_to = "step", values_to = "n") %>%
mutate(pct = 100 * n / total,
step = factor(step, levels = c("step1", "step2"),
labels = c(sprintf("1. expr_cells > %g", LK_MINN),
sprintf("2. pct_expr_cells_threshold\n> %g%%", LK_PCT))))
## gene-level aggregation: expression-weighted mean of transcript meanInfRV,
## restricted to genes with at least one filtered transcript in BOTH references
gene_level <- plot_all %>%
filter(!is.na(gene_id)) %>%
group_by(library, reference, gene_id) %>%
summarise(wInfRV = weighted.mean(meanInfRV, w = mean_count), .groups = "drop") %>%
pivot_wider(names_from = reference, values_from = wInfRV) %>%
filter(!is.na(TRAILS), !is.na(GENCODE)) %>%
mutate(log10_TRAILS = log10(TRAILS + PC),
log10_GENCODE = log10(GENCODE + PC))
frac_below <- gene_level %>%
group_by(library) %>%
summarise(n_genes = n(),
frac_TRAILS_lt_GENCODE = mean(TRAILS < GENCODE),
.groups = "drop")
base_theme <- theme_bw(base_size = 10) +
theme(strip.text = element_text(face = "bold", size = 10),
legend.position = "top",
legend.title = element_blank(),
plot.tag = element_text(face = "bold", size = 12))
make_filterbar <- function(lib) {
d <- filter(step_long, library == lib)
kept <- filter(step_data, library == lib) %>%
mutate(txt = sprintf("%s %s (%.1f%%)", format(kept, big.mark = ","),
reference, 100 * kept / total))
ggplot(d, aes(x = step, y = pct, fill = reference)) +
geom_col(position = position_dodge(width = 0.75), width = 0.65) +
geom_text(aes(label = sprintf("%.1f%%", pct), group = reference),
position = position_dodge(width = 0.75),
vjust = -0.4, size = 2.8) +
scale_fill_manual(values = cols) +
scale_y_continuous(limits = c(0, 100),
expand = expansion(mult = c(0, 0.08))) +
labs(x = "filter step (applied sequentially)",
y = "% of transcripts filtered",
title = lib_labels[[lib]],
subtitle = sprintf("kept after both steps: %s",
paste(kept$txt, collapse = " / "))) +
base_theme
}
make_ecdf <- function(lib) {
ggplot(filter(plot_all, library == lib),
aes(x = log10_InfRV, color = reference)) +
stat_ecdf(linewidth = 0.8) +
scale_color_manual(values = cols) +
labs(x = bquote(log[10]~"(meanInfRV + 0.01)"),
y = "ECDF", title = lib_labels[[lib]]) +
base_theme
}
make_scatter <- function(lib) {
d <- filter(gene_level, library == lib)
fb <- frac_below[frac_below$library == lib, ]
ggplot(d, aes(x = log10_GENCODE, y = log10_TRAILS)) +
geom_pointdensity(size = 0.8, alpha = 0.9) +
scale_color_viridis_c(
option = "viridis",
guide = guide_colorbar(barwidth = 8, barheight = 0.6,
title = "local density",
title.position = "top")) +
geom_abline(slope = 1, intercept = 0, linetype = "dashed", color = "black") +
labs(x = bquote("GENCODE v49 gene-level"~log[10]~"(meanInfRV + 0.01)"),
y = bquote("TRAILS gene-level"~log[10]~"(meanInfRV + 0.01)"),
title = lib_labels[[lib]],
subtitle = sprintf(paste0(
"%s shared genes; %.1f%% below diagonal (TRAILS < GENCODE)\n",
"gene-level InfRV = mean_count-weighted mean of transcript meanInfRV"),
format(fb$n_genes, big.mark = ","),
100 * fb$frac_TRAILS_lt_GENCODE)) +
base_theme +
theme(plot.subtitle = element_text(size = 8))
}
fig_comparison <- (make_filterbar("3p") | make_filterbar("5p")) /
(make_ecdf("3p") | make_ecdf("5p")) /
(make_scatter("3p") | make_scatter("5p")) +
plot_annotation(tag_levels = "A") &
theme(plot.tag = element_text(face = "bold", size = 12))
fig_comparison
Every pattern in the figure traces back to how each catalog was built and to where the reads land. TRAILS is a long-read atlas, so it carries many low-abundance novel isoforms that are detected in more than 10 cells yet never clear 10 UMIs in any single cell; its removals are therefore dominated by the second filter step. GENCODE is a comprehensive annotation, so a large fraction of its transcripts are never or barely expressed in PBMCs at all and are removed at the first step. The 5’ library carries substantially more inferential uncertainty than the 3’ library under either reference because 5’ reads reach into transcript bodies and across splice junctions, where isoforms diverge, whereas 3’ reads concentrate in terminal exons shared across isoforms. The reference ordering is chemistry-dependent for the same reason: in 3’ data, where most fragments map to shared terminal exons, the two references are nearly indistinguishable, while in 5’ data the experimentally resolved junction structures in TRAILS give fragments a unique target more often than GENCODE’s larger, partly redundant catalog, shifting the TRAILS distribution left and placing the expression-weighted gene-level InfRV below the diagonal for a clear majority of shared genes. Because the assemblies define different transcripts, all comparisons here are distributional or gene-level, and differences below about 10% should not be over-interpreted given the 20-bootstrap budget.
The shipped object carries all three overdispersion components plus
their integrated fusion. Components 2 (OD_moments), 3
(OD_prior), and OD_integrated are exploratory.
Component 1 (OD_boot) remains the default correction
vector. The scatter plots below use the labelKeep-filtered transcripts
(the analysis set).
od_filtered <- pbmc_infrv_filtered %>%
mutate(combo = paste0(lib_labels[as.character(library)], " / ", reference))
pair_scatter <- function(xvar, yvar) {
rho <- od_filtered %>%
group_by(combo) %>%
summarise(r = cor(.data[[xvar]], .data[[yvar]], method = "spearman"),
.groups = "drop")
d <- od_filtered %>%
left_join(rho, by = "combo") %>%
mutate(panel = sprintf("%s\nSpearman rho = %.2f", combo, r))
ggplot(d, aes(x = .data[[xvar]], y = .data[[yvar]])) +
geom_pointdensity(size = 0.5, alpha = 0.8) +
scale_color_viridis_c(option = "viridis", guide = "none") +
geom_abline(slope = 1, intercept = 0, linetype = "dashed", color = "grey40") +
scale_x_log10() + scale_y_log10() +
facet_wrap(~ panel, nrow = 1) +
labs(x = paste0(xvar, " (log10)"), y = paste0(yvar, " (log10)")) +
theme_bw(base_size = 9) +
theme(strip.text = element_text(size = 7.5))
}
pair_scatter("OD_boot", "OD_moments") /
pair_scatter("OD_boot", "OD_integrated") /
pair_scatter("OD_moments", "OD_prior") +
plot_annotation(tag_levels = "A")
The three components measure different quantities, and the
differences between them are mechanistic. OD_moments sits
at 1 for the large majority of transcripts because the moment estimator
on nonzero counts is dominated by ordinary sampling noise at these
expression levels: after shrinkage, most transcripts have variance at or
below the Poisson mean among expressing cells, and only highly variable
transcripts form a long right tail. OD_prior is >= 1
everywhere by construction and identical for all transcripts of a gene
because it encodes the potential for mapping ambiguity implied by the
annotation; most expressed transcripts belong to multi-isoform genes, so
the prior assigns them baseline complexity independently of the data.
OD_boot is the only component that directly measures
realized mapping-ambiguity variance, which is why it remains the default
correction vector. OD_integrated is anchored by the
bootstrap component, pulled upward where the prior indicates complex
loci and shrunk toward the high-confidence median for sparsely detected
transcripts, and is best viewed as a sensitivity analysis around the
bootstrap estimate.
The object above carries the diagnostic layer. The correction workflow operates on Alevin output directories directly. With the PBMC quants above, the full pipeline is:
## per-library overdispersion from bootstraps
od <- od_boot(
alevin_dir = "quants/5p_trails/alevin",
n_boot = 20, min_cells_expr = 10
)
## read counts and build a corrected Seurat assay (RNA_corr)
s <- read_alevin(
sample_id = "5p_trails",
base_dir = "quants",
n_boot = 20
)
seu <- correct_seurat(
sample_id = "5p_trails",
counts = s$counts, od = s$od, feats = s$feats, cells = s$cells,
od_source = "bootstrap" # default; "integrated", "moments", "prior" also possible
)
## optional: sanity-check the corrected assay
diagnose_correction(list(`5p_trails` = seu))
help(package = "scAmbi"))
documents the full API, including od_moments(),
od_prior(), od_integrated(),
correct_seurat(), diagnose_correction(),
get_od(), and get_feature().data-raw/pbmc_infrv.R, the
full reproducible script for building the shipped data object from the
public FASTQ files and reference annotations.sessionInfo()
#> R version 4.4.3 (2025-02-28)
#> Platform: x86_64-conda-linux-gnu
#> Running under: Ubuntu 24.04.4 LTS
#>
#> Matrix products: default
#> BLAS/LAPACK: /opt/conda/lib/libopenblasp-r0.3.34.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: Etc/UTC
#> tzcode source: system (glibc)
#>
#> attached base packages:
#> [1] stats graphics grDevices utils datasets methods base
#>
#> other attached packages:
#> [1] ggpointdensity_0.2.1 patchwork_1.3.2 scales_1.4.0
#> [4] ggplot2_4.0.3 tidyr_1.3.2 dplyr_1.2.1
#> [7] scAmbi_0.3.2
#>
#> loaded via a namespace (and not attached):
#> [1] RColorBrewer_1.1-3 jsonlite_2.0.0 magrittr_2.0.5
#> [4] spatstat.utils_3.2-4 farver_2.1.2 rmarkdown_2.31
#> [7] ragg_1.5.2 vctrs_0.7.3 ROCR_1.0-12
#> [10] spatstat.explore_3.8-2 base64enc_0.1-6 htmltools_0.5.9
#> [13] sass_0.4.10 sctransform_0.4.3 parallelly_1.48.0
#> [16] KernSmooth_2.23-26 bslib_0.12.0 htmlwidgets_1.6.4
#> [19] ica_1.0-3 plyr_1.8.9 plotly_4.12.1
#> [22] zoo_1.8-15 cachem_1.1.0 uuid_1.2-2
#> [25] igraph_2.3.3 mime_0.13 lifecycle_1.0.5
#> [28] pkgconfig_2.0.3 Matrix_1.7-6 R6_2.6.1
#> [31] fastmap_1.2.0 fitdistrplus_1.2-6 future_1.75.0
#> [34] shiny_1.14.0 digest_0.6.39 Seurat_5.5.1
#> [37] tensor_1.5.1 RSpectra_0.16-2 irlba_2.3.7
#> [40] textshaping_1.0.1 labeling_0.4.3 progressr_1.0.0
#> [43] spatstat.sparse_3.2-0 httr_1.4.8 polyclip_1.10-7
#> [46] abind_1.4-8 compiler_4.4.3 withr_3.0.3
#> [49] S7_0.2.2 fastDummies_1.7.6 MASS_7.3-66
#> [52] tools_4.4.3 lmtest_0.9-40 otel_0.2.0
#> [55] httpuv_1.6.17 future.apply_1.20.2 goftest_1.2-3
#> [58] glue_1.8.1 nlme_3.1-170 promises_1.5.0
#> [61] grid_4.4.3 pbdZMQ_0.3-14 Rtsne_0.17
#> [64] cluster_2.1.8.3 reshape2_1.4.5 generics_0.1.4
#> [67] gtable_0.3.6 spatstat.data_3.1-9 data.table_1.18.4
#> [70] sp_2.2-3 spatstat.geom_3.8-2 RcppAnnoy_0.0.23
#> [73] ggrepel_0.9.8 RANN_2.6.2 pillar_1.11.1
#> [76] stringr_1.6.0 spam_2.11-4 IRdisplay_1.1
#> [79] RcppHNSW_0.7.0 later_1.4.8 splines_4.4.3
#> [82] lattice_0.23-1 survival_3.8-9 deldir_2.0-4
#> [85] tidyselect_1.2.1 miniUI_0.1.2 pbapply_1.7-4
#> [88] knitr_1.51 gridExtra_2.3.1 scattermore_1.2
#> [91] xfun_0.60 matrixStats_1.5.0 eds_1.8.0
#> [94] stringi_1.8.7 yaml_2.3.12 evaluate_1.0.5
#> [97] codetools_0.2-20 tibble_3.3.1 cli_3.6.6
#> [100] uwot_0.2.5 IRkernel_1.3.2 xtable_1.8-8
#> [103] reticulate_1.46.0 systemfonts_1.3.2 repr_1.1.7
#> [106] jquerylib_0.1.4 Rcpp_1.1.2 globals_0.19.1
#> [109] spatstat.random_3.5-1 png_0.1-9 spatstat.univar_3.2-0
#> [112] parallel_4.4.3 dotCall64_1.2 listenv_1.0.0
#> [115] viridisLite_0.4.3 ggridges_0.5.7 SeuratObject_5.4.0
#> [118] purrr_1.2.2 crayon_1.5.3 rlang_1.3.0
#> [121] cowplot_1.2.0