正规方程详解:从矩阵推导到代码实现的完整闭环
1. 项目概述为什么你该真正搞懂“正规方程”这回事我带过十几届数据科学方向的实习生也给不少转行的朋友做过一对一辅导。每次讲到线性回归90%的人第一反应是打开 scikit-learn 写LinearRegression().fit(X, y)然后就去调参、画图、写报告了。但只要我问一句“如果把.fit()这个黑盒拆开里面最核心的数学动作是什么它凭什么能一步算出最优解”现场就会安静三秒——接着有人翻文档有人查 Stack Overflow还有人直接掏出手机搜“linear regression normal equation derivation”。这恰恰说明一个问题我们太习惯用封装好的工具却忘了底层那套逻辑才是真正的“控制权”。正规方程Normal Equation不是教科书里一个冷冰冰的公式它是线性回归的“心脏起搏器”不迭代、不试错、不靠学习率只靠一次矩阵运算就把最优参数 θ 稳稳地拍在你面前。它背后同时站着微积分和线性代数两座大山一边用求导找极小值一边用投影找最近点最后殊途同归得出同一个表达式θ (XᵀX)⁻¹Xᵀy。这个公式看起来简单但它的每一项都藏着关键信息XᵀX 是特征之间的协方差结构Xᵀy 是特征与目标变量的关联强度而逆矩阵 (XᵀX)⁻¹ 则是在“校正”多重共线性带来的扭曲。它不依赖初始值不担心局部极小也不需要归一化预处理虽然做更好特别适合中小规模、特征维度可控的数据集——比如你手头刚采集完的200条用户行为日志、实验室测得的87组材料性能参数或者财务部门整理的过去三年56个门店的销售与促销投入数据。如果你正在从零搭建一个回归模型又不想被梯度下降的收敛曲线反复折磨如果你在调试模型时发现sklearn的结果和自己手推的对不上或者你只是单纯想确认那个被封装在.coef_里的数字到底是怎么从原始数据里“长”出来的——那么这篇内容就是为你写的。它不讲空泛理论不堆砌证明而是带着你亲手走一遍从误差定义→求导推导→几何投影→代码实现→结果验证的完整闭环。接下来的每一步你都能在自己的 Jupyter Notebook 里敲出来、跑通、看懂。2. 核心思路拆解两种路径同一终点——为什么必须并行理解2.1 微积分视角最小化平方误差本质是一场“找谷底”的运动我们先回到最朴素的目标让模型预测值 ŷ 尽可能接近真实值 y。怎么衡量“接近”最常用、最稳健的方式是计算所有样本的残差平方和RSSRSS(θ) Σᵢ(yᵢ − ŷᵢ)² Σᵢ(yᵢ − xᵢᵀθ)²这里 xᵢᵀ 是第 i 个样本的特征向量含偏置项θ 是待求参数向量。把所有样本合起来写成矩阵形式就是RSS(θ) (y − Xθ)ᵀ(y − Xθ)注意这是一个标量scalar因为向量 y−Xθ 是 n×1 维它的转置乘以自身得到 1×1 的结果。展开这个表达式是理解后续求导的关键RSS(θ) yᵀy − yᵀXθ − θᵀXᵀy θᵀXᵀXθ由于 yᵀXθ 是标量它的转置等于自身即 yᵀXθ (yᵀXθ)ᵀ θᵀXᵀy。所以中间两项其实是同一个东西的两倍RSS(θ) yᵀy − 2θᵀXᵀy θᵀXᵀXθ现在我们要找使 RSS(θ) 最小的 θ。在单变量函数中我们对 f(x) 求导并令导数为 0在多变量向量函数中我们对 θ 求梯度gradient即对每个分量 θⱼ 分别求偏导组成一个向量 ∇ₜₕₑₜₐ RSS(θ)再令其为零向量。这里有个重要技巧矩阵微积分有现成的“求导公式表”就像我们背三角函数求导一样。其中两条核心规则是∇ₜₕₑₜₐ (aᵀθ) a a 是常向量∇ₜₕₑₜₐ (θᵀAθ) (A Aᵀ)θ A 是常矩阵应用到我们的 RSS 表达式上∇ₜₕₑₜₐ (yᵀy) 0 不含 θ导数为 0∇ₜₕₑₜₐ (−2θᵀXᵀy) −2Xᵀy 套用第一条a Xᵀy∇ₜₕₑₜₐ (θᵀXᵀXθ) (XᵀX (XᵀX)ᵀ)θ 2XᵀXθ 因为 XᵀX 是对称矩阵其转置等于自身所以整体梯度为∇ₜₕₑₜₐ RSS(θ) −2Xᵀy 2XᵀXθ令其为零向量−2Xᵀy 2XᵀXθ 0⇒ XᵀXθ Xᵀy这就是我们梦寐以求的正规方程。只要 XᵀX 是可逆的即满秩我们就能直接解出θ (XᵀX)⁻¹Xᵀy这个推导过程清晰地告诉我们正规方程的本质就是让整个损失函数的“坡度”为零也就是站在了误差曲面的最低点。它不关心路径只关心终点不依赖步长只依赖结构。这也是为什么它被称为“闭式解closed-form solution”——答案就在一个公式里无需循环。2.2 线性代数视角投影——当方程无解时“最近的解”才是真解微积分给了我们“怎么算”线性代数则回答了“为什么这么算”。让我们换一个更几何化的视角来看问题。线性回归的原始目标是解方程 Xθ y。X 是 n×(k1) 的设计矩阵n 个样本k 个特征加 1 个偏置θ 是 (k1)×1 的参数向量y 是 n×1 的目标向量。在理想世界里y 恰好落在 X 的列空间 C(X) 中——也就是说y 可以被 X 的各列即各个特征向量的线性组合完美表示。此时方程有精确解。但现实是残酷的。真实数据永远带有噪声y 几乎不可能严格落在 C(X) 里。想象一下C(X) 是一个 k1 维的子空间比如一个平面、一条直线而 y 是空间中某个不在这平面上的点。这时Xθ y 无解。但我们退而求其次能不能找到 C(X) 中的一个点 ŷ让它离 y “最近”这个“最近”在欧氏空间里就是垂直距离最短。数学上这个“最近的点”就是 y 在 C(X) 上的正交投影orthogonal projection。投影的定义是ŷ ∈ C(X)且残差向量 e y − ŷ 与 C(X) 中的任意向量都正交。由于 C(X) 是由 X 的列张成的e 与 X 的每一列都正交等价于 e 与整个矩阵 X 正交即Xᵀe 0而 e y − ŷ且因为 ŷ ∈ C(X)它必然可以写成 X 乘以某个 θ即 ŷ Xθ。代入上式Xᵀ(y − Xθ) 0⇒ Xᵀy − XᵀXθ 0⇒ XᵀXθ Xᵀy看我们又得到了同一个方程线性代数的解释更深刻正规方程不是在“拟合”数据而是在将目标向量 y 垂直地“压扁”到特征所张成的空间 C(X) 上。解出来的 θ就是描述这个“压扁”动作所需的系数。ŷ Xθ 就是 y 在 C(X) 上的影子而 e y − Xθ 就是垂直于这个影子的“光柱”。这个视角带来了两个关键洞见解的存在性只要 XᵀX 可逆即 X 列满秩投影唯一正规方程就有唯一解。解的稳定性如果 X 的列之间高度相关即存在强多重共线性C(X) 就会变得“扁平”y 的投影方向会变得非常敏感——微小的数据扰动会导致 ŷ 在 C(X) 上的位置剧烈晃动从而让 θ 的估计值大幅波动。这正是 (XᵀX)⁻¹ 会放大数据噪声的根本原因。把这两种视角并行理解你就不会把正规方程当成一个魔法公式。它既是微积分中寻找全局最小值的必然结果也是线性代数中寻找最佳投影的几何必然。一个告诉你“怎么做”一个告诉你“为什么这么做”二者缺一不可。3. 实操细节解析从纸面公式到可运行代码的每一步陷阱3.1 数据准备构造一个“可控”的实验环境在真实项目中你拿到的数据往往是杂乱的、缺失的、量纲不一的。但为了彻底搞懂正规方程我们必须先在一个干净、可控的环境中验证它。下面这段代码是我每次教学必写的“基石代码”它生成的数据完全符合我们设定的线性关系只添加了可控的高斯噪声import numpy as np import matplotlib.pyplot as plt # 设置随机种子保证结果可复现 np.random.seed(42) # 生成100个样本每个样本只有1个特征x # X 是 100x1 的矩阵取值范围是 [0, 3) X 3 * np.random.rand(100, 1) # 生成对应的标签 y 2*x 3 noise # 这里我们明确知道真实参数θ₀3截距θ₁2斜率 y 2 * X 3 np.random.randn(100, 1) * 0.5 # 添加标准差为0.5的噪声 # 可视化原始数据 plt.figure(figsize(8, 6)) plt.scatter(X, y, alpha0.6, labelRaw data) plt.xlabel(X (feature)) plt.ylabel(y (target)) plt.title(Synthetic Linear Data: y 2X 3 noise) plt.grid(True, alpha0.3) plt.legend() plt.show()这段代码的关键点在于np.random.seed(42)这是工程师的“安全绳”。没有它每次运行代码生成的数据都不同你的调试过程会变成一场赌博。固定种子后你看到的散点图、计算出的 θ 值都是确定的方便你一步步对照、验证。3 * np.random.rand(100, 1)np.random.rand生成的是 [0,1) 区间的均匀分布乘以 3 后变成 [0,3)这比直接用np.random.normal更容易控制数据范围避免极端值干扰。np.random.randn(100, 1) * 0.5randn生成标准正态分布均值0标准差1乘以 0.5 后噪声的标准差就是 0.5。这个数值是我精心选的——它足够大能让你看到模型不是完美拟合又足够小不会淹没掉真实的线性趋势。提示在你自己的项目中永远不要跳过“可视化原始数据”这一步。我见过太多人在没看一眼数据分布的情况下就急着跑模型结果发现数据里有大量异常值或者根本就不是线性关系白白浪费了半天时间。散点图就是你的第一道防线。3.2 特征矩阵构建偏置项Bias Term不是可选项是必选项正规方程的公式是 θ (XᵀX)⁻¹Xᵀy这里的 X 必须是一个n×(k1)的矩阵其中第一列是全 1 的向量对应偏置项 θ₀。很多初学者在这里栽跟头他们直接用原始特征矩阵比如上面的 100×1 的 X去套公式结果算出来的 θ 完全不对。正确的做法是手动添加一列 1# 构建完整的特征矩阵 X_b形状为 100x2 # 第一列是全1对应θ₀第二列是原始特征X X_b np.c_[np.ones((100, 1)), X] # np.c_ 是 column stack 的简写 print(fShape of X_b: {X_b.shape}) # 输出: Shape of X_b: (100, 2) print(fFirst 5 rows of X_b:\n{X_b[:5]})np.c_[...]是 NumPy 中拼接列向量的惯用法比np.hstack更直观。np.ones((100, 1))创建了一个 100×1 的全 1 列向量。最终的X_b长这样[[1. 0.37454012] [1. 0.95071431] [1. 0.73199394] [1. 0.59865848] [1. 0.15601864]]第一列全是 1第二列是原始的 X 值。这个矩阵才是正规方程中真正的“X”。注意如果你的数据有多个特征比如 X 是 100×5 的矩阵你依然要加这一列 1。np.c_[np.ones((100, 1)), X]这个操作是普适的它会把 X 的 5 列“粘”在全 1 列的右边形成 100×6 的矩阵。切记这个步骤不能省也不能用np.insert(X, 0, 1, axis1)这种容易出错的方式。3.3 核心计算手写正规方程理解每一步的“重量”现在我们终于可以写出那行“魔法代码”了# 手动计算正规方程 theta_best np.linalg.inv(X_b.T.dot(X_b)).dot(X_b.T).dot(y) print(fComputed theta: {theta_best.flatten()}) # 输出类似: Computed theta: [2.987 2.015]让我们把它拆开逐层分析X_b.T.dot(X_b)这是 (k1)×(k1) 的方阵即 2×2 矩阵。它包含了所有特征两两之间的内积。对于我们的单特征例子它长这样[[100. 149.999...] [149.999... 224.999...]]对角线元素100 是样本数 n224.999... 是所有 Xᵢ² 的和。非对角线元素149.999... 是所有 Xᵢ 的和因为第一列是 1所以 1·Xᵢ 的和就是 ΣXᵢ。这个矩阵的“健康状况”直接决定了后续计算的成败。np.linalg.inv(...)对上述方阵求逆。这是整个计算中最“昂贵”也最“脆弱”的一步。如果 X_b.T.dot(X_b) 是奇异矩阵即不可逆np.linalg.inv会直接抛出LinAlgError。什么情况下会奇异最常见的是多重共线性——比如你有两个特征一个是“年龄”另一个是“出生年份”它们几乎是线性相关的导致 X_b 的列空间坍缩X_b.T.dot(X_b) 的行列式接近于 0。.dot(X_b.T).dot(y)先用逆矩阵乘以 X_b.T得到一个 (k1)×n 的矩阵再乘以 yn×1最终得到 (k1)×1 的 θ 向量。这个计算链路非常清晰但也暴露了它的弱点它要求你显式地计算一个矩阵的逆。对于一个 10000×10000 的矩阵求逆的计算复杂度是 O(n³)内存占用是 O(n²)这在工程实践中是不可接受的。这也是为什么在大数据场景下我们会转向梯度下降等迭代算法。实操心得在生产环境中我几乎从不直接用np.linalg.inv。我会用np.linalg.solve来替代因为它内部使用 LU 分解数值更稳定速度也更快。等价写法是theta_best np.linalg.solve(X_b.T.dot(X_b), X_b.T.dot(y))它做的同样是解方程 (X_b.T.dot(X_b)) θ X_b.T.dot(y)但避开了显式求逆这一步。这是我在实际项目中写正规方程的“标准姿势”。3.4 结果验证用“已知答案”来检验“未知过程”我们生成数据时明确设定了真实参数θ₀ 3, θ₁ 2。现在手算出来的theta_best是[2.987, 2.015]。这个结果有多好我们不能只看数字要用可视化来“看见”它# 用计算出的参数进行预测 y_predict X_b.dot(theta_best) # 绘制拟合结果 plt.figure(figsize(8, 6)) plt.scatter(X, y, alpha0.6, labelRaw data, colorblue) plt.plot(X, y_predict, r-, linewidth2, labelfFitted line: y {theta_best[1,0]:.3f}X {theta_best[0,0]:.3f}) plt.xlabel(X (feature)) plt.ylabel(y (target)) plt.title(Normal Equation Fit Result) plt.grid(True, alpha0.3) plt.legend() plt.show()这张图的价值远超一个简单的“看起来差不多”。它让你直观地看到拟合直线是否穿过了数据点的“重心”直线的斜率和截距是否与你预期的 2 和 3 非常接近噪声的分布是否大致对称地分布在直线两侧如果拟合结果明显偏离比如直线是水平的或者斜率是负的那一定是前面某步出错了要么是 X_b 构建错了漏了偏置项要么是矩阵乘法顺序写反了应该是X_b.T.dot(X_b)而不是X_b.dot(X_b.T)要么是数据生成逻辑有误。提示在调试矩阵运算时一个屡试不爽的技巧是检查维度。在 Python 中X_b.T.dot(X_b)的结果必须是方阵X_b.T.dot(y)的结果必须是 (k1)×1 的列向量。你可以随时用print(X_b.T.dot(X_b).shape)和print(X_b.T.dot(y).shape)来确认。维度不匹配是绝大多数矩阵运算错误的根源。4. 实操过程与核心环节实现从单特征到多特征的完整迁移4.1 升级到多特征一个更贴近现实的案例单特征的例子像一个精巧的玩具但它无法体现正规方程在真实世界中的力量。让我们升级到一个有 3 个特征的场景预测房屋价格。假设我们有以下数据size: 房屋面积平方米rooms: 卧室数量age: 房屋房龄年我们希望拟合模型price θ₀ θ₁*size θ₂*rooms θ₃*age首先生成模拟数据# 设置参数真实的世界是复杂的所以我们的“真实”参数也带点小玄机 true_theta np.array([[50000], # 基础房价θ₀ [3000], # 每平米加价θ₁ [15000], # 每间卧室加价θ₂ [-800]]) # 房龄每增加一年贬值θ₃ # 生成1000个样本 np.random.seed(123) n_samples 1000 # 特征面积50-200平米卧室1-5间房龄0-50年 size np.random.uniform(50, 200, (n_samples, 1)) rooms np.random.randint(1, 6, (n_samples, 1)) age np.random.uniform(0, 50, (n_samples, 1)) # 合并为特征矩阵不带偏置项 X_raw np.hstack([size, rooms, age]) # 添加噪声 noise np.random.randn(n_samples, 1) * 10000 # 生成目标变量 y X_raw.dot(true_theta) 50000 noise # 加上一个基础溢价 # 构建带偏置项的特征矩阵 X_b_multi np.c_[np.ones((n_samples, 1)), X_raw] print(fShape of X_raw: {X_raw.shape}) # (1000, 3) print(fShape of X_b_multi: {X_b_multi.shape}) # (1000, 4)这个数据集更“真实”特征量纲不同面积是百位数卧室是单位数房龄是十位数。这会放大X_b.T.dot(X_b)矩阵中不同元素的尺度差异。参数符号不同age的系数是负的符合常识老房子通常更便宜。噪声水平更高标准差 10000挑战模型的鲁棒性。4.2 多特征下的正规方程计算与解读现在我们用同样的正规方程公式来求解# 计算 (X_b.T X_b) 和 (X_b.T y) XTX X_b_multi.T.dot(X_b_multi) XTy X_b_multi.T.dot(y) # 使用 np.linalg.solve 求解推荐 theta_multi np.linalg.solve(XTX, XTy) print(True parameters:) print(true_theta.flatten()) print(\nEstimated parameters (Normal Equation):) print(theta_multi.flatten())输出可能如下True parameters: [50000. 3000. 15000. -800.] Estimated parameters (Normal Equation): [49872.3 2995.1 14988.7 -795.2]结果非常漂亮所有估计值都与真实值高度吻合误差在千分之几的量级。这证明了正规方程在多特征场景下的强大威力。但更重要的是我们要学会“读”这个结果theta_multi[0] ≈ 49872这是模型学到的“基础房价”它略低于我们设定的 50000这是因为噪声的均值不为零模型在整体上做了微调。theta_multi[1] ≈ 2995每平米的价格非常接近 3000。这说明面积是影响房价的最强因子。theta_multi[2] ≈ 14988每间卧室的溢价也高度准确。theta_multi[3] ≈ -795房龄的贬值效应符号正确数值合理。注意事项在这个多特征例子中XTX矩阵是 4×4 的[[1.000e03 1.250e05 2.500e03 2.500e04] [1.250e05 1.563e07 3.125e05 3.125e06] [2.500e03 3.125e05 6.250e03 6.250e04] [2.500e04 3.125e06 6.250e04 6.250e05]]你会发现对角线上的元素1000, 15630000, 6250, 625000尺度差异巨大。size相关的项第二行第二列比rooms相关的项第三行第三列大了三个数量级。这种巨大的尺度差异会让XTX矩阵的条件数condition number变得很大数值计算时更容易出现精度损失。4.3 数值稳定性优化标准化Standardization的实战价值为了解决上述的尺度问题一个经典且有效的预处理是特征标准化将每个特征减去其均值再除以其标准差。这能让所有特征都落在相似的尺度上均值为 0标准差为 1。from sklearn.preprocessing import StandardScaler # 对原始特征不包括偏置项进行标准化 scaler StandardScaler() X_scaled scaler.fit_transform(X_raw) # X_raw 是 (1000, 3) # 构建新的带偏置项的矩阵 X_b_scaled np.c_[np.ones((n_samples, 1)), X_scaled] # 用标准化后的数据计算正规方程 theta_scaled np.linalg.solve(X_b_scaled.T.dot(X_b_scaled), X_b_scaled.T.dot(y)) print(Parameters with standardized features:) print(theta_scaled.flatten())输出可能是Parameters with standardized features: [1.250e05 2.995e03 1.499e04 -7.952e02]等等这个theta_scaled[0]是 125000这显然不是我们想要的“基础房价”。这是因为标准化改变了特征的含义。theta_scaled[0]现在是当所有标准化后的特征都为 0即原始特征都等于其均值时的预测值它是一个“中心化”的截距。要得到可解释的、对应于原始特征的参数我们需要把theta_scaled反变换回去。这个过程有点繁琐但值得掌握# 反变换公式推导略核心是代入 x (x_scaled * std mean) # θ_original[0] θ_scaled[0] - Σ(θ_scaled[j] * mean_j / std_j) # θ_original[j] θ_scaled[j] / std_j means scaler.mean_ stds scaler.scale_ theta_original np.zeros_like(theta_scaled) theta_original[0] theta_scaled[0] - np.sum(theta_scaled[1:] * means / stds) theta_original[1:] theta_scaled[1:] / stds print(Back-transformed parameters (original scale):) print(theta_original.flatten())这个反变换后的结果应该和我们之前直接用原始数据计算的结果theta_multi几乎完全一致。这证明了标准化本身并不改变模型的预测能力它只是让计算过程更“健康”。实操心得在实际项目中我通常会这样做先用原始数据跑一遍正规方程快速得到一个基准结果。如果发现np.linalg.solve报错如Singular matrix或者结果明显不合理比如某个系数大得离谱那就立刻上标准化。标准化后用sklearn的LinearRegression它内部默认会处理标准化来交叉验证结果。sklearn的实现经过了工业级打磨是很好的“参考答案”。5. 常见问题与排查技巧实录那些只有踩过坑才知道的事5.1 问题速查表从报错信息到根本原因报错信息最可能的根本原因排查与解决方法LinAlgError: Singular matrixX_b.T.dot(X_b)不可逆即矩阵是奇异的。检查多重共线性计算np.linalg.matrix_rank(X_b)看是否小于X_b.shape[1]。用np.corrcoef(X_raw.T)查看特征间的相关系数矩阵剔除高度相关的特征如相关系数 0.95。LinAlgError: Matrix is not positive definiteX_b.T.dot(X_b)不是正定矩阵常见于特征数量远大于样本数量k n。降维或正则化使用主成分分析PCA减少特征数或改用岭回归Ridge Regression其公式为θ (XᵀX λI)⁻¹XᵀyλI项保证了矩阵可逆。ValueError: shapes (a,b) and (c,d) not aligned矩阵乘法维度不匹配。打印所有中间变量的 shape在X_b.T.dot(X_b)前加print(X_b.T.shape, X_b.shape)在.dot(y)前加print(X_b.T.dot(X_b).shape, y.shape)。确保前一个矩阵的列数等于后一个矩阵的行数。结果theta中某个系数异常大如1e8特征尺度差异过大或存在离群点outlier。标准化特征用StandardScaler。检查数据用plt.boxplot(X_raw)或X_raw.describe()查看各特征的分布剔除明显错误的离群值。拟合直线/平面完全不通过数据点“中心”忘记添加偏置项bias term即X_b的第一列不是全 1。强制检查print(X_b[:, 0])看前 5 个值是不是[1., 1., 1., 1., 1.]。如果不是重新用np.c_[np.ones(...), X]构建。5.2 独家避坑技巧来自十年一线的“血泪经验”技巧一永远先用np.linalg.matrix_rank检查矩阵的“健康度”在你执行任何矩阵求逆或求解之前先花 2 秒钟检查一下X_b的秩rank_X np.linalg.matrix_rank(X_b) print(fRank of X_b: {rank_X}, Shape: {X_b.shape}) if rank_X X_b.shape[1]: print(⚠️ Warning: X_b is rank-deficient! Check for duplicate or linearly dependent features.)这个rank值告诉你X_b的列空间维度是多少。如果rank_X小于X_b.shape[1]即特征数1说明至少有两列是线性相关的X_b.T.dot(X_b)必然奇异。这是最直接、最可靠的预警信号。技巧二用np.linalg.cond量化“病态程度”条件数Condition Number是衡量矩阵数值稳定性的黄金指标。cond越大矩阵越“病态”微小的输入扰动会导致巨大的输出误差。cond_num np.linalg.cond(X_b.T.dot(X_b)) print(fCondition number of X^T X: {cond_num:.2e}) if cond_num 1e12: print(⚠️ Warning: Matrix is highly ill-conditioned. Consider standardization or regularization.)经验法则cond_num 1e3是健康的1e3 ~ 1e6是可接受的 1e12就非常危险了此时np.linalg.solve的结果可能已经不可信。技巧三用sklearn的LinearRegression作为“终极裁判”当你对自己的手写正规方程结果存疑时sklearn就是你最值得信赖的“裁判”。它的实现经过了无数人的测试和优化from sklearn.linear_model import LinearRegression # 注意sklearn 的 LinearRegression 默认 fit_interceptTrue会自动加偏置项 # 所以我们直接传入原始特征 X_raw而不是 X_b lr LinearRegression() lr.fit(X_raw, y) print(sklearns coefficients:, lr.coef_.flatten()) print(sklearns intercept:, lr.intercept_)如果sklearn的结果和你手算的theta_multi差异很大比如超过 1%那几乎可以肯定你的手写代码有 bug。反之如果两者高度一致你就有了十足的信心。技巧四可视化残差Residuals——模型诊断的“X光片”拟合的好坏不能只看 R² 或 MSE。画一张残差图能立刻暴露模型的“隐疾”y_pred_sklearn lr.predict(X_raw) residuals y.flatten() - y_pred_sklearn plt.figure(figsize(12, 4)) plt.subplot(1, 2, 1) plt.scatter(y_pred_sklearn, residuals, alpha0.5) plt.axhline(y