ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

200类鸟类识别CNN实验复现:AlexNet/ResNet-18微调与避坑

200类鸟类识别CNN实验复现:AlexNet/ResNet-18微调与避坑

简介:这是一份面向深度学习初学者与计算机视觉方向学生的实验报告文档,聚焦卷积神经网络在细粒度鸟类识别任务中的完整实践。报告以AlexNet与ResNet-18两种经典架构为基线,将输出层改为200类,以适配CUB-200-2011数据集,并对比ImageNet预训练权重微调与随机初始化从头训练的性能差异,同时分析学习率、训练步数与衰减策略等超参数对结果的影响。文档还给出训练过程loss曲线与验证集accuracy的可视化解读以及边界框预测结果展示,读者可据此理解细粒度分类中的数据划分、迁移学习与过拟合判断思路,适合作为课程实验或练手项目的参考范本。压缩包共1个文件,为docx格式实验报告,体积约1.36MB,内含网络结构图、数据集示例与结果对比图表,便于直接阅读与引用。目前已有184人学习。

1. 200 类鸟塞进 CNN:一份鸟类识别实验报告能复现到什么程度

把 200 类鸟塞进一个卷积神经网络里,最容易翻车的不是模型不够深,而是数据划分和输出层没对齐。这份鸟类识别实验报告给出的路线很直接:AlexNet 和 ResNet-18 两套 CNN,用 ImageNet 预训练权重初始化,把输出层改成 200 类,在 CUB-200-2011 数据集上做微调,再用 loss、accuracy 曲线和预测框做过程可视化,最后对比预训练与随机初始化、不同学习率和训练步数的差异。它适合想跑通细粒度图像识别基线的人,也适合拿它当课程实验或报告底稿的技术人员;如果你只想找一个能直接跑出高准确率的模型,这份材料不是那种调参大全,它的价值在于把“为什么这样设”和“哪一步容易翻车”讲清楚。

2. 模型与数据先对齐:AlexNet/ResNet-18 改 200 类输出与 CUB-200-2011 划分

2.1 为什么细粒度鸟类识别优先用预训练 CNN 而不是从头训练

CUB-200-2011 只有约 1.18 万张图,200 类,平均每类 60 张左右。这个量级对 ImageNet 预训练模型来说只够做微调,不足以从随机初始化开始喂出一个稳定的 CNN。鸟类识别又属于细粒度图像识别,类别之间的差异常常在喙形、翼纹、羽色分布这些局部区域,浅层卷积学到的边缘和纹理仍然通用,预训练权重能把这些通用特征带进来,训练初期收敛会快很多。

实验报告里选择 AlexNet 和 ResNet-18 作为起点,思路是对的:AlexNet 结构简单,适合观察全连接层替换和微调流程;ResNet-18 有残差连接,训练更深一点时梯度更稳,作为对照能看出骨架差异。两者都把输出层改成 200,这不是可选项,是必须项。ImageNet 原始输出是 1000 类,直接拿来跑 CUB 会得到完全错位的标签空间。

常见做法是把新输出层从头初始化,骨干网络用较小学习率微调,新层用较大学习率。这样做的好处是新层快速适应 200 类鸟类,骨干网络不至于被小数据集带偏。实验报告里提到“对预训练参数使用较小学习率微调”,落地时就要把参数分组,而不是所有参数共用一个学习率。

模型输入尺寸输出层位置输出维度预训练权重微调重点
AlexNet224×224classifier 第 6 层200ImageNet替换最后一层 Linear,骨干小学习率
ResNet-18224×224fc 层200ImageNet替换 fc,BN 统计量注意冻结策略

提示:AlexNet 在 torchvision 里的默认输入是 224×224,不要按原论文的 227 去凑尺寸,预处理和模型结构对不上时,第一层卷积的期望输入会直接报错。

2.2 替换输出层与冻结策略:可运行的 PyTorch 改法

先写模型构建和输出层替换。这里不绑定具体版本号,权重枚举用当前环境可用的 DEFAULT,避免硬编码某个权重字符串。代码里同时处理 AlexNet 和 ResNet-18,返回模型和模型名,后面训练循环复用。

