单细胞转录组 / Seurat 与 Scanpy

单细胞分析流程:Seurat v5 与 Scanpy 从质控到细胞注释

本页按 Seurat 5.5、SeuratObject 5.4 和 Scanpy 1.12 写成,R 与 Python 两条路线的代码都在 10x PBMC 3k 上跑通。重点是官方教程没有展开的判断:阈值怎么定,旧教程在 v5 上哪里会报错,整合方法和聚类分辨率怎么选,注释结果怎么核对,内存不够时怎么办。

直接答案

单细胞分析的标准流程是:读入 Cell Ranger 的 filtered 矩阵;按样本跑双细胞检测;用 MAD(中位数绝对偏差)定质控阈值,线粒体比例用 3 MAD 并设固定下限(人常用 10%,小鼠 5%);LogNormalize 后选 2,000 个高变基因、做 PCA;多样本先看未整合结果,需要时用 Harmony、RPCA 或 scVI 整合;用 clustree 和多随机种子的稳定性选分辨率;最后用 marker 基因结合 SingleR、CellTypist 或 Azimuth 注释。Seurat v5 中 slot 参数已失效,要改用 layer;IntegrateLayers 之前要按样本 split,差异分析之前要 JoinLayers。

第 1 步

读 filtered_feature_bc_matrix(或同名 .h5),它已去掉空液滴。raw_feature_bc_matrix 只在做 SoupX、CellBender 时作为输入。

v4 写法在 v5 上的报错与改法(本页实测,SeuratObject 5.4.0)r
# Seurat v5 / SeuratObject 5.4: replacements for v4 code
GetAssayData(obj, slot = "counts")   # Error: The `slot` argument of `GetAssayData()` was deprecated in SeuratObject 5.0.0 and is now defunct.
obj@assays$RNA@counts                # Error: no slot of name "counts" for this object of class "Assay5"

obj[["RNA"]]$counts                  # v5 accessors
LayerData(obj, assay = "RNA", layer = "data")
GetAssayData(obj, layer = "data")    # works only while the assay has one "data" layer

# Old v4 object read into v5: convert the assay class when a tool needs Assay5 (e.g. BPCells, IntegrateLayers)
obj[["RNA"]] <- as(obj[["RNA"]], "Assay5")
# Tool that only accepts v4 assays: convert back (merge layers first)
obj <- JoinLayers(obj); obj[["RNA"]] <- as(obj[["RNA"]], "Assay")

Cell Ranger 7 以后的数据基因数更高

Cell Ranger 7.0 起 count 的 --include-introns 默认为 true,内含子 reads 也计入表达,每个细胞检测到的基因数和 UMI 数整体上升。PBMC 3k 教程的 nFeature < 2500 上限来自 2016 年 Cell Ranger 1.1 生成的数据(本页实测中位基因数 817),照搬到新数据上会切掉正常细胞。阈值要按自己数据的分布重新定。

v5 对象的表达矩阵存在 layers 里

Seurat v5 的 Assay5 把 counts、data、scale.data 存为 layers,多样本 merge 后每个原对象各占一层(例如 counts.1、counts.2)。v4 教程中的 slot 参数和 @counts 写法在 SeuratObject 5.4 上直接报错,见下方代码块中的报错原文。

GetAssayData 只能取单层

assay 中有多个 data 层时,GetAssayData 报 GetAssayData doesn't work for multiple layers in v5 assay,FindMarkers 报 data layers are not joined. Please run JoinLayers。先 JoinLayers 再取矩阵或做差异分析。

基因名带下划线会被改写

CreateSeuratObject 把基因名中的下划线替换成短横线,并给出 Feature names cannot have underscores 警告。用基因名去匹配外部表格(例如 marker 列表、GTF 注释)时,先做同样的替换。

第 2 步

质控剔除三类细胞:几乎没测到东西的(空液滴或破损细胞),基因数和 UMI 异常高的(多为双细胞),线粒体比例高的(细胞膜破损、胞质 mRNA 流失)。阈值按每个样本的分布单独计算。

本页实测:macOS arm64(8 核、16 GB),Scanpy 1.12.4 / anndata 0.13.4 / Python 3.12,Seurat 5.5.1 / SeuratObject 5.4.0 / R 4.5,10x PBMC 3k(2,700 个细胞)。测试时同一台机器还在运行其他任务,耗时只用来看量级。

PBMC 3k 的线粒体比例中位数为 2.03%,第 95 百分位为 4.01%,第 99 百分位为 5.88%。教程固定阈值(200 < 基因数 < 2500 且线粒体 < 5%)保留 2,638 个细胞。MAD 法(UMI、基因数、前 20 基因占比各 5 MAD,线粒体 3 MAD 且 > 8%)剔除 104 个、保留 2,596 个;其中基因数的 MAD 区间是 366 到 1,820,UMI 是 708 到 6,811。

同一数据上线粒体 3 MAD 的上界只有 3.65%。去掉 8% 下限、只按 3 MAD 过滤时,被剔除的细胞从 104 个增加到 276 个,多出来的大部分是线粒体比例在 3.7%–5% 之间的正常 PBMC。这就是 MAD 法需要固定下限的原因。

MAD 法同样会删掉 RNA 含量低的真实细胞群。PBMC 3k 中 PPBP 高表达的血小板样细胞有 19 个,中位基因数只有 397,MAD 法剔除了其中 10 个,剩下的不足以单独成群,Scanpy 流程最终没有血小板群;固定阈值流程保留了这个 13 个细胞的群。关心血小板、红细胞前体、中性粒细胞等低 RNA 细胞时,先看被剔除细胞的 marker,再决定阈值。

多样本数据要逐个样本计算 MAD。把样本合并后统一算,深度高的样本会把深度低样本的正常细胞判成离群值。

