1. 项目概述:理解过拟合与欠拟合的核心概念
在机器学习实践中,我们经常会遇到两个关键问题:模型过于简单导致欠拟合(Underfitting),或者模型过于复杂导致过拟合(Overfitting)。这个项目通过PyTorch构建了一个直观的演示环境,帮助我们理解这两种现象的本质差异。
1.1 问题背景与核心价值
想象一下你正在学习绘画。如果只教你画直线(模型太简单),你永远画不出优美的曲线(欠拟合);但如果要求你临摹每一个细微的笔触(模型太复杂),你可能连基本的形状都把握不准(过拟合)。这个项目正是通过代码再现这种困境,让我们能够:
- 可视化观察训练过程中两种现象的表现
- 定量分析不同模型复杂度下的损失变化
- 理解正则化和数据量对模型泛化能力的影响
关键提示:在实际项目中,我们90%的时间都在与过拟合作斗争。掌握这个实验的核心思路,能帮你节省大量调参时间。
1.2 实验设计原理
项目采用了一个精妙的数据生成策略:
- 真实模型是三次多项式:y = 5 + 1.2x - 3.4(x²/2!) + 5.6(x³/3!)
- 生成带噪声的训练数据(100个样本)和测试数据(100个样本)
- 通过控制使用的多项式阶数,模拟不同复杂度的模型:
- 正常拟合:使用4阶(匹配真实模型)
- 欠拟合:仅使用2阶
- 过拟合:使用全部20阶
这种设计让我们能清晰观察到:
- 欠拟合时训练/测试误差都较高
- 过拟合时训练误差很低但测试误差很高
- 正常拟合时两者都达到较优平衡
2. 核心代码解析与实现细节
2.1 数据生成的关键步骤
数据构造是本次实验的核心亮点,这段代码值得逐行分析:
python复制# 生成200个标准正态分布的样本点
features = np.random.normal(size=(n_train + n_test, 1))
# 将每个x扩展为多项式特征:[x⁰, x¹, x²,..., x¹⁹]
poly_features = np.power(features, np.arange(max_degree).reshape(1, -1))
# 对高次项进行归一化处理(除以阶乘)
for i in range(max_degree):
poly_features[:, i] /= math.gamma(i + 1) # gamma(n) = (n-1)!
为什么要除以阶乘?这是为了防止x的高次幂数值爆炸。例如当x=2时:
- 原始值:2¹⁹ = 524,288
- 归一化后:2¹⁹/19! ≈ 4.4×10⁻¹³
这种处理使得不同阶数的特征值保持在相近数量级,让模型训练更稳定。
2.2 模型架构设计
项目采用了极简的线性回归模型:
python复制net = nn.Sequential(nn.Linear(train_features.shape[-1], 1, bias=False))
几个设计考量:
- 单层线性层:故意保持模型简单,便于观察核心现象
- 无偏置项(bias=False):因为多项式特征已包含常数项(x⁰)
- 输入维度可变:通过train_features.shape[-1]动态适应不同阶数
这种设计让我们能纯粹地观察"模型复杂度"对拟合效果的影响,而不被其他因素干扰。
2.3 训练过程监控
训练循环中特别设置了损失可视化:
python复制animator = d2l.Animator(
xlabel='epoch', ylabel='loss', yscale='log',
xlim=[1, num_epochs], ylim=[1e-3, 1e2],
legend=['train', 'test'])
关键参数说明:
- yscale='log':对数坐标能更清晰显示损失变化
- ylim=[1e-3, 1e2]:合理设置范围避免曲线挤压
- 每20个epoch记录一次:平衡实时性和可视化效果
这种监控方式让我们能直观看到:
- 欠拟合:两条曲线都居高不下
- 过拟合:训练损失持续下降而测试损失反弹
- 正常拟合:两条曲线同步下降后趋于平稳
3. 三种拟合状态的对比实验
3.1 正常拟合(4阶多项式)
python复制train(poly_features[:n_train, :4], poly_features[n_train:, :4],
labels[:n_train], labels[n_train:])
实验结果特征:
- 训练和测试损失都收敛到约0.01
- 学到的权重接近真实值[5, 1.2, -3.4, 5.6]
- 曲线平稳无剧烈波动
这验证了"模型复杂度与真实数据分布匹配"时的理想情况。
3.2 欠拟合(2阶多项式)
python复制train(poly_features[:n_train, :2], poly_features[n_train:, :2],
labels[:n_train], labels[n_train:])
典型表现:
- 最终训练损失约0.3(是正常情况的30倍)
- 测试损失与训练损失接近但略高
- 学到的权重只能拟合线性趋势,无法捕捉曲线特征
这种情况常见于:
- 模型架构过于简单(如用线性模型拟合非线性数据)
- 特征工程不充分(遗漏关键特征)
3.3 过拟合(20阶多项式)
python复制train(poly_features[:n_train, :20], poly_features[n_train:, :20],
labels[:n_train], labels[n_train:])
显著特征:
- 训练损失可降至0.001以下
- 测试损失在约100轮后开始反弹
- 学到的权重中高阶项出现异常大的数值
这种情况的典型成因:
- 模型容量远大于数据复杂度
- 训练数据量不足(本例仅100个样本)
- 缺乏正则化约束
4. 实战技巧与问题排查
4.1 如何识别拟合状态
通过训练曲线可以直观判断:
| 现象 | 训练损失 | 测试损失 | 曲线特征 |
|---|---|---|---|
| 欠拟合 | 高 | 接近训练损失 | 两条平行的高位线 |
| 正常拟合 | 低 | 接近训练损失 | 两条接近的下降曲线 |
| 过拟合 | 很低 | 明显高于训练损失 | 训练曲线持续下降,测试曲线U型反弹 |
4.2 常见问题解决方案
应对欠拟合:
- 增加模型复杂度
- 更多层/更大隐藏层(对神经网络)
- 更高阶多项式(对线性模型)
- 添加更有意义的特征
- 特征交叉
- 领域知识衍生特征
- 减少正则化强度
- 增大L2正则的λ值
- 减少Dropout比例
应对过拟合:
- 获取更多训练数据(最有效但成本高)
- 使用正则化技术
- L1/L2正则化
- Dropout
- Early Stopping
- 简化模型结构
- 减少层数/参数
- 降低多项式阶数
- 数据增强
- 对图像:旋转/裁剪
- 对文本:同义词替换
4.3 权重分析技巧
训练后打印权重能获得重要洞察:
python复制print(net[0].weight.data.numpy())
- 正常拟合:前几项权重接近真实值,高阶项接近0
- 欠拟合:权重值整体偏小,无法充分拟合
- 过拟合:高阶项出现异常大的正负值,这是拟合噪声的表现
在实际项目中,建议定期检查权重分布:
- 出现极大/极小值往往预示问题
- 权重分布应与特征重要性匹配
- 突然的权重爆炸可能需梯度裁剪
5. 项目扩展与实践建议
5.1 值得尝试的改进实验
-
数据量影响实验
- 固定用20阶模型,逐步增加n_train观察过拟合程度变化
-
正则化对比实验
- 在过拟合情况下,分别添加L1/L2正则观察效果
- 示例代码修改:
python复制optimizer = torch.optim.SGD([ {'params': net[0].weight, 'weight_decay': 0.1} # L2正则 ], lr=0.01)
-
Early Stopping实现
- 监控验证集损失
- 当连续N轮不改善时停止训练
5.2 工程实践中的注意事项
-
数据划分策略
- 小数据场景建议使用交叉验证
- 确保训练/测试集分布一致
-
学习率选择
- 过拟合时尝试减小学习率
- 欠拟合时可适当增大
-
批量大小影响
- 本例使用batch_size=10
- 可尝试调整观察训练动态变化
-
监控指标
- 除了损失,还应监控业务相关指标
- 设置合理的评估频率
5.3 可视化改进建议
-
添加权重变化动画
- 每20轮记录一次权重分布
- 用热力图展示权重演变
-
预测曲线对比
- 绘制真实曲线 vs 模型预测曲线
- 直观显示拟合不足/过度情况
-
置信区间可视化
- 对预测结果添加置信带
- 过拟合时置信区间会异常扩大
这个项目虽然代码量不大,但完整呈现了机器学习模型开发中最关键的平衡艺术。在实践中,我通常会先构建这样一个简化实验验证思路,再扩展到复杂模型。记住:好的模型不是最复杂的,而是在简单和准确之间找到最佳平衡点的那个。