import torch import torch.nn as nn from torchvision import models def build_model(name, num_classes=200, pretrained=True): """构建 AlexNet 或 ResNet-18,并把输出层改成 num_classes。""" if name == "alexnet": weights = models.AlexNet_Weights.DEFAULT if pretrained else None model = models.alexnet(weights=weights) in_features = model.classifier[6].in_features # AlexNet 的分类头在 classifier 的第 6 个模块 model.classifier[6] = nn.Linear(in_features, num_classes) elif name == "resnet18": weights = models.ResNet18_Weights.DEFAULT if pretrained else None model = models.resnet18(weights=weights) in_features = model.fc.in_features # ResNet 系列分类头就叫 fc model.fc = nn.Linear(in_features, num_classes) else: raise ValueError(f"unsupported model: {name}") return model def set_finetune(model, name, freeze_backbone=True): """控制骨干是否冻结,分类头始终可训练。""" for p in model.parameters(): p.requires_grad = not freeze_backbone if name == "alexnet": for p in model.classifier[6].parameters(): p.requires_grad = True else: for p in model.fc.parameters(): p.requires_grad = True return model

逻辑上,build_model先加载预训练骨架,再把分类头换成 200 维;set_finetune决定骨干是否参与反向传播。参数上,num_classes=200对应 CUB 的类别数,pretrained=True表示加载 ImageNet 权重,freeze_backbone=True时只训练新分类头,适合先跑一个快速基线;如果显存和时间允许,更稳的做法是解冻全部参数,用分组学习率微调。

分组学习率要按参数名或模块归属拆开。下面的写法把分类头单独放一组,骨干放另一组,避免新层和预训练层共用同一个 lr。

def build_optimizer(model, name, backbone_lr=1e-4, head_lr=1e-3, momentum=0.9, weight_decay=5e-4): """分类头用 head_lr,骨干用 backbone_lr。""" if name == "alexnet": head_params = list(model.classifier[6].parameters()) else: head_params = list(model.fc.parameters()) head_ids = {id(p) for p in head_params} backbone_params = [ p for p in model.parameters() if p.requires_grad and id(p) not in head_ids ] return torch.optim.SGD( [ {"params": backbone_params, "lr": backbone_lr}, {"params": head_params, "lr": head_lr}, ], momentum=momentum, weight_decay=weight_decay, )

逻辑上,head_ids用id()标记分类头参数,剩下的可训练参数归入骨干组。参数上,backbone_lr=1e-4是微调预训练层的常见起点,head_lr=1e-3让新初始化的 200 类分类头学得更快,momentum=0.9和weight_decay=5e-4是 SGD 微调的常用组合。如果只冻结骨干训练分类头,骨干组会是空列表,这时优化器只更新分类头,属于快速验证流程,不是最终性能方案。

2.3 CUB-200-2011 目录、划分文件与 Dataset 接法

CUB 的原始标注文件里,images.txt是图像 id 到相对路径的映射,image_class_labels.txt是图像 id 到类别标签的映射,train_test_split.txt标记每张图属于官方训练集还是测试集。实验报告里写训练集、验证集、测试集三划分,但官方划分只有 train/test,验证集必须自己从训练集里分层抽出来,并且固定随机种子。否则每次跑出来的 val accuracy 都不可比。

先看一个把官方文件转成三份 txt 的脚本。输出格式是每行“相对路径 类别标签”,标签从 0 到 199。

import random from collections import defaultdict def read_kv(path): d = {} with open(path, "r", encoding="utf-8") as f: for line in f: k, v = line.strip().split(maxsplit=1) d[int(k)] = v return d def make_splits(root, out_dir, val_ratio=0.1, seed=42): images = read_kv(f"{root}/images.txt") labels = read_kv(f"{root}/image_class_labels.txt") is_train = read_kv(f"{root}/train_test_split.txt") by_cls = defaultdict(list) test_rows = [] for img_id, rel in images.items(): y = int(labels[img_id]) - 1 # CUB 标签 1..200 转 0..199 if is_train[img_id] == "1": by_cls[y].append(rel) else: test_rows.append((rel, y)) rng = random.Random(seed) train_rows, val_rows = [], [] for y, rels in by_cls.items(): rels = sorted(rels) rng.shuffle(rels) n_val = max(1, int(len(rels) * val_ratio)) val_rows += [(r, y) for r in rels[:n_val]] train_rows += [(r, y) for r in rels[n_val:]] def dump(name, rows): with open(f"{out_dir}/{name}", "w", encoding="utf-8") as f: for rel, y in rows: f.write(f"{rel} {y}\n") dump("train.txt", train_rows) dump("val.txt", val_rows) dump("test.txt", test_rows) print(len(train_rows), len(val_rows), len(test_rows))

