一、训练误差 vs 泛化误差
训练误差(Training Error):模型在训练数据上的误差。
泛化误差(Generalization Error):模型在从未见过的新数据上的误差。
类比:训练误差 = 平时做课堂练习的正确率;泛化误差 = 期末考试的成绩。
关键结论:
我们真正关心的是泛化误差,而不是训练误差。
因为我们训练模型不是为了在训练集上得高分,而是为了处理将来遇到的新数据。
二、验证数据集 vs 测试数据集
为了正确地估计泛化误差,我们需要把手里的数据拆成三份:
2.1 训练数据集(Training Set)
- 用来训练模型参数。
- 比如把全部数据的 60% 拿来。
2.2 验证数据集(Validation Set)
- 用来选择/评估模型、调超参数(学习率、层数、Dropout 比例等)。
- 不能和训练数据混在一起,否则失去意义。
- 比如把 60% 训练集里再拿出 20% ~ 50% 作为验证集。
# 从训练集中划分出验证集n_train=int(0.8*len(train_dataset))train_sub=torch.utils.data.Subset(train_dataset,range(0,n_train))val_sub=torch.utils.data.Subset(train_dataset,range(n_train,len(train_dataset)))# train_sub 用于训练,val_sub 用于调参2.3 测试数据集(Test Set)
- 只用一次的数据集,最终评估性能用。
- 在任何情况下都不能用来调超参数,否则结果就是"作弊"。
类比:
- 验证集 =模拟考试(可以反复做,用来调整复习方向)
- 测试集 =正式高考(只考一次,考完就定论了)
- 如果你拿高考真题来练手,那高考成绩就毫无意义了
为什么不能拿测试集调参?
因为一旦你根据测试集的结果调整了模型,测试集就不再"没见过"了,它的评估就失去了泛化意义——就像上过考场见过真题的人,成绩已经不反映真实水平。
三、K-折交叉验证(K-Fold Cross Validation)
当数据不够多的时候,单纯拿出一部分做验证集太浪费。这时用K-折交叉验证。
3.1 算法过程
算法:K-折交叉验证
- 把训练数据均匀分成 K 块
- For i = 1, 2, …, K:
使用第 i 块作为验证数据集
其余 K-1 块合并作为训练数据集
训练模型,记录第 i 折的验证误差 - 报告 K 个验证误差的平均值
常用 K = 5 或 K = 10。
数据分割示意(K=5): 折1: [验证][训练][训练][训练][训练] 折2: [训练][验证][训练][训练][训练] 折3: [训练][训练][验证][训练][训练] 折4: [训练][训练][训练][验证][训练] 折5: [训练][训练][训练][训练][验证] ↑ 每折轮流当一次"验证集"3.2 代码实现
importtorchfromtorch.utils.dataimportDataset,DataLoaderdefk_fold_cross_validation(dataset,k,train_fn):""" 对 dataset 做 K-折交叉验证。 train_fn: 训练函数,接收 (train_loader, val_loader),返回验证集误差 """total_len=len(dataset)fold_size=total_len//k val_losses=[]foriinrange(k):# 第 i 折的验证集索引val_indices=list(range(i*fold_size,(i+1)*fold_size))train_indices=list(set(range(total_len))-set(val_indices))train_sub=torch.utils.data.Subset(dataset,train_indices)val_sub=torch.utils.data.Subset(dataset,val_indices)train_loader=DataLoader(train_sub,batch_size=256,shuffle=True)val_loader=DataLoader(val_sub,batch_size=256,shuffle=False)# 每一折重新训练一个模型val_loss=train_fn(train_loader,val_loader)val_losses.append(val_loss)print(f"折{i+1}: 验证误差 ={val_loss:.6f}")# 报告 K 个验证误差的平均returnfloat(torch.tensor(val_losses).mean())K=5avg_loss=k_fold_cross_validation(train_dataset,K,my_train_fn)print(f"{K}-折平均验证误差 ={avg_loss:.6f}")3.3 K=5 和 K=10 怎么选
| 选项 | 优点 | 缺点 | 适用 |
|---|---|---|---|
| K=5 | 训练快,每折训练 80% 数据 | 验证集稍大,偏差稍高 | 数据中等,追求速度 |
| K=10 | 每折训练 90% 数据,更接近全量 | 训练慢,成本高 | 数据少且珍贵,追求精度 |
原则:数据越少、越珍贵,K 越大;数据很多时,直接固定一个验证集就行,不必交叉验证。
四、多项式函数拟合实战
我们用一个三次多项式做实验
4.1 生成带噪声的真实数据
importtorchimportmatplotlib.pyplotasplt n_train=100# 训练样本n_test=100# 测试样本# 真实函数:y = 1.2x - 3.4x^2 + 5.6x^3 + 5,再加一点噪声deftrue_fn(x):return1.2*x-3.4*x**2+5.6*x**3+5# 生成 x 和带噪声的 yfeatures=torch.randn(n_train+n_test,1)noise=torch.normal(0,0.5,size=features.shape)labels=true_fn(features)+noise# 用多项式特征扩展:把一列 x 变成 [x, x^2, x^3, ...]defpoly_features(x,degree):returntorch.cat([x**iforiinrange(1,degree+1)],1)# 我们用次数 3 的多项式特征去拟合(和真实函数次数一致)degree=3poly_features=poly_features(features,degree)4.2 三个模型对比:欠拟合 / 合适 / 过拟合
我们用三种"模型容量"来拟合——次数不足、刚好、过高:
| 模型 | 用它拟合 | 结果 |
|---|---|---|
| 线性(次数 1) | 三次数据 | 欠拟合 |
| 三次多项式(次数 3) | 三次数据 | 刚好合适 |
| 高次多项式(次数 e.g.7) | 三次数据 | 过拟合 |
importtorchfromtorchimportnndeftrain_poly(degree,epochs=500,lr=1e-2):"""用指定次数的多项式模型去拟合三次函数的数据"""# 多项式特征features_poly=torch.cat([features**iforiinrange(1,degree+1)],1)n_train=100train_f=features_poly[:n_train]train_l=labels[:n_train]test_f=features_poly[n_train:]test_l=labels[n_train:]# 线性模型(对多项式特征做线性组合,等价于多项式拟合)net=nn.Linear(degree,1,bias=False)net.weight.data.normal_(0,0.01)loss=nn.MSELoss()trainer=torch.optim.SGD(net.parameters(),lr=lr)forepochinrange(epochs):trainer.zero_grad()l=loss(net(train_f),train_l)l.backward()trainer.step()train_loss=loss(net(train_f),train_l).item()test_loss=loss(net(test_f),test_l).item()returntrain_loss,test_lossfordegreein[1,3,7]:tr,te=train_poly(degree)print(f"次数{degree}: 训练误差{tr:.6f}| 泛化(测试)误差{te:.6f}")五、过拟合与欠拟合
- 欠拟合(Underfitting):模型过于简单,训练误差都无法降低。连课堂例题都做不对。
- 过拟合(Overfitting):模型过于复杂,训练误差很低但泛化误差很高。把例题全背下来了,换个问法就废。
六、模型容量与 VC 维
7.1 模型容量(Model Capacity)
模型容量 = 模型拟合各种函数的能力。
- 低容量模型:难以拟合训练数据(欠拟合)。
- 高容量模型:可以记住所有训练数据(但往往过拟合)。
两种极端都不好。我们的目标是:容量刚好够拟合真实规律,但又不会大到把噪声也背下来。
7.2 VC 维(VC Dimension)
怎么定量衡量模型容量?一个常用指标就是VC 维。
VC 维的定义:一个模型最多能"打散(shatter)"多少个数据点——即不管这些点的标签怎么排列(正/负),模型都能找到一个参数把这组点完全分开。
- 二维平面上的线性分类器:VC 维 = 3。它最多能完全分开 3 个点(任意正负组合都能用一条直线分开),但 4 个点就不行了(比如 XOR 布局)。
- 数据点越多、越分散,需要的 VC 维越大。
VC 维 = 3(二维直线的例子)
3 个点:无论正负怎么排,总能画一条直线分开 → 能打散,VC维至少3
4 个点:存在排法(如 XOR)无法用直线分开 → 打不散,所以 VC维 < 4
结论:二维线性分类器 VC 维 = 3
直觉:VC 维越大,模型越"能记",但也越容易过拟合。而在我们的代码里,多项式次数越高,等价于 VC 维越高。
八、触发过拟合的典型场景
| 场景 | 为什么容易过拟合 |
|---|---|
| 训练数据太少 | 模型把少量样本的噪声当成了规律 |
| 模型参数太多 | 容量过大,拟合能力超过真实复杂度 |
| 训练轮数过多(epoch 过长) | 越训越"背"训练集,验证误差开始反弹 |
| 特征维度远大于样本数 | 如基因数据、图像展平向量 |
八、应对过拟合的策略
8.1 降低模型容量
- 少几层网络 / 少几个隐藏单元
- 高次多项式 → 降到合适的次数(前面实验里次数 7 → 次数 3)
8.2 数据增强(Data Augmentation)
让数据"变多变花",降低对噪声的记忆:
# 图像随机翻转 + 旋转,制造更多样本transform=torchvision.transforms.Compose([torchvision.transforms.RandomHorizontalFlip(),torchvision.transforms.RandomRotation(10),torchvision.transforms.ToTensor(),])8.3 正则化(Regularization)
给损失函数加上对"大权重"的惩罚,让模型不要太"张狂"。
L2 正则化(权重衰减 Weight Decay):
# 优化器自带 weight_decaytrainer=torch.optim.SGD(net.parameters(),lr=0.01,weight_decay=0.05)# weight_decay 让每一步更新时权重都往 0 缩一点,抑制过拟合8.4 Dropout
训练时随机"失活"一部分神经元,防止过度依赖某些特征:
net=nn.Sequential(nn.Linear(784,256),nn.ReLU(),nn.Dropout(0.5),# 训练时随机丢弃 50% 神经元nn.Linear(256,128),nn.ReLU(),nn.Dropout(0.5),nn.Linear(128,10),)8.5 早停(Early Stopping)
在验证误差开始回升的那一刻停止训练:
best_val=float('inf')forepochinrange(num_epochs):train_one_epoch()val_loss=evaluate(model,val_loader)ifval_loss<best_val:# 出现了更低的验证误差best_val=val_loss torch.save(model.state_dict(),'best.pt')# 存最好的# 如果连续 N 轮验证误差不再下降,就早停ifno_improve_count>patience:break参考资料:
- 《动手学深度学习》第 4 章:模型选择、欠拟合和过拟合
- Bishop, C. (2006). Pattern Recognition and Machine Learning.