☰
PyTorch人脸表情识别实战:FER2013数据预处理到ResNet训练
2026/10/10 7:26:05 网站建设 项目流程

简介:面向计算机专业学生与实战练习者,是一份基于PyTorch实现人脸表情识别的完整项目,覆盖CNN、VGG、ResNet三类网络,包含模型定义、训练测试、GPU加速、数据划分、数据可视化、图像表情映射及人脸检测配置等模块,可直接用于课程设计、期末大作业或毕业设计参考,也可作为入门深度学习的练手项目。项目由导师指导并获99分评审,代码完整可运行,主干脚本与测试脚本分离,另有模型对比、人脸检测XML文件和README说明,目录结构清晰,便于快速跑通、定位模块及二次扩展。压缩包共15个文件,以13个Python脚本为主,另含1个XML人脸检测模型和1个Markdown说明文件,整体体积仅162KB,轻量易下载,特别适合离线查阅和快速启动。已有168人学习下载,适合需要从零完成人脸表情识别、对比不同网络模型并开展实验的读者。

1. 用 PyTorch 拆解人脸表情识别:一套能直接跑的课程设计源码

人脸表情识别是计算机视觉课程里出现频率最高的期末方向之一,很多同学拿到题目,脑子里立刻冒出 PyTorch、CNN 这些词,可真把 FER2013 数据集下载下来,面对pixels那一长串数字却不知道怎么下手。这份基于 PyTorch 实现的 CNN + VGG + ResNet 人脸表情识别源码附带项目说明,把从数据读取、模型搭建到训练评估的完整链路都整理好了,按着跑就能出结果。它不挑显卡,不依赖特殊环境,改几个参数就能切换三种网络对比,适合做课程设计或期末大作业的复现起点。源码结构清晰,注释写到了函数级别,哪怕你只想读懂其中某一段,也能省下不少查资料的时间。

2. 数据加载与预处理:从 CSV 到可直接训练的 PyTorch Dataset

2.1 FER2013 的 CSV 结构与 emotion / pixels / Usage 三列含义

人脸表情识别最常见的入门数据集是 FER2013,它的原始形态不是一堆图片文件,而是一个 CSV。每行对应一张 48x48 的灰度人脸图,emotion列是表情标签(0 到 6,分别对应生气、厌恶、恐惧、开心、中性、悲伤、惊讶),pixels列是 2304 个 0 到 255 的灰度值,用空格分隔,Usage列标记这条数据属于训练集还是测试集。很多课程设计模板直接从图像文件夹读数据,但 FER2013 的 CSV 结构更常见,也更适合做数据加载基本功。

我第一次处理这个数据集时犯了个低级错误,直接对pixels字符串做int()转换,结果整行报错。正确做法是把它按空格拆成数组,再 reshape 成 48x48 的单通道图像。下面是一个标准的 Dataset 实现,能直接跑通 CSV 到张量的转换:

import pandas as pd import numpy as np import torch from torch.utils.data import Dataset class Fer2013Dataset(Dataset): def __init__(self, csv_path, mode='Training'): # FER2013 原始 CSV 的 Usage 列取值有 Training / PublicTest / PrivateTest self.df = pd.read_csv(csv_path) self.df = self.df[self.df['Usage'] == mode].reset_index(drop=True) self.labels = torch.tensor(self.df['emotion'].values, dtype=torch.long) self.pixels = self.df['pixels'].values def __len__(self): return len(self.df) def __getitem__(self, idx): # 把空格分隔的像素字符串转成 numpy 数组,再 reshape 成单通道图像 pixel_str = self.pixels[idx] pixel_arr = np.array(pixel_str.split(), dtype=np.float32) img = pixel_arr.reshape(48, 48) / 255.0 img_tensor = torch.from_numpy(img).unsqueeze(0) # 形状变为 (1, 48, 48) return img_tensor, self.labels[idx]

这里有几个细节值得说明。第一,np.array(pixel_str.split(), dtype=np.float32)是比np.fromstring更稳的写法,新版 NumPy 对fromstring的字符串解析支持越来越弱,换成分裂再转数组基本不会踩版本坑。第二,归一化直接除以 255.0,把像素值压到 0 到 1 区间,这比不做归一化训练收敛快得多。第三,unsqueeze(0)是给灰度图补上通道维,PyTorch 卷积层期望的输入是(batch, channel, height, width),少这个维度会直接报错。