逻辑上,脚本先读三个官方映射文件,把官方训练集按类别分组,再按类别抽 10% 做验证集,官方测试集原样保留。参数上,val_ratio=0.1控制验证集比例,seed=42保证划分可复现,max(1, ...)保证每类至少有一张验证图;如果某类样本太少,验证集过小会让曲线抖动很大,这时可以把val_ratio提到 0.15,但要同步固定划分文件,不能每轮重抽。

接着写 Dataset。它只依赖上面生成的 txt,不猜目录结构,路径拼接清晰。

from torch.utils.data import Dataset from PIL import Image class CUBTextDataset(Dataset): def __init__(self, root, split_file, transform=None): self.root = root self.transform = transform self.samples = [] with open(split_file, "r", encoding="utf-8") as f: for line in f: line = line.strip() if not line: continue rel, label = line.rsplit(" ", 1) self.samples.append((rel, int(label))) def __len__(self): return len(self.samples) def __getitem__(self, idx): rel, label = self.samples[idx] img = Image.open(f"{self.root}/{rel}").convert("RGB") if self.transform is not None: img = self.transform(img) return img, label

逻辑上,__getitem__按相对路径读图并强制转 RGB,避免灰度图或带 alpha 通道的图片在卷积层前维度不一致。参数上,root指向 CUB 解压后的根目录,split_file指向 train/val/test 的 txt,transform由训练或验证阶段传入;标签已经在生成 txt 时转成 0 到 199,Dataset 不再做二次减一,否则会整体错位。

注意:划分文件一旦确定就不要再动。训练集、验证集、测试集之间只要有同一张图泄漏,最后 test accuracy 会虚高,而且这种虚高在混淆矩阵里很难看出来。

3. 训练管线拆解:从 DataLoader 到 loss/accuracy 曲线

3.1 数据增强与输入尺寸:AlexNet 224、ResNet-18 224 的差异

两个模型在 torchvision 里都按 224×224 输入组织预处理,所以训练和验证的 transform 可以共用一套尺寸参数。鸟类细粒度识别里,颜色是判别特征,颜色抖动不要默认开;随机裁剪的下限也别压得太狠,否则鸟的主体被裁掉,模型学到的更多是背景。水平翻转通常安全,但如果你做的是需要方向信息的任务,翻转前先确认标签语义不会被破坏。

from torchvision import transforms train_tf = transforms.Compose([ transforms.RandomResizedCrop(224, scale=(0.7, 1.0)), transforms.RandomHorizontalFlip(p=0.5), transforms.ToTensor(), transforms.Normalize( mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225], ), ]) val_tf = 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], ), ])

逻辑上,训练阶段先随机裁剪到 224 再翻转,验证阶段先缩到 256 再中心裁剪到 224,保证评估时输入分布稳定。参数上,scale=(0.7, 1.0)表示随机裁剪保留原图 70% 到 100% 的面积,下限太低会把鸟裁成局部纹理;p=0.5是水平翻转概率;归一化参数沿用 ImageNet 统计量,因为骨干权重来自 ImageNet,输入分布要跟预训练时一致。

DataLoader 的参数里,batch_size先按显存给,AlexNet 的全连接层比较吃显存,ResNet-18 相对轻。num_workers不要一上来拉满,先看磁盘和 CPU,pin_memory=True在 GPU 训练时能减少拷贝等待。

from torch.utils.data import DataLoader train_ds = CUBTextDataset("data/CUB_200_2011", "splits/train.txt", train_tf) val_ds = CUBTextDataset("data/CUB_200_2011", "splits/val.txt", val_tf) test_ds = CUBTextDataset("data/CUB_200_2011", "splits/test.txt", val_tf) train_loader = DataLoader(train_ds, batch_size=32, shuffle=True, num_workers=4, pin_memory=True) val_loader = DataLoader(val_ds, batch_size=64, shuffle=False, num_workers=4, pin_memory=True) test_loader = DataLoader(test_ds, batch_size=64, shuffle=False, num_workers=4, pin_memory=True)

