3. 描述符计算与特征选择
描述符计算这一步,说白了就是把分子结构翻译成计算机能读懂的数值。特征选择呢,就是筛掉那些噪音大、相关性低的维度,让模型更稳健。这两步做不好,后面的模型练得再花哨也是白搭。
3.1 描述符计算的坑与经验
我刚入行那会儿,一股脑把上千个描述符全算出来,直接扔进模型训练。结果呢?过拟合得一塌糊涂,泛化能力几乎为零。后来才明白,描述符不是越多越好,关键要跟你的活性终点相关。
常见的描述符类型我给你列个表,方便你对照选型:
| 类型 | 典型例子 | 适用场景 | 注意事项 |
|---|---|---|---|
| 2D描述符 | 分子量、LogP、氢键供体/受体数 | ADME/Tox预测,分类任务 | 计算快,但对立体特异活性不敏感 |
| 3D描述符 | 分子表面积、形状指数、静电势 | 对接打分、构效关系精细建模 | 依赖构象搜索,速度慢,重现性差 |
| 指纹图谱 | ECFP、MACCS、Morgan指纹 | 相似性搜索、虚拟筛选 | 维度高,需配合特征选择降维 |
| 量子化学描述符 | HOMO/LUMO能量、偶极矩 | 反应活性、电荷转移机制 | 计算成本极高,仅小分子库可行 |
我习惯用RDKit做2D描述符,计算又快又稳定。碰到3D描述符需求时,我会先用OpenBabel做结构优化再算。给你个代码片段,这是我最常用的批量计算2D描述符的思路:
from rdkit import Chem
from rdkit.Chem import Descriptors, Descriptors3D
import pandas as pd
def calc_2d_descriptors(smiles_list):
"""
输入SMILES列表,输出DataFrame,包含所有2D描述符
"""
results = []
for smi in smiles_list:
mol = Chem.MolFromSmiles(smi)
if mol is None:
results.append(None)
continue
desc = {}
desc['MolWt'] = Descriptors.MolWt(mol)
desc['LogP'] = Descriptors.MolLogP(mol)
desc['NumHDonors'] = Descriptors.NumHDonors(mol)
desc['NumHAcceptors'] = Descriptors.NumHAcceptors(mol)
desc['TPSA'] = Descriptors.TPSA(mol)
# 更多描述符可以按需添加
results.append(desc)
df = pd.DataFrame(results)
return df
注意:计算描述符前务必做批次质控!我曾经在一个项目中,用PaDEL计算了2000个分子的描述符,结果发现其中300个分子在数据库里是盐形式,计算出的分子量、电荷全部偏离实际。后来我们强制要求所有输入分子必须先标准化成中性分子,再计算描述符。
说到标准化,我一般会做这几步:
- 去除盐和溶剂(用RDKit的
StripMol) - 中性化电荷(处理成最常见的pH=7.4的形态)
- 标准化官能团(比如硝基、磺酸基的统一写法)
这些预处理代码我会单独封装成一个函数,每次计算描述符前强制调用。你想想看,如果连输入分子都没洗干净,后面算出来的描述符能靠谱吗?
3.2 特征选择:别让噪音淹没收益
描述符算完,特征矩阵动辄几百列。但真正的信号可能只藏在几十个特征里。我见过太多人无脑把所有特征丢进模型,然后抱怨模型过拟合。特征选择就是干这个的——精准定位那些真正影响活性的变量。
常见的特征选择方法分三类,我帮你梳理了对比表:
| 方法类别 | 代表算法 | 优点 | 缺点 |
|---|---|---|---|
| 过滤式 | 方差阈值、相关系数、互信息、卡方检验 | 计算快,不依赖模型,适合大尺度筛选 | 单变量独立筛选,忽略特征间交互 |
| 包裹式 | 递归特征消除(RFE)、前向/后向选择 | 考虑特征组合,结果更优 | 计算慢,容易过拟合,需要交叉验证 |
| 嵌入式 | Lasso、决策树特征重要性、XGBoost | 特征选择与模型训练同时完成 | 超参敏感,可解释性可能下降 |
我个人习惯这样组合使用:先用过滤式快速砍掉那些方差极低的描述符(比如所有分子都有一样的值),再跟一个嵌入式方法(比如Lasso或随机森林)做精细挑选。如果项目周期紧,我会直接上XGBoost的特征重要性来做选择,速度快效果也不错。
给你看一段我常用的Lasso特征选择脚本:
from sklearn.linear_model import LassoCV
from sklearn.preprocessing import StandardScaler
import numpy as np
def select_features_by_lasso(X, y, cv=5):
"""
用LassoCV自动选择特征
返回:保留特征的索引列表
"""
scaler = StandardScaler()
X_scaled = scaler.fit_transform(X)
# 指定正则化强度范围,用交叉验证选最佳alpha
lasso = LassoCV(cv=cv, random_state=42, n_alphas=100)
lasso.fit(X_scaled, y)
# 系数非零的特征视为保留
selected = np.where(lasso.coef_ != 0)[0]
return selected.tolist()
经验之谈:Lasso的alpha选择很关键。我试过直接用默认的alphas范围,结果把大部分特征都压缩成0了。后来我把n_alphas设大一点(比如100),并设置eps=0.001,让搜索空间更细。另外,标准化是必须的,不然量级大的描述符更容易被Lasso选中,这是不对的。
还有一个容易翻车的地方:特征选择必须跟交叉验证结合。我曾经直接在整个数据集上用Lasso筛选特征,然后重新用这些特征训练模型,导致模型在外部测试集上表现惨淡。正确的做法是把特征选择放在交叉验证循环内部,每一折都重新筛选,才能避免信息泄露。
3.3 避坑指南:描述符与特征选择的常见雷区
我踩过不少坑,给你列几个最常见的,你以后做项目时留个心眼:
- 描述符重复列:有些描述符定义接近(比如分子量和对数分子量),导致多重共线性。用热力图先检查一下相关性,>0.9的直接删掉一个。
- 缺失值处理:3D描述符常因为构象生成失败而出现NaN。不要直接删整行,可以用中位数填充,或者都用同一个最低能构象重新算。
- 指纹维度过高导致维数灾难:尤其是ECFP指纹,4096位太稀疏。可以先做特征筛选或使用PCA降维到100-500维。
- 批次效应:不同批次实验的分子结构差异较大时,描述符分布可能偏移。我习惯先对描述符做Z-score标准化,并校验每个描述符的均值、方差是否在合理范围内。
核心原则:描述符计算和特征选择,最终是为模型服务的。每个环节的质控标准都应以“能否提升模型稳定性和泛化能力”为导向。不要为了追求复杂度而堆砌特征,也不要为了追求速度而牺牲质量。平衡,才是实战中的智慧。
最后,我把整个章节的核心流程梳理成了一张图,方便你记忆:
这张图把从原始分子到特征矩阵的全过程串联了起来。中间两个提示块——质控要点和选择原则——是我在实际项目中总结出来的最低要求。你每次跑流程前,对照检查一遍,能避免80%的常见错误。