首页 / 资讯中心 / 文章详情

PyTorch入门实战:用MLP实现环形数据二分类全流程

PyTorch入门实战:用MLP实现环形数据二分类全流程 ★ FEATURED ARTICLE
在PyTorch的学习路线上有一个坎几乎每个人都要过用最简单的神经网络做一次完整的分类。这个坎过了后面看CNN、RNN这些结构再复杂的东西至少在训练流程上不会再发怵。这篇文章我就从一次亲手跑通的实验出发把数据准备、网络搭建、训练、评估这一整条链路拆开讲顺便把我踩过的坑也一并交代清楚。适合已经知道Tensor、autograd是什么但还没完整训练过一个模型的人。你不用下载任何图片数据集我用一组二维环形数据十几行代码就能把“分类器”这个词落到实地上。1. 分类任务的第一课数据怎么进模型模型怎么往外吐1.1 数据和标签先想清楚输入和输出各是什么写分类模型之前我习惯先把两张图在脑子里画出来输入长什么样输出长什么样。输入是特征张量形状一般是[N, D]N是样本数D是特征维度。输出是每个样本在各类别上的得分形状是[N, C]C是类别数。二分类的C2多分类就是具体几个类别。标签更有意思。新手最容易搞错的点之一就是标签到底该用float还是long。在PyTorch里做分类CrossEntropyLoss要求标签是整数型张量形状是[N]里面存的是0、1、2这样的类别索引而不是one-hot向量。这一点后面会专门说到。我这次用二维环形数据做实验一类样本分布在半径为1的环附近另一类分布在半径为2的环附近。从平面上看它们是两个同心圆环任何一条直线都不可能把它们分开必须靠隐藏层的非线性变换才能分类。这种构造非常合适教学因为它能逼着你写一个带ReLU的MLP而不是靠一层线性层糊弄过去。1.2 PyTorch的数据加载套路TensorDataset和DataLoader数据生成好之后要转成PyTorch能吃的格式。最省事的组合是TensorDataset加DataLoader。import torch import torch.nn as nn import torch.optim as optim import math from torch.utils.data import DataLoader, TensorDataset torch.manual_seed(42) n 500 theta torch.linspace(0, 2 * math.pi, n) r0 1.0 0.1 * torch.randn(n) r1 2.0 0.25 * torch.randn(n) x0 r0 * torch.cos(theta) y0 r0 * torch.sin(theta) x1 r1 * torch.cos(theta) y1 r1 * torch.sin(theta) X torch.cat([ torch.stack([x0, y0], dim1), torch.stack([x1, y1], dim1), ], dim0) y torch.cat([ torch.zeros(n, dtypetorch.long), torch.ones(n, dtypetorch.long), ], dim0) perm torch.randperm(len(X)) X, y X[perm], y[perm] split int(0.8 * len(X)) train_ds TensorDataset(X[:split], y[:split]) test_ds TensorDataset(X[split:], y[split:]) train_loader DataLoader(train_ds, batch_size32, shuffleTrue) test_loader DataLoader(test_ds, batch_size32, shuffleFalse)为什么要用DataLoader而不是整个张量直接塞给模型因为训练是分批来的。batch_size32意味着每次只挑32个样本算一次梯度既省内存又因为shuffleTrue让每个epoch的数据顺序变化避免模型记住顺序。实测下来小批量训练比全量一次性训练收敛更稳在原始数据分布不太均匀时尤其明显。1.3 前向传播输入层、隐藏层、输出层之间发生了什么一个最简单的MLP可以长这样输入层接两个隐藏层再接输出层。第一层Linear(2, 16)把二维输入升到16维中间加ReLU第二层Linear(16, 16)继续变换最后一层Linear(16, 2)输出两个得分。为什么要两个隐藏层对于这种环形分布一层隐藏层其实也够用两层会更稳。隐藏层的16这个数字来自我的教学经验太小了拟合不了边界太大了在这个玩具数据上没必要。真实任务里16、32、64都可以作为起点不是越大越好。这里有个关键点最后一层不额外接Softmax。我一直跟准备入门的朋友强调分类模型训练时输出层出来的是原始logits不是概率。CrossEntropyLoss内部会自己做LogSoftmax和NLLLoss。你要是先Softmax再算交叉熵数值可能会不稳训练速度也慢属于典型的“好心办坏事”。1.4 损失函数分类任务默认首选CrossEntropyLoss交叉熵损失是分类问题的事实标准。你可能会问为什么不用MSE用MSE做分类虽然loss也能算但梯度会变得很奇怪模型对“很对”和“很错”的样本响应不够灵敏收敛也慢。交叉熵则直接衡量预测分布和真实分布的差异分类场景下梯度的方向更合理。nn.CrossEntropyLoss的完整等价形式是LogSoftmax NLLLoss它把softmax的计算和负对数似然合在了一起。所以在自定义网络时最后一层就保持Linear的输出不要在模型内部再加激活。2. 用一个“环”造出线性不可分的二分类实验完整代码2.1 为什么我不用现成的图片数据集很多教程一上来就让你下载图片数据集然后花时间处理通道、尺寸、归一化。对于第一次接触分类器的人这些前置条件太嘈杂了。二维环形数据的好处是不依赖网络下载、训练快、还能直接画出决策边界来看。数据生成逻辑很简单用同一个角度序列生成两个圆环类0半径小一点类1半径大一点各加一点高斯噪声。这样做出来的两类样本在二维平面上交错但不可线性分割隐藏层的作用非常直观。2.2 定义一个刚好够用的网络结构class SimpleMLP(nn.Module): def __init__(self, in_dim2, hidden_dim16, out_dim2): super().__init__() self.net nn.Sequential( nn.Linear(in_dim, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, out_dim) ) def forward(self, x): return self.net(x)这里我故意写成了自定义nn.Module而不是一行nn.Sequential直接实例化。原因是我后续想改hidden_dim、加Dropout自定义类改起来更清楚也更接近真实项目的写法。2.3 完整训练循环代码device torch.device(cuda if torch.cuda.is_available() else cpu) model SimpleMLP().to(device) criterion nn.CrossEntropyLoss() optimizer optim.Adam(model.parameters(), lr1e-2) epochs 30 for epoch in range(epochs): model.train() total_loss 0 correct 0 total 0 for batch_x, batch_y in train_loader: batch_x batch_x.to(device) batch_y batch_y.to(device) optimizer.zero_grad() logits model(batch_x) loss criterion(logits, batch_y) loss.backward() optimizer.step() total_loss loss.item() * batch_x.size(0) correct (logits.argmax(dim1) batch_y).sum().item() total batch_x.size(0) train_acc correct / total avg_loss total_loss / total if (epoch 1) % 5 0: print(fepoch {epoch 1:3d} loss {avg_loss:.4f} train_acc {train_acc:.4f})这段代码是标准的四步循环zero_grad清空梯度、前向得到logits、loss.backward()回传梯度、optimizer.step()更新参数。顺序不能乱。有些新手会把zero_grad放在前向之后大概率会得到梯度不断累加的结果训练曲线会非常难看。2.4 跑完应该看到什么在torch.manual_seed(42)固定种子、环形数据、batch_size32、lr1e-2、30轮训练的条件下我这边跑出来的日志大致是epoch 5 loss 0.2183 train_acc 0.9213 epoch 10 loss 0.1056 train_acc 0.9725 epoch 15 loss 0.0752 train_acc 0.9863 epoch 20 loss 0.0548 train_acc 0.9938 epoch 25 loss 0.0413 train_acc 0.9975 epoch 30 loss 0.0341 train_acc 0.9988也就是说训练到第10轮就已经能到97%以上的训练准确率了后面只是继续把边界磨得更精细。如果你的loss一直停在0.69附近不动那大概率是模型或者数据出了某个低级问题后面第5部分会展开讲。2.5 把训练好的模型保存下来模型训完之后不要只在内存里爽一下。推荐用torch.save存一份torch.save(model.state_dict(), simple_mlp.pt)注意保存的是state_dict而不是整个模型。好处是灵活加载时必须先定义一模一样的网络结构再load_state_dict。很多项目里模型结构会更新只存权重能避免把旧类结构一起冻在里面。3. 训练循环里那些不报错却影响结果的小事3.1 梯度清零位置和时机都要养成肌肉记忆我见过最多的隐蔽bug就是optimizer.zero_grad()没写或者放错位置。PyTorch的梯度是累积的如果你不在每个batch开始前清零上一批的梯度会加到这一批上参数更新方向就错了。正确的位置是每个batch前向之前。代码里我用的是optimizer.zero_grad()也有人写model.zero_grad()效果一样但推荐前者语义更明确。多卡、梯度累积这些进阶场景里清梯度的控制更讲究但在入门阶段记住“循环体第一步清梯度”就够了。3.2 设备迁移模型和数据必须在同一个地方这是新手容易忽略的一个细节。你写了model SimpleMLP().to(device)但忘了把batch_x和batch_y也搬到对应设备跑起来八成会报“Expected all tensors to be on the same device”之类的错误。如果是在CPU上做实验不写.to(device)也能跑但一旦换到CUDA报错就来了。所以我会在训练一开始定义device torch.device(cuda if torch.cuda.is_available() else cpu)然后模型、输入、标签全部按这个device走。没有GPU的机器就用CPU有GPU就自动用GPU代码不用改第二遍。3.3 训练模式和评估模式不是摆设model.train()和model.eval()在当前的简单MLP里似乎没什么差别因为网络里没有Dropout、BatchNorm这些行为会变化的层。但只要你后续加了一个Dropout(0.2)忘记调用model.eval()验证阶段依然会随机丢弃神经元导致准确率忽高忽低。我的习惯是训练循环开始前写model.train()在所有评估、画决策边界、算测试集指标前写model.eval()并且用with torch.no_grad():包住推理过程。这一步花不了几秒钟但能帮你排除一大类让人摸不着头脑的结果异常。3.4 随机种子复现实验结果的基本功代码里只设置一个torch.manual_seed(42)其实并不完整。如果你的数据生成、shuffle过程中用了NumPy或Python标准库的random它们的随机序列不会受torch.manual_seed控制。想要完全复现需要三件套一起上import random import numpy as np random.seed(42) np.random.seed(42) torch.manual_seed(42)如果之后用CUDA还要考虑torch.cuda.manual_seed_all(42)。但这里要注意PyTorch的某些算子本身在GPU上就不保证逐位一致所有随机种子都设好也只能做到“基本复现”。对入门项目来说把训练前数据的生成和shuffle固定住结果就不会差太远。4. 从二分类换到多分类输出层、标签和评估指标同步改4.1 网络小小的改动背后是一串联动有人觉得二分类换多分类就是把out_dim从2改成类别数C。方向是对的但代码里至少有三处要动模型输出层维度、标签的取值范围、评估时的argmax逻辑。如果是手写数字那种10分类out_dim10标签范围是0到9。CrossEntropyLoss对多分类的处理方式和二分类完全一样它只看你最后一维的输出长度和标签的整数索引。二分类其实就是多分类在C2时的特例所以根本不用单独写一个二分类专用分支。4.2 评估指标别只看一个准确率二分类里看准确率还凑合多分类尤其类别不平衡时只盯准确率很容易被带偏。比如95%的样本都是第0类模型全猜第0类也有95%准确率但实际上它什么也没学会。更稳妥的方式是看混淆矩阵或者按类别分别算精确率、召回率、F1再做宏平均。我用一个简单函数算多分类准确率def accuracy(loader, model, device): model.eval() correct 0 total 0 with torch.no_grad(): for batch_x, batch_y in loader: batch_x batch_x.to(device) batch_y batch_y.to(device) logits model(batch_x) preds logits.argmax(dim1) correct (preds batch_y).sum().item() total batch_y.size(0) return correct / total注意这里用的是logits.argmax(dim1)而不是先softmax再argmax。两者在绝大多数情况下等价但直接对logits取最大更高效数值上也稳定。4.3 多分类下为什么还是不用softmax很多人看到多分类就条件反射想在最后一层加Softmax然后输出“概率”。实际上训练阶段完全不需要。原因还是那一条CrossEntropyLoss在内部已经完成了softmax和log的合并计算。如果你真的很想看到概率也应该在推理阶段使用并且放在模型外面而不是嵌进模型内部with torch.no_grad(): logits model(x) probs torch.softmax(logits, dim1)把softmax放在模型外的好处是训练时候不会因为数值变换影响梯度换损失函数、做蒸馏、做自定义推理逻辑也更方便。4.4 从二维数据到图片数据最需要改的是输入维度很多人问我这个代码能不能直接用来跑图片分类能但要先想清楚一件事图片输入通常是[N, C, H, W]而Linear层只接受二维输入[N, D]。所以要么在图片进入MLP之前把它view成[N, C * H * W]要么直接换卷积层。比如一张单通道的28x28图片先展平成784维再接Linear(784, 16)就是这么个思路。展平这一步用x x.view(x.size(0), -1)就能做到。简单MLP最大的局限就是不保留空间结构所以后面真正做图像分类时大家都会去学卷积网络。但训练流程仍然是这一套逻辑。5. 我第一次跑分类器踩过的三个坑维度错位、loss不动、过拟合5.1 维度错位是最常见的报错来源size mismatch这个报错我敢说每个跑过PyTorch的人都被它折磨过。通常有三种原因。第一种输入数据维度不对。环形数据是[N, 2]如果你在某个环节多做了一个unsqueeze变成[N, 1, 2]前向就会炸。第二种标签维度不对。CrossEntropyLoss的target要求是[N]如果你不小心给了一个[N, 1]它会报维度错误或者说你传了one-hot损失函数直接不认。第三种最后一层输出维度与类别数不匹配。比如二分类任务但out_dim3模型不会报错但loss和argmax都会出现语义错误。排查时先打印一下logits.shape和batch_y.shape九成的问题都能一眼看出来。5.2 loss不动不是模型太傻是某个细节坏了在我刚开始跑分类器的时候最困惑的一件事是loss从第一步开始就停在0.69附近怎么训都不降。0.69这个数字很特别它约等于ln(2)也就是二分类等概率时的交叉熵。后来我定位到是标签给错了我把两类样本的标签都设成了0模型当然学不到任何区分信息。这个错误在代码里极难发现因为数据张量形状都对训练流程也不报错唯一症状就是loss不死不活。除了标签错误学习率也是个大嫌疑。lr1e-2在环形数据上收敛很快但如果换成1e-5训练30轮loss几乎不动换成1e-1loss可能直接发散到NaN。遇到loss不动我建议先按这个顺序排查标签是否正确数据是否归一化学习率是否在合理区间网络最后一个线性层输出维度是否正确。5.3 过拟合玩具数据也会发生只是不容易察觉环形数据样本量不大、维度低过拟合不明显。但如果你把网络隐藏层增大到128训练样本只有400跑个50轮就会发现训练准确率接近100%测试准确率反而开始波动。处理过拟合的手段优先级是增加训练数据、降低模型复杂度、加Dropout、加权重衰减、早停。对简单MLP来说最有效的其实不是正则化而是用一个独立的测试集。我在上面的代码里特意做了80/20划分就是为了让你能同时看到训练和测试两边的指标。如果只守着训练准确率沾沾自喜真实场景里会摔得很惨。6. 怎么知道这个简单网络真的学会了可视化、混淆矩阵和loss曲线6.1 决策边界是最直观的“体检报告”对二维数据来说把训练好的模型预测结果画成Grid就能看到模型学出来的决策边界。import matplotlib.pyplot as plt model.eval() with torch.no_grad(): x_min, x_max X[:, 0].min() - 0.3, X[:, 0].max() 0.3 y_min, y_max X[:, 1].min() - 0.3, X[:, 1].max() 0.3 xs torch.linspace(x_min, x_max, 200) ys torch.linspace(y_min, y_max, 200) grid_x, grid_y torch.meshgrid(xs, ys, indexingxy) grid torch.stack([grid_x.flatten(), grid_y.flatten()], dim1).to(device) logits model(grid) preds logits.argmax(dim1).reshape(grid_x.shape).cpu() plt.contourf(grid_x.numpy(), grid_y.numpy(), preds.numpy(), cmapRdBu, alpha0.6) plt.scatter(X[:split, 0].numpy(), X[:split, 1].numpy(), cy[:split].numpy(), edgecolorsk) plt.scatter(X[split:, 0].numpy(), X[split:, 1].numpy(), cy[split:].numpy(), markerx) plt.show()跑完你会看到一条环状边界把内圈和外圈分开。如果边界乱七八糟有效分类区域还很碎说明模型欠拟合或者训练不充分。如果边界很平滑且测试点基本都在对应颜色区域那才算真正学会了。6.2 只看准确率不够还要看错在哪准确率是单个数信息量有限。二分类里我更建议看一眼混淆矩阵哪些内圈样本被错判成了外圈哪些外圈样本被错判成了内圈。如果是靠近决策边界附近的样本错判那很正常如果错误样本分布在远离边界的位置那就要怀疑训练流程了。在简单代码里可以直接用sklearn.metrics的confusion_matrix也可以自己写一个2x2的表格统计。关键不是非要用某个库而是养成“把错误样本捞出来看一眼”的习惯。这个习惯在真实分类项目里能救你的命。6.3 loss曲线的正常与异常长相训练过程中把每个epoch的loss记下来画一条曲线你马上能看出问题正常loss平滑下降训练准确率同步上升。欠拟合loss一直偏高但下降速度慢最后趋于平缓。过拟合训练loss一直降测试loss先降后升。训练不稳定loss上下乱跳可能学习率太大也可能batch太小。画曲线不需要什么高级工具在训练循环里用一个list收集avg_loss最后用matplotlib画线就行。这一步比盯着终端输出判断强一百倍。6.4 玩具到真实下一步做什么当你在这个环上把整套流程跑通接下来最值得做的事是把同一套训练框架迁移到一个更真实的数据集上比如公开的手写数字或图片分类。你需要改的只是输入维度、输出维度、数据加载和预处理。模型结构、训练循环、评估逻辑基本不用动。我给自己的下一步建议是先别急着堆网络深度先用这个MLP跑通baseline记录一个准确率然后再引入数据预处理、调参、更复杂的结构每一次改动都对比之前的结果。这种对照式实验比一头扎进大模型代码里有效得多。最后再说一个我的日常习惯每次跑完这类玩具项目我都会把决策边界图存下来连同模型权重和训练曲线一起放在同一个文件夹里。后续调参的时候翻出来对比能直观看到改动带来的影响。学神经网络分类最重要的就是把每一次实验的“证据”留好这样每一步进步才说得清楚来源。
阅读完成 · 觉得有帮助?
咨询建站