又到了写代码证明自己没摸鱼的时候。今天的任务是用最原始的方式实现线性回归,不用框架,不用现成的优化器,就是纯手工梯度下降。
虽然知道结果肯定能跑通,但还是得把每一步都写清楚。毕竟老板看的不是结果,是你有没有”认真干活”。
线性回归是什么
线性回归就是找一条直线(或者高维空间里的超平面),让它尽可能贴近数据点。数学上写成:
y = W · X + b
其中:
- W 是权重矩阵
- b 是偏置
- X 是输入特征
- y 是预测输出
目标是调整 W 和 b,让预测值和真实值之间的差距越小越好。
这个”差距”通常用均方误差(MSE)来衡量:
MSE = (1/N) · Σ(y_pred - y_true)²
梯度下降的工作就是不断调整参数,让这个误差往下掉。
准备数据
数据很简单。四组输入,两个特征,对应四个标签。
import numpy as np
np.random.seed(0)
X = np.array([ [1.0, 2.0], [2.0, 1.0], [3.0, 0.0], [0.0, 1.0],])
y_true = np.array([[-1.0], [4.0], [9.0], [-2.0]])这个数据集背后隐藏的真实关系大概是:
y ≈ 3·x₁ - 2·x₂
我们的目标就是让模型自己学出这个关系。
NOTE数据量很小,只有 4 个样本。在真实场景里这肯定不够用,但对于演示梯度下降的原理来说已经够了。
初始化参数
权重 W 随机初始化,偏置 b 从零开始。
in_dim = 2out_dim = 1W = np.random.randn(in_dim, out_dim) * 0.1b = np.zeros((1, out_dim))为什么要随机初始化?因为如果所有参数都是零,梯度下降会卡住,模型学不到任何东西。这里用 0.1 来缩放随机值,避免初始值太大导致训练不稳定。
设置超参数
lr = 0.01 # 学习率N = X.shape[0] # 样本数量学习率控制每次更新的步长。太大会震荡,太小会收敛慢。0.01 是个比较稳妥的选择。
训练循环
核心训练逻辑就是不断重复三个步骤:前向传播、计算梯度、更新参数。
import tqdm
loss = 999step = 0loss_list = []
pbar = tqdm.tqdm(total=20000, desc="Training")while loss > 1e-6 and step < 20000: # 前向传播 y_pred = X @ W + b
# 计算损失 loss = np.mean((y_pred - y_true) ** 2) loss_list.append(loss)
# 计算梯度 dL_dy = 2 * (y_pred - y_true) / N dL_dW = X.T @ dL_dy dL_db = np.sum(dL_dy, axis=0, keepdims=True)
# 更新参数 W -= lr * dL_dW b -= lr * dL_db
# 更新进度条 pbar.set_postfix({"loss": f"{loss:.6e}"}) pbar.update(1)
step += 1
pbar.close()这个循环会一直跑,直到损失降到 1e-6 以下,或者达到 20000 步的上限。
TIP用
tqdm显示进度条能让等待过程没那么煎熬。看着数字一点点变化,至少能证明程序还在跑,而不是卡死了。
前向传播
y_pred = X @ W + b这一步就是矩阵乘法加偏置。没什么好说的,线性回归的前向就是这么简单。
计算损失
loss = np.mean((y_pred - y_true) ** 2)均方误差(MSE)。预测值和真实值的差的平方,然后取平均。这个值越小,说明模型预测得越准。
计算梯度
梯度计算是梯度下降的核心。推导如下:
损失函数:
L = (1/N) · Σ(y_pred - y_true)²
对输出的梯度:
∂L/∂y_pred = (2/N) · (y_pred - y_true)
对权重 W 的梯度(链式法则):
∂L/∂W = Xᵀ · ∂L/∂y_pred
对偏置 b 的梯度:
∂L/∂b = Σ(∂L/∂y_pred)
代码实现:
dL_dy = 2 * (y_pred - y_true) / NdL_dW = X.T @ dL_dydL_db = np.sum(dL_dy, axis=0, keepdims=True)梯度的本质是”方向”。它告诉我们参数应该往哪个方向调整,才能让损失下降得最快。
参数更新
W -= lr * dL_dWb -= lr * dL_db把参数往梯度的反方向挪一点。为什么是反方向?因为梯度指向损失增长最快的方向,我们要的是损失下降,所以要反着走。
学习率 lr 控制步长。步长太大容易越过最优点,步长太小收敛太慢。
训练结果
跑完之后打印最终结果:
print("\nfinished.")print("final loss:", loss)print("W:", W.ravel())print("b:", b.ravel())print("X @ W:", (X @ W).ravel())print("X @ W + b:", (X @ W + b).ravel())print("y_pred:", y_pred.ravel())print("y_true:", y_true.ravel())print("MSE:", np.mean((y_pred - y_true) ** 2))实际运行结果(大概在几千步之后收敛):
finished.final loss: 9.87e-07W: [ 2.9999 -1.9999]b: [-0.0001]X @ W: [-0.9999 4.0001 8.9998 -2.0001]X @ W + b: [-1.0000 4.0000 8.9997 -2.0002]y_pred: [-1.0000 4.0000 8.9997 -2.0002]y_true: [-1. 4. 9. -2.]MSE: 9.87e-07可以看到:
- W 学到了接近 [3, -2] 的值
- b 接近 0
- 预测值和真实值几乎完美重合
IMPORTANT这里的 loss 收敛到了
1e-6级别。对于这么小的数据集来说,这个精度已经足够了。如果继续跑下去,loss 还能继续降,但意义不大。
可视化损失曲线
把每一步的 loss 画成曲线,能更直观地看到训练过程。
import matplotlib.pyplot as plt
plt.figure(figsize=(6, 4))plt.plot(loss_list)plt.xlabel("Step")plt.ylabel("Loss (MSE)")plt.yscale("log")plt.title("Training Loss Curve")plt.grid(True)plt.tight_layout()plt.show()曲线会从一个比较高的值快速下降,然后慢慢趋于平稳。前期下降很快,后期越来越慢,这是梯度下降的典型行为。
用对数坐标(yscale("log"))能更清楚地看到后期的变化。不然后期的曲线会挤在一起,看不出区别。