2.2 训练集与测试集的增强策略为什么必须分开

表情识别任务里,数据增强的作用比想象中大。FER2013 是实验室采集的静态人脸图,光照、角度、遮挡都比较单一,直接在原图上训练,模型很快就会过拟合,测试集准确率上不去。常见做法是给训练集加随机水平翻转、随机旋转、随机平移,给测试集只做归一化,不做任何随机变换。

这里有个容易翻车的细节:PyTorch 的ToTensor会帮我们做归一化和通道转换,但 CSV 方式加载的图像已经是 Tensor 了,所以增强要手动写在 Dataset 里,或者单独构造一个 Transform 流程。我一般会把训练集和测试集的 Transform 分开定义,避免测试时也做随机翻转,导致评估结果不稳定。

from torchvision import transforms train_transform = transforms.Compose([ transforms.RandomHorizontalFlip(p=0.5), transforms.RandomRotation(degrees=10), transforms.RandomAffine(degrees=0, translate=(0.1, 0.1)), transforms.ToTensor(), transforms.Normalize(mean=[0.5], std=[0.5]) ]) test_transform = transforms.Compose([ transforms.ToTensor(), transforms.Normalize(mean=[0.5], std=[0.5]) ])

注意这里的Normalize参数是(0.5, 0.5),对应灰度图的均值和标准差。因为前面已经把像素除以 255,所以用 0.5 做归一化是合理的。如果你在 Dataset 里已经手动归一化过,又要用transforms.ToTensor(),就会得到两次归一化的结果。这种叠加问题很隐蔽,表现为训练 loss 一直偏高,但模型又不是完全不收敛。数据流的方向要理清楚:要么在 Dataset 里做全部预处理,要么在 Transform 里做全部预处理,不要混用。

2.3 类别不均衡:先看分布再定损失函数

FER2013 的 7 类表情样本数量差别不小,开心和中性样本多,厌恶样本很少。直接用交叉熵损失训练,模型会把所有不确定的样本都往多数类上推,导致单类准确率差距很大。训练前先统计一下分布,是很有必要的。

表情类别大致样本占比训练难度
开心最多易过拟合
中性多易过拟合
生气中等尚可
悲伤中等尚可
惊讶中等尚可
恐惧偏少较难
厌恶极少最难

最简单的处理办法是在交叉熵损失里传weight参数,给样本少的类别更高权重。权重可以按类别样本数的倒数计算并归一化,也可以用sklearn的compute_class_weight快速得到。我给这个资源写说明时,把损失权重计算直接做成了工具函数,因为这个环节很多课程设计报告里会忽略,但它对最终分数的影响很明显。

from sklearn.utils.class_weight import compute_class_weight def build_class_weight(labels): classes = [0, 1, 2, 3, 4, 5, 6] weights = compute_class_weight('balanced', classes=classes, y=labels.numpy()) return torch.tensor(weights, dtype=torch.float32) criterion = nn.CrossEntropyLoss(weight=build_class_weight(train_dataset.labels))

参数说明:compute_class_weight的balanced模式会自动按n_samples / (n_classes * bincount)计算权重,厌恶这类样本少的类别会拿到更大的权重,模型就不会一味偏向多数类。需要留意的是,权重过大会放大噪声样本的影响,实际训练时如果发现厌恶类准确率上来了但整体准确率掉了,可以把权重再乘一个 0.5 左右的缩放系数,属于常规调参操作。

3. CNN 基线与 VGG:从自己搭卷积到复用经典结构

3.1 三层卷积基线:为什么先从小网络开始

拿到表情识别任务,不建议一上来就搬 ResNet 跑全流程。第一次跑通一个三层卷积的小网络,把数据管道和训练循环验证无误,再切换到 VGG 和 ResNet,这是排错成本最低的顺序。三层卷积加池化加全连接,对 48x48 灰度图来说参数量不大,CPU 也能完成一轮训练。

