1. 第二天到底该学什么:先把KPI关掉如果你和我一样,第一天装完环境、跑通一个手写数字识别的Demo、看着loss从2.3掉到0.1就觉得自己已经“会AI”了,那第二天大概率会陷入一种迷惑:接下来该干嘛?继续调参把准确率从98%刷到99%?还是赶紧去看Transformer论文?我建议你先别急,因为第二天真正该解决的,是搞清楚训练过程里到底发生了什么。我第一天跑通Demo时的状态,基本就是“按回车,看数字,觉得神奇”。模型内部做了什么、数据是怎么喂进去的、为什么换一个batch就会出不一样的结果,全都没概念。第二天我不想再这样蒙着眼往前走,于是给自己定了一个目标:把训练过程拆到一眼能看到底。说白了,就是给训练装一块“监控仪表盘”,让每个部件的状态都清晰可见。这也是我认为AI模型学习day2最合理的定位:不是追求精度KPI,而是把训练的三件事——观察信号、隔离信号、压缩信号——真正走一遍。先解释一下这三个词:观察信号:模型到底在看什么数据?tensor的形状对不对、数值范围合不合理、标签有没有错位。隔离信号:是模型的问题、数据的问题,还是优化过程的问题?出了状况能快速定位到具体环节。压缩信号:把原始输入逐层抽象成高层的特征表达,也就是训练里最核心的“学习”过程。很多教程会在这时直接扔给你一堆数学公式,然后是各种网络结构对比,让人越看越焦虑。我的建议是:第二天别碰复杂网络,也不要纠结“为什么用Adam而不用SGD”这种暂时回答不了的问题。你要做的是把一条极简训练链路跑通,并且能亲手改每一个环节,看到改完之后的直接后果。具体来说,我给自己列了一份day2清单,一共五条:跑通任意一个小模型,我从LeNet手写数字的Demo改成了两个卷积层的极简结构。亲手打印训练过程中每一层的输入输出shape,直到不需要再猜“中间发生了什么”。把batch size从64改成128,观察loss曲线和训练时长的变化。人为把验证集数据混进训练集,观察过拟合现象有多严重。每次实验固定随机种子,确保同一个代码跑两次结果完全一致。做完这五件事,你对模型训练的认识会比第一天的“Demo跑通”深一个层次。接下来我详细说day2的实际内容和踩坑过程。2. 训练过程的四大部件:数据管线、模型结构、损失函数、优化器2.1 数据管线:shape永远是你最先该确认的事day2的第一个坑,几乎都出在数据上。我当时用的是一套公开的图片分类数据,类别有十种。第一天跑Demo时框架已经写好了DataLoader,我没怎么细看。第二天我想自己写一个数据读取,结果第一行就报错:Expected 3D tensor, got 2D。原因很简单:transform里面少了一步ToTensor(),图片被读成了HWC格式的numpy数组,而模型期望的是CHW格式的tensor。这个错误很基础,但它让我意识到一个关键点——数据管线的每一个环节都在改变数据的形状,你必须能随时打印出每个环节之后的shape。之后我写数据管线时养成了一个习惯:在Dataset的__getitem__里、在DataLoader取完一个batch之后、在模型forward入口处,各加一行打印。别嫌啰嗦,这一步能省掉你后面几小时的debug时间。我后来把这个习惯保留到了所有项目里,只不过把print换成了logger。再说一个容易被忽略的点:数据标签的“对齐”问题。很多时候你会听到“数据泄露”“标签错位”这类词,听起来很高大上,其实就是一个意思——模型看到的输入和标签不是一一配对的。我做过一个实验:把训练集的前100张图片的标签整体往后挪一位,结果loss在前期下降一点之后就开始震荡,准确率卡在20%左右上不去。这种问题程序不会报错,因为它所有tensor形状都对得上,只是语义上错了。所以第二天一定要自己手动喂一个batch,把图片和标签逐一打印出来看一眼,相信我,那一眼比跑十轮epoch都有用。2.2 模型结构:从input到output的shape流转模型结构是day2的第二个观察点。我不建议第二天就去照着论文堆什么残差、注意力模块,除非你只是想感受一下框架的灵活度。更值得做的是:选一个极简网络,然后自己把前向传播的每一步展开写一遍。我当时写了一个两卷积加两全连接的极简结构,大概长这样:import torch.nn as nn class TinyNet(nn.Module): def __init__(self, in_channels=1, num_classes=10): super().__init__() self.features = nn.Sequential( nn.Conv2d(in_channels, 16, kernel_size=3, padding=1), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(16, 32, kernel_size=3, padding=1), nn.ReLU(), nn.MaxPool2d(2) ) self.classifier = nn.Sequential( nn.Flatten(), nn.Linear(32 * 7 * 7, 64), nn.ReLU(), nn.Linear(64, num_classes) ) def forward(self, x): x = self.features(x) x = self.classifier(x) return x这个网络结构朴素到不能再朴素,但它有一个好处:你很容易心算每一层的输出shape。输入如果是[64, 1, 28, 28],经过第一个卷积后是[64, 16, 28, 28],池化后变成[64, 16, 14, 14],第二个卷积后是[64, 32, 14, 14],再池化变成[64, 32, 7, 7],Flatten之后是[64, 32 * 7 * 7],也就是[64, 1568],最后经过全连接得到[64, 10]。中间的Flatten是一个很容易写错的地方。第一次我把32 * 7 * 7写成了32 * 14 * 14,前向传播直接报维度不匹配。这个报错虽然明显,但它能逼着你去把shape理一遍。如果你用的图片尺寸不是28x28,那这个计算过程就更要小心,因为你不能靠背代码,只能靠手动推导。2.3 损失函数与优化器:先别急着换day2最容易犯的一个错误,就是在训练不理想时立刻去换优化器或损失函数。我当时就干过这事:看起来loss下降太慢,就把Adam换成了SGD,结果更慢了,然后又想把学习率调大,结果loss直接飞了。后来我才明白一个道理:训练不理想,优先检查数据和代码,而不是先怀疑优化器或者损失函数选错了。在绝大多数入门场景里,交叉熵损失加上Adam优化器已经足够应对常见任务了。你换优化器产生的那点变化,通常远不如数据质量对结果的影响大。不过损失函数有一个值得理解的要点:它决定了模型优化的方向和“好坏”的定义。分类任务用交叉熵,本质上是让模型输出的概率分布越来越接近真实标签的one-hot分布。回归任务用MSE,本质上是让预测值和目标值的距离越来越小。你不需要在第二天推导它们的数学细节,但至少要知道“我在用什么损失、它在衡量什么差距”。优化器就更简单了:它的作用是决定权重往哪个方向走、走多大步。SGD是最朴素的“顺着梯度走”,Adam相当于在此基础上自动调整每一步的步长。第二天你只需要记住一点:学习率过大,loss会震荡甚至爆炸;学习率过小,loss下降缓慢得像蜗牛。可以像做实验一样,把学习率从0.1往下试,用0.01、0.001、0.0001各跑几个epoch,看loss曲线的斜率差异。这张对比表会非常直观:学习率前20个batch的loss表现结论0.1从2.3跳到87,直接NaN过大,梯度爆炸0.01缓慢下降,但很粗糙偏大,曲线抖动明显0.001平稳下降,曲线光滑训练可用,推荐0.0001下降幅度极小,几乎看不出变化偏小,训练会非常慢2.4 四大部件的翻车现场:一张表讲清楚为了在day2少走弯路,我把后续几天遇到的四大部件问题整理成了一张排查表。每次训练出问题,我先按这张表定位,再动手改:部件常见症状排查入口数据管线shape不匹配、标签错位、loss不降打印batch的shape和标签,可视化几张图片模型结构forward报错维度不对、输出全是同一个值打印每一层输入输出shape,检查是否有Flatten或reshape错误损失函数loss恒为常数、分类结果全偏向某一类检查标签范围是否正确,样本类别是否平衡优化器loss震荡、训练慢、权重不更新看梯度范数、降低学习率、固定种子复现这个表我一直用到后面的实战项目里,每次遇到奇奇怪怪的错误,先对照它定位到具体部件,再去深挖,效率高很多。day2的学习,本质就是在建立这张排查表的过程。3. 搭建第一个训练脚本的不动点骨架3.1 为什么“模板化”反而更好很多人觉得深度学习项目要自己从头写才厉害,其实恰恰相反。成熟的做法是:训练脚本的骨架保持稳定,每天换的只是模型和数据部分。骨架稳定,意味着你每次跑实验的环境是一致的,出了问题也容易对比。如果你每天从零重写训练循环,那每次出bug都在改不同的地方,很难积累经验。我的day2训练循环骨架大概这样:import torch import torch.nn as nn from torch.utils.data import DataLoader from tqdm import tqdm import random import numpy as np def set_seed(seed=42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) def train_one_epoch(model, dataloader, optimizer, criterion, device): model.train() total_loss = 0.0 correct = 0 total = 0 for inputs, labels in tqdm(dataloader): inputs, labels = inputs.to(device), labels.to(device) optimizer.zero_grad() outputs = model(inputs) loss = criterion(outputs, labels) loss.backward() optimizer.step() total_loss += loss.item() * inputs.size(0) preds = outputs.argmax(dim=1) correct += (preds == labels).sum().item() total += labels.size(0) avg_loss = total_loss / total accuracy = correct / total return avg_loss, accuracy def evaluate_model(model, dataloader, criterion, device): model.eval() total_loss = 0.0 correct = 0 total = 0 with torch.no_grad(): for inputs, labels in dataloader: inputs, labels = inputs.to(device), labels.to(device) outputs = model(inputs) loss = criterion(outputs, labels) total_loss += loss.item() * inputs.size(0) preds = outputs.argmax(dim=1) correct += (preds == labels).sum().item() total += labels.size(0) avg_loss = total_loss / total accuracy = correct / total return avg_loss, accuracy这段代码没什么神奇之处,但它把训练和评估分成了两个清晰的函数,而且在每个epoch都能拿到平均loss和准确率。我把set_seed(42)放在训练开始前调用,保证每次跑同一个实验都能得到几乎一样的结果。不要小看这一步,它是后续做任何对比实验的前提——如果你每次跑的随机状态都不一样,那两轮实验结果之间的差异到底是来自你的改动还是来自随机性,你根本分不清。3.2 数据集拆分:验证集不是测试集的替代品很多人第一天会在全部数据上训练,这没问题,反正只是跑通Demo。但到第二天,你必须建立“训练集、验证集、测试集”三份数据的概念。简单说:训练集:用来更新模型参数。验证集:用来观察模型在没见过的数据上的表现,以及调超参数。测试集:最后评估一次模型水平,尽量只在结束时碰它。我第二天用到的数据量不大,直接用了random_split:from torch.utils.data import random_split train_size = int(0.8 * len(full_dataset)) val_size = int(0.1 * len(full_dataset)) test_size = len(full_dataset) - train_size - val_size train_dataset, val_dataset, test_dataset = random_split( full_dataset, [train_size, val_size, test_size] )这个步骤看起来只是切分数据,但它暗含了一个很深的坑:如果你用全量数据训练,只是在同一批数据上看loss下降,那你几乎无法判断模型是“学到了规律”还是“背下了答案”。只有把一部分数据留出来,让模型在训练时永远看不到它,你才能通过验证集上的表现来判断泛化能力。我栽过跟头的那次是:验证集上的loss一开始也在下降,我天真地以为一切正常,后来才发现是因为我在预处理阶段对整个数据集做了均值方差标准化,而均值和方差是从包含验证集在内的所有数据上算出来的。这相当于验证集的信息在训练过程中被间接泄露了。修复方式很简单:只用训练集统计均值方差,再用同样的参数去标准化验证集和测试集。这个教训让我到现在都对“数据预处理是否会引入数据泄露”非常敏感。3.3 用deepcopy保存最佳模型:一个小而关键的细节训练几个epoch之后,你需要把最好的模型保存下来,而不是最后那个epoch的结果。直观的做法是每个epoch结束看验证集准确率,如果比之前高就把model.state_dict()存下来。这里有一个细节容易踩坑:直接best_model = model是浅拷贝,后面继续训练会把同一份模型的参数覆盖掉,导致你“存下来的最佳模型”其实变成了最后的状态。正确的做法是:import copy if val_acc > best_acc: best_acc = val_acc best_model_wts = copy.deepcopy(model.state_dict())deepcopy会为state_dict开辟一片独立的内存,后续训练再怎么更新模型参数,都不会影响你保存下来的那一份。这个细节我是在实际项目里吃亏之后才记住的。当时我以为模型已经保存好了,结果训练结束后加载回来发现准确率和最后几个epoch几乎一样,完全不符合预期。排查了半天,才发现是浅拷贝的锅。顺便提一个版本差异:较新版本的PyTorch中,state_dict本身在拷贝时已经不容易和原模型共享底层存储了,但deepcopy始终是保险做法,而且一眼就能看出意图,不会有歧义。4. 我最常遇到的两类“翻车”事故复盘4.1 训练损失不下降的排查链路这是day2最普遍的挫败点:你照着教程把网络搭起来,开始训练,然后发现loss从2.3掉到2.25就再也不动了,仿佛模型在“躺平”。我第一次遇到这事时,第一反应是觉得模型太简单了,于是去加层数,结果更糟。后来我学乖了,按下面的链路一步步排查,效率高很多。第一步:检查数据标签是否随机。我先随机打乱训练集的标签,发现loss照样下降到某个固定值附近,而且准确率接近“随机乱猜”的水平。如果你的模型在随机标签上还能把loss压得很低,那多半是模型容量大到了能硬记住随机映射,这不是真学习。在随机标签上loss基本不掉,反而是正常现象,能说明你的模型没有作弊。第二步:检查输入数据的数值范围。图片像素范围是否在0到1之间?如果是0到255,那模型输入太大,权重更新会被放大,loss容易爆炸。我第二天就犯过这个错,因为自己写的Dataset忘了除以255,前几轮loss直接NaN。第三步:看梯度的数量级。在训练循环里加一行代码,把第一个卷积层梯度的grad norm打出来:total_grad = 0.0 for p in model.parameters(): if p.grad is not None: total_grad += p.grad.norm().item() ** 2 print(total_grad ** 0.5)如果梯度范数很小(比如1e-8级别),权重几乎不动,那可能是模型初始化不好或数据没归一化。如果梯度范数巨大(1e5以上),可能要检查是不是梯度爆炸,此时降低学习率是第一步。第四步:用极小数据做过拟合测试。我抽了训练集里的32张图片,关掉正则化和dropout,反复训练同一个batch。如果loss在几十步内还不能降到接近0,那你的模型或者数据管线一定有bug。反过来,如果loss降得下去,说明模型本身有学习能力,再回到全量数据上从头调。这个“小数据过拟合”思路是我认为day2最值得掌握的方法之一。它能把你从“玄学调参”的泥潭里拉出来,用最短的时间验证“代码能不能学”。4.2 过拟合的第一次正面遭遇当我把训练loss压到很低之后,验证集准确率还是没有显著提升,甚至开始下降。这是我第一次正面遭遇过拟合。用生活化的方式解释,就是模型在训练集上“背答案”背得滚瓜烂熟,但换个新环境就露馅。具体症状很典型:训练准确率稳步爬到99%,验证准确率在92%附近就见顶了,之后训练准确率还在涨,验证准确率反而往下掉。这个背离就是过拟合的信号。我在day2里的处理方式很粗糙但很有效:第一件事:加数据增强。对图片做随机旋转、平移、翻转或加噪声,相当于让模型见到的“同一张图”有更多变化,逼它学更稳固的特征。哪怕只是加一个RandomCrop加RandomHorizontalFlip,验证集的表现都会有肉眼可见的变化。from torchvision import transforms train_transform = transforms.Compose([ transforms.RandomCrop(28, padding=4), transforms.RandomHorizontalFlip(), transforms.ToTensor(), ])第二件事:加Dropout或减少模型容量。我的TinyNet里全连接层只有两层,但还是可以塞一个nn.Dropout(0.5)进去。dropout的工作方式是每次前向传播随机“关闭”一些神经元,这会让模型无法依赖某几个固定神经元,逼迫它分散学习特征。对于小数据集,这是一个成本极低但效果显著的策略。第三件事:加权重衰减。Adam优化器中的weight_decay参数是一个正则化项,他让权重尽量保持一个小的量级,不至于某个维度被过度放大。我一般从1e-4或1e-5开始试。这三件事不是每次都要全部上,而是根据症状增量调整。如果只加数据增强就能让验证loss不再发散,那我不会急着加dropout。做实验要一步一步来,改一次跑一次,记录一次,否则你永远不知道到底是哪个改动起了作用。4.3 为什么“固定随机种子”如此重要这一节想特别强调一下set_seed的价值。深度学习中到处都是随机性:数据加载顺序的随机、权重初始化的随机、dropout的随机。如果你不固定种子,同一份代码跑两次结果可能差两个点。这会导致你在调参时根本分不清效果是来自改动还是来自运气。固定种子之后,我可以放心地做对比实验:只改学习率、只加dropout、只换数据增强,跑出来的差异就能归因到对应改动上。固定种子的代码就是前面写过的set_seed函数,记着在训练前以及使用任何可能涉及随机数的库之前调用。值得一提的另一个细节:如果你把代码从CPU迁移到GPU,同样的种子不一定复现完全一样的结果——因为GPU上的并行计算本身导致了非确定性,尤其是使用cudnn时。想减少这种不确定性,可以在进GPU训练前加一句:torch.backends.cudnn.deterministic = True torch.backends.cudnn.benchmark = False这两行不是必需的,但它们能让实验的可复现性提升不少。5. 教程之外的进阶路线:给“day3”做点什么铺垫5.1 给训练过程加一根“把标打点”的日志线day2结束后,我已经能稳定跑通训练和验证了,但我意识到另一个问题:训练过程中的信息太简陋了,只有每次epoch结束的两个数字。如果我想看到更细粒度的趋势,就需要把每个batch的loss记下来,画成曲线。这既是可视化练习,也是给day3的铺垫。我用了最简单的方式:在训练循环里,每50个step打印一次当前的loss,同时用一个列表把所有loss值收着,等训练结束之后用matplotlib直接画出来。这一步能让你直观看到loss下降的形状:是平滑下降,还是锯齿状乱跳,还是先降后升。不同的形状对应不同的问题。再往后,你还可以把loss按一个固定频率写入csv文件,或者直接用可视化工具逐个加载。很多新手初学可视化时容易陷入“一定要搭一个多完整的监控系统”的执念,其实在day2阶段,一根简单的csv曲线已经足够。5.2 建立自己的“实验登记本”第二个习惯是给每轮实验写一句话记录:改了什么、结果如何、猜测原因是什么。你可以用一个表格,在每次改完参数后填一行。别小看这个动作,它其实是很多工程团队里实验管理的最小雏形。我把自己的实验记录简化成四个字段:实验编号改动点验证集准确率备注exp01基线,lr=0.00192.3%验证loss在后期开始回升exp02加RandomCrop94.1%回升现象减轻exp03加Dropout(0.5)94.8%没有继续回升exp04加weight_decay=1e-495.2%当前最优每一行都简洁明了。回看的时候,你能从一条“验证集准确率稳步上升”的路径里看出自己决策的合理性。这个方法一直到后面做复杂项目都还在用,只是载体从表格变成了更正式的工具,习惯没有变。5.3 “带着问题学”比“跟着教程学”更重要day2结束那天,我给自己列了一张day3的问题清单:为什么batch size增大之后,同样学习率下loss曲线会变震荡?dropout和weight_decay在数学上分别是在“惩罚”些什么?如果模型在训练集上过拟合,优化器更新梯度的方向有什么变化?我应该什么时候把SGD换成Adam,或者反过来?这些问题可以说是我后续学习的导航地图。每弄懂一个,对训练过程的理解就加深一层。这比漫无目的地刷论文要高效得多。很多人学AI最大的困惑不是资料太少,而是问题不够具体。我建议你从第二天开始就养成这个习惯:每个实验结束,写下一条“为什么”和一条“如果怎么做会怎样”。等第三天再回头验证,你会看到自己的进步。如果你正在day1到day2的过渡期,希望这篇记录能帮你少走一点弯路。哪怕只带走“固定随机种子”和“小数据过拟合测试”这两个习惯,也值了。我自己的体会是,AI模型学习的第二天并不在于你掌握了多少花哨的架构,而在于你是否真正开始像一个工程师那样,对着数据和loss曲线说话。这个习惯一旦养成,后面所有项目都会受益。
1. 第二天到底该学什么:先把KPI关掉如果你和我一样,第一天装完环境、跑通一个手写数字识别的Demo、看着loss从2.3掉到0.1就觉得自己已经“会AI”了,那第二天大概率会陷入一种迷惑:接下来该干嘛?继续调参把准确率从98%刷到99%?还是赶紧去看Transformer论文?我建议你先别急,因为第二天真正该解决的,是搞清楚训练过程里到底发生了什么。我第一天跑通Demo时的状态,基本就是“按回车,看数字,觉得神奇”。模型内部做了什么、数据是怎么喂进去的、为什么换一个batch就会出不一样的结果,全都没概念。第二天我不想再这样蒙着眼往前走,于是给自己定了一个目标:把训练过程拆到一眼能看到底。说白了,就是给训练装一块“监控仪表盘”,让每个部件的状态都清晰可见。这也是我认为AI模型学习day2最合理的定位:不是追求精度KPI,而是把训练的三件事——观察信号、隔离信号、压缩信号——真正走一遍。先解释一下这三个词:观察信号:模型到底在看什么数据?tensor的形状对不对、数值范围合不合理、标签有没有错位。隔离信号:是模型的问题、数据的问题,还是优化过程的问题?出了状况能快速定位到具体环节。压缩信号:把原始输入逐层抽象成高层的特征表达,也就是训练里最核心的“学习”过程。很多教程会在这时直接扔给你一堆数学公式,然后是各种网络结构对比,让人越看越焦虑。我的建议是:第二天别碰复杂网络,也不要纠结“为什么用Adam而不用SGD”这种暂时回答不了的问题。你要做的是把一条极简训练链路跑通,并且能亲手改每一个环节,看到改完之后的直接后果。具体来说,我给自己列了一份day2清单,一共五条:跑通任意一个小模型,我从LeNet手写数字的Demo改成了两个卷积层的极简结构。亲手打印训练过程中每一层的输入输出shape,直到不需要再猜“中间发生了什么”。把batch size从64改成128,观察loss曲线和训练时长的变化。人为把验证集数据混进训练集,观察过拟合现象有多严重。每次实验固定随机种子,确保同一个代码跑两次结果完全一致。做完这五件事,你对模型训练的认识会比第一天的“Demo跑通”深一个层次。接下来我详细说day2的实际内容和踩坑过程。2. 训练过程的四大部件:数据管线、模型结构、损失函数、优化器2.1 数据管线:shape永远是你最先该确认的事day2的第一个坑,几乎都出在数据上。我当时用的是一套公开的图片分类数据,类别有十种。第一天跑Demo时框架已经写好了DataLoader,我没怎么细看。第二天我想自己写一个数据读取,结果第一行就报错:Expected 3D tensor, got 2D。原因很简单:transform里面少了一步ToTensor(),图片被读成了HWC格式的numpy数组,而模型期望的是CHW格式的tensor。这个错误很基础,但它让我意识到一个关键点——数据管线的每一个环节都在改变数据的形状,你必须能随时打印出每个环节之后的shape。之后我写数据管线时养成了一个习惯:在Dataset的__getitem__里、在DataLoader取完一个batch之后、在模型forward入口处,各加一行打印。别嫌啰嗦,这一步能省掉你后面几小时的debug时间。我后来把这个习惯保留到了所有项目里,只不过把print换成了logger。再说一个容易被忽略的点:数据标签的“对齐”问题。很多时候你会听到“数据泄露”“标签错位”这类词,听起来很高大上,其实就是一个意思——模型看到的输入和标签不是一一配对的。我做过一个实验:把训练集的前100张图片的标签整体往后挪一位,结果loss在前期下降一点之后就开始震荡,准确率卡在20%左右上不去。这种问题程序不会报错,因为它所有tensor形状都对得上,只是语义上错了。所以第二天一定要自己手动喂一个batch,把图片和标签逐一打印出来看一眼,相信我,那一眼比跑十轮epoch都有用。2.2 模型结构:从input到output的shape流转模型结构是day2的第二个观察点。我不建议第二天就去照着论文堆什么残差、注意力模块,除非你只是想感受一下框架的灵活度。更值得做的是:选一个极简网络,然后自己把前向传播的每一步展开写一遍。我当时写了一个两卷积加两全连接的极简结构,大概长这样:import torch.nn as nn class TinyNet(nn.Module): def __init__(self, in_channels=1, num_classes=10): super().__init__() self.features = nn.Sequential( nn.Conv2d(in_channels, 16, kernel_size=3, padding=1), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(16, 32, kernel_size=3, padding=1), nn.ReLU(), nn.MaxPool2d(2) ) self.classifier = nn.Sequential( nn.Flatten(), nn.Linear(32 * 7 * 7, 64), nn.ReLU(), nn.Linear(64, num_classes) ) def forward(self, x): x = self.features(x) x = self.classifier(x) return x这个网络结构朴素到不能再朴素,但它有一个好处:你很容易心算每一层的输出shape。输入如果是[64, 1, 28, 28],经过第一个卷积后是[64, 16, 28, 28],池化后变成[64, 16, 14, 14],第二个卷积后是[64, 32, 14, 14],再池化变成[64, 32, 7, 7],Flatten之后是[64, 32 * 7 * 7],也就是[64, 1568],最后经过全连接得到[64, 10]。中间的Flatten是一个很容易写错的地方。第一次我把32 * 7 * 7写成了32 * 14 * 14,前向传播直接报维度不匹配。这个报错虽然明显,但它能逼着你去把shape理一遍。如果你用的图片尺寸不是28x28,那这个计算过程就更要小心,因为你不能靠背代码,只能靠手动推导。2.3 损失函数与优化器:先别急着换day2最容易犯的一个错误,就是在训练不理想时立刻去换优化器或损失函数。我当时就干过这事:看起来loss下降太慢,就把Adam换成了SGD,结果更慢了,然后又想把学习率调大,结果loss直接飞了。后来我才明白一个道理:训练不理想,优先检查数据和代码,而不是先怀疑优化器或者损失函数选错了。在绝大多数入门场景里,交叉熵损失加上Adam优化器已经足够应对常见任务了。你换优化器产生的那点变化,通常远不如数据质量对结果的影响大。不过损失函数有一个值得理解的要点:它决定了模型优化的方向和“好坏”的定义。分类任务用交叉熵,本质上是让模型输出的概率分布越来越接近真实标签的one-hot分布。回归任务用MSE,本质上是让预测值和目标值的距离越来越小。你不需要在第二天推导它们的数学细节,但至少要知道“我在用什么损失、它在衡量什么差距”。优化器就更简单了:它的作用是决定权重往哪个方向走、走多大步。SGD是最朴素的“顺着梯度走”,Adam相当于在此基础上自动调整每一步的步长。第二天你只需要记住一点:学习率过大,loss会震荡甚至爆炸;学习率过小,loss下降缓慢得像蜗牛。可以像做实验一样,把学习率从0.1往下试,用0.01、0.001、0.0001各跑几个epoch,看loss曲线的斜率差异。这张对比表会非常直观:学习率前20个batch的loss表现结论0.1从2.3跳到87,直接NaN过大,梯度爆炸0.01缓慢下降,但很粗糙偏大,曲线抖动明显0.001平稳下降,曲线光滑训练可用,推荐0.0001下降幅度极小,几乎看不出变化偏小,训练会非常慢2.4 四大部件的翻车现场:一张表讲清楚为了在day2少走弯路,我把后续几天遇到的四大部件问题整理成了一张排查表。每次训练出问题,我先按这张表定位,再动手改:部件常见症状排查入口数据管线shape不匹配、标签错位、loss不降打印batch的shape和标签,可视化几张图片模型结构forward报错维度不对、输出全是同一个值打印每一层输入输出shape,检查是否有Flatten或reshape错误损失函数loss恒为常数、分类结果全偏向某一类检查标签范围是否正确,样本类别是否平衡优化器loss震荡、训练慢、权重不更新看梯度范数、降低学习率、固定种子复现这个表我一直用到后面的实战项目里,每次遇到奇奇怪怪的错误,先对照它定位到具体部件,再去深挖,效率高很多。day2的学习,本质就是在建立这张排查表的过程。3. 搭建第一个训练脚本的不动点骨架3.1 为什么“模板化”反而更好很多人觉得深度学习项目要自己从头写才厉害,其实恰恰相反。成熟的做法是:训练脚本的骨架保持稳定,每天换的只是模型和数据部分。骨架稳定,意味着你每次跑实验的环境是一致的,出了问题也容易对比。如果你每天从零重写训练循环,那每次出bug都在改不同的地方,很难积累经验。我的day2训练循环骨架大概这样:import torch import torch.nn as nn from torch.utils.data import DataLoader from tqdm import tqdm import random import numpy as np def set_seed(seed=42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) def train_one_epoch(model, dataloader, optimizer, criterion, device): model.train() total_loss = 0.0 correct = 0 total = 0 for inputs, labels in tqdm(dataloader): inputs, labels = inputs.to(device), labels.to(device) optimizer.zero_grad() outputs = model(inputs) loss = criterion(outputs, labels) loss.backward() optimizer.step() total_loss += loss.item() * inputs.size(0) preds = outputs.argmax(dim=1) correct += (preds == labels).sum().item() total += labels.size(0) avg_loss = total_loss / total accuracy = correct / total return avg_loss, accuracy def evaluate_model(model, dataloader, criterion, device): model.eval() total_loss = 0.0 correct = 0 total = 0 with torch.no_grad(): for inputs, labels in dataloader: inputs, labels = inputs.to(device), labels.to(device) outputs = model(inputs) loss = criterion(outputs, labels) total_loss += loss.item() * inputs.size(0) preds = outputs.argmax(dim=1) correct += (preds == labels).sum().item() total += labels.size(0) avg_loss = total_loss / total accuracy = correct / total return avg_loss, accuracy这段代码没什么神奇之处,但它把训练和评估分成了两个清晰的函数,而且在每个epoch都能拿到平均loss和准确率。我把set_seed(42)放在训练开始前调用,保证每次跑同一个实验都能得到几乎一样的结果。不要小看这一步,它是后续做任何对比实验的前提——如果你每次跑的随机状态都不一样,那两轮实验结果之间的差异到底是来自你的改动还是来自随机性,你根本分不清。3.2 数据集拆分:验证集不是测试集的替代品很多人第一天会在全部数据上训练,这没问题,反正只是跑通Demo。但到第二天,你必须建立“训练集、验证集、测试集”三份数据的概念。简单说:训练集:用来更新模型参数。验证集:用来观察模型在没见过的数据上的表现,以及调超参数。测试集:最后评估一次模型水平,尽量只在结束时碰它。我第二天用到的数据量不大,直接用了random_split:from torch.utils.data import random_split train_size = int(0.8 * len(full_dataset)) val_size = int(0.1 * len(full_dataset)) test_size = len(full_dataset) - train_size - val_size train_dataset, val_dataset, test_dataset = random_split( full_dataset, [train_size, val_size, test_size] )这个步骤看起来只是切分数据,但它暗含了一个很深的坑:如果你用全量数据训练,只是在同一批数据上看loss下降,那你几乎无法判断模型是“学到了规律”还是“背下了答案”。只有把一部分数据留出来,让模型在训练时永远看不到它,你才能通过验证集上的表现来判断泛化能力。我栽过跟头的那次是:验证集上的loss一开始也在下降,我天真地以为一切正常,后来才发现是因为我在预处理阶段对整个数据集做了均值方差标准化,而均值和方差是从包含验证集在内的所有数据上算出来的。这相当于验证集的信息在训练过程中被间接泄露了。修复方式很简单:只用训练集统计均值方差,再用同样的参数去标准化验证集和测试集。这个教训让我到现在都对“数据预处理是否会引入数据泄露”非常敏感。3.3 用deepcopy保存最佳模型:一个小而关键的细节训练几个epoch之后,你需要把最好的模型保存下来,而不是最后那个epoch的结果。直观的做法是每个epoch结束看验证集准确率,如果比之前高就把model.state_dict()存下来。这里有一个细节容易踩坑:直接best_model = model是浅拷贝,后面继续训练会把同一份模型的参数覆盖掉,导致你“存下来的最佳模型”其实变成了最后的状态。正确的做法是:import copy if val_acc > best_acc: best_acc = val_acc best_model_wts = copy.deepcopy(model.state_dict())deepcopy会为state_dict开辟一片独立的内存,后续训练再怎么更新模型参数,都不会影响你保存下来的那一份。这个细节我是在实际项目里吃亏之后才记住的。当时我以为模型已经保存好了,结果训练结束后加载回来发现准确率和最后几个epoch几乎一样,完全不符合预期。排查了半天,才发现是浅拷贝的锅。顺便提一个版本差异:较新版本的PyTorch中,state_dict本身在拷贝时已经不容易和原模型共享底层存储了,但deepcopy始终是保险做法,而且一眼就能看出意图,不会有歧义。4. 我最常遇到的两类“翻车”事故复盘4.1 训练损失不下降的排查链路这是day2最普遍的挫败点:你照着教程把网络搭起来,开始训练,然后发现loss从2.3掉到2.25就再也不动了,仿佛模型在“躺平”。我第一次遇到这事时,第一反应是觉得模型太简单了,于是去加层数,结果更糟。后来我学乖了,按下面的链路一步步排查,效率高很多。第一步:检查数据标签是否随机。我先随机打乱训练集的标签,发现loss照样下降到某个固定值附近,而且准确率接近“随机乱猜”的水平。如果你的模型在随机标签上还能把loss压得很低,那多半是模型容量大到了能硬记住随机映射,这不是真学习。在随机标签上loss基本不掉,反而是正常现象,能说明你的模型没有作弊。第二步:检查输入数据的数值范围。图片像素范围是否在0到1之间?如果是0到255,那模型输入太大,权重更新会被放大,loss容易爆炸。我第二天就犯过这个错,因为自己写的Dataset忘了除以255,前几轮loss直接NaN。第三步:看梯度的数量级。在训练循环里加一行代码,把第一个卷积层梯度的grad norm打出来:total_grad = 0.0 for p in model.parameters(): if p.grad is not None: total_grad += p.grad.norm().item() ** 2 print(total_grad ** 0.5)如果梯度范数很小(比如1e-8级别),权重几乎不动,那可能是模型初始化不好或数据没归一化。如果梯度范数巨大(1e5以上),可能要检查是不是梯度爆炸,此时降低学习率是第一步。第四步:用极小数据做过拟合测试。我抽了训练集里的32张图片,关掉正则化和dropout,反复训练同一个batch。如果loss在几十步内还不能降到接近0,那你的模型或者数据管线一定有bug。反过来,如果loss降得下去,说明模型本身有学习能力,再回到全量数据上从头调。这个“小数据过拟合”思路是我认为day2最值得掌握的方法之一。它能把你从“玄学调参”的泥潭里拉出来,用最短的时间验证“代码能不能学”。4.2 过拟合的第一次正面遭遇当我把训练loss压到很低之后,验证集准确率还是没有显著提升,甚至开始下降。这是我第一次正面遭遇过拟合。用生活化的方式解释,就是模型在训练集上“背答案”背得滚瓜烂熟,但换个新环境就露馅。具体症状很典型:训练准确率稳步爬到99%,验证准确率在92%附近就见顶了,之后训练准确率还在涨,验证准确率反而往下掉。这个背离就是过拟合的信号。我在day2里的处理方式很粗糙但很有效:第一件事:加数据增强。对图片做随机旋转、平移、翻转或加噪声,相当于让模型见到的“同一张图”有更多变化,逼它学更稳固的特征。哪怕只是加一个RandomCrop加RandomHorizontalFlip,验证集的表现都会有肉眼可见的变化。from torchvision import transforms train_transform = transforms.Compose([ transforms.RandomCrop(28, padding=4), transforms.RandomHorizontalFlip(), transforms.ToTensor(), ])第二件事:加Dropout或减少模型容量。我的TinyNet里全连接层只有两层,但还是可以塞一个nn.Dropout(0.5)进去。dropout的工作方式是每次前向传播随机“关闭”一些神经元,这会让模型无法依赖某几个固定神经元,逼迫它分散学习特征。对于小数据集,这是一个成本极低但效果显著的策略。第三件事:加权重衰减。Adam优化器中的weight_decay参数是一个正则化项,他让权重尽量保持一个小的量级,不至于某个维度被过度放大。我一般从1e-4或1e-5开始试。这三件事不是每次都要全部上,而是根据症状增量调整。如果只加数据增强就能让验证loss不再发散,那我不会急着加dropout。做实验要一步一步来,改一次跑一次,记录一次,否则你永远不知道到底是哪个改动起了作用。4.3 为什么“固定随机种子”如此重要这一节想特别强调一下set_seed的价值。深度学习中到处都是随机性:数据加载顺序的随机、权重初始化的随机、dropout的随机。如果你不固定种子,同一份代码跑两次结果可能差两个点。这会导致你在调参时根本分不清效果是来自改动还是来自运气。固定种子之后,我可以放心地做对比实验:只改学习率、只加dropout、只换数据增强,跑出来的差异就能归因到对应改动上。固定种子的代码就是前面写过的set_seed函数,记着在训练前以及使用任何可能涉及随机数的库之前调用。值得一提的另一个细节:如果你把代码从CPU迁移到GPU,同样的种子不一定复现完全一样的结果——因为GPU上的并行计算本身导致了非确定性,尤其是使用cudnn时。想减少这种不确定性,可以在进GPU训练前加一句:torch.backends.cudnn.deterministic = True torch.backends.cudnn.benchmark = False这两行不是必需的,但它们能让实验的可复现性提升不少。5. 教程之外的进阶路线:给“day3”做点什么铺垫5.1 给训练过程加一根“把标打点”的日志线day2结束后,我已经能稳定跑通训练和验证了,但我意识到另一个问题:训练过程中的信息太简陋了,只有每次epoch结束的两个数字。如果我想看到更细粒度的趋势,就需要把每个batch的loss记下来,画成曲线。这既是可视化练习,也是给day3的铺垫。我用了最简单的方式:在训练循环里,每50个step打印一次当前的loss,同时用一个列表把所有loss值收着,等训练结束之后用matplotlib直接画出来。这一步能让你直观看到loss下降的形状:是平滑下降,还是锯齿状乱跳,还是先降后升。不同的形状对应不同的问题。再往后,你还可以把loss按一个固定频率写入csv文件,或者直接用可视化工具逐个加载。很多新手初学可视化时容易陷入“一定要搭一个多完整的监控系统”的执念,其实在day2阶段,一根简单的csv曲线已经足够。5.2 建立自己的“实验登记本”第二个习惯是给每轮实验写一句话记录:改了什么、结果如何、猜测原因是什么。你可以用一个表格,在每次改完参数后填一行。别小看这个动作,它其实是很多工程团队里实验管理的最小雏形。我把自己的实验记录简化成四个字段:实验编号改动点验证集准确率备注exp01基线,lr=0.00192.3%验证loss在后期开始回升exp02加RandomCrop94.1%回升现象减轻exp03加Dropout(0.5)94.8%没有继续回升exp04加weight_decay=1e-495.2%当前最优每一行都简洁明了。回看的时候,你能从一条“验证集准确率稳步上升”的路径里看出自己决策的合理性。这个方法一直到后面做复杂项目都还在用,只是载体从表格变成了更正式的工具,习惯没有变。5.3 “带着问题学”比“跟着教程学”更重要day2结束那天,我给自己列了一张day3的问题清单:为什么batch size增大之后,同样学习率下loss曲线会变震荡?dropout和weight_decay在数学上分别是在“惩罚”些什么?如果模型在训练集上过拟合,优化器更新梯度的方向有什么变化?我应该什么时候把SGD换成Adam,或者反过来?这些问题可以说是我后续学习的导航地图。每弄懂一个,对训练过程的理解就加深一层。这比漫无目的地刷论文要高效得多。很多人学AI最大的困惑不是资料太少,而是问题不够具体。我建议你从第二天开始就养成这个习惯:每个实验结束,写下一条“为什么”和一条“如果怎么做会怎样”。等第三天再回头验证,你会看到自己的进步。如果你正在day1到day2的过渡期,希望这篇记录能帮你少走一点弯路。哪怕只带走“固定随机种子”和“小数据过拟合测试”这两个习惯,也值了。我自己的体会是,AI模型学习的第二天并不在于你掌握了多少花哨的架构,而在于你是否真正开始像一个工程师那样,对着数据和loss曲线说话。这个习惯一旦养成,后面所有项目都会受益。