
1. 为什么用PyTorch搭神经网络以及搭建前必须想清楚的事先说一个我自己的判断在深度学习框架里PyTorch可能不是最省心的那个但绝对是把“神经网络构造”这件事讲得最明白的那个。你不需要把数据切好、图建好、话都说完它才动手而是先按普通Python代码那样把计算流程写出来跑一遍就自动把计算图建好了。这个特性对新手非常友好对老手来说也是调试时最顺手的东西——哪里不对直接print中间张量一目了然。在真正开始写代码之前我想先把“构造神经网络”这个概念拆开。很多人一上来就搜“pytorch 神经网络构造”然后复制一个模型类就开跑但没过多久就卡住了不知道损失为什么降不下去不知道怎么改结构甚至不知道模型输出了什么。所以我打算不急着甩代码先把你必须想明白的几个问题摆出来。首先你准备用神经网络解决什么任务是图像分类、文本分类还是回归预测某个数值不同任务决定了输出层的设计和损失函数的选择。图像分类用交叉熵回归用均方误差二分类用BCEWithLogitsLoss这些都是从一开始就要定下来的。其次你的数据长什么样通道数是多少是序列还是表格这决定了你第一层应该用卷积、RNN还是直接Linear接展平。最后你的硬件条件如何有没有GPU显存多大这决定了batch size、模型层数和输入尺寸的上限。这三个问题想清楚了后面构造网络时就不会到处乱试。提示PyTorch里最核心的两个模块是torch.nn和torch.Tensor。前者提供了一切层的封装后者是你手里所有数据的载体。搞懂这两个基本就搞懂了60%的PyTorch。2. 神经网络构造的核心nn.Module与计算图的构建2.1 nn.Module到底在干什么很多人第一次看PyTorch代码都会觉得class Net(nn.Module)这个写法很奇怪。它其实做的事情就三件第一帮你把所有子模块登记到self下面。你在__init__里写的self.fc1 nn.Linear(784, 256)会被nn.Module的__setattr__方法自动识别成一个子模块并且后续在调用model.to(device)、model.eval()、model.parameters()时都会自动递归遍历到这些子模块。这个特性极其关键意味着你不用手动管理每一层的参数框架全帮你管好了。第二它在__call__里接上了前向传播。你定义forward方法之后模型实例model(x)会先经过nn.Module.__call__的预处理比如把参数切到当前设备、切到训练模式再调用你的forward。所以不要直接调model.forward(x)那样会跳过很多必要步骤。第三它提供了parameters()、train()、eval()、state_dict()这些工具方法。这些方法几乎覆盖了训练、保存、推理、迁移学习的所有场景。我自己在debug时有个习惯会先print(model)看一眼模型结构再print(model.state_dict().keys())看一眼参数名。这能快速确认各层的命名和维度是否符合预期比盯着forward函数干想要高效得多。2.2 构造一个最简单的全连接网络以MNIST手写数字分类为例输入是28x28的灰度图展开成784维向量输出是10个类别。一个最基础的三层全连接网络写法如下import torch import torch.nn as nn class MLP(nn.Module): def __init__(self, input_dim784, hidden_dim256, num_classes10): super().__init__() self.fc1 nn.Linear(input_dim, hidden_dim) self.relu nn.ReLU() self.fc2 nn.Linear(hidden_dim, hidden_dim) self.fc3 nn.Linear(hidden_dim, num_classes) def forward(self, x): x x.view(x.size(0), -1) # 展平 x self.relu(self.fc1(x)) x self.relu(self.fc2(x)) x self.fc3(x) return x这个结构看起来很短但信息量非常大。我来说几个细节第一input_dim、hidden_dim、num_classes不要写死在代码里要作为构造参数传进去。因为后面你很可能想换一个数据集或者调整隐藏层宽度写死之后每次都要改代码很麻烦。第二forward里第一步把xview成(batch_size, -1)。这里-1是自动推断的意思表示除了第一维batch之外的其他所有维度都展平。很多人一开始在这里写错维度比如直接x.view(-1, 784)如果输入已经是四维张量这样的view会报错或者产生错乱的数据。第三注意输出层fc3没有接激活函数。这是因为后面我们用CrossEntropyLoss时它内部自带了Softmax计算。如果在输出层先手动Softmax再算交叉熵数值上更容易出现不稳定而且梯度反向传播的写法上也绕了一圈。PyTorch官方推荐的做法是模型的最后一层输出原始logits损失函数里用CrossEntropyLoss处理。2.3 激活函数不能随便选激活函数是神经网络的“非线性来源”。如果没有激活函数不管堆多少层Linear整体上还是一个线性变换表达能力极其有限。而激活函数的选型直接影响训练的稳定性和收敛速度。ReLU是当前最主流的默认选择计算简单梯度不会饱和在深层网络中表现稳定。但它有个问题叫“神经元死亡”——输入为负的那部分梯度恒为0如果某个神经元的输入长期为负它可能再也无法更新。所以我现在更多会在中等深度的网络里使用GELU或SiLU也叫Swish它们的梯度在负半轴不是完全为0训练会更稳一点。当然GELU计算代价略高在小模型上差别不大。还有一个容易被忽略的点最后的输出层之前要不要Dropout我的经验是对于数据量比较小的场景在最后一个隐藏层之后加一个nn.Dropout(p0.3)是性价比很高的正则化手段。它会在训练时随机把30%的神经元输出置零迫使模型不依赖单一路径泛化能力会好不少。注意推理时要model.eval()切到评估模式Dropout才会自动关闭否则预测结果会带随机性这个坑几乎每个人都踩过。3. 实操从零构造一个可训练的神经网络3.1 数据准备先把手里的数据整理成Tensor构造完网络结构之后真正的工作才刚刚开始。要让网络真正“学到东西”需要数据、损失函数、优化器、训练循环这四件套。我这里用一个模拟数据集来演示这样你哪怕没有现成数据也能跟着跑通。from sklearn.datasets import make_classification from sklearn.model_selection import train_test_split import torch from torch.utils.data import DataLoader, TensorDataset # 生成一个20维特征、2类别的模拟分类数据 X, y make_classification(n_samples5000, n_features20, n_informative15, n_redundant5, random_state42) X_train, X_val, y_train, y_val train_test_split(X, y, test_size0.2, random_state42) train_dataset TensorDataset( torch.tensor(X_train, dtypetorch.float32), torch.tensor(y_train, dtypetorch.long) ) val_dataset TensorDataset( torch.tensor(X_val, dtypetorch.float32), torch.tensor(y_val, dtypetorch.long) ) train_loader DataLoader(train_dataset, batch_size64, shuffleTrue) val_loader DataLoader(val_dataset, batch_size64, shuffleFalse)这里有两个容易出错的地方。第一个是dtype特征一定要转成float32标签如果是分类任务就转成long也就是int64。PyTorch里的默认浮点类型是torch.float32如果你从numpy拿到的数组是float64不转换的话模型参数是float32输入是float64直接报类型不匹配的错。这个错误信息很隐蔽它不是维度错误而是说“expected scalar type Float but found Double”新手很容易看懵。第二个是DataLoader的shuffle参数。训练集必须加shuffleTrue否则每个epoch里batch的构成都一样模型在一个固定顺序上反复迭代收敛效果会变差。验证集和测试集不需要shuffle因为只是评估跟顺序无关。提示如果机器内存够可以把num_workers调成4或8让数据加载过程走多进程能明显减少每个batch之间的等待时间。但如果数据本身很小num_workers0反而更稳省去进程调度的开销。3.2 模型结构再设计针对表格数据调整宽度和深度有了模拟数据我们可以把上一节的MLP结构调整成适用于20维输入的形式。因为20维特征不算高维过宽的隐藏层反而会过拟合。我的习惯是输入维度不高时隐藏层宽度从输入维度附近开始逐层减半最后一层隐藏层宽度不要低于类别数的2倍。针对这个数据我会把隐藏层宽度设为64和32class Classifier(nn.Module): def __init__(self, input_dim20, num_classes2): super().__init__() self.fc1 nn.Linear(input_dim, 64) self.bn1 nn.BatchNorm1d(64) self.fc2 nn.Linear(64, 32) self.bn2 nn.BatchNorm1d(32) self.fc3 nn.Linear(32, num_classes) self.dropout nn.Dropout(0.3) self.gelu nn.GELU() def forward(self, x): x self.gelu(self.bn1(self.fc1(x))) x self.dropout(x) x self.gelu(self.bn2(self.fc2(x))) x self.dropout(x) x self.fc3(x) return x这里我引入了两个新东西BatchNorm1d和Dropout。BatchNorm的作用是让每一层的输入分布保持稳定解决内部协变量偏移的问题。直观理解就是它把当前层的输出强行拉回到均值为0、方差为1的分布附近然后再通过两个可学习参数scale和shift去放缩。这样做的好处非常明显——可以用更大的学习率收敛更快而且对参数初始化的敏感度大大降低。需要注意BatchNorm1d的输入格式是(batch, features)卷积网络通常用BatchNorm2d输入是(batch, channels, height, width)。你要是把两者弄混了错误信息会提示4D张量被传给了期待2D输入的层排查起来很快。3.3 训练循环一步步把网络参数调好构造完模型现在开始训练。这里不需要写那种高大上的Trainer类先写一个最朴素、你能完全掌控的训练循环model Classifier() optimizer torch.optim.Adam(model.parameters(), lr1e-3) criterion nn.CrossEntropyLoss() epochs 30 best_val_acc 0.0 for epoch in range(epochs): model.train() total_loss 0.0 correct 0 total 0 for xb, yb in train_loader: optimizer.zero_grad() logits model(xb) loss criterion(logits, yb) loss.backward() optimizer.step() total_loss loss.item() * xb.size(0) pred logits.argmax(dim1) correct (pred yb).sum().item() total xb.size(0) train_loss total_loss / total train_acc correct / total # 验证 model.eval() val_correct 0 val_total 0 with torch.no_grad(): for xb, yb in val_loader: logits model(xb) pred logits.argmax(dim1) val_correct (pred yb).sum().item() val_total yb.size(0) val_acc val_correct / val_total print(fEpoch {epoch1:03d} | Loss {train_loss:.4f} | Train Acc {train_acc:.3f} | Val Acc {val_acc:.3f}) if val_acc best_val_acc: best_val_acc val_acc torch.save(model.state_dict(), best_model.pt)这个循环里藏着几个非常容易出错的细节我一个个说。optimizer.zero_grad()必须在每个batch开始前调用。PyTorch的反向传播是累积梯度的也就是说每次loss.backward()会把梯度累加到param.grad上。如果不清零上一个batch的梯度会跟当前batch的梯度加在一起参数更新方向完全错乱。这是新手最容易犯的错误之一而且不会报错只会让你看到Loss震荡不定非常诡异。model.train()和model.eval()的切换。前面说过Dropout和BatchNorm在训练和推理时的行为是不同的。训练模式下Dropout随机失活神经元BatchNorm用当前batch的统计数据评估模式下Dropout全通过BatchNorm用训练阶段累积的滑动平均。你如果在验证时忘了切到model.eval()BatchNorm会继续用当前batch的统计量小batch下验证结果会非常不稳定而且Bug特别难定位。with torch.no_grad()包住验证过程。这行代码的作用是告诉autograd引擎下面的计算不需要记录计算图。光这一行就能省下大量显存和计算时间。验证阶段既不需要反向传播自然就不需要保存中间激活值。3.4 把训练搬到GPU上如果数据量和模型规模上去之后CPU训练会慢到怀疑人生。PyTorch做GPU迁移其实非常简单就三行代码device torch.device(cuda if torch.cuda.is_available() else cpu) model model.to(device) # 每个batch数据也要搬到同样设备 xb, yb xb.to(device), yb.to(device)这里最常见的坑是设备不匹配。你的模型在GPU上输入还在CPU上跑model(xb)时PyTorch会直接报错说“Expected all tensors to be on the same device”。我的建议是只要机器有GPU就把所有数据和模型一次性全搬到GPU上不要存侥幸心理。还有一个小细节nn.CrossEntropyLoss()本身没有可学习参数不需要to(device)。它内部在计算时会把标签自动匹配到跟输入一样的设备所以不用管。实际项目中我还会额外打印设备信息和一个简单的计时器确认当前确实在GPU上运行。因为有时候明明装了PyTorch的CPU版本代码里不报错、只是跑得慢容易让人误判。4. 训练过程中的常见问题与排错记录4.1 Loss变成NaN了怎么办这是初学者最常遇到的问题。我遇到过好几种情况这里按概率排序第一学习率太大。Adam默认学习率是1e-3大部分场景下没问题但网络比较小、数据比较尖峰时学习率大会让参数直接飞出去Loss变成NaN。我一般先把学习率降到1e-4试试如果还NaN再往下调。第二数据里有NaN值。尤其是做表格数据时原始数据中可能有缺失值被直接填充成了NaN或者做归一化时出现除零。一个最笨但有效的排查方法是在训练循环里每个epoch检查一下x.min()和x.max()如果出现NaN基本上就是数据源头的问题。第三损失函数使用不当。比如在回归任务里用了CrossEntropyLoss且标签值不是整数或者标签是one-hot编码但模型输出是类别索引两者不匹配时内部取log的地方会算出NaN。要仔细核对损失函数的输入格式预期。第四梯度爆炸。这种情况在RNN或者很深网络里更常见。解决方案是梯度裁剪torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0)在optimizer.step()之前调用即可。梯度裁剪的原理是把所有参数的梯度按L2范数缩放如果整体范数超过阈值就等比例缩小防止参数更新步长过大。4.2 训练集准确率高但验证集准确率低这就是典型的过拟合。现象是自己背住了训练样本但不会泛化。我的排查顺序是先看数据量是不是太少了然后看模型层数是不是太深、宽度是不是太宽最后看正则化手段是不是用得不够。针对这个情况有四个立竿见影的调整手段加大Dropout比例从0.3调到0.5。加L2正则PyTorch里在优化器参数里直接写weight_decay1e-4。减少模型宽度或层数让模型容量跟数据量匹配。做数据增强。表格数据可以做特征的随机噪声扰动或mixup图像数据可以随机裁剪、翻转、色彩抖动。我平时会在几个值之间快速试三种组合每种跑20个epoch用验证集准确率做筛选。这种方法虽然粗暴但在实际项目里确实是效率最高的一招。4.3 维度不匹配错误速查PyTorch的维度错误信息通常写得很明确比如“mat1 and mat2 shapes cannot be multiplied (64x256 and 512x10)”。这种错误几乎都出在nn.Linear层的输入维度跟上一层输出维度不一致。实战中我的排查顺序是打开一个Python解释器直接构造一个模拟输入比如x torch.randn(2, 3, 28, 28)传给模型前向过程看哪个层报错。在forward的每一行后面手动printx.shape快速定位是哪一层之后shape发生了变化。确认输入到nn.Linear之前的展平操作是否写对了。全连接层要求输入是(batch, features)如果直接从卷积层出来一定是(batch, channels, h, w)必须view展平。这个方法比盯着代码看十分钟有用得多。你不需要记住每个层对维度的精确要求多用print调试自然就有感觉了。4.4 BatchNorm在batch size比较小时的不稳定问题BatchNorm在batch size较小比如4或者8时每个batch的均值和方差波动很大训练和验证表现都会飘。如果你的场景必须用小batch训练我建议直接把BatchNorm换掉改成nn.LayerNorm或nn.GroupNorm。LayerNorm在NLP任务里是标配它不依赖batch维度对batch size不敏感。对于表格数据的MLPLayerNorm的效果也相当不错。我实际遇到过一个场景模型在batch size64时验证准确率正常改成batch size8之后准确率掉了5%。排查半天发现就是BatchNorm在作怪换成LayerNorm后问题立刻消失。5. 网络设计中的几个关键决策从实际项目中学到的经验5.1 层数和宽度的选择原则关于神经网络该有几层、每层多宽其实没有一个万能公式但根据我的经验可以总结出几条可操作的原则。第一从简单到复杂。先做一个一层的Logistic回归或者一个单隐藏层MLP把这个基线的准确率跑出来再往上加层。如果单隐藏层已经能做到80%你直接上一个五层网络可能也只能做到82%但过拟合风险和训练时间会增加好几倍。第二隐藏层宽度有一个“甜点区”。太窄小于输入维度的1/4时模型学不动太宽大于输入维度4倍时容易过拟合。我常用的是64、128、256这几个档位除非特别大的数据集一般不会超过512。第三深度和宽度的关系。一般来说深度增加带来的表达能力提升比宽度增加更有效。但深度增加也意味着梯度传播路径变长训练难度上升。所以我的策略是优先加深度但每加一层都要配套加BatchNorm或者残差连接否则训练容易炸掉。5.2 初始化不要忽视这个隐形的坑很多人直接用默认初始化训练也成功了于是就不再管了。但遇到多层网络或者特殊激活函数时初始化方式直接决定你是否能收敛。PyTorch默认的nn.Linear会用Kaiming均匀初始化配合ReLU类激活函数是合理的。但如果你用了Tanh或者Sigmoid最好显式换成Xavier初始化def init_weights(m): if isinstance(m, nn.Linear): nn.init.xavier_uniform_(m.weight) nn.init.zeros_(m.bias) model.apply(init_weights)model.apply会递归遍历所有子模块对满足条件的层执行初始化函数。把这段代码放在模型构造完之后调用即可。我的建议是即使你用的是ReLU也有意识地检查一下初始化方式尤其是当你发现模型Loss下降非常慢、几乎不收敛时先检查初始化。5.3 优化器与学习率退火从Adam入手永远是安全的。它自带自适应学习率对参数的尺度不敏感省心。但Adam有一个潜在问题训练后期会因为学习率不衰减导致在局部极小值附近震荡最终精度可能不如SGD配合学习率退火。所以我的策略通常是前80%的训练epoch用Adam学习率固定后20%换成SGD或者继续用Adam但把学习率减到原来的0.1倍也可以直接用PyTorch的torch.optim.lr_scheduler.CosineAnnealingLR让学习率从初始值余弦下降到接近0。余弦退火的效果在线性模型和小型MLP上非常明显基本不需要手动调参。如果你只是跑个demo我建议直接加一行scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_maxepochs)然后在每个epoch结束时调用scheduler.step()。注意配合BatchNorm时学习率退火的效果会更平滑因为BatchNorm本身对学习率的变化有一定鲁棒性。5.4 损失函数里的小门道我特别想说一下CrossEntropyLoss。它看起来只是一个损失函数但它内部做了三件事Softmax、取对数、计算负对数似然。很多人不知道它还有一个参数叫label_smoothing设置为0.1可以让模型对“正确答案”不要过于自信很多分类任务里加了它之后验证集准确率会有0.5到1个百分点的提升泛化性能也更好。对于回归任务MSELoss是最常用的但它对离群值非常敏感。如果你的数据里有异常点我记得可以考虑SmoothL1Loss也叫Huber Loss它在误差小时用L2误差大时用L1对离群值的容忍度更高。这两个选择在实际项目里往往能决定训练是否稳定。6. 一个完整的小案例把上面的思路串起来到这里我想用一个小项目把所有内容串一遍。场景是这样的假设你拿到一个结构化数据文件20维特征、2分类共5000条样本。你要构造一个神经网络完成分类并且要在验证集上达到90%以上的准确率。因为所有样本都是模拟生成的正常情况下90%不是难事重点是你把流程走通。完整代码如下这段代码可以直接复制跑import torch import torch.nn as nn from torch.utils.data import DataLoader, TensorDataset from sklearn.datasets import make_classification from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler # 数据准备 X, y make_classification( n_samples5000, n_features20, n_informative15, n_redundant5, random_state42, class_sep1.0 ) X_train, X_val, y_train, y_val train_test_split( X, y, test_size0.2, stratifyy, random_state42 ) scaler StandardScaler() X_train scaler.fit_transform(X_train) X_val scaler.transform(X_val) train_ds TensorDataset( torch.tensor(X_train, dtypetorch.float32), torch.tensor(y_train, dtypetorch.long) ) val_ds TensorDataset( torch.tensor(X_val, dtypetorch.float32), torch.tensor(y_val, dtypetorch.long) ) train_loader DataLoader(train_ds, batch_size64, shuffleTrue) val_loader DataLoader(val_ds, batch_size64, shuffleFalse) # 模型定义 class MLPClassifier(nn.Module): def __init__(self, input_dim20, hidden_dim64, num_classes2): super().__init__() self.net nn.Sequential( nn.Linear(input_dim, hidden_dim), nn.BatchNorm1d(hidden_dim), nn.GELU(), nn.Dropout(0.3), nn.Linear(hidden_dim, hidden_dim // 2), nn.BatchNorm1d(hidden_dim // 2), nn.GELU(), nn.Dropout(0.3), nn.Linear(hidden_dim // 2, num_classes) ) def forward(self, x): return self.net(x) # 训练配置 device torch.device(cuda if torch.cuda.is_available() else cpu) model MLPClassifier().to(device) criterion nn.CrossEntropyLoss(label_smoothing0.05) optimizer torch.optim.Adam(model.parameters(), lr1e-3) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max30) epochs 30 best_acc 0.0 for epoch in range(epochs): model.train() train_loss 0.0 correct 0 total 0 for xb, yb in train_loader: xb, yb xb.to(device), yb.to(device) optimizer.zero_grad() logits model(xb) loss criterion(logits, yb) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm2.0) optimizer.step() train_loss loss.item() * xb.size(0) pred logits.argmax(dim1) correct (pred yb).sum().item() total xb.size(0) scheduler.step() model.eval() val_correct 0 val_total 0 with torch.no_grad(): for xb, yb in val_loader: xb, yb xb.to(device), yb.to(device) logits model(xb) pred logits.argmax(dim1) val_correct (pred yb).sum().item() val_total yb.size(0) train_acc correct / total val_acc val_correct / val_total if val_acc best_acc: best_acc val_acc torch.save(model.state_dict(), best_model.pt) print(fEpoch {epoch1:03d} | Train Loss {train_loss/total:.4f} | fTrain Acc {train_acc:.4f} | Val Acc {val_acc:.4f})这段代码里有几个值得注意的细节变化第一stratifyy保证训练/验证集的类别比例一致避免因为随机切分导致验证集里某一类太少而出现评估偏差。第二StandardScaler对特征做标准化。这一步在表格数据里非常关键。神经网络对输入特征的尺度比较敏感如果某个特征的范围是0到1另一个是0到10000网络一开始很容易被大尺度特征带着走。标准化之后所有特征都变成均值为0、方差为1的分布训练会稳定得多。注意fit_transform只在训练集上调用验证集只用transform因为模型不应该知道验证集任何统计信息。第三label_smoothing0.05让分类结果更平滑对模拟数据来说效果大概率是正向的。如果你在真实项目中不放心也可以删掉这个参数。第四梯度裁剪给了一个max_norm2.0的上限。虽然这个简单模型几乎不会梯度爆炸但加上它不会有什么损失遇到训练不稳定时它已经在默默保护你的参数更新过程了。7. 调参实战一组让我印象深刻的对比实验我做过的对比实验越多越觉得参数之间的交互比单个参数的绝对值更重要。这里分享三组我自己跑过的对比结果供你参考。第一组是关于隐藏层宽度的。同样是两层隐藏层网络我分别把隐藏宽度设为32、64、128跑同样的30个epoch。32宽的网络验证准确率大约在85%上下64宽的在90%128宽的也只到91%但训练时间翻了将近一倍。这说明在数据量固定的情况下宽度不是越大越好存在一个性价比最高的区间。第二组是关于激活函数的。同样的网络ReLU和GELU的最终验证准确率差距大概只有一个百分点但GELU的训练过程更平稳Loss曲线几乎没有那种突然跳一下再恢复的抖动。如果你的数据里噪声比较多GELU会更稳一点。第三组是关于学习率的。Adam配合1e-3的学习率在大多数场景下能正常工作但你把学习率调成1e-2时模型几乎必然在第一步就把Loss冲到NAhalf然后再也不回来。而1e-4时训练过程极其平稳但收敛速度慢30个epoch远远不够。所以我的习惯是lrs从1e-3起步观察前3个epoch的Loss下降情况再决定是调大还是调小。我特别想强调“观察前3个epoch”这个习惯。神经网络训练不像传统软件写函数不会一次跑完就出结果。你需要把训练过程当成一个动态系统来观察前几个epoch的Loss是否在明显下降训练集和验证集的差距是在变大还是变小准确率是在稳步上升还是剧烈震荡这些现象比一个孤立的最终数字更有诊断价值。8. 常见问题排查速查表我在一栏里汇总一下平时答疑时遇到频率最高的问题直接给结论。现象可能原因解决方案Loss变成NaN学习率过大、数据含NaN、损失函数输入不匹配降学习率到1e-4检查数据核对标签类型验证集准确率远低于训练集过拟合增加Dropout、加L2正则、缩小模型、加数据增强维度报错Linear输入维度和上一层输出维度不一致打印每个层输出的shape检查view展平训练与验证结果不稳定忘记切换model.eval()验证前调用model.eval()训练前调用model.train()GPU利用率很低batch太小、数据加载耗时增大batch、调num_workers准确率停滞不动学习率过小、特征未标准化、初始化有问题调学习率、加标准化、检查初始化方式两个epoch之间Loss差别巨大忘记optimizer.zero_grad()在每个batch开始前清零梯度模型保存后无法加载保存的是model而不是state_dict统一用torch.save(model.state_dict(), path)加载时用model.load_state_dict(torch.load(path))这张表是我自己踩过坑之后总结出来的每个条目都对应过至少一次真实的debug经历。如果你训练过程觉得很奇怪先把这张表过一遍。注意如果保存模型时用的是torch.save(model, path)那会连同模型结构一起序列化。这种方式依赖于模型类的定义路径项目结构一变就加载失败。把所有模型保存都统一成state_dict是更稳妥的做法。9. 我再多说几句关于神经网络构造的一些个人体会在模型代码层面神经网络构造其实是一件非常“模块化”的事情。层与层之间的差异就是输入输出维度和计算方式PyTorch把大部分细节都封装好了你真正要操心的是数据怎么流转、每个模块怎么拼接、训练过程怎么控制。所以我不太建议一开始就追求那些花哨的模型结构先把一个简单的MLP训好把数据清洗、标准化、batch组织、训练循环、评估逻辑全跑通后面不管学CNN还是Transformer套路都是差不多的。我个人在实际操作中还有一个习惯每次构造新网络时先用一个小数据集比如100条样本跑通整个流程确认Loss能下降、维度不报错再上全量数据。这个习惯救了我很多次。因为在大数据集上如果维度写错了跑了几分钟才报错等于白白浪费时间。小数据集的训练时间很短几秒钟就能看出来有没有问题是性价比最高的验证手段。另外我想多说一下关于打印观察这件事。我几乎在每个项目里都会在训练循环里加一个简单的时间统计比如计算每个epoch的平均耗时并顺手打印模型当前在哪个设备上运行。这些信息看起来不起眼但在你对比GPU和CPU的训练速度、判断数据加载是否成为瓶颈时能省掉很多猜测。最后如果你打算把训练好的模型用到实际环境中记住推理时要model.eval()并且把输入数据做跟训练时完全一样的预处理比如一样的标准化参数、一样的图像Resize方式。这一步漏掉的后果往往是线上表现比离线测试差很多而且你根本不知道差在哪——因为预处理的差异压根不会写在错误信息里。神经网络构造本身不玄乎框架只是工具核心还是数据、结构、训练策略三者之间的匹配。你把这几条主线捋顺了剩下的就是多跑实验、多观察曲线的耐心活了。