multi.lin.sce is a simulated trajectory with three end
points, made with dyntoy for the benchmarks of tradeSeq (Saelens et al. 2019; Van den Berge et al. 2020)
and analysed with Monocle3 (Cao et al.
2019). Its cells carry a hard path label (group) and
the Monocle3 pseudotime (Monocle3_Pseudotime); the Trajectory
input vignette shows how to build such labels.
library(scMaSigPro)
library(SingleCellExperiment)
library(ggplot2)
data("multi.lin.sce", package = "scMaSigPro")
table(colData(multi.lin.sce)$group)
#>
#> path_Y_15 path_Y_18 path_Y_18|path_Y_15 path_Y_52
#> 307 676 43 498
#> root
#> 453tsne <- as.data.frame(reducedDim(multi.lin.sce, "TSNE"))
colnames(tsne) <- c("tSNE1", "tSNE2")
tsne$path <- colData(multi.lin.sce)$group
tsne$pseudotime <- colData(multi.lin.sce)$Monocle3_Pseudotime
p_path <- ggplot(tsne, aes(tSNE1, tSNE2, colour = path)) +
geom_point(size = 0.5) +
theme_minimal() +
theme(legend.position = "bottom", legend.direction = "vertical")
p_time <- ggplot(tsne, aes(tSNE1, tSNE2, colour = pseudotime)) +
geom_point(size = 0.5) +
scale_colour_viridis_c() +
theme_minimal() +
theme(legend.position = "bottom")
patchwork::wrap_plots(p_path, p_time, ncol = 2)The cells start in the root, which all three lineages
share. One lineage leads straight to the end path
path_Y_52; the other two first share a segment, the link
path_Y_18|path_Y_15, and then part into the end paths
path_Y_15 and path_Y_18. The pseudotime runs
from the root through each lineage; every lineage shares it.
The labels are shortened first: the path_ prefix is
dropped and the link gets the separator _links_, the
default of sc.restruct(). To keep the vignette fast, the
analysis uses the first 200 of the 501 genes.
cell_data <- as.data.frame(colData(multi.lin.sce))
cell_data$group <- gsub("path_", "", cell_data$group, fixed = TRUE)
cell_data$group <- gsub("|", "_links_", cell_data$group, fixed = TRUE)
table(cell_data$group)
#>
#> root Y_15 Y_18 Y_18_links_Y_15 Y_52
#> 453 307 676 43 498
genes <- rownames(multi.lin.sce)[seq_len(200)]
scmp_ob <- create_scmp(
counts = counts(multi.lin.sce)[genes, ],
cell_data = cell_data,
ptime_col = "Monocle3_Pseudotime",
path_col = "group"
)The first way treats the root, the link and the three end paths as
five paths. sc.squeeze() bins each of them along its own
pseudotime, so the bins of every segment start at 1:
The model then compares each segment with a reference segment. With
the root as the reference, the terms of every other segment describe how
it differs from the root, at the same binned pseudotime.
reference names it; without it, sc.set.poly()
takes the first path in C-locale (byte) order, here Y_15,
whose uppercase Y sorts before the lowercase r
of root.
seg_ob <- sc.set.poly(seg_ob, poly_degree = 3, reference = "root")
seg_ob <- sc.p.vector(seg_ob, verbose = FALSE)
seg_ob <- sc.t.fit(seg_ob, verbose = FALSE)
seg_ob <- sc.filter(seg_ob, rsq = 0.6, vars = "groups")
seg_res <- scmpResults(seg_ob)
seg_lists <- as.data.frame(seg_res[, grep("^sig_", colnames(seg_res))])
colSums(seg_lists)
#> sig_Y_15vsroot sig_Y_18vsroot sig_Y_18_links_Y_15vsroot
#> 120 120 97
#> sig_Y_52vsroot sig_root
#> 110 56sig_root holds the genes that change along the root, and
sig_Y_52vsroot, for example, the genes whose profile in
Y_52 differs from that in the root. The overlaps of the
lists sort the genes by the segments in which they differ from the
root:
For example, 17 genes differ from the root in all three end paths but not in the link: along these genes the link carries on the expression of the root, and the end paths leave it. One of them:
This view answers questions about the segments themselves, such as which genes the link turns on, but every segment starts again at binned pseudotime 1, so it does not follow a gene from the root to the end of a lineage.
The second way follows each lineage from the start: the end path
Y_15 holds the bins of the root, then those of the link,
then its own, and so do Y_18 and Y_52 with the
segments on their way. sc.restruct() does this after
sc.squeeze(): it copies the bins of the root and of the
links into the end paths that share them, shifts the binned pseudotime
of the later bins to follow them, and computes the pseudo-bulk counts
again.
lin_ob <- sc.restruct(sc.squeeze(scmp_ob),
end_node_list = list("Y_15", "Y_18", "Y_52"),
root_node = "root",
link_node_list = list("Y_18_links_Y_15"),
verbose = FALSE
)
plotBinTile(lin_ob)The end paths now share their first bins, and the model compares
whole lineages. sc.restruct() puts the paths in the order
of end_node_list, so Y_15 is the reference
unless reference names another:
lin_ob <- sc.set.poly(lin_ob, poly_degree = 3)
lin_ob <- sc.p.vector(lin_ob, verbose = FALSE)
lin_ob <- sc.t.fit(lin_ob, verbose = FALSE)
lin_ob <- sc.filter(lin_ob, rsq = 0.6, vars = "groups")
lin_res <- scmpResults(lin_ob)
colSums(as.data.frame(lin_res[, grep("^sig_", colnames(lin_res))]))
#> sig_Y_18vsY_15 sig_Y_52vsY_15 sig_Y_15
#> 54 64 72lin_ob <- sc.cluster.trend(lin_ob, geneSet = "union", k = 4)
plotTrendCluster(lin_ob, verbose = FALSE)sc.restruct() handles a root, links and end paths. For
other shapes, build the bins by hand: take the bin table of
sc.squeeze() with cDense(), stack the bins of
the segments of each lineage with their binned pseudotime shifted, give
the stacked bins the label of the lineage in the path column, store the
table with cDense<- and compute the pseudo-bulk counts
with pb_counts(). For this trajectory, the result is that
of sc.restruct():
bins <- cDense(sc.squeeze(scmp_ob))
stack_bins <- function(bins, segments, path) {
out <- NULL
shift <- 0
for (segment in segments) {
b <- bins[bins$group == segment, , drop = FALSE]
b$scmp_binned_pseudotime <- b$scmp_binned_pseudotime + shift
shift <- max(b$scmp_binned_pseudotime)
out <- rbind(out, b)
}
out$group <- path
rownames(out) <- paste(path, "bin", out$scmp_binned_pseudotime, sep = "_")
out
}
hand_ob <- sc.squeeze(scmp_ob)
cDense(hand_ob) <- rbind(
stack_bins(bins, c("root", "Y_18_links_Y_15", "Y_15"), "Y_15"),
stack_bins(bins, c("root", "Y_18_links_Y_15", "Y_18"), "Y_18"),
stack_bins(bins, c("root", "Y_52"), "Y_52")
)
#> 'value' has 66 bins instead of 44: the pseudo-bulk counts of the 'Dense' slot are removed; run pb_counts() to compute them for the new bins.
hand_ob <- pb_counts(hand_ob)
restruct_ob <- sc.restruct(sc.squeeze(scmp_ob),
end_node_list = list("Y_15", "Y_18", "Y_52"),
root_node = "root",
link_node_list = list("Y_18_links_Y_15"),
verbose = FALSE
)
identical(eDense(hand_ob), eDense(restruct_ob))
#> [1] TRUEThe dummies of the model compare every path with the reference path,
so the reference decides what the coefficients and the
"groups" lists of sc.filter() say: with
Y_15 as the reference, sig_Y_52vsY_15 lists
the genes whose profile in Y_52 differs from that in
Y_15. The full model is the same with any reference, only
written in other terms, so the p-values of sc.p.vector()
and its non-flat genes do not change, up to rounding. With
Y_52, the lineage that leaves the root on its own, as the
reference:
ref_ob <- sc.set.poly(lin_ob, poly_degree = 3, reference = "Y_52")
showPoly(ref_ob)
#> [1] "beta0 + beta1*Y_15vsY_52 + beta2*Y_18vsY_52 + beta3*scmp_binned_pseudotime + beta4*scmp_binned_pseudotimexY_15 + beta5*scmp_binned_pseudotimexY_18 + beta6*scmp_binned_pseudotime2 + beta7*scmp_binned_pseudotime2xY_15 + beta8*scmp_binned_pseudotime2xY_18 + beta9*scmp_binned_pseudotime3 + beta10*scmp_binned_pseudotime3xY_15 + beta11*scmp_binned_pseudotime3xY_18"
ref_ob <- sc.p.vector(ref_ob, verbose = FALSE)
all.equal(scmpResults(ref_ob)$p_value, lin_res$p_value)
#> [1] TRUE
identical(scmpResults(ref_ob)$non_flat, lin_res$non_flat)
#> [1] TRUE
ref_ob <- sc.t.fit(ref_ob, verbose = FALSE)
ref_ob <- sc.filter(ref_ob, rsq = 0.6, vars = "groups")
ref_res <- scmpResults(ref_ob)
colSums(as.data.frame(ref_res[, grep("^sig_", colnames(ref_res))]))
#> sig_Y_15vsY_52 sig_Y_18vsY_52 sig_Y_52
#> 70 66 72A good reference is the path the others are best described against:
the root when the segments are compared, a control or the most common
fate when lineages are compared. Without reference, the
first path is the first level of the path column when it is a factor,
and otherwise the first label in C-locale (byte) order, in every locale;
after sc.restruct(), the first path of
end_node_list. Naming the reference keeps an analysis the
same when the labels change.
sessionInfo()
#> R version 4.6.1 (2026-06-24)
#> Platform: x86_64-pc-linux-gnu
#> Running under: Ubuntu 26.04.1 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.32.so; LAPACK version 3.12.0
#>
#> locale:
#> [1] LC_CTYPE=en_US.UTF-8 LC_NUMERIC=C
#> [3] LC_TIME=en_US.UTF-8 LC_COLLATE=en_US.UTF-8
#> [5] LC_MONETARY=en_US.UTF-8 LC_MESSAGES=en_US.UTF-8
#> [7] LC_PAPER=en_US.UTF-8 LC_NAME=C
#> [9] LC_ADDRESS=C LC_TELEPHONE=C
#> [11] LC_MEASUREMENT=en_US.UTF-8 LC_IDENTIFICATION=C
#>
#> time zone: Etc/UTC
#> tzcode source: system (glibc)
#>
#> attached base packages:
#> [1] stats4 stats graphics grDevices utils datasets methods
#> [8] base
#>
#> other attached packages:
#> [1] ggplot2_4.0.3 SingleCellExperiment_1.35.2
#> [3] SummarizedExperiment_1.43.0 Biobase_2.73.2
#> [5] GenomicRanges_1.65.4 Seqinfo_1.3.2
#> [7] IRanges_2.47.5 S4Vectors_0.51.10
#> [9] BiocGenerics_0.59.12 generics_0.1.4
#> [11] MatrixGenerics_1.25.0 matrixStats_1.5.0
#> [13] maSigPro_1.85.0 scMaSigPro_0.6.0
#> [15] BiocStyle_2.41.0
#>
#> loaded via a namespace (and not attached):
#> [1] gtable_0.3.6 xfun_0.62 bslib_0.12.0
#> [4] lattice_0.23-1 vctrs_0.7.3 tools_4.6.1
#> [7] parallel_4.6.1 Matrix_1.7-6 RColorBrewer_1.1-3
#> [10] S7_0.2.2 lifecycle_1.0.5 compiler_4.6.1
#> [13] farver_2.1.2 codetools_0.2-20 htmltools_0.5.9
#> [16] sys_3.4.3 buildtools_1.0.0 sass_0.4.10
#> [19] yaml_2.3.12 venn_1.13 jquerylib_0.1.4
#> [22] MASS_7.3-66 admisc_0.41 BiocParallel_1.47.0
#> [25] DelayedArray_0.39.8 cachem_1.1.0 abind_1.4-8
#> [28] mclust_6.1.3 nlme_3.1-171 digest_0.6.39
#> [31] maketools_1.3.2 labeling_0.4.3 splines_4.6.1
#> [34] fastmap_1.2.0 grid_4.6.1 cli_3.6.6
#> [37] SparseArray_1.13.4 patchwork_1.3.2 S4Arrays_1.13.2
#> [40] withr_3.0.3 scales_1.4.0 rmarkdown_2.32
#> [43] XVector_0.53.0 otel_0.2.0 gridExtra_2.3.1
#> [46] evaluate_1.0.5 knitr_1.52 UpSetR_1.4.1
#> [49] viridisLite_0.4.3 mgcv_1.9-4 rlang_1.3.0
#> [52] Rcpp_1.1.2 glue_1.8.1 BiocManager_1.30.27
#> [55] jsonlite_2.0.0 plyr_1.8.9 R6_2.6.1