ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

深度学习第三天:亲手完成从数据到模型的完整训练闭环

深度学习第三天:亲手完成从数据到模型的完整训练闭环

没人告诉你实话:深度学习的第三天,才是大多数人真正放弃的时候。第一天新鲜感还在,pip install 敲得噼里啪啦响,跟着教程跑通了第一个 MNIST 手写数字识别,感觉自己已经站在人工智能之巅。第二天稍微有点不对劲,反向传播的公式开始像天书,但还能咬牙往下面看。

到了第三天,问题彻底暴露了:你发现前面跑通的代码只是别人的搬运,轮到自己从头写一个模型,脑子里一片空白。你开始怀疑自己是不是太笨,又或者深度学习这件事压根不适合你。但我作为过来人,负责任地讲——第三天学不下去,不是因为难,而是因为你还在用“看教程”的方式学一个“需要上手练”的技能。这个阶段的信息密度和实操强度,和前两天完全不在一个量级。

这篇内容就是写给处于第三天前夜的你:帮你把这个阶段的学习路径理顺,梳理哪些事必须亲手做、哪些可以暂缓,我只讲实际的、可复现的操作。既然已经坚持到了第三天,别让进度止步于此。

1. 第三天究竟卡在哪儿

1.1 前两天的“假会”与“真不懂”交织

先回顾一下前两天的典型体验。第一天,你大概率知道了什么是神经网络、什么是感知机、激活函数到底在干什么,然后用某个深度学习框架跑通了一个图像分类的 Demo。整个过程像坐滑梯,顺得太快,顺利得让人误以为自己已经掌握了这项技能。

第二天,剧情急转直下。你开始接触反向传播、梯度下降、学习率、损失函数这些概念。当你试图把一个三层的全连接网络从零开始用 Numpy 实现一遍时,一切都变得模糊起来。梯度到底怎么传的?为什么要对权重求偏导?学习率调大调小又有什么区别?这些疑问像滚雪球一样越滚越大,而你手头的教程只是轻描淡写地说一句“反向传播是深度学习的核心”,连个像样的展开都没有。于是你卡住了,闷头看了一整天公式推导,代码一行没写。

1.2 第三天要完成一次认知升级

其实,走到这里恰恰说明你在做正确的事。深度学习这种工程属性极强的领域,恰恰禁止一直停留在“看懂”的舒适区。第三天的一个核心目标,就是要完成一次认知升级:从“我知道概念”变成“我能用这些概念解决一个像样的任务”。

这个像样的任务不该再是教程里手把手教你做的 MNIST,而是那种只给了数据和目标、剩下全凭自己折腾的小型实战题。比如找一份猫狗图片数据集,自己动手写数据加载、搭建模型、定义损失函数、写训练循环、观察指标、保存模型、做预测。

我做这个选题的初衷,就是帮你走通这样一趟独立实战的流程。你不用一次达到多高的准确率,关键是把每个环节亲手操作一遍,遇到问题再回来查资料。下面所有内容都围绕这条主线展开。

2. 第三天必须亲手做对的一件事:从数据到模型的完整闭环

2.1 确立一个“跳一跳够得着”的目标

学深度学习最怕一开始就搞大动作,比如想复现某个顶会论文,或者做一个堪比商业级的产品。第三天的体力只够支撑一个中等偏小的目标。我建议你找一个公开的、二分类的图像数据集,或者把手上能找到的图片素材按类别分好。核心标准有三条:数据总量不宜太大(几百到一两千张足够)、类别不要超过三类、图片内容差异要明显。

就以猫狗分类为例。拿到这批数据后,先别急着打开编辑器写模型,先把文件夹结构整理成下面这个形态:

data/ train/ cat/ cat_001.jpg cat_002.jpg dog/ dog_001.jpg val/ cat/ dog/

训练集和验证集分开存放,这是所有后续操作的基础。很多初学者喜欢把所有图片一股脑塞进去,等代码跑起来才想起连训练集和测试集都混在一起,这是极为危险的,因为它会直接污染后续的一切指标评估。你在第三步搭建训练代码时,所有数据划分的假设都会被这份目录结构影响。我之前带过一位朋友,他用了乱七八糟的目录,结果模型训练时验证集里混进了大量训练图片,准确率高得吓人,一测真实场景立刻露馅了。

