第4章:基于正则表达式的SMILES分词
SMILES 说白了就是一串字符。要把它喂给神经网络,第一步就是切开。
但怎么切?按字母一个个切?那肯定不行。原子符号有单字母(C、O、N),也有双字母(Cl、Br)。环标记有数字,分支有括号,手性有 @ 和 @@。你需要一套规则的、可复现的分词方案。
我个人习惯用正则表达式来做这件事。正则写好了,一行代码扫过去,token 就全出来了。我在项目中见过不少同行用暴力 if-else 判断字符类型,代码又长又容易漏——嗯,正则其实更干净。
4.1 SMILES 里的 token 类型
我们先梳理一下 SMILES 字符串到底包含哪些基本元素。我把它们分成了七大类:
| 类别 | 示例 | 说明 |
|---|---|---|
| 有机原子 | C O N P S B F I | 单字母符号,大写字母表示非芳香族 |
| 双字母原子 | Cl Br Mg Si Se | 首字母大写 + 小写字母 |
| 芳香族原子 | c o n p s | 小写字母,表示芳香性原子 |
| 括号修饰 | ( ) [ ] | 括号用于分支和方括号原子 |
| 键型 | - = # : / \ | 单键、双键、三键、芳香键、顺反异构键 |
| 环编号 | 1 %10 %99 | 一位数字或 % + 两位数字 |
| 手性与电荷 | @ @@ + - H | 手性标记、电荷、显式氢 |
这里面有个坑:双字母原子和单字母原子的顺序问题。如果你先匹配 C,会把 Cl 中的 C 先吃掉,剩下 l 就成了非法 token。我曾经在调一个分子指纹模型时踩过这个坑,排查了半天才发现是分词顺序反了——嗯,从那以后我写正则一定会把长的模式放在前面。
4.2 正则表达式模式设计
我们需要一个单一的正则模式,能够按优先级匹配最长的有效 token。这是我在生产环境中用过的一个版本:
import re
SMILES_TOKEN = re.compile(r"""
(Cl|Br|Mg|Si|Se|Fe|Ca|Na|Li|K|Be|B|C|N|O|P|S|F|I) # 双字母 + 单字母原子
|(c|o|n|p|s) # 芳香族小写原子
|\(|\)|\[|\] # 括号
|\-|\=|\#|\:|\/|\\ # 键型
|(%\d{2}) # 两位数字环
|(\d) # 一位数字环
|(@@|@) # 手性
|(\+|\-|\+\+|--) # 电荷(简单处理)
|(H\d?|h\d?) # 显式氢
""", re.VERBOSE)
注意几点:
Cl|Br这类双字母必须写在单字母C|B前面,否则会误匹配。%\d{2}要写在\d前面,因为环编号最多两位(虽然 SMILES 规范里只支持 1-100,但两位以内就用%xx形式)。@@要写在@前面,否则@@会被拆成两个@,手性就丢了。
\d+ 来匹配环编号,结果把一个分子里的 123 当成了三级环,实际上 SMILES 标准里只有 \d 或 %\d{2} 是有效环。后来改回来后,模型准确度直接提升了 3 个百分点——分词错了,后面所有注意力都白算。
4.3 分词函数
有了模式,分词就简单了:
def tokenize_smiles(smiles: str) -> list:
"""
使用预编译的正则表达式对 SMILES 进行分词。
返回 token 列表。
"""
tokens = SMILES_TOKEN.findall(smiles)
# findall 返回的是元组,每组只有一个非空元素
result = []
for tup in tokens:
for t in tup:
if t:
result.append(t)
break
return result
# 示例
test_smiles = "CCO" # 乙醇
print(tokenize_smiles(test_smiles))
# 输出: ['C', 'C', 'O']
test_smiles2 = "Clc1ccccc1" # 氯苯
print(tokenize_smiles(test_smiles2))
# 输出: ['Cl', 'c', '1', 'c', 'c', 'c', 'c', 'c', '1']
test_smiles3 = "C@@H" # 手性碳
print(tokenize_smiles(test_smiles3))
# 输出: ['C', '@@', 'H']
你看,Cl 被正确识别为一个 token,手性 @@ 也没有被拆散。说白了,这个函数只有几行,但正则是整个流程的骨架。
4.4 关于芳香族和显式氢
芳香族小写原子(c、o、n、p、s)在 SMILES 里是单独 token,不能跟大写混。方括号里的原子(如 [NH4+])则比较复杂,里面有显式氢和电荷。我建议对方括号内容单独做一次二级分词,但那是更高级的话题了。
对于大多数深度学习任务(比如注意力热力图),我们只关心 token 序列的整体结构,方括号内部信息可以整体当一个 token。你自己可以调整。
@ 和 @@ 当作不同 token,它们在手性中心上承载了完全不同的立体信息。我习惯在训练前做一次 token 统计,确保手性 token 不会被低频过滤掉。
4.5 分词流程总览
下面这张图概括了我们刚才讲的整个分词逻辑:
4.6 边界情况与扩展
没有正则能覆盖所有 SMILES 变体。比如:
- 同位素标记:
[13C]前面的数字13怎么处理?我的做法是在方括号内部用二级正则解析。 - 高价电荷:
[Fe+3]或[Fe+++],你需要额外处理连续加号或数字。 - 广义 SMILES 扩展:比如 CXSMILES 中的
*通配符、~任意键等,你可能需要追加模式。
经验总结: 对于大多数深度学习项目而言,上面的正则已经够用。你可以在预处理器里先做一次合法性校验,遇到无法匹配的字符直接抛异常或跳过。我建议把分词函数封装成一个独立的模块,方便后续扩展。