从线性回归到非线性拟合:用代码讲透背后的数学

2026-06-17 · Steve Chan

本文以仓库里两个真实例子为线索:

  • heuristic_thinking/calculator_nn2.py —— 一个想学会四则运算的全连接网络
  • heuristic_thinking/2013_nonlinear_fitting/2013_nonlinear_fitting.py —— 一个拟合 sin(x) + 噪声NonlinearModel

目标:不写一个数学公式,全部用可以运行的 Python/PyTorch 代码,把藏在这两段代码背后的 线性代数、微积分、概率 三块知识讲清楚,并解释为什么从 LinearModel 一步步走到 NonlinearModel


0. 一句话主线

整个机器学习训练,就是下面这个循环:

for epoch in range(epochs):
    pred = model(x)            # ① 线性代数:一堆矩阵乘法 + 非线性
    loss = criterion(pred, y)  # ② 概率:用"误差多大"度量"模型有多不可信"
    optimizer.zero_grad()
    loss.backward()            # ③ 微积分:自动求每个参数对 loss 的导数
    optimizer.step()           # ④ 微积分:沿导数反方向走一小步
  • model(x) 这一步,本质是线性代数(矩阵乘法)。
  • criterion(pred, y) 这一步,本质是概率(极大似然 → 最小二乘)。
  • loss.backward()optimizer.step() 这两步,本质是微积分(链式法则 + 梯度下降)。

下面逐块拆开。


1. 起点:线性回归 LinearModel

仓库里的 NonlinearModel 用了 3 个 nn.Linear。要理解它,先把它砍到只剩一层 —— 那就是线性回归。

1.1 一个 nn.Linear 到底在算什么(线性代数)

nn.Linear(in, out) 不是魔法,它就是一次矩阵乘法加偏置。我们手写一遍,和 PyTorch 对照:

import torch
import torch.nn as nn

x = torch.tensor([[2.0, 3.0]])      # 1 个样本,2 个特征  形状 (1, 2)

layer = nn.Linear(2, 1)             # 2 个输入 -> 1 个输出
W = layer.weight                    # 形状 (1, 2) —— 输出 x 输入
b = layer.bias                      # 形状 (1,)

# PyTorch 内部做的事:y = x @ W^T + b
manual = x @ W.t() + b
print(torch.allclose(layer(x), manual))   # True

所以一个"神经元"就是:把输入向量和一行权重做点积,再加一个偏置。 点积 = 加权求和 = 线性代数里最基础的运算。

calculator_nn2.py 里的 nn.Linear(3, 128),意思就是: 3 维输入(两个数 + 运算符编码)通过一个 128 × 3 的矩阵,被"投影"成 128 维。 矩阵的每一行,都是一个看待输入的不同角度。

1.2 把"线性回归"写成最小可运行的代码

import torch

# 造一批服从 y = 2x + 1 的数据,再加一点噪声
x = torch.linspace(-1, 1, 50).unsqueeze(1)      # (50, 1)
y = 2 * x + 1 + torch.randn(50, 1) * 0.1        # 真值 + 噪声

model = torch.nn.Linear(1, 1)                   # 只有 w 和 b 两个参数
opt = torch.optim.SGD(model.parameters(), lr=0.1)
loss_fn = torch.nn.MSELoss()

for _ in range(500):
    pred = model(x)
    loss = loss_fn(pred, y)
    opt.zero_grad(); loss.backward(); opt.step()

print(model.weight.item(), model.bias.item())  # 约等于 2 和 1

这就是 NonlinearModel 的"婴儿版"。它能拟合直线,但拟合不了 sin(x) —— 原因下一节揭晓。


2. 为什么线性不够:线性的"封闭性"陷阱(线性代数)

直觉上你会想:那我多堆几层 nn.Linear 不就更强了?错。多个线性层叠起来,还是一个线性层。 用代码证明:

import torch
import torch.nn as nn

torch.manual_seed(0)
x = torch.randn(5, 3)

# 两层纯线性,中间不加任何非线性
a = nn.Linear(3, 4)
b = nn.Linear(4, 2)
two_layers = b(a(x))

# 把它们合并成"一层":W = Wb @ Wa, bias = Wb @ ba + bb
W = b.weight @ a.weight
bias = b.weight @ a.bias + b.bias
one_layer = x @ W.t() + bias

print(torch.allclose(two_layers, one_layer, atol=1e-6))   # True

结论:Linear(Linear(x)) 恒等于某个 Linear(x)。 无论叠多少层,只要中间没有非线性,模型能表达的永远只是一条直线 / 一个超平面。 这就是为什么 sin(x) 这种弯弯曲曲的曲线,纯线性网络永远拟合不了。


3. 转折点:激活函数让网络"拐弯"(线性代数 → 非线性)

