ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

深度学习第三天:手动推反向传播,训练多层感知机

深度学习第三天:手动推反向传播,训练多层感知机

深度学习第三天,这个标题本身就很有意思。如果你正处于这个阶段,大概率已经熬过了环境配置的第一天,挺过了被矩阵运算和导数折磨的第二天,正处在一种“好像懂了一点,但又说不清自己到底会什么”的微妙状态里。这篇文章就是写给此刻的你:一个刚摸到深度学习门槛、还没完全推开门的新手。今天不谈大模型,不聊Transformer,只把第三天真正该做的几件事讲透——手动跑通一次反向传播,亲手训练一个最基础的多层感知机,并且搞清楚每一个环节到底在做什么。对于已经在搬砖的开发者,也可以用十分钟快速扫一眼,看看有没有理解不到位的地方。

1. 第三天开跑前:先把前两天的基础盘点清楚

1.1 环境与工具链:别在起跑线上反复折腾

关于环境,我给新手一个直到今天仍然适用的建议:不要追求一步到位,也不要为了“显得专业”去折腾GPU版本。第三天这个阶段,你的任务不是训练大规模模型,而是理解梯度从loss一路回传到每个参数的过程,CPU完全够用,甚至更合适——因为CPU上更容易观察每一步的运行逻辑,出了问题也更可控。

我见过有个朋友,第一天装环境就花了一整天,从Python版本、显卡驱动一路排查下来,最后发现只是某个镜像源配置不对。装环境这件事,卡两天就算失败案例了。如果你现在还在环境里挣扎,直接在干净的环境里用最新稳定版Python,配合官方渠道安装不带GPU的深度学习框架即可。具体记不清命令也没关系,搜索“对应框架 安装 官方”比记笔记更实际。

第三天真正要用到的工具清单其实很短:一个支持代码单元格的笔记本环境,最新稳定版的深度学习框架,matplotlib用来画loss曲线和数据分布,再加一个sklearn用来生成实验数据。这四个就够,一个都不需要多装。如果已经装了其他框架也没事,但我个人建议第三天先统一用同一个框架练手,免得被两套API来回切换搞到怀疑人生。

1.2 从感知机到损失函数:第三天需要的“最小前置知识”

前两天你大概率接触过感知机。很多人学到这里会有一个困惑:“单个感知机只能处理线性分类,我学它干什么?”实际上感知机是整个深度学习最小的积木。输入数据乘上权重,加上偏置,过一个激活函数,得到输出——这句话第三天依然成立,只不过积木更多了,堆叠成了“多层感知机”。

第三天开始动手前,有三个概念必须真懂,不是看过就算懂:

  • 损失函数:衡量模型预测和真实答案的差距,是深度学习的“方向盘”。拿MSE举例,预测值越接近真实值,损失越小。
  • 梯度:损失函数对每个参数的导数。梯度告诉你的是“如果把这个参数往某个方向调一点点,损失是变大还是变小”。梯度下降就是沿着让损失变小的方向更新参数。
  • 学习率:每次参数更新的步幅。步幅太大容易直接跳过最低点,太小则需要很久才能走到。

这三个概念不需要你数学推导得非常严谨,但一定要能用自己的话解释清楚。一个特别形象的理解方式是把损失函数想象成一片起伏的山地,你站在某个位置,手里有一个高度计(loss),梯度就是脚下山坡的倾斜方向,学习率是你每步迈多远。深度学习训练,本质上就是在天黑之前“摸着坡往下走”,走到最低点附近就算训练成功了。

有了这个基础,第三天的重头戏就可以开始了:把“梯度到底怎么从最末尾的loss传到最前面的参数”这件事亲手拆开看一遍。这就是反向传播。

2. 第三天的核心任务:亲手跑通反向传播

2.1 手动推一遍反向传播,比看十遍视频都管用

很多课程会把反向传播讲成“链式法则的应用”,这句话对,但对新手来说太抽象了。所以我建议第三天第一件事:拿草稿纸,把一个极小的网络手动算一遍。别看视频里别人推得轻松,自己动手写过一次之后,backward()在你眼里就不再是黑魔法了。

我们构造一个最简单的网络。输入 x=1,真实标签 y=0。模型只有两层:隐藏层一个神经元,输出层一个神经元,激活函数都用sigmoid。

