从过拟合走到泛化:AI 正则化笔记

我这次就是 L2 正则化 + Dropout + 数据增强一起上的。

问题是怎么出现的

前几个月在做一个图像分类任务,数据量不算大,大概 5 万张图片,分成 10 个类别。一开始的想法很直接:搭个深层网络,多加几个卷积层,训练的时候加大学习率,应该很快就能收敛。

实际跑下来,训练集上的准确率确实很快就到了 98% 以上,但验证集的曲线完全不是那么回事。准确率卡在 70% 左右晃悠,有时候还往下掉。训练损失一路走低,验证损失却开始往上走——这是最典型的过拟合信号。

这种现象背后有几个原因。网络参数太多,而数据量又不够充分,模型有足够的容量记住所有训练样本。数据本身可能存在噪声或者标注不准确,但模型强行拟合了这些错误。还有可能是数据分布不够均衡,某些类别的样本很少,模型就记住了这些少样本的特征,而不是学到了真正的类别特征。

下面这张图可以帮你看清楚过拟合和正常学习的区别。左侧是典型的过拟合模式,训练损失持续下降但验证损失反而开始上升;右侧是加上正则化后的正常学习模式,两条曲线基本同步下降。

Overfitting vs Normal Learning Pattern

从图中能看出,过拟合的关键特征就是训练集表现和验证集表现开始出现明显分歧。一旦发现这种趋势,就该考虑上正则化手段了。

先搞清楚正则化到底是什么

说人话的话,正则化就是在训练过程中给模型加一点"约束",让它不要走得太偏。就像考试时如果只背答案,考场上遇到新题就傻眼;但如果学会了解题思路,就算题目变一点也能应付。

最常用的正则化方法有几种:

  • L1/L2 正则化:在损失函数里加一项惩罚项,让模型的权重保持比较小的值。L2 会把所有权重都往 0 压,L1 则会让很多权重真正变成 0,起到特征选择的作用。

  • Dropout:训练时随机"扔掉"一部分神经元,让网络不能过度依赖某几个特定的神经元。每次训练都相当于在训练一个"残缺"的子网络,最后 ensemble 起来就变得更稳健。

  • 数据增强:通过旋转、翻转、缩放、颜色抖动等手段,从现有数据中造出"伪新样本"。这相当于增加了训练数据的数量和多样性,让模型学到的是"不变特征"而不是样本的具体像素。

  • 早停:训练时盯着验证集的指标,一旦发现验证损失不再下降就停止训练。这其实也是一种正则化思想,不让模型在训练集上走得太远。

这些方法不是互相排斥的,实际项目中经常会组合使用。我这次就是 L2 正则化 + Dropout + 数据增强一起上的。

实际怎么做

先搭一个基础网络,用 ResNet-50 作为 backbone,改掉最后的全连接层来适配我的 10 分类任务。

import torch
import torch.nn as nn
from torchvision import models

class ImageClassifier(nn.Module):
    def __init__(self, num_classes=10, dropout_rate=0.5):
        super().__init__()
        # 用预训练的 ResNet-50
        self.backbone = models.resnet50(pretrained=True)
        # 冻结前面的层,只训练最后的分类头
        for param in self.backbone.parameters():
            param.requires_grad = False

        # 替换最后的全连接层
        num_features = self.backbone.fc.in_features
        self.backbone.fc = nn.Sequential(
            nn.Linear(num_features, 512),
            nn.ReLU(),
            nn.Dropout(dropout_rate),  # Dropout
            nn.Linear(512, num_classes)
        )

    def forward(self, x):
        return self.backbone(x)

训练的时候在损失函数里加上 L2 正则化,PyTorch 里这个参数叫 weight_decay

# 优化器配置,weight_decay 就是 L2 正则化系数
optimizer = torch.optim.AdamW(
    model.parameters(),
    lr=1e-4,
    weight_decay=1e-4  # L2 正则化
)

# 损失函数
criterion = nn.CrossEntropyLoss()

数据增强这块,我主要用了翻转、旋转、颜色抖动和随机裁剪。这些变换虽然会改变图像的外观,但不会改变图像的语义类别——这就是为什么数据增强有效的原因。

下面这张图展示了不同数据增强技术的效果,从基础变换到组合变换:

Data Augmentation Techniques Demonstration

数据增强的关键在于保持语义不变性。比如一张猫的照片,不管怎么旋转、翻转、调整亮度,它还是一张猫的照片。这样模型学到的就是"猫"这个概念,而不是某一组特定的像素值。

from torchvision import transforms

train_transform = transforms.Compose([
    transforms.RandomResizedCrop(224),  # 随机裁剪和缩放
    transforms.RandomHorizontalFlip(p=0.5),  # 水平翻转
    transforms.RandomRotation(15),  # 旋转 ±15 度
    transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2),  # 颜色抖动
    transforms.ToTensor(),
    transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])
])

