048

异常结构识别:训练集里哪些分子应该删除

训练集中的异常结构会污染模型。这篇给出系统的识别方法与该删该留的判断标准。

真实的化学数据中总有一些不该进入训练集的结构:解析错误的、化学上不合理的、超出建模范围的。识别并处理它们,是数据清洗中判断成分最多的一步——因为「异常」的定义取决于你的建模目标。

异常结构的类型

类型 例子 处理
解析失败 SMILES 语法错误 删除并记录
化学不合理 五价碳、异常价态 删除
超出范围 分子量 > 2000、单原子 视任务决定
含罕见元素 放射性元素、稀有金属 通常删除
混合物 去盐后仍有多个片段 检查后决定
聚合物/重复单元 * 的结构 删除
活性数据异常 pIC50 = 15(不可能的活性) 检查后删除
结构-活性矛盾 同结构不同活性 047《去盐与去重复》

系统的检查流程

from rdkit import Chem
from rdkit.Chem import Descriptors, rdMolDescriptors
import numpy as np

ALLOWED_ELEMENTS = {"H", "C", "N", "O", "S", "P",
                    "F", "Cl", "Br", "I", "B", "Si"}

def check_structure(smiles, mw_range=(50, 1000),
                    max_fragments=1, allowed=None):
    """返回该结构的所有问题"""
    allowed = allowed or ALLOWED_ELEMENTS
    issues = []

    mol = Chem.MolFromSmiles(smiles)
    if mol is None:
        return ["解析失败"]

    # 1) 片段数
    frags = Chem.GetMolFrags(mol)
    if len(frags) > max_fragments:
        issues.append(f"多组分({len(frags)} 个片段)")

    # 2) 元素检查
    elements = {a.GetSymbol() for a in mol.GetAtoms()}
    unusual = elements - allowed
    if unusual:
        issues.append(f"罕见元素: {unusual}")

    # 3) 分子量范围
    mw = Descriptors.MolWt(mol)
    if not (mw_range[0] <= mw <= mw_range[1]):
        issues.append(f"分子量超范围: {mw:.1f}")

    # 4) 原子数
    n_heavy = mol.GetNumHeavyAtoms()
    if n_heavy < 3:
        issues.append(f"原子太少: {n_heavy}")

    # 5) 【价态检查】
    try:
        Chem.SanitizeMol(Chem.Mol(mol))
    except Exception as e:
        issues.append(f"价态问题: {e}")

    # 6) 【通配符/聚合物标记】
    if "*" in smiles or any(a.GetAtomicNum() == 0 for a in mol.GetAtoms()):
        issues.append("含通配符(可能是聚合物或 Markush)")

    # 7) 【电荷检查】
    charge = Chem.GetFormalCharge(mol)
    if abs(charge) > 2:
        issues.append(f"净电荷异常: {charge}")

    # 8) 【环的大小】
    ri = mol.GetRingInfo()
    for ring in ri.AtomRings():
        if len(ring) > 12:
            issues.append(f"超大环: {len(ring)} 元")
            break

    # 9) 【同位素】
    if any(a.GetIsotope() > 0 for a in mol.GetAtoms()):
        issues.append("含同位素标记")

    return issues

# 批量检查
def audit_dataset(smiles_list, **kwargs):
    from collections import Counter
    all_issues = Counter()
    flagged = []
    for i, smi in enumerate(smiles_list):
        issues = check_structure(smi, **kwargs)
        if issues:
            flagged.append((i, smi, issues))
            for issue in issues:
                all_issues[issue.split(":")[0]] += 1
    print(f"【{len(flagged)}/{len(smiles_list)} 个结构有问题】")
    for issue, count in all_issues.most_common():
        print(f"  {issue}: {count}")
    return flagged

活性数据的异常检查

import numpy as np