2.2 写一个属于自己的 DataLoader

现在要做的不是调用框架里现成的高层数据接口,而是自己动手写数据加载逻辑。这一步看似繁琐,实际上能帮你建立对数据流最直接的体感。

以 PyTorch 为例,核心要做的事就两件:找到所有图片路径和标签,然后在训练时一批一批地取出来、做预处理。

from torch.utils.data import Dataset from PIL import Image import os class CatDogDataset(Dataset): def __init__(self, root_dir, transform=None): self.root_dir = root_dir self.transform = transform self.samples = [] for label_name in ["cat", "dog"]: class_dir = os.path.join(root_dir, label_name) label = 0 if label_name == "cat" else 1 for fname in os.listdir(class_dir): if fname.lower().endswith((".jpg", ".jpeg", ".png")): self.samples.append((os.path.join(class_dir, fname), label)) def __len__(self): return len(self.samples) def __getitem__(self, idx): path, label = self.samples[idx] image = Image.open(path).convert("RGB") if self.transform: image = self.transform(image) return image, label

这份代码没有任何魔法,就是在遍历目录、把路径存起来、按索引读取。但请注意我做了这样几个关键设计:

  • 用 label 的数字和字符串一一对应,后续计算损失函数时不需要再额外转换。
  • 读取图片时调用 convert("RGB") 强制统一通道,避免某些灰度图或带透明通道的图片导致后面对齐出错。
  • 缓存了所有路径到内存里,避免训练时反复 os.listdir 拖慢速度。

注意:训练的图片如果尺寸参差不齐,必须先统一尺寸。图像缩放的插值方式也有讲究,一般用双线性就行。要是做分类任务,千万别忘记在数据预处理里顺手做归一化,把像素范围缩到[0,1]附近。这一步漏了,模型可能收敛得极慢,甚至完全不收敛。

2.3 搭建一个“小得合理”的模型

第三天搭模型,重心放在“结构逻辑清晰、容易训练出效果”上。通常我从一个简单的卷积网络开始写:

import torch.nn as nn class SimpleCNN(nn.Module): def __init__(self, num_classes=2): super().__init__() self.features = nn.Sequential( nn.Conv2d(3, 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), nn.Conv2d(32, 64, kernel_size=3, padding=1), nn.ReLU(), nn.MaxPool2d(2), ) self.classifier = nn.Sequential( nn.Flatten(), nn.Linear(64 * 4 * 4, 64), nn.ReLU(), nn.Linear(64, num_classes), ) def forward(self, x): x = self.features(x) x = self.classifier(x) return x

这个网络结构非常常见,就是“卷积 + 池化 + 全连接”的老三段。它不追求刷指标,胜在简单且能平稳收敛。如果你输入的图片尺寸是 64×64 或者 128×128,这个结构都能跑得动。

我特别想提醒你的是,写模型的时点其实暴露你对张量形状变化的理解程度。conv 层不改变高宽,池化层每经过一次高宽减半,FC 层的输入维度必须和最后的特征图尺寸严格对应。我见过太多人到了全连接层随便写了一个 Linear(1024, 64),结果一跑就报 shape 不匹配。其实有一个很土但实用的办法:在 forward 里临时 print(x.shape),看一遍就能把维度算清楚,改完再删掉即可。这种办法看着不优雅,却极其高效。

2.4 亲手写训练循环:反向传播其实就这么回事

到了第三步,终于要直面训练循环了。你可能已经发现,网上教程都会刻意回避自己写训练循环,推荐你直接用框架的高阶封装,比如 PyTorch Lightning 或者 Keras 的 model.fit。第三天我不建议你直接用这些封装。原因很简单:你还没有真正理解一个 batch 的数据是如何前向传播、算损失、清零梯度、反向传播、更新权重的。这些步骤一旦被封装遮住,后面排查问题会非常被动。

下面是一个标准的 PyTorch 训练循环:

import torch import torch.optim as optim def train_one_epoch(model, dataloader, criterion, optimizer, device): model.train() total_loss = 0.0 correct = 0 total = 0 for inputs, labels in 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) _, predicted = torch.max(outputs, 1) total += labels.size(0) correct += (predicted == labels).sum().item() avg_loss = total_loss / total accuracy = correct / total return avg_loss, accuracy

