Over the last decade, the size of scRNA-seq datasets has exploded to millions of cells sequenced in a single study. This allows us profile the transcriptomes of various cell types across tissues. The rapid growth of scRNA-seq data has also created an unique set of challenges, for instance, there is a pressing need for scalable approaches for scRNA-seq data visualization.
This vignette introduces scBubbletree1, a transparent workflow for quantitative exploration of single cell RNA-seq data.
In short, the algorithm of scBubbletree performs clustering to identify clusters (“bubbles”) of transcriptionally similar cells, and then visualizes these clusters as leafs in a hierarchical dendrogram (“bubbletree”) which describes their relationships. The workflow comprises four steps: 1. determining the clustering resolution, 2. clustering, 3. hierarchical cluster grouping and 4. visualization. We explain each step in the following using scRNA-seq dataset of five cancer cell lines.
To run this vignette we need to load a few packages:
Here we will analyze a scRNA-seq data3 containing a mixture of 3,918 cells from five human lung adenocarcinoma cell lines (HCC827, H1975, A549, H838 and H2228). The dataset is available here4.
The library has been prepared with 10x Chromium platform and sequenced with Illumina NextSeq 500 platform. Raw data has been processed with cellranger. The tool demuxlet has been used to predict the identity of each cell based on known genetic differences between the different cell lines.
Data processing was performed with the R-package Seurat. Gene expressions were normalized with the function SCTransform using default parameters, and principal component analysis (PCA) was performed with function RunPCA based on the 5,000 most variable genes in the dataset identified with the function FindVariableFeatures.
In the data we saw that the first 15 principal components capture most of the variance in the data, and the proportion of variance explained by each subsequent principal component was negligible. Thus, we used the single cell projections (embeddings) in 15-dimensional feature space, A3, 918 × 15.
# # This script can be used to generate data("d_ccl", package = "scBubbletree")
#
# # create directory
# dir.create(path = "case_study/")
#
# # download the data from:
# https://github.com/LuyiTian/sc_mixology/raw/master/data/
# sincell_with_class_5cl.RData
#
# # load the data
# load(file = "case_study/sincell_with_class_5cl.RData")
#
# # we are only interested in the 10x data object 'sce_sc_10x_5cl_qc'
# d <- sce_sc_10x_5cl_qc
#
# # remove the remaining objects (cleanup)
# rm(sc_Celseq2_5cl_p1, sc_Celseq2_5cl_p2, sc_Celseq2_5cl_p3, sce_sc_10x_5cl_qc)
#
# # get the meta data for each cell
# meta <- colData(d)[,c("cell_line_demuxlet","non_mt_percent","total_features")]
#
# # create Seurat object from the raw counts and append the meta data to it
# d <- Seurat::CreateSeuratObject(counts = d@assays$data$counts,
# project = '')
#
# # check if all cells are matched between d and meta
# # table(rownames([email protected]) == meta@rownames)
# [email protected] <- cbind([email protected], meta@listData)
#
# # cell type predictions are provided as part of the meta data
# table([email protected]$cell_line)
#
# # select 5,000 most variable genes
# d <- Seurat::FindVariableFeatures(object = d,
# selection.method = "vst",
# nfeatures = 5000)
#
# # Preprocessing with Seurat: SCT transformation + PCA
# d <- SCTransform(object = d,
# variable.features.n = 5000)
# d <- RunPCA(object = d,
# npcs = 50,
# features = VariableFeatures(object = d))
#
# # perform UMAP + t-SNE
# d <- RunUMAP(d, dims = 1:15)
# d <- RunTSNE(d, dims = 1:15)
#
# # save the preprocessed data
# save(d, file = "case_study/d.RData")
#
# # save the PCA matrix 'A', meta data 'm' and
# # marker genes matrix 'e'
# d <- get(load(file ="case_study/d.RData"))
# A <- d@[email protected][, 1:15]
# m <- [email protected]
# e <- t(as.matrix(d@assays$SCT@data[
# rownames(d@assays$SCT@data) %in%
# c("ALDH1A1",
# "PIP4K2C",
# "SLPI",
# "CT45A2",
# "CD74"), ]))
#
# d_ccl <- list(A = A, m = m, e = e)
# save(d_ccl, file = "data/d_ccl.RData")
Load the processed PCA matrix and the meta data
# Extract the 15-dimensional PCA matrix A
# A has n=cells as rows, f=15 features as columns (e.g. from PCA)
A <- d_ccl$A
dim(A)
#> [1] 3918 15
# Extract the meta-data. For each cell this data contains some
# additional information. Inspect this data now!
m <- d_ccl$m
colnames(m)
#> [1] "orig.ident" "nCount_RNA" "nFeature_RNA"
#> [4] "cell_line_demuxlet" "non_mt_percent" "total_features"
#> [7] "nCount_SCT" "nFeature_SCT"
# Extract the normalized expressions of five marker genes. Rows
# are cells.
e <- d_ccl$e
colnames(e)
#> [1] "ALDH1A1" "SLPI" "CD74" "PIP4K2C" "CT45A2"
We will analyze this data with scBubbletree.
As main input scBubbletree uses matrix An × f which represents a low-dimensional projection of the original scRNA-seq data, with n rows as cells and f columns as low-dimension features. Here we use A3, 918 × 15 as input.
Important remark about A: the scBubbletree workflow works directly with the numeric matrix An × f and is agnostic to the initial data processing protocol. This enables seamless integration of scBubbletree with computational pipelines using objects generated by the R-packages Seurat and SingleCellExperiment. The users simply have to extract A from the corresponding Seurat or SingleCellExperiment objects.
The scBubbletree workflow performs the following steps:
How many clusters (cell types) are there are in the data?
Before we apply clustering, we must first find appropriate value for the resolution parameter k (if we intend to use k-means) or r (if we intend to use graph based community detection approaches such as Louvain). In the next we will first perform Louvain clustering and then k-means clustering.
How many clusters (cell types) are there are in the data?
For Louvain clustering we need to select a clustering resolution r. Higher resolutions lead to more communities (k′) and lower resolutions lead to fewer communities.
To find a reasonable value of r we can study the literature or
databases such as the human protein atlas database (HPA). We can also
use the function get_r
for data-driven inference of r based on the Gap statistic.
Lets use the function get_r
for data-driven estimation
of r based on the Gap
statistic and WCSS. As input we need to provide the matrix A and a vector of rs to inspect. See the help function
?get_r
to learn more about the remaining input parameters.
The output of get_r
is the Gap statistic and WCSS estimate
for each r (or the number of
communities k′ detected at
resolution r).
Lets run get_r
now [this might take a minute]:
b_r <- get_r(B_gap = 5,
rs = 10^seq(from = -4, to = 0.5, by = 0.5),
x = A,
n_start = 10,
iter_max = 50,
algorithm = "original",
knn_k = 50,
cores = 1)
The Gap curve has noticeable knee (elbow) at r ≈ 0.003 (dashed gray line). Means
(points) and 95% confidence intervals are shown for the Gap statistic at
each r using
B_gap
=5 MCMC simulations.
ggplot(data = b_r$gap_stats_summary)+
geom_line(aes(x = r, y = gap_mean))+
geom_point(aes(x = r, y = gap_mean), size = 1)+
geom_errorbar(aes(x = r, y = gap_mean, ymin = L95, ymax = H95), width = 0.1)+
ylab(label = "Gap")+
xlab(label = "r")+
geom_vline(xintercept = 0.003, col = "gray", linetype = "dashed")+
scale_x_log10()+
annotation_logticks(base = 10, sides = "b")
The resolutions r are difficult to interpret. Lets map r to the number of detected communities k′ (analogous to clusters k in k-means clustering), and show the Gap curve as a function of k′.
ggplot(data = b_r$gap_stats_summary)+
geom_line(aes(x = k, y = gap_mean))+
geom_point(aes(x = k, y = gap_mean), size = 1)+
geom_errorbar(aes(x = k, y = gap_mean, ymin = L95, ymax = H95), width = 0.1)+
geom_vline(xintercept = 5, col = "gray", linetype = "dashed")+
ylab(label = "Gap")+
xlab(label = "k'")
A range of resolutions yield k′=5 number of communities, i.e. among the tested rs, we saw k′=5 communities for r = 0.003, 0.01, 0.03 and 0.1. We can e.g. use r=0.1 for our clustering.
ggplot(data = b_r$gap_stats_summary)+
geom_point(aes(x = r, y = k), size = 1)+
xlab(label = "r")+
ylab(label = "k'")+
scale_x_log10()+
annotation_logticks(base = 10, sides = "b")+
theme_bw()
Table with rs that match to k′=5:
knitr::kable(x = b_r$gap_stats_summary[b_r$gap_stats_summary$k == 5, ],
digits = 4, row.names = FALSE)
gap_mean | r | k | gap_SE | L95 | H95 |
---|---|---|---|---|---|
2.1645 | 0.0032 | 5 | 0.0059 | 2.1530 | 2.1759 |
2.1646 | 0.0100 | 5 | 0.0017 | 2.1612 | 2.1680 |
2.1656 | 0.0316 | 5 | 0.0070 | 2.1519 | 2.1792 |
2.1620 | 0.1000 | 5 | 0.0072 | 2.1480 | 2.1760 |
If we want to use k-means for clustering, then we need to find a
reasonable value of k, e.g. by
applying once again a data-driven search for k using get_k
.
Here get_k
will inspect the Gap and WCSS at k = 1, 2, …, 10.
b_k <- get_k(B_gap = 5,
ks = 1:10,
x = A,
n_start = 50,
iter_max = 200,
kmeans_algorithm = "MacQueen",
cores = 1)
Notice the similar Gap curve with noticeable knee (elbow) at k = 5 (dashed gray line). Means
(points) and 95% confidence intervals are shown for the Gap statistic at
each k using
B_gap
=5 MCMC simulations.
Now that we found out that r = 0.1 (k′ = 5) is a reasonable choice based
on the data, we will perform Louvain clustering with r = 0.1 and A as inputs. For this we will use
the function get_bubbletree_graph
.
After the clustering is complete we will organize the bubbles by hierarchical clustering. For this we perform B bootstrap iterations. In iteration b the algorithm draws a random subset of Neff (default Neff = 200) cells with replacement from each cluster and compute the average inter-cluster Euclidean distances. This data is used to populate the distance matrix (Dbk′ × k′), which is provided as input for hierarchical clustering with average linkage to generate a hierarchical clustering dendrogram Hb.
The collection of distance matrices that are computed during B iterations are used to compute a consensus (average) distance matrix (D̂k′ × k′) and from this a corresponding consensus hierarchical dendrogram (bubbletree; Ĥ) is constructed. The collection of dendrograms are used to quantify the robustness of the bubbletree topology, i.e. to count the number of times each branch in the bubbletree is found among the topologies of the bootstrap dendrograms. Branches can have has variable degrees of support ranging between 0 (no support) and B (complete support). Distances between bubbles (inter- bubble relationships) are described quantitatively in the bubbletree as sums of branch lengths.
Steps 2. (clustering) and 3. (hierarchical grouping) are performed now:
l <- get_bubbletree_graph(x = A,
r = 0.1,
algorithm = "original",
n_start = 20,
iter_max = 100,
knn_k = 50,
cores = 1,
B = 300,
N_eff = 200,
round_digits = 1,
show_simple_count = FALSE)
# See the help `?get_bubbletree_graph` to learn about the input parameters.
… and plot the bubbletree
Lets describe the bubbletree:
bubbles: The bubbletree has k'=5
bubbles (clusters) shown as leaves. The absolute and relative cell
frequencies in each bubble and the bubble IDs are shown as labels.
Bubble radii scale linearly with absolute cell count in each bubble,
i.e. large bubbles have many cells and small bubbles contain few
cells.
Bubble 0 is the largest one in the dendrogram and contains 1,253 cells (≈ 32% of all cells in the dataset). Bubble 4 is the smallest one and contains only 437 cells (≈ 11% of all cells in the dataset).
We can access the bubble data shown in the bubbletree
label | Cells | n | p | pct | lab_short | lab_long | tree_order |
---|---|---|---|---|---|---|---|
4 | 437 | 3918 | 0.11 | 11.2 | 4 (0.4K, 11.2%) | 4 (437, 11.2%) | 5 |
3 | 590 | 3918 | 0.15 | 15.1 | 3 (0.6K, 15.1%) | 3 (590, 15.1%) | 4 |
0 | 1253 | 3918 | 0.32 | 32.0 | 0 (1.3K, 32%) | 0 (1253, 32%) | 3 |
2 | 761 | 3918 | 0.19 | 19.4 | 2 (0.8K, 19.4%) | 2 (761, 19.4%) | 2 |
1 | 877 | 3918 | 0.22 | 22.4 | 1 (0.9K, 22.4%) | 1 (877, 22.4%) | 1 |
topology: inter-bubble distances are represented by
sums of branch lengths in the dendrogram. Branches of the bubbletree are
annotated with their bootstrap support values (red branch labels). The
branch support value tells us how many times a given branch from the
bubbletree was found among the B bootstrap dendrograms. We ran
get_bubbletree_graph
with B = 300. All but one branch have
complete (300 out of 300) support, and one branch has lower support of
270 (90%). This tells us that the branch between bubbles (3, 4) and 0 is
not as robust.
To perform clustering with the k-means method we can use the function
get_bubbletree_kmeans
.
The two dendrograms are shown side-by-side.
To compare a pair of bubbletrees generated based on the same data but
with different inputs we can use the function
compare_bubbletrees
.
The function generates two bubbletrees and a heatmap, where the tiles of the heatmap are color coded according to the jaccard distances (JDs) between the pairs of bubbles from the two bubbletrees, and the tile labels show the numbers of cells in common between the bubbles.
Reminder of the Jaccard index (J) and the Jaccard distance (JD):
For clusters A and B we compute the Jaccard index $J(A,B)=\dfrac{|A \cap B|}{|A \cup B|}$ and distance JD(A, B) = 1 − J(A, B). If A and B contain the same set of cells JD(A, B)=0, and if they have no cells in common JD(A, B)=1.
The heatmap hints at nearly identical clusterings between the bubbletrees. Only 3 cells (red tiles with label = 1) are classified differently by the two bubbletrees.
To extract biologically useful information from the bubbletree (and also for 2D UMAP or t-SNE plots) we need to adorn it with biologically relevant cell features. This includes both numeric and categorical cell features.
Numeric cell features:
Categorical cell features:
In the next two paragraph we will explain how to ‘attach’ numeric and categorical features to the bubbletree using scBubbletree.
Categorical cell features can be ‘attached’ to the bubbletree using
the function get_cat_tiles
. Here we will show the relative
frequency of cell type labels across the bubbles (parameter
integrate_vertical=TRUE
).
Interpretation of the figure below:
w1 <- get_cat_tiles(btd = l,
f = m$cell_line_demuxlet,
integrate_vertical = TRUE,
round_digits = 1,
x_axis_name = 'Cell line',
rotate_x_axis_labels = TRUE,
tile_text_size = 2.75)
(l$tree|w1$plot)+
patchwork::plot_layout(widths = c(1, 1))
We can also show the inter-bubble cell type composition, i.e. the
relative frequencies of different cell types in a specific bubble (with
parameter integrate_vertical=FALSE
).
Interpretation of the figure below:
w2 <- get_cat_tiles(btd = l,
f = m$cell_line_demuxlet,
integrate_vertical = FALSE,
round_digits = 1,
x_axis_name = 'Cell line',
rotate_x_axis_labels = TRUE,
tile_text_size = 2.75)
(l$tree|w2$plot)+patchwork::plot_layout(widths = c(1, 1))
scBubbletree uses R-package ggtree to visualize the bubbletree, and ggplot2 to visualize annotations. Furthermore, R-package patchwork is used to combine plots.
To quantify the purity of a cluster (or bubble) i with ni number of cells, each of which carries one of L possible labels (e.g. cell lines), we can compute the Gini impurity index:
$\textit{GI}_i=\sum_{j=1}^{L} \pi_{ij}(1-\pi_{ij})$,
with πij as
the relative frequency of label j in cluster i. In homogeneous
(pure
) clusters most cells carry a distinct label. Hence,
the π’s are close to either 1
or 0, and GI takes on a small value close to zero. In
impure
clusters cells carry a mixture of different labels.
In this case most π are far
from either 1 or 0, and GI diverges from 0 and approaches 1. If
the relative frequencies of the different labels in cluster i are equal to the (background)
relative frequencies of the labels in the sample, then cluster i is completely
impure
.
To compute the overall Gini impurity of a bubbletree, which represents a clustering solution with k bubbles, we estimated the weighted Gini impurity (WGI) by computing the weighted (by the cluster size) average of the
$\textit{WGI}=\sum_{i=1}^{k} \textit{GI}_i \dfrac{n_i}{n}$,
with ni as the number of cells in cluster i and n = ∑ini.
The Gini impurity results are shown below:
#> cluster GI
#> 1 3 0.010129273
#> 2 1 0.004553202
#> 3 4 0.000000000
#> 4 2 0.007863642
#> 5 0 0.000000000
All cluster-specific GIs are close to 0 and hence also
WGI is close to 0. This indicates nearly perfect mapping of
cell lines to bubbles. This analysis performed for different values of
r with function
get_gini_r
, which takes as main input the output of
get_k
or get_r
From the figure we can conclude that WGI drops to 0 at
k=5
, and all labels are nearly perfectly split across the
bubbles with each bubble containing cells exclusively from one cell
type.
We can also “attach” numeric cell features to the bubbletree. We will “attach” the expression of five marker genes, i.e. one marker gene for each of the five cancer cell lines.
We can visualize numeric features in two ways.
First, we can show numeric feature aggregates (e.g. “mean”, “median”,
“sum”, “pct nonzero” or “pct zero”) in the different bubbles with
get_num_tiles
w3 <- get_num_tiles(btd = l,
fs = e,
summary_function = "mean",
x_axis_name = 'Gene expression',
rotate_x_axis_labels = TRUE,
round_digits = 1,
tile_text_size = 2.75)
(l$tree|w3$plot)+patchwork::plot_layout(widths = c(1, 1))
Second, we can visualize the distributions of the numeric cell
features in each bubble as violins with get_num_violins
What is the percent of UMIs coming from mitochondrial genes in each bubble?
w_mt_dist <- get_num_violins(btd = l,
fs = 1-m$non_mt_percent,
x_axis_name = 'MT [%]',
rotate_x_axis_labels = TRUE)
w_umi_dist <- get_num_violins(btd = l,
fs = m$nCount_RNA/1000,
x_axis_name = 'RNA count (in thousands)',
rotate_x_axis_labels = TRUE)
w_gene_dist <- get_num_violins(btd = l,
fs = m$nFeature_RNA,
x_axis_name = 'Gene count',
rotate_x_axis_labels = TRUE)
(l$tree|w_mt_dist$plot|w_umi_dist$plot|w_gene_dist$plot)+
patchwork::plot_layout(widths = c(1, 1, 1, 1))+
patchwork::plot_annotation(tag_levels = 'A')
Numerous approaches exist for clustering of scRNA-seq data, and
scBubbletree
implements the function get_bubbletree_dummy
to allow users
to incorporate results from various clustering approaches together with
our workflow. With this function we skip the clustering portion of the
workflow and proceed with computing distances between the clusters and
generation of the bubbletree.
Lets try get_bubbletree_dummy
. First, will perform
k-medoids clustering with R-package cluster and
then generate the bubbletree:
pam_k5 <- cluster::pam(x = A, k = 5, metric = "euclidean")
dummy_k5_pam <- get_bubbletree_dummy(x = A,
cs = pam_k5$clustering,
B = 200,
N_eff = 200,
cores = 2,
round_digits = 1)
dummy_k5_pam$tree|
get_cat_tiles(btd = dummy_k5_pam,
f = m$cell_line_demuxlet,
integrate_vertical = TRUE,
round_digits = 1,
tile_text_size = 2.75,
x_axis_name = 'Cell line',
rotate_x_axis_labels = TRUE)$plot
# e.g. matrix from CellChat
cc_mat <- matrix(data = runif(n = 25, min = 0, max = 0.5),
nrow = 5, ncol = 5)
colnames(cc_mat) <- 0:4
rownames(cc_mat) <- 0:4
diag(cc_mat) <- 1
cc <- reshape2::melt(cc_mat)
cc$Var1 <- factor(x = cc$Var1, levels = rev(l$tree_meta$label))
cc$Var2 <- factor(x = cc$Var2, levels = rev(l$tree_meta$label))
colnames(cc) <- c("x", "y", "cc")
g_cc <- ggplot()+
geom_tile(data = cc, aes(x = x, y = y, fill = cc), col = "white")+
scale_fill_distiller(palette = "Spectral")+
xlab(label = "Cluster x")+
ylab(label = "Cluster y")
g_cell_feature_1 <- get_num_cell_tiles(btd = l,
f = e[,1],
tile_bw = FALSE,
x_axis_name = "ALDH1A1 gene expr.",
rotate_x_axis_labels = FALSE)
g_cell_feature_2 <- get_num_cell_tiles(btd = l,
f = e[,2],
tile_bw = FALSE,
x_axis_name = "SLPI gene expr.",
rotate_x_axis_labels = FALSE)
l$tree|g_cell_feature_1$plot|g_cell_feature_2$plot
scBubbletree promotes simple and transparent visual exploration of scRNA-seq. It is not a black-box approach and the user is encouraged to explore the data with different values of k and r or custom clustering solutions. Attaching of cell features to the bubbletree is necessary for biological interpretation of the individual bubbles and their relationships which are described by the bubbletree.
#> R version 4.4.2 (2024-10-31)
#> Platform: x86_64-pc-linux-gnu
#> Running under: Ubuntu 24.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.26.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=C
#> [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] stats graphics grDevices utils datasets methods base
#>
#> other attached packages:
#> [1] patchwork_1.3.0 ggtree_3.15.0 ggplot2_3.5.1 scBubbletree_1.9.0
#> [5] BiocStyle_2.35.0
#>
#> loaded via a namespace (and not attached):
#> [1] RColorBrewer_1.1-3 sys_3.4.3 jsonlite_1.8.9
#> [4] magrittr_2.0.3 spatstat.utils_3.1-1 farver_2.1.2
#> [7] rmarkdown_2.29 fs_1.6.5 vctrs_0.6.5
#> [10] ROCR_1.0-11 spatstat.explore_3.3-3 htmltools_0.5.8.1
#> [13] gridGraphics_0.5-1 sass_0.4.9 sctransform_0.4.1
#> [16] parallelly_1.39.0 KernSmooth_2.23-24 bslib_0.8.0
#> [19] htmlwidgets_1.6.4 ica_1.0-3 plyr_1.8.9
#> [22] plotly_4.10.4 zoo_1.8-12 cachem_1.1.0
#> [25] buildtools_1.0.0 igraph_2.1.1 mime_0.12
#> [28] lifecycle_1.0.4 pkgconfig_2.0.3 Matrix_1.7-1
#> [31] R6_2.5.1 fastmap_1.2.0 fitdistrplus_1.2-1
#> [34] future_1.34.0 shiny_1.9.1 digest_0.6.37
#> [37] aplot_0.2.3 colorspace_2.1-1 Seurat_5.1.0
#> [40] tensor_1.5 RSpectra_0.16-2 irlba_2.3.5.1
#> [43] labeling_0.4.3 progressr_0.15.1 fansi_1.0.6
#> [46] spatstat.sparse_3.1-0 httr_1.4.7 polyclip_1.10-7
#> [49] abind_1.4-8 compiler_4.4.2 proxy_0.4-27
#> [52] withr_3.0.2 BiocParallel_1.41.0 fastDummies_1.7.4
#> [55] MASS_7.3-61 tools_4.4.2 lmtest_0.9-40
#> [58] ape_5.8 httpuv_1.6.15 future.apply_1.11.3
#> [61] goftest_1.2-3 glue_1.8.0 nlme_3.1-166
#> [64] promises_1.3.2 grid_4.4.2 Rtsne_0.17
#> [67] cluster_2.1.6 reshape2_1.4.4 generics_0.1.3
#> [70] gtable_0.3.6 spatstat.data_3.1-4 tidyr_1.3.1
#> [73] data.table_1.16.2 sp_2.1-4 utf8_1.2.4
#> [76] spatstat.geom_3.3-4 RcppAnnoy_0.0.22 ggrepel_0.9.6
#> [79] RANN_2.6.2 pillar_1.9.0 stringr_1.5.1
#> [82] yulab.utils_0.1.8 spam_2.11-0 RcppHNSW_0.6.0
#> [85] later_1.4.1 splines_4.4.2 dplyr_1.1.4
#> [88] treeio_1.31.0 lattice_0.22-6 survival_3.7-0
#> [91] deldir_2.0-4 tidyselect_1.2.1 maketools_1.3.1
#> [94] miniUI_0.1.1.1 pbapply_1.7-2 knitr_1.49
#> [97] gridExtra_2.3 scattermore_1.2 xfun_0.49
#> [100] matrixStats_1.4.1 stringi_1.8.4 lazyeval_0.2.2
#> [103] ggfun_0.1.7 yaml_2.3.10 evaluate_1.0.1
#> [106] codetools_0.2-20 tibble_3.2.1 BiocManager_1.30.25
#> [109] ggplotify_0.1.2 cli_3.6.3 uwot_0.2.2
#> [112] xtable_1.8-4 reticulate_1.40.0 munsell_0.5.1
#> [115] jquerylib_0.1.4 Rcpp_1.0.13-1 globals_0.16.3
#> [118] spatstat.random_3.3-2 png_0.1-8 spatstat.univar_3.1-1
#> [121] parallel_4.4.2 dotCall64_1.2 listenv_0.9.1
#> [124] viridisLite_0.4.2 tidytree_0.4.6 scales_1.3.0
#> [127] ggridges_0.5.6 SeuratObject_5.0.2 leiden_0.4.3.1
#> [130] purrr_1.0.2 rlang_1.1.4 cowplot_1.1.3