def check_activity_values(df, value_col="pActivity",
                          plausible_range=(2, 12)):
    """检查活性值是否合理"""
    v = df[value_col]

    # 1) 【物理上不可能的值】
    #   pIC50 > 12 意味着 IC50 < 1 pM
    #   → 极少数超强结合物才可能
    #   pIC50 < 2 意味着 IC50 > 10 mM
    #   → 通常这类数据没有意义
    out_of_range = df[(v < plausible_range[0]) | (v > plausible_range[1])]
    print(f"【超出合理范围的: {len(out_of_range)}】")

    # 2) 【分布检查】
    print(v.describe())
    #   分布过窄 → 建模没有意义
    #   多峰 → 可能混合了不同的测定体系

    # 3) 【堆积在某个值上】
    #   大量数据恰好等于某个值(如 5.0)
    #   → 【可能是删失数据被错误处理】
    counts = v.round(2).value_counts()
    if counts.iloc[0] > 0.05 * len(v):
        print(f"【警告:{counts.index[0]} 出现 {counts.iloc[0]} 次"
              f"({counts.iloc[0]/len(v):.1%})】")
        print("  → 检查是否为删失数据的占位值")

    # 4) 【与分子大小的关系】
    #   如果活性与分子量高度相关,
    #   可能是「大分子在测定中假阳性」
    from rdkit import Chem
    from rdkit.Chem import Descriptors
    mw = df["smiles"].apply(
        lambda s: Descriptors.MolWt(Chem.MolFromSmiles(s))
        if Chem.MolFromSmiles(s) else np.nan)
    corr = np.corrcoef(mw.dropna(), v[mw.notna()])[0, 1]
    print(f"活性与分子量的相关性: {corr:.3f}")
    if abs(corr) > 0.5:
        print("  【警告:相关性过高,可能有系统性偏倚】")

    return out_of_range

「该删该留」的判断

# 【明确该删的】:
#   ✗ 解析失败的
#   ✗ 价态错误的
#   ✗ 含通配符/Markush 结构的
#   ✗ 活性值物理上不可能的
#   ✗ 【重复但活性严重矛盾的】(见 047)
#
# 【需要判断的】:
#
#   ? 【分子量超范围】
#     取决于建模目标:
#       做小分子药物 → MW > 1000 通常删
#       做大环、多肽 → 【范围要放宽】(见 373)
#     → 【不要盲目套用 Lipinski 范围】
#
#   ? 【含金属的化合物】
#     铂类药物、含硼药物是真实存在的
#     → 如果建模范围包含它们,应该保留
#     → 但指纹与描述符对它们的表达可能不足
#
#   ? 【多组分】
#     共晶、共药可能是有意义的
#     → 检查后决定
#
#   ? 【极端的活性值】
#     可能是真实的超强结合物
#     → 【不要仅因为「离群」就删除】
#     → 应该回查原始文献
#
# 【最重要的原则】:
#   1) 【删除前记录】:删了什么、为什么
#   2) 【不要因为「模型预测不准」就删数据】
#      → 【这是在拟合噪声,会让评测虚高】
#   3) 【删除标准应该在看到模型结果之前确定】
#      → 否则就是数据窥探
#
# 【一个反面例子】:
#   「删掉模型预测误差最大的 5% 数据,
#    R² 从 0.6 提升到 0.75」
#   → 【这个提升是虚假的】
#   → 实际部署时同样的分子仍然会来

用 ChEMBL Structure Pipeline 的检查器

# pip install chembl_structure_pipeline

from chembl_structure_pipeline import checker

# checker 返回 (严重程度, 问题描述) 的列表
# 严重程度:
#   2  轻微(如立体化学未定义)
#   5  中等
#   6  较严重
#   7  严重(如价态错误)
#   9  极严重

def chembl_check(molblock):
    issues = checker.check_molblock(molblock)
    return issues

# 【典型的问题类型】:
#   - InChI 生成失败
#   - 原子价态错误
#   - 立体化学定义有冲突
#   - 多个片段
#   - 含 R 基团/通配符
#   - 分子太大
#
# 【实用做法】:
#   严重程度 >= 6 的自动剔除
#   2~5 的标记出来供检查
#   → 【这个分级比自己写规则更全面】

关键要点

  • 「异常」的定义取决于建模目标——做大环时不能套用小分子的分子量范围;
  • 大量数据堆积在某个值上常常是删失数据被错误处理的信号;
  • 删除标准必须在看到模型结果之前确定——事后删除误差大的数据是数据窥探;
  • 删除前记录删了什么与为什么;ChEMBL Structure Pipeline 的分级检查比自写规则全面。

延伸资源