4. 分子图表示:分子图结构、原子特征、键特征、图邻接矩阵

好,我们进入第四讲。前面我们聊了Transformer的基本套路,也讲了怎么把分子变成序列。但说实话,序列化表示总让我觉得有点别扭——分子明明是三维空间里原子手拉手连起来的网络,硬拉成一串字符,信息多少会有损失。

这节我们就来解决这个问题:直接用图来表示分子。你在做ADMET预测时,分子的性质往往跟局部结构、连接方式、官能团关系更紧密,图结构天然就保留了这些信息。

4.1 分子图:从SMILES到图结构

什么是分子图?说白了,就是你把原子看成节点,把化学键看成边。每个节点带着自己的属性(原子类型、电荷、杂化状态等),每条边也带着自己的属性(键型、是否共轭等)。

那怎么从一个SMILES字符串变成一张图呢?我推荐用RDKit,它可以直接把分子对象转换成一个非常友好的数据结构。

比如乙醇(CCO),它的图结构大概就是:两个碳原子连着,最后一个氧连着第二个碳,然后各自连氢(不过氢原子在大多数分子图里被省略了)。

from rdkit import Chem
from rdkit.Chem import Descriptors

# 读取分子
mol = Chem.MolFromSmiles('CCO')
# 看看原子数
print(f"原子数: {mol.GetNumAtoms()}")
# 看看键数
print(f"键数: {mol.GetNumBonds()}")

# 打印每个原子的信息
for atom in mol.GetAtoms():
    print(f"原子索引:{atom.GetIdx()}, 元素符号:{atom.GetSymbol()}, 度:{atom.GetDegree()}")

输出结果:

原子数: 3
键数: 2
原子索引:0, 元素符号:C, 度:1
原子索引:1, 元素符号:C, 度:2
原子索引:2, 元素符号:O, 度:1

你看,三个原子,两条键。这个图很简单,但它就是建模的基础。对于ADMET任务,分子图的大小一般在20-50个重原子之间,所以后面跑Transformer时,计算量也还好。

避坑指南: 我之前有次在处理一个含大环的分子时,没注意它自己环化回自己,结果邻接矩阵算出来的度少了一条边。记得检查一下分子的隐式氢,还有环结构的闭合情况。

4.2 原子特征工程

图建好了,接下来得给每个节点赋特征。你不能光靠一个原子序数去表达所有信息,比如碳在苯环里、在烷基链里、在羰基里,性质差别大了去了。

我个人习惯用一组原子特征向量,这些特征来自RDKit直接能取的属性,基本不需要额外计算。对于ADMET预测,以下9维特征是我觉得比较靠谱的基础配置:

特征类别 维度 取值示例 说明
原子类型 1(独热) C, N, O, S, F... 常用前10种元素独热编码
度 1(数值) 1, 2, 3, 4 该原子连接的原子数(不含H)
形式电荷 1(数值) -1, 0, +1 最常用的就是-1/0/+1
杂化类型 1(独热) SP, SP2, SP3 三种杂化方式,再加一个其他
是否芳香 1(布尔) True/False 1或者0
氢键供体/受体 2(布尔) 0/1 对ADMET影响很大的特征
在环中 1(布尔) 0/1 是否属于某个环系

这些特征加起来大约20维左右。你想想看,每个原子用20维向量描述,50个原子的分子就是50×20的节点特征矩阵。对Transformer来说,这个规模很舒服。

一个有用的经验: 有些项目会把原子位置信息(比如3D坐标)也作为特征输入,但ADMET预测主要依赖拓扑结构,位置坐标往往引入噪声。除非你确实需要构象依赖性预测(比如与蛋白对接的位点结合),否则我一般不加。

4.3 键特征:你不能忽视的连接属性

原子特征搞定了,但光有原子还不够。单键和双键的旋转能力不一样,芳香键和酰胺键的电子性质也完全不同。如果不把键的特征编码进去,模型会学成一个刚性分子团,毫无化学活性可言。

键特征相对简单,一般5-6维就够了:

  • 键型:单键、双键、三键、芳香键 — 独热编码4维
  • 是否共轭:布尔值,1维,这对π-π堆积效应很关键
  • 是否在环中:布尔值,1维,决定键的柔性
  • 立体构型:无双键立体、顺式、反式 — 独热编码3维(有时候省略)