逻辑上,训练集打乱,验证集和测试集不打乱,保证评估顺序稳定。参数上,batch_size=32是起点,显存不够就减半或开梯度累积;验证和测试用 64 是为了减少前向次数,不影响指标;num_workers=4是常见折中,太大在小数据集上反而增加进程开销。

3.2 分组学习率与训练循环

训练循环要记录每个 epoch 的 train loss、train acc、val loss、val acc,后面画曲线和选最佳 checkpoint 都用它。优化器用第 2 章的分组写法,学习率调度器可以用余弦退火,也可以用 StepLR,关键是别在训练中途手动改 lr 又不记录。

import torch def train_one_epoch(model, loader, criterion, optimizer, device): model.train() total_loss, correct, total = 0.0, 0, 0 for x, y in loader: x, y = x.to(device), y.to(device) optimizer.zero_grad(set_to_none=True) logits = model(x) loss = criterion(logits, y) loss.backward() optimizer.step() total_loss += loss.item() * x.size(0) correct += (logits.argmax(1) == y).sum().item() total += x.size(0) return total_loss / total, correct / total @torch.no_grad() def evaluate(model, loader, criterion, device): model.eval() total_loss, correct, total = 0.0, 0, 0 for x, y in loader: x, y = x.to(device), y.to(device) logits = model(x) loss = criterion(logits, y) total_loss += loss.item() * x.size(0) correct += (logits.argmax(1) == y).sum().item() total += x.size(0) return total_loss / total, correct / total

逻辑上,训练函数做前向、反传、更新,评估函数只做前向并关闭梯度。参数上,criterion用nn.CrossEntropyLoss(),它内部包含 softmax,不要在外面再加 softmax;optimizer.zero_grad(set_to_none=True)比清零更省一点显存;logits.argmax(1)取 200 类里概率最大的类别,和标签维度对齐。

外层循环负责调度器和最佳模型保存。验证集 loss 下降时保存,不要只看 train acc。

import copy def fit(model, train_loader, val_loader, optimizer, scheduler, device, epochs=30, ckpt_path="best.pt"): criterion = torch.nn.CrossEntropyLoss() history = {"train_loss": [], "train_acc": [], "val_loss": [], "val_acc": []} best_val_loss = float("inf") best_state = None for epoch in range(1, epochs + 1): tr_loss, tr_acc = train_one_epoch( model, train_loader, criterion, optimizer, device ) va_loss, va_acc = evaluate( model, val_loader, criterion, device ) scheduler.step() history["train_loss"].append(tr_loss) history["train_acc"].append(tr_acc) history["val_loss"].append(va_loss) history["val_acc"].append(va_acc) if va_loss < best_val_loss: best_val_loss = va_loss best_state = copy.deepcopy(model.state_dict()) torch.save(best_state, ckpt_path) print(f"epoch {epoch:02d} train_loss={tr_loss:.4f} " f"train_acc={tr_acc:.4f} val_loss={va_loss:.4f} " f"val_acc={va_acc:.4f}") return history, best_state

逻辑上,每个 epoch 先训练再验证,调度器在验证后推进一步,最佳模型按验证集 loss 保存。参数上,epochs=30是跑通流程的起点,报告里强调训练步数过长会过拟合、过短会欠拟合,实际要看 val loss 拐点;ckpt_path只存最佳权重,测试时加载它,不要拿最后一个 epoch 的权重直接报结果。

3.3 可视化:loss、accuracy 与预测框

曲线不用花哨,一张双轴图足够:左轴 loss,右轴 accuracy。train loss 和 val loss 放一起看欠拟合/过拟合,val acc 看收敛平台。代码里把 history 直接传进去,保存图片方便写报告。

import matplotlib.pyplot as plt def plot_curve(history, out_path="curve.png"): epochs = range(1, len(history["train_loss"]) + 1) fig, ax1 = plt.subplots(figsize=(7, 4)) ax1.plot(epochs, history["train_loss"], label="train loss") ax1.plot(epochs, history["val_loss"], label="val loss") ax1.set_xlabel("epoch") ax1.set_ylabel("loss") ax1.legend(loc="upper left") ax2 = ax1.twinx() ax2.plot(epochs, history["val_acc"], color="tab:green", linestyle="--", label="val acc") ax2.set_ylabel("accuracy") ax2.legend(loc="upper right") fig.tight_layout() fig.savefig(out_path, dpi=160)

