从零手写线性回归:Python代码逐行拆解与深度学习入门
2026/9/24 19:23:30 网站建设 项目流程

很多刚开始接触深度学习的朋友,第一道坎往往不是那些听着高大上的卷积神经网络,反而是看起来最简单的线性回归。我刷“考研李哥深度学习”系列时也有同样的感受:原理一听就懂,公式一推就会,但真到了打开编辑器写代码,手却停在键盘上,不知道从哪一行开始。网上教程要么是 sklearn 三行调包,要么是密密麻麻的数学推导,中间断层严重,特别不友好。

这篇文章就是我个人的“小白线性回归代码心得”,不搞长篇大论的理论,也不玩花活,核心就是带着你用 Python 把线性回归从零手写一遍,弄清楚每一行代码在干什么、为什么要这么写,然后再过渡到框架用法。适合刚看完原理视频、准备动手敲第一行深度学习代码的同学,也适合那些调包调得飞起但对底层实现始终没底的人。读完你至少能回答三个问题:梯度下降到底怎么落到代码上的?多维特征该怎么处理?线性回归跟深度学习的连接点在哪?

1. 先别急着敲代码:线性回归在深度学习中到底处于什么位置

很多教程一上来就让你写代码,但我觉得动手之前必须把“这东西在整个体系里算什么”这个问题搞清楚。否则代码写得再顺,你也只是一个在敲背板而不是在学功夫的人。

1.1 从数学直觉到代码逻辑

线性回归要解决的问题非常朴素:给定一堆数据点,找到一条线(或者一个超平面),让这条线尽可能贴住这些点。数学上就是 y = wx + b,多维度时写作 y = XW + b。这里的 w 是权重,b 是偏置,训练的过程就是不断调整 w 和 b,让预测值和真实值的差距越来越小。

关键在于“差距”怎么衡量。最常用的就是均方误差 MSE,把所有样本的预测误差平方后取平均。为什么要平方?两点考虑:一是消除正负误差抵消的问题,二是放大那些偏差大的样本,让模型更关注“错得离谱”的点。这个损失函数选得好,后续梯度下降才有明确方向。理解了这个逻辑,代码里写loss = np.mean((y_pred - y)**2)就不再是背公式,而是顺着思路自然写出来的事。

1.2 线性回归为什么是深度学习的必修第一课

我见过不少同学嫌线性回归简单,直接跳到 CNN 和 Transformer,结果连反向传播的链式法则都捋不顺。线性回归本质上是“没有隐藏层的神经网络”,它的前向传播、损失计算、梯度回传、参数更新这一整套流程,和复杂神经网络是完全一致的。你现在把线性回归的代码写明白,后面看到model.fit()里藏着的东西才不会觉得黑盒。

说得再直白一点,线性回归就是深度学习世界的“hello world”。它的数据集是自己就能造的,模型结构简单到不可能出错,但涵盖了机器学习最核心的训练闭环。这套闭环你现在不打扎实,后面调 LSTM、调扩散模型,出了问题根本定位不到是数据、梯度、学习率还是网络结构哪一环出了错。很多人训模型一遇 loss 不降就慌了神,本质就是没在简单模型上建立过“排障直觉”。

2. 做好这些准备工作,之后的代码不会再让人抓狂

工欲善其事,必先利其器。这个环节主要解决三件事:用什么语言、用什么工具、去哪里搞数据。很多人忽略这步,直接复制网上的代码,结果跑出一堆环境报错,还以为是自己数学不行,其实只是没做好环境隔离。

2.1 环境配置与工具选型

深度学习再怎么吹,语言层面的答案基本是 Python,这是生态决定的。你不会 Python 也能入门,但每前进一步都要跟“调用接口”死磕,体验极差。起码要会 numpy 的基础用法,比如数组创建、矩阵运算、广播机制,这些是手写模型的地基。

环境用 Anaconda 最省心,创建一个独立环境是基本操作:

conda create -n dl-basic python=3.9 conda activate dl-basic pip install numpy matplotlib scikit-learn

为什么不直接把包装进 base 环境?我踩过太多次坑。不同项目的依赖版本经常打架,今天装了个新包把 numpy 版本顶了,明天另一个项目就莫名其妙跑不了。独立环境是隔离成本最低的手段,这个习惯越早养成越好。

另外,推荐装一个代码诊断插件,比如 VS Code 的 Pylance。新手写代码最常见的低级错误是括号不匹配、变量名拼写不一致、缩进混乱,插件能在你按下运行键之前就标红提示。这不是偷懒,而是把精力留给真正需要思考的算法逻辑。

