线性回归大概是很多人机器学习路上绕不开的第一道坎。我见过太多人一上来就啃深度学习、Transformer,结果连损失函数怎么收敛都说不清楚,回头补基础的时候又觉得“线性回归太简单了没什么好学的”。但实际情况恰恰相反——线性回归里藏着梯度下降、特征缩放、学习率选择、过拟合判断这些核心概念,把这些吃透了,后面学逻辑回归、神经网络会顺很多。这篇内容就是围绕吴恩达机器学习课程里线性回归这个经典实验展开的,我会把单变量线性回归的完整实现过程拆开讲清楚,包括代价函数的推导、梯度下降的手动实现、学习率的调试经验,以及向量化写法为什么比循环快。适合刚入门机器学习、想动手把理论跑通的人,也适合学了一段时间但想回头夯实基础的朋友。整个实验用Python加NumPy就能跑,不需要GPU,一台普通笔记本足够。
1. 实验整体设计与思路拆解
1.1 为什么第一个实验选线性回归
机器学习的入门实验有很多选择,比如KNN、决策树、朴素贝叶斯,这些算法理解起来也不难。但线性回归被放在第一个,是有它的道理的。它同时具备三个特点:数学模型足够简单、优化过程足够典型、评估方式足够直观。
先说数学模型简单。线性回归的假设函数就是一个线性方程,单变量情况下是 ( h_\theta(x) = \theta_0 + \theta_1 x ),两个参数,画出来就是一条直线。你不需要理解复杂的网络结构或者概率图模型,就能把注意力集中在“模型怎么从数据里学到参数”这件事上。
再说优化过程典型。线性回归用的梯度下降法,是几乎所有机器学习算法优化的基础。你在这里搞懂了“沿着梯度反方向更新参数”这件事,后面学逻辑回归、神经网络、甚至一些强化学习算法,优化思路都是一脉相承的。代价函数对参数的偏导数怎么求、学习率怎么影响收敛、迭代次数怎么定,这些问题在线性回归里暴露得最干净。
最后说评估方式直观。单变量线性回归可以把数据点和拟合直线画在同一张图上,你一眼就能看出拟合得好不好。代价函数随迭代次数的变化曲线也能直接画出来,收敛没收敛一目了然。这种可视化反馈对初学者建立直觉特别重要。
所以这个实验的设计思路就是:用最简单的模型,把机器学习的核心流程完整走一遍。流程包括加载数据、定义假设函数、定义代价函数、实现梯度下降、可视化结果、评估模型。这个流程走通了,后面换任何模型都是换中间那几个函数的事。
1.2 实验数据的选择与预处理思路
吴恩达课程里的原始数据是一个城市人口和利润的关系,大概97条样本。我这里不直接用那个数据,而是构造一份类似的模拟数据,方便你理解数据生成的逻辑,也避免直接照搬课程作业。
数据构造的思路是这样的:假设真实关系是 ( y = 3 + 2x + \epsilon ),其中 ( \epsilon ) 是噪声,服从均值为0、标准差为0.5的正态分布。x 取0到10之间的均匀分布,生成100个点。这样构造出来的数据,真实参数是 ( \theta_0 = 3 )、( \theta_1 = 2 ),我们后面看梯度下降能不能收敛到接近这两个值。
为什么要加噪声?因为真实世界的数据不可能完美落在一条直线上。如果数据完全线性,那用最小二乘法一步就能解出来,梯度下降的意义就体现不出来了。加噪声之后,代价函数是一个碗状的凸函数,有唯一最小值,梯度下降会逐步逼近这个最小值,这个过程才是我们要观察的。
预处理方面,单变量线性回归其实不需要太复杂的预处理。但有一个步骤很关键:特征缩放。如果 x 的取值范围是0到10,而 y 的取值范围是0到25,两个参数的梯度尺度不一样,梯度下降可能会震荡。不过在这个量级下问题不大,我后面会专门讲什么时候必须做特征缩放,以及怎么做。
注意:构造模拟数据的时候,随机种子要固定,否则每次运行结果不一样,调试的时候会很痛苦。用
np.random.seed(42)这种写法,保证结果可复现。
1.3 工具选型:为什么用NumPy而不是sklearn
这个实验我坚持用NumPy手写梯度下降,不用sklearn的LinearRegression。原因很简单:sklearn的LinearRegression用的是正规方程(Normal Equation)或者SVD分解,直接给出解析解,你看不到迭代过程。而梯度下降的迭代过程,恰恰是这个实验最值得看的部分。
用NumPy手写,你需要自己算偏导数、自己写更新公式、自己控制迭代次数和学习率。这个过程会逼着你把每个细节都想清楚。比如代价函数 ( J(\theta) = \frac{1}{2m}\sum_{i=1}^{m}(h_\theta(x^{(i)}) - y^{(i)})^2 ) 里的 ( \frac{1}{2m} ) 为什么要有个2,因为求导之后2会约掉,让公式更简洁。这种细节,用sklearn是体会不到的。
当然,实际工作中我肯定优先用sklearn,因为快、稳、经过充分测试。但学习阶段,手写一遍的价值远大于调包。我的建议是:先用NumPy手写跑通,理解每一步在干什么,然后再用sklearn跑一遍,对比结果是否一致。如果一致,说明你手写的逻辑没问题;如果不一致,回去检查哪里错了。
工具清单很简单:Python 3.8以上、NumPy、Matplotlib。不需要pandas,因为数据量小,直接用NumPy数组就够了。不需要Jupyter Notebook也行,但我个人习惯用Notebook做这种探索性实验,因为可以分块运行,改一个参数就能立刻看到结果。
2. 核心细节解析与实操要点
2.1 假设函数与代价函数的数学推导
假设函数是线性回归的核心。单变量情况下,假设函数写成:
[ h_\theta(x) = \theta_0 + \theta_1 x ]
这里的 ( \theta_0 ) 是截距,( \theta_1 ) 是斜率。用向量形式写就是 ( h_\theta(x) = \theta^T x ),其中 ( \theta = [\theta_0, \theta_1]^T ),( x = [1, x]^T )。为什么要给 x 前面补一个1?因为这样可以把截距项统一到矩阵乘法里,写代码的时候不用单独处理 ( \theta_0 )。这个技巧在后面多变量线性回归里会显得更重要。
代价函数用的是均方误差(MSE):
[ J(\theta_0, \theta_1) = \frac{1}{2m} \sum_{i=1}^{m} (h_\theta(x^{(i)}) - y^{(i)})^2 ]
这个函数衡量的是模型预测值和真实值之间的差距。差距越大,代价越高。前面的 ( \frac{1}{2m} ) 有两个作用:m 是样本数,除以 m 是为了消除样本数量对代价的影响,让不同规模的数据集可以比较;除以 2 是为了求导方便,因为平方求导会出来一个2,和分母的2约掉。
代价函数的偏导数推导过程是这样的:
对 ( \theta_0 ) 求偏导:
[ \frac{\partial J}{\partial \theta_0} = \frac{1}{m} \sum_{i=1}^{m} (h_\theta(x^{(i)}) - y^{(i)}) ]
对 ( \theta_1 ) 求偏导:
[ \frac{\partial J}{\partial \theta_1} = \frac{1}{m} \sum_{i=1}^{m} (h_\theta(x^{(i)}) - y^{(i)}) \cdot x^{(i)} ]
这两个偏导数就是梯度下降的方向。注意 ( \theta_1 ) 的偏导多乘了一个 ( x^{(i)} ),这是因为 ( \theta_1 ) 在假设函数里是乘以 x 的,链式法则求导之后会留下这个 x。
提示:如果你对推导过程不熟,建议拿纸笔自己推一遍。不用推得多严谨,但至少要知道每一项是怎么来的。推导一遍之后,写代码的时候就不会把 ( \theta_0 ) 和 ( \theta_1 ) 的更新公式搞混。
2.2 梯度下降的更新规则与同步更新
梯度下降的更新规则是:
[ \theta_j := \theta_j - \alpha \frac{\partial J}{\partial \theta_j} ]
其中 ( \alpha ) 是学习率。这个公式的意思是:沿着梯度的反方向,走一小步。梯度指向函数上升最快的方向,反方向就是下降最快的方向。学习率控制步长,太大容易震荡甚至发散,太小收敛太慢。
这里有一个非常容易踩的坑:同步更新。也就是说,计算 ( \theta_0 ) 和 ( \theta_1 ) 的新值时,必须用旧的 ( \theta_0 ) 和 ( \theta_1 ) 来计算梯度,不能先用新 ( \theta_0 ) 更新完,再用新 ( \theta_0 ) 去算 ( \theta_1 ) 的梯度。正确的做法是先把两个梯度都算出来存到临时变量里,然后同时更新。
用代码表示就是:
# 正确写法:同步更新 tmp_theta0 = theta0 - alpha * d_theta0 tmp_theta1 = theta1 - alpha * d_theta1 theta0 = tmp_theta0 theta1 = tmp_theta1# 错误写法:非同步更新 theta0 = theta0 - alpha * d_theta0 theta1 = theta1 - alpha * d_theta1 # 这里的d_theta1是用旧的theta0算的,但theta0已经变了在单变量线性回归里,这两种写法导致的差异可能不明显,但在多变量或者复杂模型里,非同步更新会导致结果完全错误。养成同步更新的习惯,从第一个实验就开始。
2.3 学习率的选取与调试经验
学习率 ( \alpha ) 是梯度下降里最需要调的超参数。我试过的学习率从0.001到1.0都有,下面是我总结的经验:
| 学习率 | 现象 | 是否可用 |
|---|---|---|
| 0.001 | 收敛极慢,迭代1000次代价还在下降 | 可用但效率低 |
| 0.01 | 收敛速度适中,迭代500次左右基本收敛 | 推荐 |
| 0.03 | 收敛较快,迭代200次左右收敛 | 推荐 |
| 0.1 | 收敛快,但代价曲线有轻微震荡 | 可用 |
| 0.3 | 震荡明显,代价不降反升 | 不可用 |
| 1.0 | 直接发散,代价变成NaN | 不可用 |
这个数据是基于我构造的那份模拟数据(x范围0到10,y范围0到25)测出来的。如果你的数据量级不同,这个表只能作为参考,不能照搬。
调试学习率的方法:先从一个很小的值开始,比如0.001,跑100次迭代,看代价是不是在下降。如果下降太慢,逐步增大学习率,每次乘以3左右。当代价曲线开始出现震荡或者上升的时候,说明学习率太大了,退回上一个值。这个过程我一般会试3到5次,就能找到一个合适的值。
还有一个技巧:把代价随迭代次数的变化画出来。如果曲线是平滑下降然后趋于平缓,说明学习率合适;如果曲线是锯齿状的,说明学习率偏大;如果曲线下降很慢几乎是一条斜线,说明学习率偏小。
注意:学习率和迭代次数是配合使用的。学习率小,迭代次数就要多;学习率大,迭代次数可以少。但学习率不能无限大,超过某个阈值就会发散。这个阈值和数据的尺度有关,数据尺度越大,阈值越小。
2.4 特征缩放的必要性与方法
单变量线性回归里,特征缩放不是必须的,因为只有一个特征。但如果你把 x 的范围改成0到10000,y 的范围还是0到25,那 ( \theta_1 ) 的梯度会非常大,( \theta_0 ) 的梯度相对很小,梯度下降会在 ( \theta_1 ) 方向上震荡,收敛很慢。
特征缩放的做法有两种:归一化(Normalization)和标准化(Standardization)。
归一化是把特征缩放到0到1之间:
[ x' = \frac{x - \min(x)}{\max(x) - \min(x)} ]
标准化是把特征缩放到均值为0、标准差为1:
[ x' = \frac{x - \mu}{\sigma} ]
在线性回归里,两种方法都常用。我个人的习惯是用标准化,因为它对异常值没那么敏感。但要注意,缩放之后得到的 ( \theta_1 ) 是缩放后特征的系数,如果要还原到原始尺度,需要做逆变换。
什么时候必须做特征缩放?我的经验是:如果不同特征的取值范围相差超过一个数量级,就做。比如一个特征是年龄(0到100),另一个特征是收入(0到100000),那必须做。如果所有特征范围差不多,可以不做,但做了也没坏处。
3. 实操过程与核心环节实现
3.1 数据生成与可视化检查
第一步是生成数据。我用NumPy来构造:
import numpy as np import matplotlib.pyplot as plt np.random.seed(42) m = 100 # 样本数 x = np.random.uniform(0, 10, m) y = 3 + 2 * x + np.random.normal(0, 0.5, m) plt.scatter(x, y, alpha=0.6) plt.xlabel('x') plt.ylabel('y') plt.title('模拟数据散点图') plt.show()这段代码生成100个样本,x在0到10之间均匀分布,y = 3 + 2x 加上正态噪声。散点图画出来应该能看到一个明显的线性趋势,但点不会完美落在一条直线上。
可视化检查这一步不能省。我见过有人拿到数据直接跑模型,结果数据里有个明显的异常值或者非线性关系,模型效果很差还找不到原因。画个散点图,30秒的事,能避免很多弯路。
3.2 代价函数的向量化实现
代价函数的实现有两种写法:循环和向量化。先看循环写法:
def compute_cost_loop(x, y, theta0, theta1): m = len(y) total_cost = 0 for i in range(m): h = theta0 + theta1 * x[i] total_cost += (h - y[i]) ** 2 return total_cost / (2 * m)这种写法逻辑清晰,但速度慢。100个样本还好,100万样本的时候循环会慢到无法接受。向量化写法:
def compute_cost(x, y, theta): m = len(y) h = x @ theta # x是m×2的矩阵,theta是2×1的向量 return np.sum((h - y) ** 2) / (2 * m)这里 x 需要构造成 m×2 的矩阵,第一列全是1,第二列是原始特征值:
X = np.column_stack((np.ones(m), x)) theta = np.zeros(2)向量化写法利用NumPy的矩阵运算,底层是C语言实现的,比Python循环快几十倍甚至上百倍。我实测过,100万样本下,向量化写法大概0.01秒,循环写法要好几秒。这个差距在迭代1000次的时候会被放大到几千秒。
提示:向量化写法的关键是理解矩阵乘法的维度。X是m×2,theta是2×1,X @ theta 得到m×1的预测值向量。这个维度关系想清楚了,后面多变量线性回归也是同样的套路。
3.3 梯度下降的完整实现
梯度下降的实现如下:
def gradient_descent(X, y, theta, alpha, num_iters): m = len(y) J_history = np.zeros(num_iters) for i in range(num_iters): h = X @ theta error = h - y gradient = (X.T @ error) / m theta = theta - alpha * gradient J_history[i] = compute_cost(X, y, theta) return theta, J_history这段代码里,X.T @ error计算的是所有样本的梯度之和,除以m得到平均梯度。theta = theta - alpha * gradient是同步更新,因为gradient是先算好的,更新theta的时候用的是同一个gradient向量。
跑一下:
theta_init = np.zeros(2) alpha = 0.01 num_iters = 1000 theta_final, J_history = gradient_descent(X, y, theta_init, alpha, num_iters) print(f"最终theta: {theta_final}") print(f"真实theta: [3, 2]")我跑出来的结果大概是[2.98, 2.01]左右,和真实值很接近。代价曲线从初始的几十下降到0.1左右,收敛得比较平滑。
3.4 结果可视化与拟合直线绘制
把拟合直线画到散点图上:
plt.scatter(x, y, alpha=0.6, label='数据点') plt.plot(x, X @ theta_final, color='red', label='拟合直线') plt.xlabel('x') plt.ylabel('y') plt.legend() plt.title('线性回归拟合结果') plt.show()再画代价曲线:
plt.plot(range(num_iters), J_history) plt.xlabel('迭代次数') plt.ylabel('代价') plt.title('代价函数收敛曲线') plt.show()这两张图是这个实验的核心产出。拟合直线图告诉你模型学到的关系对不对,代价曲线告诉你优化过程顺不顺利。如果拟合直线明显偏离数据趋势,或者代价曲线不下降,那就要回去检查代码或者调参数。
3.5 不同学习率的对比实验
为了直观展示学习率的影响,我跑了一组对比:
alphas = [0.001, 0.01, 0.03, 0.1, 0.3] plt.figure(figsize=(12, 8)) for i, alpha in enumerate(alphas): theta_init = np.zeros(2) theta_final, J_history = gradient_descent(X, y, theta_init, alpha, 200) plt.subplot(2, 3, i+1) plt.plot(J_history) plt.title(f'alpha={alpha}') plt.xlabel('迭代次数') plt.ylabel('代价') plt.tight_layout() plt.show()从结果可以看到,alpha=0.001时代价下降很慢,200次迭代还没收敛;alpha=0.01和0.03下降平滑且收敛快;alpha=0.1有轻微震荡但整体下降;alpha=0.3直接发散,代价越来越大。这个对比实验建议每个人都跑一遍,比看文字描述直观得多。
4. 常见问题与排查技巧实录
4.1 代价函数不下降或变成NaN
这是最常见的问题。原因通常有三个:学习率太大、数据没有做特征缩放、代码里有bug。
排查顺序:先把学习率调小10倍,看代价是否下降。如果下降,说明学习率太大。如果还是NaN,检查数据里有没有NaN或者无穷大的值。如果数据没问题,检查梯度计算是否正确。一个简单的验证方法是用数值梯度对比解析梯度:
def numerical_gradient(X, y, theta, epsilon=1e-4): grad = np.zeros_like(theta) for i in range(len(theta)): theta_plus = theta.copy() theta_minus = theta.copy() theta_plus[i] += epsilon theta_minus[i] -= epsilon grad[i] = (compute_cost(X, y, theta_plus) - compute_cost(X, y, theta_minus)) / (2 * epsilon) return grad数值梯度和解析梯度的差应该在1e-6以内。如果差很大,说明解析梯度算错了。
4.2 收敛到局部最优还是全局最优
线性回归的代价函数是凸函数,只有一个全局最优解,不存在局部最优的问题。如果你发现代价收敛到一个比较高的值,那不是局部最优,而是学习率太小或者迭代次数不够,还没走到最低点。
验证方法:把迭代次数加到10000次,看代价是否还在下降。如果还在下降,说明之前没收敛。如果已经平了,说明收敛了。另外,线性回归也可以用正规方程直接求解析解:
[ \theta = (X^T X)^{-1} X^T y ]
用这个解作为基准,梯度下降的结果应该接近它。如果差很远,说明梯度下降没收敛。
4.3 特征缩放后参数如何还原
如果对x做了标准化,得到的 ( \theta_1' ) 是缩放后特征的系数。还原到原始尺度的公式是:
[ \theta_1 = \frac{\theta_1'}{\sigma} ]
[ \theta_0 = \theta_0' - \theta_1' \cdot \frac{\mu}{\sigma} ]
其中 ( \mu ) 和 ( \sigma ) 是原始x的均值和标准差。这个还原过程容易搞错,建议缩放前先把 ( \mu ) 和 ( \sigma ) 存下来,还原的时候直接用。
4.4 常见问题速查表
| 问题 | 可能原因 | 解决方法 |
|---|---|---|
| 代价不下降 | 学习率太小 | 增大学习率,试0.01、0.03、0.1 |
| 代价震荡 | 学习率太大 | 减小学习率,试0.001、0.003 |
| 代价变成NaN | 学习率过大或数据有NaN | 检查数据,大幅减小学习率 |
| 收敛太慢 | 未做特征缩放 | 对特征做标准化或归一化 |
| 结果与sklearn不一致 | 未收敛或梯度算错 | 增加迭代次数,用数值梯度验证 |
| 参数更新后代价反而上升 | 非同步更新 | 改为同步更新,先算梯度再更新 |
4.5 几个我踩过的坑
第一个坑:忘记给x加一列1。结果theta只有1个参数,假设函数变成 ( h = \theta_1 x ),没有截距项。拟合直线强制过原点,效果很差。这个错误很隐蔽,因为代码不报错,只是结果不对。
第二个坑:用Python列表而不是NumPy数组做运算。列表的*是重复,不是乘法。比如[1,2] * 2得到[1,2,1,2],不是[2,4]。这个错误在调试的时候很难发现,因为结果看起来像是某种奇怪的数值。解决办法是养成习惯,数据一进来就转成np.array。
第三个坑:迭代次数设得太少。我一开始设100次,代价还在下降就停了,以为收敛了。后来画代价曲线才发现,100次的时候代价还在明显下降。现在我的习惯是至少跑1000次,然后看代价曲线最后100次的变化,如果变化小于1e-6,才算收敛。
第四个坑:学习率用固定值。有些数据前期需要大学习率快速下降,后期需要小学习率精细收敛。固定学习率要么前期慢要么后期震荡。解决办法是用学习率衰减,比如每100次迭代学习率乘以0.9。不过线性回归里这个问题不严重,固定学习率够用。
4.6 从单变量到多变量的扩展思路
单变量线性回归跑通之后,扩展到多变量其实很简单。假设函数变成 ( h_\theta(x) = \theta_0 + \theta_1 x_1 + \theta_2 x_2 + ... + \theta_n x_n ),向量形式还是 ( h_\theta(x) = \theta^T x ),只是x和theta的维度变了。梯度下降的代码几乎不用改,因为向量化写法本身就是支持多维的。
需要改的地方有两个:一是特征缩放变得必须做,因为不同特征的尺度可能差异很大;二是代价曲线的震荡可能更明显,需要更仔细地调学习率。另外,多变量线性回归可以用正规方程直接求解,但特征数超过10000的时候,矩阵求逆的计算量太大,还是得用梯度下降。
我个人的建议是:单变量实验跑通之后,自己构造一份多变量数据,把代码改一改跑一遍。不用改太多,主要是把X的构造从两列变成多列,然后观察特征缩放对收敛速度的影响。这个练习做完,线性回归这部分就算真正掌握了。