
1. 为什么我不调库偏要手写线性回归1.1 从一次面试翻车说起前几年我面过一家做数据服务的公司技术面聊得挺顺最后面试官扔过来一道题“不用sklearn手写一个线性回归要求支持多元输入用梯度下降训练写在一张纸上。”我当时脑子一空公式记得但矩阵维度怎么对齐、学习率怎么设、收敛怎么判断全卡壳了。那次之后我回去老老实实用NumPy把线性回归从头到尾撸了一遍跑在Jupyter里一步步打印损失、画拟合线才算真正把这块知识焊死在脑子里。这篇东西就是那次复盘的完整记录。核心关键词就三个机器学习、Jupyter、Python线性回归而且重点是“自己写的算法”不是from sklearn.linear_model import LinearRegression一行了事。它解决的是一个很具体的问题当你需要真正理解梯度下降在干什么、为什么学习率会影响收敛、正规方程和迭代法各自适合什么场景时调库是给不了你答案的。适合谁看刚入门机器学习、被公式劝退过、或者像我一样在面试里栽过跟头的人。哪怕你只会一点点Python和一点点矩阵乘法跟着走也能跑通。1.2 手写和调库到底差在哪很多人觉得线性回归这么简单的东西调库不就行了我一开始也这么想。但实际做下来你会发现调库你只拿到了一个结果手写你拿到的是整个过程的控制权。举个最直接的例子数据里有量纲差异极大的特征比如一个特征是面积几千到几百另一个是房间数1到5调库时你不管它模型可能也能跑但手写梯度下降时如果不做标准化损失会直接炸成NaN你被迫去理解“为什么特征缩放对梯度下降是刚需”。这种被迫理解才是手写的价值。再一个手写让你能改。比如你想把损失从MSE换成MAE想加L2正则想换成随机梯度下降调库你得翻文档找参数手写你直接改几行代码。Jupyter在这里的角色就是实验台一个cell一个cell地跑改一个参数立刻看损失曲线变化这种即时反馈对建立直觉太重要了。所以我的定位很明确这不是一个“替代sklearn”的方案而是一个教学向、可拆解、可魔改的实现生产环境该调库还是调库。2. 开工前的环境与数据准备2.1 Jupyter环境怎么搭最省心我试过几种组合最后稳定在Anaconda Jupyter Notebook这套上。原因很简单Anaconda把NumPy、Matplotlib、Pandas这些科学计算的包一次性装齐了不用一个个pip省得版本打架。装完之后命令行敲jupyter notebook浏览器自动弹出来新建一个.ipynb文件就能开写。如果你不想装Anaconda这么重的东西用pip install jupyter numpy matplotlib也行但要注意Python版本别太老3.8以上比较稳。我踩过的坑是某些系统里pip装的NumPy和系统自带的冲突导致import numpy报奇怪的错这时候用虚拟环境python -m venv myenv隔离一下就好了。提示Jupyter里建议第一个cell就写%matplotlib inline这样画图直接嵌在notebook里不用弹窗看损失曲线特别方便。2.2 造一份自己能看懂的数据真实数据往往脏且难解释学习阶段我建议先自己造数据因为你知道真实规律是什么能验证算法学出来的对不对。我用的是最经典的“面积-房价”单变量场景但为了后面演示多元我多加了一个“房间数”特征。import numpy as np import matplotlib.pyplot as plt np.random.seed(42) m 100 # 样本数 area np.random.uniform(50, 150, m) # 面积 rooms np.random.randint(1, 5, m) # 房间数 # 真实规律房价 0.8*面积 5*房间数 20 噪声 price 0.8 * area 5 * rooms 20 np.random.normal(0, 5, m) X np.column_stack((area, rooms)) y price.reshape(-1, 1)这里np.random.seed(42)是为了让每次跑出来的随机数一样方便复现。column_stack把两个一维特征拼成(100, 2)的矩阵y转成(100, 1)的列向量这个维度对齐后面会反复强调是新手最容易翻车的地方。2.3 特征标准化不做这一步后面全白搭刚才说了面积是几十到几百房间数是个位数量纲差了两个数量级。梯度下降对这种情况极其敏感因为损失函数在不同方向上的“坡度”差异巨大学习率稍微大一点在面积那个方向上就震荡发散了。所以标准化是必须的我用的是Z-score标准化mu X.mean(axis0) sigma X.std(axis0) X_norm (X - mu) / sigmaaxis0表示按列每个特征算均值和标准差。标准化之后每个特征均值0、方差1梯度下降就好走多了。记住把mu和sigma存下来预测新数据时要用同样的参数去标准化不然结果全错。这个细节调库时是自动帮你做的手写就得自己操心。3. 线性回归的数学底子用人话讲一遍3.1 假设函数就是个加权求和线性回归的假设函数说白了就是“每个特征乘一个权重加起来再加个偏置”$$h_\theta(x) \theta_0 \theta_1 x_1 \theta_2 x_2 \dots \theta_n x_n$$写成矩阵形式就是 $h X\theta$其中$X$是$(m, n1)$的矩阵多出来的一列全是1对应偏置$\theta_0$$\theta$是$(n1, 1)$的参数向量。为什么要加一列1因为偏置项可以看成“特征恒为1”对应的权重这样统一用矩阵乘法就能表达代码写起来干净。我一开始老忘了加这列1结果偏置永远学不出来拟合线总是过原点排查半天才发现。3.2 损失函数衡量“错得有多离谱”我们用均方误差MSE作为损失$$J(\theta) \frac{1}{2m}\sum_{i1}^{m}(h_\theta(x^{(i)}) - y^{(i)})^2$$那个$\frac{1}{2}$是故意的因为求导时平方的2会和它约掉公式更简洁。$m$是样本数除以$m$是为了让损失和样本量无关方便比较。MSE的几何意义就是所有点到拟合直线的垂直距离平方的平均越小说明拟合越好。这个函数是凸函数只有一个全局最小值所以梯度下降一定能找到最优解不用担心局部最优这也是线性回归好教好学的根本原因。3.3 梯度下降蒙眼下山靠坡度找方向梯度下降的直觉特别简单你站在山坡上想走到谷底但看不见路只能感受脚下哪个方向最陡然后朝那个方向迈一小步重复直到走不动。数学上就是$$\theta_j : \theta_j - \alpha \frac{\partial}{\partial \theta_j} J(\theta)$$$\alpha$是学习率也就是“步子大小”。对MSE求偏导结果是$$\frac{\partial}{\partial \theta_j} J(\theta) \frac{1}{m}\sum_{i1}^{m}(h_\theta(x^{(i)}) - y^{(i)}) \cdot x_j^{(i)}$$矩阵形式就是 $\frac{1}{m}X^T(X\theta - y)$。这个式子我建议你手推一遍推完你就明白为什么代码里是X.T.dot(error) / m这个写法了而不是死记。4. 手写核心算法从零到能跑4.1 给X补上那列1def add_bias_column(X): m X.shape[0] return np.column_stack((np.ones(m), X))np.ones(m)生成全1列拼在特征矩阵最左边。这样$\theta_0$就对应这一列后面统一处理不用单独写偏置逻辑。4.2 损失函数实现def compute_loss(X, y, theta): m len(y) error X.dot(theta) - y return (1 / (2 * m)) * np.sum(error ** 2)X.dot(theta)就是$X\theta$得到预测值减y得到误差平方求和除以2m。这个函数我建议单独写出来训练过程中每隔几十轮打印一次能直观看到损失在降心里有底。4.3 梯度下降主循环def gradient_descent(X, y, theta, alpha, iterations): m len(y) loss_history [] for i in range(iterations): error X.dot(theta) - y gradient (1 / m) * X.T.dot(error) theta theta - alpha * gradient loss compute_loss(X, y, theta) loss_history.append(loss) if i % 100 0: print(f迭代 {i}, 损失 {loss:.4f}) return theta, loss_history这段是整个算法的核心。X.T.dot(error)就是$X^T(X\theta - y)$除以m得到平均梯度然后theta减去alpha * gradient完成一次更新。loss_history把每轮损失存下来后面画曲线用。我特意每100轮打印一次是因为如果学习率设大了前几轮损失就会飙到天文数字或者NaN早点发现早点调。4.4 跑起来看看X_b add_bias_column(X_norm) theta np.zeros((X_b.shape[1], 1)) theta, loss_history gradient_descent(X_b, y, theta, alpha0.1, iterations1000) print(学到的参数, theta.flatten())初始theta全设0这是最常用的初始化因为线性回归是凸的从哪起步都能到同一个谷底。alpha0.1、迭代1000次是我试出来的一个比较稳的组合。跑完你会看到损失从几百降到个位数参数也接近我们造数据时的真实值标准化后参数含义变了但趋势对。5. 训练过程的可视化与调参5.1 损失曲线判断收敛最直接的工具plt.plot(loss_history) plt.xlabel(迭代次数) plt.ylabel(损失) plt.title(损失下降曲线) plt.show()这条曲线如果是一条平滑下降然后趋于水平的线说明训练正常。如果曲线震荡甚至往上翘八成是学习率太大如果下降得特别慢像蜗牛那是学习率太小。我一般会跑几个不同的alpha对比比如0.001、0.01、0.1、0.5把曲线画在一张图上一眼就能看出哪个合适。这个实验在Jupyter里做特别爽改个数字重跑一个cell就行。5.2 学习率怎么选一个实用的试错法学习率没有万能值但有个经验做法从0.001开始每次乘10试到1.0看损失曲线。通常0.01到0.1之间是比较舒服的区间。如果0.001都发散那说明数据没标准化或者有异常值如果1.0还能稳定下降那可以再往上试。我实测下来标准化之后的数据0.1基本是个不错的起点。注意学习率太大时损失会变成inf或nan这时候别慌把alpha调小重跑就行。Jupyter的好处就是可以只重跑出问题的那几个cell不用从头来。5.3 迭代次数看损失曲线决定迭代次数不是越多越好够收敛就行。判断标准就是损失曲线变平了再迭代损失也不怎么降了。我一般先设1000次看曲线如果还没平就加到5000。但要注意如果学习率合适通常几百次就收敛了如果几千次还在明显下降那可能是学习率偏小与其加迭代次数不如调大学习率。6. 从单变量到多元维度对齐的坑6.1 多元只是矩阵变宽了单变量时$X$是$(m, 2)$一列1加一列特征多元时就是$(m, n1)$。算法代码一行都不用改因为矩阵乘法天然支持任意列数。这就是矩阵形式的优雅之处。我上面造的数据有两个特征所以X_b是(100, 3)theta是(3, 1)跑出来的三个参数分别对应偏置、面积权重、房间数权重。6.2 维度不匹配的报错怎么读新手最常见的报错是shapes (100,3) and (2,1) not aligned意思是矩阵乘法维度对不上。矩阵乘法要求前一个的列数等于后一个的行数。(100,3)乘(3,1)得到(100,1)没问题但如果theta初始化成(2,1)就会报这个错。排查方法很简单在每个关键步骤后打印shape比如print(X_b.shape, theta.shape)一眼就能看出谁不对。6.3 正规方程另一条路适合小数据除了梯度下降线性回归还有解析解叫正规方程$$\theta (X^T X)^{-1} X^T y$$代码就一行theta_ne np.linalg.inv(X_b.T.dot(X_b)).dot(X_b.T).dot(y)它的好处是不用调学习率、不用迭代一步到位。坏处是要算矩阵求逆复杂度是$O(n^3)$特征一多就慢得离谱而且$X^T X$可能不可逆特征间高度相关时。所以我的建议是特征少于几千个、数据量不大时可以用正规方程验证梯度下降的结果特征多、数据大时老老实实梯度下降。我一般两个都跑一遍结果接近就说明梯度下降收敛得不错。7. 常见问题与排查实录7.1 损失变成NaN怎么办这是最高频的问题原因基本就三个学习率太大、数据没标准化、数据里有异常值或缺失值。排查顺序我一般是先确认标准化做了没再看学习率是不是超过1了最后检查数据里有没有inf或nan。用np.isnan(X).sum()和np.isinf(X).sum()能快速定位。7.2 损失不降反升如果损失曲线往上走几乎可以肯定是学习率太大步子迈太大直接跨过了谷底越走越远。把alpha除以10再试。还有一种可能是梯度算错了比如忘了除以m导致梯度被放大m倍等效于学习率大了m倍。7.3 参数学出来和真实值差很远先别怀疑算法检查两件事一是标准化参数有没有在预测时一致地应用二是迭代次数够不够。我遇到过一次训练时标准化了验证时忘了用同样的mu和sigma结果预测全偏。另外如果特征之间高度相关比如面积和房间数强相关参数可能不唯一但预测值还是对的这种情况不用纠结单个参数的值。问题现象最可能原因排查动作损失NaN学习率过大/未标准化调小alpha检查标准化损失上升学习率过大alpha除以10重试收敛太慢学习率过小alpha乘10重试参数偏差大标准化不一致检查预测时的mu/sigma维度报错矩阵形状不匹配打印各步shape7.4 一个容易被忽略的坑y的形状y必须是(m, 1)的列向量不能是(m,)的一维数组。因为X.dot(theta)出来是(m,1)如果y是(m,)相减时NumPy会广播成(m,m)结果完全错但不报错特别隐蔽。我踩过一次损失看着在降但就是不对查了半天才发现是y的形状问题。养成习惯y y.reshape(-1, 1)。8. 我个人的几条实操心得第一先跑通再优化。别一上来就想着加正则、换优化器先用最简单的梯度下降把单变量跑通看到损失下降、拟合线画出来有了正反馈再往下走。我见过太多人卡在环境配置和维度报错上就放弃了。第二Jupyter里多打印中间结果。theta、损失、梯度范数想看什么就打印什么。手写算法的最大优势就是透明别浪费这个优势。我习惯在每个函数里加一两行print跑通之后再删掉。第三用正规方程做交叉验证。梯度下降跑完用正规方程算一遍两者结果接近就说明你的梯度下降实现是对的。这是最省事的自检方法比盯着公式看半天强。第四标准化参数一定要存。训练完把mu和sigma保存下来np.save或者直接pickle预测新数据时用同一套参数处理否则预测结果毫无意义。这个坑我在实际项目里见别人踩过线上预测和线下训练不一致查了一整天才发现是标准化没对齐。这套代码我后来又扩展过加了L2正则就是在损失里加$\lambda\sum\theta_j^2$梯度里加$2\lambda\theta$加了随机梯度下降每次只用一个样本更新还试过用PolynomialFeatures造多项式特征做非线性拟合。核心的梯度下降框架一直没变改的都是损失和梯度那几行。这也是我坚持手写的理由——你真正拥有它才能随意改造它。