2.2 三种实现方式的取舍

线性回归的代码实现大体有三条路:手写 numpy 版、sklearn 调包版、PyTorch 版。我的建议是三条路都走一遍,顺序不能乱。

  • numpy 手写版:逐行实现前向传播、损失函数、梯度下降,建立对算法的直觉。
  • sklearn 调包版:几行代码出结果,但你得知道它内部帮你干了什么。
  • PyTorch 版:从手写版平滑迁移到自动求导框架,理解框架帮我们解决了什么问题。

有些同学一上来就 PyTorch,用了torch.nn.Linearloss.backward()两个接口,跑通了就觉得“我会了”。真要问他梯度是怎么传回来的,偏置有没有跟着更新,经常答不上来。我这篇就重点讲第一种和第三种,因为这两条路能把脑子里的弯弯绕绕彻底走通。

2.3 数据从哪来:自己造数据才是最优解

看别人的示例代码总有隔靴搔痒的感觉,因为数据是别人的,你不知道它的分布。自己造数据才能完全掌控全局。线性回归可以简单到只有一个特征,这正好适合调试:

import numpy as np # 设置随机种子,让每次运行结果可复现 np.random.seed(42) # 生成100个样本,特征x在0到10之间均匀分布 X = np.linspace(0, 10, 100).reshape(-1, 1) # 真实关系:y = 2x + 1,加上高斯噪声模拟真实场景 y = 2 * X + 1 + np.random.randn(100, 1) * 1.5

这里的reshape(-1, 1)很容易被忽略,但极其重要。sklearn 和大部分矩阵运算要求输入是二维的,形状是(样本数, 特征数)。如果你写成np.linspace(0, 10, 100),它是一维数组,形状只有(100,),后续矩阵乘法直接维度报错。新手阶段有 80% 的报错都是因为形状不对,学会随时打印X.shape能救你半条命。

为什么加噪声?因为现实世界就没有完全符合 y = 2x + 1 这种规则的数据,总有一些无法解释的波动。噪声让模型学不到完美的 2 和 1,但正好用来看训练过程的损失曲线是不是平滑下降到稳定值。如果你造的数据过于完美,损失变成 0,反而掩盖了模型的学习过程。

3. 从零手写线性回归:核心代码逐行拆解

这章是全篇的重头戏。我先把完整代码贴出来,然后一行一行讲清楚,顺便把新手最容易掉进去的坑标出来。

3.1 初始化参数:起点决定了你会不会踩坑

# 初始化权重为0,偏置为0 w = np.zeros((1, 1)) b = np.zeros((1, 1))

这里初始化为 0 在线性回归里没问题,因为损失函数是凸函数,从任何点出发最终都能收敛到全局最优。但如果你后面学了多层神经网络,权重全 0 初始化会导致所有神经元输出相同、梯度相同,模型退化成单神经元,所以神经网络里一般用随机初始化。你现在养成“看一眼初始化方式”的习惯,后面学深度学习会省很多事。

另外,这里的 w 和 b 我都保持成二维矩阵而不是一维数组,就是为了让它在矩阵运算中不出现形状不匹配的情况。小白时期少点形状报错,就多点学习耐心。

3.2 前向传播与损失计算:每一行的含义

# 前向传播:计算预测值 y_pred = np.dot(X, w) + b # 计算均方误差损失 loss = np.mean((y_pred - y) ** 2)

np.dot(X, w)就是矩阵乘法,X 形状是 (100, 1),w 形状是 (1, 1),结果是 (100, 1),每一行代表一个样本的预测值。加上 b 就是 y = Xw + b 的向量化实现,一次算完所有样本。

有人会问,为什么不用 for 循环一个个算?因为向量化运算能把计算时间压缩几个数量级。你可以做个对比实验:100 个样本 for 循环可能感觉不到差别,但 100 万条数据差距就非常明显了。深度学习模型动辄千万级参数,向量化是唯一的可行性方案。

损失计算用np.mean而不是np.sum,好处是梯度值不会随样本量增大而变大,不同 batch size 之间的损失也具有可比性。很多框架的默认损失函数都是平均而不是求和,正是在这一点上的长期实践结论。

3.3 梯度计算:整个深度学习体系的地基

梯度就是损失函数对每个参数的偏导数。这一步是新手最懵的地方,因为涉及到一些数学,但落到代码上其实非常简洁:

# 计算梯度 dw = np.dot(X.T, (y_pred - y)) / X.shape[0] db = np.mean(y_pred - y)

