手写线性回归梯度下降:从代价函数到特征归一化全解析
1. 作业目标与实际价值为什么第一周就要手推线性回归这门课的第一周作业也就是ex1是整个系列里最基础、但也是最值得反复做的一道题。它的核心不是让你调库而是要求你从零实现一元线性回归和多元线性回归的批量梯度下降并且亲手写出代价函数。很多初学机器学习的朋友把精力都花在看视频理论上到了编程练习却想着赶紧把代码跑通交差这其实浪费了课程设计者最用心的一层安排。我在第一次做ex1的时候走了不少弯路。坦白讲光看视频里那个 J(θ) 的公式推导我觉得自己懂了可一旦面对用代码实现梯度下降迭代这个任务就有种无从下手的感觉。后来我花了两个晚上把每一步拆开在草稿纸上把矩阵维度对齐才真正理解了批量梯度下降到底在做什么。这也是我写这篇详解的初衷不只是给你能通过的答案而是把每行代码背后的计算逻辑、矩阵形状、常见错误全部讲透。这篇内容适合以下读者刚开始学机器学习想做编程作业但不知道从哪下手的人视频看了好几遍但一写代码就卡壳的人已经交了作业但想搞清楚为什么某些写法会报错、某些写法能跑通的人想复习线性回归手写实现的老手快速梳理一遍梯度下降的细节这次作业使用的语言是 Octave 或 MATLAB配套框架是课程给出的 ex1.m、ex1data1.txt 和 ex1data2.txt。如果你本机还没有装好 Octave我建议直接装最新版然后用pwd命令确认工作目录在 ex1 文件夹下再用ls看一遍文件是否齐全。这一步虽然简单但见过不少人就是因为目录没切对导致加载数据时报错。ex1 做的是预测餐车利润和预测房价两个任务前者是一元线性回归后者是多元特征回归加上特征归一化。这两个问题虽然数据不同但背后的核心只涉及三件事假设函数、代价函数、梯度下降。把这三件事从公式落到代码你后续学逻辑回归、神经网络、甚至深度学习的反向传播都会顺很多。2. 数据和环境准备ex1data1.txt 里到底装了什么开始写代码之前先把数据加载环节讲清楚。很多初学者会忽略这一步直接跳到 computeCost然后怎么都调不对。其实数据读进来之后长什么样、维度是多少决定了你后面的代码怎么写。ex1data1.txt 是一个纯文本文件每行有两个数值用逗号分隔。第一个数值是城市人口单位是万人第二个数值是该城市餐车的利润单位是万元。课程给的数据是 97 个城市的样本也就是 97 行、两列。在 Octave 里加载这段数据的标准写法是data load(ex1data1.txt); X data(:, 1); y data(:, 2);注意这里 load 出来的是一个 97×2 的矩阵。X 取第一列y 取第二列。可问题来了ex1 框架里要求 X 是一个 97×2 的矩阵第一列是常数项 1第二列才是人口。很多人第一步就栽在这里直接拿 X data(:, 1) 去算后面 computeCost 的矩阵乘法维度永远对不上。正确做法是先给 X 左边拼接一列 1m length(y); % 样本数97 X [ones(m, 1), data(:, 1)]; y data(:, 2);这行代码的意思是把一个 m×1 的全 1 列向量和原始的第一列人口数据横向拼接最终 X 的维度是 97×2。为什么必须加这一列 1因为线性回归的假设函数是 h_θ(x) θ₀ θ₁x这里的 θ₀ 是偏置项它对应的特征值永远是 1。把偏置项统一成矩阵运算代码才能写成一个矩阵乘法的形式。另外y 保持为列向量维度是 97×1。theta 初始值通常设为 zeros(2, 1)也就是一个 2×1 的列向量。这样 X 是 97×2theta 是 2×1X*theta 的结果正好是 97×1再减去 y 也是 97×1维度完全对齐。这是全局最重要的一个约定从第一步就把所有向量的形状弄清楚后面才不会反复调试。数据加载之后我建议先做一次可视化确认数据长什么样。ex1.m 里已经帮你写好了 plotData 函数的调用但函数本身需要你实现。可视化代码非常简单function plotData(x, y) figure; plot(x, y, rx, MarkerSize, 10); xlabel(Population of City in 10,000s); ylabel(Profit in $10,000s); end画出来之后你会看到横轴是人口、纵轴是利润点分布大致呈现一条向右上方倾斜的带状。这个直观印象很重要它告诉你用一条直线来拟合这个数据是合理的。如果你画完发现点分布是完全乱序的曲线那就要考虑数据是否选错或者加载有问题。再说说环境变量这一层。做 ex1 时我常用的一个操作是who它会列出当前工作区里所有的变量名和维度。每写完一段计算就用who看一眼 X、y、theta 的形状是否符合预期这比单步调试还管用。还有一个命令size()可以单独查看某个变量的维度。养成随时检查维度的习惯对后面所有作业都有帮助。3. computeCost 的实现思路从公式到矩阵运算的一一对应代价函数在视频里长这样J(θ) (1/2m) * Σᵢ₌₁ᵐ (h_θ(x⁽ⁱ⁾) - y⁽ⁱ⁾)²注意这里用的是 1/2m 而不是常见的 1/m。为什么因为对 J(θ) 求导时平方项求导会出来一个 2前面的 1/2 正好把它消掉梯度表达式更干净。这个细节在纯数学推导里无伤大雅但作业里 computeCost 必须按课程公式实现否则初始代价数值会对不上。你需要在 computeCost.m 里补全函数输入是 X、y、theta输出是 J。一个可以直接通过的向量化写法如下function J computeCost(X, y, theta) m length(y); predictions X * theta; sqrErrors (predictions - y).^2; J 1 / (2 * m) * sum(sqrErrors); end逐行解释一下predictions 就是 h_θ(x)是 X 乘以 theta 后得到的预测值列向量维度是 m×1。predictions - y 是每个样本的预测误差也是一个 m×1 列向量。对误差向量逐元素平方用 .^2 而不是 ^2这一点极其重要。在 Octave/MATLAB 里X^2 是矩阵平方而 X.^2 才是逐元素平方。新手经常在这行代码上报错或者结果面目全非。sum() 是对向量所有元素求和相当于公式里的累加符号。最后乘上 1/(2*m) 就是完整的代价函数值。你可能已经注意到predictions - y这一行代码背后有个更精简的表达X*theta 其实已经把每个样本的预测值批量算好了根本不需要 for 循环。这就是向量化的威力。用循环写也行但性能差、代码冗余。做作业的时候框架希望你用向量化写法这也是吴恩达在视频里反复强调的思想。我见过很多人在这一步得到的 J 初始值不是 32.07而是其他数值。原因通常有两个一是 X 没有拼接全 1 列二是 y 方向搞错比如 y 变成了行向量。如果你算出来 J 32.07那说明数据加载、矩阵维度、向量化写法全部正确可以放心进入下一步。顺便提一个进阶问题如果你把公式里的 1/(2m) 误写成 1/m这里算出的初始代价会变成 64.14 左右和标准答案对不上。虽然梯度下降依然能收敛但后续打印的代价历史全都偏了一倍。所以写公式时务必对照课程原式不要自作聪明地改成更熟悉的写法。4. gradientDescent 的核心循环理解 θ 的同时更新规则梯度下降是整个 ex1 的灵魂。它做的事情简单说就是根据当前 θ 算出梯度方向然后沿反方向迈一步反复迭代最终让代价函数降到足够小。视频里给的更新规则是θⱼ : θⱼ - α * (1/m) * Σᵢ₌₁ᵐ (h_θ(x⁽ⁱ⁾) - y⁽ⁱ⁾) * xⱼ⁽ⁱ⁾注意这里的符号是同时更新所有 θⱼ 必须用同一批旧 θ 算出的梯度来更新不能先更新 θ₀再拿更新后的 θ₀ 去算 θ₁。如果你在代码里写成了两次分开更新梯度下降的结果会偏移虽然不至于完全不收敛但代价函数的下降曲线会变得不正常。作业要求你补全 gradientDescent.m 的主体框架里已经帮你写好了迭代循环的结构和 J_history 的保存逻辑。我的实现如下function [theta, J_history] gradientDescent(X, y, theta, alpha, num_iters) m length(y); J_history zeros(num_iters, 1); for iter 1:num_iters predictions X * theta; errors predictions - y; gradient (1 / m) * (X * errors); theta theta - alpha * gradient; J_history(iter) computeCost(X, y, theta); end end这段代码里最关键的一行是gradient (1/m) * (X * errors)。为什么写成 X 的转置乘以误差向量我们一步步拆errors 是 m×1 的误差向量。X 是 2×97 的矩阵原先 X 是 97×2。X * errors 的结果是 2×1 的列向量它的第一个元素是 Σᵢ errorsⁱ * 1对应 θ₀ 的梯度第二个元素是 Σᵢ errorsⁱ * xⁱ对应 θ₁ 的梯度。用矩阵乘法一步算出全部参数的梯度比写 for 循环遍历每个特征更简洁也更容易推广到多元的情形。你可以亲手把矩阵乘法展开和公式对照一遍就能彻底理解。另外注意一个容易错的小地方更新语句theta theta - alpha * gradient必须放在计算 J_history 之前还是之后作业答案里常见写法是先更新 theta再计算这一轮迭代结束后的代价这样 J_history 记录的就是每次更新后的代价。这与框架预期一致。如果你先算 J_history 再更新 theta画出来的收敛曲线会整体平移一个迭代步虽然不是致命错误但会让人困惑。放一个验证标准答案alpha 取 0.01、num_iters 取 1500 时最终 theta 大约在 (-3.6303, 1.1664) 附近最终代价 J 大约 4.4834。如果你得到的 theta 离这个值偏差超过 5%建议回头检查一下更新顺序或者梯度计算中等号方向。5. 线性回归结果的可视化与预测画直线、看收敛、做预测梯度下降跑完之后你手上有了 theta 和 J_history 两个最重要的产物。这一步的乐趣在于把它们可视化用眼睛确认模型的训练是否成功。第一张图是把拟合直线画到原始数据上。在 Octave 里可以直接利用已有的数据点绘制直线plot(X(:, 2), X * theta, -)这里 X*theta 就是每个样本的预测值。由于 X[:,2] 是原始人口数据这行代码会画出所有离散点对应的预测点用连线看起来就是一条直线。更常见的画法是先生成一组等距的 x 值hold on; plot(X(:, 2), X*theta, -); legend(Training data, Linear regression); hold off;画完之后你应该看到一条从左下到右上的直线穿过散点带子中间。如果直线偏离得很离谱说明梯度下降没有收敛最常见原因是学习率 alpha 过大导致震荡或者迭代次数不够。第二张图是 J_history 的收敛曲线。把 J_history 作为纵轴、迭代次数作为横轴画出来你会看到代价一路下降然后趋于平稳。这张图是诊断梯度下降是否正常工作的最重要工具。如果曲线在下滑过程中出现明显的上升段或者锯齿状抖动说明 alpha 太大如果曲线下降得非常缓慢、1500 次迭代还没到底说明 alpha 偏小。我建议你手动试三组学习率感受一下alpha 0.011500 次迭代曲线平稳下降最后平坦。alpha 0.1下降很快但前几次迭代可能会有一个较大的落差。alpha 1.0大概率曲线剧烈震荡代价甚至可能变成 NaN。这三组对比能让你直观理解学习率对训练过程的影响。这段时间花费很值得因为后面逻辑回归、神经网络调参时诊断思路完全一样。预测部分同样是必做项。比如你想预测人口为 3.5 万和人口为 7 万的城市餐车利润代码是predict1 [1, 3.5] * theta; predict2 [1, 7.0] * theta;注意 predict1 里的 [1, 3.5] 对应的是 [偏置项, 人口]这个顺序必须和 X 的列顺序保持一致。课程文档里预测出的利润大约是 0.4520 万元和 4.5342 万元你可以对照一下。如果你的结果偏差很大要么是 theta 没收敛要么是 [1, 3.5] 和 theta 的匹配顺序错了。比如写成 [3.5, 1] 就完全反了预测结果自然不对。这里还有个常识性检查人口和利润的单位不同。预测出来的并不是人均利润这种经不起推敲的数字而是该城市开设餐车后的总利润。在做业务解读时一定要记得单位换算万人对应万元否则数值含义会被误读。6. 多元线性回归与特征归一化ex1data2.txt 的完整解法第二题是房价预测特征有两个房屋面积平方英尺和卧室数量。数据量不大只有 47 个样本但特征之间的数值范围相差巨大——面积可能是几千卧室数量则只有个位数。这种量纲差异会直接拖慢梯度下降的收敛速度甚至让梯度下降无法收敛。所以课程安排了特征归一化这个关键步骤。你需要实现 featureNormalize.mfunction [X_norm, mu, sigma] featureNormalize(X) mu mean(X); sigma std(X); X_norm (X - mu) ./ sigma; end这里的 mu 是每个特征的均值sigma 是每个特征的标准差。归一化的公式是 (x - mu) / sigma将每个特征缩放到均值为 0、方差为 1 的分布。注意用的是./而不是/和前面 .^2 的道理一样逐元素运算必须加点。为什么要归一化你可以这样想如果两个特征尺度差 1000 倍代价函数在参数空间的等高线会呈现一个非常扁长的椭圆。梯度下降在这个椭圆表面走起来会反复震荡像在狭窄的山谷里左右碰壁前进速度极慢。而把所有特征缩放到相近范围后等高线更接近圆形梯度下降可以近似直线冲向最低点收敛快得多。做完归一化之后还要在 X 前面拼接全 1 列X [ones(m, 1), X_norm];然后可以沿用第一题写的 computeCost 和 gradientDescent因为它们只依赖矩阵维度不依赖特征的语义。这就是封装函数的好处——一旦写好多元场景直接复用。如果你希望做预测新样本房价必须对输入数据先做同样的归一化也就是说要用训练集算出的 mu 和 sigma 去处理新样本price [1, (1650 - mu(1)) / sigma(1), (3 - mu(2)) / sigma(2)] * theta;这里有个很隐蔽也很常见的坑新样本的归一化必须使用训练集的 mu 和 sigma而不是重新计算新样本自己的均值和方差。如果你用新样本加上全部训练数据重新算归一化参数相当于改变了训练时的数据分布结果和训练过程就不一致了。选定学习率时多元回归比一元要更加敏感。我的经验是先从 alpha 0.01 开始观察 J_history 是否平稳下降。如果下降太慢按 3 倍左右增大0.03、0.1、0.3如果出现震荡或发散按 3 倍左右减小。课程要求你最后选一个合适的 alpha并打印出预测价格正常结果大约在 29 万到 30 万美元附近。我跑通时 alpha 0.01、迭代 400 次预测 1650 平方英尺、3 间卧室的房价约为 293081 美元左右你可以参考这个数量级。7. 常见报错与调试技巧把我踩过的坑提前告诉你做 ex1 这个作业的过程中报错是常态尤其是第一次接触 Octave/MATLAB 语法的人。我把自己见过、踩过的坑总结成了一张对照表按照出现频率排列问题现象根本原因解决办法error: operator *: nonconformant arguments两个矩阵相乘时内维度不匹配用 size() 分别查看 X 和 theta 的维度确认 X 是 97×2、theta 是 2×1结果里有 NaN 或 Inf学习率太大梯度更新越过了最优点甚至发散把 alpha 调小比如从 0.01 调到 0.001正常先画 J_history 确认J 初始值不是 32.07X 没拼接全 1 列或 y 方向错误检查 X 构造代码确认第一列全是 1y 是列向量theta 和标准答案差距较大更新顺序写错先更新 θ₀ 再更新 θ₁改成向量化写法先算整个 gradient再一次更新 theta直线没画出来或画歪了plot 参数顺序错误或没有 hold on确认横轴是 X(:,2)纵轴是 X*theta先 plot 数据点再 hold on 直线归一化后预测值异常新样本没有用训练集的 mu 和 sigma把 mu 和 sigma 从 featureNormalize 函数里返回预测时直接复用调试时我最推荐的方法是在 OCTAVE 命令行里逐行运行而不是直接run ex1.m一把梭。一行一行跑的好处是你能看到每个中间变量的值出错位置一目了然。比如在 computeCost 之前手动敲一遍X load(ex1data1.txt); y X(:, 2); X X(:, 1);这样你就不会把 X 和 y 搞混。课程提供的原始代码框架里ex1.m 是一步步调用函数的你可以打断点逐步执行。另外一个实用技巧是随时打印中间结果。比如在 gradientDescent 循环里加一行fprintf(iter %d: J %f\n, iter, J_history(iter));不过注意1500 次全打印会刷屏不利于观察。建议只打印头 10 次迭代的代价或者画 J_history 曲线。我个人的习惯是先把迭代次数改成 100 跑一遍快速验证确认 J 在下降后再改回 1500 跑正式结果。这个方法能为你节省大量的调试时间。8. 通过标准验证与思考题怎样才算真正做完了 ex1作业提交之后很多人立刻开始 Next 课程但我建议你先停一下做几件验证和思考的事。这些事比作业通过本身更有价值。第一件事是把标准答案跑一遍确认所有数值和你自己实现的一致。具体指标是computeCost 初始值J ≈ 32.07线性回归最终 theta约 (-3.63, 1.17)最终代价约 4.48预测人口 3.5 万的利润约 0.45 万预测人口 7 万的利润约 4.53 万归一化后房价预测约 29.3 万美元左右与你选的 alpha 和迭代次数有微小差异如果你的结果在这些值附近波动说明实现正确。如果有某个量偏离很多就对照前面几节查一查。第二件事试着回答下面这几个问题如果去掉特征归一化直接跑多元回归J_history 曲线和收敛速度差别有多大为什么梯度下降的更新规则里所有 θ 要同时更新如果不同时更新数学上会发生什么变化alpha 越大一定收敛越快吗什么情况下 alpha 过大会导致不收敛使用正规方程求解线性回归得到的 theta 和梯度下降的结果是不是一致的这些问题在课上没有直接发解答但都是理解机器学习基础的关键。我当年因为时间紧直接跳过这些思考结果学到后续神经网络时对代价函数面上怎么走始终有种隔靴搔痒的感觉后来花了更多时间补课才追回来。第三件事我再分享一个自己验证梯度下降正确性的土办法把迭代次数加到非常巨大比如 50000 次如果算法实现正确theta 会稳步逼近正规方程的解。我自己实际测试过alpha 0.01、迭代 50000 次得到的 theta 与正规方程解一致到小数点后三位。这比任何调试手段都更能证明代码没问题。ex1 这个题目难吗如果说只是让它跑通确实不难网上能搜到无数份答案。但如果想真正理解矩阵运算为什么这样写、梯度下降为什么收敛、归一化为什么必要就需要把每一步代码和公式对应起来慢慢咀嚼。我始终认为这个作业是整门课性价比最高的一道编程题投入两三个晚上彻底搞懂它后面所有的作业都会轻松很多。在做后续作业时我开始注意到另一个细节为什么所有课程练习都建议用 Octave 而不是直接在高级语言里调库因为手写核心循环能让你摸到每一层计算的温度这是任何自动微分框架都给不了的基础功。这份手感恰恰是后来调模型、改损失函数、设计新算法的底气。