NonlinearModel.forward

def forward(self, x):
    x = torch.relu(self.fc1(x))   # <-- relu 是关键
    x = torch.relu(self.fc2(x))
    x = self.fc3(x)
    return x

torch.relu 就一行逻辑:负数变 0,正数不变

import torch
z = torch.tensor([-2.0, -0.5, 0.0, 1.0, 3.0])
print(torch.relu(z))      # tensor([0., 0., 0., 1., 3.])
print(torch.maximum(z, torch.zeros_like(z)))  # 完全等价

它本身简单到不像数学,但正是这个"折一下"打破了第 2 节的线性封闭性。 每个 ReLU 神经元 = 一个铰链(hinge),在某个位置把直线折弯。 把很多段折线拼起来,就能逼近任意曲线:

import torch
import torch.nn as nn
import matplotlib.pyplot as plt

# 只用一层隐藏层 + relu,看它怎么用"折线"逼近 sin
x = torch.linspace(-6, 6, 200).unsqueeze(1)
target = torch.sin(x)

net = nn.Sequential(nn.Linear(1, 30), nn.ReLU(), nn.Linear(30, 1))
opt = torch.optim.Adam(net.parameters(), lr=0.01)

for _ in range(2000):
    loss = ((net(x) - target) ** 2).mean()
    opt.zero_grad(); loss.backward(); opt.step()

plt.plot(x, target, label="sin(x)")
plt.plot(x, net(x).detach(), label="ReLU 折线逼近")
plt.legend(); plt.show()   # 你会看到一条由许多小直线段拼出来的"假 sin"

这就是 通用逼近定理(Universal Approximation) 的代码直觉: 线性层负责"在哪折、折多大",激活函数负责"真的折下去"。 这正是从 LinearModelNonlinearModel 的本质飞跃 —— 仓库里那张 2013_nonlinear_fitting_pytorch.png 就是它拟合成功的结果。


4. 损失函数从哪来:MSE 背后是概率(概率论)

两份代码都用了 nn.MSELoss()(均方误差)。为什么是"平方",不是"绝对值"或"四次方"? 答案来自概率:MSE 是"假设噪声服从高斯分布"时的极大似然估计。

4.1 先看噪声怎么造出来的

回看 2013_nonlinear_fitting.py 的数据:

y = torch.sin(x) + torch.rand(n, 1) * 0.5

数据 = 一个确定的规律(sin) + 一个随机的噪声。 真实世界的测量永远带噪声,所以 y 不是一个定值,而是一个随机变量。 模型要学的,是这个随机变量的期望(均值)

4.2 用代码说明"为什么平方误差 = 高斯假设"

假设噪声是高斯的,那么"模型预测得有多准"可以用高斯概率密度来打分。 我们把"最小化 MSE"和"最大化高斯似然"放在一起跑,看它们是不是同一回事:

import torch

pred = torch.tensor([1.0, 2.0, 3.0])
true = torch.tensor([1.2, 1.9, 3.5])
err = pred - true

# 方案 A:均方误差
mse = (err ** 2).mean()

# 方案 B:假设误差 ~ 高斯(0, sigma),写出"负对数似然"
sigma = 1.0
nll = (err ** 2 / (2 * sigma ** 2)).mean()   # 高斯 NLL 去掉常数项后剩下的部分

print(mse, nll * 2)   # 成正比 —— 最小化 MSE 等价于最大化高斯似然

一句话:你选 MSE,就等于悄悄声明"我认为数据噪声是高斯的"。 如果噪声有很多离群点(重尾分布),那 MSE 就不再是最优选择,这时会换成 L1(绝对值)损失 —— 那对应的是拉普拉斯分布假设。损失函数的选择,本质是对噪声分布的概率假设。

4.3 这也解释了 calculator_nn2.py 里的"loss 太大"现象

文件注释里记录了三次运行:

run1: Loss: 2184328  ->  32*3 预测成 296(离谱)
run2: Loss: 764      ->  389+88 预测成 -964(还是错)
run3: Loss: 22       ->  32*3 预测成 94.7(接近 96 了)

为什么 loss 数字这么吓人?因为 MSE 是平方:乘法结果能到 100×100=10000, 误差平方一下就到了百万级。这正是平方的双刃剑 —— 对大误差极其敏感(高斯假设下大偏差概率极低,所以惩罚极重)。 这也提示了一个真实工程问题:没做归一化(normalization), 输入范围 1~100、输出范围却能到 10000,量纲差异让训练极难收敛。


5. 训练的发动机:梯度下降(微积分)

现在到了 loss.backward()optimizer.step()。这两行是整篇文章里微积分含量最高的地方。

5.1 导数 = "我往哪个方向动,loss 会下降"