先说 dw 为什么会同时出现 X 的转置和差值。均值损失函数中 MSE 关于 w 的导数是 2 * (1/m) * X^T * (Xw + b - y),前面的常数 2 通常被并入学习率,所以代码里省略。X.T 的作用是把每个样本的特征和它的预测误差相乘,然后对样本维度求和平均。这是矩阵求导的结论,你现在不懂推导没问题,但要记住这个形态:特征转置点乘误差再除以样本量

从梯度公式可以读出很重要的信息:梯度大小跟预测误差成正比,误差大的样本对参数更新的贡献大。这就是模型“学习”的本质——它会被那些错得离谱的样本重点教育。理解了这一点,你就能明白为什么数据里的离群点对线性回归影响这么大。

3.4 参数更新:学习率该怎么选

# 设置学习率 lr = 0.01 # 梯度下降更新参数 w -= lr * dw b -= lr * db

“参数减去梯度乘以学习率”这个操作就是梯度下降的全部。为什么是减?因为梯度指向的是损失增长最快的方向,我们要往反方向走,才能让损失变小。学习率是每一步迈多大。

学习率的选择是手感活儿,也是新手最先遇到的玄学问题。我自己的经验是:

  • 学习率过大,比如 0.5,损失会震荡甚至飞掉,打印出来是 inf 或 nan。
  • 学习率过小,比如 0.0001,损失下降非常慢,训练半天感觉模型没动。
  • 先用 0.01 起步,观察损失曲线的下降速度,再按 10 倍步长调整。

如果数据分布差异较大(比如某个特征取值范围是 0~1,另一个是 1000~100000),不同方向的梯度尺度差异巨大,用同一个学习率很难收敛。这就引出特征缩放的重要性,后面专门讲。

3.5 完整训练循环

把上面所有代码串起来就是这样:

import numpy as np # 1. 造数据 np.random.seed(42) X = np.linspace(0, 10, 100).reshape(-1, 1) y = 2 * X + 1 + np.random.randn(100, 1) * 1.5 # 2. 初始化 w = np.zeros((1, 1)) b = np.zeros((1, 1)) lr = 0.01 # 3. 训练 for epoch in range(1000): # 前向传播 y_pred = np.dot(X, w) + b # 损失计算 loss = np.mean((y_pred - y) ** 2) # 梯度计算 dw = np.dot(X.T, (y_pred - y)) / X.shape[0] db = np.mean(y_pred - y) # 参数更新 w -= lr * dw b -= lr * db if epoch % 100 == 0: print(f"Epoch {epoch}, Loss: {loss:.4f}, w: {w.item():.4f}, b: {b.item():.4f}")

跑完 1000 轮之后,你会发现 w 逼近 2,b 逼近 1,损失稳定在噪声方差附近,不会降到 0。这就是一个正常模型该有的样子:能学到数据的整体趋势,但无法复现每一个点的噪声波动。如果你看到 w 几乎精确等于 2、b 几乎精确等于 1,反而要怀疑数据是不是造得太假了。真实场景中,参数基本不会和理论值完全一致。

4. 可视化与结果解读:这些指标真的能说明模型好吗

训练完代码之后不能直接收工,要会看结果。很多人的模型训练完,打印一个 loss 就以为万事大吉,这其实错过了整个调试阶段最重要的信息来源。

4.1 画两条曲线,一眼看出学没学会

第一个必画的图是数据点和拟合线的叠加图

import matplotlib.pyplot as plt # 画出所有数据点的散点图 plt.scatter(X, y, alpha=0.6, label='真实数据') # 用训练好的参数画拟合线 plt.plot(X, np.dot(X, w) + b, color='red', linewidth=2, label='拟合结果') plt.xlabel('X') plt.ylabel('y') plt.legend() plt.title('线性回归拟合效果') plt.show()

如果红色的拟合线能穿在数据点的“中间带”上,说明模型学到了主要趋势。如果拟合线明显偏离,比如斜率太低,说明欠拟合,可能是学习率太小或者训练轮数不够。如果拟合线穿过了每一个点,画出了一条剧烈弯曲的曲线,说明过拟合,但在线性回归这种简单模型里不太容易发生。

第二个必画的是损失曲线,也就是每个 epoch 的 loss 值:

loss_history = [] for epoch in range(1000): # ...训练代码... loss_history.append(loss) plt.plot(range(len(loss_history)), loss_history) plt.xlabel('Epoch') plt.ylabel('MSE Loss') plt.title('训练损失下降曲线') plt.yscale('log') # 对数坐标更容易观察收敛趋势 plt.show()

