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来说,这个规模很舒服。
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]。
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。这种矩阵在后续做图注意力、图卷积时都非常直接。
4.5 本章知识体系总览
为了让你看得更清楚,我画了一张结构图,把整个分子图表示的流程串起来:
这张图把整个过程串得很清楚。你从SMILES出发,并行提取三个视图(原子、键、连接关系),最后拼成完整的图结构。下一步你学Graph Transformer的时候,本质上就是在处理这个三重组合。
嗯,这一节的核心内容就是这些。我建议你回去自己拿10个常见分子练习一下,把它们的原子特征矩阵和邻接矩阵都算出来。练完这步,后面的图Transformer你上手会非常快。