注意第 10~13 行,这四个操作是训练循环的灵魂:

  • optimizer.zero_grad() 把上一轮累积的梯度清零。PyTorch 的梯度默认是累积的,不清零的话梯度会在不同 batch 之间叠加,导致优化方向彻底错误。
  • loss.backward() 根据当前 batch 的损失值,计算所有参数的梯度。
  • optimizer.step() 拿着这些梯度去更新权重,这一步才是真正改变模型参数的时刻。

很多初学者难以理解这种参数更新模式,我打个比方:你手里有一个旋钮,用来调模型里几百万个参数,每看一批数据,就评估一下当前旋钮的各个刻度哪个方向能让损失变小,然后朝那个方向拧一点点。这个“评估方向”的动作就是反向传播,“拧一点”的动作就是优化器更新。如此反复几千次,模型就慢慢从混乱走向靠谱。

2.5 训练中的学习率选择:最容易被低估的参数

关于优化器,我建议第三天不要一上来就试 Adam 以外的选择。Adam 默认的学习率 0.001 在绝大多数小规模任务上都能跑出正常效果。相比它,SGD 会让训练过程更敏感、更依赖学习率,新手很容易陷入“要么发散、要么龟速”的困境。

如果你有兴趣做对比实验,可以在同一个小数据集上分别用下面两个配置跑 5 个 epoch:

配置优化器学习率预期现象
ASGD0.01损失下降较慢,后期略抖
BAdam0.001损失下降快,曲线平滑

看过对比结果,你会记住:优化器和学习率对训练的影响比网络结构本身更直接。我就亲眼见过有人把一个 ReLU 换成 GELU 后测试精度纹丝不动,但学习率从 0.1 调到 0.01,模型从发散直接变成收敛。

第三天学习的目标不是一次性掌握所有细节,而是积累这种“变量影响了什么”的第一手体感。这种体感是任何教程都教不出来的。

3. 写出你自己的训练脚本:完整参考方案

3.1 数据变换与加载的常用配置

数据预处理部分,我给出一个比较标准的组合方案。这里强调一下:验证集只做基础变换,不能做随机增强。

