Python单细胞分析实战:从标准化到聚类的完整代码解析(附避坑指南)
Python单细胞分析实战:从标准化到聚类的完整代码解析(附避坑指南)
如果你刚刚完成单细胞数据的质控,手握一个“干净”的AnnData对象,正准备大展拳脚,却发现从标准化到聚类这一步,网上教程要么语焉不详,要么参数设置让人摸不着头脑,结果跑出来的UMAP图一团乱麻——别担心,这种感觉我太熟悉了。几年前我刚接触单细胞分析时,也在这段路上踩过无数坑,从标准化方法的选择到聚类分辨率(resolution)的玄学调整,每一步都可能让结果南辕北辙。本文不是对现有教程的简单复述,而是结合我处理数十个真实单细胞数据集(从10x Genomics到Smart-seq2)的经验,为你梳理出一条逻辑清晰、参数明确、可复现的分析路径。我们会深入每个步骤背后的“为什么”,而不仅仅是“怎么做”,并附上那些教程里通常不会提,但却至关重要的“避坑指南”。无论你是生物信息学新手,还是希望优化现有流程的研究者,这里都有你需要的实战细节。
1. 数据标准化:不仅仅是“除以总数”
拿到质控后的计数矩阵,第一步标准化看似简单,实则暗藏玄机。很多初学者会直接套用sc.pp.normalize_total,却对后续影响浑然不觉。标准化根本目的是消除技术噪音,让细胞间的基因表达量具有可比性,但具体怎么做,取决于你的数据特性和下游分析目标。
1.1 深度理解标准化与对数化
在Scanpy中,最基础的标准化流程通常包含两步:
import scanpy as sc
# 假设 adata 是已经完成质控的 AnnData 对象
sc.pp.normalize_total(adata, target_sum=1e4)
sc.pp.log1p(adata)
这两行代码做了什么?
normalize_total: 将每个细胞的所有基因表达计数之和,缩放至一个固定的总数(默认为10,000)。这主要是为了消除因测序深度不同带来的差异。一个细胞测了50,000条reads,另一个测了100,000条,直接比较计数显然不公平。log1p: 对标准化后的数据进行 log(x+1) 变换。这里的“+1”是为了避免对零值取对数。对数变换能压缩数据的动态范围,使高表达基因的方差不至于过大,让模型更关注表达变化的相对模式,而非绝对数值。
关键避坑点:target_sum 的设置并非金科玉律。 对于某些细胞体积差异巨大或RNA含量悬殊的数据集(例如,神经元与肝细胞),盲目使用1e4可能导致偏差。一个更稳健的做法是检查细胞总计数的分布:
import matplotlib.pyplot as plt
import numpy as np
# 计算细胞总计数
cell_total_counts = adata.X.sum(axis=1)
plt.figure(figsize=(8,5))
plt.hist(cell_total_counts, bins=50, edgecolor='black')
plt.axvline(x=np.median(cell_total_counts), color='red', linestyle='--', label=f'Median: {np.median(cell_total_counts):.0f}')
plt.xlabel('Total counts per cell')
plt.ylabel('Frequency')
plt.legend()
plt.title('Distribution of Library Sizes')
plt.show()
如果中位数与1e4相差甚远(例如,中位数在50,000左右),那么使用中位数作为target_sum可能更为合理:sc.pp.normalize_total(adata, target_sum=np.median(cell_total_counts))。
1.2 是否应该进行“缩放”(Scaling)?
许多教程会在标准化后立即进行sc.pp.scale(即Z-score标准化,使每个基因在所有细胞中的均值为0,方差为1)。我的建议是:先别急。 缩放最好在完成特征选择(高变基因筛选)之后进行。原因在于,缩放会改变所有基因的分布,包括那些低表达、高噪音的基因,这可能会干扰后续高变基因的识别。一个更合理的流程顺序是:标准化 -> 对数化 -> 特征选择 -> 缩放 -> 降维。
这里有一个常见的误区需要澄清:
注意:
adata.raw = adata这行代码通常在log1p之后执行。它的作用是将当前处理后的数据(即标准化+对数化后的数据)保存到adata.raw属性中。这是一个非常重要的步骤,因为后续的差异表达分析(如sc.tl.rank_genes_groups)需要基于未经过缩放和特征筛选的“原始”标准化数据。如果你不小心覆盖或丢失了.raw,后续的差异分析结果可能会不准确。
2. 特征选择:如何找到真正的“信息基因”
单细胞数据矩阵动辄包含上万个基因,但其中大部分基因在不同细胞间表达变化很小(管家基因)或噪音很大。特征选择的目标就是筛选出那些在不同细胞间表达差异显著(即高变基因,Highly Variable Genes, HVGs)的基因,用它们来代表细胞的生物学状态,可以极大降低数据维度、减少计算负担并提升信噪比。
2.1 Scanpy 高变基因筛选的核心参数解析
执行高变基因筛选的代码很简单:
sc.pp.highly_variable_genes(adata, min_mean=0.0125, max_mean=3, min_disp=0.5)
adata = adata[:, adata.var.highly_variable].copy()
但 min_mean, max_mean, min_disp 这几个参数决定了哪些基因能入选。理解它们的含义至关重要:
| 参数 | 含义 | 默认值 | 调整策略 |
|---|---|---|---|
min_mean |
基因在所有细胞中平均表达量的下限。 | 0.0125 | 过滤掉极低表达的基因,这些基因噪音大。如果数据质量高,可略微调低以保留更多潜在信号。 |
max_mean |
基因在所有细胞中平均表达量的上限。 | 3 | 过滤掉极高表达的基因(如核糖体基因),它们可能主导方差计算。对于某些高表达基因有生物学意义的数据,可适当调高。 |
min_disp |
基因“离散度”(dispersion)的下限。离散度是归一化后的方差,衡量基因表达的变化程度。 | 0.5 | 这是最关键参数。值越大,筛选越严格,得到的HVGs越少、越“高变”。通常需要根据散点图调整。 |
运行 sc.pl.highly_variable_genes(adata) 会生成一张散点图,X轴是平均表达量(mean),Y轴是离散度(dispersion)。图中被标记为红色的点就是被识别出的高变基因。一个理想的分布是:高变基因(红点)主要分布在图的中上部,形成一条“云带”,与低变基因(灰点)有较好的分离。
避坑实战:当你的HVGs数量异常时怎么办?
- 情况一:HVGs太少(<1000个)。 这通常意味着
min_disp设得太高,或者max_mean设得太低,把许多有生物学变异的基因过滤掉了。尝试逐步降低min_disp(如从0.5调到0.3)或提高max_mean。 - 情况二:HVGs太多(>5000个)。 这可能导致降维和聚类时引入过多噪音。尝试提高
min_disp,或者检查数据标准化是否充分,也许某些批次效应或技术噪音被误认为生物学变异。 - 情况三:HVGs分布奇怪。 如果红点没有集中在中等表达区域,而是散乱分布,可能需要回顾质控步骤,看是否有大量低质量细胞或基因未被有效过滤。
2.2 替代策略与高级方法
除了Scanpy内置的方法,在一些特殊情况下你可能需要考虑其他策略:
- 针对稀疏数据的Seurat v3方法:Scanpy 通过
sc.pp.highly_variable_genes(adata, flavor='seurat_v3')提供了对10x Genomics等超稀疏数据适应性更好的方法。 - 基于基因模型的筛选:有些流程会先拟合基因表达均值与方差的关系模型,然后挑选那些方差显著高于模型预测值的基因作为HVGs,这能更准确地分离技术噪音和生物学变异。
无论用哪种方法,记住:特征选择的结果会直接影响后续所有分析。花时间可视化并理解你的高变基因,是值得的。
3. 降维:从万维基因到二维图谱
筛选出几千个高变基因后,我们仍然面临一个高维空间(每个基因是一个维度),无法直观观察细胞之间的关系。降维就是将细胞从高维基因空间映射到低维空间(如2维或3维)的过程,以便进行可视化和探索。
3.1 PCA:线性降维的基石
主成分分析(PCA)是第一步,也是至关重要的一步。它通过线性变换找到数据中方差最大的几个正交方向(主成分)。在单细胞分析中,PCA有两大作用:1) 大幅压缩数据维度(从几千个基因到几十个主成分);2) 为后续的非线性降维(如UMAP)和基于图的聚类提供输入。
# 在进行PCA之前,通常需要对筛选出的高变基因进行缩放
sc.pp.scale(adata, max_value=10)
# 运行PCA,使用‘arpack’求解器通常更稳定
sc.tl.pca(adata, svd_solver='arpack')
# 可视化主成分的方差贡献
sc.pl.pca_variance_ratio(adata, log=True, n_pcs=50)
sc.pp.scale 中的 max_value=10 参数是一个截断值,它将缩放后大于10的值设置为10,小于-10的值设置为-10。这能防止少数极端值(outliers)对整体数据分布产生过度影响。
关键决策:选择多少个主成分(PCs)? pca_variance_ratio 图展示了每个主成分所解释的数据方差比例。图中通常会有一个“拐点”(elbow),拐点之后的主成分解释的方差增量变得很小。一个常见的经验法则是:
- 选择拐点之前的主成分数量。
- 或者,选择累计解释方差达到一定比例(如80%或90%)所需的主成分数。
- 在Scanpy的后续步骤(如计算邻域图
sc.pp.neighbors)中,n_pcs参数通常设置为一个固定值,如30、40或50。对于大多数数据集,使用前20-50个主成分是一个合理的起点。你可以通过以下代码测试不同PC数对后续聚类稳定性的影响。
3.2 UMAP/t-SNE:将结构可视化
PCA是线性的,而细胞在基因表达空间中的关系往往是非线性的。UMAP(Uniform Manifold Approximation and Projection)是目前最流行的非线性降维可视化方法,相比t-SNE,它在保留全局结构方面通常表现更好,且计算速度更快。
# 基于PCA结果构建细胞间的邻域图,这是UMAP和聚类的基础
sc.pp.neighbors(adata, n_neighbors=15, n_pcs=40)
# 运行UMAP降维
sc.tl.umap(adata)
# 可视化,用总计数和线粒体基因占比着色,检查是否有技术偏差
sc.pl.umap(adata, color=['n_counts', 'percent_mt'])
这里有两个核心参数需要仔细调整:
n_neighbors: 控制构建图时考虑多少个最近邻。较小的值(如5-15) 会强调局部结构,可能使簇更分散、更碎片化;较大的值(如30-50) 会强调全局结构,可能使簇更紧凑,但会模糊细微的细胞亚群。对于细胞类型组成复杂的数据集,建议从15开始尝试。n_pcs: 指定使用前多少个主成分来构建邻域图。这应该与你认为有生物学意义的PC数量一致。使用太少(如10)可能会丢失信息,使用太多(如全部)可能会引入噪音。
可视化检查点:在UMAP图上用 n_counts(细胞总计数)和 percent_mt(线粒体基因占比)着色。理想情况下,细胞在这张图上的分布应该与这些技术指标没有明显的相关性。如果你看到UMAP的某个区域明显对应着高 n_counts 或高 percent_mt,那很可能意味着技术偏差没有被完全消除,需要回顾标准化或质控步骤。
4. 聚类分析:揭开细胞类型的面纱
聚类是单细胞分析的核心,目标是将转录组相似的细胞归为同一组,这些组通常对应着不同的细胞类型或状态。Scanpy默认使用Leiden算法,它是一种基于图的高性能社区发现算法。
4.1 Leiden 聚类与分辨率参数的艺术
执行聚类只需一行代码,但其中的 resolution 参数是“兵家必争之地”:
sc.tl.leiden(adata, resolution=0.8, key_added='leiden_cluster')
sc.pl.umap(adata, color='leiden_cluster', legend_loc='on data', palette='tab20')
resolution 参数直接控制聚类的粒度:
- 低分辨率(如0.2-0.5):产生较少、较大的簇。适合初步探索,或细胞类型差异非常明显的数据集。
- 中等分辨率(如0.6-1.2):最常用的范围,能识别出主要的细胞类型和部分大亚群。
- 高分辨率(如1.5-2.0+):产生很多小簇。适合精细分型,例如区分CD4+ T细胞的各个功能亚群、肿瘤细胞的不同恶性程度亚克隆等。
如何选择“正确”的分辨率? 没有绝对正确的答案,只有生物学上合理的答案。我的策略是进行“分辨率扫描”:
import pandas as pd
# 尝试一系列分辨率
resolutions = [0.2, 0.4, 0.6, 0.8, 1.0, 1.2, 1.5]
cluster_results = {}
for res in resolutions:
# 为每次聚类结果使用不同的key,避免覆盖
key = f'leiden_res_{res}'
sc.tl.leiden(adata, resolution=res, key_added=key, random_state=42)
cluster_results[res] = adata.obs[key].astype('category').copy()
print(f"Resolution {res}: {adata.obs[key].nunique()} clusters")
# 可以快速可视化比较其中两到三种分辨率的结果
fig, axes = plt.subplots(1, 3, figsize=(18, 5))
for ax, res in zip(axes, [0.4, 0.8, 1.2]):
key = f'leiden_res_{res}'
sc.pl.umap(adata, color=key, ax=ax, show=False, title=f'Resolution={res}', legend_loc='on data', size=30)
ax.set_title(f'Resolution={res} (n clusters: {adata.obs[key].nunique()})', fontsize=12)
plt.tight_layout()
plt.show()
观察随着分辨率变化,簇是如何分裂或合并的。结合已知的标记基因(见下一节)来评估:在某个分辨率下,已知的细胞类型是否被分离到独立的簇中?是否有某个簇明显是混合的?通过这种迭代探索,找到一个能平衡“避免过度分裂”和“避免过度合并”的折中点。
4.2 基于标记基因的初步注释
聚类完成后,我们得到了一堆编号的簇(如0, 1, 2...)。下一步就是给这些簇赋予生物学意义,即细胞类型注释。这通常从已知的标记基因入手。
首先,你需要根据你的组织或研究领域,准备一个标记基因字典。这个字典可以来自文献、数据库(如PanglaoDB, CellMarker)或你自己的先验知识。
# 示例:一个简化的免疫细胞标记基因字典
marker_genes_dict = {
'T细胞': ['CD3D', 'CD3E', 'CD8A', 'CD4'],
'B细胞': ['CD79A', 'MS4A1', 'CD19'],
'髓系细胞': ['LYZ', 'CD14', 'FCGR3A'], # 单核/巨噬
'NK细胞': ['NKG7', 'GNLY', 'KLRD1'],
'造血干细胞/祖细胞': ['CD34', 'PROM1'],
'增殖细胞': ['MKI67', 'TOP2A'],
'内皮细胞': ['PECAM1', 'VWF'],
'成纤维细胞': ['COL1A1', 'DCN', 'LUM']
}
然后,使用Scanpy强大的可视化工具来探索这些标记基因的表达模式:
# 1. 在UMAP上叠加标记基因表达
sc.pl.umap(adata, color=['CD3D', 'CD79A', 'LYZ'], ncols=3, cmap='Reds', vmax='p99')
# ‘vmax='p99'’ 将颜色标尺的上限设为该基因表达量的99分位数,避免个别极高表达细胞影响整体颜色对比。
# 2. 点图(Dot plot)是进行初步注释的利器
sc.pl.dotplot(adata, marker_genes_dict, groupby='leiden_cluster',
dendrogram=True, standard_scale='var', cmap='Blues')
点图能同时展示两个信息:点的大小表示在某个簇中表达该基因的细胞比例,颜色的深浅表示该基因在表达细胞中的平均表达水平。通过观察,你可以初步判断:簇0高表达CD3D和CD8A,可能是CD8+ T细胞;簇1高表达CD79A和MS4A1,可能是B细胞,等等。
避坑指南:标记基因不表达或表达混乱?
- 检查基因名:数据库和你的数据中基因命名可能不一致(大小写、符号版本)。确保你使用的基因名存在于
adata.var_names中。 - 检查数据过滤:你关注的标记基因可能在质控阶段因低表达被过滤掉了。可以检查
adata.raw.var_names(如果保存了)或在质控时放宽对该基因的过滤阈值。 - 生物学意义:有些标记基因并非绝对特异。例如,LYZ在单核细胞和巨噬细胞中都表达。需要结合多个标记基因共同判断。
5. 结果巩固与下游分析准备
完成初步聚类和注释后,有几项收尾工作能让你的分析更专业,并为后续探索铺平道路。
5.1 聚类结果的统计与可视化
了解每个簇的大小和基本特征是一个好习惯:
import seaborn as sns
# 统计细胞数
cluster_summary = adata.obs['leiden_cluster'].value_counts().sort_index()
print(cluster_summary)
# 绘制带有细胞数量的美观条形图
plt.figure(figsize=(12, 6))
ax = sns.barplot(x=cluster_summary.index.astype(str), y=cluster_summary.values, palette='viridis')
plt.xlabel('Cluster ID', fontsize=12)
plt.ylabel('Number of Cells', fontsize=12)
plt.title('Cell Distribution Across Clusters', fontsize=14)
# 在柱子上添加数量标签
for i, v in enumerate(cluster_summary.values):
ax.text(i, v + max(cluster_summary.values)*0.01, str(v), ha='center', fontsize=9)
plt.xticks(rotation=45)
plt.tight_layout()
plt.show()
你还可以计算并可视化每个簇的一些QC指标中位数,确保没有哪个簇是由低质量细胞构成的:
# 假设你的adata.obs中有‘n_genes_by_counts’和‘percent_mt’
qc_stats = adata.obs.groupby('leiden_cluster')[['n_genes_by_counts', 'n_counts', 'percent_mt']].median()
print(qc_stats)
5.2 保存与分析状态
在投入大量时间进行聚类和初步注释后,务必将当前状态完整保存。Scanpy的AnnData对象可以保存所有数据、计算图和注释信息。
# 保存处理后的完整对象
adata.write('./results/processed_clustered_adata.h5ad', compression='gzip')
print(f"AnnData object saved. Shape: {adata.shape}")
print(f"Clustering key in obs: {[col for col in adata.obs.columns if 'leiden' in col]}")
最佳实践:在保存的文件名中包含关键参数或版本信息,例如 processed_leiden_res0.8.h5ad,这样当你尝试了多种分辨率后,可以轻松回溯和比较不同结果。
5.3 展望:从聚类出发的探索之路
至此,你已经拥有了一个经过标准化、降维、聚类和初步注释的单细胞数据集。这就像一个完成了初步测绘的地图,接下来的探险才真正开始:
- 差异表达分析:使用
sc.tl.rank_genes_groups找出每个簇相对于其他所有簇特异性高表达的基因。这些基因是定义该簇身份的最有力证据,也可能发现新的标记基因。 - 细胞类型精细注释:除了手动对照标记基因,可以使用半自动注释工具(如
sc.tl.annotate_categories或外部工具SingleR、SCINA),将你的数据与已注释的参考数据集进行比对。 - 轨迹推断:如果你的细胞处于一个连续的分化或激活过程(如干细胞分化、T细胞激活),可以使用PAGA、Diffusion Map或Palantir等算法来推断细胞的发育轨迹和伪时间顺序。
- 细胞间相互作用分析:基于配体-受体对数据库,推测不同细胞簇之间潜在的通讯网络。
每一步都充满挑战和发现。记住,单细胞分析是一个迭代和探索的过程。今天的“最佳”聚类,明天可能因为一个新标记基因的加入而被重新审视。保持好奇心,严谨对待每个参数的选择,并用生物学知识不断验证你的计算结果,是驾驭这片数据海洋的不二法门。
更多推荐


所有评论(0)