047

去盐与去重复:化合物数据清洗的第一步

去盐与去重复是数据清洗的第一步。这篇给出具体做法、去重时的活性数据合并策略与常见陷阱。

拿到一批化合物数据,第一件事是去盐与去重。这两步看似简单,但「怎么去重」「重复的活性数据怎么合并」这些判断,直接影响后续模型的质量。

去盐:保留哪个片段

from rdkit import Chem
from rdkit.Chem.MolStandardize import rdMolStandardize

# ---- 方法一:保留最大的有机片段(推荐)----
def desalt(smiles):
    mol = Chem.MolFromSmiles(smiles)
    if mol is None:
        return None
    parent = rdMolStandardize.FragmentParent(mol)
    return Chem.MolToSmiles(parent) if parent else None

print(desalt("CC(=O)[O-].[Na+]"))          # CC(=O)[O-]
print(desalt("CN1CCC[C@H]1c1cccnc1.Cl"))   # 去掉 HCl

# ---- 方法二:用盐列表移除 ----
remover = rdMolStandardize.LargestFragmentChooser()
mol = remover.choose(Chem.MolFromSmiles("CC(=O)O.O.O"))

# ---- 需要注意的情况 ----
#
# 【情况一:共晶与共药】
#   两个都是活性成分的共晶
#   → 【去掉一个就丢失了信息】
#   → 应该标记出来单独处理
#
# 【情况二:季铵盐等本身带电的化合物】
#   CC[N+](C)(C)C.[Br-]
#   → 阳离子部分是「主体」,溴离子是抗衡离子
#   → FragmentParent 会正确处理
#
# 【情况三:两个片段大小相近】
#   「最大片段」的判断可能不符合预期
#   → 【应该检查】

def check_desalting(smiles_list, n_sample=20):
    """检查去盐是否合理"""
    multi_component = []
    for smi in smiles_list:
        if "." not in smi:
            continue
        mol = Chem.MolFromSmiles(smi)
        if mol is None:
            continue
        frags = Chem.GetMolFrags(mol, asMols=True)
        sizes = sorted([f.GetNumHeavyAtoms() for f in frags], reverse=True)
        # 【如果前两大片段大小接近,需要人工检查】
        if len(sizes) >= 2 and sizes[1] > 0.5 * sizes[0]:
            multi_component.append((smi, sizes))
    print(f"【{len(multi_component)} 个分子的多个片段大小接近,需检查】")
    return multi_component[:n_sample]

去重:用什么作为标识

