SMILES字符串解析:语法校验、标准格式转换与错误处理

做化学信息学的人都知道,SMILES 看着简单,用起来全是坑。我刚开始批量处理分子库的时候,就吃过格式不规范的亏——一个批次里混进来几条“看着像 SMILES”但解析不了的数据,整个流程直接崩了。所以这一章,咱们专门聊聊怎么把 SMILES 从“字符串”变成“能用的结构”,同时把错误挡在外面。

语法校验:哪些“SMILES”其实是伪装的

很多刚入行的朋友以为,只要是字母、数字和括号拼起来的字符串就是 SMILES。其实不是。SMILES 虽然没像 XML 那样搞一套 DTD,但它有自己严格的文法。你看这条:C1=CC=C(C=C1——少了一个右括号,对吧?但系统不提醒你的话,可能拖到分子生成才炸。

⚠️ 注意:SMILES 语法校验不是凭肉眼扫一遍就能完成的。我建议在管道入口就做一次轻量级检查,而不是等下游脚本报错再回溯。

常见的 SMILES 语法问题,我一般分成两类:

类别 典型问题 例子
规范性问题 不是标准 SMILES 字符集、用了小写字母而含义不明确、双键/三键位置不合法 c1ccccc1(芳香键,但上下文期望 Kekulé 形式时需转换)
语法问题 括号不匹配、环编号重复或缺失、分支位置错误、原子价态超限 C1CCC1(环编号为 1 但未闭合第二个环键)

我个人习惯是先做一遍 字符集白名单过滤,再调正则做括号和环编号配对。这一步很快,能筛掉 70% 以上的脏数据。

import re

def simple_smiles_syntax_check(smiles: str) -> bool:
    # 1. 基本字符集
    allowed = re.compile(r'^[A-Za-z0-9@+\-\[\]()#=%\/\\\.]+$')
    if not allowed.match(smiles):
        return False

    # 2. 括号匹配
    stack = []
    for ch in smiles:
        if ch == '(':
            stack.append('(')
        elif ch == ')':
            if not stack:
                return False
            stack.pop()
    if stack:
        return False

    # 3. 环编号:数字出现次数必须为偶数(除了大于9的用%标识)
    ring_nums = re.findall(r'(\d+|%\d+)', smiles)
    from collections import Counter
    cnt = Counter(ring_nums)
    for k, v in cnt.items():
        if v % 2 != 0:
            return False
    return True

💡 小技巧:环编号超过 9 的,SMILES 会用 %10 %11 这样的形式。正则匹配时别忘了加上 %\d+,否则 10 以上直接漏检。

标准格式转换:从“能读”到“标准”

即使语法通过了,不同工具写的 SMILES 风格也可能差很多。你想想看,RDKit 生成的 canonical SMILES 和 Open Babel 写出来的可能互相认不出来。这就引出两个核心概念:规范化(canonicalization) 和 Kekulé 转换。

规范化最简单,用 RDKit 一行就行:

from rdkit import Chem

smiles = "C1=CC=C(C=C1)"   # 这个其实已经是规范的苯表示
mol = Chem.MolFromSmiles(smiles)
canonical = Chem.MolToSmiles(mol)
print(canonical)            # 输出:c1ccccc1

但如果你拿到的是 Kekulé 形式(含显式双键),而下游需要芳香形式(小写 c),就得多做一步。反过来也一样——我遇到过的一个项目需求是必须输出 Kekulé 形式,因为渲染引擎不支持芳香键。

Kekulé 转芳香,内部其实是把交替单双键统一成 delocalized 的环。而芳香转 Kekulé,则需要显式指定双键位置。RDKit 的处理方法是:

def to_kekule(smiles: str) -> str:
    mol = Chem.MolFromSmiles(smiles)
    if mol is None:
        raise ValueError(f"Invalid SMILES: {smiles}")
    Chem.Kekulize(mol)
    return Chem.MolToSmiles(mol, kekuleSmiles=True)

# 示例
kekule_form = to_kekule("c1ccccc1")
print(kekule_form)    # 输出:C1=CC=CC=C1

重点:转换过程中,原有立体化学信息(@、@@、/、\)可能会丢失或变形。我建议转换完做一次 round-trip 校验——再转回来,看能不能得到相同的分子对象。

错误处理:别让一条脏数据拖垮整个批次

这是实战中最头疼的部分。你处理一个 10 万条 SMILES 的批次,跑到第 8 万条时,出来个 None。然后呢?整个批次停掉?还是跳过?还是修复?

我自己的经验是:永远不要在生产管道里用裸的 Chem.MolFromSmiles 而不做 try/except。曾经接手过一个批次分析失败的案例,查到最后发现是上游工具在某些 SMILES 里混入了不可见字符(比如零宽空格)。

一个实用的错误隔离机制:

def safe_mol_from_smiles(smiles: str, sanitize: bool = True):
    try:
        mol = Chem.MolFromSmiles(smiles, sanitize=sanitize)
        if mol is None:
            # 这里可以做更详细的原因分析
            # 比如检查是否有非法原子符号
            raise Chem.MolSanitizeException(f"Sanitization failed for {smiles}")
        return mol
    except Exception as e:
        # 记录到日志,而不直接崩溃
        print(f"[WARN] Failed to parse: {smiles} -> {e}")
        return None

我个人强烈建议把失败记录写到独立文件里,方便后续人工审核。可以用一个简单的错误分类表:

错误类型 描述 恢复策略
原子不匹配 元素符号不在元素周期表中(如 “Qz”) 直接丢弃,记录到错误清单
括号不匹配 括号数不对称 尝试插入缺失括号(风险较高,一般不自动做)
价态超标 碳连了 5 根键等 关闭 sanitize 读取,然后手动修正
环编号不配对 环数字只出现一次 跳过该条 SMILES
未知符号 出现了 @ 以外的符号 用正则剔除后重试

对于批次差异分析来说,我还会在错误处理阶段加入一个 成功率阈值检查——如果某个批次的解析成功率低于 95%,这个批次就应该标记为可疑,而不是继续正常分析。

⚠️ 注意:不要完全信任自动修复。我见过有人批量插入缺失的右括号,结果分子的连接性完全错了。自动修复只适合你知道确切模式(比如缺失甲基表示法)的场景。

另外,如果数据量大,可以考虑先用多进程做预解析,把成功和失败的 SMILES 分到两个列表里,再做下游处理。这样失败的不影响成功的流程,批次处理不会卡死。

💡 踩坑提示:当 SMILES 中含金属有机配合物时,RDKit 的默认 sanitize 经常会失败。这时候可以设置 sanitize=False,自己写一个轻量级的 valency check,不要一刀切地丢弃。

SMILES解析决策流程 原始 SMILES 字符串 语法校验:正则 + 括号 + 环号 成功 → 继续标准化 失败 → 记录错误批次 标准化:Kekulé ↔ 芳香 + Canonical 失败 SMILES 隔离与重试 标准 SMILES + 错误列表

上面这张图,是我在项目里实际用的解析决策流程。简单说就是 先筛、再转、最后兜底。语法校验是对字符串负责,标准化是对语义负责,错误处理是对整体管道负责。

自定义异常:让错误信息自己说话

Python 内置的异常在批次分析里信息量往往不够。比如 Chem.MolSanitizeException 不会告诉你是哪个原子超标了。我习惯写几个自定义异常,把失败粒度和场景打包进去:

class SMILESParseError(Exception):
    def __init__(self, smiles: str, reason: str, line_no: int = None):
        self.smiles = smiles
        self.reason = reason
        self.line_no = line_no
        super().__init__(f"[Line {line_no}] {smiles}: {reason}")

class ValenceError(SMILESParseError):
    def __init__(self, smiles, atom_idx, valence, max_valence):
        super().__init__(smiles, f"Atom {atom_idx} valence {valence} > max {max_valence}")

# 使用示例
try:
    mol = Chem.MolFromSmiles("C1CC1C", sanitize=True)
except Chem.MolSanitizeException as e:
    raise ValenceError("C1CC1C", atom_idx=2, valence=5, max_valence=4)

这样你在分析错误日志的时候,一眼就能看出是哪个原子、哪条 SMILES、为什么失败——而不是在一个大 Exception 信息里瞎翻。

💡 实战建议:始终保留原始 SMILES 和它在源文件中的行号。这样后期人工核查时能快速定位。