损失曲线本质上是模型学习过程的“心电图”。正常的曲线应该是指数式下降后趋于平缓。我常用的判断方法:前 100 轮损失快速下降,说明模型在抓住主要规律;后面增速放缓,说明进入精细调整阶段;最后变成一条水平线,说明模型已经收敛。如果你看到损失震荡、跳跃、甚至变大,那就是学习率太大或者数据里有异常值。

4.2 R² 到底是什么含义

除了 MSE,另一个绕不开的指标是 R²(决定系数)。它的公式是:

ss_res = np.sum((y - y_pred) ** 2) ss_tot = np.sum((y - np.mean(y)) ** 2) r2 = 1 - ss_res / ss_tot

R² 的含义是:相比“直接用均值做预测”这个最粗糙的基准线,我们的模型把误差减少了多少。R² 越接近 1,说明模型解释掉了大部分方差;R² 接近 0,说明模型和直接猜均值差不多;R² 为负,说明模型比猜均值还烂,基本是废了。

这里有一个新手常犯的理解偏差:R² 高不等于模型好。如果数据本身就一条直线,R² 很容易到 0.99,但这不代表你的模型聪明,只是任务简单。反过来,如果数据噪声很大,R² = 0.5 可能已经是很理想的结果。看指标一定要结合具体任务的“噪声底噪”。

5. 多维特征、特征缩放与踩坑记录

现实中绝大多数问题不会只靠一个特征预测。房价不只是面积,还跟位置、楼层、房龄、朝向有关。特征升到多维之后,代码层面几乎不用改,但有两个新问题会突然冒出来。

5.1 从单特征到多特征的代码升级

假设现在有两个特征,生成数据和训练的代码改动极少:

# 两个特征:面积、房龄 X = np.random.randn(100, 2) # 真实关系:y = 3*x1 - 2*x2 + 5 + 噪声 y = 3 * X[:, 0:1] - 2 * X[:, 1:2] + 5 + np.random.randn(100, 1) # 初始化权重为 (特征数, 1) 的形状 w = np.zeros((2, 1)) b = np.zeros((1, 1)) for epoch in range(2000): y_pred = np.dot(X, w) + b loss = np.mean((y_pred - y) ** 2) dw = np.dot(X.T, (y_pred - y)) / X.shape[0] db = np.mean(y_pred - y) w -= lr * dw b -= lr * db

你仔细观察这跟单特征版本有什么不同——几乎没有。真正的核心逻辑全部被矩阵运算统一了。这就是向量化表示的魅力:一套代码,从 1 个特征到 1000 个特征,结构都不变。w 的形状从(1, 1)变成(2, 1),仅此而已。

5.2 特征缩放:让梯度下降快如闪电

多维特征带来的第一个真正的大问题是量纲不一致。比如面积范围是 50~200,房龄范围是 1~50,朝向可能是 0 或 1。这种情况下,不同特征的梯度尺度差别巨大,梯度下降会在山谷里来回震荡,收敛极慢。

解决办法是标准化(Standardization),把每个特征变成均值为 0、标准差为 1:

mean = X.mean(axis=0) std = X.std(axis=0) X_norm = (X - mean) / std

这个操作背后的几何意义是:将损失函数的等高线从拉长的椭球变成近似的圆形。在圆的表面上做梯度下降,每一步都指向圆心,收敛路径几乎是直线。不做缩放,路径会是锯齿状的“Z”字形,可能要几千轮才收敛;做了缩放,几百轮就到了。

为什么深度学习中这个操作更关键?因为深层网络的参数互相作用更复杂,输入尺度不一致的问题会被逐层放大。很多 “loss 不降” 的现场,最终都是特征没归一化导致的。

5.3 我的踩坑记录:从 loss 涨到 10 亿开始的排查

有次我在练手时,随手把学习率从 0.01 改成了 0.5,跑出来的 loss 直接变成 inf。当时第一反应是“代码写错了”,结果一行一行查了很久也没发现问题。回头看,问题就是学习率太大:参数更新迈的步子太大,直接越过了最优点,误差变大后梯度也变大,进一步跨得更远,形成正反馈发散。

第二个经典案例是数据没归一化时,loss 一直缓慢下降,但 5000 轮还没收敛到理想值。我也曾以为需要加更多训练轮数,其实是因为两个特征的量纲差了 100 倍,梯度下降一直在走锯齿路径。后来加了标准化,几百轮就收敛了。

