单变量线性回归精讲:从代价函数到梯度下降的实战指南
很多年之后再回头看吴恩达的机器学习课程依然是很多人入门的首选。Lecture 2 的“Linear regression with one variable”——单变量线性回归单看内容量好像就是一条直线拟合数据点加上一个代价函数再来一个梯度下降。但恰恰是这一讲把机器学习里最核心的几块基石一次性铺开了模型表示、代价函数、梯度下降、学习率、收敛判断。后面那些听着很唬人的神经网络、支持向量机、推荐系统本质上都还是围绕这些概念在打转。这篇文章我想以过来人的角度把 Lecture 2 里那些“当时没听懂、后来踩了坑才明白”的点掰开揉碎聊一聊。不管你是刚看完视频还在懵圈的初学者还是想用 Python 把课程里的算法亲手复现一遍的开发者这篇文章都能帮你把这一讲真正吃透。我会把公式背后的直觉、代码实现里的细节、以及跑实验时常见的坑全部过一遍尽量做到你看完就能自己动手写一个单变量线性回归并且理解每一步到底在做什么。1. 核心思路拆解这一讲到底在解决什么问题1.1 从房价预测说起监督学习的最小完整闭环这一讲的起点是房价预测。你有一堆历史数据房屋面积x和成交价格y希望根据面积预测价格。这个场景的标准说法是“监督学习”因为每个训练样本都有“标准答案”y。更具体一点这是监督学习里的“回归问题”因为输出 y 是连续值价格可以是 100 万、101.5 万无穷多个可能。单变量线性回归要做的就是找到一条直线h_θ(x) θ₀ θ₁x这个 h 代表 hypothesis假设函数θ₀ 和 θ₁ 是模型的参数也是我们最终要求解的东西。整个监督学习的流程可以拆成四步收集带标签的训练集 (x⁽ⁱ⁾, y⁽ⁱ⁾)i 1, 2, ..., m选择一个模型 h_θ(x)这里是线性函数定义一个代价函数 J(θ)用来衡量当前参数下模型的预测误差通过优化算法梯度下降最小化 J(θ)得到最终的 θ这个流程在你学完整门课之后会发现它几乎是所有监督学习算法的共同骨架。哪怕是后面学的逻辑回归、神经网络、Softmax 分类器Step 1 到 Step 4 的结构都没变过变的只是模型 h_θ(x) 的形式和代价函数的形态。1.2 为什么从最简单的线性模型开始机器学习领域有句老话叫“先让你的模型在训练集上跑通再考虑让它变强”。线性回归就是那个“能跑通”的起点。它形式简单、有闭式解、代价函数是凸函数梯度下降能保证收敛到全局最优这些性质让它可以作为理解优化算法的完美试验台。很多初学者会犯一个毛病一上来就想用复杂的模型觉得线性回归太简单、不够“机器学习”。但我的建议恰恰相反把单变量线性回归彻底吃透比囫囵吞枣地过一遍十种算法有用得多。因为后面学到的所有优化技巧——特征缩放、学习率调整、向量化、批量梯度下降——在单变量情形下都能用最直观的方式展示出来你看得见、摸得着、画得出图。1.3 训练集、特征、标签怎么对应到代码里课程里的符号体系放到代码里其实非常直接。我用一组简单的数据来对照说明import numpy as np # 训练集房屋面积平米和价格万元 x np.array([50, 75, 100, 125, 150, 175, 200]) y np.array([150, 200, 250, 300, 350, 400, 450]) m len(y) # 训练样本数 m 7这里的 x 是特征面积y 是标签价格m 是样本数量。后面的所有公式最终都要落到对这几个数组的操作上。牢记符号的含义再看公式就不会头晕。2. 代价函数如何量化模型的好坏2.1 平方误差代价函数的定义与直觉模型的好坏需要有量化标准代价函数就是这个标准。Lecture 2 里用的平方误差代价函数J(θ₀, θ₁) (1/2m) Σ(h_θ(x⁽ⁱ⁾) - y⁽ⁱ⁾)²公式里的 Σ 是对所有训练样本求和h_θ(x⁽ⁱ⁾) - y⁽ⁱ⁾ 是模型预测值与真实值的差也就是误差。平方的作用有两个一是消除正负误差相互抵消的问题二是放大大误差的惩罚误差 2 的平方是 4误差 4 的平方是 16大误差的代价会快速上升。前面的 1/(2m) 是平均值的变体除以 m 是为了消除样本量对代价数值的影响多除以一个 2 纯粹是为了后续求导后系数能约掉让公式更干净。生活化地理解代价函数就像一个打分系统你不断调整直线的斜率和截距让所有点到直线的“平均垂直距离的平方”最小。打分越低说明这条直线对训练数据的拟合越好。2.2 为什么用平方误差而不是绝对值或四次方绝对值误差 MAEMean Absolute Error在求导时会在误差为 0 的点产生不可导的尖点梯度下降走到那里会“不知道该往哪走”。四次方误差虽然处处可导但因为指数太高会导致误差稍微大一点时梯度爆炸数值上极不稳定。相比之下平方误差处处光滑可导、凸性完美、对离群点的惩罚力度适中是回归任务最自然的选择。需要注意平方误差对离群点outlier比较敏感。因为误差被平方后一个偏离很远的点会主导整个代价函数。如果你的数据里有明显的异常值可以先做清洗再训练否则拟合出来的直线会被那个异常点“拽”过去。2.3 代价函数的等高线图与全局最优解在编程作业中你会画 J(θ₀, θ₁) 的等高线图contour plot。横轴是 θ₀纵轴是 θ₁等高线代表 J 值相等的点的连线。线性回归的代价函数是碗状的凸函数所以它有且只有一个全局最小值没有局部极小值这个说法。这意味着只要梯度下降跑得够久不管你从哪组初始参数出发最终大概率都会收敛到同一个最优解。实操中我建议你在代码里加上一行把每次迭代得到的 J 值保存下来画成迭代曲线。如果曲线是单调递减然后趋于水平说明一切正常如果曲线上升或者来回震荡说明学习率太大或者实现有 bug。这个习惯能从第一课延续到你日后训练深度学习模型非常管用。3. 梯度下降让参数自己“走”向最优值3.1 直观理解下山问题梯度下降的思想非常朴素。把你当前的参数组合想象成站在一片山谷中的一个点你的目标是走到谷底。你看不清全局地形但你能感知到自己脚下最陡的下降方向于是你每次沿着这个方向迈一小步不断重复就能到达谷底。这个“最陡的下降方向”在数学上就是代价函数 J 对 θ 的梯度偏导数构成的向量。沿着梯度的反方向走函数值下降最快。梯度下降更新公式θⱼ : θⱼ - α · (∂/∂θⱼ) J(θ₀, θ₁)其中 α 是学习率也就是步长。步长太大可能一步迈过谷底在两侧来回震荡;步长太小训练会非常慢。3.2 更新公式逐项拆解α、偏导数和同步更新这个公式有三个关键点任何一个理解不到位写代码都会出问题。第一个是 α 的梯度下降更新需要两个参数同时更新。如果你先更新了 θ₀然后用更新后的 θ₀ 去算 θ₁ 的偏导这就破坏了算法的正确性。正确做法是先用旧的 θ₀ 和 θ₁ 算出两个偏导值再同时更新。代码里写成# 正确写法先算偏导再更新 theta0_grad np.mean(h - y) # 对 θ0 的偏导 theta1_grad np.mean((h - y) * x) # 对 θ1 的偏导 theta0 theta0 - alpha * theta0_grad theta1 theta1 - alpha * theta1_grad第二个是学习率。α 是人为设定的超参数梯度下降不会自动帮你选。α 太小收敛慢α 太大代价函数可能发散输出 nan 或者 inf。后面我会专门讲怎么调试它。第三个是偏导数的计算。对于平方误差代价函数可以推导出∂J/∂θ₀ (1/m) Σ(h(x⁽ⁱ⁾) - y⁽ⁱ⁾) ∂J/∂θ₁ (1/m) Σ(h(x⁽ⁱ⁾) - y⁽ⁱ⁾) · x⁽ⁱ⁾值得留意的是θ₀ 的偏导就是误差的平均值θ₁ 的偏导是误差乘以对应特征值的平均值。这个形式在后续学多变量线性回归、逻辑回归时也会反复出现可以当成一个记忆锚点。3.3 学习率对收敛的影响实测对比学习率的选择是个经典问题。我用上面那组房价数据跑了三个不同 α 的实验能非常直观地看到差异。表格里记录的是迭代 30 轮后的代价函数值学习率 α迭代 5 轮后 J迭代 30 轮后 J现象0.00012513416890收敛极慢损失还是很大0.01120051快速下降基本接近最优0.143442下降更快但最后有点震荡0.51.7e72.3e17直接发散彻底跑飞从表格里可以总结出两点经验第一α 太小会让训练慢到让人失去耐心第二α 太大代价函数会在某次迭代后突然爆炸因为步子迈太大直接跨过了谷底跑到了代价更高的坡上甚至会越跑越远。实际调试时我习惯从 0.01 这个数量级开始观察 J 的迭代曲线再按 3 倍左右的速度调整。3.4 为什么线性回归的代价函数只有一个全局最小值这一讲里吴恩达反复强调线性回归的代价函数是凸函数但没有深入证明。我在这里给你一个直观的解释J(θ₀, θ₁) 是一个关于 θ₀ 和 θ₁ 的二次函数因为误差项里 θ₀ 和 θ₁ 都是一次幂平方之后就变成二次。二元二次函数在三维空间里画出来是一个抛物面椭圆抛物面类似于一个碗。碗的特性就是只有一个最低点没有“坑中坑”。所以梯度下降一定收敛到全局最优只要学习率不是太大。这个性质是线性回归的“特权”。当你日后用到神经网络时代价函数会变成高维非凸曲面里面有无数的局部极小值、鞍点优化难度完全不在一个量级。这也是为什么很多人建议把线性回归彻底搞懂再学深度学习。4. 完整 Python 实现从公式到可运行代码4.1 环境准备与数据构造建议直接用 Anaconda 环境Python 3.8 以上就行不需要额外装深度学习框架只要 numpy 和 matplotlib 两个库。我习惯在 Jupyter Notebook 里边写边看曲线调试体验比纯脚本好很多。构造数据的时候为了验证算法的正确性建议先用一组“已知答案”的数据。比如真实关系是 y 3 2x你按这个关系生成不带噪声的数据。如果梯度下降最后求出的 θ₀ ≈ 3、θ₁ ≈ 2说明实现是对的。之后再往数据里加一点高斯噪声模拟真实场景。import numpy as np import matplotlib.pyplot as plt # 生成带噪声的线性数据: 真实参数 θ03, θ12 np.random.seed(42) x np.linspace(0, 10, 100) y_true 3 2 * x y y_true np.random.randn(100) * 2 # 加入标准差为2的噪声4.2 代价函数与梯度下降代码实现下面是一个最简但完整的实现。为了让代码更清晰我把代价计算和梯度计算分开写def compute_cost(x, y, theta0, theta1): m len(y) h theta0 theta1 * x cost (1 / (2 * m)) * np.sum((h - y) ** 2) return cost def gradient_descent(x, y, theta0_init, theta1_init, alpha, num_iters): m len(y) theta0 theta0_init theta1 theta1_init cost_history [] for i in range(num_iters): h theta0 theta1 * x grad0 (1 / m) * np.sum(h - y) grad1 (1 / m) * np.sum((h - y) * x) # 同时更新 theta0 theta0 - alpha * grad0 theta1 theta1 - alpha * grad1 cost_history.append(compute_cost(x, y, theta0, theta1)) return theta0, theta1, cost_history # 训练 theta0, theta1, cost_history gradient_descent(x, y, 0, 0, alpha0.02, num_iters1000) print(f训练结果: θ0{theta0:.3f}, θ1{theta1:.3f})运行这段代码你会得到 θ₀ ≈ 3.0θ₁ ≈ 2.0 左右和设定的真实参数非常接近。这里的循环次数、初始参数都可以自己调每次改动都应该观察代价函数的变化形成“参数改动 → 结果变化”的直觉。4.3 训练过程可视化拟合直线与代价下降曲线训练完之后一定要画两张图第一张是原始数据散点图叠加最终拟合直线第二张是 cost_history 的下降曲线。这两张图能帮你省下大量 debug 时间。plt.figure(figsize(12, 4)) plt.subplot(1, 2, 1) plt.scatter(x, y, label训练数据, alpha0.7) plt.plot(x, theta0 theta1 * x, r-, label拟合直线) plt.xlabel(x) plt.ylabel(y) plt.legend() plt.subplot(1, 2, 2) plt.plot(range(len(cost_history)), cost_history) plt.xlabel(迭代次数) plt.ylabel(代价 J) plt.title(代价函数下降曲线) plt.tight_layout() plt.show()第一张图能直观看出拟合效果第二张图如果呈现平滑下降并逐步平稳说明梯度下降工作正常。如果下降曲线有锯齿状说明 α 偏大如果下降速度慢得像蜗牛说明 α 偏小或者需要做特征缩放。4.4 特征归一化对训练速度的影响在单变量线性回归中如果输入特征 x 的量纲很大比如房价面积梯度下降的收敛速度可能会变慢。原因是从代价函数等高线图来看当特征取值差异大时等高线会拉得很扁长梯度下降的路径会呈锯齿状来回震荡。解决方法是对特征做标准化feature scaling。最简单的方式是均值归一化x_norm (x - x.mean()) / x.std()做完归一化后特征均值约为 0标准差约为 1等高线变得更接近圆形梯度下降能沿着更直接的方向快速收敛。在多变量线性回归中这个问题会更突出而你会在 Lecture 4 里学到系统的处理方法。在单变量阶段你可以先动手试一下归一化前后的迭代次数对比感受会非常深刻。5. 常见问题与排查技巧实录5.1 代价函数输出 nan 或 inf 怎么办这是初学者最先踩到的坑几乎都是 α 太大导致的。初始化 θ 后第一次算代价还是正常数值但更新一次参数后由于步子迈得太大误差平方急剧膨胀再下一次更新时甚至溢出成 inf之后所有计算都废了。排查方法很简单把 α 往小了调比如从 0.01 调到 0.001然后看前 10 轮的 cost_history 是否还在正常范围内。还有一个隐藏原因x 的量级太大比如几万和 α 相乘后数值溢出。这种情况做特征归一化就能解决。5.2 代价函数一直在下降但速度极慢一个典型场景是你画出来的下降曲线是一条缓慢的“长尾”迭代几千轮还没收敛到理想值。这时候第一反应是调大 α但不能一下跳太多可以按 3 倍、10 倍这样往上试直到代价曲线在早期就开始快速下降并且没有出现震荡。我踩过的一个坑是只盯着最终代价看没注意收敛速度。有次跑多变量线性回归α0.001 时 J 从 5000 降到 4000 花了 2000 轮表面看没问题但实际上离最优值还差好几个数量级。后来把 α 调到 0.05100 轮就把 J 降到了接近 0。所以训练时一定要看曲线的形状而不是只看它“有没有在下降”。5.3 怎么判断梯度下降已经收敛吴恩达在课程里提到了自动收敛检测如果 J(θ) 在两次连续迭代之间的变化小于某个阈值 ε比如 1e-5就认为收敛。但这个阈值不太容易选太小会多跑很多无用迭代太大会提前“下车”得到一个次优解。我个人的习惯是看代价曲线是否进入平台期也就是连续 50 轮 J 值的变化幅度小于当前 J 的 1e-4 左右。这个方法比固定阈值更稳因为不同数据集的代价本身量级差异很大固定阈值很难通用。5.4 为什么一定要“同时更新”θ₀ 和 θ₁这个问题我在文章前面提过但值得再重点强调一次。如果你写成了先更新 θ₀、再用新 θ₀ 参与 θ₁ 的计算代码也能运行结果还不一定错得很离谱——因为这两种更新方式都让参数往下降方向走。但从数学上讲你用的就不再是“在 (θ₀, θ₁) 这一点”的梯度了而是在一个参数已经变化后的“错位点”计算的梯度。这个偏差在单变量情下影响不大但在神经网络的反向传播里如果顺序更新逻辑混乱会导致梯度方向错误训练直接失败。所以从一开始就养成正确习惯用一个临时变量或者同一次前向传播的结果一次性计算出所有梯度然后一次性更新所有参数。5.5 实用避坑清单初始参数 θ 0 是安全的。线性回归不会因为初始位置不对而陷入局部最优不要在这个问题上过度纠结。归一化要放在划分训练集之前还是之后在单变量回归里无所谓但在后续做验证集、测试集划分时要记住归一化的均值和标准差只能用训练集计算避免“数据泄露”。画等高线图时注意范围。J 值变化跨越多个数量级时等高线图可能会在边缘挤成一团这时候用对数坐标或者缩小 θ 范围会看得更清楚。把代价函数封装成独立函数后面调试多变量回归时可以直接复用减少重复工作。如果代码结果和课程作业不一致先检查 cost_history 的前几个值大概率能直接定位是计算公式错误还是学习率问题。6. 从单变量到多变量这一讲的思路如何延伸这一讲的所有内容都可以无缝迁移到多变量线性回归。当特征从 1 个变成 n 个假设函数变成 h_θ(x) θ₀ θ₁x₁ θ₂x₂ ... θₙxₙ代价函数和梯度下降公式的形式几乎不变只是需要对每个参数分别求偏导。用矩阵运算表达的话就是向量的点积和矩阵乘法速度会快很多。我自己当年学完 Lecture 2 后是这么消化这一讲内容的手推了一遍偏导数公式然后用 numpy 从零实现了梯度下降最后画出了代价函数的三维曲面和等高线图。做完这三件事后面听 Lecture 4 多变量线性回归时几乎不用换脑子。我强烈建议你也试试这个流程——真正让知识长在手上的方式永远是亲手算一笔、写一段、画一图。这一讲最后还有个非常值得思考的点梯度下降本质上是一种通用优化器它不关心你的模型是不是线性回归。这意味着你完全可以把梯度下降的框架用到别的模型上只要你能定义出一个可导的代价函数。我后来用这个思路改写过逻辑回归的代码几乎把之前实现的梯度下降函数原封不动地拿过去换个代价函数和梯度计算就训练成功了。这个体会让我意识到学机器学习时把“优化器”和“模型”分开理解会让思路清晰很多——这也算是 Lecture 2 给我留下的、影响最深的一句话之外的收获了。