从GEO原始矩阵到细胞注释:一套可复用的Seurat单细胞分析流程
从GEO原始矩阵到细胞注释:一套可复用的Seurat单细胞分析流程
最近在整理自己的单细胞分析代码时,我越来越倾向于把流程拆成两个目标同时推进:
- 分析结果要能跑通,也就是从原始矩阵一路走到可解释的聚类和注释;
- 流程结构要可复用,不同项目只改数据输入和少量参数,就能快速迁移。
这篇文章就以 GSE182244 PBMC 数据 为例,系统梳理一套比较完整的 Seurat 分析流程。整套代码覆盖了:
- 项目目录初始化
- 原始表达矩阵与 metadata 读入
- metadata 统一命名与标准化
- 基础质量控制(mt/rb/hb)
- 按样本动态阈值过滤低质量细胞
scDblFinder双细胞识别- 标准 Seurat 聚类
- Harmony 批次效应校正
- marker 可视化与细胞类型注释
如果你也想把自己的单细胞代码从“能跑”整理成“能复用”,这套思路会非常实用。
1. 流程设计思路:先搭框架,再跑分析
我现在比较推荐的写法,不是直接从 Read10X() 或 CreateSeuratObject() 开始往下堆,而是先把整个项目管理框架搭起来。
1.1 为什么先做目录初始化
单细胞项目很容易越做越乱,特别是当你同时输出:
- 原始对象
- QC 后对象
- 过滤后对象
- 双细胞识别后对象
- Harmony 校正后对象
- metadata 表格
- marker 表格
- UMAP / DotPlot / QC 图
如果这些文件都混在一个目录里,后面维护成本会非常高。
因此,这份流程的第一步就是封装 init_project():
1 | init_project <- function(project_id, base_dir = ".") { |
1.2 目录结构为什么值得标准化
这一步最大的价值不在“创建文件夹”,而在于统一项目约定。
例如:
- 原始数据永远放
raw/ - 参数记录永远放
meta/ - 中间对象永远放
qs/ - 统计表永远放
tables/ - 可视化输出永远放
plots/
这样后面你换数据集时,只需要换 project_id 和输入文件,整个分析骨架基本不用重写。
2. 用 GSE182244 作为示例项目
这份流程以 GSE182244 为例,先把项目参数记录下来:
1 | project_id <- "GSE182244" |
这里我很喜欢再加一步:把参数写入文本文件。
1 | save_params <- function(param_file, params) { |
这样做有两个好处:
- 后面回看项目时,一眼就知道这套结果对应的是哪份数据;
- 如果将来做多个项目并行,参数记录非常利于追踪。
3. 原始表达矩阵与 metadata 的读入
这一部分其实最容易“因数据而异”。很多 GEO 项目不是标准 10x 输出,因此不能简单套 Read10X(),而要按具体文件结构处理。
这份代码采用的是:
- 表达矩阵用
data.table::fread()读入; - metadata 通过
scan()先读表头,再用read.table()读正文。
1 | expr <- data.table::fread(expr_file, data.table = FALSE) |
这里有一个非常实用的小细节:
1 | colnames(mat) <- sub("\\.([0-9]+)$", "-\\1", colnames(mat)) |
很多 GEO 表达矩阵里的细胞条形码格式和 metadata 里的细胞名不完全一致,比如:
- 矩阵里是
AAAC... .1 - metadata 里是
AAAC...-1
如果不统一,后面就会卡在矩阵列名和 metadata 行名无法匹配的问题上。
3.1 交集细胞这一步一定要做
1 | common_cells <- intersect(colnames(mat), rownames(meta)) |
这是我现在几乎每个项目都会保留的一步。
因为只要表达矩阵和 metadata 有一边多了一些细胞,后面 CreateSeuratObject() 虽然不一定立即报错,但分析对象会留下隐患。最稳妥的做法就是:
只保留两边都存在的细胞,保证
colnames(mat)与rownames(meta)完全一致。
检查方式也很直接:
1 | identical(colnames(mat), rownames(meta)) |
4. 创建 Seurat 对象前,先把 metadata 做标准化
很多初学者在这里容易忽略一个问题:
真正决定后续流程是否顺畅的,不只是表达矩阵,还有 metadata 是否统一。
我现在比较倾向于把 metadata 明确收敛到一组固定字段:
project_idsample_idgroupbatchnCount_RNAnFeature_RNApercent.mtpercent.rbpercent.hb
这样做的好处是:
- 后续 QC 函数可以复用;
- 多个数据集整合时字段名不乱;
- pseudobulk、CellChat、DESeq2 等下游分析更容易衔接。
4.1 统一 sample_id 和 group 的写法
这份代码里用了两套映射:
1 | sample_map <- c( |
这是非常值得保留的习惯。
原因很简单:原始数据里的样本名和分组名往往并不适合直接进入正式分析。
比如:
HD1/HD2/HD3更适合映射到真实 GSM 编号;GPP这种缩写更适合统一成可解释的疾病标签。
4.2 创建 Seurat 对象
在 metadata 整理完之后,再去建对象:
1 | rownames(mat) <- make.unique(rownames(mat)) |
这里还有两个很关键的小点:
(1)重复基因名先处理
1 | rownames(mat) <- make.unique(rownames(mat)) |
否则后面很多函数会直接报错。
(2)尽快转成稀疏矩阵
1 | mat <- Matrix(mat, sparse = TRUE) |
单细胞数据大部分是稀疏的,越早转成 sparse matrix,内存压力越小。
5. 基础质量控制:把 mt、rb、hb 一次性补齐
这份代码里封装了一个我很喜欢的 QC 函数:add_basic_qc()。
核心思想是:
- 如果对象里已经有
percent.mt/percent.rb/percent.hb,就直接用; - 如果没有,就自动计算;
- 同时导出 QC 表格和常见 QC 图。
5.1 先准备基因集合
1 | mt_genes <- grep("^MT-", rownames(seu), value = TRUE) |
这里对血红蛋白基因还专门排除了 HBP1,这是个很细但很实用的处理:
单纯正则匹配
^HB容易误抓一些并不应该算进血红蛋白污染的基因。
5.2 质量控制函数
1 | seu <- add_basic_qc( |
运行后,它会输出:
qc_metrics.csvqc_violin.pdfqc_scatter.pdf
这一步的价值在于:
- 数据是否整体偏低质量,一眼就能看出来;
- 后面定过滤阈值时,更有依据;
- 所有图表都有固定输出位置,方便追踪项目。
6. 过滤低质量细胞:不要只用固定阈值
很多教程会直接写:
1 | subset(seu, subset = nFeature_RNA > 200 & percent.mt < 20) |
当然这种写法可以用,但当样本间差异比较大时,固定阈值往往太粗糙。
这份代码更进一步,封装了 filter_pbmc_cells(),特点是:
- 仍然保留全局默认阈值;
- 但同时按样本计算动态阈值;
- 最终阈值取“默认阈值”和“动态阈值”的组合。
6.1 为什么按样本算动态阈值
不同样本在这些指标上可能天然不同:
- 文库深度
- 检测到的基因数
- 线粒体比例
如果所有样本都硬套一刀切标准,很可能:
- 某些样本被过度过滤;
- 某些样本又保留了过多异常细胞。
6.2 动态阈值的核心逻辑
代码里用了两种统计策略:
对 nFeature_RNA 和 nCount_RNA
先取 log10,再按 IQR 规则算上下界:
1 | lower <- 10^(q[1] - 1.5 * iqr) |
对 percent.mt
只取上界:
1 | q[2] + 1.5 * iqr |
最终又和默认阈值合并:
1 | threshold_df$min_features_final <- pmax(min_features, threshold_df$min_features_dyn, na.rm = TRUE) |
这种策略我个人很喜欢,因为它避免了两个极端:
- 既不是完全死板的固定阈值;
- 也不是完全放任动态阈值无限飘。
6.3 过滤结果也要存档
函数最后除了返回过滤后的对象,还会输出:
filter_thresholds.csvcell_filter_decisions.csvfilter_summary.csv
这对后期复盘特别重要。因为你不只是知道“过滤了”,还知道:
每个样本到底用了什么阈值、哪些细胞被删掉、总体删掉多少。
7. 双细胞识别:按样本跑 scDblFinder
在我自己的实践里,双细胞识别最好不要粗暴地对整个对象一把跑完,尤其是在多样本场景下。
这份代码使用的是按样本循环运行 scDblFinder:
1 | run_scDblFinder_per_sample <- function(seu, sample_col = "sample_id", seed = 123) { |
7.1 为什么按样本跑
因为双细胞率通常与这些因素相关:
- 每个样本的细胞数
- 上机条件
- 文库复杂度
- 混样方式
如果把所有样本混在一起跑,容易让某些样本的双细胞识别偏移。
7.2 这段代码里有一个很实用的保护逻辑
1 | if (ncol(sub) < 100) { |
如果某个样本细胞数太少,就不强行做双细胞识别。这是非常合理的:
小样本本来就不适合稳定估计双细胞结构,硬跑反而容易引入噪音。
7.3 识别结果的输出
运行后可以直接检查:
1 | table(seu_df$doublet_status) |
然后保留 singlet:
1 | seu_singlet <- subset(seu_df, subset = doublet_status == "singlet") |
并且把:
- 带 doublet score 的对象
- 去除 doublet 后的对象
都保存下来,后面回头查也非常方便。
8. 标准 Seurat 聚类流程
去除双细胞后,就进入比较经典的 Seurat 标准流程:
1 | seu_singlet <- NormalizeData(seu_singlet, verbose = FALSE) |
这是一个非常标准的分析链条。
如果要做得更“工程化”一点,我建议保留以下输出:
DimPlot(group.by = "sample_id")DimPlot(group.by = "group")DimPlot(label = TRUE)
因为这三张图可以快速回答三个问题:
- 样本有没有明显批次分离;
- 分组有没有初步结构差异;
- 聚类本身是否分得清楚。
9. Harmony 批次效应校正
在多样本项目中,聚类前后的批次问题几乎一定要看。
这份流程使用的是 Harmony:
1 | seu_harmony <- harmony::RunHarmony( |
然后基于 Harmony 低维空间继续:
1 | seu_harmony <- FindNeighbors(seu_harmony, reduction = "harmony", dims = 1:30) |
9.1 这里有一个值得注意的点
代码里写的是:
1 | group.by.vars = "group" |
也就是说,当前校正目标是 group。
这个设置在实际项目里要根据目标来决定:
- 如果你只是想先去掉样本批次,很多时候更常见的是
sample_id或batch; - 如果你这里的
group更像技术批次或混样来源,也可以这样做; - 但如果
group本身代表真正的生物学分组,是否用它做校正变量,需要非常谨慎。
换句话说,这段代码的框架没有问题,但校正变量一定要回到具体研究问题里判断。
9.2 矫正前后一定要对比看
代码里保留了前后对比图:
- 未校正版 UMAP
- Harmony 后 UMAP
这个习惯非常重要。
因为批次校正不是“跑完就一定更好”,而是要确认:
- 样本间混合是不是更合理了;
- 真正的生物学结构有没有被抹掉。
10. Marker 设计与注释思路
这一部分我认为是整套代码里非常有“博客价值”的地方。
因为它不是把注释写死在某一步,而是显式构建了 marker 字典。
1 | marker_list <- list( |
10.1 为什么我喜欢“marker 字典”这种写法
因为它把注释过程拆成了两个层次:
第一层:你主观上认为应该有哪些大类细胞
比如:
- T cell
- NK cell
- Monocyte
- B cell
- Dendritic cell
第二层:再通过 DotPlot 和 FindAllMarkers 去验证
这样注释不是黑箱,而是:
先有生物学预期,再用数据支持这个预期。
10.2 自定义 DotPlot 包装函数也很实用
代码里封装了 my_DotPlot():
1 | my_DotPlot <- function(DotPlot_obj){ |
优点是:
- 统一风格;
- 更容易调字体和颜色;
- 以后不同项目可以直接复用。
11. 用 FindAllMarkers 作为注释辅助证据
完成初步聚类后,这份代码还会跑:
1 | markers_harmony <- FindAllMarkers( |
并导出 marker 表:
1 | write.csv( |
然后再取每个 cluster 的 top10 marker:
1 | top10_markers <- markers_harmony %>% |
这一步非常重要,因为它能让你的注释不只是“凭经验看 DotPlot”,而是同时参考:
- 经典 marker
- cluster-specific marker
这两套证据放在一起,注释就会稳很多。
12. 最终注释与对象保存
这份流程最后采用了显式 cluster 对 cell type 的映射:
1 | cluster_map <- c( |
然后写入:
1 | seu_harmony$celltype_l1 <- unname(cluster_map[as.character(seu_harmony$seurat_clusters)]) |
12.1 为什么建议保留 Unknown
很多人做注释时喜欢强行给每个 cluster 一个名字,但我现在越来越倾向于:
看不准就保留
Unknown。
因为:
- 某些 cluster 可能是低质量残留;
- 某些 cluster 可能需要更细粒度 marker 才能拆;
- 某些 cluster 可能本身就是混合群。
与其误注释,不如先诚实保留。
12.2 为什么保存多个阶段对象很重要
这份代码会把不同阶段的对象都存到 qs/ 目录,比如:
- raw
- qc
- filtered
- singlet
- clustered
- harmony_annotated
这种做法最大的好处是:
后面如果你要返工某一步,不需要从头再跑。
例如:
- 想改 QC 阈值,就从
qc.qs往后接; - 想改双细胞去除,就从
filtered.qs往后接; - 想改注释,就直接载入
harmony_group.qs。
这对大型项目节省时间非常明显。
13. 这套流程适合哪些场景
我觉得这套分析框架尤其适合下面几类场景:
13.1 GEO 公共数据复现
因为它对“非标准 10x 文件格式”的兼容性更好,特别适合:
- 表达矩阵和 metadata 分开下载;
- 细胞命名格式需要手动对齐;
- metadata 列名杂乱,需要统一重命名。
13.2 多项目并行分析
因为项目目录、文件命名和保存习惯都是标准化的,所以多个项目可以共用一套框架。
13.3 后续还要接下游分析
比如:
- CellChat
- pseudobulk + DESeq2
- module score
- 差异表达
- 轨迹分析
只要前面的 metadata 和对象保存规范了,下游衔接会顺很多。
14. 我觉得还可以继续优化的地方
虽然这套代码已经很完整,但从“长期复用”的角度,我觉得还有几处可以继续升级。
14.1 把参数彻底配置化
比如把这些内容放到单独配置文件:
- 物种
- 组织类型
- 过滤阈值
- marker 字典
- cluster 注释映射
- Harmony 校正变量
这样整套流程就更接近“半自动管线”。
14.2 注释部分可引入参考映射
现在是基于 marker + 人工判断做一级注释,这很适合作为基础版本。
后面还可以引入:
- SingleR
- Azimuth
- scRNA reference mapping
把人工 marker 注释和参考图谱注释结合起来。
14.3 Harmony 的校正变量要更明确
前面提到,group.by.vars = "group" 这一步一定要根据研究问题谨慎设置。最好把:
sample_idbatchgroup
这几种方案都可视化对比一下,再定最终策略。
15. 小结
如果只用一句话来总结这套流程,我会这么说:
这不是一份“把 Seurat 常规命令串起来”的脚本,而是一套从项目管理、metadata 标准化、QC、动态过滤、双细胞识别、批次校正到注释保存都尽量规范化的单细胞分析骨架。
它最大的价值不是某一步用了哪个函数,而是把这些关键问题都提前想清楚了:
- 文件怎么组织
- metadata 怎么统一
- QC 怎么留痕
- 双细胞怎么按样本处理
- 批次校正怎么接入
- 注释怎么既有 marker 支撑,又能可视化展示
- 中间结果怎么保存,方便返工
如果你正在整理自己的单细胞代码,我非常建议把“脚本能跑”再往前推进一步,升级成“流程可复用”。
附:这套流程的最小主线
最后附一版我理解下的核心主线,方便快速浏览。
1 | # 1. 初始化项目 |
如果后面我把这套流程进一步整理成函数化模板或 Snakemake / targets 版本,我再继续补一篇。


