线性回归大概是很多人机器学习路上绕不开的第一道坎。我见过太多人一上来就啃深度学习、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_{i1}^{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_{i1}^{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_{i1}^{m} (h_\theta(x^{(i)}) - y^{(i)}) ]对 ( \theta_1 ) 求偏导[ \frac{\partial J}{\partial \theta_1} \frac{1}{m} \sum_{i1}^{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到10y范围0到25测出来的。如果你的数据量级不同这个表只能作为参考不能照搬。调试学习率的方法先从一个很小的值开始比如0.001跑100次迭代看代价是不是在下降。如果下降太慢逐步增大学习率每次乘以3左右。当代价曲线开始出现震荡或者上升的时候说明学习率太大了退回上一个值。这个过程我一般会试3到5次就能找到一个合适的值。还有一个技巧把代价随迭代次数的变化画出来。如果曲线是平滑下降然后趋于平缓说明学习率合适如果曲线是锯齿状的说明学习率偏大如果曲线下降很慢几乎是一条斜线说明学习率偏小。注意学习率和迭代次数是配合使用的。学习率小迭代次数就要多学习率大迭代次数可以少。但学习率不能无限大超过某个阈值就会发散。这个阈值和数据的尺度有关数据尺度越大阈值越小。2.4 特征缩放的必要性与方法单变量线性回归里特征缩放不是必须的因为只有一个特征。但如果你把 x 的范围改成0到10000y 的范围还是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, alpha0.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×2theta是2×1X 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, alpha0.6, label数据点) plt.plot(x, X theta_final, colorred, 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, i1) plt.plot(J_history) plt.title(falpha{alpha}) plt.xlabel(迭代次数) plt.ylabel(代价) plt.tight_layout() plt.show()从结果可以看到alpha0.001时代价下降很慢200次迭代还没收敛alpha0.01和0.03下降平滑且收敛快alpha0.1有轻微震荡但整体下降alpha0.3直接发散代价越来越大。这个对比实验建议每个人都跑一遍比看文字描述直观得多。4. 常见问题与排查技巧实录4.1 代价函数不下降或变成NaN这是最常见的问题。原因通常有三个学习率太大、数据没有做特征缩放、代码里有bug。排查顺序先把学习率调小10倍看代价是否下降。如果下降说明学习率太大。如果还是NaN检查数据里有没有NaN或者无穷大的值。如果数据没问题检查梯度计算是否正确。一个简单的验证方法是用数值梯度对比解析梯度def numerical_gradient(X, y, theta, epsilon1e-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的构造从两列变成多列然后观察特征缩放对收敛速度的影响。这个练习做完线性回归这部分就算真正掌握了。
阅读完成 · 觉得有帮助?