指标推荐判据依据注意
UMI 数(nCount / total_counts)log1p 后中位数 ±5 MADsc-best-practices 质控章节5 MAD 是宽松阈值,目的是不误删小群细胞
基因数(nFeature / n_genes_by_counts)log1p 后中位数 ±5 MAD同上上限不能替代双细胞检测
前 20 个基因的计数占比中位数 ±5 MAD同上占比过高说明文库复杂度低
线粒体比例中位数 +3 MAD,且同时高于固定下限sc-best-practices 用 8%;Osorio 与 Cai 2021 建议人 10%、小鼠 5%只用 3 MAD 时,线粒体本来就低的数据会被过度过滤
单核测序(snRNA-seq)线粒体比例通常远低于单细胞,可设 1%–5% 作为上限细胞核中没有线粒体,线粒体 reads 来自胞质污染高线粒体核群可能是污染,不一定是低质量细胞
Osorio 与 Cai 2021(Bioinformatics)分析了 PanglaoDB 中 1,349 个数据集、553 万个细胞:小鼠 121 种组织中只有全肾、全心和远端小肠的平均线粒体比例超过 5%;人 44 种组织中有 13 种平均值超过 5%。

第 3 步

双细胞检测按捕获通道(10x 的每个 channel)分别跑,放在严格质控之前;环境 RNA 校正是可选步骤,影响最大的是 marker 和差异分析。

本页实测:同一份 PBMC 3k,scDblFinder(1.24.10)判为双细胞 124 个(4.6%),Scanpy 内置 Scrublet 只判 32 个(1.2%)。按每 1,000 个细胞 0.8% 估算,2,700 个细胞的期望双细胞数约 58 个,两种工具分布在期望值两侧。论文里报告用哪个工具、剔除了多少,并在 UMAP 上检查被判为双细胞的细胞是否集中在两群交界处。

被判为双细胞的细胞可以先标记、不删除,聚类后如果某个小群同时高表达两种谱系的 marker(例如 CD3E 和 CD14)且双细胞分数高,再整群去掉。sc-best-practices 也采用先保留标记的做法。

Janssen 等 2023 年在 Genome Biology 上用基因型混合样本比较了 CellBender、DecontX 和 SoupX:去背景对聚类和细胞分类的改善很小,对 marker 基因检测的改善最明显,CellBender 对背景水平的估计最准确。只做聚类和粗注释时可以跳过这一步;要做差异表达、报告某基因在某群中特异表达时,建议校正。

问题工具输入与时机关键参数何时需要
双细胞scDblFinder(R,Bioconductor)去掉空液滴后的原始 counts;按 samples= 分通道dbr.per1k 默认 0.008(每 1,000 个细胞 0.8%);10x HT 芯片用 0.004每个 10x 样本都做;上样细胞多于 5,000 时双细胞比例超过 4%
双细胞DoubletFinder(R)单个样本、已去除低质量群的 Seurat 对象pK 用 BCmvn 扫描;nExp 按上样密度估计并扣除同型双细胞习惯 Seurat 时;不要在合并或整合后的对象上跑
双细胞Scrublet(Scanpy 内置 sc.pp.scrublet)原始 counts;多样本时 batch_key="sample"expected_doublet_rate 默认 0.05Python 路线;自动阈值偏保守,建议看分数分布直方图
环境 RNASoupX(R)raw 与 filtered 矩阵 + 粗聚类结果autoEstCont 自动估计污染比例血红蛋白、免疫球蛋白等高表达基因出现在不该表达的细胞群里时
环境 RNACellBender(Python)raw_feature_bc_matrix.h5v0.3.0 起 expected-cells 等参数自动估计要求 marker 和差异分析准确时;官方要求用 GPU(--cuda)

第 4 步

LogNormalize(Scanpy 的 normalize_total + log1p)是稳妥的默认选择。Ahlmann-Eltze 与 Huber 2023 年在 Nature Methods 上的比较显示,log(y/s + 1) 加 PCA 的效果与 SCTransform、Pearson 残差等更复杂的变换相当或更好。

Scanpy 完整流程(本页实测通过)python
# Scanpy 1.12 (Python >= 3.12): PBMC 3k from Cell Ranger output to annotated clusters
import numpy as np, scanpy as sc
from scipy.stats import median_abs_deviation

adata = sc.read_10x_mtx("filtered_gene_bc_matrices/hg19", var_names="gene_symbols")
# Cell Ranger >= 3: sc.read_10x_h5("filtered_feature_bc_matrix.h5")
adata.var_names_make_unique()

# QC metrics: mitochondrial / ribosomal / hemoglobin genes (mouse: "mt-", ("Rps","Rpl"), "^Hb[^(p)]")
adata.var["mt"] = adata.var_names.str.startswith("MT-")
adata.var["ribo"] = adata.var_names.str.startswith(("RPS", "RPL"))
adata.var["hb"] = adata.var_names.str.contains("^HB[^(P)]")
sc.pp.calculate_qc_metrics(adata, qc_vars=["mt", "ribo", "hb"], percent_top=[20], log1p=True, inplace=True)

# Doublets on raw counts, per capture (batch_key="sample" when several samples are concatenated)
sc.pp.scrublet(adata, random_state=0)

# MAD outliers: 5 MAD on counts / genes / top-20 share, mito 3 MAD AND above a fixed floor
def outlier(x, nmads, upper_only=False):
    med, mad = np.median(x), median_abs_deviation(x)
    return (x > med + nmads * mad) if upper_only else ((x < med - nmads * mad) | (x > med + nmads * mad))
o = adata.obs
bad = (outlier(o.log1p_total_counts, 5) | outlier(o.log1p_n_genes_by_counts, 5)
       | outlier(o.pct_counts_in_top_20_genes, 5)
       | (outlier(o.pct_counts_mt, 3, upper_only=True) & (o.pct_counts_mt > 8)))
adata = adata[~bad & ~adata.obs.predicted_doublet].copy()
sc.pp.filter_genes(adata, min_cells=3)

