做基因组相关分析的朋友应该都有过这种经历:手里一份全基因组 SNP 的 VCF 文件、一份自己关心的候选基因或分子标记列表,想快速出一张既能看变异密度分布、又能把标记位置叠加上去的染色体图。这个需求听起来不难,网上相关的 R 包和工具也一大堆,但真到了动手环节,缓存、环境依赖、坐标对齐、图例排版,哪一步都可能卡上小半天。这篇文章我就把平时我自己最常用的一套在线绘制流程完整拆开讲一遍,从数据准备到脚本实现再到常见坑位,争取你看完就能直接照着跑出一张能投稿的图。
我现在的首选方案是:用 Google Colab 这类在线环境跑一个基于 Python matplotlib 的脚本,输入三份文本文件——染色体长度表、变异位置表、标记位置表——输出一张多染色体并列的“密度柱状图 + 标记竖线”叠加图。整套流程不需要在本地安装任何生物信息学软件,只要能打开浏览器就能复现。下面直接进入正题。
1. 先想清楚再动手:这类图到底在画什么
1.1 密度图层和标记图层叠加后能读出什么信息
很多人第一次接触这类图,觉得它不就是“把 SNP 数画成柱子”吗?实际用途比想象中大得多。密度图最基础的功能是全基因组变异的可视化质控:如果一个样本或一组样本的变异密度在某条染色体上出现明显异常的尖峰,往往是测序覆盖度不均、比对错误或者参考基因组中混入冗余序列造成的,这在正式分析之前就能帮你筛掉一批数据问题。
叠加标记层之后,图的解读价值会再上一个台阶。假设你手里有 20 个与抗病性相关的候选基因,你想知道它们是不是集中在某个重组热点区域,或者是不是恰好落在 SNP 密度很低的选择清除区间。把标记的位置用竖线标到密度图上,一眼就能看出规律,这种直观性是表格怎么列都比不了的。
我一般还会在这种图上同时标上染色体骨架和 Mb 刻度,方便读者对齐物理位置。做群体遗传学的同事通常还会再加一层窗内平均 Fst 或 π 的信号,不过那是进阶玩法,我们先把密度和标记这两个常规图层做扎实。
1.2 为什么我选在线绘制而不是本地软件
本地画这类图的常见选项有 R 的 karyoploteR、ggbio,或者直接用 karyoploteR 画染色体分布,也有专门做全基因组可视化的 Circos。这些工具确实都能出图,但问题在于环境配置。很多做生物学的朋友,课题做到一半要临时画图,本地环境可能 Python 版本不对、R 包依赖冲突、Java 版本不兼容,折腾安装的时间比画图本身还长。
在线方案的核心优势就是零配置。只要打开浏览器进入 Colab,所有的 Python 环境、生物信息学常用库基本都是预装好的,缺什么直接 pip install,不需要动本地系统。而且代码和参数全部写在同一个文件里,发给合作者的时候别人打开就能复现,数据更新后重跑一遍就行,不存在“我这能跑你那就报错”的尴尬。
当然在线方案也有自己的局限。Colab 免费版对上传到临时目录的文件大小和会话时长有限制,如果处理超大 VCF 文件,可能得先做一次轻量提取,把几 GB 的 VCF 压缩成几十 MB 的两列位置文件再传上去。另外涉及隐私或敏感数据时,也要注意不要盲目上传到任何在线平台,这是底线。
1.3 密度的本质:从一堆点事件到一条可读的信号
从算法角度讲,变异位点本身是一组在染色体上的离散坐标点,这些点本身没法直接画成柱状图,必须先完成一步“分箱聚合”。最常见的方法就是滑动窗口计数:把一条染色体切成长度相等的窗口,比如每 1 Mb 一段,然后统计每个窗口里面落进了多少个 SNP,这个计数值就是该窗口的密度。
窗口大小怎么选很关键。窗口太大,细节会被平均掉,小范围的峰值看不出来;窗口太小,噪音又太强,相邻窗口的数值忽高忽低。我自己的经验公式是:先看变异总数和基因组大小,估算平均每 Mb 的变异数,然后让每个窗口至少能落到 50~200 个 SNP,这样既平滑又不丢结构。
更进阶的做法是重叠滑动窗口,也就是窗口之间有不少重叠区域,这种方式画出来的曲线更平滑,但计算量会翻好几倍,对于常规示意图来说,非重叠窗口完全够用。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据准备:三份输入文件都要什么格式
2.1 染色体长度表(chrs.txt)
画图第一件事是告诉程序“这个物种有几条染色体、每条多长”。我把这个文件叫做 chrs.txt,Tab 分隔,三列:染色体名、起始坐标、终止坐标。起始坐标我习惯写 0,终止坐标就是染色体长度,这个约定和后续画图时横轴从 0 开始的逻辑一致。
获取方式也简单,如果你有参考基因组的 fasta 文件,用 samtools faidx genome.fa 生成一个 .fai 文件,第一列是染色体名,第二列是长度,提取前两列就是现成的长度表。不想装 samtools 的话,直接用 Python 读取 fasta 统计每条序列长度也完全可以。
要注意的是,很多物种的染色体名字里会带空格,或者不同来源的文件一个写 chr1 另一个写 1,这些不一致问题虽然不影响画图程序本身,但会导致标记和密度对不到同一条染色体上,后面会专门展开。
2.2 变异位置文件(variants_pos.txt)
第二份数据是变异的位置。我们不需要 VCF 里那些复杂的信息,画图只需要两列:染色体名、位置坐标。我通常会在本地先用命令行工具把 VCF 压缩成一个轻量两列文件再上传。
以常见的 bcftools 为例,一行命令就能搞定:
bash复制bcftools query -f '%CHROM\t%POS\n' input.vcf.gz > variants_pos.txt
如果你只想保留 SNP、过滤掉 INDEL,可以加一个简单的 awk 过滤:
bash复制zgrep -v '^#' input.vcf.gz | awk 'length($4)==1 && length($5)==1 {print $1"\t"$2}' > variants_pos.txt
这里的第 4 列是 REF 碱基、第 5 列是 ALT 碱基,两者长度都为 1 说明是单碱基替换,也就是 SNP。对于大规模变异组数据,过滤这一步很重要,否则插入缺失会把密度图拉出一堆假峰。
2.3 标记文件(markers.txt)
标记文件是你想叠加到密度图上的那批位点,格式类似:染色体名、位置、标签文本。标签是可选列,如果不需要显示名称,两列就够。
比如:
code复制chr1 23450000 GeneA
chr1 88760000 Marker1
chr3 145200000 QTL_region
如果标记很多,整理时建议统一命名规范,不要用中文和特殊符号。倒不是程序不支持,而是在线环境的默认字体经常缺少中文字形,画出来容易变成方框,到时候改字体又是一堆麻烦事。
3. 核心绘图脚本:密度与标记怎么叠在一张图上
3.1 脚本框架和依赖选择
我选用了 Python 生态里的三个库:pandas 负责读表,numpy 负责分箱统计,matplotlib 负责最终绘图。Colab 里这三个库都是预装好的,不需要额外安装,这也是我推荐这个组合的原因之一。
脚本的整体逻辑分四步:读入三份输入文件 → 对每条染色体统计窗口密度 → 逐个染色体画密度柱状图和标记竖线 → 保存输出。代码量不大,但每一步都有几个容易踩的细节。
3.2 密度统计:窗口计算的关键细节
统计密度我直接用 numpy 的 histogram 函数,它本质上就是分箱计数。给定一个染色体长度和窗口大小,先用手动构造的方式生成一组窗口边界,然后把该染色体上的全部变异坐标扔进 np.histogram,返回结果就是每个窗口的计数。
python复制bins = np.arange(0, chrom_len + window_size, window_size)
hist, edges = np.histogram(positions, bins=bins)
这里有个容易忽略的坑:np.arange 生成的窗口边界最后一个值可能刚好不够覆盖染色体末端,所以我把上限定成 chrom_len + window_size,保证最后一个窗口能包住染色体的最后一位,避免坐标等于染色体长度的位点被丢弃。
另外,每根柱子的横坐标我取的是窗口中心位置,也就是左边界加上半个窗口大小。如果你直接把左边界当横坐标画,柱子会整体左移半个窗口,图上的峰值位置和信息表对不上。
3.3 绘制主图:染色体骨架、密度柱、标记竖线怎么叠
染色体骨架我用一个非常矮的矩形画在底部,颜色用灰色,宽度正好是染色体长度。这么做的好处是读者能清楚看到染色体本身的边界,密度柱子就不会像浮在半空中。
密度柱用半透明颜色填充,方便柱子和顶部的标记线互相透视。标记叠加时,我在每个标记位置画一条贯穿密度图下半部分的竖线,颜色要和密度柱明显区分开,同时顶部用 annotate 标上名称,竖线和标签使用同一种颜色,读者就能自然地把线和字对应起来。
这里还要注意一个细节:不同染色体的密度峰值高度差异可能很大,如果所有子图都用同一个 y 轴范围,小染色体的密度信号会被压成一条扁平线。我的做法是每条染色体单独设置 y 轴范围,也就是每条染色体内部自比,这样小染色体的分布细节也能看清。如果你需要做染色体之间的横向对比,再改成全局统一 y 轴。
3.4 多染色体排版与坐标统一
多染色体的排版方式,我推荐用多个横向子图纵向堆叠,每条染色体一行,从上到下排列。所有子图的横轴统一换算成 Mb 单位,也就是把原始坐标除以 1e6,这样图的 x 轴刻度就是用户熟悉的 Mb 数值,而不是一长串难以直观理解的 bp。
子图间距也值得调整。染色体越短,子图挨得越近时,标签越容易重叠。我一般把 hspace 设成 0.3~0.5,具体数字根据染色体数量微调。如果只有几条染色体,间距可以大一点,看起来更清爽;如果像小麦那样有二十几条染色体,间距就得压缩,否则整张图会拉得很长。
4. 在线复现:Google Colab 里跑通整个流程
4.1 创建 Colab 笔记本并上传数据
进入 Colab 新建一个笔记本,第一步就是上传数据。数据量小的时候,我直接这样写:
python复制from google.colab import files
uploaded = files.upload()
执行后会弹出文件选择框,把 chrs.txt、variants_pos.txt、markers.txt 三个文件一次性上传就行。上传成功后文件会存在当前会话的临时目录里,脚本用相对文件名就能读到。
如果数据量较大,或者你希望能长期保存数据文件,建议把文件放到自己的 Google Drive,然后挂载:
python复制from google.colab import drive
drive.mount('/content/drive')
之后把文件路径写成 /content/drive/MyDrive/.../chrs.txt 即可。上传之后建议先跑一句 !ls -lh 确认文件确实在,我之前就有过文件没传成功、后面脚本报 FileNotFoundError 的经历。
4.2 完整代码脚本与运行说明
下面是我把整个流程整理后的一份完整可跑脚本。你在 Colab 新建代码单元格,直接粘贴运行就行。
python复制import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.patches import Rectangle
# ---------- 输入输出路径配置 ----------
CHROM_LEN_FILE = "chrs.txt"
VARIANT_FILE = "variants_pos.txt"
MARKER_FILE = "markers.txt"
OUTPUT_FILE = "chromosome_plot.png"
# ---------- 绘图参数配置 ----------
WINDOW_SIZE = 1_000_000 # 统计密度用的窗口大小,单位 bp,这里取 1Mb
FIGSIZE = (14, 1.0) # 单条染色体占的高度,后续乘染色体数
# ---------- 读入数据 ----------
chroms = pd.read_csv(CHROM_LEN_FILE, sep="\t", header=None,
names=["chrom", "start", "end"])
variants = pd.read_csv(VARIANT_FILE, sep="\t", header=None,
names=["chrom", "pos"])
markers = pd.read_csv(MARKER_FILE, sep="\t", header=None,
names=["chrom", "pos", "label"])
# ---------- 按染色体数创建子图 ----------
n_chroms = len(chroms)
fig, axes = plt.subplots(n_chroms, 1,
figsize=(FIGSIZE[0], FIGSIZE[1] * n_chroms),
sharex=False)
plt.subplots_adjust(hspace=0.4)
if n_chroms == 1:
axes = [axes]
for idx, row in chroms.iterrows():
ax = axes[idx]
chrom_id = row["chrom"]
chrom_len = row["end"]
# 当前染色体上的变异坐标
sub_pos = variants.loc[variants["chrom"] == chrom_id, "pos"].values
# 窗口密度统计
bins = np.arange(0, chrom_len + WINDOW_SIZE, WINDOW_SIZE)
hist, edges = np.histogram(sub_pos, bins=bins)
centers = (edges[:-1] + WINDOW_SIZE / 2) / 1e6
widths = np.diff(edges) / 1e6
ymax = max(hist.max(), 1)
# 密度柱状图
ax.bar(centers, hist, width=widths, color="#4C72B0",
alpha=0.7, edgecolor="none", linewidth=0)
# 染色体骨架
ax.add_patch(Rectangle((0, -ymax * 0.08), chrom_len / 1e6,
ymax * 0.04, facecolor="#8C8C8C",
edgecolor="black", linewidth=0.8, zorder=5))
# 标记竖线
sub_marker = markers.loc[markers["chrom"] == chrom_id]
for _, mk in sub_marker.iterrows():
pos = mk["pos"] / 1e6
ax.plot([pos, pos], [ymax * 0.02, ymax * 0.85],
color="#DD8452", linewidth=1.4, zorder=6)
ax.annotate(mk["label"], xy=(pos, ymax * 0.9),
xytext=(0, 6), textcoords="offset points",
ha="center", fontsize=9, rotation=90,
color="#DD8452")
# 坐标轴范围与刻度标签
ax.set_xlim(0, chrom_len / 1e6)
ax.set_ylim(-ymax * 0.2, ymax * 1.2)
ax.set_yticks([])
ax.spines[["top", "right", "left"]].set_visible(False)
ax.set_title(chrom_id, loc="left", fontsize=11, pad=8)
if idx == n_chroms - 1:
ax.set_xlabel("Genomic position (Mb)")
else:
ax.set_xticklabels([])
plt.tight_layout()
plt.savefig(OUTPUT_FILE, dpi=300)
plt.show()
这份脚本输出的是一个纵向堆叠的多染色体图,染色体名显示在左上角,密度柱在中间,标记竖线从底部延伸到密度柱中上部,标签竖向显示在最高点上方。
4.3 运行后怎么检查输出图是否合理
出图之后不要急着收工,先检查几个地方。第一,染色体名字有没有对整齐,密度柱是否只出现在有变异的区域。第二,标记竖线有没有落在坐标范围之外,如果某个标记的坐标写错了,竖线可能直接被裁掉。第三,密度柱的整体形态是否符合预期,正常情况下 SNP 密度会在染色体两端高、中间低,这是普遍规律,如果你看到一条染色体中间出现突兀的尖峰,数据可能有污染或比对问题。
另一个实用建议是输出矢量图。投稿的时候很多期刊要求矢量图,你可以在代码里加一句:
python复制plt.savefig("chromosome_plot.pdf", dpi=300)
PDF 格式会保留所有矢量信息,后续用 AI 或 Inkscape 编辑字体和细节都很方便。
5. 常见问题与排查技巧实录
5.1 染色体位置对不上
这是我最常遇到的问题。表现是密度图能画出来,但某条染色体上一片空白,或者标记竖线画到了完全错误的位置。排查第一步是检查文件里的染色体名是否完全一致,比如 chr1 和 1 在 pandas 里就是两个不同的值,直接筛选会查不到数据。
处理方法是在读入数据后统一做一次标准化,去掉 chr 前缀,比如:
python复制chroms["chrom"] = chroms["chrom"].astype(str).str.replace("^chr", "", regex=True)
变异文件和标记文件做同样的处理,保证三份文件用的是同一套染色体命名。
另一个容易踩的坑是 0-based 和 1-based 坐标。VCF 的 POS 列是 1-based,BED 格式的起始坐标是 0-based,如果混着用,标记位置可能整体偏差 1 bp。这个误差在 Mb 尺度的图上完全看不出来,但如果哪天你要精确到单碱基的区间判断,就一定得统一坐标系。
5.2 密度图出现巨大尖峰
密度图中间突然冒出一个超高的柱子,十有八九是重复序列或比对错误区域。比如着丝粒附近、核糖体 DNA 区域,短读段容易发生多重比对,产生一堆假 SNP。另外一个常见来源是线粒体或叶绿体序列,如果参考基因组构建时没有屏蔽细胞器序列,这些区域在核基因组上会有大批同源位点,密度直线拉升。
处理办法不复杂:先在数据里去重和过滤。使用 VCF 时,用 bcftools filter 剔除 QUAL 过低或 DP 异常的位点,通常能消掉一大部分假峰。画图阶段也可以直接把已知的问题染色体排除掉,比如在 chroms 文件里删掉线粒体那行,眼不见为净。
5.3 标记文字重叠严重
当标记特别多、位置又扎堆时,标签会叠成一团黑。我的经验是分级处理:重要的标记用 annotate 标全名,其余次要标记只画竖线不画字。如果确实需要每个都标注,可以用 adjustText 库自动调整标签位置,避免重叠。
python复制!pip install adjustText
from adjustText import adjust_text
把需要标注的文本对象收集起来,统一传入 adjust_text(texts, ax=ax),它会用优化算法把文字弹开到不重叠的位置。这个库在 Colab 里安装很快,效果也不错,就是运行时间会稍微长一点,标记上千个时尤其明显。
5.4 数据量太大导致运行卡顿
如果变异文件有上千万行,pandas 读入和 loc 筛选会比较吃力。我通常会先压缩数据:不需要保留每条变异的具体碱基变化,只要位置,几千万行也就几百 MB,但 pandas 读起来仍然慢。
这时候可以换个读取方式,直接用 numpy 按列读,或者分染色体处理,一次只筛一条染色体,避免每次 loc 都扫全表。再不够的,从 VCF 阶段就按染色体拆分,每个染色体单独出一个位置文件,画图时逐条读入并绘制。别小看这个优化,高中基因组数据基本都是这个量级,处理得当可以从“等到怀疑人生”变成“十几秒出图”。
最后再分享一个小经验
这套流程我陆陆续续用了挺久,最大的体会是:画图脚本本身不难,但数据清洗和坐标对齐的功夫占了八成时间。每次项目换物种、换参考基因组版本,最容易出问题的永远是染色体命名和窗口参数,而不是绘图代码。所以我建议你第一次跑通之后,把脚本里与物种相关的参数单独抽出来集中管理,比如染色体长度、窗口大小、数据路径,下次换数据的时候只需要改这一处。
另外一个小技巧是,如果最终要投稿,配色选色盲友好的颜色会稳妥很多,比如 Okabe-Ito 配色方案,密度柱用蓝色、标记线用橙色,大多数情况下既能区分又不刺眼。先截图发给同事看一眼,再定最终参数,省得来回改图浪费半天。
