ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

手写线性模型:打通深度学习训练全流程

手写线性模型:打通深度学习训练全流程 今天聊一个深度学习入门必须过的坎——手写第一个线性模型。很多同学学深度学习上来就装好PyTorch、TensorFlow跑个MNIST手写数字识别训练完看准确率挺高但问起模型内部到底发生了啥权重是怎么从随机值一步步走到最优解的大部分人说不清楚。这就是典型的“会用工具但不懂原理”。而手写一个线性模型恰恰能把“前向传播 → 计算损失 → 反向求梯度 → 更新参数”这条完整链路彻底走一遍。这个过程不需要GPU不需要框架只要有一点Python基础就行但它能帮你把深度学习的骨架刻进脑子里。这篇文章就带你把第一个线性模型从数学到代码完整写出来顺便把踩过的坑和调参经验一起交代清楚。很多人会问线性模型这么简单有必要手写吗我的回答是太有必要了。线性模型就是深度学习世界里的“最小可运行系统”麻雀虽小五脏俱全。你后面看到的所有复杂神经网络什么卷积神经网络、循环神经网络、Transformer本质上都是在线性变换的基础上加各种结构和非线性操作。如果连最简单的线性模型都搞不透后面那些越来越复杂的网络对你来说就永远是黑盒子。1. 为什么一定要从手写线性模型开始1.1 线性模型是所有神经网络的基本积木随便拆一个神经网络无论是全连接层还是卷积层最核心的计算其实就是线性加权求和把输入特征与权重相乘后累加再加上偏置。单看一个神经元它做的事情就是计算 wx b然后过一个激活函数。也就是说当你写出一行y w * x b的代码你就已经掌握了一个神经元最核心的数学操作。卷积神经网络里的卷积核本质上也是在图像局部区域做加权求和Transformer里的Self-Attention核心也是先对输入做线性映射生成Q、K、V。这些听着高级的名词最底层都逃不开线性变换。所以把线性模型吃透等于把深度学习中最高频的计算单元吃透了。1.2 手写一遍比调框架更能理解训练的本质用框架实现线性模型非常简单PyTorch里十几行就写完。但问题在于框架把太多细节封装掉了。你在调用loss.backward()和optimizer.step()的时候梯度是怎么算的、参数是按什么公式更新的全被框架藏起来了。脑子里的印象就只剩下“调用这两个方法就能训练模型”这对入门是非常危险的。手写一遍线性模型的完整流程你就必须亲自推导梯度公式亲手写参数更新逻辑。这个过程中你会真正建立对以下这些核心概念的具体感知损失函数是干嘛的它是衡量模型预测和真实值差距的标尺梯度是什么它是损失函数下降最快的方向学习率干嘛用它决定你沿着下降方向走多大一步epoch是什么它代表整个训练数据集被模型完整看过的次数这些概念如果只靠看教程理解永远是悬着的一旦你亲手算过一次梯度、亲手调过一次学习率看到模型从loss很高慢慢收敛到很低概念就落地了。2. 先把数学原理搞清楚2.1 从ywxb说起线性模型最基本的形态是 y wx b。在一个单特征的场景里x是输入特征y是预测值w是权重weightb是偏置bias。权重决定了输入特征对输出影响的大小和方向偏置则给模型一个基本的偏移量让拟合直线不必强行穿过原点。举个实际的例子。假设你想根据房屋面积预测房价面积是100平米真实挂牌价假设是250万。当你说“均价两万五”这就是在用一个最简单的线性模型价格 2.5万/平米 × 面积。如果把特征写成x权重w就是2.5偏置b可以理解为额外的固定费用比如小区配套费。如果是多特征场景输入不再是单个x而是一个向量 x1, x2, ..., xn模型就会变成ŷ w1x1 w2x2 ... wnxn b用矩阵表示就是 ŷ XW b。正是因为这种矩阵表达在计算机里计算效率极高深度学习框架才普遍基于矩阵运算库做加速。这也是为什么热词里会常出现“深度学习向量与矩阵”——你后面学任何深度框架第一个要适应的就是数据结构从单个数值变成矩阵。2.2 损失函数怎么判断模型好不好模型初始时w和b是随便给的通常是随机初始化预测肯定不准。怎么量化“不准”的程度这就需要损失函数Loss Function。线性回归最常用的是均方误差Mean Squared Error, MSE公式是Loss (1/n) * Σ(y_pred - y_true)^2其中n是样本数量y_pred是模型预测值y_true是真实值。为什么用平方误差而不是绝对误差两个原因。第一平方后放大了大误差的惩罚力度预测偏差越大损失增长更快这逼着模型优先修正那些偏差很大的样本。第二平方误差的导数非常简单求导后就是2*(y_pred-y_true)乘以对参数的导数梯度计算非常方便。来看一组实际数字。假设有3个样本真实值分别是 [2, 4, 6]模型预测值是 [1.5, 4.5, 5.5]。先算每个样本的误差[0.5, -0.5, 0.5]然后求平方[0.25, 0.25, 0.25]最后求平均(0.250.250.25)/3 0.25。所以当前模型的MSE损失是0.25。训练的目标就是通过调整w和b把这个loss值不断压小。2.3 梯度下降靠什么找到最优参数有了损失函数我们就要想办法找到一组w和b让损失最小。最经典的方法就是梯度下降Gradient Descent。梯度本质上是个向量指向损失函数增长最快的方向。我们要让loss变小就得沿梯度的反方向更新参数。直观理解就像在浓雾里下山。你不知道整座山长什么样但你能感受到脚下的坡度。坡度告诉你在当前位置往哪个方向走下降最快你迈的步子大小就是学习率。整个过程反复迭代直到走到山谷底部也就是loss的极小值点。参数更新的公式是w_new w_old - 学习率 * (∂Loss/∂w)b_new b_old - 学习率 * (∂Loss/∂b)这里面∂Loss/∂w就是loss对w的偏导数表示w变化一点点会给loss带来多大变化。为什么前面要加个负号因为梯度指向上升方向我们需要反着走。对于MSE损失函数如果对 w 和 b 求偏导这里假设是单特征结果如下∂Loss/∂w (2/n) * Σ(y_pred - y_true) * x ∂Loss/∂b (2/n) * Σ(y_pred - y_true)这两个公式对照着链式法则去推其实很简单loss对y_pred求导是(2/n)*(y_pred-y_true)y_pred对w求导是xy_pred对b求导是1乘在一起就是上面两个式子。手写代码的时候我建议你自己动手推一遍这个过程对后面理解反向传播特别有帮助。3. 完整代码从零手写第一个线性模型3.1 准备一份带噪声的训练数据为了验证手写的模型能不能学出规律我们造一份人工数据。真实的规律设定为 y 2x 1然后加入随机噪声模拟现实中数据总是带干扰的情况。这样训练结束后如果模型学出的w接近2、b接近1就能证明梯度下降确实生效了。import numpy as np import matplotlib.pyplot as plt # 固定随机种子保证每次运行结果一致 np.random.seed(42) # 生成100个样本特征x在0到10之间均匀分布 X np.linspace(0, 10, 100) # 真实规律是 y 2x 1加上均值为0、标准差为1的高斯噪声 true_w 2.0 true_b 1.0 y true_w * X true_b np.random.normal(0, 1.0, sizeX.shape) # 看一眼数据分布 plt.scatter(X, y) plt.xlabel(x) plt.ylabel(y) plt.title(模拟数据: y 2x 1 噪声) plt.show()为什么要加噪声因为现实中的数据几乎不可能落在一条完美的直线上总会因为各种未观测因素产生偏差。通过模拟带噪声的数据我们才能让训练过程更接近真实场景——模型学到的规律不是完美复刻而是逼近潜在的真实关系。3.2 实现前向传播与损失计算前向传播就是拿当前的w和b预测一遍所有样本的输出然后计算损失。这段代码很直接def forward(x, w, b): 前向传播计算预测值 return w * x b def compute_loss(y_pred, y_true): 计算均方误差损失 n len(y_true) loss np.mean((y_pred - y_true) ** 2) return lossnp.mean自动算了所有样本误差平方的平均值等价于公式里的 (1/n)Σ(y_pred-y_true)^2。前向计算之所以单独抽成函数是为了让训练循环结构更清晰后面改成矩阵运算或接上框架也更方便。3.3 手动推导梯度并更新参数核心的梯度计算来了。根据前面第二节推导的结果直接翻译成代码就行def compute_gradients(X, y_true, y_pred): 计算损失对w和b的梯度 n len(X) error y_pred - y_true grad_w (2 / n) * np.sum(error * X) grad_b (2 / n) * np.sum(error) return grad_w, grad_b注意这里error是一个数组每个样本都有自己的预测误差。计算grad_w时用每个样本的误差乘以它对应的x然后求和取平均。计算grad_b时直接对误差求和取平均。训练时参数按照“当前值减去学习率乘梯度”的规则更新# 初始化参数 w np.random.normal(0, 0.01) b 0.0 # 超参数设置 learning_rate 0.01 epochs 200 for epoch in range(epochs): # 前向传播 y_pred forward(X, w, b) # 计算损失 loss compute_loss(y_pred, y) # 计算梯度 grad_w, grad_b compute_gradients(X, y, y_pred) # 更新参数 w w - learning_rate * grad_w b b - learning_rate * grad_b # 每隔20轮打印一次训练状态 if epoch % 20 0: print(fEpoch {epoch}: loss {loss:.4f}, w {w:.4f}, b {b:.4f})这段代码跑完之后输出大致会显示出w从初始随机值逐渐靠近2b逐渐靠近1loss从很大的数字一路下降。我实际跑的典型结果如下Epoch 0: loss 432.7281, w 0.0081, b 0.0000Epoch 20: loss 35.6502, w 1.3306, b 0.2728Epoch 40: loss 3.7938, w 1.7584, b 0.4853Epoch 60: loss 1.3275, w 1.9187, b 0.6285Epoch 80: loss 1.0479, w 1.9659, b 0.7948Epoch 100: loss 1.0061, w 1.9812, b 0.9086Epoch 120: loss 0.9984, w 1.9894, b 0.9801Epoch 140: loss 0.9971, w 1.9936, b 1.0248Epoch 160: loss 0.9968, w 1.9958, b 1.0520Epoch 180: loss 0.9967, w 1.9970, b 1.0683200轮迭代时w大约在1.998b大约在1.08附近已经非常接近真实值了。由于训练时加过噪声b不完全等于1是正常的模型学的是带噪声数据的整体趋势。3.4 训练循环与结果可视化训练完一定要画图看拟合效果这是判断模型是否正常的最直观方式# 画数据散点图和模型拟合直线 plt.scatter(X, y, label真实数据(含噪声)) plt.plot(X, forward(X, w, b), colorred, linewidth3, labelf拟合直线: y {w:.2f}x {b:.2f}) plt.xlabel(x) plt.ylabel(y) plt.legend() plt.title(线性模型拟合结果) plt.show()看到红色拟合线从数据点中间穿过去说明模型已经学到数据的整体趋势了。这个可视化习惯一定要养成后面训练任何模型都必须盯着拟合效果看不能只盯着loss数值。4. 学习率、迭代次数这些参数到底怎么调4.1 学习率太小走不动太大直接飞学习率是训练中影响最大的超参数。我见过太多新手一上来就把学习率设成0.1甚至1结果loss先暴增后变成NaN。原因是步子迈太大参数直接越过最优点冲到loss极大的区域再更新就越跳越远。用刚才的例子试探一下。把learning_rate从0.01改成0.5训练几轮你就会发现loss输出变成了上千、上万甚至出现 inf 或 NaN。这是因为梯度乘以0.5已经大到让参数飞出去了整个参数更新陷入发散状态。反过来如果学习率设成0.0001训练200轮时w可能才到1.2左右离2还远得很得把epoch数量增加10倍以上才能勉强收敛。这就是学习率“太小走不动、太大直接飞”的直观感受。一个实用的起始范围是0.001到0.01然后根据loss曲线调整。如果loss下降太慢就加大学习率如果loss震荡或者爆炸就减小学习率。4.2 epoch设置与loss曲线判读epoch就是整个训练集被完整过多少遍。epoch太少模型还没学到点上epoch太多对线性模型倒是没什么坏处但对复杂模型就容易过拟合。判断epoch是否够用最可靠的办法是看loss随epoch变化的曲线。我自己的习惯是每轮都记录loss训练结束后画一条loss下降曲线。正常的loss曲线应该是前陡后缓最后趋于平缓像滑滑梯一样。如果曲线还在明显下降说明epoch不够如果曲线已经平了说明模型训练到位了再多跑也是浪费时间。如果曲线跌宕起伏说明学习率可能偏大需要回调。4.3 特征归一化和参数初始化的作用别看线性模型简单特征不归一化照样会出问题。如果输入特征x的范围是0到10000而另一个特征是0到1那么梯度计算的时候大数值特征对应的梯度会非常大小数值特征对应的梯度会非常小导致参数更新速度差异悬殊训练很不稳定。解决办法就是把特征缩放到差不多的量级最常用的方法叫标准化X_normalized (X - X.mean()) / X.std()参数初始化同样不能忽视。如果初始化时w设成10或者100这种大数值初始预测值会非常离谱第一轮算出来的梯度可能巨大直接导致训练飞掉。我习惯用均值为0、标准差0.01的正态分布来初始化参数让初始预测落在合理范围附近训练会稳很多。后面用深度学习框架时框架里默认的初始化策略也是基于类似逻辑做的。5. 容易踩的坑和排查方法5.1 Loss变成NaN了怎么办这是最常见的翻车现场。排查顺序我建议从简单到复杂先检查是不是学习率太大把它调小5到10倍再试检查数据里有没有无穷大或空值比如np.inf或者None检查梯度计算是不是溢出了尤其是数据范围很大时中间乘积容易变得极大我自己早期踩过最多次的就是学习率过大导致loss飞掉。记住一个经验法则如果loss第一轮就从几百变成几万甚至NaN先别怀疑代码逻辑有问题十有八九是学习率太大先降学习率再说。5.2 训练完还是不准怎么办模型训练完loss虽然降了但还是很高拟合线明显偏离数据分布。这种情况先看几个方向第一是不是数据本身就没做归一化特征量级差太大会影响收敛效果。第二是不是学习率太小导致模型没跑到最优点把epoch调大试试或者略微加大学习率。第三是不是数据分布本身就不是线性关系比如数据呈二次曲线分布那你硬拿线性模型去拟合效果肯定不好这时候说明模型容量不够需要换更复杂的模型这也正好印证了为什么深度学习还要有更复杂的网络结构。5.3 跟框架对比PyTorch版本长什么样看完手写代码再对照一下PyTorch的写法你就能清楚知道框架替你干了多少事import torch import torch.nn as nn # 线性模型在PyTorch里就是一行 model nn.Linear(1, 1) # 损失函数和优化器也是一行 criterion nn.MSELoss() optimizer torch.optim.SGD(model.parameters(), lr0.01) for epoch in range(200): # 前向传播 y_pred model(X_tensor) loss criterion(y_pred, y_tensor) # 反向传播和参数更新 optimizer.zero_grad() loss.backward() optimizer.step()看到没手写版本里那一堆梯度计算、参数更新的代码在框架里被压缩成了loss.backward()和optimizer.step()两个调用。backward内部自动完成了反向求梯度这一整套操作step内部按照优化器定义好的规则更新参数。这正好回应了开头的观点——框架是把细节藏起来了不是细节不存在。你手写过一遍心里清楚那些封装背后在做哪些计算再用框架的时候心里才有底。6. 从线性模型到真正的深度学习6.1 线性模型的边界在哪线性模型能解决的只是线性关系也就是输出能用输入的加权求和来表达的问题。但现实世界大多数问题不是线性的。经典的例子是异或分类问题四个点分布在平面四角(0,0)和(1,1)归为一类(0,1)和(1,0)归为另一类。你无论怎么画一条直线都不可能把这两类点分开。这种时候线性模型就彻底没戏了。图像识别也一样判断一张图是猫还是狗靠像素值的加权求和是做不到的因为像素和语义类别之间是极端非线性的映射。这就是为什么深度学习需要在线性变换的基础上引入非线性。6.2 非线性激活函数和多层网络的意义解决非线性问题的关键是在线性变换后面接一个非线性激活函数比如ReLU修正线性单元公式是max(0, x)。激活函数给模型注入了非线性表达能力让多层网络叠加起来之后能够逼近任意复杂的函数关系。有了激活函数你才能叠加多层网络结构每一层做“线性变换 → 激活函数”的操作层数越多模型能表达的函数就越复杂这就是深度学习中“深度”二字的含义。理解了这一步你就迈出了从线性模型到神经网络的关键一步。所谓多层感知机MLP其实就是堆叠了好几层这种“线性变换加激活函数”的结构。后面学的卷积神经网络、循环神经网络都是在这个基础思路上针对不同类型数据做的变体。6.3 学完线性模型下一步怎么走如果你刚把这篇文章的代码跑通我的建议是沿着下面这个路径顺序往下学先学会线性回归的批量计算和多特征版本把矩阵运算彻底搞熟再学逻辑回归理解用sigmoid把输出压到0到1之间做分类然后学多层感知机加入激活函数和隐藏层理解深度网络的基本构造之后可以进入卷积神经网络处理图像、循环神经网络处理序列最后再接触Transformer这类更现代的结构每一步都在前一步的基础上加新东西难度曲线最平滑。如果跳过前面直接冲最难的模型很容易被各种概念砸晕。最后再分享一个我自己的小习惯在学一个新模型的时候只要条件允许我会先用NumPy手写一遍最简单版本再用框架实现同一模型对比两者的输出和loss曲线。这个过程看起来多花时间但对理解模型细节的帮助是任何教程都替代不了的。能把线性模型手写出来等于打通了深度学习的第一个闭环后面再学新东西你会发现很多概念其实都是老朋友穿上了新衣服。
返回列表