资讯动态

从零手写线性回归:原理、梯度下降与工程实践完全指南

发布时间:2026/9/24 19:26:50 来源:尧图企业网站定制
从零实现一个线性回归模型是我觉得入门机器学习最值得做的一件小事。很多人一开始就扎进复杂的神经网络、Transformer结果被概念和数学砸得晕头转向反而连最基本的“模型是怎么学习”的都没搞明白。这个项目标题里的“性回归模型”按字面理解应该是“线性回归模型”我们就把它当作一次完整的从零实现练习不用现成的机器学习库用Python和NumPy手写训练过程把回归模型的原理、梯度下降、评估指标全跑通顺便解决掉环境配置、代码调试这些绕不开的坑。整个项目做下来你对“机器学习到底在做什么”会有非常具体的体感而不是停留在背概念。这篇文章会以我实际做这个项目的过程为主线从方案选型、环境准备、数学原理到手写代码、调参、常见报错排查尽量把每一步为什么这么做讲清楚。适合刚学完Python基础、想进入机器学习但不知道从哪下手的人也适合为了交人工智能相关课程作业、需要拿出一个能讲明白原理的实战项目的人。认真跟完你不仅能跑出一个预测效果还不错的回归模型还能自己对它做性能分析和调优。1. 整体设计与方案选型1.1 为什么选择手写回归模型而不是直接调库我第一次接触机器学习时也习惯import sklearn然后一行LinearRegression().fit(X, y)就完事。模型确实能出结果但对我来说它是个黑盒里面怎么算的、为什么学出来的一组权重是这个值、学习率调大调小到底改变了什么我一概不知。后来做人工智能课程项目我决定换个思路——所有核心算法逻辑都用NumPy自己实现一遍只在最后用sklearn做结果对照。这一步选择让我的理解深度完全不一样了。手写实现虽然代码量多一些大概两百行左右但每一步都是透明的。你可以自己打印出损失函数每一轮下降的值可以看到每个特征对应的权重怎么从随机初值一点点逼近最优解甚至还能故意设置一个很大的学习率观察损失函数当场爆炸。这些“见证过程”的经验是直接调库完全给不了你的。而且从实际效果来看纯NumPy实现的线性回归在小规模数据集上性能完全够用。用波士顿房价这类经典数据虽然这个数据集在较新版本sklearn中被标记为不建议直接使用但做练习完全没问题几千条样本的矩阵运算毫秒级完成根本不需要上框架。1.2 项目采用的技术栈和工具链这个项目的核心工具链非常简单但也恰好是机器学习领域最基本的组合Python 3.8以上版本推荐3.10左右NumPy负责矩阵运算这是手写算法的地基Matplotlib用来画损失曲线和数据拟合效果scikit-learn仅用作数据集加载和最终结果对比验证Jupyter Notebook 或 VSCode作为交互式开发环境环境配置是很多人刚开始卡住的地方。如果你用的是VSCode建议直接在扩展市场装好Python插件然后按CtrlShiftP调出命令面板选择“Python选择解释器”新建一个虚拟环境再安装依赖。# 在项目目录下创建虚拟环境Windows/Linux/macOS通用 python -m venv ml_env # 激活虚拟环境 # Windows PowerShell: ml_env\Scripts\Activate.ps1 # macOS/Linux: source ml_env/bin/activate # 安装依赖 pip install numpy matplotlib scikit-learn jupyter # 安装完可以通过一行代码验证环境是否正常 python -c import numpy, matplotlib, sklearn; print(环境OK)这里有个个人建议尽量用虚拟环境别图省事直接往全局Python里pip install。之前我图方便把一堆包塞进系统Python结果不同项目依赖的NumPy版本冲突改代码的时间远多于省下的那几分钟。项目需求一变环境就崩给你看这个坑我踩过太多次了。2. 回归模型的核心原理拆解2.1 线性回归到底在计算什么线性回归解决问题的场景非常直观。比如你想根据“房屋面积”和“卧室数量”来预测房价这就是典型的回归任务——输出连续数值而不是分类标签。线性回归的数学表达其实就一句话把输入特征的加权求和再加上一个偏置项作为预测结果。预测公式用向量形式写出来非常简洁# 预测函数y_pred X * w b # 其中X是形状为(m, n)的特征矩阵m条样本n个特征 # w是形状为(n, 1)的权重向量 # b是偏置标量 import numpy as np def predict(X, w, b): return np.dot(X, w) b但这里有个新手很容易懵的点为什么矩阵乘法的顺序是X * w不是w * X我们约定的习惯是每行代表一条样本每列代表一个特征。假设有100条样本、2个特征X的形状是(100, 2)w的形状是(2, 1)那么np.dot(X, w)得到的就是(100, 1)正好每条样本对应一个预测值。如果把X和w的顺序搞反NumPy会直接报维度不匹配这也是调试时最常看到的错误之一。为了让矩阵运算更统一工程上通常会把偏置b折叠进权重向量。做法是在X的最前面或最后面插入一列全1对应的w就多一个维度。这样预测函数就直接写成# 把偏置合并进权重后X_new形状为(m, n1)w_new形状为(n1, 1) # 预测就只剩一个矩阵乘法 def predict(X_with_ones, w): return np.dot(X_with_ones, w)很多工程代码里看到的w[0]其实就是偏置w[1:]才是特征的权重。这个习惯在实现多项式回归、逻辑回归时也同样适用。2.2 损失函数为什么要选均方误差MSE有了预测公式下一步就是衡量预测得好不好。线性回归最常用的损失函数是均方误差Mean Squared ErrorMSE公式也不复杂把所有样本的预测值和真实值的差的平方求平均。为什么选平方而不是直接用误差本身因为误差有正有负直接求和会正负抵消模型无论怎么学损失都是零那就没法优化了。取平方不仅解决了正负抵消的问题还可以放大较大误差的影响让模型更关注那些偏离很远的“难样本”。这一点从生活经验上理解也容易你看一件商品的价格预测偏差2块钱和偏差200块钱带来的损失显然不是同一个数量级平方项能把这个差异体现出来。当然损失函数也不止MSE一种。平均绝对误差MAE取的是绝对值的平均对异常值更鲁棒。这可能让人疑惑既然MAE对异常值更稳为什么线性回归教科书里还是默认MSE关键原因是MSE在数学上处处可导而且导函数是线性的用梯度下降优化起来非常顺滑。MAE在误差为零处不可导优化起来会多很多边界判断。深度学习里常见的损失函数选择本质上也是同样的权衡既要反映任务目标又要方便梯度传导。MSE的代码实现也就几行def compute_mse(y_true, y_pred): n len(y_true) return np.mean((y_true - y_pred) ** 2)2.3 梯度下降模型学习的幕后引擎有了损失函数我们还需要一套方法去找到能让损失最小的权重w和偏置b。梯度下降就是干的这件事。它的核心思想特别像你在浓雾里下山看不见整座山的地形但你能感受到脚下哪边是陡坡就朝最陡的下坡方向走一步再感受、再走不断重复直到走到谷底。数学上第j个权重w_j的更新公式是# 损失函数对w_j的偏导 # d(L)/d(w_j) (2/n) * sum((y_pred - y_true) * X[:, j]) # 梯度下降更新 # w_j w_j - learning_rate * gradient_j这里learning_rate是学习率也是整个项目里最值得调的超参数。学习率设太小模型要跑几万轮才收敛训练过程相当煎熬设太大每一步都跨过山谷损失函数会剧烈震荡甚至直接发散到无穷大。我刚开始调参时就设过一个learning_rate 0.2的案例结果损失值第一轮从2000多直接飙到几千万Matplotlib画出的曲线都在屏幕外当场知道大事不妙。在实际代码里更推荐用向量化的方式一次性算出所有梯度而不是写循环逐个更新。这样代码简洁而且NumPy底层用SIMD指令加速性能比Python for循环快几个数量级。def compute_gradient(X, y, w): n len(y) y_pred np.dot(X, w) # 梯度向量化计算 gradient (2 / n) * np.dot(X.T, y_pred - y) return gradientX.T是X的转置这是把“每个样本的误差”转换成“每个特征的梯度”的关键一步。如果你自己推导一遍只带一个特征的二维案例就会发现np.dot(X.T, error)本质上就是在对误差按特征加权求和和数学公式完全对应。3. 从零实现的完整实操流程3.1 准备一份可复现的样本数据开始写模型前得先有数据。我推荐两步走先用构造的线性数据验证代码逻辑再用真实数据集检验泛化能力。构造数据的代码非常简单import numpy as np import matplotlib.pyplot as plt # 保证随机结果可复现 np.random.seed(42) # 生成100条样本1个特征 X np.linspace(0, 10, 100).reshape(-1, 1) # 真实的权重是2.5偏置是3.0再加上噪声模拟真实场景 true_w 2.5 true_b 3.0 y true_w * X.flatten() true_b np.random.randn(100) * 2.0 # 可视化看一下数据分布 plt.scatter(X, y, alpha0.6) plt.xlabel(X) plt.ylabel(y) plt.title(合成线性回归数据) plt.show() # 在第0列前面插入全1列把偏置折叠进权重矩阵 X_with_bias np.c_[np.ones((len(X), 1)), X]构造数据时故意加一个标准差为2.0的高斯噪声是为了让数据更接近现实——在真实业务中影响目标值的因素永远不是单一的总有一些我们没采集到的变量在起干扰作用。如果数据毫无噪声拟合曲线会直接精准穿过所有点反而失去了回归分析的意义。这里用np.c_插入全1列是个很实用的小技巧尤其是在后面扩展到多项式回归时你只需要不断往这个特征矩阵里拼接新的特征列就行。注意全1列放在第0列还是最后一列会改变权重向量里偏置的位置不要搞混。3.2 手写梯度下降训练过程数据准备好后就进入核心环节训练模型。我用一个train函数把参数初始化、梯度计算、参数更新、损失记录全部串起来。def gradient_descent(X, y, w_init, learning_rate0.01, epochs1000): w w_init.copy() loss_history [] for epoch in range(epochs): # 前向传播计算预测值 y_pred np.dot(X, w) # 计算损失 loss compute_mse(y, y_pred) loss_history.append(loss) # 计算梯度 gradient compute_gradient(X, y, w) # 参数更新 w - learning_rate * gradient # 每100轮打印一次方便观察收敛过程 if epoch % 100 0: print(fEpoch {epoch}, Loss: {loss:.4f}, w: {w}) return w, loss_history # 初始化权重特征列数量 原始特征数1 偏置列1 2 w_init np.zeros((X_with_bias.shape[1], 1)) w_final, loss_history gradient_descent(X_with_bias, y.reshape(-1, 1), w_init, learning_rate0.01, epochs1000)初始化权重为全0是可行的。也许有人会疑惑神经网络里不推荐全0初始化为什么线性回归没问题因为线性回归的损失函数是凸函数只有一个全局最优点不存在对称性破坏的问题从全0开始梯度方向是完全确定的。这一点也和神经元网络区分开来可以顺便理解“凸优化”在传统机器学习里意味着什么。训练结束后把得到的权重和真实值比较一下。我跑出来的结果大概是w_final [3.03, 2.49]和最初设定的偏置3.0、权重2.5非常接近。噪声再大也能学出这个趋势这其实就是“回归”的本质从带干扰的数据中恢复出真实的统计规律。3.3 直观画出拟合效果和损失曲线模型训完如果你只盯着终端里越来越小的损失数字还是缺少直观感受。画图的代码很简单plt.figure(figsize(12, 4)) # 左图数据点 回归直线 plt.subplot(1, 2, 1) plt.scatter(X, y, alpha0.6, label真实数据) plt.plot(X, np.dot(X_with_bias, w_final), colorred, linewidth2, label拟合曲线) plt.xlabel(X) plt.ylabel(y) plt.title(线性回归拟合效果) plt.legend() # 右图损失下降曲线 plt.subplot(1, 2, 2) plt.plot(range(len(loss_history)), loss_history) plt.xlabel(Epoch) plt.ylabel(MSE Loss) plt.title(损失函数下降过程) plt.yscale(log) # 对数坐标更容易观察损失的变化趋势 plt.grid(True) plt.tight_layout() plt.show()损失曲线用对数量级会让趋势更清楚。如果训练正常你会看到损失在前几十轮悬崖式下降后面进入平缓期因为梯度越来越小参数更新步子也就越来越小。好的训练过程不会是剧烈震荡的而是一条平滑的对数曲线。注意如果你画出的是损失先下降后突然飙升的形状百分之百是学习率太大。解决办法就两个调小learning_rate或者用特征缩放standardization把X的数值范围压到差不多同一量级。特征缩放的原理是让不同特征对梯度的影响一致避免某个特征因为数值范围大而主导更新方向。实操中一张StandardScaler就搞定手写实现也不难。3.4 用测试集评估模型真实表现一个回归模型好不好光看训练集上的损失还不够。就像学生做练习册题目全对但换一套卷子就不会了——模型如果只记住了训练数据的细节就是过拟合。所以标准流程是把数据集划分成训练集和测试集。from sklearn.model_selection import train_test_split X_train, X_test, y_train, y_test train_test_split( X_with_bias, y, test_size0.2, random_state42 ) # 重新用训练集训练 w_train, loss_train gradient_descent( X_train, y_train.reshape(-1, 1), np.zeros((X_train.shape[1], 1)), learning_rate0.01, epochs1000 ) # 在训练集和测试集上分别评估 y_pred_train np.dot(X_train, w_train) y_pred_test np.dot(X_test, w_train) mse_train compute_mse(y_train, y_pred_train) mse_test compute_mse(y_test, y_pred_test) print(f训练集MSE: {mse_train:.4f}) print(f测试集MSE: {mse_test:.4f})这里有个常被误解的细节合成数据里信号本身比较强所以即使不划分验证集模型泛化一般也不会差。但真实项目里训练集和测试集的表现差值才真正暴露问题。如果训练集MSE是100测试集突然变成10000那就得考虑是不是特征太多、样本太少或者数据本身有泄露问题。这就是为什么“评估”这个环节要严格分开数据而不是随手拿全部数据训练再在全部数据上自嗨。常用的回归评估指标不只MSE一个我把它们列个表格方便项目里按需选用指标公式含义特点与适用场景MSE误差平方的平均对大误差敏感最常用方便求导RMSEMSE开根号量纲回到原值更容易直观理解误差幅度MAE误差绝对值的平均对异常值更鲁棒但不可导R²1 - 残差平方和/总平方和衡量模型解释数据方差的比例越接近1越好在这个项目里我建议至少看RMSE和R²。RMSE告诉你平均误差大概多少钱、多少米R²告诉你模型比“直接用均值预测”好了多少。如果R²是0.6意味着线性模型能解释60%的数据波动剩下40%是特征没覆盖到或噪声造成的。看着这四个指标一起变化你才真正理解“调模型”是个整体工程而不是单盯着一个数字。4. 常见问题与排查技巧实录4.1 梯度消失和学习率导致的损失震荡做线性回归遇到的最典型问题就是损失函数在训练过程中反复横跳、甚至干脆发散。我在真实项目里踩过一次特别经典的坑特征是房屋面积和房价面积动辄几百房价又是几百万两者数值范围差了十万八千里。这种量纲差异会导致梯度在某个特征方向上特别大、另一个方向上特别小更新时就像一个人两腿长短不一左腿迈大了右腿跟不上模型怎么都不稳。这个问题的解决办法很简单就是特征缩放。如果你不想引入sklearn也可以手工做一个标准化def standardize(X): mean np.mean(X, axis0) std np.std(X, axis0) # 防止除以0的极端情况 std[std 0] 1 return (X - mean) / std, mean, std标准化之后再跑梯度下降损失几乎都稳定收敛。学习率从这个稳定版本开始调就非常轻松了比如0.01到0.1区间都能得到不错的结果。另外补充一句如果训练初期损失下降很快后面掉得极其缓慢不一定是代码问题可能是权重已经接近最优解梯度微小更新幅度自然小。你可以选择调大学习率并配合衰减策略learning rate decay或者直接改用了带动量momentum的优化器这些在更复杂的深度学习里是常规操作但在本科课程作业里如果损失已经足够低就完全没必要硬追求极致的收敛速度。4.2 维度不匹配和常见NumPy报错手写矩阵运算最容易遇到的就是维度不匹配错误信息通常是shapes (100,2) and (1,2) not aligned。这个提示虽然看起来吓人但诊断思路很固定先打印每个关键变量的.shape核对一下就定位了。根据我几次调试的经验最常见的几种情况y是一维数组形状是(100,)而X是二维数组(100, 2)直接做减法的时候会得到(100, 2)的广播结果然后计算梯度全乱。解决办法是把yreshape成(100, 1)。忘记插入全1列导致偏置没地方安放模型只能拟合过原点的直线在数据不过原点时误差巨大。权重向量的形状和特征列数不一致比如特征列是3列但权重是2行np.dot直接报错。我有一个调试习惯代码里习惯性地加上shape检查# 在关键节点顺手断言节省你大把debug时间 assert X.shape[1] w.shape[0], f特征列与权重维度不匹配: {X.shape[1]} vs {w.shape[0]} assert y.shape (X.shape[0], 1), fy的形状应为{(X.shape[0], 1)}实际为{y.shape}别小看这两行断言。以前我改完数据预处理代码经常因为某个reshape遗忘导致后面的训练全部白算有了断言至少当场报错不会带着错误结果跑几百轮才发现不对劲。4.3 与sklearn结果对照验证手写代码最担心的是什么不是效率问题而是怕自己算错了还不知道。一个很实用的兜底办法用同一个数据集分别跑手写实现和sklearn的LinearRegression把两边的权重结果放在一起对比。from sklearn.linear_model import LinearRegression from sklearn.preprocessing import StandardScaler model LinearRegression() # 注意sklearn接口不需要手动加全1列偏置是单独的参数 model.fit(X_train, y_train) # 手动实现如果用标准化后的特征训练需要相应还原权重 # 对比model.coef_、model.intercept_ print(sklearn 权重:, model.coef_, 偏置:, model.intercept_) print(手写实现权重: 前面的权重w[0]偏置按具体情况对比)如果两者差异在10的负几次方这个量级以内说明实现基本正确。如果差异很大优先怀疑归一化后的还原步骤、学习率没收敛等问题。这个过程不是“作弊”而是工程上常见的交叉验证思路确保你的独立实现经过了同行参照检验。重要提示使用sklearn只是为了验证不要直接把它变成你的主实现。否则就失去了“从零实现”的意义。我就见过有人提交的项目里手写的代码跑出来的结果和sklearn一模一样连浮点误差都不差明摆着是调完库硬套了一层壳。做技术项目真实实力会写在每一行代码里不值得为这一点表面功夫丢了学习的本质。4.4 回归模型在真实数据上的边界这个项目做完你可能会产生“线性回归好像能解决一切预测问题”的错觉。实际上线性回归的边界非常清晰线性回归适合因变量与自变量之间近似线性关系的场景一旦数据呈明显的非线性比如房价随面积先涨后平稳只用直线拟合就会在两端产生系统性偏差。线性回归对异常值敏感。MSE会使模型为了降低一个异常大误差的平方不惜大幅扭曲拟合曲线来“讨好”它。特征共线性会让权重估计变得不稳定。两个特征高度相关时模型很难区分它们各自的贡献权重的方差会变得很大。这些问题对应的解法有很多加入多项式特征、换用Ridge或Lasso回归解决共线性和过拟合、改用对异常值更鲁棒的损失函数。饭店上为了让项目更有含金量在完成基本线性回归后你可以顺手用Ridge再做一次对比“在同一个真实数据集上Ridge比普通最小二乘的权重更稳定”这就足以在答辩或文档里成为一个有深度的扩展点。5. 扩展方向与个人心得5.1 从一元线性回归走向多元与多项式回归如果你已经能把一元的线性回归从零跑通扩展多元回归的工作量其实很小。你只需要把数据集换成多个特征其余代码基本不用改。通用流程是确认特征矩阵X的形状为(m, n)对每个特征列做标准化给X插入全1列初始化权重向量长度为n 1训练和评估流程完全复用这种“代码架构复用”正是面向机器学习工程的核心习惯。我当时做这个项目前一直没有规划代码结构的直觉一上来就写命令。后来才发现把预测、损失、梯度、训练拆成独立函数不仅调试方便后续换数据集、加功能都会轻松很多。多项式回归就更直观了比如你觉得面积和房价之间不是直线关系那就构造一个X_poly [1, x, x², x³]的特征矩阵继续做线性回归。这个思路在sklearn里有现成的PolynomialFeatures但我们既然在从零实践自己拼接第2列、第3列反而更能理解“特征的组合和变换”是如何让线性模型建模非线性关系的。5.2 关于“从零实现”这件事的真实价值做这个项目最大的收获不是学会了一个线性回归模型而是建立了一套分析思路拿到一个预测问题先看数据类型和分布再选模型、定损失函数用梯度下降或解析解去优化最后用多种指标评估并验证模型在未知数据上的表现。这套思路不限于线性回归放到逻辑回归、决策树、神经网络里都是共通的。我身边也有不少人学机器学习是从深度学习框架、大模型API开始的这当然没什么不好。但如果你想理解底层原理特别是“模型为什么要设置学习率”“梯度到底是什么”“过拟合是怎么发生的”一次从零手写的机会比其他任何网课都高效。以后你再去调sklearn、调PyTorch看到那些参数时就不再是背API而是能猜出它的实现大概是怎么回事。5.3 几个值得养成的编码习惯训练代码里把随机种子random_state/np.random.seed固定下来。没有固定种子的机器学习项目每一次运行的结果都可能略有差异调试时你会被这种“玄学”折磨疯。写代码时实时记录不同超参数下的实验结果最好用表格整理。我最早就是懒得记调了半天学习率最后想问“我第一个实验用的什么参数”完全想不起来。每个函数尽量只做一件事比如compute_gradient不负责更新参数update_parameters不负责记录日志。函数粒度越小越容易用测试函数单独验证也越容易排查bug。多用print看中间变量的shape和值不要等整个剧本跑完再悔恨。线性回归的代码很简单但真实项目里一个维度错误经常藏在后面几十行没有及时观察会浪费大量时间。5.4 后续可以从这些方向继续深入完成基本回归模型后自由探索的空间其实很大在同一个数据集上尝试Ridge、Lasso对比正则化带来的权重变化和过拟合抑制效果。自己实现批量梯度下降Batch GD、随机梯度下降SGD、小批量梯度下降Mini-batch GD画图对比三种方式收敛路径的差异。给模型加入L2正则化项观察损失函数加正则项前后测试集误差的变化。换一份真实且字段更多的数据比如电商销量、天气温度预测、二手物品估价把整个流程重新跑一遍。最后分享一个我做这类项目时养成的小习惯每次调参之前先在纸上写下“当前的猜测是什么、调整哪一个变量、预期输出会怎么变化”再动手改代码。这会让实验目的性变强而不是无头苍蝇似的乱调。这次线性回归项目也是靠着这个习惯一遍遍验证不同学习率和特征缩放方式的组合才把原理和代码真正吃透。如果你也想在机器学习这条路上走得扎实一点不妨从这样一个最简但完整的模型开始它也许会让你少走很多弯路。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价