from torchvision import transforms from torch.utils.data import DataLoader train_transform = transforms.Compose([ transforms.Resize((64, 64)), transforms.RandomHorizontalFlip(p=0.5), transforms.ColorJitter(brightness=0.2, contrast=0.2), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) val_transform = transforms.Compose([ transforms.Resize((64, 64)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) train_dataset = CatDogDataset("data/train", transform=train_transform) val_dataset = CatDogDataset("data/val", transform=val_transform) train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True, num_workers=2) val_loader = DataLoader(val_dataset, batch_size=32, shuffle=False, num_workers=2)

关于数据集增强,说一句实在话:RandomHorizontalFlip 这种低成本增强对猫狗分类这个任务非常有效。因为猫和狗的照片天然存在左右对称性,翻转不会破坏类别信息,却能有效扩充样本形态。而像随机裁剪这种操作,如果没调好尺度很容易裁掉主体,让人工标注完全失效。

3.2 损失函数选择:分类问题默认从交叉熵开始

关于分类任务,第三天直接使用 CrossEntropyLoss,几乎不会错。需要提醒的是,PyTorch 的 CrossEntropyLoss 内部已经包含了 Softmax 操作,所以在模型最后一层不要再额外加 Softmax,否则会得到双 Softmax 的效果,既浪费计算还可能影响训练梯度。

下面这段代码负责把模型、损失函数、优化器组装到一起:

import torch.optim as optim device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = SimpleCNN(num_classes=2).to(device) criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=0.001)

如果你是 CPU 训练,又不想等太久,我建议把图片统一缩到 64×64,网络通道数再缩水一半,迭代轮数控制在 10 轮以内。训练一两个小时后看到损失曲线下降,就已经完成第三天的使命了,没必要非要用大模型和大分辨率证明什么。

3.3 完整训练文件组织方式

如果你想把今天的练习沉淀成一个清晰的小项目,推荐按下面的文件结构组织代码:

catdog_project/ data/ train/ cat/ dog/ val/ cat/ dog/ dataset.py model.py train.py predict.py

dataset.py 负责放自定义数据集类,model.py 负责放网络结构,train.py 是训练脚本,predict.py 是加载模型对单张图片做推理的脚本。这样结构化以后,哪怕只写了四五十行代码,也具备了小型工程项目的基本雏形,后续加功能会非常顺手。

3.4 加入验证逻辑

训练过程中不光要看训练集的损失,还要每完成一个 epoch 就做一次验证。这种做法意义有两个:第一,监督是否过拟合,训练损失在降、验证损失反而在升,说明模型开始背答案了;第二,验证集指标才是你向外界展示模型真实水平的数据依据。

验证集的标准写法是 model.eval() 加 torch.no_grad(),前者关闭 Dropout 和 BatchNorm 的训练行为,后者彻底停掉梯度计算,减少内存占用和多余算力消耗。

def evaluate(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) _, predicted = torch.max(outputs, 1) total += labels.size(0) correct += (predicted == labels).sum().item() avg_loss = total_loss / total accuracy = correct / total return avg_loss, accuracy

注意一个重要细节:验证代码虽然在 for 循环之外写了一段,但循环里依然要沿用 train 函数中那种从 batch 里取出 input 和 label 的结构,只是少了 loss.backward() 和 optimizer.step()。如果你把训练和验证写成一个函数,通过参数控制是否反向传播,我劝你尽早拆开,避免后续在复杂项目里踩坑。

4. 训练现场:真实反馈与调试记录

4.1 损失一直在高位震荡,怎么办

有一种第三天很常见的情况:跑了十个 epoch,训练损失稳定在 0.69 附近,既不涨也不怎么跌。0.69 这个数字在二分类里很有意思,它约等于 -ln(0.5),说明模型训练半天和随机猜测没什么区别。

要排查的因素主要包括:学习率过大导致 loss 在震荡、标签和数据没对齐、数据增强过猛把图片折腾得不像原图、网络结构里使用了不合适的初始化方式。其中最容易被忽略的其实是数据加载环节。你可以单独写几行代码,把一个 batch 的图片用 matplotlib 显示出来,肉眼看一下图片内容和标签是否匹配。

import matplotlib.pyplot as plt import numpy as np def show_batch(dataloader): images, labels = next(iter(dataloader)) img = images[0].permute(1, 2, 0).numpy() img = np.clip(img, 0, 1) plt.imshow(img) plt.title(f"label: {labels[0].item()}") plt.show()

注意:如果你做了 Normalize 标准化,显示图片前一定要把像素值范围还原到 [0,1] 附近,否则你会看到一张花掉的红绿图,容易误以为数据出了问题。

4.2 BatchNorm 和 Dropout 在训练与验证时的双重行为

前期如果模型里加了 BatchNorm 或 Dropout,就会遇到一个很隐蔽的坑:它们在训练模式和验证模式下的行为不一致。BatchNorm 在训练时使用当前 batch 的均值和方差,在验证时使用训练阶段累积的全局统计量;Dropout 在训练时随机丢弃部分神经元,验证时不丢弃。

因此,在写代码时必须在训练循环开始时调用 model.train(),在验证循环开始前调用 model.eval()。很多第三天的学习者会因为忘记调用 eval(),导致验证指标忽高忽低,隔几分钟跑一次,结果完全不一样。

4.3 过拟合信号:训练指标一路飞升,验证指标开始回头

跑了一段时间,如果你发现训练准确率已经到 95% 以上,验证准确率却卡在 72% 左右不再向上,甚至开始往下掉,这就是标准的过拟合信号。第三天不需要对这个信号产生恐慌,反而应该感到兴奋,因为你已经能独立复现出深度学习中最经典的现象之一。

在过拟合出现时,几个常见的操作思路如下:

提示:训练流程比预想中更容易出错,但这正是深度学习工程能力养成的开始。不要追求一次训练出完美模型,而是追求快速定位问题、修正问题的能力。第三天能顺利走完一遍“数据 → 模型 → 训练 → 验证”的全流程,就已经达到目标。

5. 第三天最后要做完整的一次推理验证

5.1 保存模型:两种方式

模型训练完毕后,要把参数保存下来。PyTorch 里常见的有两种状态保存方式:只保存模型权重,或者把整个模型结构一起保存。前者推荐,后者在实际开发中坑更多。

# 保存权重 torch.save(model.state_dict(), "catdog_model.weights.pth") # 加载权重 model = SimpleCNN(num_classes=2) model.load_state_dict(torch.load("catdog_model.weights.pth")) model.to(device)

使用 load_state_dict 前必须先把模型实例化出来,然后才能把权重灌进去。如果模型结构定义和保存时不一致,会直接报 KeyError 或者尺寸不匹配的错误。这个细节很基础,但我见过不少朋友在这里卡了一晚上。

5.2 单张图片推理

加载模型后,最好再写一个 predict.py 脚本,对任意一张图片输出它属于猫还是狗。这样整个流程就完成了闭环,模型不再只是黑盒,而是能动手调用的工具。

def predict_image(image_path, model, transform, device): image = Image.open(image_path).convert("RGB") image_tensor = transform(image).unsqueeze(0).to(device) model.eval() with torch.no_grad(): outputs = model(image_tensor) probs = torch.softmax(outputs, dim=1) predicted = torch.argmax(probs, dim=1).item() return predicted, probs[0, predicted].item()

这个推理脚本是一个非常好的“工具箱”,后续你想接入摄像头、接入 Web 服务,都可以从这段代码开始改造。它充分体现了一个事实:深度学习模型部署的基础,本质上就是数据预处理加模型前向传播。

5.3 把结果记录下来

第三天结束前,我强烈建议你写一份一页纸的实践笔记,把数据来源、模型结构、训练轮数、学习率、最终验证准确率、踩过的坑、对结果的理解这七项内容记录下来。这样做对后续学习有极大帮助,三天、十天、三十天后再翻回来,你会发现自己那时候对深度学习的理解已经完全不同了。

6. 第三天最容易忽视的实战习惯

6.1 固定随机种子

训练结果不稳定,很多时候不是代码逻辑出了问题,而是随机性在作祟。模型初始化权重不同、数据加载顺序不同、数据增强产生的随机变换不同,都可能导致微小的结果差异。为了能复现结果,建议在最开头固定随机种子:

import random import numpy as np import torch def set_seed(seed=42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) torch.backends.cudnn.deterministic = True

这里特别注意 cudnn.deterministic = True 和 torch.backends.cudnn.benchmark = False 的搭配。前者强制算法确定性,后者禁止自动选择性能最快的卷积算法。代价是训练速度可能略降,但换来的是可复现的结果。

6.2 养成用 TensorBoard 或简单日志记录训练过程的习惯

很多初学者训练时只看终端里 print 出来的 loss,还盯着满屏滚动的数字,看得头昏眼花。我更推荐用最简单的 CSV 日志记录每个 epoch 的训练损失、训练准确率、验证损失、验证准确率。后面你随时可以拿这份 CSV 画出曲线来。

你甚至不需要一开始就使用 TensorBoard,直接用 matplotlib 画 loss 曲线就足够。

6.3 学会看报错信息,而不是复制报错去聊天工具问

第三天最应该建立的工程习惯是阅读 Python traceback。遇到 shape mismatch 或者 RuntimeError,先顺着报错信息找到具体文件的第几行,再从那行往前面推,通常看一遍数据形状就能找到答案。这一步不到位的话,以后每次调试都会高度依赖别人,成长速度会大打折扣。

真实经验与后续扩展

按照我自己的经验,第三天的学习闭环做到这里,你实际上已经跑完了“从数据到模型再到部署前验证”的核心链路。很多人直到找工作面试时,才被要求手写一个训练循环、解释 DataLoader 的作用、说明模型验证为什么需要单独的目录。而这些早就在第三天的实战中被消化吸收了。

这个项目后续自然延伸的方向非常多:把二分类换成多分类、在模型结构中加入预训练权重做迁移学习、把推理脚本包装成一个 Flask 接口、引入 TensorBoard 可视化训练曲线。我可以肯定地告诉你,等到你回头进入这些进阶课题时,会明显感觉到自己的每一步都踩得很扎实。

最后分享一个只有自己动手写代码才会发现的经验:深度学习入门阶段,真正阻碍你的往往不是公式推导,而是工程习惯。目录怎么组织、代码怎么复用、遇到报错怎么排查。这些基本功越早补上,后面的路走得越顺畅。第三天,你的目标不是成为高手,而是把基本功练到形成肌肉记忆,从这个角度来说,今天已经值得了。

RELATED READING

延伸阅读

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