很多年之后再回头看,吴恩达的机器学习课程依然是很多人入门的首选。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 为什么用平方误差而不是绝对值或四次方
绝对值误差 MAE(Mean 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.0001 | 25134 | 16890 | 收敛极慢,损失还是很大 |
| 0.01 | 1200 | 51 | 快速下降,基本接近最优 |
| 0.1 | 434 | 42 | 下降更快,但最后有点震荡 |
| 0.5 | 1.7e+7 | 2.3e+17 | 直接发散,彻底跑飞 |
从表格里可以总结出两点经验:第一,α 太小会让训练慢到让人失去耐心;第二,α 太大,代价函数会在某次迭代后突然爆炸,因为步子迈太大直接跨过了谷底,跑到了代价更高的坡上,甚至会越跑越远。实际调试时,我习惯从 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 # 生成带噪声的线性数据: 真实参数 θ0=3, θ1=2 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, alpha=0.02, num_iters=1000) 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='训练数据', alpha=0.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.05,100 轮就把 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 给我留下的、影响最深的一句话之外的收获了。