简介:一套面向计算机相关专业学生及初学者的持续学习图像分类Python项目,可直接用于机器学习课程大作业、毕业设计或初期项目立项。项目基于CIFAR100数据集,通过--dataset、--start、--increment、--rehearsal等命令行参数灵活配置初始任务类别数、每轮增量类别数及经验重放样本数,并集成herding、random、均值最近样本选择、类别平衡微调、余弦归一化、less-forget损失和margin ranking损失等多种防遗忘策略,能直观展示如何缓解灾难性遗忘并对比不同策略效果。压缩包共86个文件,含32个.py核心源码、48个.pyc预编译缓存、4个.txt与2个.md说明文档,整体仅115KB;核心代码覆盖训练入口、验证流程、增量ResNet与余弦分类器定义,以及损失函数等模块,目录结构清晰,便于逐模块对照学习,项目说明文档也对实验配置与参数含义做了必要说明。已有111人学习下载,适合有一定Python和深度学习基础、希望深入理解持续学习实现细节并做二次开发的读者。
1. 持续学习图像分类大作业:这门课最能拿得出手的方向
如果你正在做机器学习大作业,又不想交一个「加载 MNIST → 训练 CNN → 画准确率曲线」的模板,持续学习(Continual Learning)是一个性价比极高的选题:它既有明确的问题设定,又有能讲清楚的数学模型,还能在答辩时回答「你的工作和普通图像分类有什么区别」这类追问。这个标题里的zip包,本质上交付的就是三样东西:能跑通的图像分类源码、把「连续学多个任务而不忘记旧任务」这条主线串起来的项目说明,以及一套可复现的实验记录。适合机器学习期末或课程设计阶段,想用一份代码同时覆盖「算法理解 + 工程实现 + 实验分析」三类评分点的学生。
2. 持续学习与灾难性遗忘:图像分类为什么需要它
2.1 从「一次学完」到「分任务学」:三个关键设定
普通的图像分类是拿到一份完整数据集,划分训练集和测试集,训一个模型去拟合所有类别。持续学习面对的场景完全不同:训练数据按任务(Task)分批次到达,模型在学任务 2 的时候拿不到任务 1 的原始数据,但最终评测时要对任务 1 和任务 2 的测试集都有良好表现。这个设定更贴近真实世界的部署场景——摄像头不断采集新场景、电商不断上架新类目,模型不能每次上新都从零训练。
做这个大作业之前,先要确认一个基本问题:你要做的是 Task-Incremental、Domain-Incremental 还是 Class-Incremental?三个设定的区别直接决定代码里怎么设计数据加载和评测函数。
| 设定 | 任务边界是否已知 | 推理时是否提供 Task ID | 难度 |
|---|---|---|---|
| Task-Incremental | 已知 | 提供(或可以从输入推断) | 低 |
| Domain-Incremental | 已知 | 不提供,但类别集合不变 | 中 |
| Class-Incremental | 已知 | 不提供,且类别集合逐任务新增 | 高 |
对大作业来说,我一般建议选 Class-Incremental,或者 Task-Incremental 作为「保底方案」。因为 Class-Incremental 的评测指标(平均准确率和遗忘率)最能说明持续学习的价值,也最容易在报告里画出有说服力的曲线。如果只做 Task-Incremental,会有人质疑「你只是给每个任务配了一个输出头」,区分度不够。部分课程评分标准里也明确写了要看「新旧任务的权衡」,这正是 Class-Incremental 的核心。
2.2 为什么「直接微调」会翻车:灾难性遗忘的机理
你可能会想:按顺序把任务 1、任务 2 的数据依次喂给同一个模型,不就能持续学习了吗?实验会告诉你,这种做法几乎必然翻车——模型学完任务 2 后,任务 1 的准确率从 85% 掉到 30% 以下。这就是持续学习领域最核心的问题:灾难性遗忘(Catastrophic Forgetting)。
原因要从梯度下降的机制说起。神经网络的参数在训练任务 2 时,梯度只指向「让任务 2 的损失下降」的方向。对于任务 1 的分类决策边界,这些参数更新可能是有害的——它把原本区分任务 1 各类别的权重推向了错误的方向。而且图像分类模型的特征提取层是共享的,低层卷积核一改,高层分类器拿到的特征分布就全变了。简单说:新任务的学习没有义务保护旧任务的决策面,除非你在算法层面人为地加约束。
围绕这个问题,持续学习形成了三条主流技术路线,课程大作业里最常见的也是这三条:
- 正则化方法:在损失函数里加一项约束,让参数更新时不要偏离旧任务学到的状态太远。代表算法是 EWC(Elastic Weight Consolidation)和 LwF(Learning without Forgetting)。优点是实现简单、不需要存旧数据;缺点是任务多了以后约束会互相打架。
- 回放方法:显式保存一部分旧任务样本,训练新任务时混入这些样本一起训练。代表算法是经验回放(Experience Replay)和 GEM。优点是最直观、效果通常最好;缺点是要占存储,而且数据集划分的代码会稍微复杂一点。
- 参数隔离方法:给每个任务分配独立的参数子集,新任务不去动旧任务的参数。代表是 Progressive Neural Networks。效果上限高,但模型体积随任务数线性膨胀,大作业里很少用。
作为一个完整的课程项目,我建议的路线是:以 EWC 为主算法,用「直接微调」和「联合训练」做两个 baseline,再叠加一个经验回放做对照实验。这样既覆盖了两类主流方法,又能在报告里画出三组对比曲线。这个方案在周志华的《机器学习》西瓜书里虽然没有直接写持续学习这一章,但「模型在增量数据上的稳定性与可塑性平衡」这个思路和书中关于偏差-方差、集成学习的讨论是相通的,答辩时能引出来。
2.3 选 PyTorch 而不是 TensorFlow:大作业视角的对比
这个标题明确写了 Python 源码,那么框架选什么?我一般推荐 PyTorch。不是因为 TensorFlow 不好,而是持续学习这个方向的论文代码绝大多数是 PyTorch 写的,你遇到问题时能搜到的参考实现最多。另一个实际原因是:持续学习要做很多「hook 式的操作」,比如在反向传播后拿到每个参数的梯度、在训练中途冻结某些层,PyTorch 的动态图和torch.nn的模块化设计让这些操作直观很多。
如果你是第一次在本地配环境,vscode 里装好 Python 插件后,用 pip 安装 torch 即可。CPU 版本跑 CIFAR-10 分 5 个任务、每任务 2 个类的小实验完全够用,只是每个任务大概要多等几分钟。如果机器有 NVIDIA 显卡,记得装对应 CUDA 版本的 torch,训练速度能快一个数量级。
# 建议用虚拟环境管理依赖,避免污染全局 Python python -m venv cl_env source cl_env/bin/activate # Windows 用 cl_env\Scripts\activate pip install torch torchvision pip install numpy matplotlib scikit-learn参数说明:venv创建独立环境,source激活;torchvision用来加载 CIFAR-10 和做标准数据增强;scikit-learn用来算混淆矩阵和各类分类指标。不要用全局 Python 直接装,机器学习项目里依赖版本冲突太常见了,虚拟环境是成本最低的后悔药。
3. 把 CIFAR-10 拆成 5 个任务:数据划分与四个关键参数
3.1 为什么用 CIFAR-10 而不是手写数字
课程大作业里有一个隐性要求:数据规模要「说明不了问题但跑得动」。MNIST 虽然快,但分类任务太简单,持续学习的效果差异会被压缩得很小——直接微调可能只掉几个点,灾难性遗忘的直观冲击力不够。CIFAR-10 是更合适的选择:32×32 的彩色图像,10 个类别,单机 CPU 训练也撑得住,但难度足以让「直接微调」的准确率明显下滑。
另一个原因和「最新的图像分类模型」这个热词有关。CIFAR-10 上你可以选用 ResNet-18 这种经典结构,也可以换成 Vision Transformer 的小型变体。但大作业我不建议一上来就用 ViT——持续学习的核心是「算法层面的遗忘抑制」,而不是把特征提取器做得最强。用 ResNet-18 做骨干网络,训练速度快、显存占用低,而且在持续学习论文里是最常用的 backbone,参考实现最多,踩坑时能搜到答案。
3.2 任务划分 Dataset 类:代码与参数
持续学习的核心数据结构是「任务列表」。CIFAR-10 有 10 个类,常见划分策略是拆成 5 个任务,每个任务引入 2 个新类别;也可以拆成 10 个任务、每任务 1 个类,但那样训练轮次会很多,大作业时间上不划算。下面这个 Dataset 类把「按任务切分数据」的逻辑封装起来,是整套源码的地基。
import torch from torch.utils.data import Dataset from torchvision import datasets, transforms class ContinualCIFAR10(Dataset): """按任务切分的 CIFAR-10:每个任务只暴露部分类别""" def __init__(self, root, train=True, task_id=0, classes_per_task=2, transform=None, download=False): # 先加载完整 CIFAR-10 full = datasets.CIFAR10(root=root, train=train, download=download) # 计算当前任务包含的类别区间,例如 task_id=0 对应类别 0、1 start = task_id * classes_per_task end = (task_id + 1) * classes_per_task # 筛选属于当前任务的样本索引 indices = [i for i, label in enumerate(full.targets) if start <= label < end] self.data = full.data[indices] # 把类别标签重映射到 0~classes_per_task-1,方便模型输出 self.targets = [full.targets[i] - start for i in indices] self.transform = transform def __len__(self): return len(self.data) def __getitem__(self, idx): img, label = self.data[idx], self.targets[idx] img = self.transform(img) return img, label逻辑说明:这个类不做任何持续学习算法层面的事,它只负责回答一个问题——「第几个任务应该看到哪些类别」。full.data[indices]从原始 CIFAR-10 里筛出当前任务涉及的图片,full.targets[i] - start把全局标签(比如 5、6)重映射到局部标签(0、1),这样模型每个任务的输出维度就固定为classes_per_task而不是10。如果这里不做重映射,模型输出层维度会随任务变化,代码会变得非常难调。
参数说明:classes_per_task是全局最重要的数据参数,设 2 则 5 个任务学完 10 类;设 5 则只有 2 个任务,持续学习的过程不够明显。task_id决定了当前数据集暴露哪些类别,在训练循环里由外层代码按顺序传入。
3.3 数据增强与正态分布:容易被忽视的实验公平性
持续学习实验里有个隐藏的坑:数据增强如果没处理好,会直接影响「旧任务的遗忘曲线」到底是算法起作用还是数据增强在起作用。我一般建议训练集做随机裁剪和水平翻转,但测试集必须只做标准化——这是图像分类的常识,但在持续学习里如果某个任务的数据增强配置写错了,对比实验就失去了公平性。
# 训练集增强:随机裁剪 + 水平翻转 train_transform = transforms.Compose([ transforms.RandomCrop(32, padding=4), transforms.RandomHorizontalFlip(), transforms.ToTensor(), transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2023, 0.1994, 0.2010)), ]) # 测试集不变换:只转 Tensor + 标准化 test_transform = transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2023, 0.1994, 0.2010)), ])参数说明:Normalize的均值和标准差必须用 CIFAR-10 数据集的全局统计量,也就是上面写死的这四个数组。如果你没有用官方统计量,而是拍脑袋填了(0.5, 0.5, 0.5), (0.5, 0.5, 0.5),训练仍然能收敛,但迁移到别的数据集时会出问题,而且论文复现时数值对不上。RandomCrop(32, padding=4)表示先向外填充 4 像素再随机裁剪回 32×32,这个设置在 CIFAR 系列上是标配,不建议调大,padding 太大等于引入了和原图分布不一致的黑色边框。
还有一个容易被忽视的点:torch 的default_collate会把标签拼成 tensor,但连续学习里每个 task 的classes_per_task不同,模型输出层的维度必须和标签最大值对齐,否则训练时报维度不匹配。这是新手最常见的报错之一,后面避坑章节会展开说。
4. 用 PyTorch 实现 EWC 与 LwF:核心代码与可调参数
4.1 骨干网络:ResNet-18 的简化版本
持续学习大作业里的模型结构不需要自己发明,用 torchvision 里现成的resnet18改最后一层即可。但有一个细节:持续学习里经常要拿到「每个参数的梯度大小」来算 Fisher 信息矩阵,所以代码里要把模型拆成features和fc两部分,方便后续按参数名过滤。
import torch.nn as nn from torchvision import models def build_model(num_classes=2): # 加载预训练? 大作业不建议,持续学习实验要从随机初始化开始 model = models.resnet18(weights=None, num_classes=num_classes) return model逻辑说明:weights=None表示不使用 ImageNet 预训练权重。持续学习论文的标准实验设定都是从随机初始化开始,因为如果用了预训练权重,模型初始特征就已经很好了,灾难性遗忘现象会被弱化,你的 EWC 实现到底有没有效果就说不清了。num_classes在 Task-Incremental 设定下等于classes_per_task,在 Class-Incremental 全任务统一输出头的设定下等于总类别数 10。
一个更贴近大作业实际的做法:用torchvision.models.resnet18但把fc层替换成自己定义的单层全连接,这样报告里能写「采用 ResNet-18 作为特征提取器,全连接层输出维度随任务设定调整」。答辩时老师通常不会深究结构是否足够新,反而会追问「为什么不用更深网络」——答案是为了在 CPU 上跑得动,同时持续学习的核心是算法不是网络宽度。
4.2 EWC 的 Fisher 信息矩阵计算:完整可运行实现
EWC 的核心思想是:训练完一个任务后,用最优参数附近的二阶信息(Fisher 信息矩阵的对角线)来度量「每个参数对旧任务的重要性」,然后在新任务的损失函数里加一个正则项,惩罚那些「对旧任务重要」的参数发生大幅漂移。
实现上分三步:第一步,在旧任务的最优模型上计算 Fisher 信息矩阵;第二步,把 Fisher 值存下来;第三步,在训练新任务时把 EWC 正则项加进损失函数。下面这段代码是一次完整实现。
import torch from torch.nn import functional as F def compute_fisher(model, dataloader, device): """ 在旧任务的训练集上计算 Fisher 信息矩阵的对角线。 Fisher 的估计方式: 对每个样本,取预测概率分布的平方作为重要性权重。 """ model.eval() fisher = {name: torch.zeros_like(param) for name, param in model.named_parameters()} count = 0 for images, labels in dataloader: images, labels = images.to(device), labels.to(device) logits = model(images) # 用模型预测的 softmax 概率作为“伪标签分布” probs = F.softmax(logits, dim=1) # 对每个样本,计算交叉熵损失, 但梯度是对正确类别的梯度? # 注意: EWC 的标准实现是对每个类别的概率求梯度平方,而不是只对正确类别 for i in range(images.size(0)): for c in range(probs.size(1)): loss = F.nll_loss(torch.log(probs[i:i+1]), torch.tensor([c]).to(device)) model.zero_grad() loss.backward(retain_graph=True) for name, param in model.named_parameters(): if param.grad is not None: fisher[name] += probs[i, c].item() * param.grad.pow(2) count += 1 # 取平均 for name in fisher: fisher[name] /= max(count, 1) return fisher逻辑说明:这里用F.nll_loss(torch.log(probs), c)构造了一个「对第 c 类求交叉熵」的临时损失,然后对每个类别都做一次反向传播,把梯度的平方乘以该类的概率后累加。这就是 Fisher 对角线的蒙特卡洛估计。retain_graph=True是必须的,因为一次样本要背多次反向传播,计算图不能提前释放。这段代码效率不高,但大作业数据集很小,跑起来完全没问题,而且胜在直观——答辩时你能说清楚每一行在做什么。
| 步骤 | 代码行为 | 常见错误 |
|---|---|---|
| 1 | 遍历旧任务训练集 | 误用测试集,导致 Fisher 高估模型未见过样本的重要性 |
| 2 | 对每个类别求梯度平方 | 只对正确类别求梯度,Fisher 退化为普通梯度范数 |
| 3 | 累加后取平均 | 忘记除以样本数,日志里显示正则项疯涨 |
参数说明:fisher字典的 key 是参数名,value 是和参数同形状的张量。后面 EWC 正则项要用的就是它。retain_graph=True会让显存占用上升,如果显卡显存小,可以改成「每个样本只采样一个类别」的近似——只对probs.argmax(dim=1)对应的类别做一次反向传播,这样代码快很多,但估计方差会大一些。
4.3 训练循环:EWC 损失与多任务顺序学习
有了 Fisher 矩阵,训练循环就变得直接了。训练任务 2 的时候,除了正常的分类损失,还要加一项「EWC 正则」:对每个参数,计算当前参数与旧任务最优参数的差,乘上 Fisher 重要性权重,求和。这个正则项的目标是让参数在更新时「避开」对旧任务重要的方向。
def train_task(model, train_loader, optimizer, device, ewc_lambda=500, fisher=None, old_params=None): model.train() total_loss = 0.0 for images, labels in train_loader: images, labels = images.to(device), labels.to(device) optimizer.zero_grad() logits = model(images) ce_loss = F.cross_entropy(logits, labels) # EWC 正则项: 只在有旧任务记忆时生效 ewc_loss = 0.0 if fisher is not None and old_params is not None: for name, param in model.named_parameters(): if name in fisher and param.grad is not None: ewc_loss += (fisher[name] * (param - old_params[name]).pow(2)).sum() loss = ce_loss + ewc_lambda * ewc_loss loss.backward() optimizer.step() total_loss += loss.item() return total_loss / len(train_loader)逻辑说明:fisher和old_params来自上一个任务训练结束后的模型状态。old_params要在训练新任务之前就深拷贝一份——如果你在训练过程中才去复制,旧参数已经被更新了,正则项就失效了。ewc_lambda是 EWC 里最重要的超参数,它控制「对旧任务的保护力度」和「新任务的学习能力」之间的平衡。
参数说明:ewc_lambda=500在 CIFAR-10 分 5 个任务的设定下通常是一个不错的起点。设得太小(比如 10),模型基本不管旧任务,效果接近直接微调;设得太大(比如 5000),新任务学不进去,准确率上不来。推荐做法是网格搜索 [50, 200, 500, 1000, 2000],每个值跑一遍完整实验,找出「遗忘率最低且平均准确率还过得去」的值。这个五组对比实验本身就是报告里的加分项。
4.4 完整训练流程:任务循环与提前停止
把所有模块串起来的顶层逻辑是一个双层循环:外层遍历 5 个任务,内层在每任务上训练多个 epoch。每训练完一个任务,在已见过的所有任务测试集上做一次评测,记录准确率和遗忘率。这个流程就是持续学习实验的标准范式。
def run_experiment(model, tasks_config, device, epochs_per_task=20, ewc_lambda=500, use_ewc=True): memory = {"fisher": None, "old_params": None, "seen_tasks": []} records = [] # 记录每个任务结束后在所有已见任务上的准确率 for task_id in range(len(tasks_config)): print(f"=== Training on Task {task_id + 1}/{len(tasks_config)} ===") # 训练当前任务 train_loader = make_loader(task_id, train=True) test_loader = make_loader(task_id, train=False) optimizer = torch.optim.SGD(model.parameters(), lr=0.01, momentum=0.9, weight_decay=1e-4) # 如果是任务 0,没有 Fisher,退化为普通训练 if use_ewc and memory["fisher"] is not None: for epoch in range(epochs_per_task): train_task(model, train_loader, optimizer, device, ewc_lambda=ewc_lambda, fisher=memory["fisher"], old_params=memory["old_params"]) else: for epoch in range(epochs_per_task): train_task(model, train_loader, optimizer, device) # 训练结束后,记录旧参数、计算 Fisher,供下一个任务使用 memory["old_params"] = {k: v.detach().clone() for k, v in model.named_parameters()} if use_ewc: memory["fisher"] = compute_fisher(model, train_loader, device) # 评测所有已见任务 seen_acc = evaluate_all_seen(model, task_id, device) records.append(seen_acc) return records逻辑说明:注意一个关键顺序——先深拷贝旧参数,再算 Fisher,两个动作都在「新任务训练完成后」执行,但它们服务的是「下一个任务」。也就是说,任务 0 训练完,存下old_params_0和fisher_0,训练任务 1 时使用;任务 1 训练完,再存old_params_1和fisher_1。Fisher 永远基于「刚训练完的那个任务」的模型状态计算,而不是基于任务开始前的状态,这一点初学者经常搞反。
参数说明:epochs_per_task=20在 CIFAR-10 上基本够用,再多会明显拉长训练时间而收益有限。lr=0.01配合 SGD 是 ResNet-18 在 CIFAR-10 上的标准配置,建议不要直接换成 Adam——EWC 的理论推导是在 SGD 的更新规则下做的,换优化器后正则项的行为会变。
5. 持续学习大作业避坑:5 个能让结果翻车的细节
5.1 随机种子没固定:每次运行结果都对不上
现象:同一份代码,连着跑两次,最后画出来的平均准确率曲线差 5 到 10 个百分点。你很难判断是 EWC 起作用了还是单纯运气好。
原因:PyTorch 的数据加载是多进程的,每个 DataLoader worker 的随机状态是独立的;CUDA 的卷积实现本身也有随机性。如果不固定种子,实验等于在随机噪声里游泳。
解决:在run_experiment的开头设置三重种子。
def set_seed(seed=42): torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) import random random.seed(seed) import numpy as np np.random.seed(seed) torch.backends.cudnn.deterministic = True torch.backends.cudnn.benchmark = False参数说明:cudnn.deterministic = True会让卷积使用确定性算法,代价是速度略慢;cudnn.benchmark = False禁止 cuDNN 在运行时自动搜索最优卷积算法。这两个开关缺一个,torch.manual_seed都镇不住 GPU 端的随机性。跑正式实验时把seed设成多个值(如 3 个种子)取平均,报告里写「mean ± std」,可信度立刻提升一个档次。
5.2 Fisher 用测试集计算:正则项保护了不存在的信息
现象:EWC 的效果看起来特别好,遗忘率几乎为零,但参数调大ewc_lambda后新任务完全学不进去。
原因:Fisher 信息矩阵应该在模型「见过的数据」上计算——也就是训练集。如果你图省事,在评测循环里顺手对测试集算了 Fisher,等于泄露了测试集信息,模型在真实部署场景下不可能拿到未来任务的测试样本。
解决:Fisher 只基于当前任务训练集计算。上面的compute_fisher(model, train_loader, device)已经写对了——注意是训练好的模型在训练集上算,不是训练过程中算,也不是测试集上算。如果不小心用了测试集,EWC 的正则项会过度保护参数,导致新任务欠拟合,看起来遗忘少但这是一种虚假的「好」。
5.3 EWC 正则项算出来是 NaN:梯度爆炸的连锁反应
现象:训练到第二个任务,loss 变成 nan,模型参数全部变成 nan,只能从头重来。
原因:EWC 正则项对差值param - old_params做平方,如果当前参数已经是一个很大的数(比如 loss 爆炸导致的),平方会进一步放大,loss 直接溢出。
解决:三管齐下。第一,输入标准化确认用的是 CIFAR-10 官方均值方差,而不是(0.5, 0.5, 0.5)——后者会让首层梯度变大;第二,SGD 的weight_decay=1e-4别省略,它能抑制参数增长;第三,在训练循环里加一个梯度裁剪,防止单步更新过大。
optimizer = torch.optim.SGD(model.parameters(), lr=0.01, momentum=0.9, weight_decay=1e-4) # 在 loss.backward() 之后、optimizer.step() 之前加 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=20.0)参数说明:max_norm=20.0是经验值,对 ResNet-18 在 CIFAR-10 上足够宽松,不会干扰正常训练,又能阻止极端梯度。如果设 5.0 可能让训练收敛变慢,因为正常情况下的梯度范数通常在几十这个量级。
5.4 评测时给了 Task ID:Class-Incremental 结果虚高
现象:报告里平均准确率 92%,看起来很漂亮,但答辩时老师问「你测试的时候是不是告诉模型当前是第几个任务了」,你答不上来。
原因:如果你的模型每个任务只输出classes_per_task个类别,推理时必须知道「当前输入属于哪个任务」才能把输出映射到全局类别。这就是 Task-Incremental 设定,泄露了任务边界信息。如果老师期望的是 Class-Incremental(模型看到任何一张图都要直接输出 10 类中的一类),你给的 92% 就没有意义。
解决:做两个版本,或者明确在论文里声明自己做的是 Task-Incremental。如果要做 Class-Incremental,模型输出层固定为 10,每个任务训练时只喂对应类别的数据,评测时不提供任务信息。这样难度大很多,但报告的说服力也强很多。如果你的课程评分标准参照了最新的图像分类模型评测方式,大概率是要求后者的。
5.5 对比实验只跑了 EWC:没有 baseline 就没有分析
现象:报告里只有 EWC 一条曲线,老师让你解释「持续学习比普通方法好多少」,你拿不出数据。
原因:持续学习实验必须有对照组。最少需要两组:直接微调(Fine-tuning,每学完一个任务继续在下一个任务上 finetune,不做任何保护)和联合训练(Joint Training,一次性看到所有任务的数据,这是性能上界)。
解决:run_experiment里加一个method参数,fine_tune模式不使用 Fisher 和 old_params,相当于普通训练;joint模式不切任务,直接在所有类别上训练。三组曲线画在同一张图里,数据立刻「活」了。血的教训:很多大作业翻车不是算法实现错了,而是没有对照,老师只看一眼图就知道你有没有真正理解这个问题。
6. 用遗忘率而不是准确率验收:评价指标与可视化
很多课程报告只画一条「每个任务结束后,在所有已见任务上的平均准确率」曲线,但这条曲线有两个问题:一是新任务的准确率会拉高平均值,掩盖旧任务掉点的事实;二是没法区分「模型什么都没学会,但旧任务也没忘」和「模型持续学到了新东西」这两种状态。所以持续学习论文里还有一个标准指标——遗忘率(Forgetting Measure),它度量的是「某个旧任务在学到最新任务后,相比刚学完时的准确率下降了多少」。
定义是:模型在任务 k 刚学完时,在任务 k 测试集上的准确率为acc_{k,k};训练完所有后续任务后,再测任务 k 的准确率为acc_{k,T},遗忘率就是acc_{k,k} - acc_{k,T},对所有旧任务取平均。遗忘率越接近 0,说明模型学新东西时越「厚道」。
下面是一段把这两类指标一起算出来的评估函数,同时生成一个逐任务的准确率矩阵,方便画热力图。
import numpy as np def evaluate_all_seen(model, current_task_id, device, task_configs): """返回所有已见任务各自的准确率列表""" accs = [] for task_id in range(current_task_id + 1): loader = make_loader(task_id, train=False) correct = 0 total = 0 model.eval() with torch.no_grad(): for images, labels in loader: images, labels = images.to(device), labels.to(device) logits = model(images) preds = logits.argmax(dim=1) correct += (preds == labels).sum().item() total += labels.size(0) accs.append(100.0 * correct / total) return accs def compute_forgetting(acc_matrix): """acc_matrix[i][j] 表示训练完任务 j 后,在任务 i 上的准确率""" n_tasks = len(acc_matrix) forgetting = [] for i in range(n_tasks - 1): # 对除最后一个任务外的所有任务 best = max(acc_matrix[i][i:]) # 刚学完时的准确率 last = acc_matrix[i][-1] # 最后的准确率 forgetting.append(best - last) return np.mean(forgetting)逻辑说明:evaluate_all_seen的返回列表里每一项是「当前模型在某一个旧任务上的准确率」,在主循环里把它 append 到acc_matrix,最后得到一个上三角矩阵。compute_forgetting用max而不是直接取acc_matrix[i][i],是因为某些任务训练得不太充分时,后续任务的训练反而会让准确率小幅回升,用最大值为基准更公允——这也和持续学习论文里的常用计算方式一致。
参数说明:acc_matrix的形状是(num_tasks, num_tasks),横轴是「训练到第几个任务」,纵轴是「在哪个任务上评测」,对角线到右上角是上三角。报告里用 matplotlib 画成热力图或者折线图,每一行一条线,就能直观看到「遗忘发生在哪一次任务切换」。
我个人习惯是在训练日志里同时记录三个数:当前任务准确率、已见任务平均准确率、平均遗忘率。实验跑完后,先看遗忘率再决定要不要调ewc_lambda——如果遗忘率超过 10%,优先加大正则强度;如果平均准确率低但遗忘率很低,说明模型学不进去,需要减小正则强度。这套判断逻辑,比单看一条准确率曲线可靠得多。
最后的收尾习惯:每次实验跑完,把配置参数、种子、最终指标写进一个 CSV 文件,哪怕当时觉得没什么用。因为持续学习实验的状态空间很大——任务数、每任务类别数、正则系数、是否回放——你调了两天参之后,很可能忘记「最好的那一版参数到底是多少」。CSV 是数据科学里最便宜的后悔药。这个习惯我从第一次跑持续学习实验一直用到现在,希望帮到你。
本文还有配套的精品资源,点击获取