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,不要一刀切地丢弃。
上面这张图,是我在项目里实际用的解析决策流程。简单说就是 先筛、再转、最后兜底。语法校验是对字符串负责,标准化是对语义负责,错误处理是对整体管道负责。
自定义异常:让错误信息自己说话
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 和它在源文件中的行号。这样后期人工核查时能快速定位。