
简介一套面向计算机相关专业学生及初学者的持续学习图像分类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 各类别的权重推向了错误的方向。而且图像分类模型的特征提取层是共享的低层卷积核一改高层分类器拿到的特征分布就全变了。简单说新任务的学习没有义务保护旧任务的决策面除非你在算法层面人为地加约束。围绕这个问题持续学习形成了三条主流技术路线课程大作业里最常见的也是这三条正则化方法在损失函数里加一项约束让参数更新时不要偏离旧任务学到的状态太远。代表算法是 EWCElastic Weight Consolidation和 LwFLearning 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, trainTrue, task_id0, classes_per_task2, transformNone, downloadFalse): # 先加载完整 CIFAR-10 full datasets.CIFAR10(rootroot, traintrain, downloaddownload) # 计算当前任务包含的类别区间例如 task_id0 对应类别 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, padding4), 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, padding4)表示先向外填充 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_classes2): # 加载预训练? 大作业不建议持续学习实验要从随机初始化开始 model models.resnet18(weightsNone, num_classesnum_classes) return model逻辑说明weightsNone表示不使用 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, dim1) # 对每个样本计算交叉熵损失, 但梯度是对正确类别的梯度? # 注意: EWC 的标准实现是对每个类别的概率求梯度平方而不是只对正确类别 for i in range(images.size(0)): for c in range(probs.size(1)): loss F.nll_loss(torch.log(probs[i:i1]), torch.tensor([c]).to(device)) model.zero_grad() loss.backward(retain_graphTrue) 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_graphTrue是必须的因为一次样本要背多次反向传播计算图不能提前释放。这段代码效率不高但大作业数据集很小跑起来完全没问题而且胜在直观——答辩时你能说清楚每一行在做什么。步骤代码行为常见错误1遍历旧任务训练集误用测试集导致 Fisher 高估模型未见过样本的重要性2对每个类别求梯度平方只对正确类别求梯度Fisher 退化为普通梯度范数3累加后取平均忘记除以样本数日志里显示正则项疯涨参数说明fisher字典的 key 是参数名value 是和参数同形状的张量。后面 EWC 正则项要用的就是它。retain_graphTrue会让显存占用上升如果显卡显存小可以改成「每个样本只采样一个类别」的近似——只对probs.argmax(dim1)对应的类别做一次反向传播这样代码快很多但估计方差会大一些。4.3 训练循环EWC 损失与多任务顺序学习有了 Fisher 矩阵训练循环就变得直接了。训练任务 2 的时候除了正常的分类损失还要加一项「EWC 正则」对每个参数计算当前参数与旧任务最优参数的差乘上 Fisher 重要性权重求和。这个正则项的目标是让参数在更新时「避开」对旧任务重要的方向。def train_task(model, train_loader, optimizer, device, ewc_lambda500, fisherNone, old_paramsNone): 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_lambda500在 CIFAR-10 分 5 个任务的设定下通常是一个不错的起点。设得太小比如 10模型基本不管旧任务效果接近直接微调设得太大比如 5000新任务学不进去准确率上不来。推荐做法是网格搜索 [50, 200, 500, 1000, 2000]每个值跑一遍完整实验找出「遗忘率最低且平均准确率还过得去」的值。这个五组对比实验本身就是报告里的加分项。4.4 完整训练流程任务循环与提前停止把所有模块串起来的顶层逻辑是一个双层循环外层遍历 5 个任务内层在每任务上训练多个 epoch。每训练完一个任务在已见过的所有任务测试集上做一次评测记录准确率和遗忘率。这个流程就是持续学习实验的标准范式。def run_experiment(model, tasks_config, device, epochs_per_task20, ewc_lambda500, use_ewcTrue): 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, trainTrue) test_loader make_loader(task_id, trainFalse) optimizer torch.optim.SGD(model.parameters(), lr0.01, momentum0.9, weight_decay1e-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_lambdaewc_lambda, fishermemory[fisher], old_paramsmemory[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_task20在 CIFAR-10 上基本够用再多会明显拉长训练时间而收益有限。lr0.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(seed42): 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_decay1e-4别省略它能抑制参数增长第三在训练循环里加一个梯度裁剪防止单步更新过大。optimizer torch.optim.SGD(model.parameters(), lr0.01, momentum0.9, weight_decay1e-4) # 在 loss.backward() 之后、optimizer.step() 之前加 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm20.0)参数说明max_norm20.0是经验值对 ResNet-18 在 CIFAR-10 上足够宽松不会干扰正常训练又能阻止极端梯度。如果设 5.0 可能让训练收敛变慢因为正常情况下的梯度范数通常在几十这个量级。5.4 评测时给了 Task IDClass-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, trainFalse) 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(dim1) 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 是数据科学里最便宜的后悔药。这个习惯我从第一次跑持续学习实验一直用到现在希望帮到你。本文还有配套的精品资源点击获取