2、分子描述符与特征工程:分子描述符类型、特征提取与标准化

做ADMET预测,说来说去,第一步就是把分子结构变成机器能理解的东西。这个转化过程就是特征工程。我个人习惯把它比喻成"给分子拍CT":你得从不同角度、不同层次提取信息,最后拼出一张完整的健康画像。

特征工程做得好不好,直接决定模型的上限。我以前踩过一个坑:花了两周调超参数,结果R²死活上不去。后来发现是特征没处理好——有些描述符量级差了上千倍,模型根本学不动。从那以后,我每次做项目都会先把特征工程这步扎扎实实过一遍。

2.1 分子描述符类型

分子描述符基本可以分成四大类。你想想看,它们就像分子信息的四个维度:

类型 含义 常用工具 我个人的经验
基于结构的2D描述符 从分子图拓扑结构提取,比如分子量、LogP、氢键供体/受体数 RDKit、OpenBabel 最稳定,几乎不会遇到计算失败的情况
基于结构的3D描述符 考虑构象和空间性质,比如分子表面积、体积、极性表面积 RDKit、Open3DALIGN 计算慢,需要先优化构象,我一般在需要对接信息时才用
指纹描述符 用固定长度的位向量表示分子子结构是否存在 Morgan/Circular指纹、MACCS、ECFP ECFP4是ADMET领域的标配,强烈推荐
量子化学描述符 基于DFT或半经验计算的电子结构性质,如HOMO/LUMO能级、偶极矩 Gaussian、ORCA、xTB 精度高但代价大,小数据集可以试试,大数据集我一般不用
💡 我的建议:刚开始做ADMET项目,优先用RDKit能直接算的2D描述符 + ECFP4指纹。这两个组合足以在大多数任务上拿到不错的基线。等基线稳定了,再考虑要不要加3D或量子化学描述符。

2.2 特征提取:从SMILES到向量

实际干活的时候,我们拿到的大多是SMILES字符串。怎么把SMILES变成数值向量?过程其实就三步:

  1. 解析SMILES:用RDKit的Chem.MolFromSmiles转成分子对象。
  2. 计算描述符:逐项调用函数或算子。
  3. 拼接成特征矩阵:把所有描述符堆起来,每一行是一个分子,每一列是一个特征。

下面给个实际代码片段,我项目里经常这样写:

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

def extract_2d_descriptors(smiles_list):
    """从SMILES列表中提取常用2D描述符"""
    desc_list = []
    for smi in smiles_list:
        mol = Chem.MolFromSmiles(smi)
        if mol is None:
            desc_list.append([np.nan]*14)  # 处理无效SMILES
            continue
        desc = [
            Descriptors.MolWt(mol),           # 分子量
            Descriptors.MolLogP(mol),         # LogP
            Descriptors.NumHDonors(mol),      # 氢键供体
            Descriptors.NumHAcceptors(mol),   # 氢键受体
            Descriptors.TPSA(mol),            # 拓扑极性表面积
            Descriptors.RingCount(mol),       # 环数
            Descriptors.RotatableBonds(mol),  # 可旋转键
            Descriptors.FractionCSP3(mol),    # sp3碳比例
            Descriptors.HeavyAtomCount(mol),  # 重原子数
            Descriptors.NumHeteroatoms(mol),  # 杂原子数
            Descriptors.NumSaturatedRings(mol),
            Descriptors.NumAromaticRings(mol),
            Descriptors.NumAliphaticRings(mol),
            Descriptors.chi0v(mol),           # 键合序指数
        ]
        desc_list.append(desc)
    return np.array(desc_list)
⚠️ 注意:上面的代码只是示例,实际特征数量可以更多。我一般会先用RDKit的DescriptorCalculator一次性算200多个,然后做特征选择。

指纹特征提取更简单。下面是我最常用的ECFP4(Morgan指纹,半径2,1024位):