初始参数随便定一组好算的:w1=0.5,b1=0,w2=0.5,b2=0。损失函数用均方误差,写成 L = 1/2 (y_pred - y)^2,前面的1/2是为了求导之后式子干净,不影响结果。

前向传播,就是从输入一路计算到loss:

z1 = w1 * x + b1 = 0.5 h = sigmoid(0.5) ≈ 0.62246 z2 = w2 * h + b2 = 0.5 × 0.62246 ≈ 0.31123 y_pred = sigmoid(0.31123) ≈ 0.57719 L = 0.5 × (0.57719 - 0)^2 ≈ 0.16658

这个loss告诉我们:现在的预测离0还有点远,需要更新参数。接下来就是反向传播,核心只有一件事——用链式法则从后往前,把loss对每个参数的“敏感度”(即梯度)算出来。

先算loss对输出层的梯度:

dL/dy_pred = y_pred - y ≈ 0.57719 dL/dz2 = dL/dy_pred × sigmoid'(z2) ≈ 0.57719 × 0.57719 × (1 - 0.57719) ≈ 0.14085 dl/dw2 = dL/dz2 × h ≈ 0.14085 × 0.62246 ≈ 0.08767 dl/db2 = dL/dz2 ≈ 0.14085

然后梯度继续往前传到隐藏层:

dl/dh = dL/dz2 × w2 ≈ 0.14085 × 0.5 ≈ 0.07043 dl/dz1 = dl/dh × sigmoid'(z1) ≈ 0.07043 × 0.62246 × (1 - 0.62246) ≈ 0.01655 dl/dw1 = dl/dz1 × x ≈ 0.01655 dl/db1 = dl/dz1 ≈ 0.01655

注意观察:梯度是从loss出发,逐层往前“倒推”出来的。某一层的梯度,一定依赖后一层传回来的梯度和这一层前向计算时的中间结果。这就是“反向传播”四个字的含义。

有了梯度,就可以用梯度下降更新参数了。设定学习率 lr=0.5:

w1_new = 0.5 - 0.5 × 0.01655 ≈ 0.49173 w2_new = 0.5 - 0.5 × 0.08767 ≈ 0.45617 b1_new = 0 - 0.5 × 0.01655 ≈ -0.00828 b2_new = 0 - 0.5 × 0.14085 ≈ -0.07043

更新完成后,重新前向计算,loss会从0.16658微微下降。训练一轮下来loss只要变小,就说明参数更新的方向是对的。你可以自己再手推一两轮,感受一下。如果推完第三轮发现loss继续下降,你其实已经掌握了深度学习训练最核心的机制。

2.2 用代码验证手算结果:自动求导不是黑魔法

很多初学者第一次用自动求导时,看到一行backward就能拿到全部梯度,会觉得很神奇。其实它的原理和上面手算一模一样,只是把链式法则自动跑了一遍。为了让你彻底放心,这里再用代码验证一下手算的结果。

import torch x = torch.tensor([1.0]) y = torch.tensor([0.0]) w1 = torch.tensor([0.5], requires_grad=True) b1 = torch.tensor([0.0], requires_grad=True) w2 = torch.tensor([0.5], requires_grad=True) b2 = torch.tensor([0.0], requires_grad=True) z1 = w1 * x + b1 h = torch.sigmoid(z1) z2 = w2 * h + b2 y_pred = torch.sigmoid(z2) loss = 0.5 * (y_pred - y).pow(2).sum() loss.backward() print(w1.grad.item(), b1.grad.item()) # 大约 0.01655, 0.01655 print(w2.grad.item(), b2.grad.item()) # 大约 0.08767, 0.14085

运行结果应该会非常接近手算的数值。这里有几个细节新手容易忽略:

  • 只有设置 requires_grad=True 的张量会被记录梯度。
  • backward() 之后梯度会累加到 .grad 里,所以每次更新完要用 zero_grad() 手动清零,否则下一轮梯度会叠加出错。
  • 中间变量也在计算图里,等到内存释放后就不能再取它们的梯度了。实际工程里基本只关心参数层的梯度。

你在笔记本里把这段代码跑一遍,和手算结果对比。如果数值能对上,说明你既能“看见”反向传播,又会用工具验证自己学得对不对——这个能力会一直陪着你走完后期的路。

3. 实战:训练一个真正能分类的多层感知机

3.1 数据准备与加载:从二维月亮数据开始最合适

