简介:一份基于单细胞RNA测序数据的细胞类型注释算法研究Python毕业设计源码,针对计算机相关专业正在做毕设或需要项目实战的学习者,可用于课程设计与期末大作业。项目代码完整、经导师指导评审通过,可直接运行,覆盖数据预处理、特征筛选、模型构建、训练测试与结果预测等核心流程,并附有大量功能测试脚本及README说明,便于从零掌握单细胞数据分析与深度学习结合的实现思路。压缩包共90个文件,以61个py源码文件为主,辅以7个xml配置文件、3个csv数据标签、6个pyc缓存文件及md/txt说明文档,整体仅235KB,轻量易部署。目前已有85人学习浏览,适合希望快速复现算法流程、参考毕业设计架构的学习者。
1. 单细胞注释不是分类题,是决策题
拿到一份 scRNA-seq 数据,绝大多数人的第一反应是“跑个聚类看有几个群”。但聚类只是形状,注释才是把形状翻译成生物学结论的步骤。一个 cluster 是 T 细胞还是 NK 细胞,单靠聚类算法永远答不出来。细胞类型注释的本质,是在表达矩阵上执行一次有约束的映射:既要保留数据驱动的分群结构,又要参考已知的 marker 基因或参考注释集。这也是为什么“注释算法研究”在很多毕业论文里既做方法对比,又做流程整合。本文围绕 Python 生态(主要是 Scanpy + 分类器 + 参考映射)拆解一套可复现的注释方案:从数据质检到 marker 打分,从有监督训练到批次处理,最后给出参数调优与排错思路。适合正在做毕业设计、或者刚接手单细胞流程但不想只调包的工程师。
2. 先看懂注释的三种技术路线,再写代码
2.1 基于 marker 基因的特征打分法
最早也是最直观的注释方法:人为指定每个细胞类型的标志基因,然后看每个 cluster 中这些基因的表达是否显著富集。常见做法是先用scanpy.tl.score_genes给每个聚类打分,再结合rank_genes_groups的结果做人工核对。
import scanpy as sc import numpy as np adata = sc.read_h5ad("filtered_clusters.h5ad") # 准备 marker 列表:CD3D/CD3E 标记 T 细胞,MS4A1 标记 B 细胞 markers = { "T_cell": ["CD3D", "CD3E"], "B_cell": ["MS4A1", "CD79A"], "Monocyte": ["LYZ", "CD14"] } # 为每个 cluster 计算 marker 打分 for cell_type, gene_list in markers.items(): # 过滤掉数据集中不存在的基因,避免 KeyError valid_genes = [g for g in gene_list if g in adata.var_names] if valid_genes: sc.tl.score_genes(adata, gene_list=valid_genes, score_name=f"score_{cell_type}") # 查看每个 cluster 的平均打分 df = adata.obs.groupby("leiden")[[f"score_{t}" for t in markers]].mean() print(df.round(3))这段代码的关键是score_genes会计算每个细胞中指定基因相对随机基因集的表达富集程度,输出为 z-score 形式的得分。判断某个 cluster 是什么类型,不是看绝对分,而是横向比较不同 cluster 在同一 marker 组上的得分差异。比如 T_cell 得分在 cluster 0 是 1.8、在 cluster 3 是 -0.2,那 cluster 0 倾向归为 T 细胞。
这个方法的局限也很明确:marker 列表基本靠人工维护,不同文献给出的 marker 往往不一致,并且低质量细胞或 doublet 会造成假阳性打分。它适合做初步筛选,不适合直接当最终结论。
2.2 基于参考数据集的标签迁移方法
如果手头有标注好的公共数据集(比如 Human Cell Atlas 或某个组织的参考图谱),更稳定做法是把“未知标签的查询集”映射到“已知标签的参考集”,然后取近邻标签作为预测结果。
import scanpy as sc import numpy as np from sklearn.neighbors import KNeighborsClassifier # 假设 ref_adata 已注释好,label 存在 obs["cell_type"] ref_adata = sc.read_h5ad("reference_atlas.h5ad") query_adata = sc.read_h5ad("query_raw.h5ad") # 用参考集的高变基因作为特征空间 sc.pp.normalize_total(ref_adata, target_sum=1e4) sc.pp.log1p(ref_adata) sc.pp.highly_variable_genes(ref_adata, n_top_genes=2000, flavor="seurat") hv_genes = ref_adata.var["highly_variable"].values # 查询集也做同样归一化,并只保留参考集的特征基因 sc.pp.normalize_total(query_adata, target_sum=1e4) sc.pp.log1p(query_adata) query_adata = query_adata[:, ref_adata.var_names].copy()基因对齐做完后,用 PCA 嵌入向量训练一个 KNN 分类器。这里用 KNN 而不是直接拼表达值的原因在于:表达谱噪声大,直接算欧氏距离会被高表达基因主导,而 PCA 层已经做了去相关和降维,距离度量更稳定。
# 参考集 PCA 嵌入 sc.pp.pca(ref_adata, n_comps=50) X_ref = ref_adata.obsm["X_pca"] y_ref = ref_adata.obs["cell_type"].values # 查询集投影到参考的 PCA 空间 sc.pp.pca(query_adata, n_comps=50) X_query = query_adata.obsm["X_pca"] # KNN 分类:k 取 10~30,具体看参考集规模 knn = KNeighborsClassifier(n_neighbors=15, weights="distance") knn.fit(X_ref, y_ref) pred = knn.predict(X_query) query_adata.obs["pred_celltype"] = pred参数上最值得注意的其实不是n_neighbors,而是 PCA 的n_comps。单细胞数据往往只有几千个高变基因,但有效信号可能只有 20~30 个主成分,设成 50 可能引入噪声,设成 10 又会丢掉弱信号。一般建议用sc.tl.pca的方差解释曲线看看拐点,按拐点选主成分个数,这也是我处理参考映射时固定顺序:先对齐基因,再选主成分,再跑 KNN。
2.3 基于有监督分类器的注释方法
参考映射的升级版是把问题当作标准的监督分类任务,特征工程的方式可以更灵活。除了 PCA,还可以直接输入高变基因的表达值、或者用 marker 基因集打分拼成的“特征面板”。分类器上常用随机森林、XGBoost 或带 Dropout 的全连接网络。
import xgboost as xgb from sklearn.model_selection import cross_val_score # 用高变基因表达矩阵作为输入 X_train = ref_adata[:, ref_adata.var["highly_variable"]].X.toarray() y_train = ref_adata.obs["cell_type"].values model = xgb.XGBClassifier( n_estimators=300, max_depth=6, learning_rate=0.05, subsample=0.8, colsample_bytree=0.6, eval_metric="mlogloss", tree_method="hist", n_jobs=8 ) # 交叉验证估计泛化能力 scores = cross_val_score(model, X_train, y_train, cv=5) print("CV accuracy:", scores.mean())树模型对单细胞表达矩阵有一个天然优势:数值经过 log 归一化后并非线性可分,而树模型可以通过分裂点自动处理非线性边界。相比 KNN,XGBoost 对参考集中的稀有细胞类型更友好,因为 KNN 的决策边界会被多数类样本拉扯,而树的每次分裂只考虑当前节点的纯度和信息增益,不会受全局类别分布主导。
需要额外说明的是:XGBoost 训练阶段如果直接把.X矩阵传入,数据量在五万细胞以上时内存会飙升。建议用 PCA 层或挑选 top 500~1000 个高变基因做特征压缩,而不是全量喂入,这一步能省下 60% 的训练时间和内存。
2.4 三条路线的选型判断
实际项目里我会按一个简单的规则选型:数据量小于 2 万细胞、且参考集与目标组织同源时,用 PCA+KNN 标签迁移最稳妥,因为参数少、可解释性强,且不容易过拟合。当参考集规模偏大且来源批次参差时,优先考虑带类别权重调整的树模型。而如果样本来自全新组织、没有可靠参考集,就只能退回 marker 打分加人工经验。
千万不要在拿不到可靠参考集的情况下强行训练分类器。“用模型把未知分出来”听上去很智能,但监督学习的目标分布是参考集定义的,如果参考集里没有某个细胞类,模型永远不可能把它分对。
3. 搭建一套可复现的注释分析流程
3.1 原始数据处理到聚类分群的完整链路
注释的前提是把细胞分群做好。经验不足的分析者会在未过滤低质量细胞时直接跑聚类,然后发现 cluster 数量异常偏多、marker 表达混乱。我会把上游到注释的流程固定为下面这个顺序,而不是想到哪步做哪步。
import scanpy as sc import scrublet as scr adata = sc.read_10x_h5("filtered_feature_bc_matrix.h5") # 基础质控:过滤低质量细胞和低表达基因 sc.pp.filter_cells(adata, min_genes=200) sc.pp.filter_genes(adata, min_cells=3) # 线粒体基因比例,高比例代表细胞状态差 adata.var["mt"] = adata.var_names.str.startswith("MT-") sc.pp.calculate_qc_metrics(adata, qc_vars=["mt"], percent_top=None, log1p=False) adata = adata[adata.obs["pct_counts_mt"] < 20, :] adata = adata[adata.obs["n_genes_by_counts"] < 6000, :]n_genes_by_counts上限的确定需要看测序深度。表观上设置 6000 是偏保守的做法,如果文库很深,会在 8000~10000 之间才出现拐点。一个比较实用的办法是画sc.pl.violin(adata, keys=["n_genes_by_counts"])看分布,而不是硬套阈值。
# doublet 检测:用 Scrublet 识别多细胞捕获 scrub = scr.Scrublet(adata.X) doublet_scores, predicted_doublets = scrub.scrub_doublets() adata.obs["doublet_score"] = doublet_scores adata = adata[~predicted_doublets, :] # 归一化、高变基因、PCA、邻居图、聚类 sc.pp.normalize_total(adata, target_sum=1e4) sc.pp.log1p(adata) sc.pp.highly_variable_genes(adata, n_top_genes=2000, flavor="seurat") adata = adata[:, adata.var["highly_variable"]].copy() sc.pp.pca(adata, n_comps=30) sc.pp.neighbors(adata, n_neighbors=15, n_pcs=20) sc.tl.leiden(adata, resolution=0.5)关键参数在sc.pp.neighbors的n_neighbors。注释任务不同于纯聚类探索:我们既要维持正确分群,又要避免把同一类型的细胞碎成多个群。n_neighbors设大(20~30)会让局部结构模糊,小类群容易被吞并;设小(5~10)则分群过多,后面对照 marker 会非常痛苦。我一般先跑 15,看 cluster 数量在 8~15 之间就继续,如果是 25 个以上就提高到 20。
3.2 用 marker 打分与 rank_genes 半自动注释
聚类完成后,下一步是找出每个 cluster 的差异基因,与公共 marker 列表重合并给出候选类型。这里可以做一个“题名对账”式的匹配,而不是纯人工看图。
import pandas as pd # 对每个 cluster 跑差异表达分析 sc.tl.rank_genes_groups(adata, groupby="leiden", method="wilcoxon") result = pd.DataFrame(adata.uns["rank_genes_groups"]["names"]).head(50) # 定义 marker 字典 marker_dict = { "CD4 T": ["CD3D", "CD3E", "IL7R"], "CD8 T": ["CD3D", "CD8A", "CD8B"], "NK": ["NKG7", "GNLY", "KLRD1"], "B": ["MS4A1", "CD79A"], "Monocyte": ["LYZ", "CD14"], "Dendritic": ["FCER1A", "CST3"], "Platelet": ["PPBP"] } for cluster in result.columns: cluster_genes = set(result[cluster]) overlap = {ct: len(set(genes) & cluster_genes) for ct, genes in marker_dict.items()} best = max(overlap, key=overlap.get) print(f"cluster {cluster}: predicted {best}, overlap {overlap}")rank_genes_groups的method="wilcoxon"是 Wilcoxon 秩和检验,适合非正态分布的高通量数据,比t-test更稳健。输出中每个 cluster 的 top 基因若和某种 marker 组有 2 个以上重合,就可以给这个 cluster 打上初始标签。重合数小于 2 时建议标为 “Unknown”,不要硬猜。
这段逻辑写好后,后续替换 marker 列表、调整 cluster 数量,都只要重跑一遍即可。这也是毕业设计里保留“源代码可复现性”最有力的部分,评审最吃这一套。
4. 参考映射:构建自己的注释模型并完成标签迁移
4.1 构造参考数据集的三个硬性要求
参考映射的效果上限由参考集决定,而不是由模型决定。构造参考集时必须满足三条:注释标签经过人工审核、参考集与查询集经过相同的归一化流程、参考集的基因命名规范统一。如果参考集是从多个公共数据集合并的,还需要先做批次校正。Harmony 是当前最通用的方案,在 Scanpy 中可以直接调用。
import scanpy.external as sce # 合并多批次数据后执行 Harmony sc.pp.normalize_total(adata_merged, target_sum=1e4) sc.pp.log1p(adata_merged) sc.pp.highly_variable_genes(adata_merged, n_top_genes=2000) sc.pp.pca(adata_merged, n_comps=30) sce.pp.harmony_integrate(adata_merged, key="batch") # 在 Harmony 校正后的嵌入上重新建图、聚类 sc.pp.neighbors(adata_merged, use_rep="X_pca_harmony") sc.tl.leiden(adata_merged, resolution=0.5)harmony_integrate的key参数是数据中表示批次的列名,多个批次通过迭代聚类的方式消除技术差异。值得留意的是:Harmony 校正后的X_pca_harmony不能直接当作普通 PCA 使用,它的坐标不保留原始方差结构,如果需要做差异表达分析,仍然要用校正前的表达矩阵。这也常是毕业设计里被答辩老师追问的地方。
4.2 用参考集对查询集做标签预测
参考集构造完之后,用 KNN 映射即可给查询集打上标签。关键环节是查询集必须经过与参考集一致的基因过滤和归一化,否则特征空间不对齐,迁移后准确率直线下降。
import scanpy as sc from sklearn.neighbors import KNeighborsClassifier # 参考集已完成注释 ref = sc.read_h5ad("ref_annotated.h5ad") query = sc.read_h5ad("query_raw.h5ad") # 统一基因集 common_genes = list(set(ref.var_names) & set(query.var_names)) common_genes.sort() ref = ref[:, common_genes].copy() query = query[:, common_genes].copy() # 同样归一化 sc.pp.normalize_total(ref, target_sum=1e4) sc.pp.log1p(ref) sc.pp.normalize_total(query, target_sum=1e4) sc.pp.log1p(query) sc.pp.pca(ref, n_comps=30) sc.pp.pca(query, n_comps=30) knn = KNeighborsClassifier(n_neighbors=15, weights="distance") knn.fit(ref.obsm["X_pca"], ref.obs["cell_type"]) query.obs["pred_celltype"] = knn.predict(query.obsm["X_pca"])KNN 的weights="distance"比默认的"uniform"更适合单细胞数据,原因是近邻细胞与目标细胞的距离差异本身携带强度信息,距离加权可以让近处细胞影响更大。n_neighbors推荐在 10~30 之间搜索,但过大会引入跨类型噪声。批量预测时,每跑一组参数就输出一次混淆矩阵或可信度分布,不要只盯着准确率。
4.3 对预测结果做可信度过滤
标签迁移后的结果质量问题集中在两类:参考集里不存在的细胞类型、以及位于两种类型边界上的过渡态细胞。处理方式是计算预测概率,设定阈值过滤低置信度细胞。
# 用 predict_proba 获取每个细胞在每个类别上的概率 prob = knn.predict_proba(query.obsm["X_pca"]) max_prob = prob.max(axis=1) # 阈值设 0.6,低于该值的标记为 Unknown query.obs["pred_prob"] = max_prob query.obs["cell_type_final"] = query.obs["pred_celltype"] query.obs.loc[query.obs["pred_prob"] < 0.6, "cell_type_final"] = "Unknown" # 统计各类别细胞数 print(query.obs.groupby("cell_type_final").size())阈值的选择与参考集的注释粒度强相关。如果注释类型是“T cell”这种粗粒度,概率分布通常集中在 0.8 以上,阈值设 0.7 也不为过。如果注释粒度细到“Naive CD4 T cell”,边界概率自然会低,设太高会丢掉大量真实细胞。0.6 是一个起步值,我的习惯是画一个分位数直方图,如果 0.6~0.7 这个区间出现明显尖峰,说明参考集和查询集之间有明显批次差异,应该先做数据整合而非调低阈值。
5. 收敛到一张验证清单:调优、评估与常见坑位
注释模型的调优空间不在神经网络的层数,而在数据链路的一致性。先盘点一下最常出问题的三个地方:参考集基因名不一致导致特征大量丢失;归一化流程顺序不同造成分布偏移;阈值设定不合理使好细胞被误标为 Unknown。把这三个排查完,已经能解决 80% 的注释质量投诉。
验证阶段,我会同时看聚类与注释的一致性。用 ARI(Adjusted Rand Index)对比聚类标签和预测标签,能够量化评价“同一类型的细胞是否被拆到了多个 cluster 中”。
from sklearn.metrics import adjusted_rand_score, confusion_matrix # 将未知细胞过滤后再计算 mask = query.obs["cell_type_final"] != "Unknown" ari = adjusted_rand_score(query.obs.loc[mask, "leiden"], query.obs.loc[mask, "cell_type_final"]) print("ARI between clustering and annotation:", round(ari, 3)) # 混淆矩阵,方便查看哪些类型被混在一起 cm = confusion_matrix( query.obs.loc[mask, "leiden"], query.obs.loc[mask, "cell_type_final"], )ARI 的值超过 0.6 表示聚类与注释之间的一致性已经够好,低于 0.4 则说明聚类分辨率不匹配注释粒度。分辨率不匹配时的补救方案是改变leiden的resolution参数后重新映射一次,而不是修改分类器权重。
最后一层验证是视觉核对。sc.tl.draw_graph或 UMAP 图上按标签着色,观察同一类型的细胞是否存在离群分布的小岛。如果存在,优先怀疑 doublet 未清理干净或参考集本身有误注释。这两类问题靠调参数解决不了,只能回到质控环节重新过滤。把这条验证链路固定下来,一套注释流程从原始数据出图到质控,整体耗时能控制在半天以内。
本文还有配套的精品资源,点击获取