4. PDB文件解析(一):原子记录、坐标、元素信息提取

好,咱们今天正式开始碰PDB文件的骨头。我个人做了这么多年蛋白质结构分析,发现一个规律:只要是跟结构数据打交道的活儿,八成以上的bug都出在最开始的解析阶段。说白了,你后面算得再花哨,如果原子坐标读进来就是错的,那整个分析就是垃圾进垃圾出。

这一节,我们专门把PDB文件里最核心的记录——原子记录,给剥开了看清楚。

4.1 原子记录长什么样?

你随便打开一个PDB文件,拉到中间,会看到一大片以“ATOM”开头的行。这就是原子记录。如果你看到的是“HETATM”,别慌,它俩格式几乎一样,只是ATOM代表标准残基的原子,HETATM代表配体、水分子或非标准残基。

我刚开始学的时候,觉得这俩没区别,结果有一次跑分子对接,死活找不到配体对接口袋。排查了半天,发现是我的解析脚本把HETATM行全过滤掉了。嗯,那滋味可不好受。

记录类型 含义 常见场景
ATOM 标准残基原子 蛋白质主链、侧链
HETATM 非标准原子 配体、水分子、金属离子

一条原子记录长这样:

ATOM      1  N   ALA A   1     -0.525   1.362   0.000  1.00  0.00           N

这一行里信息量巨大。我用一个表格把这列位置和含义给你标清楚,你自己写解析脚本的时候,直接按这个偏移量切字符串就行:

列 起始 结束 字段 示例
1-6 1 6 记录名 ATOM
7-11 7 11 原子序号 1
13-16 13 16 原子名称 N
17 17 17 备用位置指示符
18-20 18 20 残基名称 ALA
22 22 22 链标识符 A
23-26 23 26 残基序号 1
27 27 27 插入码
31-38 31 38 X坐标 -0.525
39-46 39 46 Y坐标 1.362
47-54 47 54 Z坐标 0.000
55-60 55 60 占据率 1.00
61-66 61 66 温度因子(B-factor) 0.00
77-78 77 78 元素符号 N
⚠️ 踩坑提醒: 列77-78的元素符号位置,在早期PDB格式里可能没有。我见过一些老旧文件,第77列是空的,元素符号挤在12-14列。你写解析器的时候,最好先检查一下这一列有没有数据。

4.2 坐标信息才是核心

X、Y、Z这三列是PDB文件最值钱的数据。单位是Å(埃,10⁻¹⁰米)。

这里有个小坑:坐标值可能是正数也可能是负数,但格式必须是右对齐,预留8个字符宽度。假如你把坐标读成整数,那就完蛋了。我建议你理解一下PDB文件的列宽限制,这是为了兼容早期的Fortran程序而设计的。

坐标读取的Python代码,我个人习惯用切片加strip:

def parse_atom_line(line):
    rec_name = line[0:6].strip()
    if rec_name not in ('ATOM', 'HETATM'):
        return None
    
    atom_serial = int(line[6:11].strip())
    atom_name = line[12:16].strip()
    alt_loc = line[16:17].strip()
    res_name = line[17:20].strip()
    chain_id = line[21:22].strip()
    res_seq = int(line[22:26].strip())
    i_code = line[26:27].strip()
    
    x = float(line[30:38].strip())
    y = float(line[38:46].strip())
    z = float(line[46:54].strip())
    
    occupancy = float(line[54:60].strip())
    b_factor = float(line[60:66].strip())
    
    element = line[76:78].strip()
    if not element:
        element = atom_name[0]
    
    return {
        'serial': atom_serial,
        'name': atom_name,
        'residue': res_name,
        'chain': chain_id,
        'resid': res_seq,
        'x': x, 'y': y, 'z': z,
        'occupancy': occupancy,
        'b_factor': b_factor,
        'element': element
    }
💡 一个小技巧: 元素符号那列如果为空,我一般取原子名称的第一个字母作为后备。比如“C”代表碳,“N”代表氮。这个办法在大多数情况下都管用。

4.3 元素信息的推断与验证

有些PDB文件很规范,元素符号写得清清楚楚。有些文件,尤其是从旧数据库导出的,元素列可能是空的,或者写成了“CA”(钙)跟“CA”(Cα)混在一起。你想想看,如果不做区分,把钙离子当成了碳原子,那计算出来的静电势就全错了。

我曾经帮一个学生调试过类似问题。他跑MD模拟预处理,老是报原子类型不一致。我让他把解析出来元素符号打印出来一看,好家伙,所有金属离子都变成了第一个字母。后来我教他用原子名称加上残基名称来双校验,这才解决问题。

这是我的改进版元素推断逻辑:

def infer_element(atom_name, residue_name):
    # 如果是常见配体残基中的金属,直接返回
    metal_map = {
        'ZN': 'ZN', 'MG': 'MG', 'CA': 'CA',
        'FE': 'FE', 'MN': 'MN', 'CU': 'CU'
    }
    if residue_name in metal_map:
        return metal_map[residue_name]
    
    # 常规蛋白原子:取第一个字母
    element_map = {
        'N': 'N', 'C': 'C', 'O': 'O', 'S': 'S',
        'P': 'P', 'H': 'H'
    }
    first_char = atom_name[0]
    return element_map.get(first_char, 'X')

4.4 原子记录的整体数据结构

你解析完一整条原子记录后,最好设计一个结构体或字典来装这些信息。我习惯把所有原子存成一个列表,每个元素都是一个字典。这样后续按残基分组、按链切片都很方便。

下面这张SVG图帮你搭建一下整个原子记录解析的思维框架:

PDB原子记录解析流程图 PDB文件 行前缀 = ATOM / HETATM ? 列位置切片解析 (原子名/残基名/坐标/占据率/B因子) 元素列是否为空? 空 -> 根据原子名称/残基名称推断 | 非空 -> 直接提取 存入原子字典列表 原子序列号用6-11列 坐标用31-54列

解析完一条原子记录后,最终的数据结构推荐这样组织:

[
    {
        'serial': 1,
        'name': 'N',
        'residue': 'ALA',
        'chain': 'A',
        'resid': 1,
        'x': -0.525, 'y': 1.362, 'z': 0.000,
        'occupancy': 1.00,
        'b_factor': 0.00,
        'element': 'N'
    },
    ...
]

4.5 实战小建议

你如果要自己写PDB解析器,记住这几点:

  • 不要用split()切行:PDB格式是固定列宽的,空格位置有含义。split会把空白合并,导致你拿不到正确的列值。
  • 备用位置指示符要留着:同一个残基可能有多个构象,指示符是A、B还是空,会影响后续建模。
  • 插入码别丢掉:有的PDB文件里残基序号会重复(比如122A、122B),如果不处理插入码,你的残基索引就会乱套。
⚠️ 常见翻车现场: 有一些PDB文件是从不同来源合并的,里面原子序号可能不连续。如果你用原子序号作为字典键,那就等着覆盖数据吧。我建议用(链ID, 残基序号, 原子名称)这个三元组作为唯一标识。

好了,原子记录的基本解析逻辑已经清楚了。从下一段开始,我们会把解析出来的数据组织成更高级的结构,方便后续做距离计算、序列比对、或者机器学习特征提取。目前先把地基打牢,后面才不容易翻车。