手推和验证都做完后,可以上一个有真实感的任务了。我推荐用二维月亮数据做二分类。为什么?因为这种数据是从一个月牙形分布里采样出来的,有明显的非线性边界,用单层感知机很难分类,但用一个小小的多层感知机就能轻松搞定——正好能让你体会到“多层”带来的效果提升。

先造数据。下面的代码会生成200个二维样本,标签只有0和1:

from sklearn.datasets import make_moons import torch from torch.utils.data import TensorDataset, DataLoader X, y = make_moons(n_samples=200, noise=0.1, random_state=0) X_tensor = torch.tensor(X, dtype=torch.float32) y_tensor = torch.tensor(y, dtype=torch.float32).unsqueeze(1) dataset = TensorDataset(X_tensor, y_tensor) loader = DataLoader(dataset, batch_size=16, shuffle=True)

这里有两个细节值得说一下。

第一,y要加一个维度。用 torch.tensor(y) 得到的是形状 (200,) 的向量,但模型输出的是 (200, 1),直接用BCELoss计算时会对不上。所以用 unsqueeze(1) 把它变成 (200, 1)。

第二,数据要先转成浮点型。很多人后面会遇到“expected float but got long”之类的报错,就是因为整数张量没有转 float32。

DataLoader的作用是把数据分成一小批一小批地喂给模型,batch_size=16 表示每批16个样本,shuffle=True 让每轮epoch的数据顺序被打乱。小批量训练的意义不只是省内存,它还能让梯度更新带一点随机性,有时候反而能帮助模型逃离局部较差的位置。

3.2 模型搭建与训练循环:四行代码跑通一个网络

有了数据,可以定义模型了。今天只需要一个最简单的多层感知机:

import torch.nn as nn model = nn.Sequential( nn.Linear(2, 16), nn.ReLU(), nn.Linear(16, 1), nn.Sigmoid() ) criterion = nn.BCELoss() optimizer = torch.optim.SGD(model.parameters(), lr=0.1)

模型结构是:2个输入特征 → 16个隐藏神经元 → 1个输出概率。隐藏层的激活函数选ReLU而不是sigmoid,是因为sigmoid在两端饱和时梯度接近0,多层堆叠后会快速传播消失;ReLU在大范围内梯度稳定是1,训练更稳。这个问题第三天先有个概念,后续做深层网络时会深有体会。

损失函数选BCELoss(二值交叉熵)而不是MSE,原因有两个:二分类问题模型输出是概率,交叉熵衡量的是“预测分布和真实分布之间的差异”,数学性质更匹配;交叉熵的梯度在预测很差时会比较大,而MSE在输出层可能因为sigmoid饱和导致梯度很小,训练效率偏低。

训练循环看起来非常简单,核心就四行:

for epoch in range(200): for x_batch, y_batch in loader: pred = model(x_batch) loss = criterion(pred, y_batch) optimizer.zero_grad() loss.backward() optimizer.step()

很多新手看到这段代码,会以为“训练”的秘诀就在这里。实际上这四行的顺序是有讲究的:先算预测,再算loss;zero_grad清零上一轮梯度;backward算出梯度;step沿着梯度更新参数。这四行你会在后面的每一个模型里反复见到,它们就是整个深度学习训练循环的核心骨架。

如果你想知道训练是否有效,最直接的办法是记录每一个epoch结束后的loss。我建议用一个列表把历史值存下来:

loss_history = [] for epoch in range(200): epoch_loss = 0.0 for x_batch, y_batch in loader: pred = model(x_batch) loss = criterion(pred, y_batch) optimizer.zero_grad() loss.backward() optimizer.step() epoch_loss += loss.item() * x_batch.size(0) loss_history.append(epoch_loss / len(X_tensor))

训练结束后用matplotlib把曲线画出来看一眼。一个正常的训练过程应该是loss整体向下走,初期下降比较快,后期趋于平缓。如果你看到的是锯齿状震荡,多半是学习率偏大;如果是平直线,多半是梯度没更新出来。

3.3 训练结果的解读:loss降到多少才算正常

第三天最容易出现的心理活动是:“我的loss好像不降了,是不是模型坏了?”先放个标准:今天这种小规模分类任务,初始loss通常在0.69附近(因为刚开始sigmoid输出接近0.5,交叉熵计算值约为0.69),训练到200轮左右,loss应该能降到0.2左右,准确率可以达到90%以上。这个数值本身不重要,重要的是曲线呈现“下降→变缓→平稳”的形态。