# Normalize, HVG, PCA
adata.layers["counts"] = adata.X.copy()
sc.pp.normalize_total(adata, target_sum=1e4)
sc.pp.log1p(adata)
sc.pp.highly_variable_genes(adata, n_top_genes=2000, flavor="seurat_v3", layer="counts")  # add batch_key="sample" for multi-sample data
adata.raw = adata                                     # keep all genes (log-normalized) for markers and plots
hv = adata[:, adata.var.highly_variable].copy()       # scale only HVGs; scaling all genes makes X dense
sc.pp.scale(hv, max_value=10)
sc.tl.pca(hv, n_comps=50)

# Graph, clustering, UMAP
sc.pp.neighbors(hv, n_neighbors=15, n_pcs=30)
for r in (0.2, 0.4, 0.6, 0.8, 1.0):
    sc.tl.leiden(hv, resolution=r, flavor="igraph", n_iterations=2, key_added=f"leiden_{r}")
sc.tl.umap(hv)

# Markers on log-normalized expression of all genes
adata.obs["leiden"] = hv.obs["leiden_0.6"].values
sc.tl.rank_genes_groups(adata, "leiden", method="wilcoxon", use_raw=False)
sc.pl.rank_genes_groups_dotplot(adata, n_genes=5)
Seurat v5 完整流程(本页实测通过)r
# Seurat v5: same workflow in R
library(Seurat)
counts <- Read10X("filtered_gene_bc_matrices/hg19")      # Cell Ranger >= 3: Read10X_h5("filtered_feature_bc_matrix.h5")
obj <- CreateSeuratObject(counts, project = "pbmc3k", min.cells = 3, min.features = 200)
obj[["percent.mt"]] <- PercentageFeatureSet(obj, pattern = "^MT-")   # mouse: "^mt-"

# Doublets: scDblFinder on the raw counts of each capture, before strict QC
library(scDblFinder); library(SingleCellExperiment)
set.seed(1)
sce <- scDblFinder(SingleCellExperiment(list(counts = LayerData(obj, layer = "counts"))))
obj$dbl_class <- sce$scDblFinder.class               # several captures: scDblFinder(sce, samples = "sample")

# MAD thresholds instead of the 200 / 2500 / 5% values of the PBMC3k tutorial
mad_out <- function(x, n, upper = FALSE) {
  m <- median(x); d <- mad(x)
  if (upper) x > m + n * d else x < m - n * d | x > m + n * d
}
bad <- mad_out(log1p(obj$nCount_RNA), 5) | mad_out(log1p(obj$nFeature_RNA), 5) |
       (mad_out(obj$percent.mt, 3, upper = TRUE) & obj$percent.mt > 8)
obj <- subset(obj, cells = colnames(obj)[!bad & obj$dbl_class == "singlet"])

obj <- NormalizeData(obj)                            # LogNormalize, scale.factor = 1e4
obj <- FindVariableFeatures(obj, nfeatures = 2000)
obj <- ScaleData(obj)                                # HVGs only by default
obj <- RunPCA(obj, npcs = 50)
ElbowPlot(obj, ndims = 50)
obj <- FindNeighbors(obj, dims = 1:20)
obj <- FindClusters(obj, resolution = c(0.2, 0.4, 0.6, 0.8, 1.0))
obj <- RunUMAP(obj, dims = 1:20)

library(clustree)                                    # attach it; clustree::clustree() alone fails at ggsave
clustree(obj, prefix = "RNA_snn_res.")
Idents(obj) <- "RNA_snn_res.0.6"
markers <- FindAllMarkers(obj, only.pos = TRUE, min.pct = 0.25, logfc.threshold = 0.25)

SCTransform 要装 glmGamPoi

没有 glmGamPoi 时,SCTransform 提示 could not find glmGamPoi installed 并退回慢速实现。本页 2,638 个细胞用了 106.7 秒;细胞数到几万时差距更明显。用 BiocManager::install("glmGamPoi") 安装。

SCT 之后差异分析用 RNA 或 PrepSCTFindMarkers

SCT 残差只用于降维和聚类。多样本 SCT 后做 FindMarkers,要先运行 PrepSCTFindMarkers 校正各样本的测序深度,或者切回 RNA assay 用 LogNormalize 的 data 层。

高变基因数:2,000 是默认,3,000 是常用上限

Seurat 与 Scanpy 都默认 2,000。Scanpy 的 flavor="seurat_v3" 需要原始 counts(layer="counts");多样本时加 batch_key,按样本分别选再合并排序,可以避免批次特异基因被选为高变基因。Luecken 2022 的整合评测发现先选高变基因能提高整合效果。

PCA 维数:看拐点,宁多勿少

PBMC 3k 的 PC1 解释 2.9% 方差,第 7 个主成分后每个 PC 只占 0.18%–0.24%,曲线基本变平;前 10、30、50 个 PC 累计 7.6%、11.0%、14.2%。Seurat 教程称 7–12 个 PC 都说得过去。复杂组织或细胞数上万时用 20–50 个 PC;PC 太少会把稀有群并进大群,多取几个 PC 对结果影响较小。JackStraw 很慢,官方教程已不作为常规步骤。

方法命令适合本页实测(PBMC 3k)
LogNormalize / normalize_total + log1pNormalizeData();sc.pp.normalize_total(target_sum=1e4) + sc.pp.log1p默认;CellTypist 等工具要求这种输入Seurat 核心流程(归一化到 UMAP)15.9 秒
SCTransform v2SCTransform(obj, vst.flavor = "v2")测序深度差异大、想省去 ScaleData 时;整合时各样本分别 SCT未装 glmGamPoi 时 106.7 秒;与 LogNormalize 聚类的 ARI 为 0.73
Pearson 残差sc.experimental.pp.highly_variable_genes(flavor="pearson_residuals") + normalize_pearson_residualsScanpy 中替代 log 变换res 0.6 得到 10 群;与 log 流程的 ARI 为 0.63

第 5 步

