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环闭合标签
我曾经踩过的坑:有次解析一个含氯分子的 SMILES,我直接用 C+1 匹配原子,结果把 Cl 拆成了 C 和 l。记住,原子符号的匹配必须是 最长匹配——先看两个字符的原子符号,匹配失败再退回到单字符。

3.2 词法分析:把字符串拆成 Token

解析的第一步,就是 SMILES 字符串拆成一个个 token。这个阶段完全不关心分子结构,只做模式匹配。

我的个人习惯:我会把 SMILES 的 token 分成 5 类——ATOM、BOND、BRANCH_START、BRANCH_END、RING_NUM。这样后续语法分析阶段逻辑特别清晰。

看一下 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 本质上就是一种前序遍历的树结构。

核心逻辑其实就三步:

  1. 原子出栈入栈:遇到 ATOM token 就创建原子节点并压栈,同时与前一个原子连接
  2. 分支进栈:遇到 BRANCH_START 就把当前原子压入分支栈,分支内的原子作为它的子节点
  3. 环闭合:遇到 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 解析的流程画成了一下面这种图,方便你理解各阶段之间的关系:

SMILES字符串 → 分子图解析流程 SMILES 字符串 词法分析(分词器) 按模式匹配拆分成 Token 语法分析(解析器) 栈操作 + 分支 + 环闭合 → 分子图 分子图(Molecule) 原子 + 键 + 手性 + 电荷 ⚠ 解析后的分子图可用于后续标准化、去重、子结构搜索等操作

我通常把解析器拆成两阶段:词法分析做 token 化,语法分析做图构建。这样各模块职责清晰,调试维护也方便。

3.5 实践中的注意事项

我在做化合物数据库清洗时,遇到过一些 SMILES 格式的“坑”:

避坑指南
  • 分支顺序:C(C)(C)O 和 C(C)C(C)O 在解析上完全不同,前者是中心碳连两个甲基和一个羟基
  • 环标签重复:SMILES 中同一个环的起止点数字必须成对出现,否则解析会缺少一条键
  • 芳香性处理:小写原子 c 不代表任意碳,而是特指芳香环上的碳;解析时不能让它与普通 C 混淆
  • 方括号内容:[CH3] 表示带氢的碳,[CH3] 和 C 表面都产生碳原子,但前者包含氢原子计数信息
我个人建议:解析时最好保留方括号原子的完整属性。标准化阶段可能需要区分 [CH3] 和 C 给出的默认氢原子数,因为它们在标准 SMILES 输出时表现不同。

至于键的默认处理——SMILES 中如果原子间省略了键符号,默认是单键(但芳香原子间默认是芳香键)。很多初学者会忽略这个细节,导致解析出来的分子图键类型错误。