做肿瘤单细胞分析的朋友,大概率都绕不开一个问题:怎么从混杂的细胞群里面,把恶性细胞和正常细胞分开。单靠marker基因往往不够,很多肿瘤克隆的标记本来就不典型,甚至还会出现异常丢失。这时候就需要从拷贝数变异(CNV)层面去判断。inferCNVpy就是干这个事的:它通过单细胞转录组数据来推断染色体区段的拷贝数状态,帮你把可能带有大片段扩增或缺失的细胞给筛出来。这个工具之前主要是在R生态里用,inferCNVpy则是Python版,能直接接进scanpy流程。这篇内容就把我实际跑通inferCNVpy的完整过程、关键参数、踩坑记录都整理出来,供准备上手的朋友参考。
1. 为什么我最终选了inferCNVpy
1.1 R版inferCNV的痛点与Python生态的契合
R语言里那个经典的inferCNV,功能确实强,尤其是后来加了HMM的版本,可以在概率框架里判断每个染色体区段的CNV状态。但问题也很明显:它是独立R包,和Seurat的衔接还算顺畅,可一旦你的分析主流程已经迁到了Python + scanpy,就会非常别扭。每次跑完scanpy聚类,导出数据给R,跑完结果再导回来,中间还涉及矩阵格式转换、基因注释格式统一,光这些准备工作就能耗掉大半天。
inferCNVpy走的是完全不同的路子。它本身就是一个Python包,直接读取AnnData对象,输入输出都是scanpy生态的标准格式。这意味着你在scanpy里做完了标准化、聚类、注释,直接拿这个AnnData去跑inferCNVpy,结果又回到AnnData里,整个过程不需要任何格式转换。
另外一个实际体验是,inferCNVpy的接口设计比R版更简洁。R版inferCNV的核心函数参数很多,很多参数要看文档才能理解;inferCNVpy把流程收敛成几个关键函数:infercnv、pca、leiden、hmm、heatmap,每一步的输入输出非常清楚,排错也容易。
1.2 它到底在算什么:滑动窗口与HMM的基本盘
要想用好inferCNVpy,不能只把它当成一个画热图的工具。它的底层逻辑值得先理解一下。
正常情况下,一个二倍体细胞的基因表达量在染色体上的分布应该是相对均匀的,不会出现大片区域的基因同时升高或降低。但如果某个细胞发生了染色体片段的扩增,那么这段区域内的基因拷贝数变多,转录水平整体就会偏高;反过来,缺失区域的基因转录水平会整体偏低。当然,单看某一个基因,这种信号非常弱,因为基因表达本身受太多因素调控,噪声极大。
inferCNVpy的核心思路就是用“滑动窗口”来压制这种单基因噪声。它把染色体按基因顺序排好,设定一个包含一定数量基因的窗口,比如250个基因,窗口内的表达值求平均,然后窗口依次往后滑动。这样,单个基因的随机波动被平均掉,剩下的是区域性的表达趋势,而这种趋势恰好能反映拷贝数状态。
这里有个很关键的细节:计算窗口均值时,并不是把细胞逐个单独算,而是把每个细胞窗口内所有基因的表达值汇总成一个值。这一步完成后,每个细胞都得到一串“染色体区段化”的表达强度值,后续的热图、聚类、HMM都基于这个矩阵。
HMM(隐马尔可夫模型)则是更进一步,它把这串表达强度值当成观测序列,去推断背后的隐藏状态序列。这里的隐藏状态就是“拷贝数正常”、“拷贝数扩增”、“拷贝数缺失”等。HMM会综合考虑每个位置观测到的表达水平,以及状态之间的转移概率,最后给出一个平滑后的CNV状态路径。简单说,滑动窗口负责降噪,HMM负责分段判状态,两者配合才能得到有生物学意义的结果。
2. 上手前的准备:环境、数据与参考细胞
2.1 安装和依赖只有一行命令
inferCNVpy的安装比较简单,直接用pip装就行,它依赖scanpy、numpy、pandas、scikit-learn、statsmodels这些常见库,如果你的scanpy环境已经装好,额外补齐的包并不多。
我在一个干净的环境里实测,创建新conda环境后执行这条命令,几分钟就装完了:
conda create -n cnv python=3.10 -y conda activate cnv pip install scanpy infercnvpy需要提醒的是,inferCNVpy对Python版本有一些要求,我用的Python 3.10没有问题。如果你在旧的Python 3.8环境里遇到依赖冲突,建议直接新建环境,不要去强解依赖,省时省力。
装完之后可以跑一个快速自检,确认版本号能正常输出:
import infercnvpy print(infercnvpy.__version__)2.2 AnnData格式的硬性要求
inferCNVpy直接基于AnnData对象运行,所以你的表达矩阵必须在Anndata的X里。有几个地方需要提前检查:
第一,表达值是log-normalized状态。inferCNVpy不适合直接输入raw counts,你需要先跑完标准化和对数转换。如果用的是log1p之后的矩阵,通常没有问题。
第二,基因注释信息必须完整。var这个数据框里要有染色体信息和基因位置信息,inferCNVpy需要知道每个基因在哪个染色体上的什么位置,才能按顺序排布滑动窗口。我常用的列名是chromosome、start、end,不同版本的inferCNVpy可能对列名的要求略有差异,建议跑之前先看下文档,确认当前版本默认读哪些列。
如果你处理的是10X数据,常见的基因注释文件里都能找到这些信息,做一个merge就能补上。以human为例,大概这样操作:
import pandas as pd gene_anno = pd.read_csv("hg38_gene_annotation.csv") adata.var = adata.var.reset_index().merge( gene_anno, left_on="index", right_on="gene_name", how="left" ).set_index("index")第三,细胞注释列要在obs里。必须有一个列来指定每个细胞的类型或分组,因为inferCNVpy要用其中一部分正常细胞作为参考基线。
2.3 参考细胞怎么选才不容易翻车
这一步是整个分析中最容易被低估的环节。参考细胞就是你认为“确定正常”的细胞,inferCNVpy会拿它们的表达均值作为基线,其他细胞的CNV信号都相对这个基线计算。参考细胞选得好不好,直接决定结果靠不靠谱。
我看到不少新手犯的错误,是随便抓一堆T细胞当参考。问题在于,T细胞本身就存在V(D)J重组,TCR区域的基因表达天然有较大波动,如果参考T细胞数量又少,这些波动可能被当成CNV信号,导致结果里出现假阳性。
我的建议是,如果条件允许,至少选两种以上不同谱系的正常细胞作为参考,比如T细胞加B细胞加髓系细胞。这样可以互相抵消谱系特异的表达模式,让基线更接近真正的中性状态。
数量上也要注意。参考细胞太少,比如只有三五十个,均值估计会很不稳定。我一般会要求在200个以上,当然这取决于数据整体大小和细胞注释的可靠性。如果是注释不明确的公共数据集,宁可先保守一点,只在信心很高的正常细胞群上运行。
另外还有一个容易忽略的坑:性别差异。如果分析对象是男性肿瘤和女性正常组织混合数据,性染色体上的表达差异巨大,会把整个X染色体的信号拉偏。处理方式要么过滤性染色体,要么保证参考细胞和样本在性别上尽量一致。这些都属于上游质控要解决的问题,但很多人会等到热图出来才意识到。
3. 完整实操:从表达矩阵到CNV热图
3.1 数据预处理:不要做过头
这里我直接用自己跑过的一套模拟肿瘤混合数据来演示。假设你已经有一个AnnData对象,包含肿瘤组织样本,里面既有恶性细胞也有免疫细胞和基质细胞。
最关键的一点:对表达矩阵不要做过度的特征选择。比如高变基因筛选、PCA降维这些scanpy常规分析步骤,会严重干扰CNV推断,因为CNV信号是分布在全基因组范围内的,用高变基因会把它破坏掉。你要用的X矩阵应该是所有基因的log-normalized表达值。
预处理只需要做最简单的过滤和标准化:
import scanpy as sc import infercnvpy as cnv # 去掉在所有细胞中表达量过低的基因 sc.pp.filter_genes(adata, min_cells=3) # 总表达量标准化 sc.pp.normalize_total(adata, target_sum=1e4) # 对数转换 sc.pp.log1p(adata)做完这一步就可以进入inferCNVpy流程了。注意,不要先跑sc.pp.highly_variable_genes,也不要先用harmony之类的整合工具。如果要整合多个样本的批次效应,必须在标准化之后、inferCNVpy之前谨慎处理,最好是先看一下批次是否真的影响了CNV区域的整体表达。
3.2 核心参数window_size和step怎么定
准备工作做完,运行infercnv的函数就这一句:
cnv.tl.infercnv( adata, reference_key="cell_type", reference_cat=["T cells", "B cells"], window_size=250, step=50, )参数不复杂,但window_size和step这两个值值得花心思。
window_size表示滑动窗口里包含多少个基因。值越大,窗口内的基因越多,平均下来噪声越小,但代价是分辨率下降,一些小范围的拷贝数片段会被抹平。值越小,分辨率越高,但噪声也越大。step表示窗口每次滑动的基因数,step越小,窗口重叠越多,热图越平滑,计算量也越大。
我自己的经验是先跑一个默认值window_size=250、step=50,看看整体效果。如果热图噪音太大、看起来很花,就把window_size加大到500甚至1000。如果怀疑有小片段CNV被平滑掉了,就把window_size降到100、step降到20再对比。
这里的权衡逻辑很像处理时间序列数据的滑动平均:窗口越宽,曲线越平滑,但细节丢失越多。CNV推断需要在“去除单基因噪声”和“保留小片段变异”之间找平衡,没有绝对正确的参数组合,要根据具体数据的基因密度和波动程度来调整。
3.3 运行之后,结果存在哪
运行完infercnv,所有结果都会写回AnnData对象,其中最核心的就是adata.obsm["X_infercnv"]。这是一个新的矩阵,行还是细胞,列变成了“窗口”而不是“基因”,每个值代表该细胞在这个窗口内的平均表达强度。后续的PCA、聚类、热图全都是基于这个矩阵。
同时建议养成一个习惯,跑完立刻检查结果是否正常:
print(adata.obsm["X_infercnv"].shape)这个shape应该远小于原始基因矩阵的维度。如果发现矩阵行数和细胞数对不上,或者直接报错,多半是前置的基因注释、参考细胞名称写错了。
3.4 HMM状态识别与可视化输出
滑窗结果是一连串连续的数值,虽然能看趋势,但很难直接拿来下结论:到底哪些区段算扩增、哪些算缺失?这时候就需要跑HMM,拿到状态注释。
inferCNVpy里HMM的调用并不复杂,但我建议先做一步PCA和聚类,让HMM能利用细胞之间的相似性信息,效果会比直接对每个细胞单独跑稳定得多。整体顺序大概是:
cnv.tl.pca(adata) cnv.tl.leiden(adata) cnv.tl.hmm(adata)跑完以后,HMM的状态会存在adata.obs的列里,不同版本状态列的名称可能不同。通常你能看到每个细胞在每一个窗口位置都被分配了一个状态,0一般代表正常,1或更高代表扩增,负值代表缺失。
可视化部分最常用的就是heatmap函数。inferCNVpy有两个幸运之处,一是画热图不需要自己拼染色体边界,二是它保留了组树结构,能直观看到哪些细胞聚成一类、共享哪些CNV事件:
cnv.pl.heatmap( adata, reference_key="cell_type", reference_cat=["T cells", "B cells"], dendrogram=True, show=True, )如果你还想按染色体分开展示,可以用chromosome_heatmap,把每条染色体的状态铺开,很适合做报告配图。
3.5 从热图到结论:怎么判断恶性细胞
热图出来了,下一步才是关键——怎么把恶性细胞筛出来。
看热图有个常见误区,就是只盯着红色和蓝色看,觉得颜色越深越恶性。其实要看的是“模式”:恶性细胞通常在多条染色体上同时出现大范围的扩增或缺失,形成一种独特的横向条纹,而正常细胞的热图区域应当相对均匀,没有明显的大段色块。
我的习惯是,先不急着定义恶性,而是看聚类树。如果某几个聚类簇共享相似的CNV模式,比如3号染色体长臂全部扩增、8号染色体长臂缺失,这类细胞大概率来自同一个恶性克隆。再结合marker基因的表达(比如上皮来源的EPCAM、间质来源的VIM)做二次验证,基本就能锁定恶性细胞群。
这里也想强调一点:inferCNVpy给出的是一种统计推断,不是金标准。特别是在参考细胞不理想、参数不合适的条件下,结果可能失真。所以它更适合用来“筛选候选恶性细胞群”,而不是直接用它给每个细胞下定论。最终结论最好结合基因组测序或者FISH等实验验证。
4. 常见问题与排查技巧实录
4.1 参考细胞自己都带CNV怎么办
这个情况比想象中更常见。我遇到过几次,注释为正常T细胞的亚群,跑出来的热图在TCR区域附近有明显条带,甚至在6号染色体MHC区域也有异常信号。如果你笃定这些细胞是正常的,那这条带多半是转录活性的自然差异,不是真CNV。
但如果参考细胞里混入了一部分肿瘤细胞,问题就更严重了,基线本身被污染,所有结果都会出偏差。一个有效的排查方式是,先只用你最有把握的一类正常细胞做参考跑一遍,把结果热图单独拿出来看。如果参考细胞区域出现了大面积CNV信号,就得回头检查细胞注释,把可疑细胞从reference_cat里剔除。
还有一个更系统的办法:先用inferCNVpy跑一遍初筛,把明显带CNV模式的细胞临时标记为肿瘤候选,然后将这个信息做一个临时注释,再以临时注释里“正常”的细胞作为参考跑第二轮。这种两轮策略在实际分析中很稳,能有效避免人为注释误差。
4.2 热图花成一片,是参数问题还是数据问题
热图特别花,找不到明显条带,通常有三种可能。
第一,参考细胞选得不好,基线噪声大。解决办法是检查参考细胞数量,或者更换更干净的正常细胞群。
第二,window_size太小。基因表达噪声太大时,小窗口扛不住,导致整张图全是椒盐噪声。调大window_size,比如从250调到500,通常会有明显改善。
第三,上游数据本身质量差。比如测序深度很低、dropout率高,这类数据连marker基因表达都不稳,更别提CNV了。这种时候不要硬调参,先回数据质控流程,把低质量细胞和基因过滤掉,或者用更多细胞合并分析来增强信号。
如果数据是从多个样本合并来的,batch效应也会让热图看起来非常奇怪。比如某一片细胞全是同一个样本的,整片区域表达量偏高,但并不是CNV。解决思路是用整合算法先做批次校正,但要注意整合后的值会改变表达量分布,可能需要重新做标准化,保证inferCNVpy的输入矩阵是合理的表达量矩阵。
4.3 与R版inferCNV结果对不上是谁的锅
很多人在同一批数据上分别跑R版inferCNV和inferCNVpy,结果发现热图风格差异很大,于是担心某个工具算错了。
其实两者在核心原理上是同源的,滑窗加HMM的思想一致,但实现细节不同,包括窗口滑动的边界处理、基因排序方式、HMM初始参数不同等。更关键的是,R版默认对表达矩阵做了额外的归一化步骤,而inferCNVpy要求前置标准化基本完成,这个输入差异就会导致最终数值规模不同。
我的建议是,不要追求两次结果完全一致,而是看关键结论是否一致:同一个细胞簇是否都被判为CNV阳性、是否存在相同的染色体片段异常。如果大方向一致,细节上的色阶差异完全正常。如果你发现一片细胞在R版里是明显扩增,在inferCNVpy里却是正常,那才说明某个环节出了问题,优先检查输入矩阵和参考细胞是否完全一致。
4.4 常见问题速查表
整理一张表,把我在实践里踩过的坑和对应解法记录下来:
| 问题现象 | 可能原因 | 处理办法 |
|---|---|---|
| 运行infercnv报KeyError | var或obs缺少指定列 | 检查chromosome列、参考细胞注释列是否存在 |
| 热图在大片区域颜色极深 | 参考细胞数量不足或基线被污染 | 扩大参考细胞数量,剔除异常参考细胞 |
| X染色体整体异常 | 样本性别不一致 | 过滤性染色体或统一参考细胞性别 |
| 热图噪声大,看不出模式 | window_size过小 | 调大window_size,观察是否改善 |
| 不同样本呈明显分块 | 存在批次效应 | 先做批次校正,确认输入表达量形态 |
| HMM结果全部为正常状态 | 参考细胞和肿瘤细胞没有显著差异,或数据不支持CNV推断 | 检查参考细胞是否选错,调整参数后重试 |
| 运行缓慢、内存爆满 | 细胞数量和基因数量过大 | 先用聚类后的平均表达矩阵运行,或用更大window_size减少计算量 |
4.5 一个容易被忽略的细节:基因排序
inferCNVpy分析的基础是基因在染色体上的正确排序。如果你从不同来源合并表达矩阵,基因symbol大小写不一致、Ensembl ID混用,都会导致基因注释匹配不上,进而影响窗口顺序。
我习惯在跑之前做一次基因ID统一:
adata.var["chr"] = adata.var["chr"].astype(str).str.replace("chr", "", regex=False) adata.var["start"] = pd.to_numeric(adata.var["start"], errors="coerce") adata.var["end"] = pd.to_numeric(adata.var["end"], errors="coerce") adata = adata[:, ~adata.var[["chr", "start", "end"]].isna().any(axis=1)].copy()把没有位置信息的基因直接去掉,比让它们参与计算更安全。另外一个细节是染色体的表示方式,很多公共数据里染色体用“chr1”表示,部分注释文件用“1”,inferCNVpy内部可能不区分,但你自己最好统一成一种格式,避免merge时产生大量空值。
5. inference之后,还能往下做什么
跑完inferCNVpy并不是终点,反而是一个新分析的起点。
最常用的做法是把它的结果转成细胞层面的标签,然后接回常规的单细胞分析流程。比如,你根据热图和HMM结果标记出一批“疑似恶性细胞”,可以把这个label存到adata.obs里,作为后续差异表达分析的分组依据。也可以将恶性细胞单独提取出来,再细分亚群,研究克隆内异质性。
更有意思的是对不同亚克隆的CNV事件做差异比较。比如你识别出两个肿瘤亚群,一个带有7号染色体扩增,一个带有10号染色体缺失,你可以在CNV层面验证这些亚群的稳定性,然后回到转录组层面,看看推动这些亚群分化的转录因子和信号通路有什么不同。
我自己的流程通常是:
adata.obs["malignant"] = adata.obs["cell_type"].isin(malignant_clusters) sc.tl.rank_genes_groups(adata, groupby="malignant")不过这里要提醒一句:通过CNV划出的恶性细胞群,在做差异表达时往往差异基因非常多,因为背后是整段染色体拷贝数变化,不只是单个基因的调控变化。这时候要区分清楚,哪些基因是CNV驱动的事件,哪些是细胞状态转变的伴随变化,否则很容易把拷贝数效应误当成转录调控的结果。
写在最后的一些体会
inferCNVpy不是万能的,但只要用对了场景,它的性价比非常高。我从一开始完全照搬默认参数,到后来学会根据数据特征调整窗口大小、谨慎选择参考细胞,整个分析稳定性有了质的提升。如果再让我给新手提三个建议:先检查参考细胞,再检查基因注释,最后才去调参数。这三个顺序不能乱,前面的问题不解决,后面的调参都是在沙滩上盖楼。这个工具体量不大,但每个环节都值得认真对待,你把它用好了,单细胞数据分析里的很多疑难问题都会迎刃而解。