When we have data for multiple conditions (e.g. control vs treatment, healthy vs diseased, wild-type vs mutant, etc.) and multiple biological replicates for each of them, we can investigate which genes are differentially expressed between conditions. This is similar to what is done in more conventional bulk RNA-seq analysis, but in this case we have the added benefit of being able to do our analysis focusing on particular cell clusters/types (which would not be possible to do in a bulk experiment, unless we had done cell-sorting before the sequencing).
Differential analyses of multi-condition scRNA-seq experiments can be broadly split into two categories:
In this section we will cover the first of these, i.e. how to do a differential expression analysis for a simple design with a single factor (variable) with two levels (conditions). In our case this corresponds to the comparison between cells from healthy (PBMMC) versus leukemia (ETV6-RUNX1) donors.
The standard method for doing this in Seurat is to reuse
the FindMarkers function, which we used earlier to find
cluster markers genes. However, instead of comparing clusters, they
compare conditions by inputing the groups to be compared into the
ident.1 and ident.2 arguments.
The main problem with this approach is that it treats cells as replicates and ignores the fact that they are nested within samples (i.e. donors). The pseudoreplicates are not independent from one another and so this approach can lead to an inflated false positive rate (i.e. it will identify many genes as differentially expressed when they are not). Sometimes this is called pvalue inflation.
In order to avoid this problem, we can use a pseudo-bulk approach, which consists of aggregating the counts for all cells of the same type (e.g. cluster) and sample (e.g. donor) into a single ‘pseudo-bulk’ sample, and then running a bulk RNA-seq differential expression analysis on these pseudo-bulk samples. The methods designed for bulk RNA-seq data are appropriate for this type of analysis and we also avoid the previously discussed issue of sparsity of scRNA-seq data, since the pseudo-bulk samples will have much higher counts than individual cells.
There are three main steps for a bulk analysis:
We start by loading our packages and the single-cell object that will be used. We will focus our analysis on the set of PBMMC (healthy) and ETV6-RUNX1 (leukemia) samples. These data are the full dataset (rather than those downsamled to 500 cells per sample as we have used previously) and have gone through QC, normalised, batch-corrected and clustered as shown in the previous sections. We have clustered the cells using the Leiden algorithm with k=48, and did some basic cell type annotation based on known markers of blood/immune cells.
library(Seurat)
library(ggplot2)
library(tidyverse)
library(DESeq2)
# Load the data
seurat_object <- readRDS("RObjects/Annotated.full.ETV6.PBMMC.rds")
# plot umap
DimPlot(seurat_object,
reduction = "umap",
group.by = "Idents",
label = TRUE,
label.size = 5) +
NoLegend()
Pseudo-bulk samples consist of summing the counts across all the cells for a given sample and cell Idents combination. It is up to you to decide what the cell labels should consist of. In our case, we’re using the Leiden clusters, which were further manually annotated according to cell types. But you may have defined these labels, for example, based on a more systematic cell type annotation using public atlases or reference gene panels (see Chapter 7 - Cell type annotation in the OSCA book).
To revise, here is how our sample + label combinations look like:
# tabulate number of cells per label + sample combination
table(seurat_object$Idents, seurat_object$SampleName)
##
## ETV6RUNX1-1 ETV6RUNX1-2 ETV6RUNX1-3 ETV6RUNX1-4 PBMMC-1
## B (c1) 6 4380 70 16 240
## B (c12) 6 76 1580 84 0
## B (c16) 3 105 7 850 1
## B (c22) 0 2 0 1 8
## B (c23) 0 23 8 5 4
## B (c3) 2183 14 3 5 28
## B (c4) 7 43 81 1438 44
## B (c5) 115 327 124 910 60
## B (c6) 316 495 61 264 70
## B (c7) 3 464 75 12 0
## CD20+ B (c10) 7 113 464 141 56
## Erythrocytes (c15) 0 5 80 909 0
## Erythrocytes (c17) 0 0 3 6 0
## Erythrocytes (c8) 4 17 259 898 49
## Monocytes (c14) 5 6 3 9 95
## Monocytes (c21) 1 3 15 36 17
## NK T (c11) 15 83 337 115 34
## T (c13) 7 41 49 28 0
## T (c18) 0 9 10 2 0
## T (c19) 11 61 24 18 1
## T (c2) 10 167 1701 228 104
## T (c20) 0 0 0 0 24
## Unknown (c9) 16 99 58 16 0
##
## PBMMC-2 PBMMC-3
## B (c1) 175 596
## B (c12) 16 67
## B (c16) 8 14
## B (c22) 41 28
## B (c23) 49 29
## B (c3) 76 71
## B (c4) 65 181
## B (c5) 26 161
## B (c6) 106 177
## B (c7) 2 9
## CD20+ B (c10) 448 709
## Erythrocytes (c15) 25 3
## Erythrocytes (c17) 935 41
## Erythrocytes (c8) 941 218
## Monocytes (c14) 273 420
## Monocytes (c21) 80 157
## NK T (c11) 433 314
## T (c13) 1 5
## T (c18) 1 1
## T (c19) 61 41
## T (c2) 999 750
## T (c20) 97 227
## Unknown (c9) 4 0
We can see that the contribution of each sample to the different labels (clusters) is quite unbalanced for some of them. This may be biologically correct, or it may prompt us to revise our batch correction step. For now, we will proceed with these data as they are.
We use Idents and SampleName as the
variables to aggregate by. We therefore will have a maximum of 7x23
(161) pseudo-samples. We will have less than this because, as we saw,
there are some clusters with no cells from a particular sample.
# Aggregate counts by sample and celltype
pseudo_bulk <- AggregateExpression(seurat_object,
group.by = c("Idents", "SampleName"),
assays = "RNA",
return.seurat = TRUE)
# check the new assay
pseudo_bulk
## An object of class Seurat
## 33869 features across 142 samples within 1 assay
## Active assay: RNA (33869 features, 0 variable features)
## 2 layers present: counts, data
We end up with 142 pseudo-bulk samples, which are the result of aggregating the counts for all cells of the same sample and cluster.
head(Cells(pseudo_bulk))
## [1] "B (c1)_ETV6RUNX1-1" "B (c1)_ETV6RUNX1-2" "B (c1)_ETV6RUNX1-3"
## [4] "B (c1)_ETV6RUNX1-4" "B (c1)_PBMMC-1" "B (c1)_PBMMC-2"
It has named each cell by concatenating the sample name and the cluster name, so it is easy for us to tell which pseudosamples we are dealing with.
We can also have a look at the metadata it has retained.
head(pseudo_bulk@meta.data)
## orig.ident Idents SampleName
## B (c1)_ETV6RUNX1-1 B (c1)_ETV6RUNX1-1 B (c1) ETV6RUNX1-1
## B (c1)_ETV6RUNX1-2 B (c1)_ETV6RUNX1-2 B (c1) ETV6RUNX1-2
## B (c1)_ETV6RUNX1-3 B (c1)_ETV6RUNX1-3 B (c1) ETV6RUNX1-3
## B (c1)_ETV6RUNX1-4 B (c1)_ETV6RUNX1-4 B (c1) ETV6RUNX1-4
## B (c1)_PBMMC-1 B (c1)_PBMMC-1 B (c1) PBMMC-1
## B (c1)_PBMMC-2 B (c1)_PBMMC-2 B (c1) PBMMC-2
We can see that although it has retained the SampleName
and Idents variables, it has not retained the
SampleGroup variable, which is the one we will need for our
differential analysis.
Like we did in the first section, we can add this variable back to
the metadata of the pseudo-bulk object by using the
SampleName variable to match it to the original
metadata.
# add SampleGroup variable to the metadata of the pseudo-bulk object
temp_metadata <- pseudo_bulk@meta.data %>%
mutate(SampleGroup = str_remove(SampleName, "-.*")) %>%
mutate(Cluster = Idents)
pseudo_bulk@meta.data <- temp_metadata
# check the new metadata
head(pseudo_bulk@meta.data)
## orig.ident Idents SampleName SampleGroup Cluster
## B (c1)_ETV6RUNX1-1 B (c1)_ETV6RUNX1-1 B (c1) ETV6RUNX1-1 ETV6RUNX1 B (c1)
## B (c1)_ETV6RUNX1-2 B (c1)_ETV6RUNX1-2 B (c1) ETV6RUNX1-2 ETV6RUNX1 B (c1)
## B (c1)_ETV6RUNX1-3 B (c1)_ETV6RUNX1-3 B (c1) ETV6RUNX1-3 ETV6RUNX1 B (c1)
## B (c1)_ETV6RUNX1-4 B (c1)_ETV6RUNX1-4 B (c1) ETV6RUNX1-4 ETV6RUNX1 B (c1)
## B (c1)_PBMMC-1 B (c1)_PBMMC-1 B (c1) PBMMC-1 PBMMC B (c1)
## B (c1)_PBMMC-2 B (c1)_PBMMC-2 B (c1) PBMMC-2 PBMMC B (c1)
Before we run the differential expression analysis, it is important to filter out low-count samples, as these can lead to unreliable results. For example if a pseudosample was created from only 5 cells and another pseudosample was created from 1000 cells, the counts in the first pseudosample will be much lower than those in the second pseudosample, and this can lead to a bias in the differential expression analysis. Looking back at our table of the number of cells per sample and cluster combination, we can see that there are some combinations with very few cells (e.g. cluster 7 has only 3 cell from sample ETV6RUNX1-1). We will filter out any pseudosample that was created from less than 20 cells.
# filter out pseudosamples with less than 20 cells
# determine which pseudosamples have at least than 20 cells
ps_keep <- table(seurat_object$Idents, seurat_object$SampleName) %>%
as.data.frame() %>%
filter(Freq >= 20) %>%
mutate(PseudoSample = str_c(Var1, "_", Var2)) %>%
pull(PseudoSample)
# subset our pseudo-bulk object to keep only the pseudosamples with at least 20 cells
pseudo_bulk <- subset(pseudo_bulk, cells = ps_keep)
We should also filter out the very lowly detected genes as they can appear more significant that they are due to the low counts and the fact that they are more likely to be detected in one group than the other just by chance. We will filter out any rows in our counts matrix that have less than 5 counts across all pseudosamples.
# filter out genes with less than 5 counts across all pseudosamples
# determine which genes have at least 5 counts across all pseudosamples
genes_keep <- rowSums(pseudo_bulk@assays$RNA$counts) >= 5
# subset our pseudo-bulk object to keep only the genes with at least 5 counts across
pseudo_bulk <- subset(pseudo_bulk, features = names(genes_keep)[genes_keep])
Now that we have our pseudobulk samples we can proceed with our
analyis. We could use standard bulk RNA-seq R/Bioconductor packages,
such as edgeR, DESeq2 or limma.
However the FindMarkers() function in Seurat
also supports testing for differential expression using the pseudobulk
samples, and it is a convenient way to do this without having to switch
to a different package. Instead of using the Wilcoxon rank sum test,
which is the default method for testing differential expression in
FindMarkers(), we can specify
test.use = "DESeq2" to use the DESeq2 method for testing
differential expression on the pseudobulk samples.
For this to work we need the Idents of our pseudo-bulk
object to be set to the variable that contains the sample group
information (i.e. SampleGroup), so that
FindMarkers() knows which groups to compare. We can do this
by using the Idents() function to set the
Idents of our pseudo-bulk object to the
SampleGroup variable in the metadata.
Idents(pseudo_bulk) <- pseudo_bulk$SampleGroup
# Run DESeq2 on pseudobulk samples
de <- FindMarkers(pseudo_bulk,
ident.1 = "ETV6RUNX1",
ident.2 = "PBMMC",
test.use = "DESeq2")
## converting counts to integer mode
## gene-wise dispersion estimates
## mean-dispersion relationship
## final dispersion estimates
head(de)
## p_val avg_log2FC pct.1 pct.2 p_val_adj
## LINC03000 3.738789e-111 6.936124 0.977 0.511 1.044244e-106
## ENSG00000254951 3.174132e-80 5.949381 0.977 0.267 8.865350e-76
## TERF2 1.260590e-77 4.425474 1.000 0.956 3.520828e-73
## ENSG00000286393 1.088189e-76 5.003968 0.977 0.044 3.039312e-72
## KCNN1 1.527405e-76 5.263270 0.977 0.556 4.266042e-72
## ENSG00000285534 1.600099e-76 6.271876 1.000 0.444 4.469077e-72
The results output we get from this analysis is a summarised version of what we would get had we run the same analysis directly with DESeq2. The columns are as follows:
p_val: p-value of the testavg_log2FC: average log2 fold change between the two
groupspct.1: percentage of pseudobulk samples in group 1 (ETV
RUNX1) that have non-zero counts for the genepct.2: percentage of pseudobulk samples in group 2
(PBMMC ) that have non-zero counts for the genep_val_adj: adjusted p-value for multiple testingThe default statistical test in DeSeq2 is the Wald test, which tests whether the log2 fold change between the two groups is significantly different from zero and the default multiplicity testing correction is a Benjamini-Hochberg correction, which is a false discovery rate (FDR).
This DE analysis is the same as doing a bulk experiment but what we really want to know is how the different sample groups (ETV6-RUNX1 vs PBMMC) differ in terms of their gene expression within each cluster (or celltype). We should also bear in mind that due to the pseudosamples we have after filtering, not all comparisons will be possible for all clusters. For example, cluster 7 has 2 pseudosamples from the ETV6-RUNX1 group and no pseudosamples from the PBMMC group, so we cannot do a DE analysis for this cluster.
In order to do this we need to add more detail to the
Idents of our pseudo-bulk object, so that it contains both
the sample group and the cluster information. We can do this by
concatenating the SampleGroup and Cluster
variables in the metadata of our pseudo-bulk object.
# add cluster information to the Idents of our pseudo-bulk object
pseudo_bulk$Cluster_SampleGroup <- str_c(pseudo_bulk$Cluster,
pseudo_bulk$SampleGroup, sep = "_")
Idents(pseudo_bulk) <- pseudo_bulk$Cluster_SampleGroup
Now lets try to look for differentially expressed genes between the ETV6RUNX1 and PBMMC groups within our B cell cluster 6.
The simplicity of the FindMarkers function means it is
very easy to run but as with any differential expression analysis, it is
important to carefully consider the design of the experiment and the
assumptions of the statistical test being used. There are some plots we
can draw to reassure ourselves that the assumptions of the test are
being met, for example we can draw a PCA plot of our pseudo-bulk samples
to see if they cluster by sample group and if there are any outliers
that may be driving the results.
# PCA plot of pseudo-bulk samples
c6 <- subset(pseudo_bulk, subset = Cluster == "B (c6)")
c6_counts <- GetAssayData(c6, assay = "RNA", layer = "counts")
pca <- prcomp(t(c6_counts))
pca_df <- data.frame(pca$x, SampleGroup = c6$SampleGroup)
ggplot(pca_df, aes(x = PC1, y = PC2, color = SampleGroup)) +
geom_point() +
theme_classic()
We can run the comparision by specifying the ident.1 and
ident.2 arguments in the FindMarkers()
function to be the corresponding cluster and sample group
combinations.
# Run DESeq2 on pseudobulk samples for cluster 6
de_cluster6 <- FindMarkers(pseudo_bulk,
ident.1 = "B (c6)_ETV6RUNX1",
ident.2 = "B (c6)_PBMMC",
test.use = "DESeq2")
## converting counts to integer mode
## gene-wise dispersion estimates
## mean-dispersion relationship
## final dispersion estimates
head(de_cluster6)
## p_val avg_log2FC pct.1 pct.2 p_val_adj
## ACSM3 6.300880e-59 -4.080780 0.75 1 1.759836e-54
## MDK 2.159060e-48 3.940974 1.00 1 6.030253e-44
## USP9Y 4.561823e-43 -2.404333 1.00 1 1.274117e-38
## AFF3 3.331994e-42 -3.710056 1.00 1 9.306259e-38
## ENSG00000229839 1.551937e-37 3.198410 1.00 1 4.334561e-33
## ENSG00000273118 2.264600e-37 -2.261956 0.75 1 6.325027e-33
The output dataframe has all the information for all genes tested.
We can draw a MA plot to see if the variance of our data is consistent with the assumptions of the DESeq2 method. An MA plot should have a “trumpet” like shape.
# MA plot of DE results for cluster 6
# calculate mean expression for each gene
mean_expression <- rowMeans(c6_counts)
# add mean expression to DE results
de_cluster6$mean_expression <- mean_expression[rownames(de_cluster6)]
# plot MA plot
ggplot(de_cluster6, aes(x = log2(mean_expression), y = avg_log2FC)) +
geom_point() +
theme_classic() +
xlab("Log2 mean expression") +
ylab("Log2 fold change") +
ggtitle("MA plot for cluster 6")
We can also check the pvalue histogram to get an idea of how the dataset behaved in the analysis. If the p-value histogram is uniform (i.e. flat) it suggests that there are no differentially expressed genes between the two groups, while if there is a peak near zero it suggests that there are many differentially expressed genes between the two groups. This is a useful blog article which goes into more detail.
http://varianceexplained.org/statistics/interpreting-pvalue-histogram/
# p-value histogram for DE results for cluster 6
hist(de_cluster6$p_val, breaks = 50,
main = "P-value histogram for cluster 6",
xlab = "P-value")
The extra distrete peak/s we see are indicative of sparse data, which is common in scRNA-seq data. Since we have already pseudobulked this is like due to not having harsh enough filters on the number of cells per pseudosample or the number of counts per gene.
If we are satisfied with these plots we can have a look at our
differentially expressed genes. The avg_log2FC column gives
us the log2 fold change between the two groups, so a value of 1 means
that the gene is expressed 2 times higher in the ETV6-RUNX1 group
compared to the PBMMC group, while a value of -1 means that the gene is
expressed 2 times lower in the ETV6-RUNX1 group compared to the PBMMC
group. The p_val_adj column gives us the adjusted p-value
for multiple testing, so we can use this to determine which genes are
significantly differentially expressed between the two groups.
We can filter this dataframe to keep only the genes that are significantly differentially expressed between the two groups, for example by keeping only the genes with an adjusted p-value less than 0.05 and an absolute log2 fold change greater than 1.
# filter the DE results for cluster 6
de_cluster6_sig <- de_cluster6 %>%
filter(p_val_adj < 0.05 & abs(avg_log2FC) >1)
dim(de_cluster6_sig)
## [1] 429 6
This gives us 429 significantly differentially expressed genes between the ETV6-RUNX1 and PBMMC groups within the B cell cluster 6 with a log fold change greater than 1.
We can draw some expression plots of the one of the top differentially expressed genes to see how it differs across the clusters.
# expression plots for one of the top differentially expressed genes
top_gene <- "MDK"
# counts for the top gene
top_gene_counts <- GetAssayData(pseudo_bulk,
assay = "RNA",
layer = "counts")[top_gene, ]
# plot expression of the top gene across the clusters and sample groups
top_gene_df <- data.frame(Expression = top_gene_counts,
Cluster = pseudo_bulk$Cluster,
SampleGroup = pseudo_bulk$SampleGroup,
SampleName = pseudo_bulk$SampleName)
ggplot(top_gene_df, aes(x = SampleName, y = Expression, color = SampleGroup
)) +
geom_point() +
xlab("Cluster") +
ylab("Expression of MDK") +
ggtitle("Expression of MDK across clusters and sample groups") +
facet_wrap(~ Cluster) +
theme(axis.text.x = element_text(angle = 45, hjust = 1))
We can also see the expression of this gene on the UMAP plot to see how it differs across the sample groups in the single cell level data.
FeaturePlot(seurat_object, features = top_gene, split.by = "SampleGroup")
Repeat the differential expression analysis for the CD20+ B cell cluster and see how many significantly differentially expressed genes you find between the ETV6-RUNX1 and PBMMC groups with a log fold change greater than 1. Plot one of the differentially expressed gene across the clusters and sample groups to see how it differs across the clusters.
# Run DESeq2 on pseudobulk samples for cluster 10
de_cluster10 <- FindMarkers(pseudo_bulk,
ident.1 = "CD20+ B (c10)_ETV6RUNX1",
ident.2 = "CD20+ B (c10)_PBMMC",
test.use = "DESeq2")
## converting counts to integer mode
## gene-wise dispersion estimates
## mean-dispersion relationship
## final dispersion estimates
# filter the DE results for cluster 10
de_cluster10_sig <- de_cluster10 %>%
filter(p_val_adj < 0.05 & abs(avg_log2FC) >1)
dim(de_cluster10_sig)
## [1] 13 5
# plot the top differentially expressed gene for cluster 10
top_gene <- rownames(de_cluster10_sig)[1]
top_gene_counts <- GetAssayData(pseudo_bulk,
assay = "RNA",
layer = "counts")[top_gene, ]
# plot expression of the top gene across the clusters and sample groups
top_gene_df <- data.frame(Expression = top_gene_counts,
Cluster = pseudo_bulk$Cluster,
SampleGroup = pseudo_bulk$SampleGroup,
SampleName = pseudo_bulk$SampleName)
ggplot(top_gene_df, aes(x = SampleName, y = Expression, color = SampleGroup
)) +
geom_point() +
xlab("Cluster") +
ylab("Expression of SOCS2") +
ggtitle("Expression of SOCS2 across clusters and sample groups") +
facet_wrap(~ Cluster) +
theme(axis.text.x = element_text(angle = 45, hjust = 1))
Although the changes in the 13 genes we have filtered are significant in our cluster, they are changing more in other clusters. For example, the top differentially expressed gene in cluster 10 is SOCS2, which is also significantly differentially expressed in cluster 6 but with a higher log fold change. This is not unsurprising as cluster 6 and cluster 10 are both B cell clusters, so we would expect them to share some of the same differentially expressed genes between the two groups.
Seurat’s FindMarkers() function also supports other
methods for testing differential expression, which can be specified
using the test.use parameter:
“wilcox” : Wilcoxon rank sum test (default, using ‘presto’ package) “wilcox_limma” : Wilcoxon rank sum test (using ‘limma’ package) “bimod” : Likelihood-ratio test for single cell feature expression, (McDavid et al., Bioinformatics, 2013) “roc” : Standard AUC classifier “t” : Student’s t-test “poisson” : Likelihood ratio test assuming an underlying negative binomial distribution. Use only for UMI-based datasets “negbinom” : Likelihood ratio test assuming an underlying negative binomial distribution. Use only for UMI-based datasets “LR” : Uses a logistic regression framework to determine differentially expressed genes. Constructs a logistic regression model predicting group membership based on each feature individually and compares this to a null model with a likelihood ratio test. “MAST” : GLM-framework that treates cellular detection rate as a covariate (Finak et al, Genome Biology, 2015) (Installation instructions) “DESeq2” : DE based on a model using the negative binomial distribution (Love et al, Genome Biology, 2014) (Installation instructions) For MAST and DESeq2, please ensure that these packages are installed separately in order to use them as part of Seurat. Once installed, use the test.use parameter can be used to specify which DE test to use.
sessionInfo()
## R version 4.5.2 (2025-10-31)
## Platform: x86_64-apple-darwin20
## Running under: macOS Sequoia 15.7.4
##
## Matrix products: default
## BLAS: /Library/Frameworks/R.framework/Versions/4.5-x86_64/Resources/lib/libRblas.0.dylib
## LAPACK: /Library/Frameworks/R.framework/Versions/4.5-x86_64/Resources/lib/libRlapack.dylib; LAPACK version 3.12.1
##
## locale:
## [1] en_US.UTF-8/en_US.UTF-8/en_US.UTF-8/C/en_US.UTF-8/en_US.UTF-8
##
## time zone: Europe/London
## tzcode source: internal
##
## attached base packages:
## [1] stats4 stats graphics grDevices utils datasets methods
## [8] base
##
## other attached packages:
## [1] DESeq2_1.50.2 SummarizedExperiment_1.40.0
## [3] Biobase_2.70.0 MatrixGenerics_1.22.0
## [5] matrixStats_1.5.0 GenomicRanges_1.62.1
## [7] Seqinfo_1.0.0 IRanges_2.44.0
## [9] S4Vectors_0.48.0 BiocGenerics_0.56.0
## [11] generics_0.1.4 lubridate_1.9.5
## [13] forcats_1.0.1 stringr_1.6.0
## [15] dplyr_1.2.0 purrr_1.2.1
## [17] readr_2.1.6 tidyr_1.3.2
## [19] tibble_3.3.1 tidyverse_2.0.0
## [21] ggplot2_4.0.2 Seurat_5.4.0
## [23] SeuratObject_5.3.0 sp_2.2-1
##
## loaded via a namespace (and not attached):
## [1] RColorBrewer_1.1-3 rstudioapi_0.18.0 jsonlite_2.0.0
## [4] magrittr_2.0.4 spatstat.utils_3.2-1 farver_2.1.2
## [7] rmarkdown_2.30 vctrs_0.7.1 ROCR_1.0-12
## [10] spatstat.explore_3.7-0 S4Arrays_1.10.1 htmltools_0.5.9
## [13] SparseArray_1.10.8 sass_0.4.10 sctransform_0.4.3
## [16] parallelly_1.46.1 KernSmooth_2.23-26 bslib_0.10.0
## [19] htmlwidgets_1.6.4 ica_1.0-3 plyr_1.8.9
## [22] plotly_4.12.0 zoo_1.8-15 cachem_1.1.0
## [25] igraph_2.2.2 mime_0.13 lifecycle_1.0.5
## [28] pkgconfig_2.0.3 Matrix_1.7-4 R6_2.6.1
## [31] fastmap_1.2.0 fitdistrplus_1.2-6 future_1.69.0
## [34] shiny_1.12.1 digest_0.6.39 patchwork_1.3.2
## [37] tensor_1.5.1 RSpectra_0.16-2 irlba_2.3.7
## [40] labeling_0.4.3 progressr_0.18.0 spatstat.sparse_3.1-0
## [43] timechange_0.4.0 httr_1.4.8 polyclip_1.10-7
## [46] abind_1.4-8 compiler_4.5.2 withr_3.0.2
## [49] S7_0.2.1 BiocParallel_1.44.0 fastDummies_1.7.5
## [52] MASS_7.3-65 DelayedArray_0.36.0 tools_4.5.2
## [55] lmtest_0.9-40 otel_0.2.0 httpuv_1.6.16
## [58] future.apply_1.20.1 goftest_1.2-3 glue_1.8.0
## [61] nlme_3.1-168 promises_1.5.0 grid_4.5.2
## [64] Rtsne_0.17 cluster_2.1.8.2 reshape2_1.4.5
## [67] gtable_0.3.6 spatstat.data_3.1-9 tzdb_0.5.0
## [70] data.table_1.18.2.1 hms_1.1.4 XVector_0.50.0
## [73] spatstat.geom_3.7-0 RcppAnnoy_0.0.23 ggrepel_0.9.6
## [76] RANN_2.6.2 pillar_1.11.1 spam_2.11-3
## [79] RcppHNSW_0.6.0 later_1.4.6 splines_4.5.2
## [82] lattice_0.22-9 survival_3.8-6 deldir_2.0-4
## [85] tidyselect_1.2.1 locfit_1.5-9.12 miniUI_0.1.2
## [88] pbapply_1.7-4 knitr_1.51 gridExtra_2.3
## [91] scattermore_1.2 xfun_0.56 stringi_1.8.7
## [94] lazyeval_0.2.2 yaml_2.3.12 evaluate_1.0.5
## [97] codetools_0.2-20 cli_3.6.5 uwot_0.2.4
## [100] xtable_1.8-4 reticulate_1.45.0 jquerylib_0.1.4
## [103] dichromat_2.0-0.1 Rcpp_1.1.1 globals_0.19.0
## [106] spatstat.random_3.4-4 png_0.1-8 spatstat.univar_3.1-6
## [109] parallel_4.5.2 dotCall64_1.2 listenv_0.10.0
## [112] viridisLite_0.4.3 scales_1.4.0 ggridges_0.5.7
## [115] rlang_1.1.7 cowplot_1.2.0