2026 字
5 分钟
初识神经网络
2026-07-25
NOTE

这是一篇个人向的笔记,如果你感兴趣,不妨查阅一些更加专业的文章交叉阅读。 阅读前提:请先了解 PyTorch、NumPy的基础数据概念。并配置好深度学习环境(问 AI 就行,我这里是 Miniconda + dl/nerf 双环境)。

训练神经网络要做的事情便是给定一个输入,经过网络里一层层的计算(乘权重、加偏置),最终得到一个输出,我们每次训练都要让这个输出逼近那个正确的值
为了衡量离正确答案有多远,我们引入 Loss(损失值),最经典的就是均方误差(平方确保偏差为正数,且放大偏差效应):

Loss=(预测值真实值)2Loss = (\text{预测值} - \text{真实值})^2

高数告诉我们:如果知道 Loss 对每个参数(旋钮)的敏感程度(即梯度/偏导数),我们就可以顺着这个敏感程度的反方向去微调参数,让loss减小。

Autograd#

在真正的神经网络(比如 NeRF 的 8 层全连接层)里,我们要算“最终的 Loss”对“第一层第三个权重 WW 的敏感度,就要用到链式求导法则——把每一层对上一层的敏感度一路乘回去。PyTorch 提供了 Autograd避免我们手算,它让我们只管写前向传播(forward)的代码(一堆 Tensor 运算),反向求导(backward)PyTorch 全包。

计算图——Autograd核心机制#

当我们做运算时,比如 c = a + b,PyTorch 在内存里画一张图:

  • 节点代表所有变量abc
  • 边表示运算。 如果a被标记为参数,PyTorch 不但记录a参与了加法,还会记录加法的反向求导公式,方便backward。叶节点是计算图的起点,即我们亲自定义的那些参数。由于中间变量很多,PyTorch 做了一个极其高效的策略:
    它只会把算出来的最终梯度,存放在叶节点(即定义的参数)的 .grad 属性里,至于中间变量的梯度直接丢弃以节约显存。

.backward()#

当我们算出loss后,调用 loss.backward()

  • 开始先给出loss对自己的导数(即1),然后从Loss节点出发。
  • PyTorch 倒着走,先遇到生成loss的操作,查看其记录的反向求导操作,得到流向下一层的数据。
  • 如此,不断往上走,当遇到分叉时,分别计算不同分支的值,直到数据到达所有叶节点。把它们的敏感度存入 .grad 属性,至此,反向传播结束。
WARNING
  1. PyTorch 的 backward() 函数只能被标量调用,如果输出是向量,正确做法是先对向量求和(.sum())或求平均(.mean()),然后对其进行backward,这是 MSE Loss 的标准做法。数学依据:和的导数等于导数的和
  2. PyTorch 算出的梯度是累加的,而不是覆盖的。这意味着,如果你在循环里连续调用两次 backward,梯度会变成两倍叠加。在每次反向传播前,必须手动调用 optimizer.zero_grad()

用MLP拟合sin(x)#

MLP全称为多层感知机(Multilayer Perceptron),分为三层:

  • 输入层(Input Layer):数据的入口。比如拟合 sin(x),这一层只有 1 个神经元(就是 x)
  • 隐藏层(Hidden Layers):中间夹着的多层结构。每一层一般都有几百神经元。“多层”指的就是这个部分。层数越多,网络能拟合的形状就越复杂。
  • 输出层(Output Layer):最终结果的出口。sin(x) 输出 1 个数,所以是 1 个神经元

隐藏层每一个神经元的核心计算公式:当前神经元的值 = 激活函数( 上一层所有值的加权和 + 偏置 )

  • 权重(W):控制每个输入的重要性
  • 偏置(b):控制这个神经元被激活的难易程度

nn.Module#

在MLP里面参数可能数万个,手动定义手动跟踪梯度显然不合适,于是我们可以使用nn.Module这个基类解决这个参数管理问题。

nn.Linear内部#

nn.Linearnn.Moudle的一个子类,它实现了一个仿射变换,可以看做是一个功能组件。
nn.Linear(in_features, out_features)本质上就是一个仿射变换:

y=xWT+by=xW^T+b

为什么要这样写仿射变换(为何要求转置):在 PyTorch 里,数据的批次维度(Batch)永远放在最前面,输入的x通常是一批数据,假设x有2个样本,每个样本3个特征,则x形状(2, 3),可以表示为一个矩阵。我们的目标是把这3个特征映射为5个特征,那么y的期望形状为(2, 5)。由线性代数知识可知,带转置的仿射变换形式可以完美满足我们的需求。W 的shape是(out_features, in_features)b的shape是(out_features,)。下面的代码可以帮助理解:

# 输入:3个样本,每个有4个特征
x = torch.randn(3, 4)
# 线性层:把4个特征映射到5个特征
layer = nn.Linear(in_features=4, out_features=5)
# 查看内部形状
print(layer.weight.shape) # 输出: torch.Size([5, 4]) —— 果然是 (out, in)
print(layer.bias.shape) # 输出: torch.Size([5]) —— 果然是 (out,)
# 前向传播
y = layer(x)
print(y.shape) # 输出: torch.Size([3, 5]) —— 果然是 (batch, out)

当创建这一线性层时,nn.Linear 除了自动设置 requires_grad=True,还要进行合理的参数初始化。

  • 权重W:默认采用 Kaiming 均匀分布(针对 ReLU 激活函数优化)
  • 偏置b:默认初始化为0.

激活函数(ReLU)#

激活函数的作用是在层之间插入非线性(因为多线性叠加还是线性的),让网络真正具备拟合复杂函数的能力。这里我们用ReLUmax(0,x)),是最常用、计算最快的激活函数。
.backward()只是把梯度算出来存到.grad里,并不会自动更新参数。真正让参数往梯度反方向挪一点的是optimizer.step()(优化器更新)。以最基础的梯度下降为例,它内部做的事等价于:

wwlr×w.gradw \leftarrow w - \mathit{lr} \times w.\mathrm{grad}

我们这里用Adam而不是最朴素的SGD,因为Adam会给每个参数自适应地调整学习率(大致思路是:梯度在某一区间一直很大的参数学习率小一点、梯度一直很小的参数学习率大一点),收敛通常比朴素SGD快很多,也更不容易调参导致炸掉。

至此我们可以来看看训练循环的四步了:

optimizer.zero_grad() # 先把上一轮残留的梯度清零
output = model(x) # forward:算预测值
loss = loss_fn(output, y_true) # 算loss(标量)
loss.backward() # backward:自动算出loss对每个参数的梯度,存入.grad
optimizer.step() # 用.grad里的梯度,更新每个参数

代码实现#

下面我们用代码来拟合 sinxsinx 曲线