先不做整合,把 UMAP 按样本着色。同一种细胞的不同样本已经混在一起时,不需要整合。整合只改变降维坐标(harmony、integrated.rpca 等 reduction),表达矩阵不变;差异分析始终用原始的 RNA counts 和 data。

本页实测(Seurat 5.5.1、harmony 2.0.5,把 PBMC 3k 随机分成两个“样本”):没有 split 直接运行 IntegrateLayers(method = HarmonyIntegration) 报 attempt to set an attribute on NULL;split 之后 Harmony 正常完成。整合后对亚群 subset,层已经合并,重新运行 IntegrateLayers 再次报同一错误;对子集重新 split 后通过。对 merge 得到的、已经按样本分层的对象再次 split,报 The following layers are already split。CSDN 作者报告,CCAIntegration 在未 split 时报 no applicable method for 'Assays' applied to an object of class "NULL"。

本页实测(Scanpy 1.12.4、harmonypy 2.0.2):sc.external.pp.harmony_integrate 报 ValueError: Value passed for key 'X_pca_harmony' is of incorrect shape。harmonypy 2.x 返回的 Z_corr 已经是细胞 × PC,Scanpy 的封装又转置了一次。下方代码改为直接调用 harmonypy。

Seurat v5:split、整合、JoinLayers 与亚群重新整合r
# Seurat v5 multi-sample integration: split -> per-layer preprocessing -> IntegrateLayers -> JoinLayers
obj <- merge(s1, y = list(s2, s3), add.cell.ids = c("s1", "s2", "s3"))   # already one layer per object: counts.1, counts.2, counts.3
# Object built from one matrix, or after JoinLayers: split by sample first
# obj[["RNA"]] <- split(obj[["RNA"]], f = obj$sample)
# Splitting a merged object again fails: The following layers are already split: 'counts.1', 'counts.2' Please join before splitting
obj <- NormalizeData(obj); obj <- FindVariableFeatures(obj); obj <- ScaleData(obj); obj <- RunPCA(obj)

# Unintegrated baseline first
obj <- FindNeighbors(obj, dims = 1:30, reduction = "pca")
obj <- FindClusters(obj, resolution = 0.5, cluster.name = "unintegrated_clusters")
obj <- RunUMAP(obj, dims = 1:30, reduction = "pca", reduction.name = "umap.unintegrated")

obj <- IntegrateLayers(obj, method = HarmonyIntegration, orig.reduction = "pca", new.reduction = "harmony")
# alternatives: method = RPCAIntegration (more conservative), CCAIntegration, FastMNNIntegration, scVIIntegration
obj <- FindNeighbors(obj, reduction = "harmony", dims = 1:30)
obj <- FindClusters(obj, resolution = 0.5, cluster.name = "harmony_clusters")
obj <- RunUMAP(obj, reduction = "harmony", dims = 1:30, reduction.name = "umap.harmony")

obj <- JoinLayers(obj)          # before FindMarkers / FindAllMarkers / GetAssayData

# Re-clustering a subset: layers are joined now, so split again before IntegrateLayers
sub <- subset(obj, idents = c("0", "3"))
sub[["RNA"]] <- split(sub[["RNA"]], f = sub$sample)
sub <- NormalizeData(sub); sub <- FindVariableFeatures(sub); sub <- ScaleData(sub); sub <- RunPCA(sub)
sub <- IntegrateLayers(sub, method = HarmonyIntegration, orig.reduction = "pca", new.reduction = "harmony_sub")
sub <- FindNeighbors(sub, reduction = "harmony_sub", dims = 1:20); sub <- FindClusters(sub, resolution = 0.3)
sub <- JoinLayers(sub)
Scanpy:Harmony 与 scVIpython
# Scanpy: Harmony (harmonypy) and scVI on the same AnnData
import scanpy as sc
sc.pp.highly_variable_genes(adata, n_top_genes=2000, flavor="seurat_v3", layer="counts", batch_key="sample")
hv = adata[:, adata.var.highly_variable].copy()
sc.pp.scale(hv, max_value=10); sc.tl.pca(hv, n_comps=50)
# sc.external.pp.harmony_integrate(hv, key="sample") fails with harmonypy 2.x:
#   ValueError: Value passed for key 'X_pca_harmony' is of incorrect shape ... (50,) while it should have had (n_cells,)
# harmonypy 2.x already returns Z_corr as cells x PCs, the Scanpy 1.12.4 wrapper transposes it again
import harmonypy
ho = harmonypy.run_harmony(hv.obsm["X_pca"], hv.obs, "sample")
Z = ho.Z_corr
hv.obsm["X_pca_harmony"] = Z if Z.shape[0] == hv.n_obs else Z.T
sc.pp.neighbors(hv, use_rep="X_pca_harmony", n_pcs=30)
sc.tl.leiden(hv, resolution=0.5, flavor="igraph", n_iterations=2)

# scVI: needs raw counts; a GPU shortens training, CPU works for tens of thousands of cells
import scvi
scvi.model.SCVI.setup_anndata(hv, layer="counts", batch_key="sample")
m = scvi.model.SCVI(hv, n_latent=30)
m.train()                                                      # max_epochs scales down automatically for large data
hv.obsm["X_scVI"] = m.get_latent_representation()
sc.pp.neighbors(hv, use_rep="X_scVI")

过度整合的检查方法

整合前后各聚一次类,看三件事:每个群的 marker 是否仍然只属于一个谱系;预期只在某一条件出现的细胞状态是否还能单独成群;各样本在每个群中的比例是否被压成完全相同。Seurat 官方建议对比多种整合方法与未整合结果的聚类和 marker 表达。

v4 教程的 integrated assay 不能做差异分析

v4 的 IntegrateData 会生成 integrated assay,旧教程常在其上做 FindMarkers。整合后的值是校正后的低维重构,不适合检验基因表达差异。v5 的 IntegrateLayers 只生成 reduction,差异分析在 JoinLayers 后的 RNA assay 上做。

