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变成数值向量?过程其实就三步:
- 解析SMILES:用RDKit的
Chem.MolFromSmiles转成分子对象。 - 计算描述符:逐项调用函数或算子。
- 拼接成特征矩阵:把所有描述符堆起来,每一行是一个分子,每一列是一个特征。
下面给个实际代码片段,我项目里经常这样写:
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/指纹),再到标准化和合并,最终得到可用于建模的特征矩阵。
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拼起来。