逻辑上,loss 和 accuracy 量纲不同,用双轴避免 accuracy 被压成一条平线。参数上,dpi=160够报告插图用,val_acc用虚线区分;如果曲线震荡厉害,先看 batch size 和学习率,不要急着换模型。

预测框可视化在分类实验里不是必须项,但 CUB 自带bounding_boxes.txt,拿它做错误样本回看很有用。注意bounding_boxes.txt的第一列是图像 id,不是类别 id,画框前要先通过images.txt映射到路径。代码里先读框,再按图像 id 找路径。

from PIL import Image, ImageDraw def load_boxes(root): boxes = {} with open(f"{root}/bounding_boxes.txt", "r", encoding="utf-8") as f: for line in f: parts = line.strip().split() img_id = int(parts[0]) boxes[img_id] = tuple(map(float, parts[1:])) return boxes def draw_box(root, img_id, boxes, out_path="box.png"): images = read_kv(f"{root}/images.txt") rel = images[img_id] img = Image.open(f"{root}/{rel}").convert("RGB") x, y, w, h = boxes[img_id] draw = ImageDraw.Draw(img) draw.rectangle([x, y, x + w, y + h], outline="red", width=3) img.save(out_path)

逻辑上,load_boxes把图像 id 映射到(x, y, w, h),draw_box再把图像 id 映射到相对路径并画矩形。参数上,x, y是左上角坐标,w, h是宽高,单位是像素;如果框画歪了,优先检查是不是把img_id当成了类别标签,这是很常见的翻车点。

4. 超参数与实验对比:预训练权重、学习率、步数怎么定

4.1 预训练 vs 随机初始化:控制变量表

实验报告里对比了“仅用 CUB 从随机初始化训练”和“用预训练权重微调”两组结果,结论是微调在性能和收敛速度上更好。复现这个对比时,控制变量比结论更重要:同一份划分文件、同一套数据增强、同一个 batch size、同一个评估口径,只改预训练权重和对应的学习率策略。否则你看到的差异可能来自划分或增强,而不是预训练。

实验编号模型预训练骨干 lr分类头 lrepochs观察重点
A1AlexNet否1e-31e-330收敛速度、train loss 下降斜率
A2AlexNet是1e-41e-330初期 val acc、过拟合出现时间
R1ResNet-18否1e-31e-330残差骨架从头训练稳定性
R2ResNet-18是1e-41e-330细粒度特征迁移效果

表格里的学习率是起点,不是固定答案。从头训练时骨干 lr 不能太小,否则 30 个 epoch 内根本走不出初始平台;微调时骨干 lr 要压小,避免预训练特征被小数据集快速覆盖。实验报告里提到“新输出层从头训练,预训练参数小学习率微调”,落地就是 A2/R2 这两组的配置。

把实验配置写成列表,循环跑,结果写 CSV。这样比手动改脚本可靠。

import csv experiments = [ {"name": "alexnet_scratch", "model": "alexnet", "pretrained": False, "backbone_lr": 1e-3, "head_lr": 1e-3}, {"name": "alexnet_ft", "model": "alexnet", "pretrained": True, "backbone_lr": 1e-4, "head_lr": 1e-3}, {"name": "resnet18_scratch", "model": "resnet18", "pretrained": False, "backbone_lr": 1e-3, "head_lr": 1e-3}, {"name": "resnet18_ft", "model": "resnet18", "pretrained": True, "backbone_lr": 1e-4, "head_lr": 1e-3}, ] def run_one(cfg, train_loader, val_loader, device, epochs=30): model = build_model(cfg["model"], num_classes=200, pretrained=cfg["pretrained"]) set_finetune(model, cfg["model"], freeze_backbone=False) model = model.to(device) optimizer = build_optimizer( model, cfg["model"], backbone_lr=cfg["backbone_lr"], head_lr=cfg["head_lr"], ) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR( optimizer, T_max=epochs ) history, best_state = fit( model, train_loader, val_loader, optimizer, scheduler, device, epochs=epochs, ckpt_path=f"{cfg['name']}.pt", ) return history, best_state

逻辑上,每组实验独立构建模型、优化器和调度器,避免状态串味。参数上,pretrained控制是否加载 ImageNet 权重,backbone_lr和head_lr按组传入,T_max=epochs让余弦退火在一个训练周期内走完;如果显存不够,先把 batch size 减半,不要先改学习率,否则对比条件又变了。

4.2 学习率与衰减策略的扫参方法