场景首选备选选错的后果
同一实验、同一平台的几个样本,批次效应轻不整合,或 HarmonyRPCA强行用 CCA 可能把样本间真实的组成差异抹平
对照与处理(疾病与正常)比较,期望出现条件特异的细胞状态Harmony 或 RPCA(Seurat 官方称 RPCA 更保守)scVI过度整合会把处理组特有的状态并入对照组的群,后续差异分析找不到信号
多个实验室、多个平台或图谱级数据,批次多、细胞组成差异大scVI;有部分细胞标签时用 scANVIScanoramaHarmony 校正不足,同一细胞类型按来源分成多个群
几十万细胞以上Harmony(快、内存小)或 scVI(GPU)Seurat sketch 后再整合CCA 在大数据上内存和时间开销大
Luecken 等 2022(Nature Methods)在 13 个整合任务、68 种方法与预处理组合、超过 120 万个细胞上评测:scANVI、Scanorama、scVI 和 scGen 在复杂任务上表现较好;在简单批次任务上,Harmony 这类线性方法表现良好。

第 6 步

分辨率决定群的粗细,没有唯一正确值。做法是扫一组分辨率,选群结构稳定、且每个群都有可解释 marker 的最低分辨率;需要更细的亚型时,提取大群后单独重新聚类。

在 PBMC 3k 上,res 1.5 时不同种子给出的群数在 16 到 18 之间波动,ARI 降到 0.75,说明多出来的群不稳定。30 个 PC、res 0.4 时 5 个种子结果几乎一致(ARI 0.99)。同一分辨率下,10 个 PC 比 30 个 PC 多分出 1–3 个群,PC 数与分辨率要一起报告。

clustree 图中,一个群同时接收来自上一层两个群的箭头时,说明分辨率已经高到开始重新划分细胞。本页的 PBMC 3k clustree 图里,这种交叉从 res 0.4 到 0.6 之间开始出现,与 Seurat 教程选用 0.5 一致。Seurat 官方给出的经验是约 3,000 个细胞时 res 0.4–1.2,细胞越多需要的分辨率越高。

clustree 报 Unknown guide: edge_colourbar

本页实测(clustree 0.5.1、ggraph 2.2.2、ggplot2 4.0.3):用 clustree::clustree() 而不加载包时,ggsave 或 print 阶段报 Unknown guide: edge_colourbar。先 library(clustree)(它会加载 ggraph)再画图即可。

Scanpy 的 Leiden 显式写 flavor

Scanpy 1.12.4 中不写 flavor 时仍使用 leidenalg,并给出 FutureWarning:The `igraph` implementation of leiden clustering is *orders of magnitude faster*。写 flavor="igraph", n_iterations=2,与官方教程一致。sc.tl.louvain 已弃用,环境中没有 louvain 包时直接报 ModuleNotFoundError。

提取亚群后要重新选高变基因

对 T 细胞等大群 subset 后,重新做 FindVariableFeatures、ScaleData、RunPCA 再聚类;沿用全体细胞的高变基因和 PC,区分亚型的基因可能不在其中。多样本数据还要按上一节重新 split 和整合。

设置res 0.2res 0.4res 0.6res 0.8res 1.0res 1.5
Seurat,dims 1:10(Louvain)7910111113
Scanpy,10 个 PC(Leiden,5 个种子)6(ARI 0.96)8–9(0.85)9–10(0.89)11–12(0.83)12–13(0.85)16–18(0.75)
Scanpy,30 个 PC(Leiden,5 个种子)5(0.95)7(0.99)8(0.86)9(0.90)9(0.89)12–14(0.75)
本页实测,PBMC 3k。括号内是 5 个随机种子两两之间的平均调整兰德指数(ARI),越接近 1 越稳定。Seurat 与 Scanpy 的群数不同,原因是图的构建方式和聚类算法不同。

第 7 步

自动注释工具只能给出参考数据中存在的标签。流程是:先用自动工具给每个群一个候选标签,再用经典 marker 的点图逐群核对,冲突时以 marker 和组织背景为准。

本页实测(CellTypist 1.7.1,Immune_All_Low,majority_voting,over_clustering 用 Leiden res 0.6 的 8 个群):8 个群的投票标签与 marker 判断全部一致,依次为 Tcm/Naive helper T(1,165 个细胞)、Tem/Trm cytotoxic T(280)、B cells(332)、Classical monocytes(465)、CD16+ NK(157)、Non-classical monocytes(168)、DC(27)、Megakaryocytes/platelets(13)。逐细胞预测共给出 41 种标签,只看逐细胞结果会把一个群拆成许多细碎亚型。

这个分辨率下 CD4 初始 T 与记忆 T 没有分开(都在 1,165 个细胞的群中),CCR7、LEF1 只在部分细胞中高表达。要分开它们,需要对这个群单独重新聚类,或提高分辨率后用 CCR7、S100A4 核对。

CellTypist(本页实测通过)python
# CellTypist on log1p data normalized to 10,000 counts per cell (adata after normalize_total + log1p)
import celltypist
from celltypist import models
models.download_models(model=["Immune_All_Low.pkl"])
pred = celltypist.annotate(adata, model="Immune_All_Low.pkl",
                           majority_voting=True, over_clustering=adata.obs["leiden"])
adata.obs["celltypist"] = pred.predicted_labels["majority_voting"]
adata.obs["celltypist_conf"] = pred.probability_matrix.max(axis=1)   # low values = label not in the model
SingleR 按群注释(未在本机运行)r
# SingleR + celldex (Bioconductor). On macOS arm64 bioconda has no SingleR build:
# install with BiocManager::install(c("SingleR", "celldex")) inside the R environment
library(SingleR); library(celldex)
ref <- celldex::MonacoImmuneData()                    # immune; HumanPrimaryCellAtlasData() for broad tissue types
pred <- SingleR(test = LayerData(obj, layer = "data"), ref = ref,
                labels = ref$label.fine, clusters = obj$seurat_clusters)   # per-cluster mode