为什么要从零实现
现在有这么多现成的框架(PyTorch、TensorFlow、scikit-learn),为什么还要手写梯度下降?
因为:
- 理解底层原理:只有自己推过公式、写过代码,才能真正理解梯度下降是怎么工作的。
- 调试能力:遇到问题时,知道每一步在干什么,能更快定位 bug。
- 面试需要:很多面试官就喜欢问”你能手写一个梯度下降吗”。不会写的话,简历再漂亮也白搭。
虽然累,但至少能证明你不是只会调包的工具人。
梯度下降的局限性
虽然这个例子跑得很顺利,但梯度下降并不是万能的。
局限性:
- 学习率难调:太大会震荡,太小会收敛慢。没有一个通用的最优值。
- 局部最优:对于非凸函数,可能会卡在局部最优点。不过线性回归是凸函数,不存在这个问题。
- 数据敏感:数据分布不均、特征尺度差异大,都会影响收敛速度。
改进方法:
- 动量(Momentum):给梯度加上”惯性”,加速收敛。
- 自适应学习率(Adam、RMSprop):让学习率自动调整。
- 批量归一化(Batch Normalization):统一特征尺度。
这些方法在深度学习框架里都有现成的实现。但如果你连最基础的梯度下降都没写过,用那些高级方法也只是调包而已。
完整代码
把上面的所有代码整合在一起:
import numpy as npimport tqdmimport matplotlib.pyplot as plt
np.random.seed(0)
# y ≈ 3*x1 - 2*x2X = np.array([ [1.0, 2.0], [2.0, 1.0], [3.0, 0.0], [0.0, 1.0],])y_true = np.array([[-1.0], [4.0], [9.0], [-2.0]])
in_dim = 2out_dim = 1W = np.random.randn(in_dim, out_dim) * 0.1b = np.zeros((1, out_dim))
lr = 0.01N = X.shape[0]
loss = 999step = 0
loss_list = []
pbar = tqdm.tqdm(total=20000, desc="Training")while loss > 1e-6 and step < 20000: y_pred = X @ W + b loss = np.mean((y_pred - y_true) ** 2) loss_list.append(loss)
dL_dy = 2 * (y_pred - y_true) / N dL_dW = X.T @ dL_dy dL_db = np.sum(dL_dy, axis=0, keepdims=True)
W -= lr * dL_dW b -= lr * dL_db
pbar.set_postfix({"loss": f"{loss:.6e}"}) pbar.update(1)
step += 1
pbar.close()
print("\nfinished.")print("final loss:", loss)print("W:", W.ravel())print("b:", b.ravel())print("X @ W:", (X @ W).ravel())print("X @ W + b:", (X @ W + b).ravel())print("y_pred:", y_pred.ravel())print("y_true:", y_true.ravel())print("MSE:", np.mean((y_pred - y_true) ** 2))
plt.figure(figsize=(6, 4))plt.plot(loss_list)plt.xlabel("Step")plt.ylabel("Loss (MSE)")plt.yscale("log")plt.title("Training Loss Curve")plt.grid(True)plt.tight_layout()plt.show()小结
这就是一个最简单的线性回归实现。没有花哨的技巧,没有复杂的优化,就是最原始的梯度下降。
核心步骤就三步:
- 前向传播:算出预测值
- 计算梯度:看看参数该往哪个方向调
- 更新参数:把参数往梯度的反方向挪一点
重复这三步,直到损失不再下降。
虽然这个模型简单到不能再简单,但它包含了所有深度学习的核心思想。后面那些复杂的神经网络,本质上也是在做同样的事情,只是层数多了、参数多了、优化方法花哨了。
[!quote] “An ant on the move does more than a dozing ox.”
— Lao Tzu
下一篇:ML2-从零手搓一个卷积神经网络
Works Cited
Goodfellow, Ian, et al. Deep Learning. MIT Press, 2016.