标识 严格程度 适用
原始 SMILES 字符串 无效 不同写法不会被识别为重复
规范 SMILES 基本可用(见 031《SMILES 入门》
标准化后的规范 SMILES 较严 推荐(见 046《分子标准化》
InChIKey 较严 跨工具一致(见 033《InChI 与 InChIKey》
InChIKey 前 14 位 宽松 忽略立体化学
去立体的规范 SMILES 宽松 不区分对映体
import pandas as pd
from rdkit import Chem
from rdkit.Chem.MolStandardize import rdMolStandardize

def make_key(smiles, level="standard"):
    """生成不同严格程度的去重键"""
    mol = Chem.MolFromSmiles(smiles)
    if mol is None:
        return None
    mol = rdMolStandardize.Cleanup(mol)
    mol = rdMolStandardize.FragmentParent(mol)
    if mol is None:
        return None

    if level == "standard":
        return Chem.MolToSmiles(mol)
    if level == "no_stereo":
        Chem.RemoveStereochemistry(mol)
        return Chem.MolToSmiles(mol)
    if level == "inchikey":
        return Chem.InchiToInchiKey(Chem.MolToInchi(mol))
    if level == "skeleton":
        return Chem.InchiToInchiKey(Chem.MolToInchi(mol))[:14]
    raise ValueError(level)

# 【比较不同严格程度下的重复数】
def compare_dedup_levels(smiles_list):
    for level in ["standard", "no_stereo", "inchikey", "skeleton"]:
        keys = [make_key(s, level) for s in smiles_list]
        keys = [k for k in keys if k]
        print(f"{level:12s}: {len(keys)} → {len(set(keys))} "
              f"(去掉 {len(keys) - len(set(keys))} 个重复)")

# 【判断该用哪个级别】:
#   如果数据中立体化学标注不完整
#   → 用 no_stereo 或 skeleton,避免同一化合物
#     因为标注差异被分成两条
#   如果立体化学与活性相关且标注完整
#   → 用 standard 或 inchikey

重复数据的活性合并:关键判断

# 【发现重复后,活性值怎么处理?】
#
# 【第一步:先检查一致性,不要直接合并】

import numpy as np

def check_duplicate_consistency(df, key_col="std_smiles",
                                value_col="pActivity", threshold=1.0):
    """检查重复测定的一致性"""
    grouped = df.groupby(key_col)[value_col]
    stats = grouped.agg(["count", "min", "max", "std", "median"])
    stats["spread"] = stats["max"] - stats["min"]

    dups = stats[stats["count"] > 1]
    print(f"有重复的分子: {len(dups)}")
    print(f"重复次数分布:\n{dups['count'].value_counts().head()}")

    inconsistent = dups[dups["spread"] > threshold]
    print(f"\n【差异 > {threshold} 个 log 单位的: {len(inconsistent)}】")
    print(inconsistent.sort_values("spread", ascending=False).head(10))

    return stats, inconsistent

# 【判读】:
#   spread < 0.3   → 一致性好,可以合并
#   spread 0.3~1.0 → 正常的实验波动
#   spread > 1.0   → 【可能有问题】:
#     - 不同的测定方法/条件
#     - 数据录入错误
#     - 【结构标识错误】(两个不同的化合物被当成同一个)
#     - 化合物不稳定
#   → 【应该单独检查,考虑剔除或分开处理】

# 【第二步:选择合并策略】
#
# 中位数(推荐):
#   对异常值稳健
#   df.groupby(key)[value].median()
#
# 均值:
#   受异常值影响
#
# 最大值:
#   假设「测不到活性是实验问题」
#   → 【会系统性高估】
#
# 保留信息最多的那条:
#   如按数据来源的可靠性排序
#
# 【最好的做法】:
#   合并后【保留重复次数与离散程度】作为额外列
#   → 建模时可以给一致性好的数据更高权重
#   → 【也可以用离散程度估计实验噪声的下限】

def merge_duplicates(df, key_col="std_smiles", value_col="pActivity"):
    agg = df.groupby(key_col).agg(
        value=(value_col, "median"),
        n_measurements=(value_col, "count"),
        spread=(value_col, lambda x: x.max() - x.min()),
    ).reset_index()
    return agg

# 【实验噪声的估计】:
#   重复测定的标准差 = 实验噪声的下限
#   → 【模型的 RMSE 不可能低于这个值】
#   → 这给了你一个合理的性能上限参照(见 044)

跨数据源合并时的额外问题

  • 不同来源的实验条件可能不同:即使化合物相同,IC50 也可能差几倍(见 156《AI ADMET 预测模型》)。合并前应该检查各来源的分布是否一致
  • 标识符的映射:CAS 号可能对应盐型,数据库 ID 之间的映射不完全(见 033《InChI 与 InChIKey》);
  • 数据来源作为特征:如果无法统一,把「来源」作为一个特征,让模型显式建模体系差异
  • 保留追溯信息:合并后记录每条数据来自哪些原始记录——出问题时能查
  • 不要过早合并:先分别分析各来源,确认可比后再合并。

完整的清洗流程

def clean_dataset(df, smiles_col="smiles", value_col="pActivity"):
    """完整的数据清洗流程"""
    n0 = len(df)
    log = {}

    # 1) 解析与标准化
    df["std_smiles"] = df[smiles_col].apply(lambda s: make_key(s, "standard"))
    df = df.dropna(subset=["std_smiles"])
    log["解析/标准化失败"] = n0 - len(df)

    # 2) 【检查异常结构】(见 048)
    #    分子量范围、原子类型、片段数等
    from rdkit.Chem import Descriptors
    def is_reasonable(smi):
        m = Chem.MolFromSmiles(smi)
        if m is None:
            return False
        mw = Descriptors.MolWt(m)
        return 50 < mw < 2000 and m.GetNumHeavyAtoms() >= 3
    n1 = len(df)
    df = df[df["std_smiles"].apply(is_reasonable)]
    log["异常结构"] = n1 - len(df)

    # 3) 【检查重复一致性】
    stats, inconsistent = check_duplicate_consistency(df)
    #    可选:剔除高度不一致的
    bad_keys = set(inconsistent.index)
    n2 = len(df)
    df = df[~df["std_smiles"].isin(bad_keys)]
    log["不一致的重复"] = n2 - len(df)

    # 4) 合并重复
    df = merge_duplicates(df, value_col=value_col)
    log["合并后唯一分子数"] = len(df)

    # 5) 【记录清洗日志】
    print("清洗日志:")
    for k, v in log.items():
        print(f"  {k}: {v}")
    return df

# 【清洗日志的价值】:
#   1) 可复现
#   2) 【如果丢失的数据比例很高,说明数据源有问题】
#   3) 出问题时能定位

关键要点

  • 发现重复后先检查一致性,不要直接合并——差异 > 1 个 log 单位的要单独检查;
  • 用中位数合并比均值稳健;保留重复次数与离散程度作为额外列;
  • 重复测定的标准差是实验噪声的下限,也是模型 RMSE 的合理下界;
  • 去重键的严格程度取决于立体化学标注是否完整;记录完整的清洗日志。

延伸资源