多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

线性回归从原理到代码实践:手写梯度下降与sklearn工程应用

线性回归从原理到代码实践:手写梯度下降与sklearn工程应用 1. 动手写代码之前先把线性回归那点事捋清楚1.1 线性回归到底在解决什么问题很多人一上来就对着线性回归代码看看了半天只知道“照抄能跑”但换个数据、换个场景就完全懵了。我建议在写第一行代码之前先用大白话把线性回归“是什么、解决什么问题”说透。线性回归解决的是连续数值预测问题。比如预测明天的气温、预测某件商品的销量、预测一个用户的下单金额这些都是连续值。它的数学本质特别朴素假设输出y和输入特征x之间是线性关系即y w * x bw是权重b是偏置。所谓“训练模型”就是根据一堆已经知道答案的数据x和对应的真实y去反推出一组最合理的w和b。一旦这两个参数确定新来的x就能直接带入公式算出预测值。如果你刚接触这个领域千万别把线性回归想复杂。它本质上就是初中数学里的一次函数只不过现在不是你想让这条直线穿过所有点而是让这条直线“尽量贴近”所有已知数据点贴得越近预测就越准。我经常用一个比喻来理解线性回归就是让你拿着笔在一堆散点中间画一条“最中庸的直线”这条直线要使得所有散点到它的垂直距离误差加起来最小。误差怎么量化就是真实值和预测值差的平方把所有点加起来求平均这就引出了损失函数的概念。1.2 从“瞎猜”到“参数可学”损失函数与梯度下降当你说“我要写线性回归代码”时核心要解决的不是画直线而是怎么让程序自动找到那条直线。这里有两个关键概念损失函数和梯度下降。损失函数用来量化“当前这条线有多差”。线性回归最经典的损失是均方误差MSEMean Squared Error公式长这样L (1/m) * Σ (y_pred - y_true)^2m是样本数量y_pred是模型的预测值y_true是真实值。平方是为了让正误差和负误差不会相互抵消同时放大大误差让模型更重视那些“错得离谱”的样本。有了损失函数之后问题变成了怎么调整w和b让L越来越小梯度下降就是干这个的。用大白话说梯度下降就是站在山坡上往山下走每次迈一小步方向是“最陡的下坡方向”走一步算一次损失再更新一次参数循环往复直到走到山底。对于线性回归这种简单模型把梯度公式展开其实很直白。对w的梯度是(2/m) * Σ x * (y_pred - y_true)对b的梯度是(2/m) * Σ (y_pred - y_true)。拿到梯度后用当前参数减去学习率乘以梯度更新一次w w - learning_rate * gradient_w b b - learning_rate * gradient_b这就是循环里最重要的三件事前向计算算预测值、计算损失、反向更新算梯度并更新参数。如果你能把这三点刻在脑子里后面手写代码时就不会迷路。2. 手写线性回归代码这才是初学者最该抄的第一份代码2.1 先造一份能看清结果的数据我强烈建议初学者不要一上来就去 Kaggle 上下载真实数据集先自己造一份“一眼就能看出规律”的合成数据。原因很简单你知道真实答案是y 2.5x 1.2模型学完之后你直接对比参数就知道它学得对不对。这比面对一堆真实业务数据不知道对错要顺畅得多。生成数据的代码很简单用numpy生成 100 个在 0 到 10 之间均匀分布的x然后通过y 2.5x 1.2计算理论值再加上一组服从正态分布的噪声模拟真实采样中不可避免的干扰import numpy as np np.random.seed(42) X np.linspace(0, 10, 100).reshape(-1, 1) true_w 2.5 true_b 1.2 y true_w * X true_b np.random.normal(0, 1.0, sizeX.shape)这里np.random.seed(42)是为了保证每次运行生成的随机数一致可复现是实验的基本素养。reshape(-1, 1)是为了把一维数组变成二维列向量因为后续做矩阵运算时统一维度能避免大量报错。有了数据后我会顺手看一眼形状X是 (100, 1)y是 (100, 1)。在写代码的任何阶段心里要时刻清楚每个变量的形状这是调试的底层能力。2.2 基于 NumPy 的梯度下降实现每个变量都有讲究下面这份代码我拆成几步来写每一步都有它存在的理由。先把完整流程列出再逐个讲解。第一步是参数初始化和超参数设置w 0.0 b 0.0 learning_rate 0.01 epochs 500w和b初始化为 0 在线性回归场景下没有问题因为损失函数是凸函数不会因为初始点不同而陷入局部最优。learning_rate是步长选太大容易震荡选太小收敛太慢0.01 是个温和的起点。epochs是迭代轮数对于这份 100 个样本的小数据500 轮足够看到明显收敛。第二步是训练循环的核心代码m X.shape[0] for epoch in range(epochs): y_pred w * X b # 前向计算 loss np.mean((y_pred - y) ** 2) # 均方误差损失 if epoch % 100 0: print(fEpoch {epoch}, Loss: {loss:.6f}) grad_w (2 / m) * np.sum(X * (y_pred - y)) grad_b (2 / m) * np.sum(y_pred - y) w - learning_rate * grad_w b - learning_rate * grad_b前向计算用w * X b得益于numpy的广播机制标量w会自动和每个样本相乘结果就是每条样本的预测值。计算损失时用np.mean而不是np.sum再除以m写法更简洁。梯度更新这里有几个新手容易犯的错我重点说一下。grad_w里的(2 / m) * np.sum(X * (y_pred - y))这个2来自损失函数平方项求导后的系数1/m是平均。很多人会问“除以 m 的意义”答案是如果不除样本数梯度会随数据量增大而变大学习率就需要跟着调整除以后梯度就稳定在同一个量级超参数更容易迁移到不同数据集。np.sum(X * (y_pred - y))看起来像矩阵乘法实际上不是因为这里的X和(y_pred - y)都是列向量逐元素相乘再求和。如果你的X是多维特征就要改用矩阵乘法np.dot(X.T, y_pred - y)。2.3 手写版的效果检查看参数也要看曲线训练结束后打印学到的参数与真实参数对比print(f学到的 w: {w:.4f}, 真实 w: {true_w}) print(f学到的 b: {b:.4f}, 真实 b: {true_b})正常跑完一轮你会看到学到的w大约在 2.45 到 2.55 之间b大约在 1.0 到 1.4 之间。不会和真实值完全一样因为数据里混入了噪声但这已经说明了线性回归能从前到后自动找到一条不错的直线。如果你连可视化一起做用matplotlib画散点和拟合直线效果会更直观import matplotlib.pyplot as plt plt.scatter(X, y, s5, alpha0.6, label样本数据) plt.plot(X, w * X b, colorred, label拟合直线) plt.xlabel(x) plt.ylabel(y) plt.legend() plt.show()散点会围绕一条红色直线分布那条线几乎穿过点群的正中心。这个“正中心”就是线性回归在最小化误差之后得到的最优平衡位置。我特别建议手写一遍这个流程哪怕你以后工作中都用成熟库。因为手写能让你真正看到每个循环里数值是怎么流动的而不是像用sklearn那样把整个优化过程封装成一个黑盒。当年我自己带项目时凡是连梯度公式都不知道的成员碰到模型训练不收敛时基本只能靠瞎调学习率而手写过梯度的人一眼就能定位问题是出在前向计算还是反向传递。3. 工程实践用 sklearn 把线性回归从“玩具”变成“工具”3.1 LinearRegression 的核心参数与normalize的坑手写代码能帮你建立“感觉”但真的到实际项目里大家都不会自己去写梯度下降。scikit-learn里的LinearRegression直接用最小二乘法闭式解求参数不需要迭代速度快且稳定。使用方式就三行from sklearn.linear_model import LinearRegression model LinearRegression() model.fit(X_train, y_train)很多人在参数上没太较真默认值其实已经够用但有三个点值得理解清楚。fit_interceptTrue是默认值意思是模型会自动学习偏置b。如果你提前对y做了中心化处理让均值接近 0可以把fit_intercept设为False但一般不建议因为偏置项能吸收特征未覆盖的系统偏差让模型更稳健。normalize这个参数在较新的版本中已经被弃用了。早期版本里normalizeTrue会在训练前把特征归一化但后来官方认为这个行为容易被误用因此删掉了这个参数改为建议用户自己用StandardScaler做标准化。这个变更很关键如果你读到老代码里写了normalizeTrue在新版本里会直接报错或告警需要手动改成先StandardScaler再训练。还有n_jobs它只影响并行计算对单特征或小数据没什么意义但当你面对大规模特征矩阵时可以设n_jobs-1使用全部 CPU 核心来加速计算。3.2 一份可以直接套用的回归评估代码训练模型只完成了一半工作另一半是评估模型到底准不准。这份评估代码是我在实际项目中反复使用的模板每次拿到回归任务我都会直接复制改改就能用from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler from sklearn.metrics import r2_score, mean_absolute_error, mean_squared_error # 数据集划分80% 训练20% 测试 X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.2, random_state42 ) # 特征标准化 scaler StandardScaler() X_train_scaled scaler.fit_transform(X_train) X_test_scaled scaler.transform(X_test) # 模型训练 model LinearRegression() model.fit(X_train_scaled, y_train) # 预测与评估 y_pred model.predict(X_test_scaled) r2 r2_score(y_test, y_pred) mae mean_absolute_error(y_test, y_pred) mse mean_squared_error(y_test, y_pred) rmse mean_squared_error(y_test, y_pred, squaredFalse) print(fR2: {r2:.4f}) print(fMAE: {mae:.4f}) print(fMSE: {mse:.4f}) print(fRMSE: {rmse:.4f})这套代码有一个关键细节scaler.fit_transform(X_train)之后测试集用的是transform而不是fit_transform。原因在于标准化时的均值和方差必须完全来自训练集不能让测试集的信息提前“泄漏”进预处理阶段。如果对测试集单独fit测试数据里的均值和标准差就会混入一方面信息导致评估结果虚高。我在实际项目中见过有人在这一行翻了车模型评估很好上线后效果猛掉最后定位发现就是测试集预处理时多调用了一次fit。对于这几个指标我是这样区分的R2是“模型解释了多少方差”越接近 1 越好但也要警惕过拟合MAE是平均绝对误差单位与目标值相同最容易向业务解释MSE是平方误差对大误差非常敏感如果业务场景要避免“离谱预测”重点盯 MSERMSE是 MSE 开根号量纲回归原位是业务沟通时最常用的数值。3.3 一个真实场景销量预测的完整流程说明白 API 之后还是要用一个小场景把全流程串起来。这里用“气温影响冷饮销量”来演示数据量不大但流程完整。假设我们有 12 个月的气温均值和当月冷饮销量已知销量大致是销量 30 * 气温 200再叠加一些随机扰动。我要做的是用线性回归学出这个关系然后预测“气温 32 度时的销量”。import numpy as np np.random.seed(7) temperatures np.linspace(5, 35, 50).reshape(-1, 1) sales 30 * temperatures 200 np.random.normal(0, 30, sizetemperatures.shape)这里真实斜率为 30截距为 200噪声标准差 30。如果模型学出来的参数接近这个值就说明流程没毛病。按上一小节的评估代码走一遍你会得到很高的R2因为数据本身高度线性。然后预测新样本时有一点特别容易出错新样本也必须经过同一个scaler做标准化。new_temp np.array([[32.0]]) new_temp_scaled scaler.transform(new_temp) pred model.predict(new_temp_scaled) print(f气温 32 度时的预测销量: {pred[0][0]:.2f})如果你忘了用scaler.transform而直接把32丢进模型预测值可能会离谱地大或小因为模型是在标准化后的特征空间里学习的你给它一个原始尺度数据它当然不认识。这个错误很多人都踩过尤其是刚上手时总觉得自己已经训练好了模型预测就只是model.predict而已没想到预处理管道的序列也要保持一致。4. 线性回归代码实战中的常见问题与排查技巧4.1 损失不降反升或直接溢出十有八九是学习率的问题手写梯度下降时最常见的现象就是 loss 前几轮还好然后突然变成nan。原因基本都是学习率太大导致参数更新时“跨过”了最优点误差越来越大最终数值溢出变成无限大。我自己的排查方法是先看 loss 序列如果每轮迭代 loss 都在变大那是学习率太大。如果 loss 下降极其缓慢迭代 500 轮还不如最开始那是学习率太小。学习率怎么选我的经验是先从 0.01 开始观察前 100 轮 loss 下降曲线。如果 loss 在 50 轮内快速下降且没有震荡可以尝试增大到 0.05 加速收敛如果出现震荡或nan就降为 0.001。更稳妥的方式是设置一个动态学习率比如每 100 轮衰减一次。但在线性回归这种凸优化问题上固定学习率已经足够不需要引入花哨的调度器。4.2 手写版和 sklearn 对不上先检查这两处很多人会拿自己手写的梯度下降结果跟sklearn的LinearRegression对比发现参数差不少就开始怀疑手写的代码有 bug。我排过很多类似的“bug”最后定位到的原因往往有两个。第一sklearn的LinearRegression使用的是最小二乘闭式解直接求解最优参数不是迭代逼近。而手写梯度下降是逐步逼近如果你只跑 100 轮逼近程度还不够参数自然对不上。解决办法是加大epochs比如跑到 50000 轮并且把学习率调小到 0.001 或者更低让参数充分收敛。第二数据是否标准化。sklearn的LinearRegression内部并不会自动标准化特征但如果你的特征本身量纲差异极大比如一个特征在 0-1 之间另一个在 1000-10000 之间梯度下降收敛就会非常慢甚至来回震荡而闭式解不迭代直接算就没这个问题。这种情况下先做标准化两边就能对上了。4.3 特征量纲差异大归一化到底救了你什么线性回归中特征量纲差异如果很大等于让模型天然偏爱大数值的特征。这是因为梯度的大小和特征数值本身成正比特征值大的那一列梯度就大参数更新幅度就大从而导致模型对那个特征更敏感但这只是数值尺度造成的假象不是真实的业务重要性。换句话说一个特征是“克”另一个是“千克”数值上差了 1000 倍不代表“克”那个特征就重要 1000 倍。归一化之后所有特征都压缩到相近的尺度模型才能真正按“单位变化对预测的影响”来分配权重。实际的标准化操作就用StandardScaler它把每个特征变成均值为 0、方差为 1 的分布。这一步不仅对线性回归有帮助几乎所有需要迭代优化的模型逻辑回归、神经网络都受益。如果是树模型比如xgboost、lightgbm归一化就不那么关键因为树模型基于分裂点对单调变换不敏感。4.4 欠拟合和过拟合线性回归“不生效”时的真实原因如果数据本身不是线性的你用线性回归去拟合得到的R2可能很低这就是典型的欠拟合。换个角度想线就是线你不可能用一条直线去很好拟合一条抛物线。解决办法是引入多项式特征把x²当成一个新特征放进模型里。但多项式特征引入之后又会带来一个新问题特征多了模型可能“背”下训练数据里的噪声。尤其是当你把degree设到 10 以上模型会画出一条特别曲折、几乎穿过每个训练点的曲线训练集R2极高测试集R2却很低。这就是过拟合。对于线性回归“正则化”是制衡过拟合的常用手段。所谓 L2 正则化就是在损失函数后面加一项权重平方和惩罚过大的参数让模型不要过度依赖某个特征。对应到sklearn里就是Ridgefrom sklearn.linear_model import Ridge ridge Ridge(alpha1.0) ridge.fit(X_train_scaled, y_train)alpha越大惩罚越强模型越保守。实际用的时候先设alpha1.0然后看测试集R2的变化来调整。如果测试集表现差再调大alpha或调小。调参的最终目标不是让训练集完美而是让测试集上的预测误差最小。4.5 评估指标别只看 R2要结合业务场景判断我见过不少人拿到线性回归结果只看R2觉得到了 0.95 就很满意。但有一个反直觉的问题值得注意如果测试集的目标值方差本身就很大哪怕R2只有 0.4模型已经能捕捉到最重要的趋势如果目标值几乎没什么变化方差接近 0R2可能很低但模型预测也没啥大错。在做回归评估时我会同时看MAE和RMSE对比两者的大小关系。如果RMSE比MAE大很多说明存在部分样本预测误差非常大模型在某些点上“崩了”。这时候可以回头看看这些“崩了”的样本是不是有一些特殊分布、缺失值或者极端值在干扰。另外当业务方问“这个模型预测准不准”时我几乎不用R2回答而是直接用MAE说“平均误差大概 30 件货”。这样业务方一听就懂不用解释什么是方差解释度。好的评估不只是数值上的好坏还在于你能不能把模型的能力翻译成业务语言。4.6 从玩具到工具你还需要知道这几点我最后再补充几个从“会用代码”到“能上线系统”之间常用的技巧。训练集中一定要留出验证集或者用cross_val_score做交叉验证。只用一次train_test_split的评估结果其实有运气成分数据划分方式不同结果可能差不少。为了保证模型的稳定性我在正式项目里都会跑 5 折交叉验证看 5 次结果的平均和方差。如果特征之间存在高度相关性比如“温度”和“体感温度”、“湿度”几乎线性相关那么学出来的权重在数值上可能很奇怪一个正一个负。这不一定是模型坏了只是特征冗余造成了系数不稳定。这时可以考虑做特征选择或者用PCA降维。如果你准备把模型部署成接口记得把特征预处理的 pipeline 和模型一起保存用joblib或pickle。上线预测时不光要先加载模型还要加载scaler对入参做一模一样的标准化顺序也不能变。我心里一直记着这句话模型上线失败的案例里大部分不是算法问题而是特征处理管线没跟着模型走。这些点里随便挑一个拿出来深入都能写一篇长文但放在线性回归这个题目下核心就一句话代码本身不难难的是你对你手上的特征、数据和评估结果是否有足够的理解。至少从这一节开始你要知道LinearRegression的结果不是一个黑盒输出的数字而是一条你能解释、能调优、能定位问题的直线。
返回列表