val_transform = transforms.Compose([
    transforms.Resize(256),
    transforms.CenterCrop(224),
    transforms.ToTensor(),
    transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])
])

训练循环里要做的就是把这些东西都加上,然后一边训练一边监控指标。

def train_epoch(model, dataloader, criterion, optimizer, device):
    model.train()
    running_loss = 0.0
    correct = 0
    total = 0

    for images, labels in dataloader:
        images, labels = images.to(device), labels.to(device)

        optimizer.zero_grad()
        outputs = model(images)
        loss = criterion(outputs, labels)
        loss.backward()
        optimizer.step()

        running_loss += loss.item()
        _, predicted = outputs.max(1)
        total += labels.size(0)
        correct += predicted.eq(labels).sum().item()

    epoch_loss = running_loss / len(dataloader)
    epoch_acc = 100. * correct / total
    return epoch_loss, epoch_acc

def validate(model, dataloader, criterion, device):
    model.eval()
    running_loss = 0.0
    correct = 0
    total = 0

    with torch.no_grad():
        for images, labels in dataloader:
            images, labels = images.to(device), labels.to(device)
            outputs = model(images)
            loss = criterion(outputs, labels)

            running_loss += loss.item()
            _, predicted = outputs.max(1)
            total += labels.size(0)
            correct += predicted.eq(labels).sum().item()

    epoch_loss = running_loss / len(dataloader)
    epoch_acc = 100. * correct / total
    return epoch_loss, epoch_acc

踩坑过程与参数调整

一开始我把 weight_decay 设置成了 1e-2,结果模型根本学不动,训练集上的准确率一直卡在 10% 左右——这比随机猜测还差。后来改成 1e-4,情况就正常了。正则化系数太大会让模型过度受限,该学的东西学不到。

Dropout 的比例也是个坑。一开始我用了 0.7,结果模型又学不动了,验证集上的表现比训练集还差,说明 Dropout 太激进,把有用的信息也扔掉了。调到 0.3 之后,泛化能力明显提升,但也没影响模型的学习能力。

数据增强这块也得小心。旋转角度设置成 30 度的时候,有些类别的图像(比如文字、方向性强的图案)被转得太厉害,反而不合理了。最后改成 15 度,既能增加多样性,又不会破坏语义。

还有一个问题是早停的判断。不能只看一个 epoch 的指标下降就认为还在提升,我设了一个 patience=5,就是连续 5 个 epoch 验证损失都不创新低才停止。这样可以避免偶尔的波动导致提前结束训练。

最后用的参数组合是:

  • weight_decay: 1e-4
  • dropout_rate: 0.3(分类层)
  • learning_rate: 1e-4
  • 数据增强:翻转、±15 度旋转、轻微颜色抖动

结果怎么样

加上正则化之后,训练集上的准确率没有之前那么高了,大概在 92% 左右,但验证集的准确率从 70% 提升到了 85%。这说明模型没有再死记硬背训练样本,而是学到了真正的类别特征。

最明显的变化是损失曲线:训练损失和验证损失基本同步下降,不再出现验证损失往上走的情况。模型的泛化能力确实变强了,在测试集上的表现也更稳定。

这个结果其实很有意思:牺牲一点训练集上的"完美表现",换来了更好的泛化能力。在实际应用中,我们更看重的是模型在新数据上的表现,而不是它在训练集上能多"完美"。

还有一些边界情况需要说明

正则化不是万能的。如果你的数据量实在太少,比如几百张图片要分 10 个类别,就算正则化手段都上齐了,效果可能还是很有限。这种情况下,考虑用迁移学习或者收集更多数据才是正解。

还有就是不同任务对正则化的敏感度不一样。文本分类任务可能更依赖 Dropout,图像任务可能更依赖数据增强。具体用哪种、用多少,还是要根据实际效果来调。

模型本身的复杂度也要考虑。如果一个简单的任务用了一个超大的模型,正则化可能也救不回来。这时候不如直接用一个小一点的模型,反而效果更好。

写在后面

这次折腾下来,我对正则化的理解比以前深了一些。它不是为了"提升性能",而是为了让模型学会该学的东西,而不是记住不该记的东西。训练集上的高准确率不等于好模型,真正重要的是模型在新数据上的表现。

正则化有点像生活中的"克制"。知道什么该做,更重要的是知道什么不该做。模型训练也是一样,知道如何拟合数据很重要,但知道何时停止、何时约束自己更重要。

最后说一句,这些方法都是经验和技巧,不是绝对真理。实际项目中还是要多试验、多对比,找到最适合当前任务的那套参数组合。技术就是这样,没有银弹,只有适合和不适合。

版权声明: 本文首发于 指尖魔法屋-从过拟合走到泛化:AI 正则化笔记https://blog.thinkmoon.cn/post/337-ai-regularization-overfitting-generalization-practice/) 转载或引用必须申明原指尖魔法屋来源及源地址!