分子图表示基础:原子、键、图结构

好了,咱们正式开始第二讲。上一章我们聊了ADMET是什么,以及为什么图神经网络(GNN)特别适合干这活。这一章,我们来夯实最底层的地基——分子图到底是怎么表示的。

说白了,GNN 本质上是在图上做运算。你给模型输入的不是SMILES字符串,也不是分子指纹,而是一个图。这个图由什么构成?核心就是两点:节点(原子)和边(化学键)。如果你连这个结构都构造错了,后面模型性能再好也是空中楼阁。

2.1 原子特征:节点里到底放了什么信息

我在项目中遇到过不少新手,一上来就问:“原子特征是不是就写个原子序号?”嗯,没那么简单。一个原子在图里作为一个节点,它需要携带足够多的信息,让模型知道“我是什么、我在哪、我周围环境如何”。

常用的原子特征可以分为几大类。我习惯把它们归纳成一张表,方便你做特征工程时对照。

特征类型 具体属性 我的个人习惯
身份特征 原子种类(C、N、O……)、周期、族、原子质量 必须加。One-hot 编码或嵌入都行。
拓扑特征 度数(相连原子数)、隐式氢数、形式电荷 强相关。氢数别漏,很多ADMET性质跟氢键有关。
杂化与立体特征 杂化方式(sp³/sp²/sp)、手性标记、是否是芳香环原子 你想想看,芳香性对Caco-2渗透性影响多大?必须加。
化学环境特征 是否在环内、是否在侧链、与电负性原子的距离 高级特征,算力允许再上。我在做CYP450预测时加过。
我的避坑指南: 我曾经在一个项目里,只用了一组稀疏的one-hot原子类型(C、N、O、S),结果模型在含卤素(F、Cl、Br)的分子上全崩了。后来我才意识到,卤素的电负性和氢键受体能力很强,对ADMET影响巨大。从那以后,我建议原子类型覆盖到常见的所有重原子,至少包括C、N、O、S、P、F、Cl、Br、I。

代码实现上,我一般用 RDKit 来提取特征。RDKit 的 rdkit.Chem.rdchem.Atom 对象把大部分属性都封装好了。关键代码片段大概长这样:

from rdkit import Chem

# 以SMILES为例,解析分子
mol = Chem.MolFromSmiles("c1ccccc1O")
atom = mol.GetAtomWithIdx(0)  # 获取第0号原子

# 核心特征提取
atomic_num = atom.GetAtomicNum()        # 编号,如6表示C
degree = atom.GetDegree()              # 度数
implicit_h = atom.GetNumImplicitHs()   # 隐式氢数
hybridization = atom.GetHybridization() # 杂化状态
is_in_ring = atom.IsInRing()           # 是否在环内

注意,上面这些只是初步特征。你还需要把这些数值转化成向量。比如原子序数6,你会做成一个长度为约30的one-hot向量;杂化状态是枚举值,也需要one-hot。我习惯把所有标量特征直接拼接进节点特征矩阵,维度通常在40~100之间,看你选了多少属性。

2.2 键的特征:边不仅仅是连接关系

原子之间的边,如果只用一个布尔信号(连/不连)那就太亏了。化学键本身有丰富的信息,比如键级(单键、双键、三键、芳香键)、是否共轭、是否在环内。这些信息同样需要编码成药。

你可能会问:“键的特征对ADMET预测有用吗?”太有用了。举个例子:共轭键会影响紫外吸收(导致光毒性),双键数量会影响氧化代谢的难易程度。我在做hERG活性预测时,把键级和芳香性加入边特征后,模型的AUC直接提升了3个百分点。嗯,这个坑我踩过,所以现在特别重视。

常用的边特征有这些:

  • 键的类型:single、double、triple、aromatic(芳香键)。最好用one-hot。
  • 是否共轭:布尔值,但很多预测任务中很重要。
  • 是否在环内:环上的键通常刚性更强,影响分子构象。
  • 立体化学:顺反异构(如果涉及,通常用于手性分子的ADMET差异)。

提取键特征的代码也很直接:

bond = mol.GetBondWithIdx(0)  # 获取第0号键
bond_type = bond.GetBondType()           # 键类型枚举
is_conj = bond.GetIsConjugated()         # 共轭标志
is_ring_bond = bond.IsInRing()           # 环内标志

有了节点和边的特征,图的基本骨架就有了。下一步,就是把这些拼成一个标准的图结构数据,喂给GNN。

2.3 图结构的构建:从分子到图的无缝转换

现在你手里有了原子列表和键列表,接下来要做的就是构造一个邻接矩阵或者边索引列表。在GNN里,我们更常用后者——边索引(edge index),也就是一个大小为 [2, num_edges] 的张量,每一列表示一条边从哪个节点到哪个节点。

