3、SMILES解析原理:从字符串到分子图
坦率地说,SMILES 字符串看起来就像一堆字母和符号的随机组合。但你仔细想想,计算机怎么知道 CC(=O)O 代表乙酸?它本质上是一个模式匹配和树结构解析的过程。嗯,我今天就把这套机制拆开讲清楚。
我个人觉得,理解了 SMILES 解析,你才能掌握标准化的底层逻辑。毕竟标准化本质上就是在分子图上做“归约”操作。
3.1 SMILES 字符串的构成要素
一条 SMILES 字符串,其实就是在描述一个分子图的深度优先遍历(DFS)路径。
它用的符号非常少,但组合起来却能表示几乎任意有机分子。我做过统计,所有化合物超过 99% 的 SMILES 都可以用下面这 5 类符号表达:
- 原子符号:C、N、O、S、P、F、Cl、Br、I(大写);芳香性原子用 c、n、o、s、p
- 键类型:单键 -(可省略)、双键 =、三键 #、四键 $
- 分支:用圆括号 ( ) 包裹子结构
- 环:用数字标签标记成环起点和终点,如 C1CC1
- 方括号原子:[ ] 内可指定同位素、电荷、手性等完整信息
拿乙酸举例,CC(=O)O 的解析路径是这样的:先读到 C,再读到 C,遇到左括号表示这个 C 上连着一个分支结构,分支里是 (=O),然后分支结束读到最后一个 O。
| 符号类型 | 示例 | 匹配规则 |
|---|---|---|
| 基本原子 | C, N, O | 单个大写字母 |
| 双字符原子 | Cl, Br, Si | 首字母大写,第二个字母小写 |
| 芳香原子 | c, n, o | 小写字母 |
| 方括号原子 | [CH3], [O-], [Na+] | 完整原子规范 |
| 键符号 | -, =, #, /, \ | 连接相邻原子 |
| 分支 | (...) | 子图分支 |
| 环 | 1, 2, ... 99 | 环闭合标签 |
C+1 匹配原子,结果把 Cl 拆成了 C 和 l。记住,原子符号的匹配必须是 最长匹配——先看两个字符的原子符号,匹配失败再退回到单字符。
3.2 词法分析:把字符串拆成 Token
解析的第一步,就是 SMILES 字符串拆成一个个 token。这个阶段完全不关心分子结构,只做模式匹配。
看一下 token 拆解的代码骨架:
def tokenize(smiles: str) -> list:
tokens = []
i = 0
while i < len(smiles):
c = smiles[i]
# 原子(含双字符)
if c in 'BCNOPSFIH':
if i + 1 < len(smiles) and smiles[i + 1].islower():
tokens.append(('ATOM', smiles[i:i+2]))
i += 2
else:
tokens.append(('ATOM', c))
i += 1
# 芳香原子
elif c in 'cnops':
tokens.append(('ATOM', c))
i += 1
# 键
elif c in '-=#$/\':
tokens.append(('BOND', c))
i += 1
# 分支
elif c == '(':
tokens.append(('BRANCH_START', c))
i += 1
elif c == ')':
tokens.append(('BRANCH_END', c))
i += 1
# 环数字
elif c.isdigit():
tokens.append(('RING_NUM', c))
i += 1
# 方括号原子
elif c == '[':
j = smiles.index(']', i)
tokens.append(('ATOM', smiles[i+1:j]))
i = j + 1
# 忽略点号(用于位-位连接,不是标准键)
elif c == '.':
i += 1
else:
raise ValueError(f'无法识别的字符: {c}')
return tokens
这段代码处理的是标准 SMILES。说白了,我把每个字符或字符组映射到一个 token 类型,并保留原始值。
3.3 语法分析:把 Token 序列转成分子图
Token 序列有了,接下来就是如何把它组合成分子图。这个过程我习惯用栈来实现——因为 SMILES 本质上就是一种前序遍历的树结构。
核心逻辑其实就三步:
- 原子出栈入栈:遇到 ATOM token 就创建原子节点并压栈,同时与前一个原子连接
- 分支进栈:遇到 BRANCH_START 就把当前原子压入分支栈,分支内的原子作为它的子节点
- 环闭合:遇到 RING_NUM 就查找之前标记过同数字的原子,在两者之间添加键
我用了一个简单的例子来演示:解析 C(C)(C)O(异丙醇是怎么被解析出来的?)。
def parse_smiles(smiles: str) -> Molecule:
tokens = tokenize(smiles)
mol = Molecule()
stack = []
prev_atom = None
for token_type, token_value in tokens:
if token_type == 'ATOM':
atom = mol.add_atom(token_value)
if prev_atom is not None:
mol.add_bond(prev_atom, atom, 1) # 默认单键
stack.append(atom)
prev_atom = atom
elif token_type == 'BRANCH_START':
# 分支开始,当前原子就是分支的父节点
pass # prev_atom 不变
elif token_type == 'BRANCH_END':
# 分支结束,回到父节点
prev_atom = stack[-2] # 这里实际场景会用分支栈维护
elif token_type == 'RING_NUM':
# 环闭合,依赖特殊的 ring_map 处理
pass
return mol
对于分支结构,你想想看:C(C)(C)O 中,第一个括号前是中心碳,括号内的 C 是它的子节点。解析器通过栈记录这个层次关系。
3.4 解析流程框架
我把整个 SMILES 解析的流程画成了一下面这种图,方便你理解各阶段之间的关系:
我通常把解析器拆成两阶段:词法分析做 token 化,语法分析做图构建。这样各模块职责清晰,调试维护也方便。
3.5 实践中的注意事项
我在做化合物数据库清洗时,遇到过一些 SMILES 格式的“坑”:
- 分支顺序:
C(C)(C)O和C(C)C(C)O在解析上完全不同,前者是中心碳连两个甲基和一个羟基 - 环标签重复:SMILES 中同一个环的起止点数字必须成对出现,否则解析会缺少一条键
- 芳香性处理:小写原子 c 不代表任意碳,而是特指芳香环上的碳;解析时不能让它与普通 C 混淆
- 方括号内容:[CH3] 表示带氢的碳,[CH3] 和 C 表面都产生碳原子,但前者包含氢原子计数信息
至于键的默认处理——SMILES 中如果原子间省略了键符号,默认是单键(但芳香原子间默认是芳香键)。很多初学者会忽略这个细节,导致解析出来的分子图键类型错误。