大语言模型或图生成模型在药物发现、材料设计等任务中被用来生成SMILES等分子表示,但输出结果里混着大量化学上不合法的结构并不罕见。一个看似合法的SMILES字符串,解析后可能发现某个碳原子挂了五根键,或者苯环的芳香性标记完全错乱。这类无效式如果直接流入下游的成药性预测或合成路径规划,会导致计算报错、预测失真甚至误导实验决策。因此,在生成之后、使用之前,建立一套系统的化学规则校验与有效性评分机制,是分子生成工程里必须补上的一环。

推理模型为什么经常产出无效分子结构
推理模型生成SMILES本质上是在做序列预测,它学到的是字符串层面的统计规律,而不是真正的化学约束。SMILES语法只规定了原子符号、键符号和环编号的书写方式,语法正确并不代表化学合理。比如字符串C1CCCC1语法上完全没问题,对应环戊烷;但模型可能生成C1CC1CC11这种环编号无法闭合的结构,也可能生成FC(F)(F)(F)F让氟原子带上五个键。模型并没有内建的价键表,它只是模仿训练数据里的模式,一旦分布外的片段组合出现,就会产出违反化合价的怪异结构。
另一类问题是分子完整性缺失。模型有时会截断生成过程,留下悬空的支链标记或者未闭合的环编号;有时会把盐类、溶剂片段错误拼接,产生 SMILES 解析都通不过的输入。还有一类更隐蔽的问题:结构能解析,但化学上极不合理,例如同一分子里出现大量相邻的正电荷原子、过渡态式的长链多自由基写法,这些通过基础语法检查却通不过化学家的眼睛。
理解这些错误来源,有助于我们把校验设计成分层的结构:先用快速廉价的检查拦住明显非法的输出,再用更精细的化学规则筛掉语法合法但结构离谱的样本,最后用评分函数给出量化的可信度,供下游系统决定接受、重试还是降权处理。
基于RDKit的多层化学规则校验实现
RDKit是化学信息学领域最常用的开源工具库,Python生态下做分子校验基本绕不开它。第一层校验是解析合法性,把SMILES字符串转成分子对象,解析失败直接判为无效。需要注意的是,SMILES解析默认比较宽松,某些明显异常的字符串也能转出对象,所以要在此基础上继续做结构检查。
from rdkit import Chem
from rdkit.Chem import rdMolDescriptors
def basic_validate(smiles: str) -> dict:
result = {"smiles": smiles, "valid": False, "reason": ""}
if not isinstance(smiles, str) or len(smiles) == 0:
result["reason"] = "输入为空"
return result
mol = Chem.MolFromSmiles(smiles)
if mol is None:
result["reason"] = "SMILES解析失败"
return result
# 分子必须至少包含一个重原子
if mol.GetNumAtoms() == 0:
result["reason"] = "空分子"
return result
# 检查每个原子的价键是否超出化学允许范围
for atom in mol.GetAtoms():
# 排除显式带电荷的情况,带电原子的价键规则不同
if atom.GetFormalCharge() != 0:
continue
if not atom.GetNoImplicit():
continue
result["valid"] = True
return result第二层校验是化合价与电荷合理性。RDKit在解析时会做价键检查,但显式指定键级的写法可能绕过检查,此时可以调用SanitizeMol强制执行一遍化合价规范化,捕获异常即为违规结构。对于带形式电荷的原子,还可以检查电荷分布是否过于极端,例如一个中性小分子上出现三个以上的正形式电荷,大概率是模型胡乱拼凑的产物。
from rdkit import Chem
from rdkit.Chem import rdmolops
def deep_validate(smiles: str) -> dict:
info = {"valid": False, "reasons": []}
mol = Chem.MolFromSmiles(smiles)
if mol is None:
info["reasons"].append("解析失败")
return info
try:
# 强制执行化合价检查、芳香性感知等规范化流程
Chem.SanitizeMol(mol)
except Exception as e:
info["reasons"].append(f"化学规范化失败: {e}")
return info
# 检查断裂片段数量,目标分子通常应为单一组分
frags = Chem.GetMolFrags(mol)
if len(frags) > 3:
info["reasons"].append(f"片段过多: {len(frags)}个")
# 检查分子量是否落在合理区间
mw = rdMolDescriptors.CalcExactMolWt(mol)
if mw < 50 or mw > 1000:
info["reasons"].append(f"分子量异常: {mw:.1f}")
# 检查是否含有罕见元素,生成类药物分子时通常不期望出现
rare_elements = {"Zr", "Hf", "Tc", "Pu"}
for atom in mol.GetAtoms():
if atom.GetSymbol() in rare_elements:
info["reasons"].append(f"含罕见元素: {atom.GetSymbol()}")
break
info["valid"] = len(info["reasons"]) == 0
return info第三层是领域相关的定制校验。如果你的应用场景限定为口服小分子药物,可以叠加Lipinski规则类检查:氢键供体不超过5、可旋转键数量适中、环系数量在合理范围。如果是聚合物单体,则要检查官能团组合的自洽性。这些规则不必做成硬性门槛,更多是作为评分的输入特征使用。分层设计的核心价值在于成本控制,解析失败的结构在微秒级被丢弃,只有少数通过基础检查的样本才需要进入较重的计算。
设计有效性评分函数:从布尔过滤到连续打分
纯布尔校验的问题是信息量太少:两个都合法的分子,一个化学上平平无奇,另一个结构优美且合成可达,过滤机制无法区分它们。更实用的做法是把各项校验结果和计算属性组合成一个连续的有效性分数,取值0到1,分数越高代表结构越可信。常见的评分维度包括:解析成功率(0或1)、化合价合法性、片段数量惩罚、分子量落在目标区间的程度、QED类类药性指标,以及结构新颖性。
from rdkit import Chem
from rdkit.Chem import Crippen, rdMolDescriptors
def validity_score(smiles: str) -> float:
"""计算分子结构有效性评分,范围0到1"""
mol = Chem.MolFromSmiles(smiles)
if mol is None:
return 0.0
try:
Chem.SanitizeMol(mol)
except Exception:
return 0.0
score = 1.0
# 片段惩罚:每个多余片段扣0.1分
frags = Chem.GetMolFrags(mol)
score -= 0.1 * (len(frags) - 1)
# 分子量评分:偏离类药区间越远扣分越多
mw = rdMolDescriptors.CalcExactMolWt(mol)
if mw < 150:
score -= (150 - mw) / 1000.0
elif mw > 500:
score -= (mw - 500) / 1000.0
# 类药性贡献:QED指标乘以0.2的权重
try:
score += 0.2 * rdMolDescriptors.CalcQED(mol) - 0.1
except Exception:
pass
return max(0.0, min(1.0, score))评分函数设计好后有两个典型用途。第一个是批处理过滤:对模型生成的一大批SMILES打分,按阈值截断,比如只保留0.6分以上的样本进入后续的对接筛选或性质预测。阈值需要结合实际任务的召回需求调整,过滤太严可能把有潜力的新骨架也砍掉。第二个用途是反馈给生成过程本身。如果生成模型支持强化学习或重排序采样,可以把有效性分数作为奖励信号的一部分,引导模型在训练中逐步降低无效输出的比例。实践中常见的组合策略是:有效性分数作为乘法门控,只有结构合法的分子才有资格获得活性预测等任务奖励,这样模型会先学会写出合法结构,再学习优化性质。
最后提醒一点工程细节:评分和校验要保证幂等与缓存友好。同一条SMILES可能被重复送入校验流程,建议先做规范化处理,用RDKit的canonical SMILES作为缓存键,避免重复计算。整体流水线的角色分工也很清晰,生成模型负责探索化学空间,校验评分模块负责把关质量,两者解耦后各自迭代互不干扰,这是把分子生成从演示项目推向生产系统必须走的一步。