令我印象深刻的是,有次我在处理一个大型数据集时,边的方向搞反了,导致消息传递时特征流串位。所以我建议:如果你用的是无向图(默认就是无向图),记得把边复制一份,源-目标互换,保证双向都有。大多数GNN框架(如PyG、DGL)都会自动处理,但自己手工构建时候得小心。

图中图的关键点: 每个分子的图大小不一样(原子数不同),所以你不能用固定尺寸的张量。GNN一般通过批处理(batching)来解决——把多个小图拼成一个大的稀疏图。PyG的DataLoader会自动帮你完成,你只需要保证每个Data对象包含x(节点特征)、edge_index(边索引)、y(标签)即可。

下面这个代码片段,完整演示了如何用RDKit把一个SMILES分子转成PyG支持的图数据:

import torch
from torch_geometric.data import Data
from rdkit import Chem

def mol_to_graph(smiles: str):
    mol = Chem.MolFromSmiles(smiles)
    if mol is None:
        return None

    # 1. 提取原子特征(假设有个函数 get_atom_features)
    atom_features = []
    for atom in mol.GetAtoms():
        f = get_atom_features(atom)
        atom_features.append(f)
    x = torch.tensor(atom_features, dtype=torch.float)

    # 2. 提取边索引和边特征
    edge_index = []
    edge_attr = []
    for bond in mol.GetBonds():
        i = bond.GetBeginAtomIdx()
        j = bond.GetEndAtomIdx()
        edge_index.append([i, j])
        edge_index.append([j, i])  # 无向图双向
        bond_feat = get_bond_features(bond)
        # 边特征双向相同
        edge_attr.append(bond_feat)
        edge_attr.append(bond_feat)

    edge_index = torch.tensor(edge_index, dtype=torch.long).t().contiguous()
    edge_attr = torch.tensor(edge_attr, dtype=torch.float)

    # 3. 组装Data对象
    data = Data(x=x, edge_index=edge_index, edge_attr=edge_attr)
    return data

嗯,这里要注意:edge_index 的shape必须是 [2, num_edges],PyG 对这个很严格。另外 edge_attr 如果是可选的,但加上远比不加好——能让消息传递层学到更丰富的键环境。

2.4 完整图结构示例与SVG

为了让你更直观地理解,我画了一张图来展示一个简单分子(对甲酚,HOC₆H₄CH₃)是如何表示成图数据的。SVG 里我把原子编号标出来了,每条边都有双向箭头,嗯,这就是模型看到的原始输入。

分子图结构示例:对甲酚 (HOC₆H₄CH₃) C₀ C₁ C₂ C₃ C₄ C₅ O₆ C₇ 图例: 碳原子 氧原子 甲基碳 双向边(键) 氧-碳单键

在图中,每一个原子都被赋予一个特征向量,每一条边也同样带着键特征。GNN 的“消息传递”就是沿着这些双向边,把邻居的特征传播到中心节点,然后更新节点表示。你想想,如果我们能让每个原子感知到它周围几层原子的信息(通过多层网络),那么整个分子的全局性质(比如 logD、溶解度、毒性)就能被模型捕捉到。

2.5 常用工具库与数据格式

做ADMET落地,你得熟悉几个主流工具库。我用得最多的组合是:RDKit + PyTorch Geometric (PyG) 或 DGL。RDKit 负责从 SMILES 或 SDF 里提取分子级信息,PyG/DGL 负责构造和训练图神经网络。

一个典型的分子数据格式是这样的——以 PyG 的 Data 对象为例:

Data(
    x=[num_nodes, node_feat_dim],         # 节点特征
    edge_index=[2, num_edges],            # 边索引(双向)
    edge_attr=[num_edges, edge_feat_dim], # 边特征(可选)
    y=[num_tasks]                         # 标签(如hERG值、Caco-2值)
)

如果我用DGL,图结构的表示稍有不同,但本质一样:

import dgl

g = dgl.graph((src_ids, dst_ids))   # 传入边源-目标节点编号
g.ndata['feat'] = node_features     # 节点特征
g.edata['feat'] = edge_features     # 边特征
重要提醒: 图是稀疏的,千万别随便用稠密邻接矩阵去存?一个分子几十个原子,邻接矩阵顶多100x100,稀疏表示占用内存小得多,而且配合GNN的稀疏算子(如 scatter、segment)能大幅提升训练速度。我曾见过有团队强行用稠密矩阵存百万分子,内存直接爆了——这显然不是聪明的做法。

2.6 总结:这一章你需要记住的核心

  • 分子图由原子节点和化学键边构成,两者都包含丰富的化学特征。
  • 节点特征包括原子种类、度数、杂化、电荷、环信息等;边特征包括键级、共轭、环内标志等。
  • 图必须表示为无向图,边索引双向,才能正确进行消息传递。
  • 实际编码时,先用 RDKit 提取原子和键的原始信息,再转成 PyG/DGL 的图数据对象。
  • 别忘了批处理——不同分子图节点数不同,利用框架的 DataLoader 自动拼图。