判断模型学得好不好,不要只看loss,还要看准确率。用下面这段代码把预测和标签对比一下:

preds = model(X_tensor) > 0.5 correct = (preds.float() == y_tensor).sum().item() accuracy = correct / len(y_tensor) print(f"accuracy: {accuracy:.2%}")

如果准确率超过90%,说明这个两层的网络确实把非线性边界学出来了。如果只盯着loss看不下降,其实很可能准确率已经很高,只是尾部样本让loss无法再降。所以以后每次训练,把loss曲线和准确率放在一起看,才不会被单一指标误导。

另一个值得做的事情是画决策边界。在二维平面上把每个点预测成0或1的区域用不同颜色画出来,你能直观看到模型学到的分界线。这种可视化能力很重要——它能帮你在跑大模型之前快速判断“模型是不是在乱猜”。

4. 第三天高频踩坑:我从新手阶段总结的教训

4.1 loss纹丝不动:先查初始化、学习率和梯度

我见过太多新手在第三天卡在这样一个现象上:loss一开始是0.69,训练了50轮还是0.69,纹丝不动。很多人第一反应是“网络结构写错了”,但大概率不是。

排查顺序应该固定。先看模型输出的pred是不是几乎全是0.5——如果是,说明参数根本没有有效更新。然后手动检查第一次backward之后的梯度,如果梯度全部是0,问题几乎一定出在输入数据范围异常、loss函数写错或者模型输出被某个操作“断开”了。如果梯度有值但loss不变,大概率是学习率设得离谱地小或者大。

对新手来说,把学习率从0.1改成0.01再试一次,比反复怀疑模型结构有效得多。如果换了SGD还不行,可以直接换Adam优化器。不是说要迷信Adam,只是在第三天这个阶段,Adam对学习率的敏感度更低,能让新手把注意力集中在“理解训练循环”上。等后面经验多了再回头研究原理。

也别忘了检查数据范围。如果输入里有未归一化的极大数据,sigmoid这类激活函数会直接饱和,梯度趋近于0,训练自然停摆。所以第三步先把输入范围压到0附近的小数值区域,再重跑一次。

如果loss下降了一点点,但后面再也不动,可以尝试调大学习率。另外,如果模型初始化时权重全为0,所有神经元算出来的梯度会完全相同,网络会退化得很严重。这是第三天很经典的坑:全0初始化对多层感知机是大忌,框架默认的随机初始化方式反而是安全选择。

4.2 过拟合出现得比预想早:别慌,这很正常

很多新手以为过拟合是后面才需要考虑的事,其实在第三天这个小网络里就会遇到。比如训练集200个样本,训练100轮后训练集loss降到0.05,但测试集loss停在0.3。这说明模型开始“背答案”而不是“找规律”了。

第三天遇到过一次,反而是好事。因为过拟合和欠拟合这对概念,只有亲自看到一次现象才能真正理解。什么算欠拟合?训练和测试的loss都高,就是模型能力不够,处理不了数据里的非线性结构。什么算过拟合?训练还在降,测试不降甚至上升,就是模型在记忆训练集。这套判断标准适用于之后所有模型,越早建立越好。

如果你发现过拟合,第三天不要求彻底解决,但要至少知道两条缓解思路:一是减小模型容量,比如把16个隐藏神经元减成4个,通常能直接改善;二是加Dropout或L2正则,让模型不要那么“用力”地记住每个样本。等后续学习的数据量变大,再系统地学正则化技术。

我自己的经验是,新手第三天先不用刻意练习调参,但要在训练日志里同时记录训练集和测试集的loss曲线。这一步的价值会在第四天、第十天逐渐显现出来。

4.3 张量形状报错:一个debug模板解决大部分问题

第三天写代码时,你遇到的报错里多半是维度匹配问题。这类报错其实很固定,只要掌握打印shape的习惯,基本都能快速定位。下面这些是初学者代码里最常见的场景:

报错场景可能原因标准处理
model(x_batch) 时报错 mat1 and mat2 shapes cannot be multiplied输入张量不是二维 (batch, features)检查x_batch.shape,必要时用x.unsqueeze(0)补一个batch维
BCELoss 报错 target size must match input size预测是 (batch,1),标签是 (batch,)训练前给标签加维度 y.unsqueeze(1)
backward() 报错 element 0 of tensors does not require grad参数忘了requires_grad=True检查模型参数是否被包在nn.Linear里正常创建
训练中 loss 出现 nan学习率过大或数据包含异常值调低学习率,检查输入有无nan