我来写段代码演示一下:

def get_bond_features(bond):
    """提取一条化学键的特征"""
    bond_type = bond.GetBondType()
    bt_dict = {
        Chem.rdchem.BondType.SINGLE: 0,
        Chem.rdchem.BondType.DOUBLE: 1,
        Chem.rdchem.BondType.TRIPLE: 2,
        Chem.rdchem.BondType.AROMATIC: 3
    }
    # bond type one-hot
    bt_vec = [0, 0, 0, 0]
    bt_vec[bt_dict.get(bond_type, 0)] = 1
    # conjugate
    conj = 1 if bond.GetIsConjugated() else 0
    # in ring
    in_ring = 1 if bond.IsInRing() else 0
    return bt_vec + [conj, in_ring]

# 例子:乙醇分子
mol = Chem.MolFromSmiles('CCO')
for bond in mol.GetBonds():
    print(f"键 {bond.GetBeginAtomIdx()}-{bond.GetEndAtomIdx()} : {get_bond_features(bond)}")

输出:

键 0-1 : [1, 0, 0, 0, 0, 0]
键 1-2 : [1, 0, 0, 0, 0, 0]

乙醇全是单键、非共轭、不在环里,所以前4位是[1,0,0,0],后面是[0,0]。

要注意数值范围: 我曾经遇到过模型训练不收敛的情况,查了半天发现键型的独热编码里混入了带权重的数值(比如直接把键序当成1.0, 2.0, 3.0送进去了)。这种离散的类别一定要用独热或者可学习嵌入,不要直接当连续值用。

4.4 图邻接矩阵:定义分子拓扑的核心

有了节点,有了边特征,剩下的就是告诉模型:哪些节点之间有连接。这就是邻接矩阵。对于一个有N个原子的分子,邻接矩阵是一个N×N的矩阵A。如果第i个原子和第j个原子之间有键,A[i][j] = 1(或者某种边的权重),否则为0。

不过我们在Transformer中使用图时,邻接矩阵一般不是直接送给Self-Attention的,而是用来生成掩码(mask)或者图位置编码(Graph Laplacian Positional Encoding)。当然,也有些变体把邻接矩阵当成Soft Attention的偏置项送进去。

实际编码时,我一般使用稀疏表示,因为分子图很少有全连接的稠密矩阵:

def compute_adjacency_matrix(mol):
    n = mol.GetNumAtoms()
    adj = [[0] * n for _ in range(n)]
    for bond in mol.GetBonds():
        i = bond.GetBeginAtomIdx()
        j = bond.GetEndAtomIdx()
        adj[i][j] = 1
        adj[j][i] = 1  # 无向图
    return adj

mol = Chem.MolFromSmiles('CCO')
adj = compute_adjacency_matrix(mol)
for row in adj:
    print(row)

输出:

[0, 1, 0]
[1, 0, 1]
[0, 1, 0]

一个简洁的链式连接。C0连C1,C1连O2。这种矩阵在后续做图注意力、图卷积时都非常直接。

进阶技巧: 如果做节点级别的任务(比如预测每个原子的pKa),你可能需要带自环的邻接矩阵:A' = A + I。这样每个节点也能关注到自己的信息。Transfomer里的Self-Attention本身就支持自关注,所以这个策略自然融进去了。

4.5 本章知识体系总览

为了让你看得更清楚,我画了一张结构图,把整个分子图表示的流程串起来:

分子图表示流程 输入:SMILES / Mol对象 原子特征提取 原子序数、度、杂化类型、电荷、芳香性、氢键... 键特征提取 键型、共轭、环、立体构型... 邻接矩阵构建 N×N稀疏矩阵,0/1连接 聚合图表示:原子特征矩阵 + 键特征 + 邻接矩阵 送入Transformer进行ADMET预测

这张图把整个过程串得很清楚。你从SMILES出发,并行提取三个视图(原子、键、连接关系),最后拼成完整的图结构。下一步你学Graph Transformer的时候,本质上就是在处理这个三重组合。

嗯,这一节的核心内容就是这些。我建议你回去自己拿10个常见分子练习一下,把它们的原子特征矩阵和邻接矩阵都算出来。练完这步,后面的图Transformer你上手会非常快。