
做生信分析的人几乎都绕不开VCF文件。无论你是做全基因组测序、外显子组测序还是基因panel最终拿到的高通量变异结果基本都是VCF格式。最近好几个朋友都在问我同一个问题手里有一批样本的VCF怎么快速知道每个样本到底各有多少个SNP这个需求听起来简单但真要统计得干净、准确还要能批量产出结果还是有不少细节坑的。这篇文章就把我自己实测下来最顺的一套流程完整分享出来先用bcftools做快速统计打底再写一个Python脚本把结果整理成干净表格顺带把bcftools的安装和常见坑都讲清楚保证你照着做也能5分钟跑出结果。这套方案适合的人群很广刚入门的生信学生、做临床样本分析的技术员、需要批量整理变异数据的科研人员都适用。不需要你很懂Python也不需要你把VCF格式背下来只要跟着步骤走就行。我会把背后的原理、为什么要这么处理、哪个环节容易出错都尽量说透这样你以后遇到类似场景也能自己举一反三。1. VCF与SNP统计先搞清楚我们在处理什么1.1 VCF文件里的核心信息长什么样VCF的全称是Variant Call Format是存放基因变异信息的标准文本格式。之所以叫“标准”是因为它能同时容纳多个样本的变异信息并且每个位点的等位基因、基因型、质量值、过滤标签都按固定列顺序排列。VCF文件大致分两部分以##开头的元信息行和以#CHROM开头的表头行。表头之后每行代表一个变异位点列顺序是固定的CHROM、POS、ID、REF、ALT、QUAL、FILTER、INFO、FORMAT后面跟着的就是每个样本的基因型数据。我刚开始接触VCF时也觉得这格式又长又枯燥但只要你理解了它“一列一个样本、每格一个基因型”的结构后面做统计就顺理成章了。真正重要的是FORMAT列和后面每个样本列之间的对应关系。FORMAT里通常写着GT:AD:DP:GQ:PLGT是基因型AD是各等位基因的深度DP是总深度GQ是基因型质量PL是三种基因型的Phred似然值。我们要做SNP统计主要盯住GT就够了其他字段只有在过滤低质量位点时才会用到。1.2 什么是“每个样本的SNP统计”SNP是单核苷酸多态性也就是单个碱基位置发生变异的现象。从VCF文件的角度看只要某个位点的REF和ALT分别是单个碱基比如AG、CT这个位点就是SNP。相比之下插入缺失、结构变异都不属于SNP的范畴统计时要格外注意区分。“每个样本的SNP统计”这句话再拆开意思就是在这个VCF文件包含的所有样本中逐个计算每个样本在多少SNP位点上携带了非参考等位基因。这里有几种常见计数口径可以只数非参考纯合位点个数可以只数杂合位点个数也可以两者加起来作为该样本的SNP总数。实际项目里我通常会把总数、杂合数、纯合突变数、缺失数一起统计出来看起来只是一个数字但多拆几列会给后续分析省下大量时间。不同环境和不同研究目的下这个统计结果的含义也会有差异。比如医学外显子项目里样本的SNP总数异常偏高可能提示样本污染或测序深度不均育种研究里不同品系间的SNP数量差别大通常是亲缘关系或基因组多态性差异的直观体现。所以不要小看这一步基础统计很多时候它是判断数据质量的第一道关口。1.3 哪些场景最需要这个统计最常见的场景是质控。拿到一批新样本的VCF之后我会先按样本统计SNP数量如果某个样本明显偏离群体平均水平比如是其他样本的三倍那就要警惕是不是样本建库或分析流程出了岔子。另一个高频场景是样本分组比较比如突变体与野生型、用药组与对照组想快速看组间是否有整体差异这时候一张“样本名SNP数”的表格就是最直接的依据。这个统计还有一个很实用的场景就是筛选代表样本。有些项目不需要分析全部样本只想挑几个遗传信息最丰富的个体做重测序或单细胞验证这时候每个样本的SNP总数就能帮你快速排序把多态性最高的几个样本挑出来。我自己做群体遗传分析时也经常先用这个统计给样本集做个“体检”确认样本编号、分组信息是否和数据本身匹配。2. bcftools最好用的VCF处理瑞士军刀2.1 为什么选bcftools而不是自己写脚本硬解我最早做VCF统计的时候确实写过一段纯Python遍历VCF的脚本后来数据量一上来就发现不行。几百个样本、上百万个位点的VCF纯Python逐行解析一夜都不一定能跑完。bcftools是samtools团队维护的VCF/BCF处理工具底层用C实现性能远高于我们自己写的Python解析逻辑而且生态成熟几乎所有的生信流程都会用到它。bcftools最大的优势在于它把所有常用的VCF操作都封装成了子命令view按区域或样本过滤、query灵活提取字段、stats直接生成统计报告、isect处理交集还有index、norm等配套工具。你做SNP统计时其实只需要其中两三个子命令就能完成大部分工作。更关键的是bcftools对VCF.gz格式支持得非常好不需要手动解压这一点在处理几个GB级别的文件时简直救命。2.2 三种安装方式实测哪种最省心bcftools的安装方式很多我按实际体验排个序conda最省心系统包管理器次之源码编译适合特殊需求。如果你已经装了conda或mamba只需要一句话conda install -c bioconda -c conda-forge bcftools这个方式会帮你自动处理依赖和版本兼容尤其适合不太想折腾环境变量的朋友。装完直接bcftools --version验证如果提示找不到命令检查一下你当前的conda环境是否激活。如果用Linux系统自带的包管理器Ubuntu和Debian系列可以试sudo apt update sudo apt install bcftoolsCentOS系列则是sudo yum install bcftools这类方式的好处是系统集成度高缺点是版本可能偏旧。旧版bcftools在部分过滤语法和stats输出格式上和8.x版本有差异如果你发现自己的命令和网上教程对不上先看版本。最后一种是从源码编译。到GitHub的samtools/bcftools仓库下载源码按INSTALL文档配置适合需要特定编译选项、或者要在没有管理员权限的服务器上安装的情况。源码编译对初学者不太友好可能踩到依赖库缺失的坑但如果你的服务器环境特殊这也是唯一能走通的路。任何方式装完之后都建议立刻验证bcftools --version看到类似bcftools 1.19这样的输出说明安装成功。如果提示bcftools: command not found先确认有没有把conda环境或安装目录的bin路径加进PATH。2.3 和统计样本SNP最相关的几个子命令bcftools query是我最常用的子命令之一。它像一个字段提取器能按你需要的方式从VCF里抽出指定的列然后转成自由格式的文本。做每个样本的SNP统计时query -l可以列出所有样本名称query -f可以自定义输出格式比如同时输出样本名、基因型、染色体和位置。bcftools stats是另一个核心命令它会对整个VCF文件生成一份非常详细的统计报告里面包含了变异类型分布、转换/颠换比、每个样本的SNP计数甚至能按质量值和深度分布给出统计。我们待会要用的“SNP counts by sample”就在这里。bcftools view则负责过滤比如只留下PASS位点、只留下SNP类型、只留下某几个样本。这三个子命令配合使用几乎能覆盖所有日常统计需求。把这个组合理解清楚后面做起分析来非常顺手。3. 五分钟起步用bcftools完成每个样本的SNP计数3.1 先想清楚统计口径再做命令很多人在这一步翻车就是因为没想清楚要统计什么。同样是“每个样本的SNP数量”可以指样本携带的非参考等位基因位点数也可以指样本中所有变异位点里SNP类型的总数还可以要求只统计通过了质量过滤的位点。所以我建议你在跑命令之前先明确三点只看SNP还是包含Indel只统计PASS位点还是全部位点杂合和纯合是分开统计还是加总这三点直接决定了你的命令参数怎么加。以最常用的口径为例我只统计通过FILTER标签为PASS的SNP位点并且把每个样本的杂合、纯合突变和总数分别列出来。这个需求用bcftools一行就能摸清样本列表再用stats直接出样本统计。先列出VCF文件里有哪些样本bcftools query -l your_file.vcf.gz看到返回的样本名列表后执行bcftools stats your_file.vcf.gz stats.txt然后从stats.txt里找到下面这段# SNP counts by sample: # [1] id [2] number of SNPs [3] number of transitions (ts) [4] number of transversions (tv) [5] number of ts/tv sample_A 1532 1020 512 1.99 sample_B 1478 998 480 2.08stats默认按VCF里每个样本分别统计输出的第二列就是每个样本的SNP数量。这个结果里的SNP数量是统计了所有位点的如果你想先过滤到PASS可以先用view做一次过滤再管道传给statsbcftools view -i FILTERPASS your_file.vcf.gz | bcftools stats - stats_pass.vcf.gz注意管道传给bcftools stats -时从stdin读取的VCF必须是未压缩文本格式否则会报错。3.2 让bcftools只统计某一组样本有些场景不需要所有样本只想看某个子集。假设样本名单存在samples_to_check.txt文件里一行一个样本名可以用--samples参数指定bcftools stats --samples samples_to_check.txt your_file.vcf.gz sub_stats.txt这个操作只对给定样本做统计运行速度也快很多。你还可以在stats文档里看到--samples-file的用法实际上就是从文件读取样本子集。配合bcftools view -s同样能实现“先筛样本后统计”但stats的--samples更直接不会改动原始文件也不会因为样本名顺序不同产生额外文件。如果你手头不是VCF.gz而是普通的VCFbcftools也能读只是大文件不压缩读起来会很慢。建议所有大VCF都先压缩并建索引bgzip your_file.vcf bcftools index -t your_file.vcf.gz这一步几乎不会出错但很多人容易漏掉。没有索引的文件在很多bcftools子命令里会直接报错比如“Failed to open index”。3.3 理解stats输出别把Indel混进SNP里bcftools stats的输出很长很多人一看就眼晕。这里我最想强调的一点是SNP counts by sample这一段统计的是SNP不是所有变异。bcftools内部会区分SNP与Indel所以直接看这一段就能拿到干净的SNP计数不需要你再另外过滤。如果你觉得自己用Python写会更可控也可以先用bcftools query把每个样本的GT字段全部导出bcftools query -f %CHROM\t%POS\t%REF\t%ALT[\t%SAMPLE%GT]\n your_file.vcf.gz per_sample_gt.txt然后让Python去读这个精简文件。这样就把“解析VCF”这个重活交给bcftoolsPython只做纯文本统计速度和稳定性都会好很多。这种方法尤其适合那种需要做定制化统计、但我又想避开复杂VCF解析逻辑的时候。4. Python批量处理把统计结果变成干净表格4.1 为什么已经有了bcftools还要写Pythonbcftools stats确实能输出每个样本的SNP计数但它输出的是文本报告不是直接可用的数据分析表格。你想做后续的样本分组比较、画图、筛选异常样本都得先把结果整理成CSV或DataFrame。这时候Python就有优势了整合多个统计维度、按组求均值、生成可视化图表都非常方便。另一个用Python的原因是自定义统计逻辑。比如你想同时统计每个样本的杂合SNP数、纯合SNP数、缺失率还想去掉某些低质量位点bcftools stats给的信息不够细但写Python脚本能完全按你的规则来。我这里分享一个我自己常用的脚本它不依赖pysam只读文本VCF结构简单容易读懂和修改。import gzip from collections import defaultdict def parse_vcf_snp_stats(vcf_path, pass_onlyTrue): # 记录每个样本的统计结果 stats defaultdict(lambda: {total_snp: 0, het: 0, hom_alt: 0, missing: 0}) opener gzip.open if vcf_path.endswith(.gz) else open with opener(vcf_path, rt) as fin: sample_names [] for line in fin: if line.startswith(##): continue if line.startswith(#CHROM): header line.strip().split(\t) sample_names header[9:] continue fields line.strip().split(\t) chrom, pos, ref, alt, filt fields[0], fields[1], fields[3], fields[4], fields[6] # 只统计SNPREF和所有ALT都必须是单碱基 if len(ref) ! 1: continue if any(len(a) ! 1 for a in alt.split(,)): continue if pass_only and filt ! PASS: continue # 从第10列开始是每个样本的数据 sample_data fields[9:] for idx, sample in enumerate(sample_names): gt_field sample_data[idx].split(:)[0] gt_format gt_field.replace(|, /) alleles gt_format.split(/) if . in alleles: stats[sample][missing] 1 continue if len(alleles) 2: a1, a2 int(alleles[0]), int(alleles[1]) if a1 0 and a2 0: # 参考纯合不计入SNP总数 continue elif a1 ! a2: stats[sample][het] 1 stats[sample][total_snp] 1 else: stats[sample][hom_alt] 1 stats[sample][total_snp] 1 return sample_names, stats if __name__ __main__: vcf_file your_file.vcf.gz samples, result parse_vcf_snp_stats(vcf_file) print(sample\ttotal_snp\thet\thom_alt\tmissing) for s in samples: r result[s] print(f{s}\t{r[total_snp]}\t{r[het]}\t{r[hom_alt]}\t{r[missing]})这个脚本里有一个细节值得注意GT字段里可能用|分隔等位基因也可能用/分隔前者通常表示已经分型phased后者表示未分型。统计时我都会先统一成/再处理避免漏掉。另外面对多等位基因位点比如AC,G脚本的SNP判断逻辑会依据ALT是否都是单碱基但真正的基因型计数仍依赖于样本的GT是0/1还是0/2等情况目前我把它统一视为非参考等位基因存在所以只要不是0/0就计入总数这在绝大多数场景是够用的。4.2 脚本运行起来输出长这样假设你的VCF里有sample_A和sample_B两个样本运行上面的脚本后终端会输出sample total_snp het hom_alt missing sample_A 1532 1008 524 12 sample_B 1478 976 502 35这样一张表格比bcftools stats输出更直观可以让你一眼看出不同样本的杂合与纯合突变比例。如果你还想继续做样本间比较可以把输出重定向到CSV文件或者在Python里直接生成pandas DataFrame。修改一下print部分或者把samples和result直接喂给pd.DataFrame都非常容易。这里额外说一个统计口径的坑有的项目里样本在某个位点的GT是0/0但位点本身在群体里是一个已知SNP这个“0/0”是不是要算进该样本的SNP里我的习惯是不算因为这个统计看的是“该样本携带的非参考等位基因”0/0表示该样本在这个位点没有变异。但如果你的项目关心的是“该样本在多少已知SNP位点有基因型数据”那统计逻辑就完全不同了。所以写脚本之前先明确统计口径这一步比任何代码优化都重要。4.3 用Python给统计结果画图直观看异常样本拿到每个样本的SNP统计结果后另外一个很有价值的操作是画箱线图或柱状图快速发现离群样本。我这里用matplotlib画一个最基础的分组柱状图import matplotlib.pyplot as plt samples [sample_A, sample_B, sample_C, sample_D] total_snps [1532, 1478, 2015, 1490] hets [1008, 976, 1350, 999] hom_alts [524, 502, 665, 491] x range(len(samples)) plt.figure(figsize(10, 6)) plt.bar(x, hets, labelhet, color#4C72B0) plt.bar(x, hom_alts, bottomhets, labelhom_alt, color#DD8452) plt.xticks(x, samples, rotation45) plt.ylabel(SNP count) plt.title(SNP counts per sample) plt.legend() plt.tight_layout() plt.savefig(snp_counts_per_sample.png, dpi150)从图上一眼就能看出sample_C的SNP总数明显偏高这时候我就知道要去查这个样本是否有样本污染、测序覆盖度是否异常或者是否来自遗传背景差异较大的个体。画图看起来只是锦上添花但在我实际项目里它经常能第一时间暴露问题。4.4 大规模VCF文件下的性能优化思路如果你处理的VCF文件动辄几百个样本、上千万个位点纯Python逐行解析会变得很慢。我实测过一个300样本、约800万位点的VCF用上面的纯Python脚本大概要跑七八分钟勉强能接受。如果还想再提速有两条路可以走。第一条是先用bcftools把文件里的样本数和位点数缩减只保留需要分析的样本还有通过质量过滤的位点再交给Python处理。比如bcftools view -S samples_selected.txt -i FILTERPASS your_file.vcf.gz filtered.vcf.gz然后再对filtered.vcf.gz跑Python脚本速度能快好几倍。第二条路是使用pysam库它是一个Python和HTSlib的绑定库能直接读取BCF/VCF压缩文件速度接近C工具。如果你的分析逻辑很复杂需要频繁查询位点pysam是更好的选择。import pysam vcf_in pysam.VariantFile(your_file.vcf.gz) sample_names list(vcf_in.header.samples) stats {s: {total: 0, het: 0, hom_alt: 0} for s in sample_names} for rec in vcf_in: if len(rec.ref) ! 1: continue if any(len(a) ! 1 for a in rec.alts or []): continue if rec.filter.keys() and PASS not in rec.filter.keys(): continue for s in sample_names: gt rec.samples[s].get(GT) if gt is None: continue if -1 in gt: continue a1, a2 gt if a1 0 and a2 0: continue if a1 ! a2: stats[s][het] 1 stats[s][total] 1 else: stats[s][hom_alt] 1 stats[s][total] 1这种方式熟悉之后非常顺手尤其适合后续要做更多变异注释、信息提取的项目。我的建议是一次性任务用纯Python更安全重复性的、在流程里要跑很多次的任务直接把pysam版本封装成函数。5. 实操中高频遇到的坑与排查技巧5.1 样本ID和文件编码问题第一个容易坑人的点是样本名里的隐藏字符。有时候VCF里的样本ID是从外部工具传过来的带着不可见字符或换行符用bcftools query -l看不出来但落到脚本里就成了奇怪的样本名导致最后的统计表出现一串莫名其妙的多余行。处理方式很简单拿到样本列表后先检查长度和字符如果发现异常就做strip和清洗。另外VCF文件本身的编码也值得留意。如果是从Windows环境传过来的文件可能存在换行符问题最好在Linux下用dos2unix整理一下再分析。压缩的VCF.gz文件则建议规范压缩名和索引名不要一会儿叫xxx.vcf.gz一会儿又叫xxx.vcf.gz.tbi路径写错是再低级但也最常见的错误。5.2 FILTER和INFO筛选陷阱很多刚接触bcftools的人会默认stats输出的就是“最终可靠”的SNP数量其实不然。如果你的VCF里包含了大量低质量位点而且FILTER列不是PASS这些位点默认都会被算进去。所以我自己的习惯是做大范围质控时先过滤bcftools view -i FILTERPASS input.vcf.gz | bcftools stats - stats_pass.txt在Python脚本里我也在开头就加入了pass_only参数默认只统计PASS位点。另一个容易被忽视的筛选项是INFO字段里的variant类型。有些VCF会写明INFO/ANN或INFO/CSQ等注释信息但如果你只关心SNP还是直接用REF和ALT长度来判断更可靠因为有些注释工具的过滤标注并不严格等同于变异类型。5.3 多等位基因位点与GT格式的细节VCF里的位点不一定是双等位基因比如0/1和0/2出现在同一个位点说明这个位置有多条ALT序列。统计样本SNP的时候如果样本基因型是0/2那它确实携带非参考等位基因应当计入该样本的SNP总数。但如果你的目标是“样本有多少个SNP位点发生了变异”那么无论该位点有多少条ALT一个样本最多计一次。我的脚本里就采用了“一个位点一个样本只计一次”的逻辑。这个逻辑的取舍要根据你的项目定义来我强烈建议在脚本注释里写明这一条不然过两个星期回来看脚本自己都会犹豫当时是怎么数数的。5.4 bcftools提示打不开文件或索引缺失在bcftools操作中最经典也最让人着急的报错是“Failed to open index”。出现这个报错几乎都是因为你的VCF是压缩格式但没有生成对应的索引文件或者索引文件是旧版本。解决办法是重新生成索引bcftools index -t your_file.vcf.gz如果你拿到的是一个未压缩的VCF但文件非常大也建议先压成bgzip格式再建索引否则后续很多操作都会变慢。还有一种情况是你用管道把bcftools view的输出传给bcftools stats但忘了在stats后面加-bcftools会认为你要打开一个名为空字符串的文件直接报错。这个坑我踩过不只一次命令行里的小短线经常被忽略。我把这部分常用问题整理成表方便你排查现象常见原因快速处理bcftools: command not found未安装或conda环境未激活安装bcftools或conda activateFailed to open index压缩VCF没有索引bcftools index -t file.vcf.gzstats输出不含样本统计版本较旧未支持对应选项升级bcftools版本并查看文档Python脚本样本名多了一行样本名包含隐藏字符strip()清洗或检查文件编码统计总数明显偏大FILTER列质量位点未过滤使用-i FILTERPASS先过滤统计结果包含Indel没有按REF/ALT长度判断脚本中过滤非单碱基ref/alt5.5 关于bcftools stats结果的一个小心得我再分享一个实际项目里非常有用的小技巧bcftools stats报告里的Ts/Tv转换/颠换比也能辅助判断数据质量。正常人类全基因组SNP的Ts/Tv比值大约在2.0左右如果某个样本的Ts/Tv明显低于1.5那可能是测序错误率高、或者样本混合出了问题。做每个样本的SNP统计时顺手看一下这个值比只看数量多了一个质量的判断维度。6. 把统计结果用起来扩展方向与进阶思路6.1 合并多个VCF文件的样本统计结果有些项目按染色体或区域分析每个文件只包含部分位点。你想得到全基因组尺度的每个样本SNP总数最顺滑的做法是先合并VCF再做统计bcftools concat chr1.vcf.gz chr2.vcf.gz chr3.vcf.gz -Oz -o all_chr.vcf.gz bcftools index -t all_chr.vcf.gz bcftools stats all_chr.vcf.gz all_stats.txt注意concat要求所有输入文件的样本集合完全一致位置信息不能重叠否则会报错。合并后的VCF再做Python脚本统计结果就是每个样本全基因组范围的SNP完整计数。如果不想合并也可以在多个文件上分别跑统计然后写个小脚本把结果按样本累加但这种分散处理方式容易漏掉某些样本远不如先合并再统计省心。6.2 在Python里做样本分组比较拿到每个样本的SNP统计表格之后最常见的下一步是分组比较。比如你想比较对照组和处理组的SNP数量是否存在显著差异直接用pandas和scipy就能做import pandas as pd from scipy import stats df pd.read_csv(snp_counts.csv) group1 df[df[group] control][total_snp] group2 df[df[group] treatment][total_snp] t_stat, p_value stats.ttest_ind(group1, group2) print(ft{t_stat:.4f}, p{p_value:.4f})这个逻辑对很多场景都适用但要注意先检查数据是否符合正态分布、方差是否齐性。如果不满足就用Mann-Whitney U检验。这一步看似简单但会直接影响结论的可靠性。6.3 将统计结果输出为报告如果你要给团队成员或合作方交付结果纯文本或CSV可能不够直观。我一般会把统计结果做成一个包含表格和柱状图的PDF报告。Python里用reportlab或直接生成Markdown再转PDF都可以。重点是把样本ID、SNP总数、杂合数、纯合数、缺失数、Ts/Tv以及分组信息都列全这样拿到报告的人不需要自己再翻原始文件。6.4 后续还能做哪些分析每个样本的SNP统计是整个变异分析链条的起点接下来通常能接很多方向。如果你有表型数据可以做GWAS如果你想研究样本间的关系可以基于这些SNP计算遗传距离如果你想做进化分析可以筛选高多态性SNP位点用于构建系统发育树。这篇文章讲的统计方法虽然基础但它是后面一切分析的基石把这个环节做得干净准确后面每一步都会顺畅很多。我自己在实操中对这个流程最深的感受是不要让工具替代你的思考。bcftools帮你快速出数Python帮你整理和画图但真正的统计口径、过滤标准和后续分析方向都需要你根据项目需求来决定。遇到问题时先把“我要统计什么”想清楚再去找命令和代码往往比瞎试工具更高效。最后再分享一个我个人的小习惯每次做完SNP统计我都会把样本列表、原始VCF版本、bcftools版本、Python脚本、输出表格这5样东西放在同一个目录下并把过滤条件写在脚本注释里。这样即使过了很久再回来复现结果也不会因为忘了口径而抓狂。