obj$singler <- pred$pruned.labels[match(obj$seurat_clusters, rownames(pred))]
plotScoreHeatmap(pred)                                # check that the best score clearly beats the second

marker 基因的读法

Seurat FindAllMarkers 的 pct.1、pct.2 是该基因在本群和其他群中的表达细胞比例。好的 marker 是 avg_log2FC 高且 pct.1 远大于 pct.2;只看 log2FC 会挑出表达细胞很少的基因。本页 Seurat 结果中,B 细胞群 CD79A 的 pct.1 为 0.936、pct.2 为 0.041。

核糖体基因排在最前的群

Scanpy 结果中 T 细胞大群的前 5 个 marker 是 LDHB 和 4 个 RPS 基因。核糖体基因高是初始 T 细胞的特征之一,不能单独作为注释依据;用 CD3E、IL7R、CCR7 等核对。

FindAllMarkers 慢时装 presto

Seurat 未安装 presto 时用 R 实现的 Wilcoxon 检验,并提示安装 presto。Seurat 5.6.0 起 FindAllMarkers 可以在一次 presto 调用中完成所有群的比较。

工具语言参考与输入优点局限
SingleR + celldexRcelldex 参考(HumanPrimaryCellAtlas、Blueprint/ENCODE、Monaco 免疫等);log 归一化矩阵可按群注释(clusters= 参数);给出 pruned.labels 和打分热图参考多为纯化细胞的 bulk 数据,组织特异亚型和疾病状态缺失;macOS arm64 的 bioconda 没有 SingleR 包
CellTypistPython内置模型(默认 Immune_All_Low);log1p 且每个细胞归一到 10,000 的矩阵majority_voting 按群投票,结果稳定模型以免疫细胞为主,非免疫组织需选对应器官模型
AzimuthR(Seurat)/ 网页Seurat 团队的参考图谱(PBMC、肺、肾、骨髓等),默认联网加载多级标签,与 Seurat 对象直接衔接只覆盖已有参考的组织;R 包仍为 v0.5.0
手工 marker两者文献和 CellMarker 等数据库中的 marker能识别参考中没有的群依赖经验;同一 marker 在不同组织含义不同

两条路线

两条路线可以完成同样的分析。细胞数在 10 万以上、或要用 scVI 等深度学习工具时,Python 路线内存占用更低;要用 SingleR、CellChat、Monocle 等 R 包时选 Seurat。

互转推荐 scverse 的 anndataR(Bioconductor 收录,GitHub 最新版本 v1.3.2,2026-10-07;本页用 bioconda 上的 1.2.2 测试)。SeuratDisk 的仓库自 2023 年 11 月起没有新提交,有 156 个未关闭 issue,常见问题包括 Convert 时 X 优先取 scale.data、原始 counts 没有转过去,以及读取 LZF 压缩的 h5ad 失败。

本页实测(anndataR 1.2.2):未 JoinLayers 的 v5 对象调用 write_h5ad 不会报错,只给出 Skipping Layer "counts.A" with unexpected dimensions 警告,写出的 h5ad 没有任何表达矩阵;JoinLayers 后写出的文件含 counts 和 data 两个 layer,X 为空,在 Python 中要手动指定 adata.X。反方向读取 Scanpy 输出的 h5ad 时,X 与 layers 都成为 Seurat 的 layers,obs 列和 X_pca、X_umap 都保留。anndataR 不转换 varp 以及 Seurat 的 Neighbors、Images。zellkonverter 经 SingleCellExperiment 中转,是另一条可用路线。

h5ad 与 Seurat 互转r
# R: anndataR 1.2 (Bioconductor). rhdf5 is a separate install, otherwise:
#   Error: HDF5AnnData requires the rhdf5 package
# BiocManager::install(c("anndataR", "rhdf5"))
library(Seurat); library(anndataR)

obj <- read_h5ad("pbmc3k_processed.h5ad", as = "Seurat")   # h5ad -> Seurat: X and layers become layers, obsm -> reductions
obj <- JoinLayers(obj)                                       # split layers are skipped with a warning only
write_h5ad(obj, "from_seurat.h5ad")                          # Seurat -> h5ad: layers "counts" and "data", X left empty

# Python: X is empty after the Seurat -> h5ad direction, point it at a layer
import anndata as ad
adata = ad.read_h5ad("from_seurat.h5ad")
adata.X = adata.layers["counts"].copy()                      # or layers["data"] for log-normalized values
步骤Seurat v5(R)Scanpy 1.12(Python)
读入Read10X / Read10X_h5sc.read_10x_mtx / sc.read_10x_h5
质控指标PercentageFeatureSet(pattern = "^MT-")sc.pp.calculate_qc_metrics(qc_vars=["mt"])
双细胞scDblFinder、DoubletFindersc.pp.scrublet
归一化NormalizeData / SCTransformsc.pp.normalize_total + sc.pp.log1p
高变基因FindVariableFeatures(nfeatures = 2000)sc.pp.highly_variable_genes(n_top_genes=2000)
整合IntegrateLayers(CCA、RPCA、Harmony、FastMNN、scVI)harmonypy、scVI、Scanorama
聚类FindNeighbors + FindClusters(默认 Louvain)sc.pp.neighbors + sc.tl.leiden
markerFindAllMarkerssc.tl.rank_genes_groups
自动注释SingleR、AzimuthCellTypist
大数据BPCells 存盘 + SketchDataread_lazy、backed 模式、Dask
存储.rds / .qs.h5ad / .zarr

资源

单细胞分析的内存瓶颈是稠密矩阵。计数矩阵是稀疏的;ScaleData、sc.pp.scale 一旦作用于全部基因,就会生成细胞数 × 基因数的稠密矩阵。