import torch
import torch.nn as nn
import matplotlib.pyplot as plt
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print("使用设备:", device)
# --------- 准备数据 ---------
# 目标:让网络学会拟合 y = sin(x),x的范围取 [-2π, 2π]
# 用 500 个点作为训练数据
x_train = torch.linspace(-2 * torch.pi, 2 * torch.pi, 500).unsqueeze(1) # 输入特征,shape (500, 1)
y_train = torch.sin(x_train) # 真实值,shape (500, 1)
# linspace 在区间内均匀生成指定个数的数字
# unsqueeze 给数据加一个中括号:因为在 linspace 后得到的数字是shape(500, ),纯粹的一组数据
# unsqueeze(1) 把它从 (500,) 变成 (500,1),因为 nn.Linear 要求输入shape是 (batch数, 特征数)
x_train = x_train.to(device)
y_train = y_train.to(device)
# --------- 定义网络结构 ---------
class SineMLP(nn.Module):
def __init__(self):
super().__init__()
# 3层MLP:1 -> 64 -> 64 -> 1
# 输入是x(1维),输出是预测的sin(x)(1维),中间隐藏层64个神经元
self.net = nn.Sequential(nn.Linear(1, 64), nn.ReLU(), nn.Linear(64, 64), nn.ReLU(), nn.Linear(64, 1))
# 注意:最后一层输出前不加ReLU
# 因为sin(x)有负值,而ReLU会把所有负值截断成0,
# 如果输出层前加ReLU,网络永远学不出负数,拟合会直接失败
def forward(self, x):
return self.net(x)
model = SineMLP().to(device) # 将模型参数搬到GPU
total_params = sum(p.numel() for p in model.parameters())
print("模型参数总数:", total_params)
loss_fn = nn.MSELoss() # 均方误差
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
# model.parameters() 会自动收集net里所有Linear层的W和b,一次性交给optimizer管理
# ---------- 训练循环 -----------
num_epochs = 2000
loss_history = [] # 记录每轮的loss,用来画收敛曲线
for epoch in range(num_epochs):
optimizer.zero_grad()
y_pred = model(x_train)
loss = loss_fn(y_pred, y_train)
loss.backward()
optimizer.step()
loss_history.append(loss.item())
if (epoch + 1) % 200 == 0:
print(f"epoch {epoch+1}/{num_epochs}, loss = {loss.item():.6f}")
# ----------- 验证结果,画图对比 ---------------
# 用no_grad(),模型已经训练完成,只是推理而不是训练,所以我们不需要反向传播
with torch.no_grad():
y_pred_final = model(x_train)
# 将显卡数据传输到CPU,并转换成python绘图库(matplotlib)能认识的格式
# 把 PyTorch 的张量(Tensor)格式,转换成 NumPy 的数组(ndarray)格式。
x_plot = x_train.cpu().numpy()
y_true_plot = y_train.cpu().numpy()
y_pred_plot = y_pred_final.cpu().numpy()
fig, axes = plt.subplots(1, 2, figsize=(12, 5))
axes[0].plot(x_plot, y_true_plot, label="true sin(x)", linewidth=2)
axes[0].plot(x_plot, y_pred_plot, label="MLP prediction", linestyle="--")
axes[0].legend()
axes[0].set_title("Fit result")
axes[1].plot(loss_history)
axes[1].set_yscale("log") # loss用log坐标看得更清楚,因为它下降幅度很大
axes[1].set_title("Loss curve (log scale)")
axes[1].set_xlabel("epoch")
plt.tight_layout()
plt.savefig("sine_fit_result.png", dpi=120)
plt.show()
print("图已保存为 sine_fit_result.png")

如果一切顺利,你应该会看到以下输出:

Terminal window
使用设备: cuda
模型参数总数: 4353
epoch 200/2000, loss = 0.075957
epoch 400/2000, loss = 0.009573
epoch 600/2000, loss = 0.001382
epoch 800/2000, loss = 0.000427
epoch 1000/2000, loss = 0.000176
epoch 1200/2000, loss = 0.001763
epoch 1400/2000, loss = 0.000076
epoch 1600/2000, loss = 0.000057
epoch 1800/2000, loss = 0.000047
epoch 2000/2000, loss = 0.000049
图已保存为 sine_fit_result.png

拟合曲线与loss趋势

IMPORTANT

看loss曲线(右图),有个规律性的现象:loss在整体下降的过程中,会周期性地出现向上的尖峰,而且这些尖峰随着训练推进幅度越来越大、间隔越来越短(从epoch~500附近开始出现,到后面越来越密集)。
原因Adam优化器本身的机制,Adam会维护每个参数的梯度”历史一阶矩”和”二阶矩”(滑动平均),用二阶矩去自适应缩放每一步的更新幅度。当loss越降越低、逼近一个很窄的”山谷”(loss landscape在某个方向上曲率变化剧烈)时,Adam的自适应步长机制有时会导致某一步的更新”迈过头”,loss瞬间跳高一下,然后下一步Adam根据这个新的大梯度重新自适应调整,loss又迅速被拉回来、继续下降。训练越到后期,loss landscape越陡峭尖锐(因为在逼近一个精确解),这种”过冲—修正”就越容易发生、越频繁

分享

如果这篇文章对你有帮助,欢迎分享给更多人!

初识神经网络
https://www.naie-char.cc/posts/初识神经网络/
作者
萘Naie_Char
发布于
2026-07-25
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时

目录