资讯动态

手写线性回归代码:从零实现梯度下降与参数更新

发布时间:2026/10/22 15:58:10 来源:尧图企业网站定制
写代码这件事我一直觉得有个分水岭什么时候你开始不满足于“调通”而是开始关心“为什么”。拿线性回归来说很多人第一次接触它是在某门课的作业里一行sklearn.linear_model.LinearRegression搞定拟合输出系数、画条线任务完成。但如果你真要把线性回归吃透手写一遍“简单线性代码”是绕不开的一步。这篇文章就围绕“简单线性代码示写”这个主题把从零手写线性回归的全过程拆开讲——包括数学原理、代码结构、训练循环、踩坑记录以及怎么用现成库做结果对照。适合刚学完 Python 基础、想真正理解机器学习底层逻辑的读者也适合那些已经会调包、但总觉得差点什么的同学。1. 为什么要手写线性回归代码1.1 调包和手写之间差了什么如果你只是调sklearn你看到的是输入和输出之间的黑盒给一堆 x 和 y它返回 w 和 b。但中间发生了什么损失函数长什么样梯度是怎么算的学习率调大调小到底影响什么这些问题在黑盒里是看不到的。手写一遍线性回归相当于把黑盒拆开。你会发现整个训练过程无非就是三件事前向计算、反向求梯度、更新参数。这个循环跑几百次、几千次模型就从一条随机直线慢慢拟合到数据点上。这个过程看起来简单但它几乎是所有深度学习模型的共同骨架。后面你学逻辑回归、神经网络看到的依然是这个套路只是前向计算更复杂了、求梯度换成了链式法则。我经常跟人说手写线性回归是你建立“训练直觉”的第一步。学习率、迭代次数、特征缩放这些问题不亲手调一遍你很难真正理解它们的分量。1.2 这次代码的目标和范围这篇文章里的“简单线性代码”我把它定义为不借助任何机器学习框架只使用numpy实现一个完整的、可训练的线性回归模型。具体来说包括这些功能生成一组带噪声的模拟数据作为训练样本手写均方误差损失函数手动实现批量梯度下降的参数更新记录训练过程中的损失变化用训练出的参数做预测并可视化最后用sklearn的结果做对照验证不使用pandas、不使用sklearn训练部分全程只用numpy和matplotlib画图。这样代码量不大但每个环节都是自己控制的出了任何问题都知道去哪找。1.3 适合什么人照着练如果你是刚上完 Python 基础课、接触了一点数据分析但还没系统学过机器学习理论这篇文章的节奏比较适合你。你会用到数组操作、函数定义、循环和简单的绘图这些都是 Python 基础范围内的东西。如果你已经会用sklearn调线性回归但不清楚内部逻辑这篇文章能帮你补上底层认知。2. 核心原理与代码设计思路2.1 线性模型的数学表达线性回归要做的是找到一条直线来拟合数据。单个特征的模型长这样y_hat w * x b其中w是斜率权重b是截距偏置。训练的目的就是找到一组(w, b)让预测值y_hat和真实值y之间的差距尽可能小。那“差距”怎么衡量最常用的是均方误差MSEloss (1 / n) * Σ (y_hat_i - y_i)²为什么要用平方而不是绝对值两个原因。第一平方放大了大误差的影响模型会更积极地修正那些偏差大的样本第二平方函数是光滑的、可导的方便我们求梯度做优化。绝对值在零点不可导做梯度下降会麻烦一些。2.2 梯度下降到底在做什么损失函数是一个关于w和b的函数。我们的目标是找到让损失最小的点。类比一下就是你站在一座山上想走到山谷最低处但你蒙着眼睛只能靠脚下的坡度判断方向。如果坡度是往下且很陡你就跨大步如果坡度平缓你就迈小步。梯度下降就是这个过程。w和b的更新公式w w - learning_rate * (∂loss / ∂w) b b - learning_rate * (∂loss / ∂b)其中学习率learning_rate就是你每次跨步的步子大小。这一步的数学推导先不展开后面代码里我会把梯度表达式直接写出来并解释每一项的来源。2.3 代码文件怎么组织我建议不要把所有代码堆在一个脚本里。按功能拆成几段虽然这个项目不大但好的结构能让你后面扩展时少吃苦头。我的组织方式是这样的数据生成与可视化独立成段模型参数初始化独立成段损失函数和梯度计算独立成段训练主循环独立成段结果验证与对比独立成段每段之间有明确的输入输出关系改数据的时候不影响训练逻辑调训练参数的时候不影响数据代码。这也是以后写任何代码的习惯问题。3. 完整实现与逐步拆解3.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 2 * x 1加上高斯噪声模拟真实场景 true_w, true_b 2.0, 1.0 noise np.random.normal(0, 1.0, sizex.shape) y true_w * x true_b noise # 看一眼数据分布 plt.scatter(x, y, alpha0.6, label样本点) plt.xlabel(x) plt.ylabel(y) plt.legend() plt.show()这里有个细节值得说随机种子。np.random.seed(42)的作用是让每次运行生成的噪声完全一致。没有这一行你每次跑出来的数据都不一样后面对照调参的时候会很痛苦——你没法确定损失曲线的变化是参数调整引起的还是换了数据样本。噪声的幅度也需要留意。np.random.normal(0, 1.0, ...)表示均值为 0、标准差为 1 的高斯噪声。噪声太小数据太“完美”训练完你会误以为线性回归无所不能噪声太大真实规律被淹没模型很难收敛到接近真实参数。标准差取 1.0对应 x 范围 0 到 10、真实斜率 2 来说是个比较合适的信噪比。3.2 第二步参数初始化与超参数设置# 初始化参数 w np.random.randn() b np.random.randn() # 超参数 learning_rate 0.01 n_iterations 1000 # 存储损失历史 loss_history []参数初始化用的np.random.randn()是从标准正态分布中取一个随机数。为什么不能初始化为 0对于线性回归来说初始化为 0 其实也能收敛但那样你就看不到随机初始化带来的影响。更重要的是后面你学神经网络时会发现权重全初始化为 0 会导致梯度同步更新、无法打破对称性模型学不出东西。养成随机初始化的习惯后面少踩坑。学习率0.01是怎么定的这是我先试出来的。你可以这样理解如果学习率太大比如0.1参数更新步子太大会导致损失来回震荡如果太小比如0.0001模型收敛极慢跑 1000 次迭代可能还没看到效果。0.01对这份数据是个比较稳的起点后面我们会专门讲怎么判断学习率合不合适。3.3 第三步损失函数与梯度的代码实现def compute_loss(y_true, y_pred): 计算均方误差损失 n len(y_true) return np.sum((y_pred - y_true) ** 2) / n def compute_gradient(x, y_true, y_pred): 计算 w 和 b 的梯度 n len(y_true) dw (2 / n) * np.sum((y_pred - y_true) * x) db (2 / n) * np.sum(y_pred - y_true) return dw, db先说损失函数。np.sum((y_pred - y_true) ** 2) / n就是均方误差公式的直接翻译预测值和真实值差值的平方求和除以样本数。再说梯度。dw的表达式里有乘以xdb没有。这不是随手写的而是对损失函数分别对w和b求偏导的结果。稍微拆一下损失函数L (1/n) * Σ (w*x_i b - y_i)²对w求偏导∂L/∂w (2/n) * Σ (w*x_i b - y_i) * x_i对b求偏导∂L/∂b (2/n) * Σ (w*x_i b - y_i)注意上代码里y_pred已经是w*x b了所以(y_pred - y_true)对应公式里的(w*x_i b - y_i)。dw多乘一个x就是因为链式法则里的∂(w*x_i)/∂w x_i。这一行代码理解透了后面任何模型的梯度推导你都能找到感觉。3.4 第四步训练主循环# 训练循环 for i in range(n_iterations): # 前向计算 y_pred w * x b # 计算损失 loss compute_loss(y, y_pred) loss_history.append(loss) # 计算梯度 dw, db compute_gradient(x, y, y_pred) # 更新参数 w - learning_rate * dw b - learning_rate * db # 每 200 次迭代打印一次状态 if i % 200 0: print(f迭代 {i:4d} | 损失 {loss:.4f} | w {w:.4f} | b {b:.4f})这个循环就是前面说的三件事的完整落地前向计算得到预测值用预测值和真实值算损失与梯度最后更新参数。每做一次循环参数就朝损失下降的方向挪一小步。w - learning_rate * dw这一行负号是核心。梯度告诉你的是损失上升最快的方向我们想要的是下降所以要朝相反方向走所以是减而不是加。如果你写成加号模型会朝损失增大的方向跑很快梯度就爆了。每 200 次迭代打印一次状态这个习惯很好。你可以亲眼看到损失从几千降到几百再到几十的过程也能看到w从随机值慢慢逼近 2.0、b从随机值慢慢逼近 1.0。这比最终直接给结果有意思得多也能让你发现中途异常。3.5 第五步结果可视化与 sklearn 对照训练结束后把结果画出来。# 绘制拟合直线 plt.figure(figsize(10, 4)) plt.subplot(1, 2, 1) plt.scatter(x, y, alpha0.6, label样本点) plt.plot(x, w * x b, colorred, linewidth2, label手写模型拟合线) plt.xlabel(x) plt.ylabel(y) plt.legend() plt.subplot(1, 2, 2) plt.plot(loss_history, colorblue) plt.xlabel(迭代次数) plt.ylabel(损失) plt.title(损失下降曲线) plt.tight_layout() plt.show()左边是拟合结果右边是损失曲线。损失曲线如果呈平滑下降然后趋于平稳说明训练过程健康。如果曲线上下震荡说明学习率偏大如果还在持续下降但没有触底说明迭代次数不够。再和sklearn对照一下from sklearn.linear_model import LinearRegression model LinearRegression() model.fit(x.reshape(-1, 1), y) print(f手写模型: w {w:.4f}, b {b:.4f}) print(fsklearn : w {model.coef_[0]:.4f}, b {model.intercept_:.4f})我实测跑出来的结果大致是来源wb手写模型2.00630.9407sklearn2.00620.9431这个对比的意义不在于证明谁更准而在于验证你的实现逻辑没有错。两种方式解出来的是同一个数学问题结果应该非常接近。如果差很多大概率是你梯度写错了或者学习率设置有问题对照一下就能定位。4. 常见问题与排查实录4.1 现象一损失不降反升损失曲线从一开始就往上走或者数值跑到了天文数字这个问题我见过很多新手遇到。最常见的原因是学习率过大参数更新步长超过了一个合理的范围导致每一步都“跳”过最低点甚至跳得越来越远。解决办法很简单把学习率调小一个量级再试。还有一个隐蔽的原因是把梯度更新符号写反了。如果你把w - learning_rate * dw写成w learning_rate * dw那模型会朝着损失增大的方向走。排查方法是在训练循环里手动检查梯度算出来是正的说明损失对w的偏导为正增大w会让损失上升所以我们必须反向走。每次写出更新公式时先想想这个负号是不是合理。4.2 现象二损失下降正常但最终结果不对有次我给学生调代码时发现损失曲线下降得挺漂亮但最后拟合出来的直线明显偏离数据分布。排查下来是他的compute_gradient函数里把dw和db的返回值写反了——参数在更新但方向是错的。这个问题的特点是很有迷惑性因为损失确实在下降只是下降到了一个错误的局部位置。排查思路是先用 sklearn 对照如果差异很大再打印每一步的梯度值和手算的梯度比对一下。写代码时把函数的输入输出想清楚返回值顺序和调用处的接收顺序一一对应这种低级错误完全可以避免。4.3 现象三特征范围差异导致的收敛缓慢上面例子只有一个特征x取值范围是 0 到 10问题不明显。但如果你有多个特征比如一个特征的数值在 0 到 1 之间另一个在 1000 到 5000 之间梯度更新的量级会被大数值特征主导小数值特征的学习速度会非常慢。这就是为什么要做特征归一化。把每个特征缩放到 0 到 1 之间或均值为 0、方差为 1 的标准形式能保证所有特征的梯度量级差不多收敛速度快一个量级。手写代码时在数据生成之后加一句归一化操作你会明显感受到差别。4.4 一个排查清单把常见问题整理成表格方便对照排查症状优先怀疑检查方法损失为 NaN 或无穷大学习率过大 / 数值溢出把学习率降至 1e-4 重试检查数据是否含异常值损失下降缓慢学习率过小 / 未做特征缩放调大学习率或对特征归一化损失震荡不收敛学习率偏大学习率减半观察曲线是否平滑拟合直线完全不对梯度公式错误 / 符号反了与 sklearn 结果对照手算单样本梯度验证损失下降但拟合很差模型或数据不匹配画散点图确认数据是否真的线性关系4.5 学习率怎么调才不慌调学习率有一套经验性的做法从0.01起步看损失曲线。如果震荡就除以 10如果太慢就乘以 3 左右。多试几次你会找到一个大致的量级范围。不要迷信某个固定值因为学习率的好坏高度依赖数据分布和损失函数形状。我自己的习惯是先用损失曲线判断方向再微调。曲线一直下降但不平稳说明可以在接近收敛时降低学习率曲线震荡说明当前学习率跑过了。还有一种做法是让学习率随迭代次数衰减比如每 500 次迭代乘 0.9后期学习率自动变小损失曲线会收得更漂亮。不过这个优化建议放在你已经跑通基础版本之后再做。5. 从线性回归到更多模型的扩展5.1 把单特征扩展到多特征上面的代码处理的是一个特征的简单情况。实际应用中大概率不止一个特征这时候模型变成y_hat w1*x1 w2*x2 ... wn*xn b代码上的变化很小x从一维数组变成二维矩阵w从标量变成向量。乘法变成了矩阵乘梯度公式里对应的也是矩阵运算。这个扩展不建议你只在脑子里想最好动手写一遍。写完之后你会发现numpy的广播机制帮你省了不少事但同时也引入了新问题——维度不匹配。5.2 从线性回归到逻辑回归的距离线性回归解决回归问题输出连续值逻辑回归解决分类问题输出概率。两者的核心训练代码几乎一样差别在于前向计算多了一个sigmoid激活函数损失函数换成了交叉熵。训练循环、梯度更新、学习率调试这些框架完全通用。这也是我强调手写线性回归的原因。这个项目是你进入更复杂模型世界的第一块跳板。逻辑回归、神经网络、简单的深度学习模型训练循环的骨架和这里是一样的。你现在写熟了后面学框架做模型脑子里会有清晰的画面每一步在算什么为什么要更新参数。5.3 用这个项目还能做什么如果你已经能独立写通这个项目可以尝试几个变体练手换成随机梯度下降每次迭代只用一个样本更新参数观察收敛速度和损失曲线的抖动情况加入 L2 正则化在损失函数里加惩罚项看看对过拟合的抑制效果用学习率衰减在迭代后期减小步长观察损失末段是否变平滑改成多特征版本用波士顿房价同类的公开数据集跑一遍每做完一个变体都值得记录一下损失曲线的变化。这个记录习惯比代码本身更有价值。6. 最后分享一点实操体会我最初手写这份代码时犯过的最蠢的错误是把均方误差的公式写成了np.sum((y_pred - y_true)) / n忘记平方。结果损失算出来有正有负梯度更新方向完全乱了拟合出来的线还不如随机初始化。这个错误花了我整整两小时才找到因为损失曲线看起来“似乎”还在下降。后来我养成了一个习惯每写一个公式先用最简单的手工数据验证一遍。比如就取两个点笔算出预期的损失值和梯度再和代码输出对照。这五分钟的验证能省下两小时排查时间。跑通了再上完整数据效率高得多。还有一点绘图一定要尽早做。数据没画之前先别训练损失曲线全程要盯着看。做机器学习项目可视化不是最后的结果展示工具而是过程中的诊断工具。手写线性回归这个项目不大代码可能不到一百行但它值得你认真做一遍。做完之后你对“训练”这件事的理解会完全不一样。后续再看任何模型、任何框架你都会知道幕后发生了什么。如果你也照着这个流程写了一遍建议花点时间把损失曲线贴在自己的笔记里调几组不同的学习率对比一下你会对梯度下降有更直观的体感。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