学习率不要一上来网格拉满。先粗扫分类头 lr,再细扫骨干 lr,最后看衰减策略。常见起点是分类头[3e-4, 1e-3, 3e-3],骨干[1e-5, 1e-4, 5e-4],先固定 epochs=20 看趋势,再对表现最好的两组延长到 40 或 50。每次只动一个维度,否则结果无法归因。

def lr_grid(): grid = [] for head_lr in [3e-4, 1e-3, 3e-3]: for backbone_lr in [1e-5, 1e-4, 5e-4]: grid.append({ "model": "resnet18", "pretrained": True, "head_lr": head_lr, "backbone_lr": backbone_lr, "epochs": 20, }) return grid def write_result(path, row): with open(path, "a", newline="", encoding="utf-8") as f: writer = csv.DictWriter(f, fieldnames=row.keys()) writer.writerow(row)

逻辑上,lr_grid生成 9 组组合,write_result每次追加一行,方便中断后继续。参数上,epochs=20是粗扫成本控制,head_lr和backbone_lr的范围覆盖了“太小收敛慢”和“太大震荡”的两端;如果某组 train loss 在前 5 个 epoch 完全不降,先停掉,检查权重加载和标签映射,不要继续烧时间。

衰减策略方面,StepLR 更直观,CosineAnnealingLR 在后期学习率更平滑。实验报告里提到不同学习率衰减策略会影响性能,落地时至少对比一次StepLR(step_size=10, gamma=0.1)和余弦退火。看曲线时重点看 val loss 后 10 个 epoch 是否还在降,如果已经平了,加 epoch 意义不大。

4.3 结果表与判读:欠拟合/过拟合信号

结果表不要只记一个 accuracy,至少记 best val loss、best epoch、test top1、test top5、训练耗时。这样别人复现时能判断你是“收敛早”还是“过拟合晚”。下面这张表是判读用,不是让你填假数据;每组实验按自己的日志填。

现象可能原因先做什么
train loss 几乎不降lr 太小、骨干冻结过多、标签错位解冻骨干,打印一个 batch 的标签和 logits 维度
train loss 震荡明显lr 太大、batch 太小、数据增强过猛降 head_lr,检查 RandomResizedCrop 下限
train acc 高、val acc 低过拟合、验证集太小、划分泄漏加早停,固定 split,检查同图泄漏
val loss 先降后升过拟合保存 best val loss 权重,减 epochs
val acc 平台很早学习率衰减太慢或模型容量到顶换衰减策略,对比 ResNet-18

实验报告里说“过短训练步数欠拟合、过长过拟合、过大学习率震荡、过小收敛慢”,这四句话在曲线上都有对应形态。不要只看最终数字,先把曲线形态和表里的现象对上,再决定是调 lr 还是调 epochs。

5. 避坑与排查:鸟类识别实验里最容易翻车的五件事

5.1 数据划分泄漏与类别不均衡

现象是验证集 accuracy 很漂亮,测试集一跑掉一截,或者某些类别在混淆矩阵里几乎全错。原因常见有两个:验证集从训练集里随机抽时没有按类别分层,导致少数类验证样本极少;或者生成 split 时用了随机数但没固定 seed,每次运行划分都变,调参时等于在验证集上反复过拟合。CUB 每类约 60 张,分层抽样后每类验证集只有几张,指标抖动会很大,必须固定划分文件。

解决方式是把 split 文件当成实验输入的一部分,生成一次后不再改动,训练脚本只读不重抽。下面的检查代码放在训练前跑一遍,确认没有交集。

def read_rows(path): rows = [] with open(path, "r", encoding="utf-8") as f: for line in f: line = line.strip() if line: rel, y = line.rsplit(" ", 1) rows.append((rel, int(y))) return rows train_rows = read_rows("splits/train.txt") val_rows = read_rows("splits/val.txt") test_rows = read_rows("splits/test.txt") train_set = {rel for rel, _ in train_rows} val_set = {rel for rel, _ in val_rows} test_set = {rel for rel, _ in test_rows} assert not (train_set & val_set), "train/val 有重叠" assert not (train_set & test_set), "train/test 有重叠" assert not (val_set & test_set), "val/test 有重叠"

逻辑上,用相对路径集合做交集判断,比只比标签可靠。参数上,路径必须和 Dataset 里的root拼接方式一致,否则你检查

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进