本页实测:PBMC 3k 过滤后的稀疏矩阵占 16.8 MB,同样的矩阵转成稠密(2,607 × 13,607 × 8 字节)是 270.6 MB,相差 16 倍。按这个比例,10 万个细胞 × 2 万个基因在全基因缩放时需要约 16 GB 只存这一个矩阵。

内存不够时按顺序尝试:只对高变基因做 ScaleData(Seurat 默认如此,不要传 features = rownames(obj));保存前删除不再需要的 scale.data 和 SCT assay;Seurat 用 BPCells 把 counts 放在磁盘上,再用 SketchData 抽 5 万个细胞分析、ProjectData 投射回全体;Python 用 anndata 的 read_lazy 或 backed 模式,只把需要的子集读入内存。

R 中出现 vector memory limit of 16.0 Gb reached 时,macOS 可以在 ~/.Renviron 中设置 R_MAX_VSIZE=32Gb;Seurat 多线程时还会遇到 future.globals.maxSize 的限制,大数据集按官方教程设为 options(future.globals.maxSize = 4e9) 或更高。

大数据集:BPCells + sketch(R)与 read_lazy(Python)r
# Seurat v5 + BPCells: counts stay on disk, analyze a 50,000-cell sketch, project back
library(Seurat); library(BPCells)
options(future.globals.maxSize = 4e9)
mat <- open_matrix_10x_hdf5("filtered_feature_bc_matrix.h5")
write_matrix_dir(mat, dir = "bpcells_counts")              # one-time conversion to the on-disk format
obj <- CreateSeuratObject(open_matrix_dir("bpcells_counts"))
obj <- NormalizeData(obj); obj <- FindVariableFeatures(obj)
obj <- SketchData(obj, ncells = 50000, method = "LeverageScore", sketched.assay = "sketch")
DefaultAssay(obj) <- "sketch"
obj <- FindVariableFeatures(obj); obj <- ScaleData(obj); obj <- RunPCA(obj)
obj <- FindNeighbors(obj, dims = 1:50); obj <- FindClusters(obj, resolution = 1)
obj <- RunUMAP(obj, dims = 1:50, return.model = TRUE)
obj <- ProjectData(obj, assay = "RNA", full.reduction = "pca.full", sketched.assay = "sketch",
                   sketched.reduction = "pca", umap.model = "umap", dims = 1:50,
                   refdata = list(cluster_full = "seurat_clusters"))

# Python side: open .h5ad without loading X (anndata >= 0.12)
import anndata as ad
a = ad.experimental.read_lazy("big.h5ad")   # lazy obs/var/X; subset first, then .to_memory()
a = ad.read_h5ad("big.h5ad", backed="r")    # older backed mode: read-only X on disk
规模内存(实测或报告)来源建议配置
2,600 个细胞(PBMC 3k)Scanpy 全流程峰值 0.98 GB;Seurat 全流程(含 SCTransform、整合测试)峰值 3.86 GB,Seurat 对象 100 MB本页实测笔记本即可
27,000 个细胞(PBMC 3k 复制 10 份)Scanpy 只缩放高变基因峰值 2.64 GB;缩放全部 13,607 个基因峰值 4.03 GB本页实测(复制细胞只用于测内存)16 GB 内存
81,000 个细胞(复制 30 份)Scanpy 只缩放高变基因峰值 4.42 GB本页实测32 GB 内存
30 万个细胞,基础流程Seurat 约 40 GB,Scanpy 约 10 GBBiomamba(CSDN)实测64–128 GB,或改用 Scanpy、BPCells
130 万个细胞(BPCells 存盘)Seurat 对象约 596 MB,在 5 万个抽样细胞上分析Seurat sketch 官方教程取决于抽样数,32–64 GB 可行

本页实测

本页实测:macOS arm64(8 核、16 GB),Scanpy 1.12.4 / anndata 0.13.4 / Python 3.12,Seurat 5.5.1 / SeuratObject 5.4.0 / R 4.5,10x PBMC 3k(2,700 个细胞)。测试时同一台机器还在运行其他任务,耗时只用来看量级。

项目ScanpySeurat
读入2,700 个细胞 × 32,738 个基因CreateSeuratObject(min.cells = 3, min.features = 200) 后 2,700 × 13,714
质控固定阈值保留 2,638;MAD 法保留 2,596固定阈值保留 2,638
双细胞Scrublet 32 个(1.2%)scDblFinder 124 个(4.6%)
进入聚类2,607 个细胞 × 13,607 个基因(固定阈值 + 去 Scrublet 双细胞)2,638 个细胞
本页代码块(MAD + 去双细胞)2,567 个细胞,30 个 PC、res 0.6:7 群(无血小板群)2,564 个细胞,dims 1:20、res 0.6:9 群
聚类30 个 PC、Leiden res 0.6:8 群dims 1:10、res 0.5:9 群
注释CellTypist 投票标签与 marker 一致(8/8 群)FindAllMarkers 11.5 秒(未装 presto)
耗时全脚本 186 秒,其中 Scrublet 与质控 114 秒(含首次 numba 编译)核心流程 15.9 秒;SCTransform v2 106.7 秒
峰值内存0.98 GB3.86 GB(含 SCTransform 与整合测试)
  • Seurat 5.5.1 上复现的报错:GetAssayData(slot = ) defunct;@counts 不存在;多层时 GetAssayData 与 FindMarkers 报错;未 split 的 IntegrateLayers 报 attempt to set an attribute on NULL;亚群上重新整合报同一错误。
  • Seurat 5.5.1 的 RunUMAP 默认使用 R 的 uwot(余弦距离),与旧教程中通过 reticulate 调 Python umap-learn 的结果不会完全相同。
  • SingleR 未在本机运行:macOS arm64 的 bioconda 没有 bioconductor-singler 包;代码只核对了函数与参数名。
  • CellBender、scVI 需要 GPU 或较长 CPU 时间,BPCells 未在本机安装,这三部分代码只核对了参数名与文档。
  • anndataR 1.2.2 双向转换已运行:需另装 rhdf5;未 JoinLayers 时写出的 h5ad 没有表达矩阵。