导数的定义就是"输入动一点点,输出动多少"。我们不用公式,用数值差分亲手算一次导数, 再让 PyTorch 用 backward() 算一次,对比两者:

import torch

def f(w):
    return (w - 3) ** 2          # 一个最小值在 w=3 的碗形函数

# 方法一:手动数值求导(微积分的原始定义:差商)
w = 5.0
eps = 1e-4
numeric_grad = (f(torch.tensor(w + eps)) - f(torch.tensor(w - eps))) / (2 * eps)

# 方法二:PyTorch 自动求导
wt = torch.tensor(w, requires_grad=True)
f(wt).backward()
auto_grad = wt.grad

print(numeric_grad, auto_grad)   # 两者都约等于 4.0

两种方法结果一致。backward() 不是黑魔法,它就是在自动、精确地做你手动做的事。

5.2 梯度下降:顺着导数反方向走

知道了导数(上坡方向),把参数往反方向挪一点,loss 就会降。 手写一个梯度下降,亲眼看着它滚到谷底:

import torch

w = torch.tensor(5.0, requires_grad=True)
lr = 0.1                          # 学习率:每步走多大

for step in range(20):
    loss = (w - 3) ** 2
    loss.backward()               # 算出 dloss/dw
    with torch.no_grad():
        w -= lr * w.grad          # 顺着负梯度方向走一步 <-- optimizer.step() 的核心
    w.grad.zero_()                # 清空梯度 <-- optimizer.zero_grad() 的作用
    print(f"step {step}: w={w.item():.4f}")
# w 会从 5 一路逼近 3

把这段代码和训练循环对照:

手写代码 PyTorch 等价写法
loss.backward() loss.backward()
w -= lr * w.grad optimizer.step()
w.grad.zero_() optimizer.zero_grad()

optimizer.zero_grad() 为什么不能漏? 因为 PyTorch 的梯度是累加的。 不清零,这一轮的梯度会叠加上一轮的,方向就乱了。用代码验证:

import torch
w = torch.tensor(2.0, requires_grad=True)

(w ** 2).backward(); print(w.grad)   # 4.0
(w ** 2).backward(); print(w.grad)   # 8.0 —— 没清零,累加了!

5.3 链式法则:多层网络怎么把梯度传回去

NonlinearModel 有三层,fc1 的参数离 loss 很"远"。怎么知道改 fc1 一点点, loss 会变多少?答案是微积分的链式法则:一层层把导数乘回去。 用一个两层的小例子手动复现 PyTorch 的反向传播:

import torch

x = torch.tensor(1.5)
w1 = torch.tensor(2.0, requires_grad=True)
w2 = torch.tensor(3.0, requires_grad=True)

# 前向:h = w1 * x ;  out = w2 * h ;  loss = out^2
h = w1 * x
out = w2 * h
loss = out ** 2
loss.backward()

# 手动用链式法则验证 dloss/dw1:
#   dloss/dout = 2*out
#   dout/dh    = w2
#   dh/dw1     = x
manual_grad_w1 = (2 * out) * w2 * x
print(w1.grad, manual_grad_w1.item())   # 两者相等

backward() 做的,就是从 loss 出发,沿着计算图把这些局部导数自动地连乘回每个参数。 这就是为什么深层的 NonlinearModel 也能被训练 —— 链式法则保证梯度能一路传到 fc1


6. 优化器为什么用 Adam 而不是 SGD(微积分的工程进化)

两份代码都用了 torch.optim.Adam,而第 5 节我们手写的是最朴素的 SGD。 区别在于:Adam 会自适应地调整每个参数的步长,并利用历史梯度做"惯性"(动量)。 用代码感受它们的差异:

import torch

def run(opt_name):
    w = torch.tensor([5.0, 5.0], requires_grad=True)
    opt = (torch.optim.SGD([w], lr=0.1) if opt_name == "SGD"
           else torch.optim.Adam([w], lr=0.1))
    for _ in range(50):
        # 一个在两个方向上"陡峭程度"差很多的椭圆碗
        loss = (w[0] - 3) ** 2 + 20 * (w[1] - 1) ** 2
        opt.zero_grad(); loss.backward(); opt.step()
    return w.detach()

print("SGD :", run("SGD"))
print("Adam:", run("Adam"))   # Adam 通常更快更稳地同时收敛两个方向

朴素 SGD 在"地形陡峭程度不均"时容易震荡或走得慢,Adam 通过记录梯度的一阶/二阶滑动平均, 自动给每个参数一个合适的步长。这也是 2013_nonlinear_fitting.pylr=0.01 的 Adam 就能在几千步内把 sin 拟合好的原因。


7. 概率的另一面:过拟合(probability / 泛化)

2013_nonlinear_fitting.py 文件末尾记录了一个重要现象:

