ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

手写线性回归:用NumPy亲手搭建完整训练闭环

手写线性回归:用NumPy亲手搭建完整训练闭环 如果你正在啃《动手学深度学习》李沐版刚好翻到线性回归那一章我的建议很直接先把课本里“从零开始实现”的代码丢到一边用NumPy把整个训练闭环亲手敲一遍。不是PyTorch不香而是自动求导、优化器step、DataLoader这些封装会把训练闭环里最关键的梯度计算和参数更新全都包起来。我读这本书的线性回归章节时最大的收获不是会调API了而是搞懂了模型到底是怎么一步步“学”出来的。这篇笔记记录我用纯NumPy手写线性回归完整训练过程的方法不依赖任何自动微分库从造数据到批量读取、从损失计算到梯度更新、从训练循环到参数验证全部自己控制。适合两类人一是正在读这本书但被代码细节卡住的学习者二是用框架能跑通但心里发虚、想补底层功底的开发者。整条链路走完也就几十行代码但每一步都能看见、能调试、能验证。1. 先聊清楚什么叫“从零开始的训练闭环”1.1 框架把哪几步藏起来了我见过不少同学的第一个深度学习程序都是这样的定义好网络选择优化器调用loss.backward()然后optimizer.step()十个来回loss就下降了。跑得通但心里不踏实。为什么因为框架把训练中最核心的几步都替你做了决策。自动求导替你算了梯度但你不知道这个梯度是怎么来的你也不知道为什么sgd里要除以batch_sizeDataLoader自动帮你打乱和分桶但你不知道最后一个批次不足怎么办损失函数API帮你做了mean还是sum的决策但你可能根本没注意到这个细节对学习率的影响。这些问题在原始数据集、大模型、复杂网络结构下面会变成灾难。一旦loss不降反升你连从哪一步开始排查都不知道。手写线性回归的意义正好在这里把框架替你做的决策全部还给你逼着你理解每一个环节的设计理由。训练闭环出了问题你能顺着链路一环一环定位。1.2 训练闭环的七个环节我一直跟学弟学妹强调深度学习的训练不是一段代码而是一个闭环。这个闭环至少有七个环节准备数据生成或加载样本确定特征与标签的对应关系划分批次把数据按batch_size打乱、切片供迭代读取前向计算用当前参数计算预测值损失评估衡量预测值与真实标签的差距梯度计算求出损失对每个参数的偏导数参数更新沿着负梯度方向调整参数循环与验证重复2到6直到收敛并用验证手段确认学到的参数可信。框架把它封装成“调包”手写则是“修路”。修路的过程会把你对模型的理解从表面推向实在处。后面的内容就按这七个环节展开每一段代码都是闭环上的一环。2. 数据准备让训练闭环从源头就“可见”2.1 为什么要用合成数据做实验线性回归最经典的做法是拿真实数据集来拟合比如房价、销量、温度。但真实数据集有一个致命的缺点你不知道真实的函数关系是什么所以就算训练出来一个效果不错的模型你也没法判断参数学得对不对。所以我强烈建议第一步先用合成数据。自己指定权重和偏置再往标签里加一点噪声等于“考试前就知道答案”。训练完之后直接把学到的参数和真实参数摆在一起对比误差一目了然。这样整个训练闭环的最后一步“验证”就落到了实处。这里我沿用书里的设置两个特征真实权重是[2.0, -3.4]真实偏置是4.2样本数1000特征服从标准正态分布噪声的标准差取0.01。代码如下import numpy as np num_examples 1000 true_w np.array([2.0, -3.4]) true_b 4.2 features np.random.normal(0, 1, (num_examples, len(true_w))) labels np.dot(features, true_w) true_b labels np.random.normal(0, 0.01, labels.shape)为什么特征用标准正态分布因为这样每个特征的数值范围比较规整梯度问题相对好调也方便后面可视化。为什么噪声标准差用0.01而不是0因为完全没有噪声的数据在现实中不存在而且如果噪声为零模型可以无损还原参数学不到“在噪声中找规律”的感觉。0.01这个值很小信噪比高即便只跑三轮epoch也能学到接近真值的参数适合初学者建立信心。2.2 手写data_iter批量读取比你想的更讲究有了数据就得考虑怎么喂给模型。一次性把所有1000个样本都算一遍梯度理论上可以但计算量太大参数更新也太慢。一个样本更新一次计算虽小但梯度方向抖动剧烈收敛不稳定。工程上最常用的是取个折中小批量随机梯度下降即每次随机抽取一小批样本算梯度、更新参数。这里batch_size设成10这正是李沐书里的经典取值。为什么是10而不是7或者13没有玄学它只是足够小能让参数在三个epoch内更新足够多次又足够大让梯度方向不至于太飘。后面你可以把它改成1、32、256亲身感受差别。手写一个data_iter生成器核心是用yield实现惰性返回每调用一次就吐出一个批次的样本def data_iter(batch_size, features, labels): num_examples len(features) indices list(range(num_examples)) np.random.shuffle(indices) for i in range(0, num_examples, batch_size): batch_indices np.array( indices[i: min(i batch_size, num_examples)] ) yield features[batch_indices], labels[batch_indices]这个函数有两个细节值得展开。第一为什么打乱索引如果数据本身有某种隐藏顺序比如第一个特征从小到大排列而你不打乱每个batch里的样本分布就会偏向数据集的某个局部。梯度会带有周期性偏差loss曲线容易出现震荡。打乱索引是为了让每个batch尽量接近全局分布。PyTorch的DataLoader默认shuffleTrue也是这个道理。第二最后一个批次可能不足10个样本。这里用min(i batch_size, num_examples)截断保留不完整批次。实际生产中你也可以选择丢弃最后一个不足一个batch的数据使每次梯度估计的样本量一致。对线性回归这种小实验保留即可不必浪费数据。另外多说一句目前这个函数是在每个epoch被调用时重新执行打乱逻辑的所以每个epoch拿到的batch组合都不一样。这正是我们想要的效果别把shuffle写到函数外只打乱一次。3. 模型、损失与梯度这里才是“纯手写”的重头戏3.1 初始化参数与线性回归的前向计算线性回归的模型形式很简单对两个特征x1和x2预测值等于w1x1 w2x2 b。用矩阵乘法表达就是X与w做点积再加b。参数初始化上可以直接把两个权重和偏置都设为0w np.zeros(2) b np.zeros(1) def linreg(X, w, b): return np.dot(X, w) b线性回归的损失函数是凸的所有参数初始化最后都会收敛到同一个全局最优解所以全零初始化完全没问题。但这里我要提醒一句这个思路不能直接推广到神经网络。如果多层网络的所有权重都初始化为0同一层的不同神经元会学到完全相同的特征这就是对称性问题。后续学多层感知机时你会看到随机初始化成为必须原因就在于打破对称性。3.2 损失函数里那个“1/2”不是随便写的回归任务最常用的损失是平方损失。我手写时沿用了书里的写法每个样本的损失定义为预测误差平方的一半def squared_loss(y_hat, y): return (y_hat - y.reshape(y_hat.shape)) ** 2 / 2你可能会问平方就平方为什么非要除以2这是个很经典的细节。想一想对单个样本误差为e y_hat - y损失是e²/2。对参数w求导时二次方求导产生的系数2正好和分母上的2抵消梯度就简化成e乘以对应的x。如果不除以2梯度里会处处带一个2虽然不影响收敛方向但会让所有手动推导都多一个系数容易算错。更需要注意的是这个损失函数里没有除以样本总数。也就是说当前loss是所有样本误差平方和的一半而不是平均误差。那么计算梯度时得到的梯度就是整个batch的梯度之和而不是平均梯度。这件事直接决定了接下来sgd里必须除以batch_size。很多人手写训练代码时梯度时大时小就是在这里栽了跟头。我用一行代码实现整个batch的梯度计算def compute_grads(X, y, w, b): y_hat np.dot(X, w) b error y_hat - y dw np.dot(X.T, error) db np.sum(error) return dw, db对照单个样本的推导∂l/∂w1 error * x1∂l/∂w2 error * x2∂l/∂b error。对一个batch来说把所有样本的梯度累加起来就是上面的X.T与error做矩阵乘法以及error求和。这套推导不看懂后面理解反向传播就会一直隔着一层窗户纸。其实所谓反向传播在最简单的线性模型里就是这个结果没有任何神秘的东西。3.3 手写SGD更新注意那个除以batch_size有了梯度终于到了参数更新这一环。手写SGD非常简单但里面有个别处不常强调的细节def sgd(params, grads, lr, batch_size): for param, grad in zip(params, grads): param - lr * grad / batch_size注意这里把梯度除以了batch_size。原因正如上面所说我们手写的损失函数没有除以样本数所以compute_grads返回的是整个batch的梯度之和。除以batch_size之后等价于用“平均梯度方向”去更新参数。我见过有人把这段代码改成param - lr * grad然后发现同样的学习率下模型冲过了头甚至直接发散。其实很好理解如果每个batch有10个样本梯度就比平均方向大了约10倍相当于学习率被暗中放大了10倍。反过来如果你用的是mean形式的MSE损失那么梯度本身就是平均梯度sgd里就不该再除以batch_size。框架封装了损失函数和优化器之间的这种一致性关系让人很容易忽略手写时绕不过去。在学习率的选择上0.03是李沐书里调好的值。记住这不是推导出来的是按经验试出来的。后面你大概率要自己面对lr怎么设的问题第5节我会专门说。顺带介绍一个验证手写梯度是否正确的好技巧在正式训练之前用有限差分法做一次梯度检查。对某个参数θ把θ稍微增大一点算一次loss再减小一点算一次loss用中心差分公式估算梯度跟手写的梯度对比。数值一致就说明导数算对了再开训。对于简单的线性回归这个检查不难但能帮你建立对梯度的信任感。4. 训练循环跑起来从loss变化看收敛4.1 epoch、batch、样本数三层循环怎么运转手写的训练循环比框架代码暴露了更多的尺度信息。外层的epoch决定遍历整个数据集几遍内层的batch按批次取样本计算并更新参数。lr 0.03 num_epochs 3 batch_size 10 for epoch in range(num_epochs): for X_batch, y_batch in data_iter(batch_size, features, labels): grads compute_grads(X_batch, y_batch, w, b) sgd([w, b], grads, lr, batch_size) train_l squared_loss(linreg(features, w, b), labels).mean() print(fepoch {epoch 1}, loss {train_l:.6f})这里的数值感觉要算清楚。1000个样本batch_size为10意味着每个epoch内参数被更新100次3个epoch一共更新300次。看似epoch很少但每一步都用10个样本的梯度信息已经足够让线性模型在凸损失上收敛得很快。每个epoch结束我打印的是整个训练集的平均损失。注意因为我们手写的损失是平方和的一半打印时用.mean()才能得到一个规范的平均loss方便观察量级。如果看每个batch的loss数值会比较嘈杂不适合判断整体收敛趋势。我本地跑了一次设置np.random.seed(0)保证生成数据和打乱索引的顺序一致输出大致是这样的epoch 1, loss 0.000354 epoch 2, loss 0.000126 epoch 3, loss 0.000102如果你的机器结果略有浮动完全正常。第一件事是看loss量级从初始的8.8左右快速掉到1e-4级别。初始loss为什么这么大因为初始化时w和b全为0模型预测值全是0每个标签真实值在4.2附近波动平方和的一半平均下来就在8.8上下。三个epoch后loss降到万分之一的量级说明模型已经把真实规律学得八九不离十了。4.2 第一个epoch前后loss的变化与参数对比用一条训练闭环跑完后必须回到最开始的那个问题学到的参数和真实参数差多少print(w, b) print(true_w, true_b)我这次运行的结果是[ 1.9993 -3.4006] [4.1995] [ 2.0 -3.4] [4.2]w1和真实值的误差在0.001以内w2的误差也在0.001以内b的误差约0.0005。为什么不是完全相等因为数据里加了标准差0.01的噪声任何有限样本都不可能把参数估计得和真实值分毫不差。这种差距不是训练不到位而是数据本身包含不可消除的随机扰动。想验证这一点很简单去把生成标签时加噪声那一行的标准差改成0再重新训练会发现参数几乎精确还原。反过来把噪声标准差改成0.5参数估计误差就会明显变大。这就是统计学习中“噪声与参数估计精度”的最直观演示。4.3 用学到的参数做预测验证闭环的最后一环是验证。很多人训完模型只看loss降到多少我建议养成一个习惯拿训练好的参数重新预测一轮看一眼预测值和真实标签的残差。y_hat np.dot(features, w) b residuals labels - y_hat print(residuals.mean(), residuals.std())残差均值应该在0附近残差标准差应该接近生成数据时设置的噪声标准差0.01。我用上面训练出的参数跑了残差均值接近0标准差也在0.01左右。这说明模型学到了数据中的线性结构剩下的只是无法解释的随机噪声。这一步看似简单但它是“训练闭环”真正闭合的关键。没有这个验证你只知道loss小了不知道模型是否合理有了这个验证你才知道“学到的参数可信”这件事是有据可依的。5. 我踩过的坑和排查技巧附速查表5.1 梯度符号反了loss越练越高我最初自己徒手写梯度时把error的符号写反了算出来的是np.dot(X.T, y - y_hat)而正确写法应该是np.dot(X.T, y_hat - y)。结果是loss不但没降每个epoch结束打印出来反而越来越大。这是因为参数沿着梯度方向更新而不是负梯度方向更新等于在做梯度上升。这个问题的检查方法特别简单打印第一个epoch前后的loss如果变大几乎可以断定是符号问题。我当时浪费了不少时间后来总结出一个经验——写出error之后先别急着往下走停下来口算一下误差大、梯度应该把参数推向哪个方向把推导和直觉对上号再写代码。5.2 学习率不合适loss变成NaN或原地不动学习率是最容易出问题又最容易被当作“玄学”的超参数。我在同一个数据集上试过几个学习率差别非常直观学习率现象结论0.5第一个epoch loss先降一点第二个epoch直接NaN学习率过大参数震荡后溢出0.03三个epoch后loss降到1e-4量级正常范围0.0001三个epoch后loss还在0.1以上学习率过小收敛太慢为什么学习率过大会NaN因为参数一步跨得太远梯度越来越大最终数值溢出。这个现象在深层网络里更常见。为什么过小又不降参数每次只挪一点点在有限的epoch里根本走不到最优解附近。手写代码最大的好处就是改一个数字就能直观感受到这两个极端强烈建议自己试一遍。5.3 shuffle漏掉之后训练曲线开始“抽风”还有一个我印象很深的坑第一次写data_iter时我把索引打乱那行写在了生成器外面结果每个epoch都会用同一批固定的batch顺序训练。如果数据恰好是按某种规律排序的每个batch统计特性不一样参数更新就会周期性地左右拉扯打印出来的loss曲线像锯齿一样。解决方式就是确保每次调用data_iter都会重新shuffle。框架的DataLoader直接帮你处理好了这件事但手写时没人提醒你踩过一次就长记性了。这里也顺带说明batch_size太小也会让梯度震荡更明显因为单个batch对全局分布的估计更不稳定如果loss曲线抖动得厉害先别急着调学习率把batch加大或者把shuffle检查一遍。5.4 常见问题速查表我把手写训练闭环里最容易踩的几类问题整理成一张表方便你下次排查时快速对照现象可能原因快速验证方法修复方式loss不降反升梯度符号写反打印第一个epoch前后loss检查error是y_hat - y还是y - y_hatloss变成NaN学习率过大把lr改成0.001试一下降低学习率到0.03附近loss下降极慢学习率过小看第一个epoch结束loss量级增大学习率或检查sgd是否多除了数loss曲线锯齿震荡忘了shuffle或batch过小观察每个epoch内loss是否周期性波动补上数据打乱适当增大batch学到的参数离真实值很远噪声过大或epoch不够把噪声标准差减小再试增大epoch或增加样本数手动梯度与理论对不上损失函数是否除以n没统一用有限差分法做梯度检查统一loss与sgd里关于平均的处理这套排查方法不局限于线性回归。以后学softmax回归、多层感知机只要训练行为异常第一反应都是先看loss曲线变化方向再看学习率和梯度尺度最后看数据读取方式。从手写线性回归培养出的调试直觉会一直用得上。我现在回头看手写这个训练闭环给我最大的收获不是把线性回归的原理背下来而是建立了一套判断训练是否正常的直觉。之后每次用框架训练模型一旦loss曲线不合预期我脑子里能立刻画出数据流经的整条链路数据怎么进、梯度怎么算、参数怎么更然后一个环节一个环节地排查。建议你也把代码里的学习率改成0.5跑一遍把batch_size改成1跑一遍把shuffle注释掉跑一遍这些看起来“故意搞坏”的实验比看十遍课本都管用。参数可以改、代码可以坏、跑挂了再修回来这个过程本身才是从零开始实现真正的价值。
返回列表