特征工程:分子描述符计算与降维

特征工程,说白了就是决定模型上限的环节。数据质量不行,再牛的Transformer也救不回来。我在ADMET项目里花在特征工程上的时间,往往比调模型本身还多。这一章我就把自己踩过的坑和常用的套路聊透。

用RDKit计算分子描述符

RDKit是化学信息学的瑞士军刀。我习惯用它先算最基础的描述符集,大概200多个。

from rdkit import Chem
from rdkit.Chem import Descriptors, rdMolDescriptors

mol = Chem.MolFromSmiles('CC(=O)OC1=CC=CC=C1C(=O)O')
# 基础描述符
mw = Descriptors.ExactMolWt(mol)
logp = Descriptors.MolLogP(mol)
hba = Descriptors.NumHAcceptors(mol)
hbd = Descriptors.NumHDonors(mol)

# 我更喜欢用rdkit的完整计算器
calc = Descriptors.CalcMolDescriptors(mol)
print(list(calc.keys())[:10])  # 看看有哪些描述符
提示:同一个分子在不同版本的RDKit里算出的描述符可能不一样。版本锁定很重要,我用的是2023.09。

Mordred:更丰富的描述符库

RDKit自带的描述符有些不够用,特别是涉及拓扑和三维构象的时候。Mordred补上了这个缺口,能算1800多个描述符。不过它有副作用——计算慢。

pip install mordred
from mordred import Calculator, descriptors

calc = Calculator(descriptors, ignore_3D=True)
mol = Chem.MolFromSmiles('CC(=O)OC1=CC=CC=C1C(=O)O')
results = calc(mol)
# 转换成DataFrame
import pandas as pd
df = pd.DataFrame([dict(results)], columns=list(results.keys()))
注意:Mordred的1800多个描述符里有大量冗余和零方差特征。直接全喂进模型,梯度消失和过拟合就来了。我曾经这么干过,结果模型在验证集上Loss直接飞了。

特征选择:别让冗余特征毁了模型

算完描述符,第一步是筛。我一般用三个方法串起来:

  1. 零方差过滤——所有分子都一样的特征,直接删。
  2. 高相关性消除——相关系数>0.95的特征只留一个。
  3. 基于模型的重要性排序——用XGBoost或LightGBM看特征重要性,保留Top-K。
from sklearn.feature_selection import VarianceThreshold, mutual_info_classif
import joblib

# Step 1: 零方差移除
selector = VarianceThreshold(threshold=0.01)
X_filtered = selector.fit_transform(X)

# Step 2: 基于互信息的筛选
mi = mutual_info_classif(X_filtered, y, random_state=42)
selected_idx = np.argsort(mi)[-100:]  # 保留最重要的100个

我的经验:保留100-150个描述符一般就够用了。再多的话模型容易记住噪声,而且训练时间线性增长。

归一化:让特征站在同一起跑线

分子描述符的量级差别很大——分子量和拓扑极性表面积差了几个数量级。不归一化,梯度下降就像瘸腿走路。

我常用的归一化方法:

方法 适用场景 注意事项
Min-Max缩放 特征值有明确边界时(如电荷范围) 对异常值敏感
Z-score标准化 大多数ADMET任务 假定数据服从正态分布
鲁棒缩放 描述符里混入异常值 计算更稳定
from sklearn.preprocessing import StandardScaler, RobustScaler

# 我一般用RobustScaler,分子数据经常有几个离谱的离群点
scaler = RobustScaler()
X_scaled = scaler.fit_transform(X_selected)
要点:Fit只用在训练集上,验证集和测试集要用同一组均值/中位数去转换,否则就数据泄露了。

降维:PCA与t-SNE

归一化之后,特征维度可能还有200个左右。直接塞给模型,训练慢且容易过拟合。我先用PCA降到50维左右,再用t-SNE可视化看看分布。

from sklearn.decomposition import PCA

pca = PCA(n_components=50)
X_pca = pca.fit_transform(X_scaled)
print(f'保留方差比例: {pca.explained_variance_ratio_.sum():.2f}')

降到50维后,如果看到方差能保留80%以上,说明信息损失不大。我用PCA主要是为了去掉特征间的线性相关——很多描述符本质上在描述同一个物理化学性质。

t-SNE我一般只用来看:类别是否可分?ADMET正例和负例在低维空间里有没有明显分开?

from sklearn.manifold import TSNE

tsne = TSNE(n_components=2, perplexity=30, random_state=42)
X_tsne = tsne.fit_transform(X_scaled)
# 然后画出散点图看分布
坑:t-SNE的perplexity调不对,可视化结果就是骗人的。我一般设成训练样本数的1/10左右别超过50。小了会看到一堆噪点,太大了又会把所有点挤在一起。

一个完整的流程框架

下面这个图是我在项目里总结的流程,每次做新任务时,我也会回头看一眼它:

特征工程流程框架 SMILES/分子结构 RDKit + Mordred 计算描述符 特征选择(方差+相关性+重要性) 归一化(RobustScaler/Z-score) 降维(PCA + t-SNE可视化)

走完这一套流程,特征维度从1800+降到了不到100,而且保留了95%以上的信息。我最近的一个Caco-2穿透性模型,就是用这个流程把validation MAE从0.38压到了0.31。省的是时间,涨的是精度。