1000 epochs -> Loss 0.2315   拟合刚刚好  (2013_nonlinear_fitting_pytorch.png)
5000 epochs -> Loss 0.0124   过拟合了    (2013_nonlinear_fitting_overfitting.png)

loss 更低,效果反而更差,这看起来矛盾,其实是概率/统计的核心议题。 回忆数据是 y = sin(x) + 噪声

y = torch.sin(x) + torch.rand(n, 1) * 0.5   # 信号 + 噪声
  • 训练 1000 步:模型学到了 sin(x) 这个真实信号(泛化好)。
  • 训练 5000 步:模型把 随机噪声 torch.rand 也背下来了(泛化差)。

模型没法区分"哪部分是规律、哪部分是噪声",训练太久就会去拟合那些本应被忽略的随机抖动。 用代码量化这件事 —— 真正该关心的是没见过的数据上的误差:

import torch, torch.nn as nn

torch.manual_seed(0)
x = torch.linspace(1, 10, 100).unsqueeze(1)
y = torch.sin(x) + torch.rand(100, 1) * 0.5

# 分成训练集和测试集 —— 测试集模型训练时从没见过
idx = torch.randperm(100)
tr, te = idx[:70], idx[70:]

net = nn.Sequential(nn.Linear(1, 10), nn.ReLU(),
                    nn.Linear(10, 10), nn.ReLU(), nn.Linear(10, 1))
opt = torch.optim.Adam(net.parameters(), lr=0.01)

for epoch in range(5000):
    loss = ((net(x[tr]) - y[tr]) ** 2).mean()
    opt.zero_grad(); loss.backward(); opt.step()
    if (epoch + 1) % 1000 == 0:
        with torch.no_grad():
            test_loss = ((net(x[te]) - y[te]) ** 2).mean()
        print(f"epoch {epoch+1}: 训练 loss={loss.item():.4f}  测试 loss={test_loss.item():.4f}")
# 你会看到:训练 loss 一直降,但测试 loss 降到某点后开始回升 —— 那个拐点就是该停手的地方

这就是 偏差-方差权衡(bias-variance tradeoff) 的实战版: 模型容量太小欠拟合(偏差大),训练太久/容量太大过拟合(方差大)。 应对手段(early stopping、正则化、加数据)本质都是在用概率假设约束模型, 让它只学"可复现的规律"而不是"这一批数据的偶然噪声"。


8. 把三块知识缝合回训练循环

最后回到开头那段循环,现在每一行都有了出处:

for epoch in range(epochs):
    outputs = model(x)              # 线性代数:matmul 堆叠 + ReLU 折弯(第 1、3 节)
    loss = criterion(outputs, y)    # 概率论:高斯噪声假设 → MSE(第 4 节)
    optimizer.zero_grad()           # 微积分:清空累加的梯度(第 5.2 节)
    loss.backward()                 # 微积分:链式法则自动求导(第 5.1、5.3 节)
    optimizer.step()                # 微积分:沿负梯度更新(第 5.2、6 节)
知识领域 在代码里的位置 核心直觉
线性代数 nn.Linear / model(x) 神经元 = 加权点积;多层线性会塌缩成一层
非线性 torch.relu 折线拼曲线,打破线性封闭性,是 Nonlinear 的灵魂
概率论 nn.MSELoss / 数据里的噪声 损失函数 = 对噪声分布的假设;过拟合 = 学了噪声
微积分 .backward() / optimizer.step() 导数指方向,梯度下降走下坡,链式法则传到深层

LinearModelNonlinearModel 的一句话总结

线性回归给了你一把直尺,只能画直线(第 1、2 节); 加上 ReLU 这个"折弯"动作,直尺变成了能拼任意曲线的折叠尺(第 3 节); 用高斯假设导出的 MSE 告诉你"拟合得有多好"(第 4 节); 用微积分的梯度下降一步步把尺子掰到正确的形状(第 5、6 节); 而知道什么时候停手,才是概率/泛化教给你的最后一课(第 7 节)。


附:两个例子的对照

calculator_nn2.py 2013_nonlinear_fitting.py
网络 Linear(3,128)→Linear(128,64)→Linear(64,1) + ReLU Linear(1,10)→Linear(10,10)→Linear(10,1) + ReLU
输入 两个数 + 运算符编码 (3维) 一个标量 x (1维)
目标 学会 +、-、×、÷ 拟合 sin(x)+噪声
损失 MSELoss MSELoss
优化器 Adam(lr=0.001) Adam(lr=0.01)
暴露的真实问题 没做归一化 → loss 百万级、难收敛(第 4.3 节) 训练太久 → 过拟合(第 7 节)

两个例子用的是完全相同的数学骨架,只是输入输出维度和数据规律不同。 理解了这套骨架,你就理解了几乎所有前馈神经网络。