PDB 文件格式解析:原子记录、坐标格式与常见错误

PDB 文件格式,说白了就是一套用纯文本拼出来的表格。你随便找个 .pdb 文件打开看,满屏都是固定长度的字符串。

我个人习惯在处理前先用 head -20 瞄一眼文件头。为什么?因为格式是 Fortran 77 时代定下来的,列必须对齐。错一个空格,解析就全乱套。

嗯,先看最核心的部分——原子记录。

原子记录(ATOM / HETATM)

这两种记录标识了分子中的所有原子。区别其实很简单:ATOM 记录的是标准氨基酸/核苷酸原子;HETATM 记录配体、水分子、金属离子等“非标准”实体。

它们的格式完全一致,都是 80 列定长记录。我刚开始接触时觉得这很死板,后来发现这种固定格式反而让解析变得极其稳定——只要按列切就行。

标准列分布(我通常用一个字典来记)

列位置 字段 数据类型 说明
1-6 记录类型 字符串 ATOM 或 HETATM
7-11 原子序号 整数 全局唯一编号
13-16 原子名称 字符串 如 CA ,两端带空格
17 交替位置 字符 构象不确定时标 A/B/C
18-20 残基名称 字符串 如 ALA
22 链标识符 字符 单字母链 ID
23-26 残基序号 整数 残基在链中的位置
27 插入码 字符 序列插入标记
31-54 坐标 X, Y, Z 浮点 单位:Å
55-60 占有率 浮点 0.00 - 1.00
61-66 温度因子 浮点 B 因子值

核心要点:列 13-16 的原子名称是右对齐的,列 18-20 的残基名称是左对齐的。很多人直接在 Python 里用 line.split(),遇到有空格的原子名(如 CA 是 " CA ",CA2 是 "CA2 " )就会直接错位。

坐标格式与精度陷阱

坐标占了 31-54 列,共 24 个字符,每个坐标固定 8 位宽。格式是 %8.3f,也就是说小数点后固定 3 位。

你想想看,3 位小数对于大部分 X 射线结构来说勉强够用,但如果你做的是高精度分子动力学模拟,这个精度可能会有问题。

我曾经犯过的错:直接使用 float(line[30:38]) 提取 X 坐标,但列索引从 0 开始的话应该是 [30:38],而不是 [31:38]。多一个少一个字符,整个分子的坐标漂移到几万 Å 之外。

一个标准的 ATOM 记录长这样:

ATOM     27  CA  ALA A   4      22.343  11.632  -5.390  1.00 45.82           C  

注意看原子名 CA 前后的空格。正因如此,我要求团队写解析代码时一律按列切片 + 手动 strip(),绝对不能用 split()。

常见错误归纳总结

我在项目中遇到过的 PDB 格式问题,总结起来无非以下几类:

  1. 残基跳跃 / 缺失原子:序列不连续,解析时出现 999 或 -1 等占位序号。
  2. 交替构象:列 17 出现 A / B,但大多数人只取 A 或占有率最高的那个。我建议在补全缺失原子之前,先把交替构象降维处理掉。
  3. 链 ID 混乱:使用小写字母 a、b 甚至空格。嗯,空格在严格模式下是不允许的,但很多老旧 PDB 文件就这么写。
  4. REMARK 中的分辨率信息:解析 REMARK 2 RESOLUTION. 2.30 Å 时要注意单位前缀,不同来源格式有差异。

一个小经验:拿到 PDB 文件后,先跑一个统计脚本,打印出所有记录类型、链 ID、残基序号范围。这一步能过滤掉半数以上的“脏数据”。

解析流程可视化

下面这张图是我做缺失补全前最常走的流程。注意看,我先做“交替构象处理”,再做“坐标提取”,这是有顺序逻辑的。

PDB 原子记录解析流程 原始文本行 是否为 ATOM / HETATM 开头? 否:跳过 / 标注 交替构象筛选取代 按列切片提取字段 坐标浮点转换 (X, Y, Z) 存入 DataFrame / 字典 解析标准:PDB 文件列宽固定,严禁使用逗号或制表符分隔

解析代码模板(Python)

我一般直接用 line[0:6] 做模式匹配,然后用 line[30:38] 这种切片语法提取坐标。虽然看起来繁琐,但出错的概率最低。

def parse_atom_line(line: str) -> dict | None:
    if len(line) < 54:
        return None
    
    rec_type = line[0:6].strip()
    if rec_type not in ("ATOM", "HETATM"):
        return None
    
    atom_num = 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_num = int(line[22:26].strip())
    icode = line[26:27].strip()
    
    try:
        x = float(line[30:38])
        y = float(line[38:46])
        z = float(line[46:54])
    except ValueError:
        return None
    
    occupancy = line[54:60].strip()
    b_factor = line[60:66].strip()
    
    return {
        "type": rec_type,
        "atom_num": atom_num,
        "atom_name": atom_name,
        "alt_loc": alt_loc,
        "res_name": res_name,
        "chain_id": chain_id,
        "res_num": res_num,
        "icode": icode,
        "x": x, "y": y, "z": z,
        "occupancy": occupancy,
        "b_factor": b_factor
    }

关于抗噪:我习惯在 try-except 块里做坐标转换。有些 PDB 在列 31-54 里混入了 ****** 或空值。没必要让一行坏数据搞崩整个 pipeline。

解析完原子记录,下一步就是从这些信息里判断哪些残基是“残残缺的”、哪些是“可以补的”。这部分内容,我们留在后面具体展开。