所以说,遇到 loss 问题不要急着怀疑代码逻辑。按这个顺序排查:先打印每轮的 loss 看它是否单调下降,再看学习率是否合理,再看数据是否需要归一化,最后才回头看梯度公式有没有写错。顺序反了,你会白白浪费好几个小时。

5.4 不得不提的正规方程

除了梯度下降,还有一个解析解法叫正规方程,直接一步算出最优解:

# 在X前面拼接一列1,对应偏置b X_b = np.c_[np.ones((X.shape[0], 1)), X] # 正规方程:(X^T X)^(-1) X^T y w_best = np.linalg.inv(X_b.T.dot(X_b)).dot(X_b.T).dot(y)

它的原理是基于最小二乘的解析推导,把损失函数对参数求导并令导数为零,直接解线性方程组。优点是不用调学习率、不用迭代,大样本时也不怕局部最优。缺点是当特征数很大时,矩阵求逆的计算量呈立方增长,而且如果特征之间存在高度相关性,矩阵不可逆会导致计算失败。

我建议小白先不要用正规方程当主力,因为梯度下降的迭代过程才是深度学习的灵魂。正规方程可以作为验证手段——如果梯度和正规方程算出了一致的 w 和 b,说明你的手写代码没有问题。

6. 从线性回归迈向深度学习的过渡

到此为止,你已经完整实现了线性回归的全部核心流程。最后这部分我想告诉你:这套东西和真正的深度学习之间,其实只隔着一层窗户纸。

6.1 线性回归和神经网络之间的关系

拿 PyTorch 来说,手写版和框架版的对应关系非常明显:

import torch import torch.nn as nn import torch.optim as optim # 定义模型:一个没有激活函数的线性层 model = nn.Linear(2, 1) # 定义损失函数和优化器 criterion = nn.MSELoss() optimizer = optim.SGD(model.parameters(), lr=0.01) # 转成Tensor X_t = torch.tensor(X, dtype=torch.float32) y_t = torch.tensor(y, dtype=torch.float32) # 训练循环 for epoch in range(1000): optimizer.zero_grad() y_pred = model(X_t) loss = criterion(y_pred, y_t) loss.backward() optimizer.step()

nn.Linear(2, 1)对应的就是手写代码里的 w 和 b。loss.backward()自动帮你算好了 dw 和 db。optimizer.step()对应的是参数更新。

这里面最值得品味的是zero_grad(),它相当于“清空上一次的梯度”。PyTorch 默认梯度是累积的,不清零就会把多次反向传播的梯度叠加,导致参数更新完全混乱。手写版没有这个问题是因为我们每次都在内存里直接重算 dw 和 db,而框架为了效率默认累积历史梯度。这就是框架带来的“隐藏状态”,不了解它,你会被loss.backward()背刺得很惨。

6.2 加一个激活函数,世界豁然开朗

如果在线性层后面加一个非线性激活函数,比如 Sigmoid 或 ReLU,模型就从一个纯线性函数升级成了能逼近任意非线性函数的万能逼近器。这就是深度学习的核心魔法——多层非线性变换的组合。

理解这条演进路线的关键在于:线性回归本身是一个“没有隐藏层的神经网络”,它的表达能力受限于线性关系。你训练的模型在二维空间是一条直线,在三维空间是一个平面,但现实世界的关系往往不是一条直线能描述的。当你在线性层之间插入激活函数后,模型有了弯曲数据的能力,这就是从浅层模型到深层模型的质变。

所以我的建议是:踏踏实实把线性回归代码吃透,然后朝这个方向扩展——线性层接激活函数再接线性层,你就会发现,自己已经能够理解并手写一个最简单的神经网络了。到那时候再回头看这篇心得,你会有完全不同的体会。

7. 写在最后的经验分享

如果让我只留一条核心经验给刚开始学代码的朋友,那就是:手写一遍,胜过看十遍。视频里老师讲得再清楚,代码在你手里跑起来、报错、你修好、看到损失下降的那一刻,知识才是真正属于你的。我见过太多人收藏了无数教程,却没有一个跑通,一直停在“看懂了”的错觉里。

另一个小技巧是:把这段手写代码保存好,后面每学一个新模型,就回到这份代码旁边对照一下。你会发现,不管模型变得多复杂,万变不离其宗——前向传播、算损失、回传梯度、更新参数,这四步永远是骨架。从线性回归到逻辑回归,从单层网络到 ResNet,它们共享同一套发动机,改的只是内部的零件而已。想清楚这层关系,你后面学深度学习框架、读开源代码、自己搭模型,都会觉得顺了很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询