最近我做了一个很小的机器学习项目:用 MLP 预测 20 种标准氨基酸的亲疏水性。
这个项目的数据量很小,模型也不复杂,但它刚好覆盖了一个机器学习实验最重要的几个环节:
- 原始数据如何变成模型能理解的数值特征
- 神经网络如何通过损失函数和优化器学习
- 小数据集为什么容易过拟合
- 为什么评估方式比单次准确率更重要
- 为什么要做 baseline 和正则化
项目地址中的核心流程是:
rust
SMILES 字符串 -> RDKit 解析 -> Morgan 指纹 -> MLP 分类 -> 留一交叉验证
项目目标
给定一个氨基酸的分子结构,用机器学习模型预测它是疏水还是亲水。
数据文件中每一行是一种氨基酸,例如:
scss
name,abbreviation,three_letter,smiles,hydrophobic
丙氨酸,Ala,Ala,CC(N)C(=O)O,1
丝氨酸,Ser,Ser,NC(CO)C(=O)O,0
其中:
smiles是氨基酸的分子结构表示hydrophobic是标签,1表示疏水,0表示亲水
这是一个二分类问题。
为什么不能直接把 SMILES 喂给神经网络
SMILES 是一种文本形式的分子表示,例如丙氨酸的 SMILES 是:
scss
CC(N)C(=O)O
但是神经网络本质上处理的是数值张量。它并不直接理解 C、N、O 这些字符代表什么化学意义。
所以第一步要做特征工程:把 SMILES 转换成模型可以学习的数值向量。
在这个项目中,我使用 RDKit 生成 Morgan 指纹:
python
def smiles_to_morgan_bits(
smiles: str,
radius: int = 2,
fp_size: int = 2048,
) -> torch.Tensor:
mol = Chem.MolFromSmiles(smiles)
if mol is None:
raise ValueError(f"Invalid SMILES: {smiles}")
generator = AllChem.GetMorganGenerator(radius=radius, fpSize=fp_size)
fingerprint = generator.GetFingerprint(mol)
return torch.tensor(
list(map(int, fingerprint.ToBitString())),
dtype=torch.float32,
)
Morgan 指纹可以理解为:把分子中的局部结构模式编码成一个固定长度的 0/1 向量。
比如原始输入是:
scss
CC(N)C(=O)O
经过特征工程后变成类似这样的向量:
csharp
[0, 1, 0, 0, 1, ..., 0]
在这个项目中,每个氨基酸最终都会变成一个 2048 维的向量。
模型结构
模型使用的是一个很小的 MLP:
rust
Input (2048) -> Linear -> ReLU -> Dropout -> Linear -> Output (1)
对应代码:
ini
model = HydroMLP(in_dim=2048, hidden_layer_sizes=(32,), dropout=0.1)
这里有一个重要选择:模型没有设计得很大。
原因是数据只有 20 条,而输入特征却有 2048 维。如果模型太大,它很容易把训练集记住,而不是真的学到亲疏水性的规律。这就是过拟合。
所以我做了两个约束:
- 使用较小的隐藏层:
2048 -> 32 -> 1 - 加入正则化:
Dropout和weight_decay
训练时使用:
ini
criterion = nn.BCEWithLogitsLoss()
optimizer = torch.optim.Adam(model.parameters(), lr=0.003, weight_decay=1e-3)
BCEWithLogitsLoss 适合二分类任务。它内部会把模型输出的 logit 转换成概率,再计算二分类交叉熵。
weight_decay 是 L2 正则化,可以限制模型参数不要变得过大,从而降低过拟合风险。
为什么使用留一交叉验证
一开始我用的是普通的训练集/测试集划分,例如 15 条训练、5 条测试。
但这个项目只有 20 条数据。测试集只有 5 条时,结果非常容易受随机划分影响。某一次准确率高,不一定说明模型真的好;某一次准确率低,也不一定说明模型完全没学到东西。
因此我改成了留一交叉验证。
留一交叉验证的做法是:
- 每次拿 1 个氨基酸作为测试样本
- 剩下 19 个氨基酸作为训练样本
- 重复 20 次,让每个氨基酸都当一次测试样本
- 汇总 20 次预测结果,计算总体准确率
代码中的核心逻辑是:
scss
for test_idx in range(len(X)):
train_idx = [i for i in range(len(X)) if i != test_idx]
model = train_model(X[train_idx], y[train_idx])
model.eval()
with torch.no_grad():
logit = model(X[test_idx].unsqueeze(0))
prob = torch.sigmoid(logit).item()
pred = int(prob > 0.5)
对于小数据集来说,留一交叉验证比单次随机划分更适合用来观察模型表现。
如何复现
项目使用 uv 管理依赖。安装依赖后,直接运行主脚本:
arduino
uv sync
uv run python main.py
项目结构如下:
css
├── data/
│ └── amino_acids.csv
├── src/
│ ├── features.py
│ └── model.py
├── main.py
└── pyproject.toml
其中:
data/amino_acids.csv保存 20 种氨基酸的数据和标签src/features.py负责把 SMILES 转换成 Morgan 指纹src/model.py定义 MLP 模型main.py负责训练、留一交叉验证和结果输出
实验结果
当前运行结果是:
makefile
留一交叉验证准确率: 65.0% (13/20)
部分预测结果如下:
✓ 丙氨酸: 预测=疏水 (疏水概率 0.70), 真实=疏水
✓ 缬氨酸: 预测=疏水 (疏水概率 0.61), 真实=疏水
✗ 亮氨酸: 预测=亲水 (疏水概率 0.31), 真实=疏水
✓ 丝氨酸: 预测=亲水 (疏水概率 0.29), 真实=亲水
✗ 酪氨酸: 预测=疏水 (疏水概率 0.97), 真实=亲水
✓ 精氨酸: 预测=亲水 (疏水概率 0.02), 真实=亲水
这个结果说明模型确实学到了一部分规律,但还不稳定。
比如它能识别一些明显的亲水氨基酸,也能识别一部分疏水氨基酸。但对于边界比较模糊,或者结构上有特殊基团的氨基酸,仍然容易出错。
这也提醒我:不能只看训练集损失。如果训练损失很低,但留一交叉验证表现一般,那模型很可能只是记住了训练样本。
我从这个项目学到了什么
1. 特征工程是机器学习的入口
模型并不是直接学习 SMILES 字符串,而是学习 Morgan 指纹。
所以这个项目真正的输入不是:
scss
CC(N)C(=O)O
而是:
yaml
2048 维 Morgan 指纹向量
特征工程决定了模型能看到什么信息。
2. 小数据项目里,评估比训练更重要
只有 20 条数据时,模型很容易把训练集背下来。
如果只看训练损失,很容易得到错误信心。留一交叉验证虽然不能让数据变多,但能让评估更完整。
3. 正则化是在限制模型死记硬背
这个项目里使用了两种正则化方式:
Dropoutweight_decay
它们的目的不是让模型更复杂,而是让模型更克制。
4. Baseline 很重要
这个项目目前已经有了 MLP,但下一步应该做 baseline。
Baseline 就是一个简单参照模型,用来回答一个问题:
我的复杂模型真的比简单方法更好吗?
可以尝试的 baseline 包括:
- 多数类 baseline:永远预测数据中数量更多的类别
- Logistic Regression:使用同样的 Morgan 指纹,但只训练线性分类器
- RDKit 描述符模型:使用 LogP、分子量、TPSA、氢键供体/受体数量等少量特征
如果一个简单 baseline 就能达到和 MLP 接近的准确率,那说明 MLP 可能并没有带来明显收益。
项目局限
这个项目是一个学习项目,不适合直接当作严肃的化学预测模型。
主要局限有:
- 数据只有 20 条,远远不够训练稳定模型
- 亲疏水性本身不是绝对二分类,不同教材或标度可能会有不同划分
- Morgan 指纹只是一种特征表示,可能没有捕捉到所有与亲疏水性相关的信息
- 没有和 baseline 模型做系统比较
- 没有调参实验,也没有更多评价指标
这些局限并不代表项目没有价值。相反,它们正好说明了机器学习实验中最重要的一点:模型结果必须结合数据、特征、评估方式一起解释。
后续可以继续做什么
后续我想从三个方向继续优化:
- 增加 baseline 用 Logistic Regression、Random Forest 或多数类预测作为对照。
- 尝试 RDKit 分子描述符 不只使用 Morgan 指纹,还可以加入 LogP、TPSA、分子量、氢键供体和受体数量等更直观的化学特征。
- 输出更多评价指标 除了 accuracy,还可以看 confusion matrix、precision、recall,观察模型到底更容易把哪一类预测错。
总结
这个项目很小,但它让我完整走了一遍机器学习实验流程:
rust
数据 -> 特征工程 -> 模型 -> 训练 -> 正则化 -> 交叉验证 -> 结果解释
对我来说,这个项目最重要的收获不是准确率有多高,而是开始理解:
- 模型只能学习它看到的特征
- 训练集表现好不代表泛化能力强
- 小数据集更需要谨慎评估
- baseline 是判断模型价值的参照物
- 机器学习结果需要被解释,而不是只被展示
这也是我觉得这个项目适合作为机器学习入门练习的原因:它不大,但关键概念都在里面。