def get_ecfp4_fingerprint(smiles, n_bits=1024):
    mol = Chem.MolFromSmiles(smiles)
    if mol is None:
        return None
    fp = AllChem.GetMorganFingerprintAsBitVect(mol, 2, nBits=n_bits)
    return np.array(fp)

# 批量提取
smiles = ["CCO", "c1ccccc1", "CC(=O)O"]
ecfp4_matrix = np.array([get_ecfp4_fingerprint(s, 1024) for s in smiles])
print(ecfp4_matrix.shape)  # (3, 1024)

2.3 特征标准化

特征提取完,很多人直接塞进模型。这是大忌。为什么?因为不同描述符的量级差别太大了——分子量是几百,LogP在-2到5之间,而量子化学描述符可能是-0.1到0.01。树模型对量级不敏感,但线性模型、SVM、神经网络全都会崩。

我常用的标准化方法有两种:

方法 公式 适用场景
Z-score标准化 \( z = \frac{x - \mu}{\sigma} \) 特征近似高斯分布,常用于线性模型、神经网络
Min-Max归一化 \( x' = \frac{x - x_{min}}{x_{max} - x_{min}} \) 特征值有明确边界,如位向量指纹
💡 避坑指南:标准化一定要在划分训练集和测试集之后做。我曾经犯过错:用整个数据集的均值和方差去标准化,结果导致严重数据泄露,模型得分虚高。正确的做法是:只在训练集上计算均值和标准差,然后用同样的参数去变换测试集。

用scikit-learn实现很简单:

from sklearn.preprocessing import StandardScaler, MinMaxScaler

# 假设 X_train, X_test 已经是特征矩阵
scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train)
X_test_scaled = scaler.transform(X_test)  # 注意:只用transform,不fit

2.4 知识体系总览

下面这张图概括了本章的核心流程。从输入SMILES开始,经过解析、三路特征提取(2D/3D/指纹),再到标准化和合并,最终得到可用于建模的特征矩阵。

分子描述符与特征工程 知识体系 SMILES 字符串 RDKit 解析为 Mol 对象 2D 描述符 分子量、LogP TPSA、氢键计数 ROTB、环数 3D 描述符 分子体积、表面积 极性表面积 (需构象优化) 指纹描述符 ECFP4 / MACCS 固定长度位向量 (分子片段存在性) 特征合并/拼接 标准化(Z-score / Min-Max) 特征矩阵 → 模型训练 ⚠️ 标准化必须 在 train/val 拆分之后做 防止数据泄露

2.5 常见陷阱与经验总结

说说我在项目里碰到的几个坑:

  • 缺失值处理:某些分子可能算不出TPSA或某些量子化学描述符。我之前直接把缺失值设为0,结果模型在缺失值多的样本上表现极差。后来改成用特征均值填充,或者干脆删掉计算失败的分子。具体用哪种?看缺失比例。如果低于5%可以填充,超过20%我就直接删掉这个特征。
  • 特征冗余:200多个2D描述符里,很多是高度相关的(比如分子量和重原子数)。我一般会用相关系数矩阵先筛一遍,去掉|r|>0.95的冗余特征。再用随机森林的特征重要性选Top 50。
  • 维度爆炸:ECFP4有1024位,加上2D描述符,特征维度很容易上千。对于小样本数据集(几百个分子),我建议先用PCA降到50-100维,或者用SelectKBest选择与目标变量最相关的特征。
  • 标准化顺序:记住了——先在训练集上fit,再transform训练集和测试集。千万别对全量数据做fit_transform。我刚开始做机器学习时犯过这个错,交叉验证分数虚高,一上测试就崩。
🚨 重要警告:指纹特征(比如ECFP4)因为是0/1位向量,通常不需要标准化。但如果你把指纹和连续型描述符拼接在一起作为特征矩阵,仍然需要对连续型部分做标准化。我一般会单独处理:连续列用StandardScaler,位向量列不做变换,最后用np.concatenate拼起来。