资讯详情

资讯详情

从零实现线性回归:手写训练闭环的关键细节与踩坑指南

1. 为什么线性回归值得徒手写一遍而不是直接调包很多初学者看到“线性回归从零开始实现”这个标题会想PyTorch里nn.Linear一行就搞定了手动实现有什么意义我最初也有这个想法毕竟李沐那本《动手学深度学习》前面几章看起来挺简单线性回归无非就是w乘x加b再算个损失。直到我真的跟着第三章把代码敲完才发现这个“简单”的章节里藏着整个深度学习训练流程的全部骨架数据集的构造、模型的定义、损失函数的选择、梯度的手动推导、参数的迭代更新——这五个环节在后续所有复杂的卷积网络、Transformer、扩散模型里一个都少不了。换句话说线性回归就是深度学习的最小可运行系统你在这一章里建立的“训练闭环”心智模型会在之后每读一个模型时反复被调用。李沐把这一节定位成“从零开始”意思是不借助任何深度学习框架的自动求导和封装层只用torch.Tensor和纯数学运算把整个训练过程写出来。官方教程里配套的代码很简单但正因为简单很多细节容易被一眼带过比如梯度为什么是x * (y_pred - y)比如为什么要b.grad.zero_()比如学习率0.03这个数字是怎么拍出来的。这些细节恰恰是手写代码时最容易卡壳的地方。如果你正准备啃《动手学深度学习》或者已经看完了课程视频但觉得“看懂了却写不出来”这篇文章就是为你准备的。我会把从零开始的完整实现拆开揉碎讲清楚每一步在干什么、为什么这么干、以及我实际跑代码时踩过的坑。提示阅读本文前至少要知道Pytorch Tensor的基本操作不用会自动求导因为这一节的核心恰恰是“不用自动求导”。2. 从造数据到梯度推导五个环节缺一不可2.1 造一份“看起来真实”的数据线性回归从零实现的第一步不是写模型而是先造数据。李沐的代码里用了标准的随机线性模型import torch def synthetic_data(w_true, b_true, num_examples): 生成 y Xw b 噪声 的模拟数据 X torch.normal(0, 1, (num_examples, len(w_true))) y torch.matmul(X, w_true) b_true y torch.normal(0, 0.01, y.shape) # 加入噪声 return X, y.reshape((-1, 1))这里有个值得琢磨的点为什么特征X要采样自标准正态分布而不是均匀分布原因有两个。第一标准正态分布在数学上方便推导后续如果要做更复杂的验证均值和方差都是已知的第二真实场景中许多特征本身就近似服从正态分布比如身高、考试成绩用正态分布造数据更贴近实际。噪声的方差0.01是人为设定的它决定了任务难度。噪声太大模型的拟合能力会被噪声淹没损失很难降下去噪声太小又显得太“假”体现不出泛化的意义。0.01这个值在视觉上会让结果看着几乎是一条干净的直线但又能明显感受到参数估计不是精确命中而是有一定波动非常适合展示训练收敛的过程。2.2 模型、损失和梯度先动手推导再写代码线性回归的模型长这样y_hat X w b损失函数用均方误差loss (1 / n) * sum((y_hat - y)^2)这些公式本身不复杂但真正考验人的是梯度的推导。手写训练闭环不允许调用loss.backward()你得先写出每个参数的偏导数再把它翻译成代码。对均方误差求偏导后梯度是这样梯度_w (1 / n) * X^T (y_hat - y) 梯度_b (1 / n) * sum(y_hat - y)我最初犯过一个经典错误想当然地以为梯度是X^T (y - y_hat)结果符号反了参数越更新越离谱。后来我总结了一个口诀算梯度的时候看的是损失对参数的敏感度不是参数对损失的敏感度方向反了就成了“梯度上升”每一步都在往损失增大的方向走。理论上梯度可以用数值法验证给w加一个小扰动看损失变化量除以扰动值近似等于解析梯度。我在复现时用过这个办法确实能快速定位行列是否对齐、符号是否写反。2.3 有了梯度怎么更新参数得到梯度之后更新规则是标准的随机梯度下降w - lr * grad_w b - lr * grad_b这里的核心超参数是学习率lr。李沐给的示例是lr0.03但如果你换一批数据或者改变特征的取值范围这个值可能需要重新调。学习率太小时收敛极慢可能几百轮都看不到明显变化学习率过大则会出现损失震荡甚至爆掉。我后来跑的时候把学习率调到0.1试过参数学得飞快但后期会在真实值附近来回震荡细看损失曲线像锯齿一样这时就需要配合衰减策略才能稳定。3. 手写训练闭环的关键代码与执行细节3.1 全套代码长什么样先把完整代码贴出来再逐段解释。注意这段代码的目的不是展示PyTorch的用法而是让你能看到“训练”的本质import torch # 1. 生成数据 true_w torch.tensor([4.0, -3.0]) true_b torch.tensor([2.0]) X, y synthetic_data(true_w, true_b, 1000) # 2. 初始化模型参数 w torch.normal(0, 0.01, size(2, 1), requires_gradTrue) b torch.zeros(1, requires_gradTrue) # 3. 定义模型和损失 def linreg(X, w, b): return torch.matmul(X, w) b def squared_loss(y_hat, y): return (y_hat - y.reshape(y_hat.shape)) ** 2 / 2 # 4. 定义优化算法 def sgd(params, lr, batch_size): with torch.no_grad(): for param in params: param - lr * param.grad / batch_size param.grad.zero_() # 5. 训练 lr 0.03 num_epochs 5 batch_size 32 net linreg loss squared_loss for epoch in range(num_epochs): for i in range(0, len(X), batch_size): batch_X X[i:ibatch_size] batch_y y[i:ibatch_size] l loss(net(batch_X, w, b), batch_y) l.sum().backward() sgd([w, b], lr, batch_size) with torch.no_grad(): train_l loss(net(X, w, b), y) print(fepoch {epoch 1}, loss {float(train_l.mean()):f})跑完5个epoch输出类似这样epoch 1, loss 2.145678 epoch 2, loss 0.283912 epoch 3, loss 0.044512 epoch 4, loss 0.008317 epoch 5, loss 0.002345最终打印学到的参数会很接近[4.0, -3.0]和2.0但不会完全相等。这个“不完全相等”不是缺陷而是随机梯度下降和噪声共同作用下的正常结果——理解这件事是从“照着代码敲”走向“真正理解训练”的关键一步。3.2 为什么w用正态初始化b用零初始化我见过不少人在这里纠结为什么w要torch.normal(0, 0.01)初始化而b直接设成0背后的逻辑说穿了很简单如果所有w都初始化为0那么在一个全连接层里每个神经元拿到的梯度在首轮是完全相同的因为输入特征和输出误差都一样所有参数会同方向更新相当于每层只有一个有效参数在学习这就是所谓的“对称性问题”。用随机小值初始化是为了打破对称性让每个参数走上不同的更新路径。b设成零则没有这个顾虑。偏置项不参与特征和参数之间的乘法交互哪怕初始为0第一轮梯度就能把它拉起来不会出现“所有偏置一样”的困扰。这也是PyTorch里nn.Linear默认偏置初始化的底层逻辑。如果你以后自己设计网络层初始化的套路基本就两条小随机数打破对称偏置尽量从0或极小值开始。3.3 损失的sum()和mean()之争以及除的那个batch_size在训练循环里我给每个batch计算损失后调用的是l.sum().backward()而不是l.mean().backward()。这有什么实质区别区别在于梯度的大小。如果一种实现用mean()那么梯度是每个样本梯度的平均值此时学习率的量级和batch大小无关如果用sum()梯度是每个样本梯度的总和batch越大梯度越猛学习率必须相应缩小。李沐代码里在sgd函数中手动除以batch_size本质上就是既用了sum()求梯度又在更新时做了平均的补偿。很多初学者会问“那这两个选哪个好”我的建议是锁定其中一种并且在调参时时刻记得自己选的是哪种。否则你会发现换了一个batch_size最优学习率突然就不起作用了——这很可能不是模型问题而是损失聚合方式变了梯度量级跟着变了。3.4 手动清零grad的必要性在sgd函数中有一行param.grad.zero_()这一行很容易被忽略但它的作用极其关键。PyTorch在backward()时是“累加”梯度而不是覆盖梯度。如果不手动清零每轮迭代后梯度就会叠加到之前的值上参数更新方向会被历史梯度污染收敛过程变得非常怪异——你以为模型在正常学习实际上它每次都在用“所有历史梯度的总和”更新自己。手动清零这个操作就是确保“这一轮的梯度只属于这一轮”。这个设计和我的一个旧习惯冲突过以前用纯NumPy实现时每次手动计算完梯度梯度变量就丢掉了不存在累加问题。换到PyTorch后自动求导的存在让梯度变成了“带记忆”的状态这恰恰是框架与纯手写最大的心智差异。你在读李沐的代码时看到每个batch的backward()之前或之后都有zero_要形成条件反射以后自己写训练循环才不会踩坑。4. 收敛过程可视化loss曲线、参数逼近与学习率观察4.1 画出误差曲线训练才真正“看得见”我在第一次手写实现时只盯着loss数值看总觉得少点什么。后来把过程中的loss记录到列表里绘制出来才真正感受到梯度下降的节奏。loss_history [] for epoch in range(num_epochs): for i in range(0, len(X), batch_size): batch_X X[i:ibatch_size] batch_y y[i:ibatch_size] l loss(net(batch_X, w, b), batch_y) l.sum().backward() sgd([w, b], lr, batch_size) with torch.no_grad(): train_l loss(net(X, w, b), y) loss_history.append(train_l.mean().item())把loss_history用matplotlib画出来会得到一条陡峭下降后趋于平缓的曲线。第一轮epoch结束loss可能还在2.0以上第二轮就到了0.28第三轮0.04之后就贴着噪声水平缓慢下降了。这个形态是所有训练过程的共同模板前期是快速下降的“学习期”后期是缓慢逼近的“微调期”。4.2 把参数逼近过程也画出来能治好你对“训练到底在干嘛”的困惑只画loss还不够。真正的顿悟来自于同时追踪w和b在每一轮后的取值。我在代码里加了一行记录param_history.append((w.detach().clone().numpy(), b.detach().clone().numpy()))跑完后把每一轮学到的w[0]和w[1]画成折线你会看到它们从最初接近0的随机起点一步步逼近true_w[4.0, -3.0]而且在逼近目标后还会有小幅抖动。这个抖动的大小和噪声方差、学习率有关并不需要太担心。这种可视化方法有一个非常实用的价值当你的模型学歪了curve可视化能立刻告诉你是整体方向不对还是在某个维度上震荡。比如我调试时发现w[1]在-2.8到-3.2之间反复横跳但w[0]已经收敛到3.99附近这往往是某个特征的方差太大导致的需要做特征标准化。数据标准化这个坑书里提了一句但没展开实际中却非常重要。如果你造的数据里x1范围是0.01~0.02而x2范围是100~200那么两个参数的收敛速度会有天壤之别梯度下降会变得难以捉摸。4.3 学习率从“跑不动”到“飞出去”边界在哪里学习率是手写训练闭环里最敏感的一个旋钮。我在复现时试过三组值结果极具参考性学习率现象原因0.0035个epoch后loss才1.5左右参数远未收敛步长太小需要更多轮数才能到达目标区域0.035个epoch后loss降到0.002附近参数基本收敛书上的默认值正好在“稳而快”的区间1.0loss瞬间变成NaN或巨大数值参数直接飞了步长太大每次更新跨过了目标区域梯度在震荡中不断放大实际操作中如果遇到NaN第一反应不是检查数据有没有脏值而是先检查学习率。线性回归这种凸函数都被学习率干翻了更复杂的非凸问题更敏感。手写代码最大的好处就是你能看到参数的每一步变化稍微加几行打印就能定位为“学习率”还是“梯度错误”造成的发散。提示判断学习率是否过大有一个快速方法——打印训练初期一轮内的loss变化如果loss在第一轮内不降反升或者剧烈震荡大概率是学习率偏大建议把学习率除以10再看。4.4 batch_size的影响一次看多少本书再总结规律随机梯度下降里的batch_size是另一个影响迭代节奏的参数。李沐示例里用了32我尝试过1、16、64三档体会如下batch_size1每个样本都更新一次参数梯度噪声极大收敛不光滑但乍一看loss降得很快因为每一轮步数多。batch_size16折中方案训练曲线噪声可控收敛速度也比较快。batch_size64每个epoch的参数更新次数少了但梯度方向更稳定后期loss曲线平滑前期收敛速度稍慢一些。为什么会这样因为batch_size越大梯度是对更多样本的“平均意见”方差更小方向更接近全局梯度但权重更新次数也少了整体收敛步数变少。你可以把它想象成调查民意问1个人得出的方向很随机问64个人得出的方向很靠谱但你问64个人需要花更多时间只能少问几轮。5. 我在复现时踩过的坑和调试思路5.1 坑一广播机制把loss的形状悄悄变了这个坑出现在计算损失的那一行。我的原始代码长这样def squared_loss(y_hat, y): return (y_hat - y) ** 2 / 2看起来挺对但在训练循环里batch_y是从y切片来的形状是(32, 1)而y_hat也是(32, 1)两者相减没问题。可一旦某个batch恰好只有一条数据y_hat变成(1,)而y还是(1, 1)广播机制会悄悄把形状变成(1, 1)代码不报错但语义已经变了。这种“静默广播”问题极难定位因为程序跑得很顺畅loss数值也正常但结果就是不收敛。后来我学乖了在损失函数里强制reshapey y.reshape(y_hat.shape)这样能保证两个张量的形状永远一致避免广播歧义。这种问题在纯手写代码里很常见因为你不依赖高层API帮你管好形状每一处都得自己负责。排查时最简单的方法是加断言assert y_hat.shape y.shape报错即暴露。5.2 坑二梯度下降每一步都用no_grad还是偶尔忘记了在sgd函数里我们手动修改param的数值用的是param - lr * param.grad / batch_size。如果不在torch.no_grad()上下文里执行这个操作PyTorch会把这个“参数更新”也记录到计算图里产生新的梯度路径导致后续backward时计算图越滚越大训练速度越来越慢甚至显存暴涨。我一开始偷懒没加no_grad跑了几个epoch后感觉代码越来越慢忍不住打了一堆print排查最后才想到是计算图在累积。这个问题的隐蔽性在于前几个epoch非常快几乎察觉不到异常但累积到一定量级后计算图和内存占用会像滚雪球一样膨胀。写手写训练闭环时请养成一个习惯凡是手动修改参数的操作都包在with torch.no_grad():里。我曾经见过有同学在参数更新后又调用了一次损失计算导致参数更新也被纳入了计算图整个调试过程非常崩溃。5.3 坑三梯度为0参数纹丝不动到底哪里错了另一次卡了我很久的问题是打印梯度时发现param.grad竟然全是0参数根本不动。检查代码模型、损失、初始化都看不出问题。最后发现我在构造w的时候用了.detach().clone()再赋值导致后面的requires_grad标志没有传播过去。还有些同学会在中途对w做原地操作时不小心让requires_grad消失。排查思路其实很直接在第一个batch前手动打印w.grad看看有没有值没有值再检查requires_grad是否为True一层层往上倒追。很多看起来神秘的问题最后都落在这类“看似无关紧要的Tensor状态”上。手写代码的优势就在于每一条计算链路都是自己搭的只要耐心打点逐段排查很容易找到断点。5.4 坑四特征排列顺序对收敛速度的影响还有一个容易被忽略的细节特征的量纲差异。李沐书里代码默认X ~ N(0, 1)所以不需要标准化。但如果你照着实现却把真实数据换成房价预测之类的场景——面积可能是几十到几百平米房龄是1到50年房间数是1到10——三个特征的方差差距就很大。梯度下降在量纲差异大的特征上会表现得很奇怪梯度更新的主要方向被数值大的特征主导数值小的特征几乎学不动。这不是线性回归的缺陷而是朴素梯度下降的固有弱点。碰到这种数据建议先把每个特征减去均值、除以标准差再做训练。我把这个测试做过一个有趣的对照同一份数据标准化前w1和w2的收敛速度差了3倍以上标准化后几乎同步收敛最终精度也更好。现在再做线性回归的从零实现我会直接默认数据标准化即便当前数据本来就不需要也能避免很多隐含问题。6. 手写实现与PyTorch高层API的衔接6.1 从手写代码到nn.Linear只是封装不是魔法李沐在后面的章节里会切换到nn.Linear、nn.MSELoss、optim.SGD这些高层API这会让代码大幅精简。有人担心前面花这么大力气手写会不会白费完全不会。nn.Linear的底层逻辑和我们的手写实现几乎一模一样初始化一个权重矩阵和一个偏置向量前向就是x weight.T bias。nn.MSELoss等价于我们定义的squared_loss只是额外做了mean归一化。optim.SGD则对应我们的sgd函数只是自动处理了梯度的清零、更新和参数状态管理。有一次我用nn.Linear替换了手写模型后梯度曲线的走向和手写时完全一致唯一的差异是nn.MSELoss默认除以样本数导致loss数值比手写里sum()后除以batch_size略有不同。这就验证了一件事框架做得再高级底层没有魔法只是把我们从零实现时的数学和步骤打包了。6.2 什么情况下还值得继续手写在快速迭代项目里我不会傻傻地手写每一个模型。但下面这三种情况我一定会回到手写方式调试复杂模型时。当Transformer的训练loss诡异暴涨框架自动求导又看不出问题所在时我会把某个最小子模块比如单个注意力头的梯度用手写方式复算一遍对比两边梯度是否一致。这种方法帮我找出过两个非常隐蔽的bug。研究新优化器时。想试一个新优化器、新损失函数手写梯度是最快验证想法的方式。直接改几行数学代码比翻阅框架文档找有没有内置实现更快。教学和讲解时。给别人讲清楚“训练到底是什么”手写一个回归版本往往比对着框架文档说一百句都有效。6.3 手写过程中的代码组织心得最后顺手分享一个我后期总结的代码组织习惯。不要把所有代码塞进一个单元格或一个脚本里而是按模块拆开哪怕只是一个几十行的demodata.py # 数据生成 model.py # 模型定义 loss.py # 损失函数 sgd.py # 优化器 train.py # 训练循环一开始我觉得这种拆法小题大做但当我需要同时调试几个不同版本的学习率、初始化方案时立刻感受到了好处每改一个环节只需要动一个文件也不会因为修改了数据生成代码而误伤训练循环。等后续学CNN、RNN时这个习惯会让你在“手写闭环”的基础上更好地理解更复杂的框架流程。7. 从这个最小闭环延伸下一步还能验证什么把线性回归从零实现跑通之后你可以在这个极简框架上做几个小实验每一项都花不了多少时间但对理解深度学习有实实在在的加成把学习率改成0.1用一个很小的数据集观察参数是不是在真值附近来回震荡理解“不收敛”和“震荡”的边界。把噪声方差从0.01改成0.5看看loss降到多少以后就不再下降了——这是“模型容量和噪声底限”的最直观感受。把batch_size改成1跑够几十个epoch体会随机梯度下降与全量梯度下降的差异。把“线性”模型改成带ReLU的两层网络你会发现同样的训练闭环代码几乎不用大改这恰恰说明了线性回归这一章的普适性。我在这里折腾了两天最大的收获不是学会了怎么用PyTorch而是理解了一条最根本的原则训练一个模型本质上就是反复重复“前向计算、求出误差、根据误差调整参数”这三件事。后面所有看似高深的架构无论是CNN里的卷积核、Transformer里的注意力矩阵还是GAN里的对抗博弈核心都没有逃开这个循环。先把最小闭环吃透再去看复杂的模型你会发现自己看得懂的不只是代码而是代码背后那一整套“为什么这样设计”的逻辑。如果你正在逐行啃李沐的书我特别建议你试着不看答案把这一节完整重写一遍再对照书上代码找出差异。你会发现自己写出来的代码和书上的代码可能风格迥异但训练效果殊途同归。那一刻你才算是真正把这个最小闭环消化成了自己的东西。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →