Transformer Learning —- Training and Regularization
实验环境:
- 操作系统: Linux Ubuntu 24.04
- nogpu
这一章主要讲模型训练时会遇到的问题, 以及常见的处理方法.
核心问题是: 模型不能只在训练集上表现好, 模型还要在没见过的新数据上表现好.
所以这一章会包含两类内容:
训练机制: 前向传播, 反向传播, 计算图, 参数初始化
正则化方法: 权重衰减, 暂退法, 防止过拟合
最后用 Kaggle 房价预测作为一个综合实战例子.
1 模型的正则化方法
1.1 模型选择, 欠拟合和过拟合
训练模型时, 我们通常会看两个指标:
训练误差: 模型在训练集上的误差
验证误差: 模型在没参与训练的数据上的误差
如果模型连训练集都学不好, 这叫欠拟合. 欠拟合通常说明模型太简单, 表达能力不够.
如果模型在训练集上很好, 但在验证集上很差, 这叫过拟合. 过拟合通常说明模型太复杂, 或者数据太少, 模型把训练集里的噪声也记住了.
可以这样理解:
- 欠拟合: 题都没学会
- 过拟合: 只背了练习题答案
- 泛化好: 真正理解了规律
模型选择就是在不同模型和不同超参数之间做选择, 目标是找到验证集表现最好的方案.
1.2 权重衰减
权重衰减(weight decay), 也叫 L2 正则化.
它的核心思想是: 不要让模型的权重太大
普通训练只优化预测损失(loss). 加入权重衰减后, 目标变成:
1 | loss + lambda * 权重平方和 |
代码形式是:
1 | l = loss(net(X), y) + lambd * l2_penalty(w) |
其中 lambda 控制惩罚强度:
lambda = 0, 不使用权重衰减
lambda 越大, 对大权重的惩罚越强
为什么这样能减少过拟合?
如果模型使用很大的权重, 它可能是在强行记住训练集中的细节和噪声. 权重衰减会把权重往 0 拉, 让模型更平滑, 更稳定, 不那么容易记住训练集.
一个典型的例子: https://zh.d2l.ai/chapter_multilayer-perceptrons/weight-decay.html#id2, 样本数为 20 的训练集, 但是特征点却有 200, 这样模型很容易就找到一些偶然规律进而展现出错误的判断.
PyTorch 简洁写法是:
1 | optimizer = torch.optim.Adam( |
1.3 暂退法
暂退法(Dropout), 是神经网络中常见的正则化方法.
它的核心思想是: 训练时随机关闭一部分神经元
比如隐藏层输出原来是:
1 | [h1, h2, h3, h4, h5] |
使用 Dropout 后可能变成:
1 | [h1, 0, h3, 0, h5] |
这样可以逼模型不要过度依赖某几个神经元.
PyTorch 写法是:
1 | net = nn.Sequential( |
Notice:
训练时, Dropout 生效
测试时, Dropout 关闭
这是显而易见的.
在 PyTorch 中:
1 | net.train() # 训练模式, Dropout 生效 |
2 模型调优
2.1 前向传播
前向传播就是模型从输入算到输出的过程.
比如一个 MLP:
1 | 输入 X |
代码形式是:
1 | y_hat = net(X) |
前向传播回答的问题是: 模型现在预测得怎么样?
它会计算预测结果和损失, 同时保存中间变量, 给反向传播使用.
2.2 反向传播
反向传播就是从 loss 开始, 反过来计算每个参数的梯度.
它的方向是:
1 | loss -> 输出层 -> 隐藏层 -> 输入附近的层 |
代码形式是:
1 | loss.backward() |
执行完以后, 每个可训练参数都会得到 .grad:
1 | W1.grad |
反向传播回答的问题是: 为了让 loss 变小, 每个参数应该怎么改?
优化器根据梯度更新参数:
1 | optimizer.step() |
完整训练流程是:
1 | y_hat = net(X) # 前向传播 |
2.3 计算图
计算图是 PyTorch 自动记录的一张计算关系图.
比如:
1 | X -> Linear -> ReLU -> Linear -> loss |
PyTorch 会记录每一步操作, 所以当我们调用:
1 | loss.backward() |
它就能沿着计算图反方向计算梯度.
训练时比预测更占内存, 因为训练不仅要做前向传播, 还要保存中间变量给反向传播使用.
Notice:
预测: 只需要前向传播
训练: 前向传播 + 保存中间变量 + 反向传播
2.4 参数初始化
神经网络的权重不能随便初始化.
如果权重太小, 梯度可能越来越小, 这叫梯度消失.
1 | 梯度太小 -> 参数几乎不更新 -> 模型学不动 |
如果权重太大, 梯度可能越来越大, 这叫梯度爆炸.
1 | 梯度太大 -> 参数更新过猛 -> loss 乱跳甚至 NaN |
还有一个问题叫打破对称性.
如果同一层的神经元初始化成完全一样, 它们会得到一样的输出, 一样的梯度, 最后永远学一样的东西.
所以权重通常要随机初始化.
常见初始化方法是 Xavier 初始化:
1 | nn.init.xavier_uniform_(layer.weight) |
它的目标是: 让每一层的输出不要越来越大, 也不要越来越小, 让训练更稳定.
常见搭配是:
1 | sigmoid/tanh -> Xavier 初始化 |
3 预测房价实例
这一节用 Kaggle 房价预测做一个综合实战.
这个任务是:
- input: 房子的各种属性
- output: 预测房价 SalePrice
本样例运行成功后会生成:
1 | submission.csv |
3.1 代码拆解
3.1.1 下载数据
代码里先定义数据地址和校验值:
1 | DATA_URL = "http://d2l-data.s3-accelerate.amazonaws.com/" |
download 函数负责下载 CSV, 并且会检查本地缓存.
1 | train_data = pd.read_csv(download("kaggle_house_train")) |
训练集有房价标签 SalePrice, 测试集没有.
3.1.2 合并训练集和测试集特征
1 | all_features = pd.concat((train_data.iloc[:, 1:-1], test_data.iloc[:, 1:])) |
这行做了三件事:
1 | 1. 去掉训练集的 Id |
然后把训练集特征和测试集特征拼在一起, 统一做预处理.
为什么要一起处理?
因为类别特征要做独热编码(One-Hot), 如果训练集和测试集分开处理, 可能会产生不一致的列.
One-Hot description from https://cloud.tencent.com/developer/article/1688022
3.1.3 数值特征标准化
新版 pandas 里, 字符串列可能不是
object类型, 所以这里不用dtype != "object", 而是直接选择数值列:
1 | numeric_features = all_features.select_dtypes(include=["number"]).columns |
然后标准化:
1 | all_features[numeric_features] = all_features[numeric_features].apply( |
标准化后: 均值约等于 0, 标准差约等于 1
缺失值填 0:
1 | all_features[numeric_features] = all_features[numeric_features].fillna(0) |
因为标准化之后, 0 表示这个特征的平均水平.
3.1.4 类别特征独热编码
房价数据里有很多字符串特征, 比如:
1 | MSZoning |
模型不能直接处理字符串, 所以要转成 0/1 特征:
1 | all_features = pd.get_dummies(all_features, dummy_na=True) |
dummy_na=True 的意思是, 缺失值也单独作为一种类别.
比如,
1 | Id MSZoning |
独热编码后,
1 | Id MSZoning_RL MSZoning_RM MSZoning_nan |
所以原来 1 列 MSZoning
会被拆成多列,
1 | MSZoning_RL |
每一列都是 0 或 1, 这样模型就能处理了, 最后转成 float32:
1 | all_features = all_features.astype("float32") |
这一步很重要, 因为 PyTorch 训练时需要数值张量.
3.1.5 转成 PyTorch 张量
1 | train_features = torch.tensor(all_features[:n_train].values, dtype=torch.float32) |
运行后实际形状是:
1 | train_features: (1460, 330) |
也就是:
1 | 训练集 1460 个样本 |
3.1.6 DataLoader
这里自己实现了一个简单的 make_data_iter.
1 | def make_data_iter(features, labels, batch_size, shuffle=True): |
作用是把特征和标签打包成小批量数据.
训练时每次取一小批样本, 而不是一次性把全部数据喂给模型.
3.1.7 模型
模型是一个线性回归 baseline:
1 | def get_net(in_features): |
结构是:
1 | 330 个输入特征 -> 1 个房价输出 |
3.1.8 评价指标 log RMSE
房价预测不用普通均方根误差(Root Mean Squared Error, RMSE), 而是使用对数均方根误差(log RMSE).
Notice: 这里因为使用了平方, RMSE 处理之后会对极大的误差非常敏感, 取对数之后就能缩短对极端数值的影响, 预测差 10 万, 对 12 万的房子很严重, 对 400 万的房子影响没那么大.
代码是:
1 | def log_rmse(net, features, labels): |
torch.clamp 是为了防止预测值小于 1, 因为房价要取 log, 小于等于 0 会出问题.
3.1.9 训练函数
训练流程是标准 PyTorch 写法:
1 | optimizer.zero_grad() # 清空旧梯度 |
优化器使用 Adam:
1 | optimizer = torch.optim.Adam( |
这里的 weight_decay 就是前面讲过的权重衰减.
3.1.10 K 折交叉验证
from https://zh.d2l.ai/chapter_multilayer-perceptrons/underfit-overfit.html#k
当训练数据稀缺时,我们甚至可能无法提供足够的数据来构成一个合适的验证集。 这个问题的一个流行的解决方案是采用折交叉验证。 这里,原始训练数据被分成个不重叠的子集。 然后执行次模型训练和验证,每次在个子集上进行训练, 并在剩余的一个子集(在该轮中没有用于训练的子集)上进行验证。 最后,通过对次实验的结果取平均来估计训练和验证误差。
K 折交叉验证用来更稳定地评估模型.
如果 k = 5, 就把训练集分成 5 份:
1 | 第 1 次: 第 1 份验证, 其余 4 份训练 |
最后取 5 次验证误差的平均值.
代码核心是:
1 | data = get_k_fold_data(k, i, X_train, y_train) |
每一折都会重新创建一个模型, 避免上一折训练结果影响下一折.
3.1.11 生成提交文件
交叉验证只是用来选模型和超参数.
选好之后, 再用全部训练集训练一个最终模型:
1 | train_and_pred(...) |
然后对测试集预测:
1 | preds = net(test_features).detach().numpy() |
最后生成 Kaggle 需要的 CSV:
1 | submission = pd.DataFrame( |
输出文件是:
1 | submission.csv |
3.2 完整代码
1 | import hashlib |
输出结果是:
1 | train_features: (1460, 330) |
同时生成了:
1 | submission.csv |