从GTF文件构建基因注释表,彻底解决ID转换难题
很长时间以来做基因表达分析的人都绕不开一个重复又枯燥的步骤拿到一个测序项目的结果里面的基因ID是Ensembl ID数据库注释是Symbol文章里要用的却可能是另一个平台的ID。每次都要跑到各个注释数据库网页上批量查询或者从biomaRt下载注释表费时费力还会遇到网络不稳、数据库更新导致ID映射不对等一堆麻烦事。后来我逐步换了一种思路直接从最原始的GTF注释文件出发自己动手生成一份干净的基因注释表格用它做ID转换。好处很明显——注释来源可控、离线可用、转换逻辑透明而且这个表格还能反复用顺便解决了Ensembl ID、NCBI ID、Symbol、基因类型等一堆信息对不上的问题。这篇就把整个处理和踩坑过程完整记录下来。1. GTF文件到底存了什么为什么要从它做起很多生信新手对GTF文件的理解停留在“用它算过表达量”这个层面比如用featureCounts或者STAR的时候需要它作为注释参考。但其实GTF文件的内部结构相当讲究理解了它后面做ID转换才能不迷糊。GTF全称是Gene Transfer Format是一行一行的制表符分隔文本每一行记录一个基因组上的特征feature。标准格式一共9列列号字段作用1seqname染色体/片段名称2source注释来源比如ENSEMBL、HAVANA3feature特征类型常见是gene、transcript、exon、CDS4start起始坐标5end终止坐标6score分值通常用点号占位7strand正负链或-8frame阅读框通常用点号占位9attribute属性键值对用引号和分号分隔最关键的就是第9列。比如在Ensembl下载的人类GTF文件里gene这一行通常会带这几个属性gene_id、gene_version、gene_name、gene_source、gene_biotype。而transcript这一行则会多出 transcript_id、transcript_version、transcript_name、transcript_source、transcript_biotype 等。ID转换的基础就在这一列——gene_id是稳定的Ensembl IDgene_name是人读友好的基因名而我们经常用的NCBI Gene ID实际上在多数GTF里并不直接出现在attribute列需要结合gene_source等字段做关联处理或者直接用部分GTF里含有的tag字段去对应。实际中我见过不少人在第一步就吃亏直接用read.table或者read.delim读GTF简单按第9列的英文逗号分割结果发现属性列里既有逗号又有空格还有引号切出来的列乱七八糟后面合并注释表时直接报错。这个坑我们放在后面细说但先建立认知——GTF不是简单的表格第9列是一个半结构化的键值对字符串。2. 解析GTF的核心逻辑属性列不能靠“肉眼”拆我最初处理GTF属性列用的是最笨的办法逐行扫描把gene_id、gene_name、gene_biotype等用正则表达式一个个抠出来。这种方式在小规模测试时没问题但处理整个人类GTF约300多万行时会发现两个痛点一是速度慢二是容易漏掉字段顺序不一致的行。所以后来我转向了用专门的解析思路而不是硬抠字符串。R语言里处理GTF有现成包比如rtracklayer直接读GTF输出一个GRanges对象写起来简洁优雅。但实际场景下我反而更推荐“半手工”的方式——自己写一个轻量解析函数。原因有两条第一rtracklayer虽然好用但它会把genome、seqlevels等一堆东西一起加载当你只需要提取某一列属性做ID映射时全程扛着GRanges对象很笨重第二自己写解析逻辑能让你每一步都明确知道“哪一列是什么”“缺失值是怎么处理的”这对后面排查注释问题帮助极大。当然如果项目及其忙碌或者非要用GRanges做后续区间操作那用rtracklayer读一次然后转data.frame也是完全可行的。我只是建议把“读GTF”和“生成ID注释表”拆成两步保持每一步的可控性。我常用的解析策略是这样的library(dplyr) library(tidyr) # 假设已经用 data.table::fread 读入 GTF 文件 # 列名定为: chr, source, feature, start, end, score, strand, frame, attr parse_gtf_attr - function(attr_vec, wanted_keys c(gene_id, gene_name, gene_biotype)) { # 对每一条 attribute 字符串按分号分割 # 再把每个键值对里的引号去掉返回一个列表 lapply(attr_vec, function(x) { parts - strsplit(x, ; )[[1]] kv - strsplit(parts, ) kv - kv[lengths(kv) 1] keys - sub( .*$, , parts) vals - sub(^[^ ] , , parts) vals_clean - trimws(gsub(, , vals)) setNames(as.list(vals_clean), keys) }) }再把它展开成宽表attr_list - parse_gtf_attr(gtf_raw$attr) attr_df - bind_rows(lapply(attr_list, function(x) { as.data.frame(x[intersect(names(x), wanted_keys)], stringsAsFactors FALSE) }))这个思路的本质是不依赖字段顺序——不管GTF某一行里gene_id写在前面还是后面都能正确对位。这里提醒一下不同来源的GTF文件属性写法不完全一样。Ensembl的GTF字段通常是空格分隔的键值对例如gene_id ENSG00000141510; gene_version 10; gene_name TP53;。而NCBI的GTF则是另一种风格属性列里经常带有DbxrefGeneID:7157; NameTP53;这样的写法。所以解析函数里的分隔规则、引号清理策略都要根据你的GTF来源调整。我一开始在NCBI GTF上套用Ensembl的解析代码结果gene_name全部没抓到排查了半天才发现是分隔符和引号规则不一样。3. 用R代码生成注释表的完整流水线拿到解析好的属性列之后剩下的工作就是组合、去重、建映射表。下面给出一个完整的流程我在多个项目里验证过直接复制修改就能用。第一步先用fread快速读入整个GTF文件只保留需要的列library(data.table) gtf_raw - fread(Homo_sapiens.GRCh38.110.gtf.gz, sep \t, header FALSE, skip 5, # Ensembl GTF 前5行是注释实际数据从第6行开始 select c(1, 3, 4, 5, 7, 9)) colnames(gtf_raw) - c(chr, feature, start, end, strand, attr)注意skip 5这个参数值会随文件不同而变化。Ensembl的标准GTF文件开头一定有若干行以#开头的元信息注释数量并不总是5行有时候是6行、7行。保险做法是先读取前20行的字符串找到第一个不以 # 开头的行的行号再动态传入skip。我用了一个极其简单的判断方式header_lines - readLines(Homo_sapiens.GRCh38.110.gtf.gz, n 30) # 找到第一行不以 # 开头的行 first_data_line - which(!grepl(^#, header_lines))[1]然后第二步筛选feature为gene的行解析属性列构建基因层级注释表genebed - gtf_raw[feature gene] attr_list - parse_gtf_attr(genebed$attr, wanted_keys c(gene_id, gene_version, gene_name, gene_biotype, gene_source)) attr_df - bind_rows(lapply(attr_list, as.data.frame, stringsAsFactors FALSE)) gene_anno - cbind( gene_chr genebed$chr, gene_start genebed$start, gene_end genebed$end, gene_strand genebed$strand, attr_df ) %% distinct()这个步骤里还有两个值得补充的细节。第一为什么要取feature gene而不是全部特征因为基因的坐标应该是整个基因的跨度区间。如果你只看exon或者CDS坐标范围各不相同汇总起来还要手动取min和max徒增麻烦。GTF里的gene行就是为该基因所有转录本合并后的区间直接拿来用就行。第二关于基因去重。大多数GTF中每个gene_id只出现一次但有些注释文件里同一基因可能在不同source下重复出现比如Ensembl和HAVANA都有注释这时候distinct()只能去除完全相同的行对于同id不同source的情况我建议再进一步按gene_id聚合gene_anno_dedup - gene_anno %% group_by(gene_id) %% summarise( gene_name first(na.omit(gene_name)), gene_biotype first(na.omit(gene_biotype)), gene_chr first(gene_chr), gene_start min(gene_start), gene_end max(gene_end), gene_strand first(gene_strand), .groups drop )这一步做完整理出来的表才是真正能长期使用的注释底表。我通常会把这张表存成RDS和CSV两份RDS保留数据类型信息CSV方便用Excel打开查看。第三步如果要更进一步的转录本级ID转换那就需要再从GTF中提取transcript信息。同样筛选feature transcript解析属性列关注transcript_id、transcript_version、transcript_name、transcript_biotype。因为一个基因可能有多个转录本转录本表和基因表之间是通过gene_id关联的多对一关系保存好这层关联非常有用。比如做可变剪接分析时需要从转录本ID转换成基因Symbol没有这张表就要临时去翻注释。4. 基因ID转换的实际用法从表达矩阵到符号映射注释表建好之后真正的高频场景就是用它来给表达矩阵做ID转换。这步我会用一个简单的示例说明以后可以直接复用。假设已经拿到了一个基因表达矩阵行名是Ensembl ID带版本号的那种列名是样本名。我们要把行名替换成Symbol。expr_tpm - fread(expression_tpm.txt, data.table FALSE) rownames(expr_tpm) - expr_tpm$gene_id expr_tpm$gene_id - NULL # 去除版本号如 ENSG00000141510.10 - ENSG00000141510 clean_id - sub(\\..*$, , rownames(expr_tpm)) # 从注释表映射 anno_for_map - gene_anno_dedup %% select(gene_id, gene_name) %% filter(!is.na(gene_name)) mapping - data.frame( query_id clean_id, original_row rownames(expr_tpm), stringsAsFactors FALSE ) mapping - merge(mapping, anno_for_map, by.x query_id, by.y gene_id, all.x TRUE) # 有些基因名缺失时保留原始ID或者标注为 uncharacterized mapping$gene_name[is.na(mapping$gene_name)] - mapping$query_id[is.na(mapping$gene_name)] expr_tpm$symbol - mapping$gene_name[match(rownames(expr_tpm), mapping$original_row)]这段代码里最需要注意的是版本号处理。Ensembl的gene_id通常带点号加版本号比如ENSG00000141510.17而GTF第9列里gene_id和gene_version是分开存储的。如果你模糊地把整个带版本号的字符串和注释表里的gene_id直接比对多半匹配不上。我用的策略是统一去掉版本号再去做映射逻辑最简单也最稳。至于那些映射不到Symbol的基因很多教程直接建议filter掉。这个要看项目具体情况。如果是做差异基因富集分析过滤掉无Symbol基因影响不大但如果只是为了方便阅读结果表格我建议保留原始ID代替避免把分析结果的基因数量搞得前后不一致。5. 实测中最容易翻车的细节清单GTF处理中有几个隐藏很深的坑围着这些坑我至少浪费过两个下午。这里逐个写出来希望你不会再踩。5.1 第9列分隔方式不同数据的“脾气”不一样前面提到过Ensembl和NCBI属性列写法有差异但还有一个常见情况是有些第三方构建的GTF文件键值对之间用的是空格而不是标准的;分隔甚至引号缺失。解析时如果只写死了strsplit(x, ; )一旦遇到无引号或单空格风格就会解析失败。我的临时对策是在解析函数的开头加一步“标准化”工作把所有连续空格压缩成单空格把分号紧贴引号的模式统一先替换掉。也可以退一步在写解析逻辑时尽量兼容多种分隔方式normalize_attr - function(x) { x - gsub(\\s, , x) x - gsub(, , ; , x) x }这本质上是用宽容的输入解析去对抗格式混乱的现实数据。5.2 “基因名重复”不是小概率事件做ID转换时一个Symbol经常会对应多个Ensembl ID。比如某些基因家族成员在注释文件里拥有相同的基因名或者新旧注释版本之间基因名被重新分配过。如果你在merge后直接按行名取唯一值就会有数据被静默覆盖这是最危险的一种错误。碰到这种情况正确做法是先检查重复Symbol的分布再决定怎么保留dup_symbols - gene_anno_dedup %% group_by(gene_name) %% summarise(n n(), .groups drop) %% filter(n 1)查看dup_symbols的规模。如果重复不多可以手工挑主转录本对应的那个如果重复很多可以按基因类型或表达量高低来取舍。我一般会优先保留蛋白编码基因其次保留表达量较高或被主要分析流程选中的那个。5.3 染色体和坐标列的由来GTF里gene行的start和end是全基因最左端和最右端坐标。但有些版本注释文件里gene行实际上还被ORF等重叠区域影响偶尔会看到坐标比最长转录本还长的情况这是正常的不必惊慌。真正需要注意的是如果某次分析需要的是“基因位点”来画Manhattan图我推荐直接用我们整理的gene_anno_dedup里的chr、start、end而不要再去原GTF里按feature重新筛一遍因为后续每次筛都可能有重复或遗漏容易引入新错误。5.4 转录本ID转换的频率比你想象的高很多人以为转录本级ID转换只出现在转录本定量分析里但实际上做TE家族分析、同源基因比对、isoform切换分析时都需要它。如果只用基因级注释表做转录本级映射会因为一个基因对应多个转录本而产生严重的一对多问题最后结果根本无法解释。所以转录本级的注释表不妨顺手一起生成。同样是筛选feature transcript行解析transcript_id、transcript_name、gene_id然后做去重即可。生成之后存成文件需要的时候一行代码加载成本很低收益却很大。6. “生成注释表”这个动作本身可以沉淀成项目资产自从改用这个流程之后我最大的体会是注释表不是一次性工具而是值得反复维护、版本化的资产。每次拿到一个新版本GTF比如从GRCh37换成GRCh38或者从Ensembl 104换到110我都会重新跑一遍上述流程把生成的gene_anno_dedup和transcript_anno_dedup存到项目目录下专门的assets/annotation目录里文件名带版本号。后续不管做差异分析、富集分析还是可视化都基于这套表做ID映射不再每个分析单独去数据库下载注释。这套表还有一个很方便的延伸用途如果你的表达矩阵是Symbol格式而某个外部工具要求Ensembl ID那么你只需要把mapping反向应用即可。双向映射一次搞定比每次跑到网页上批量转换省太多时间。再补充一个实际小技巧这些注释表在读取时我建议用readRDS而不是read.csv因为包含的列类型比较丰富尤其是gene_biotype这种因子列用CSV保存后重新读取容易变成字符串某些dplyr操作就会多出不必要的转换警告。RDS格式虽然文件大一点但保留了R对象的全部信息加载速度也更快。7. 具体实战案例从Ensembl GTF到差异基因的符号标注讲到这里用一个虚幻的模拟项目场景把流程串起来方便你直接参考。假设某次分析拿到一个包含6个样本的RNA-seq表达矩阵行名是带版本号的Ensembl ID。用DESeq2做完差异分析后结果表里有几百个显著差异基因全部是ENSG编号。读者要求补充基因Symbol和基因类型我们直接用之前构建好的注释表处理。操作顺序是加载gene_anno_dedup。从差异结果表提取Ensembl ID列去版本号。合并注释表得到symbol和gene_biotype。检查重复symbol数量按之前说的策略处理。输出新表包含原始统计量、符号、基因类型、染色体位置。这样输出的差异基因表直接在文章里展示几乎不用再加工。而且由于基因类型的字段齐全后期做“蛋白质编码基因”子集富集分析时只需要一行filter。这个流程我们组内好几个同事都在用。有人说可以直接用clusterProfiler的bitr函数做ID转换但那个函数本质上依赖在线数据库或者本地的OrgDb包而OrgDb的版本不一定和GTF版本完全一致转换时偶尔会丢基因或映射保守。自建GTF注释表的方式则完全避免了这个版本错配问题因为注释版本是可控且透明的。最后再提醒一句无论用哪个来源的GTF拿到文件第一时间都应该记录文件版本号、下载日期和基因组版本并写进一个README文件。否则半年后你想重新跑一遍流程发现自己已经记不清当初用的是哪个注释版本就只能从文件的修改时间推断了。这种小细节看似不起眼却是生信项目可复现性里最容易被忽视的一环。