本文基于d2l项目内容整理,介绍过拟合现象及其对抗方法,包括模型选择策略和多项式拟合实验。
1. 训练误差与泛化误差
在机器学习中,我们需要区分两个重要的概念:
核心概念:
- 训练误差 (training error):模型用训练数据集计算得到的误差
- 泛化误差 (generalization error):将模型应用到来自总体的样本时,模型误差的期望
在实践中无法获得总体,而只能在总体随机抽取未在训练集中使用过的数据作为测试集,用测试集估计总体的泛化误差。
2. 过拟合及其对抗方法
只有当模型学到了真正的泛化模式,捕捉到了总体规律,才能作出有效的预测。
2.1 拟合状态的分类
欠拟合 (underfitting)
一开始,模型在训练集上表现一般,处于欠拟合状态。这通常意味着:
- 模型过于简单,无法捕捉数据的复杂模式
- 训练时间不足
- 特征选择不当
过拟合 (overfitting)
随着训练的进行,当模型在训练集上表现良好,但在新数据上的误差较高时,认为发生了过拟合。这意味着:
- 模型过于复杂,捕捉到了来自训练数据的噪声和不必要的细节
- 没有学习到数据的总体模式或规律
- 泛化能力差
合适拟合 (good fit)
理想状态下,模型既能在训练集上表现良好,又能在测试集上保持较好的性能,实现了良好的泛化。
2.2 防止过拟合的方法
为了实现合适拟合并防止过拟合,有效的方法包括:
深度学习中的特殊情况:
在深度学习领域,往往允许一定程度的过拟合。毕竟,比起训练误差与泛化误差的差距,泛化误差更值得被关注。
若没有更广泛和多样的数据,简单模型往往更优于复杂的深度学习模型。从这个视角来看,深度学习的发展一定程度上归功于廉价存储、互联及数字化经济带来的海量数据集。
3. 使用验证集指导模型选择
模型选择是确定模型类别(如决策树之于线性模型)及其超参数(如批量大小、学习率等)的过程。
例如,希望在 MLP 模型中,找到最合适的隐藏层数量、隐藏单元数量、不同激活函数的最佳组合。
3.1 验证集
在模型选择过程中,训练集的泛化误差由测试集估计。但若在模型选择好前就使用了测试集,将带来过拟合的风险。理想情况下,每个测试数据只使用一次就丢弃,但实践中难以有充足的数据支持。
数据集划分策略:
可行的方案是将所有数据分成 3 份:
- 训练集:用于训练模型参数
- 验证集 (validation set):用于评估模型选择,指导调参
- 测试集:用于最终评估模型性能
验证集和测试集在实践中并未清晰地使用和表述,甚至被混淆。在实际项目中,实验结果更多地来自验证集。
3.2 K 折交叉验证
当训练数据稀缺时,甚至难以组织完整的验证集。幸运的是,K 折交叉验证 (K-fold cross-validation) 通过将数据集划分为 K 个子集(折 fold),多次训练和评估模型,提高了数据的利用率。
K 折交叉验证的实现步骤:
数据划分:将完整的数据集随机分成 K 个(近似)等大的子集
- 选择 K 值为 5 或 10,能在计算成本和评估稳定性间权衡平衡
迭代训练与验证:每次选择 K-1 个子集作为训练集,余下的一个子集作为验证集,训练模型,评估模型性能
重复迭代:重复迭代 K 次,每次都使用不同的验证集
计算平均性能:将 K 次迭代的结果(如准确率、精确率、召回率等)求均值以作为模型的整体性能
K 折交叉验证的优势:
- 提高数据利用率
- 在有限数据集上可靠地评估模型性能
- 减小过拟合的风险
- 提供更稳定的性能估计
4. 使用多项式探索拟合
4.1 多项式数据生成
我们使用以下三阶多项式生成数据集进行实验:
其中:
- 为了避免较大的梯度值和损失值,将原始的系数除以$i!$
- 噪声项$\epsilon \sim \mathcal{N}(0, 0.1^2)$
更通用的$n$阶多项式形式为:
其中,$a_i$为第$i$项系数,噪声项$\epsilon \sim \mathcal{N}(0, \sigma^2)$。
4.2 核心函数实现
多项式数据生成函数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| def polynomial_gen(polynomial_coefficients: list, size, noise_std=0.1) -> tuple[Tensor, Tensor]: """ 生成 n 阶多项式数据 .. math:: y = \\sum_{i=0}^{n} \\frac{a_i}{i!} x^i + \\epsilon :param polynomial_coefficients: a_i :param size: 生成的数据集大小 :param noise_std: 多项式的噪声项 :return: 特征张量与标签张量的元组 """ polynomial_coefficients = np.array(polynomial_coefficients) orders = np.arange(len(polynomial_coefficients))
x = np.random.normal(size=(size, len(polynomial_coefficients))) features = np.power(x, orders) / np.vectorize(math.factorial)(orders) labels = features @ polynomial_coefficients labels += np.random.normal(scale=noise_std, size=labels.shape) return torch.from_numpy(features).to(torch.float32), torch.from_numpy(labels).to(torch.float32)
|
特征维度调整函数
为了在模型选择时方便缩放模型复杂度,即改变假设的多项式阶数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| def resize_features(features: torch.Tensor, to_columns: int) -> torch.Tensor: """ 为每一列生成新的随机数据并扩展特征张量
:param features: 原始特征张量 :param to_columns: 新特征张量的目标列数(编号从 1 开始) :return: 扩展后的特征张量 """ num_samples, num_features = features.shape if to_columns > num_features: new_data = torch.randn(num_samples, to_columns - num_features) expanded_features = torch.cat((features, new_data), dim=1) return expanded_features else: return features[:, :to_columns]
|
数据集划分函数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| def split_dataset(features: torch.Tensor, labels: torch.Tensor, train_ratio: float = 0.8) -> tuple: """ 将数据集划分为训练集和验证集
:param features: 特征张量 :param labels: 标签张量 :param train_ratio: 训练集的比例,默认值为 0.8 :return: 训练集和验证集的特征及标签 """ num_samples = features.size(0) num_train = int(num_samples * train_ratio)
indices = torch.randperm(num_samples)
train_indices = indices[:num_train] val_indices = indices[num_train:]
train_features = features[train_indices] train_labels = labels[train_indices] val_features = features[val_indices] val_labels = labels[val_indices]
return train_features, train_labels, val_features, val_labels
|
4.3 训练与测试函数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42
| def train_test(train_features, train_labels, test_features, test_labels, epochs=1000): net = nn.Sequential(nn.Linear(in_features=train_features.size(1), out_features=1, bias=False)) net.apply(lambda m: nn.init.normal_(m.weight, mean=0, std=0.01) if isinstance(m, nn.Linear) else None)
criterion = nn.MSELoss() optimizer = optim.SGD(net.parameters(), LR)
train_losses = [] val_losses = [] train_mses = [] val_mses = []
for epoch in range(epochs): net.train() optimizer.zero_grad() train_outputs = net(train_features).squeeze() train_loss = criterion(train_outputs, train_labels) train_loss.backward() optimizer.step()
net.eval() with torch.no_grad(): val_outputs = net(test_features).squeeze() val_loss = criterion(val_outputs, test_labels)
train_losses.append(train_loss.item()) val_losses.append(val_loss.item()) train_mses.append(calculate_accuracy(train_outputs, train_labels)) val_mses.append(calculate_accuracy(val_outputs, test_labels))
if (epoch + 1) % 100 == 0: print(f'Epoch [{epoch + 1:4}/{epochs}]:' f'Train Loss: {train_loss.item():.4f}, Train MSE: {train_mses[-1]:.4f}, ' f'Validation Loss: {val_loss.item():.4f}, Validation MSE: {val_mses[-1]:.4f}')
param = net[0].weight.data.numpy().flatten() return param, train_losses, val_losses, train_mses, val_mses
|
查看完整代码实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53
| import math import numpy as np import torch from matplotlib import pyplot as plt from torch import Tensor, nn, optim
LR = 0.01
def calculate_accuracy(outputs: Tensor, labels: Tensor) -> float: """ 计算模型的精度
:param outputs: 模型输出 :param labels: 真实标签 :return: 精度 """ with torch.no_grad(): mse = nn.MSELoss()(outputs, labels) return mse.item()
def plot_training_history(train_losses, val_losses, train_mses, val_mses): """ 绘制训练损失、验证损失、训练 MSE 和验证 MSE 的变化过程
:param train_losses: 训练损失列表 :param val_losses: 验证损失列表 :param train_mses: 训练 MSE 列表 :param val_mses: 验证 MSE 列表 """
plt.subplot(1, 2, 1) plt.plot(train_losses, label='Train Loss', color='#81BBF8') plt.plot(val_losses, label='Validation Loss', color='#0C68CA') plt.xlabel('Epoch') plt.ylabel('Loss') plt.title('Training and Validation Loss') plt.yscale('log') plt.legend()
plt.subplot(1, 2, 2) plt.plot(train_mses, label='Train MSE', color='#F1A2AB') plt.plot(val_mses, label='Validation MSE', color='#AD1A2B') plt.xlabel('Epoch') plt.ylabel('MSE') plt.title('Training and Validation MSE') plt.yscale('log') plt.legend()
plt.tight_layout() plt.show()
|
5. 拟合实验结果
通过改变多项式的阶数来改变模型的容量,我们可以观察不同拟合状态的表现。
5.1 简单模型欠拟合
1 2 3 4 5 6 7 8 9
| coeffs = [5.0, 1.2, -3.4, 5.6] SIZE = 1000 EPOCHS = 1000
features, labels = polynomial_gen(coeffs, SIZE) features = resize_features(features, to_columns=2)
train_f, train_l, val_f, val_l = split_dataset(features, labels) learned_coeffs, *record = train_test(train_f, train_l, val_f, val_l, epochs=EPOCHS)
|
1 2 3 4 5
| 真实的多项式系数:[5.0, 1.2, -3.4, 5.6] 习得的多项式系数:[3.1565626 1.1513788]
Epoch [1000/1000]:Train Loss: 18.4820, Train MSE: 18.4820, Validation Loss: 25.3429, Validation MSE: 25.3429
|
欠拟合分析:
数据来自三阶多项式,应在拟合过程中使用 4 个系数。但若仅使用 2 个,本质上使用的是线性模型,对复杂的数据表现为欠拟合。
- 训练损失和验证损失都较高
- 模型无法捕捉数据的复杂模式
- 需要增加模型复杂度
5.2 复杂模型过拟合
1 2 3 4 5
| coeffs = [5.0, 1.2, -3.4, 5.6] features = resize_features(features, to_columns=7)
train_f, train_l, val_f, val_l = split_dataset(features, labels) learned_coeffs, *record = train_test(train_f, train_l, val_f, val_l, epochs=EPOCHS)
|
1 2 3 4 5 6
| 真实的多项式系数:[5.0, 1.2, -3.4, 5.6] 习得的多项式系数:[ 4.9833040e+00 1.1989247e+00 -3.3791604e+00 5.5992527e+00 3.9804489e-03 -4.9315928e-03 6.2198155e-03]
Epoch [1000/1000]:Train Loss: 0.0101, Train MSE: 0.0101, Validation Loss: 0.0101, Validation MSE: 0.0101
|
过拟合分析:
使用 7 个系数的更高阶多项式拟合将更容易捕捉到噪声,对较简单的数据表现为过拟合。
- 训练损失很低,但泛化能力可能受限
- 模型学习了额外的噪声特征
- 需要正则化或减少模型复杂度
5.3 合适模型合适拟合
1 2 3 4 5
| coeffs = [5.0, 1.2, -3.4, 5.6] features = resize_features(features, to_columns=4)
train_f, train_l, val_f, val_l = split_dataset(features, labels) learned_coeffs, *record = train_test(train_f, train_l, val_f, val_l, epochs=EPOCHS)
|
1 2 3 4 5
| 真实的多项式系数:[5.0, 1.2, -3.4, 5.6] 习得的多项式系数:[ 5.004878 1.1933998 -3.4009356 5.6035047]
Epoch [1000/1000]:Train Loss: 0.0096, Train MSE: 0.0096, Validation Loss: 0.0104, Validation MSE: 0.0104
|
合适拟合分析:
使用 4 个系数,模型复杂性与数据复杂性匹配,表现为合适拟合。
- 训练损失和验证损失都较低且接近
- 学习到的系数与真实系数非常接近
- 模型具有良好的泛化能力
总结
通过多项式拟合实验,我们可以清楚地观察到:
- 欠拟合:模型过于简单,无法捕捉数据的复杂模式
- 过拟合:模型过于复杂,学习了噪声而非真实模式
- 合适拟合:模型复杂度与数据复杂度匹配,具有良好的泛化能力
“限制特征的数量”是缓解过拟合问题的有效方法之一。在实际应用中,我们需要通过验证集和交叉验证等方法来选择合适的模型复杂度。