国内经验

以下来自 CSDN 和知乎专栏,只收录带报错原文或作者实测、且与官方文档或本页实测一致的内容。

亚群重新整合报 “NULL是不能有属性的”

CSDN 上至少两位作者(2024 年 7 月、8 月)记录了同一报错:中文环境下是“错误于names(groups) <- "group": NULL是不能有属性的”,英文是 attempt to set an attribute on NULL;一位是在提取免疫细胞亚群后用 Harmony 整合时遇到。原因是层已合并,解决办法是 Seurat GitHub Discussion #9045 给出的先 split。本页在 Seurat 5.5.1 上复现并验证了修正。

几个样本不一定需要去批次

CSDN 一位作者用 4 个小鼠前额叶样本比较了不整合、SCTransform 和 Harmony 三种流程,不整合时各样本已经混合良好,结论是先看数据再决定。这与 Seurat 官方建议先对比未整合结果一致。

30 万个细胞的内存实测

Biomamba 在 CSDN(2024 年 12 月)用 2.5 万到 30 万个细胞测了基础流程(归一化到 UMAP):30 万个细胞时 Seurat 约 40 GB,Scanpy 约 10 GB;同时提到 5 万个细胞的 monocle2 拟时序峰值内存可达 300 GB 以上。该文作者经营服务器租赁,数字有实测脚本支持,可作为量级参考。

稀疏矩阵节省的内存

知乎专栏《单细胞分析 | Seurat基础流程 | 保姆级教程》在 PBMC 3k 原始矩阵上测得稠密矩阵 709.6 MB、稀疏矩阵 29.9 MB,相差 23.7 倍。本页对过滤后的矩阵测得 16 倍。两者都说明内存问题主要出在把矩阵变稠密的步骤上。

交给 Agent

可以用一句话描述分析任务,由 Scientify 的科学智能体在云电脑中完成。

指令示例:“对 data/ 下 6 个 10x 样本(3 个对照、3 个处理)做单细胞分析:每个样本用 MAD 法质控并跑 scDblFinder,先出未整合 UMAP,再用 Harmony 和 scVI 整合并比较;用 clustree 和多种子 ARI 选分辨率;用 CellTypist 和 marker 点图注释,输出各群细胞比例和每群的处理组与对照组差异基因。”

智能体在预装 Scanpy 的云电脑中安装其余所需的包,按样本完成质控和双细胞检测,记录每一步剔除的细胞数;整合、聚类、注释后输出 UMAP、clustree 图、marker 点图、细胞比例表和差异基因表。需要 GPU 训练 scVI 时按任务自动租用。工作区保留脚本、参数、日志和 h5ad 文件,可以复现;智能体会对结果做对抗审阅,例如检查整合后是否有处理组特有的群消失、被注释的群是否有一致的 marker。关闭本机后任务继续运行。

你仍需要自己核对:质控阈值是否符合你的组织类型,整合是否抹掉了你关心的生物学差异,注释标签是否与组织背景和文献一致,差异分析是否按样本(而不是按细胞)计算重复。

资料来源

常见问题

线粒体比例阈值设多少?

没有通用值。先按每个样本计算线粒体比例的中位数加 3 MAD,再与固定下限取较宽松者:人类组织常用 10%,小鼠 5%(Osorio 与 Cai 2021),sc-best-practices 用 8%。心肌、肾小管等线粒体本身高的细胞类型,要结合 UMI 数和聚类结果判断;单核测序的上限通常设为 1%–5%。

Seurat v4 的代码在 v5 上报错怎么办?

把 slot = 改成 layer =,把 obj@assays$RNA@counts 改成 obj[["RNA"]]$counts 或 LayerData()。多样本对象在 GetAssayData、FindMarkers 之前运行 JoinLayers;IntegrateLayers 之前按样本 split。SplitObject 加 IntegrateData 的 v4 整合流程改为 split 加 IntegrateLayers。

单细胞聚类分辨率怎么选?

扫一组分辨率(例如 0.2 到 1.5),用 clustree 看从哪个分辨率开始出现一个群接收多个上层群的情况,再用多个随机种子比较结果的一致性(ARI)。选每个群都有清楚 marker 的最低稳定分辨率;需要亚型时对大群单独重新聚类。本页 PBMC 3k 的稳定范围在 0.4–0.6。

Harmony 和 Seurat 的 IntegrateLayers 是什么关系?

IntegrateLayers 是 Seurat v5 的统一接口,method = HarmonyIntegration 时内部调用 harmony 包。也可以直接用 RunHarmony(obj, group.by.vars = "sample")。两种方式都只生成新的降维坐标,表达矩阵不变。

单细胞分析需要多大内存的服务器?

按细胞数估算。1 万个细胞以内,16 GB 的笔记本可以完成;5 万到 10 万个细胞,建议 64 GB;30 万个细胞时 Seurat 基础流程约 40 GB、Scanpy 约 10 GB;更大的数据集用 BPCells 加 sketch 或 Scanpy 的 lazy 读取。拟时序、细胞通讯等下游分析的内存需求可能远高于基础流程。

Seurat 对象怎么转成 h5ad?

安装 anndataR 和 rhdf5,先 JoinLayers,再用 write_h5ad(obj, "out.h5ad");写出的文件表达矩阵在 layers["counts"] 和 layers["data"] 中,X 为空,Python 中读入后要指定 adata.X。反方向用 read_h5ad("x.h5ad", as = "Seurat")。SeuratDisk 自 2023 年 11 月起没有新提交。

把单细胞分析交给 Scientify

科学智能体在预装 Scanpy 的隔离云电脑中运行本页流程:按样本质控与双细胞检测、整合方法比较、分辨率选择、注释与差异分析,需要 GPU 时自动租用,保留全部脚本、参数和日志。新注册用户免费获得 5 美元等值额度。