每次排查维度问题,我都会在模型forward之前加两行:

print("input shape:", x_batch.shape) print("target shape:", y_batch.shape)

看到形状之后问题通常一眼就能发现。想更严格一点,可以在调用模型前加断言,让程序在错误发生前就拦住问题。这个习惯会让调试变得非常舒服,后面接触CNN时输入维度更复杂,靠shape日志能帮你省下大量时间。

这些坑看起来都很“小”,但如果你在第三天就能自己解决,说明你已经具备了新手阶段最重要的独立调试能力。不要觉得这些是不值一提的琐事,工程上大部分时间都在处理形状、类型、范围这些小问题,能快速定位它们是很实用的能力。

5. 第三天结束后:下一步怎么学才不会断档

5.1 先别急着上CNN,把优化器吃透再说

把今天的手推反向传播和MLP分类都跑通之后,很多人会想:明天是不是可以上CNN了?我的建议是,先别急,花一整天把优化器摸透,收获会更大。

CNN和MLP的区别主要在模型结构,训练流程完全没变,还是那四行训练循环。真正会影响你能不能稳定训出一个好模型的因素,反而是优化器里的这些细节:梯度下降、随机梯度下降、冲量(Momentum)、自适应学习率(Adam)。你要知道,小批量训练为什么会让loss曲线震荡?动量是怎么帮助越过局部洼地的?Adam为什么对学习率没那么敏感?这些问题看起来偏底层,但它们决定了你以后训练任何一个网络时,遇到loss不降能不能自己定位问题。

建议的第四天清单是这样的:先用SGD不同学习率训练同一个MLP,画三条loss曲线观察差异;然后换Momentum和Adam,对比速度和稳定性;最后手动实现一个非常简化的Adam更新公式,哪怕运行结果和官方库不完全一致,只要过程理解了,你对“优化器无非也是梯度下降的变体”这句话会有非常扎实的感受。

有了这个基础,后面上CNN、RNN、Transformer时,脑子里就有一条清晰的“模型换、流程不变”的主线,不会被每个新架构带偏。

5.2 把今天的成果浓缩成一张速查清单

第三天的信息量很大,我建议在结束前把今天做的事浓缩成一张属于你自己的清单。它以后可以当成模板,每次接到新任务,按这个清单走一遍,思路会非常清晰:

  1. 准备数据:装进TensorDataset + DataLoader,确认shape和dtype,必要时归一化。
  2. 定义模型:小步快跑,从最简单结构开始,逐步加容量。
  3. 配置学习信号:损失函数、优化器、学习率。
  4. 训练循环:前向→算loss→zero_grad→backward→step,永远保持这个顺序。
  5. 记录过程:每个epoch保存loss和准确率,画曲线。
  6. 观察判断:loss是否稳定下降,训练/测试是否分离,形状是否对。

这张清单的价值在于,今后你换数据、换模型、换任务,核心骨架都不变,变的只是其中某一块。比如做图像分类时,把“MLP模型”换成“CNN模型”,数据加载换成图像读取;做文本分类时,再换成“嵌入加RNN”。框架不变,替换组件,这就是深度学习工程里最底层的应用模式。

另外再提一个小技巧:每次训练完把关键参数(比如学习率、隐藏层大小、最终准确率)记录在一个小表格里。这能帮你建立起数字直觉——同样是两层MLP,隐藏层从16加到32准确率提升并不明显,但训练时间翻倍了。有了这些真实数据,后面调参就不是拍脑袋了。

写到这里,第三天该做的事基本就讲完了。我个人带过不少初学者,三天是一个很明显的分水岭:前一天还可以靠看视频、抄代码蒙混过关,但从今天手动推反向传播开始,不亲自动手算一遍,后续每一个环节都会带着“好像懂了但又不会写”的别扭感。我自己当年也在这个位置卡过很久,后来发现真正让我转过弯来的,就是拿草稿纸把那个小网络一层一层算到底。所以如果你今天已经把反向传播推通了,还亲手训出一个loss顺利下降的网络,那恭喜,你后面所有模型的学习,本质上都是在今天这套流程上换组件。接下来就是见多识广的问题了,慢慢来,不用急。

RELATED READING

延伸阅读

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