import torch.nn as nn class SimpleCNN(nn.Module): def __init__(self, num_classes=7): super().__init__() self.features = nn.Sequential( nn.Conv2d(1, 32, kernel_size=3, padding=1), nn.BatchNorm2d(32), nn.ReLU(inplace=True), nn.MaxPool2d(2), # 48 -> 24 nn.Conv2d(32, 64, kernel_size=3, padding=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True), nn.MaxPool2d(2), # 24 -> 12 nn.Conv2d(64, 128, kernel_size=3, padding=1), nn.BatchNorm2d(128), nn.ReLU(inplace=True), nn.AdaptiveAvgPool2d(1) # 不管输入多大,输出都是 1x1 ) self.classifier = nn.Linear(128, num_classes) def forward(self, x): x = self.features(x) x = x.view(x.size(0), -1) # 展平成 (batch, 128) return self.classifier(x)

这个结构的选型逻辑很直接:第一层用 32 通道,是因为 48x48 输入本身信息量不大,通道数起太高徒增参数;每一层后面跟 BatchNorm,是为了让小批量训练时梯度传播更稳;池化用MaxPool2d(2),让特征图尺寸逐层减半,最后一层用AdaptiveAvgPool2d(1)替代Flatten,这样即使输入尺寸变了,全连接层也能接上。常见误用是把最后一层写成nn.Flatten(),再接一个Linear(128 * 6 * 6, 7),这种写法一旦输入尺寸改动就会崩,换成自适应池化就一劳永逸。

3.2 轻量化 VGG:把 16 层压成适合小图的 5 层块

VGG 的核心思想是堆叠 3x3 小卷积核,用更深的网络换取更大的感受野。原版 VGG16 是为 224x224 的大图设计的,直接套到 48x48 的灰度图上,参数量大、训练极慢,而且效果不如精简版。常见做法是保留 VGG 的“卷积块堆叠”结构,但每块只放 1 到 2 层卷积,通道数起步也小一些。

def make_vgg_block(in_channels, out_channels, num_convs, pool=True): layers = [] for _ in range(num_convs): layers.append(nn.Conv2d(in_channels, out_channels, kernel_size=3, padding=1)) layers.append(nn.BatchNorm2d(out_channels)) layers.append(nn.ReLU(inplace=True)) in_channels = out_channels if pool: layers.append(nn.MaxPool2d(kernel_size=2, stride=2)) return nn.Sequential(*layers) class LightVGG(nn.Module): def __init__(self, num_classes=7, init_channels=32): super().__init__() self.features = nn.Sequential( make_vgg_block(1, init_channels, num_convs=1, pool=True), # 48 -> 24 make_vgg_block(init_channels, init_channels * 2, num_convs=1, pool=True), # 24 -> 12 make_vgg_block(init_channels * 2, init_channels * 4, num_convs=2, pool=False), nn.AdaptiveAvgPool2d(1) ) self.classifier = nn.Sequential( nn.Linear(init_channels * 4, 128), nn.ReLU(inplace=True), nn.Dropout(0.5), nn.Linear(128, num_classes) ) def forward(self, x): x = self.features(x) x = x.view(x.size(0), -1) return self.classifier(x)

这里的init_channels是控制整个网络宽度的总开关,默认 32,VGG 块内部按 32 -> 64 -> 128 翻倍。第三个块pool=False是为了不让特征图缩到 6x6 以下,因为 48 经过两次池化已经是 12,再池化就只剩 6,信息损失太严重。如果觉得准确率不够,把init_channels改成 64,就是宽度翻倍的版本,显存占用大致翻一倍,但训练时间也明显变长。我在项目说明里特别标注了:num_convs这个参数才是 VGG 的“深度”,想体验更深的网络,把第三块的num_convs改成 3 或 4 即可。

3.3 三个网络的参数量与训练时间对比

课程设计答辩时,老师几乎必问“为什么选这个网络”。准备工作里放一张对比表,能省去不少现场解释的麻烦。

网络参数量(约)CPU 上跑完 30 轮预期测试准确率区间
SimpleCNN约 20 万20 分钟以内55% ~ 60%
LightVGG约 60 万40 分钟左右58% ~ 63%
ResNet18约 1100 万2 小时以上62% ~ 70%

这些数字不是绝对的,和优化器、数据增强、随机种子都有关,但数量级基本是准的。注意 ResNet18 的参数量之所以比前两个大一个数量级,是因为它有 4 个残差阶段、每阶段通道数到 512,而表情识别只有 7 类,最后全连接层占比很小。如果电脑没有独立显卡,我建议用 LightVGG 做主线,ResNet 只做对比实验,省下的时间够多调几组参数。

4. ResNet 训练全流程:期末大作业版残差块实现与调参细节

4.1 手写 BasicBlock 与 ResNet18:残差连接的作用

ResNet 解决的是深层网络难训练的问题,核心就是残差连接:让某一层学习的是“输入和输出之间的差值”,而不是直接拟合完整映射。对于 48x48 小图,不需要原版 ResNet 那么深,ResNet18 就够了。手写 BasicBlock 并不难,关键是把downsample分支搞清楚。

class BasicBlock(nn.Module): expansion = 1 def __init__(self, in_channels, out_channels, stride=1, downsample=None): super().__init__() self.conv1 = nn.Conv2d(in_channels, out_channels, kernel_size=3, stride=stride, padding=1, bias=False) self.bn1 = nn.BatchNorm2d(out_channels) self.conv2 = nn.Conv2d(out_channels, out_channels, kernel_size=3, padding=1, bias=False) self.bn2 = nn.BatchNorm2d(out_channels) self.relu = nn.ReLU(inplace=True) self.downsample = downsample def forward(self, x): identity = x out = self.conv1(x) out = self.bn1(out) out = self.relu(out) out = self.conv2(out) out = self.bn2(out) if self.downsample is not None: identity = self.downsample(x) # 用 1x1 卷积对齐通道维和尺寸 out += identity return self.relu(out)

注意out += identity这一步,要求右侧的out和identity形状完全一致。如果上一层输入通道是 64,当前层输出通道是 128,而且 stride 为 2 导致特征图尺寸减半,就必须用downsample对输入做一次 1x1 卷积和降采样。这正好对应原版 ResNet 的设计:每个阶段的第一个残差块承担通道翻倍和尺寸减半的任务,后续残差块保持形状不变。很多人手写 ResNet 报错,基本都是这个形状匹配问题。

4.2 ResNet18 组装与灰度图输入适配

把 BasicBlock 组装成 ResNet18,需要定义四个阶段,每个阶段由两个 BasicBlock 组成。下面给出完整结构:

class ResNet18(nn.Module): def __init__(self, num_classes=7): super().__init__() self.conv1 = nn.Conv2d(1, 64, kernel_size=7, stride=2, padding=3, bias=False) self.bn1 = nn.BatchNorm2d(64) self.relu = nn.ReLU(inplace=True) self.maxpool = nn.MaxPool2d(kernel_size=3, stride=2, padding=1) self.layer1 = self._make_layer(64, 64, blocks=2, stride=1) self.layer2 = self._make_layer(64, 128, blocks=2, stride=2) self.layer3 = self._make_layer(128, 256, blocks=2, stride=2) self.layer4 = self._make_layer(256, 512, blocks=2, stride=2) self.avgpool = nn.AdaptiveAvgPool2d(1) self.fc = nn.Linear(512, num_classes) def _make_layer(self, in_channels, out_channels, blocks, stride): downsample = None if stride != 1 or in_channels != out_channels: downsample = nn.Sequential( nn.Conv2d(in_channels, out_channels, kernel_size=1, stride=stride, bias=False), nn.BatchNorm2d(out_channels) ) layers = [BasicBlock(in_channels, out_channels, stride, downsample)] for _ in range(1, blocks): layers.append(BasicBlock(out_channels, out_channels)) return nn.Sequential(*layers) def forward(self, x): x = self.conv1(x) x = self.bn1(x) x = self.relu(x) x = self.maxpool(x) x = self.layer1(x) x = self.layer2(x) x = self.layer3(x) x = self.layer4(x) x = self.avgpool(x) x = x.view(x.size(0), -1) return self.fc(x)

第一层conv1直接用了 7x7 卷积,步长 2,配合后面的最大池化,48x48 的输入会迅速降到 12x12,再经过 layer2 到 layer4 逐步减半,最后是 3x3 左右的特征图。AdaptiveAvgPool2d(1)保证不管前面怎么变,全连接层输入都是 512 维。这套结构里,conv1的输入通道是 1,因为 FER2013 是灰度图,这跟 ImageNet 预训练模型的 3 通道输入不一样,所以后面要重点处理预训练权重适配。

4.3 用 ImageNet 预训练权重初始化灰度层

ResNet18 从头训练能到 63% 左右,但如果你想让分数再往上走,利用 ImageNet 预训练权重是更高效的路子。问题在于预训练权重是为 3 通道设计的,而表情数据集是 1 通道。常见做法有两种:一种是把灰度图复制成 3 通道喂进去,卷积层完全不用改;另一种是把第一个卷积层的权重在通道维上求平均,压缩成 1 通道。

import torchvision.models as models # 方式一:灰度图复制三通道,直接加载预训练权重 def get_resnet18_pretrained(num_classes=7): net = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1) net.conv1 = nn.Conv2d(1, 64, kernel_size=7, stride=2, padding=3, bias=False) net.fc = nn.Linear(512, num_classes) # 把原 conv1 的权重在通道维平均,赋值给新的单通道 conv1 with torch.no_grad(): old_weight = net.conv1.weight # 注意此处 conv1 已被替换,原权重需要先保存 # 实际操作时先取原网络 conv1 权重 return net

上面的写法有个隐藏坑:net.conv1 = nn.Conv2d(...)之后,原来的 conv1 权重就被丢弃了。正确顺序是先把原权重取出来,做完通道平均,再赋值给新 conv1。我一般会写一个工具函数,逻辑是:先创建预训练网络,保存old_conv1_weight = net.conv1.weight.data,再替换 conv1 层,最后把平均后的权重拷贝进去。这种细节在项目说明里一定要写清楚,否则用户照着敲代码,会发现预训练权重根本没生效。

4.4 训练配置:优化器、学习率与早停

ResNet 的参数多,学习率不能照搬小 CNN 的经验。我在这份资源里给出的默认配置是 SGD 优化器,初始学习率 0.01,动量 0.9,权重衰减 5e-4,用余弦退火调度器让学习率逐步降到 1e-6。这个配置在 FER2013 上表现稳定,改起来也方便。

import torch.optim as optim def build_optimizer(model): optimizer = optim.SGD(model.parameters(), lr=0.01, momentum=0.9, weight_decay=5e-4) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR( optimizer, T_max=30, eta_min=1e-6) return optimizer, scheduler

T_max=30表示经过 30 轮学习率从 0.01 衰减到 1e-6。如果你的训练轮数不是 30,要把T_max改成同样的值,否则调度器会提前走完周期。另一个常见问题是,ResNet 里的 BatchNorm 层对学习率比较敏感,初始学习率设成 0.1 很容易直接发散,loss 变成 NaN;设成 0.001 又收敛太慢。0.01 在这个任务里算是个安全区间。早停逻辑可以简单做:连续 5 个 epoch 验证集准确率不上升,就保存当前最优权重并停训。

5. 复现避坑:数据、训练、评估三层的失败记录

复现这套源码时,我遇到过不少问题,也帮某个同学排查过几次课程设计代码。下面几条是踩得最频繁的坑,每条按现象、原因、解决写清楚,希望能省下你 debug 的时间。

坑一:np.fromstring报错或警告

现象:用np.fromstring(pixels, dtype=int, sep=' ')读像素时,新版 NumPy 弹出 DeprecationWarning,甚至直接报错。

原因:NumPy 1.20 以后逐渐废弃了fromstring的字符串解析用法,到 1.24 以上部分环境直接报错。我最初写数据加载时用的老写法,换到新环境就翻车了。

解决:统一用np.array(pixel_str.split(), dtype=np.float32),不依赖任何版本特性。字符串先按空格拆成 list,再转数组,语义清晰且跨版本稳定。

坑二:训练集和测试集用了同一套数据增强

现象:训练时 loss 正常下降,但验证集准确率忽高忽低,每次评估结果差异很大,甚至模拟预测出来的表情是随机的。

原因:把RandomHorizontalFlip等增强操作也加到了测试集 Transform 里,相当于每次评估都在随机扰动输入。某些样本翻转后确实会改变视觉语义,模型输出自然不稳定。

解决:把 Transform 拆成两份,训练集用增强,测试集只归一化。这是我在资源说明里反复强调的点,很多课程设计代码失败都不是模型问题,而是数据流切换没写对。

坑三:ResNet 预训练权重加载后第一个卷积层没生效

现象:加载预训练权重的 ResNet,准确率反而比随机初始化的还低,或者训练时第一层梯度异常。

原因:用net.conv1 = nn.Conv2d(1, 64, ...)替换原层后,原权重的通道维是 3,新层期望的是 1,PyTorch 不会自动转换,于是新 conv1 是随机初始化的,只有后面几层保留预训练信息。第一层都没学到特征,后续层再强也白搭。

解决:先保存old_weight = net.conv1.weight.data,再把平均后的权重old_weight.mean(dim=1, keepdim=True)拷贝进新 conv1,这样既保留预训练统计信息,又适配灰度输入。

坑四:类别权重设置过大导致整体准确率反降

现象:加了CrossEntropyLoss(weight=...)之后,厌恶类准确率明显提高,但整体准确率掉了 3 到 4 个点。

原因:balanced模式给少数类的权重往往比多数类高 10 倍以上,模型被引导过度关注少数类,正常样本反而分错。

解决:对算出的权重做压缩,比如weights = weights * 0.5 + 0.5,把倍率压到 1.5 到 2 倍之间,不是直接放大 10 倍。这个缩放系数据实际效果调整,一般在 0.3 到 0.8 之间。

坑五:训练和推理模式不一致导致结果完全不同

现象:训练完的模型,在测试脚本里单独跑没问题,但放到实时推理程序里准确率暴跌,概率输出还很怪异。

原因:PyTorch 的 BatchNorm 在训练模式下用批内统计量,在评估模式下用全局统计量。如果推理时忘了model.eval(),BN 层还在用当前小批量的均值方差,结果当然不稳定。Dropout 也有类似问题,训练时随机失活,推理时要关闭。

解决:写一个predict函数,内部强制model.eval(),并配合torch.no_grad()关闭梯度。这属于 PyTorch 的基本习惯,但确实是很多人容易漏掉的细节。

6. 从测试集混淆矩阵到摄像头实时推理:验证模型的正确姿势

模型训练完,最后一步是验证它到底学到了什么。只看总准确率不够,一个在 7 类上平均表现差但整体准确率高的情况,往往掩盖了某些类别完全没学会的事实。我习惯跑一个测试集混淆矩阵,配合分类报告逐类看结果。

from sklearn.metrics import confusion_matrix, classification_report def evaluate_model(model, test_loader, device): model.eval() all_preds = [] all_labels = [] with torch.no_grad(): for x, y in test_loader: x, y = x.to(device), y.to(device) out = model(x) preds = out.argmax(dim=1) all_preds.extend(preds.cpu().numpy()) all_labels.extend(y.cpu().numpy()) cm = confusion_matrix(all_labels, all_preds) report = classification_report(all_labels, all_preds, digits=3) print(report) return cm

看混淆矩阵时,重点看对角线之外哪些类别互相混淆严重。FER2013 上最常见的混淆对是恐惧和惊讶,生气和中性,因为这两组表情本身视觉特征接近。如果某个类别的行全部集中在另一列,说明模型把它整体学偏了,根因多半是数据太少,而不是网络结构有问题。

验证完之后,可以进一步把模型接到摄像头实时预测上。课程设计如果能现场演示摄像头识别表情,效果会很好。核心代码很短:把 OpenCV 读到的 BGR 帧转成灰度图,缩放到 48x48,归一化后直接送入模型。

import cv2 import torch def predict_frame(model, frame, device): gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) resized = cv2.resize(gray, (48, 48)) x = torch.from_numpy(resized).float().unsqueeze(0).unsqueeze(0) / 255.0 x = x.to(device) model.eval() with torch.no_grad(): out = model(x) pred = out.argmax(dim=1).item() return pred

这里unsqueeze(0)两次是因为模型期望输入是(batch, channel, height, width),第一次加通道维,第二次加 batch 维。/ 255.0必须和训练时保持一致,如果训练用了Normalize(0.5, 0.5),推理时也要补上等价的归一化,否则模型见到的是分布完全不同的输入,预测结果会乱掉。之前我给某同学的答辩做检查时,他改完数据增强后准确率从 0.61 掉到 0.57,第一反应是模型坏了,最后才发现是训练集翻转忘了在测试集关掉。从那以后,我每次切到新项目都强制把数据流的训练/测试分支从头走一遍,确认两次预处理完全一致,才敢把模型拿出去交差。希望这篇笔记帮你在课程设计这条路上少走几趟弯路。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询