31 Trajectory Analysis (Slingshot)

Unlike Monocle3, which represents a single branching tree, Slingshot fits one or more separate smooth lineages through the cluster structure, each rooted at a common starting cluster. Multiple lineages indicate that the data statistically supports more than one distinct path away from the root — each should be checked against the underlying biology rather than assumed to be meaningful. As in the Monocle3 chapter, the trajectory is fit once on the full dataset and again separately on the PASC-only and Healthy-only subsets, using cluster_6 as the root cluster in every case.

31.1 Load object

library(slingshot)
library(ggplot2)
library(dplyr)
library(reshape2)
library(RColorBrewer)
set.seed(1234)

combined <- combined[, !combined$cell_labels %in% c("cluster_5", "cluster_7", "cluster_8")]

31.2 Fit and plot helpers

as.SingleCellExperiment() is followed by an explicit, name-based reducedDim<- assignment rather than relying on whatever name Seurat’s SCE coercion happens to give the embedding — the original script worked around this uncertainty by referencing INTEGRATION.WNN, which does not match the integration.wnn reduction actually computed in the Cross-modal Integration chapter.

fit_slingshot <- function(obj, root_cluster = "cluster_6") {
  sce <- as.SingleCellExperiment(obj, assay = "RNA")
  reducedDim(sce, "integration.wnn") <- Embeddings(obj, "integration.wnn")

  slingshot(sce, clusterLabels = "cell_labels", reducedDim = "integration.wnn",
           dist.method = "slingshot", start.clus = root_cluster)
}

plot_lineage_pseudotime <- function(sce, pt, main = "") {
  colors <- colorRampPalette(brewer.pal(11, "Spectral")[-6])(100)
  plotcol <- colors[cut(pt, breaks = 100)]

  plot(reducedDim(sce, "integration.wnn"), col = plotcol, pch = 16, asp = 1, main = main)
  lines(SlingshotDataSet(sce), type = "l", lwd = 2, col = "black")

  legend_image <- as.raster(matrix(rev(colors), ncol = 1))
  rasterImage(legend_image, xleft = 5, ybottom = 2, xright = 5.5, ytop = 3)
  rect(5, 2, 5.5, 3, border = "grey40", lwd = 0.7)
  text(x = 5.55, y = seq(2, 3, length.out = 5),
      labels = round(quantile(pt, na.rm = TRUE), 0), cex = 0.7, pos = 4, col = "black", font = 2)
  text(x = 5.25, y = 3.3, labels = "Pseudotime", cex = 0.9, font = 2)
}

summarise_slingshot <- function(sce) {
  pt_all <- slingPseudotime(sce)

  for (i in seq_len(ncol(pt_all))) {
    plot_lineage_pseudotime(sce, pt_all[, i], main = paste("Lineage", i))
  }

  df_long <- as.data.frame(pt_all)
  colnames(df_long) <- paste0("pseudotime", seq_len(ncol(pt_all)))
  df_long$cluster <- sce$cell_labels
  df_long$condition <- sce$individual_condition
  df <- melt(df_long, id = c("cluster", "condition"))

  print(
    ggplot(df, aes(y = value, x = cluster, fill = condition)) +
      geom_boxplot() +
      facet_grid(~variable) +
      theme(axis.text.x = element_text(angle = 90, vjust = 0.5, hjust = 1))
  )

  for (i in seq_len(ncol(pt_all))) {
    breaks <- quantile(pt_all[, i], na.rm = TRUE)
    node <- cut(pt_all[, i], breaks, include.lowest = FALSE, right = TRUE, dig.lab = 3)

      count_node_df <- data.frame(node, cell_labels = sce$cell_labels,
                                  condition = sce$individual_condition) %>%
        dplyr::count(node, condition, cell_labels) %>%
        group_by(condition) %>%
        mutate(proportion = n / sum(n)) %>%
        ungroup()

    print(
      ggplot(count_node_df, aes(y = proportion, x = node, fill = cell_labels)) +
        geom_bar(position = "fill", stat = "identity") +
        xlab(paste("Trajectory Nodes (lineage", i, ")")) + ylab("Percentage of Cells")
    )
  }
}

31.3 Full dataset

sce_all <- fit_slingshot(combined)
summarise_slingshot(sce_all)

Download PDF

Download PDF

Download PDF

31.4 PASC only

sce_pasc <- fit_slingshot(combined[, combined$individual_condition == "PASC"])
summarise_slingshot(sce_pasc)

Download PDF

Download PDF

Download PDF

Download PDF

Download PDF

31.5 Healthy only

sce_healthy <- fit_slingshot(combined[, combined$individual_condition == "Healthy"])
summarise_slingshot(sce_healthy)

Download PDF

Download PDF

Download PDF

Download PDF

Download PDF

31.6 Genes changing along pseudotime (full dataset)

Testing every gene for association with pseudotime (tradeSeq::fitGAM / associationTest) is computationally expensive, so this is eval=FALSE — run manually when needed.

library(tradeSeq)

BiocParallel::register(BiocParallel::SerialParam())
combined <- FindVariableFeatures(combined)
counts <- as.matrix(GetAssayData(combined, assay = "RNA", layer = "counts")[Seurat::VariableFeatures(combined), ])
filt_counts <- counts[rowSums(counts > 5) > ncol(counts) / 100, ]

sce_genes <- fitGAM(counts = filt_counts, sds = SlingshotDataSet(sce_all))
associationRes <- associationTest(sce_genes)

pt <- slingPseudotime(sce_all)[, 1]
topgenes <- rownames(associationRes[order(associationRes$pvalue), ])[1:100]
pst.ord <- order(pt, na.last = NA)
heatdata <- assays(sce_genes)$counts[topgenes, pst.ord]
heatclus <- sce_genes$cell_labels[pst.ord]

heatmap(log1p(as.matrix(heatdata)),
       Colv = NA,
       ColSideColors = brewer.pal(9, "Set1")[as.numeric(factor(heatclus))])

31.7 Save results


# Add full-dataset pseudotime back onto the Seurat object as metadata columns.
# Only sce_all shares combined's full set of cells; sce_pasc/sce_healthy are
# subsets, so their pseudotime is added only for the cells they contain.
pt_all <- as.data.frame(slingPseudotime(sce_all))
colnames(pt_all) <- paste0("slingshot_pseudotime_all_", seq_len(ncol(pt_all)))
combined <- AddMetaData(combined, pt_all[colnames(combined), , drop = FALSE])

pt_pasc <- as.data.frame(slingPseudotime(sce_pasc))
colnames(pt_pasc) <- paste0("slingshot_pseudotime_pasc_", seq_len(ncol(pt_pasc)))
combined <- AddMetaData(combined, pt_pasc)

pt_healthy <- as.data.frame(slingPseudotime(sce_healthy))
colnames(pt_healthy) <- paste0("slingshot_pseudotime_healthy_", seq_len(ncol(pt_healthy)))
combined <- AddMetaData(combined, pt_healthy)

# Seurat metadata only holds pseudotime values, not the fitted curves,
# lineage tree, or per-lineage weights, so keep the SCE objects as well.
saveRDS(sce_all, "data/sce_slingshot_all.rds")
saveRDS(sce_pasc, "data/sce_slingshot_pasc.rds")
saveRDS(sce_healthy, "data/sce_slingshot_healthy.rds")

SaveSeuratRds(combined, "data/seurat_object_cross_modal_integration_peakslinks_motifs_grn_regulons_monoclepseudotime_slingshot.rds")