这里先尽量把最基础的东西串起来,目标不是把所有模型都讲完,而是先弄清楚:深度学习到底在学什么,模型是怎么训练起来的,以及平时写代码时到底在做什么。

深度学习在做什么

如果只看表面,深度学习好像就是“堆很多层 + 喂很多数据 + 跑很久”,但它真正做的事情本质上还是在做一个函数拟合:

$$ f_{\theta}(x) \approx y $$

其中:

  • $x$ 是输入,例如图片、文本、语音、时序数据
  • $y$ 是我们想预测的目标,例如类别、数值、下一个 token
  • $\theta$ 是模型参数,也就是网络中的权重和偏置

和传统机器学习相比,深度学习最大的特点不只是模型更大,而是特征提取和任务求解被放在了同一个可训练系统里

例如图像分类中,传统方法可能需要手工提边缘、纹理、角点等特征,再送给分类器;深度学习则倾向于直接从原始输入出发,让网络自己去学“什么特征有用”。

所以很多时候可以把深度学习理解成:

  1. 用一个参数化很强的函数去表示任务
  2. 用数据去约束这个函数
  3. 用优化算法不断调整参数,让模型输出越来越接近目标

神经网络的基本单位

感知机

神经网络里最基础的计算单元可以写成:

$$ z = w^T x + b $$

然后再接一个非线性激活函数:

$$ y = \sigma(z) $$

其中:

  • $x$ 是输入向量
  • $w$ 是权重
  • $b$ 是偏置
  • $\sigma$ 是激活函数

如果没有激活函数,那么无论堆多少层线性层,最后都等价于一层线性变换,这样表达能力会非常弱。因此非线性是神经网络能够拟合复杂关系的关键。

常见激活函数

Sigmoid

$$ \sigma(x) = \frac{1}{1 + e^{-x}} $$

输出范围是 $(0, 1)$,早期用得很多,常用于二分类输出概率。但它有一个比较明显的问题,就是输入绝对值过大时梯度会变得很小,容易出现梯度消失。

Tanh

$$ \tanh(x) = \frac{e^x - e^{-x}}{e^x + e^{-x}} $$

输出范围是 $(-1, 1)$,相比 sigmoid 以 $0$ 为中心,优化时有时会更自然一些,但同样可能出现梯度消失。

ReLU

$$ \text{ReLU}(x) = max(0, x) $$

这个函数非常简单,但在实践里特别常用。原因也很直接:

  • 计算便宜
  • 正区间梯度稳定
  • 在深层网络中通常比 sigmoid 更容易训练

当然 ReLU 也有缺点,比如当神经元长期落在负半轴时,梯度恒为 $0$,可能出现“神经元死亡”。

Leaky ReLU

$$ \text{LeakyReLU}(x)= \begin{cases} x, & x > 0 \\ \alpha x, & x \le 0 \end{cases} $$

可以理解为给负半轴留一点斜率,缓解 ReLU 的“死掉”问题。

神经网络为什么能工作

把一个神经元看成一次“线性变换 + 非线性变换”,多层网络就是不断重复这个过程。

例如一个两层全连接网络可以写成:

$$ h = \sigma(W_1x + b_1) $$

$$ \hat y = W_2h + b_2 $$

这里 $h$ 可以理解为中间表示,也就是模型自动学出来的特征。层数更深时,本质上是不断把原始输入映射成更适合当前任务的表示。

如果说得更直白一点,前面的层一般在学“局部和基础的模式”,后面的层则更偏向“更抽象、更任务相关的语义”。

训练流程

深度学习最核心的部分其实就是训练流程。大部分代码归根结底都在围绕下面这几步转:

  1. 前向传播
  2. 计算损失
  3. 反向传播
  4. 参数更新

前向传播

前向传播就是把输入一路送进网络,得到预测值。

例如分类任务里:

$$ \hat y = f_{\theta}(x) $$

这一步本身没有“学习”,只是根据当前参数计算输出。

损失函数

模型输出之后,需要一个标准来衡量“这次预测得有多差”,这个标准就是损失函数。

训练的目标通常可以写成:

$$ \min_{\theta} \mathcal{L}(f_{\theta}(x), y) $$

也就是让预测值和真实值之间的差距尽可能小。

均方误差

回归问题中常见:

$$ \mathcal{L}{MSE} = \frac{1}{n}\sum{i=1}^{n}(\hat y_i - y_i)^2 $$

误差越大,惩罚越重。

交叉熵

分类问题中更常见。二分类时常写成:

$$ \mathcal{L}_{CE} = -[y\log p + (1-y)\log(1-p)] $$

其中 $p$ 是模型预测为正类的概率。它的直觉意义是:如果模型对错误答案很自信,那么惩罚会很重。

梯度下降

有了损失函数之后,接下来要做的就是调整参数,让损失下降。

最基础的更新公式是:

$$ \theta \leftarrow \theta - \eta \nabla_{\theta}\mathcal{L} $$

其中:

  • $\eta$ 是学习率
  • $\nabla_{\theta}\mathcal{L}$ 是损失对参数的梯度

梯度告诉我们“当前参数往哪个方向改,损失会下降得最快”。所以训练的过程可以理解为:不断沿着损失下降的方向走。

反向传播

反向传播并不神秘,本质上就是链式法则在计算图上的系统应用。

如果有复合函数:

$$ L = L(y), \quad y = f(z), \quad z = g(x) $$

那么:

$$ \frac{\partial L}{\partial x}= \frac{\partial L}{\partial y} \cdot \frac{\partial y}{\partial z} \cdot \frac{\partial z}{\partial x} $$

神经网络是很多层复合函数叠起来的,因此完全可以从输出层开始,一层层把梯度传回去。

PyTorch 这类框架帮我们把这件事自动化了,所以平时看到的:

1
loss.backward()

本质上就是在自动求整张计算图上的梯度。

常见网络结构

全连接层

也叫 Linear 层或者 Dense 层,本质上就是:

$$ y = Wx + b $$

这是最基础也最通用的一层,适合处理已经被整理成向量的特征。

优点是表达直接,缺点也很明显:

  • 参数量容易很大
  • 不利用输入的局部结构

因此在图像、语音等高维结构化数据上,一般不会只靠全连接层。

卷积神经网络

卷积层的核心思想是用一个局部感受野在输入上滑动,提取局部模式。

它适合图像任务的原因主要有两个:

  1. 图像的局部区域通常有强相关性
  2. 同一种模式可能会在不同位置重复出现

卷积核共享参数,所以相比直接全连接,参数量会小很多,同时也更符合图像的空间结构。

如果说全连接是在“看整张图后直接做判断”,那卷积更像是“先看局部纹理,再逐步组合成更高层语义”。

池化层

池化层一般用于降采样,例如 max pooling。

它的作用通常包括:

  • 降低特征图尺寸
  • 减少计算量
  • 提升一定程度的平移鲁棒性

不过现在很多网络也会直接用步长卷积替代池化。

循环神经网络

RNN 适合处理序列数据,它会把上一步的隐藏状态传给下一步,因此天然适合建模“前后有关联”的数据。

最基本的形式可以写成:

$$ h_t = \phi(W_xx_t + W_hh_{t-1} + b) $$

它的问题在于长序列训练时容易梯度消失或爆炸,所以后来又有了 LSTM、GRU 这种带门控的结构。

LSTM / GRU

它们的核心想法不是“变得更复杂”,而是通过门控机制让模型自己决定:

  • 什么该记住
  • 什么该遗忘
  • 什么该输出

因此在时序任务、NLP 早期模型里用得很多。

Attention 和 Transformer

Attention 的核心思想是:当前 token 在编码时,不一定只依赖邻近位置,而是可以直接去关注序列中和自己相关的其他位置。

自注意力的公式一般写成:

$$ \text{Attention}(Q, K, V)=\text{softmax}\left(\frac{QK^T}{\sqrt{d_k}}\right)V $$

其中:

  • $Q$ 是 query
  • $K$ 是 key
  • $V$ 是 value

Transformer 可以理解为“以 attention 为核心搭起来的一整套结构”,它非常适合并行训练,这也是它后来在 NLP、CV、多模态里全面铺开的重要原因之一。

训练时经常遇到的问题

欠拟合与过拟合

欠拟合

模型太简单,或者训练不够,导致训练集上都学不好。

常见表现:

  • 训练误差高
  • 验证误差也高

过拟合

模型把训练集记得太细了,泛化能力差。

常见表现:

  • 训练误差很低
  • 验证误差明显更高

这两者是训练时最常见的判断依据之一。

数据集划分

通常会把数据分成:

  • 训练集:用来更新参数
  • 验证集:用来调超参数和观察泛化
  • 测试集:最后做一次相对客观的评估

测试集最好不要参与调参,不然最后的结果会越来越像“对测试集做了适配”。

学习率

学习率太小,训练会很慢;学习率太大,又可能直接震荡甚至发散。

它是最重要的超参数之一。很多时候模型训不起来,不一定是结构有问题,而是学习率没设对。

一个很常见的经验是:

  • 先找一个能稳定下降的学习率
  • 再考虑调度器逐步衰减

Batch、Epoch、Iteration

  • Batch:一次送进模型的样本数
  • Iteration:一次参数更新
  • Epoch:整个训练集被完整看过一轮

如果训练集大小为 $N$,batch size 为 $B$,那么一个 epoch 大约有 $\frac{N}{B}$ 次 iteration。

初始化

如果初始化过大,可能导致激活值爆炸;如果初始化过小,又可能导致信号在深层网络里迅速衰减。

因此才会有 Xavier 初始化、Kaiming 初始化这类更适合不同激活函数的初始化方式。

归一化

输入数据做归一化通常很重要,例如图像常见的做法是减均值、除标准差。

网络内部常见的归一化层有:

  • BatchNorm
  • LayerNorm

它们的目的不完全一样,但都能在不同程度上帮助训练更稳定。

正则化

为了缓解过拟合,常见手段有:

  • L2 正则化
  • Dropout
  • 数据增强
  • 提前停止(early stopping)

其中 dropout 可以理解为在训练时随机“屏蔽掉”一部分神经元,避免网络过度依赖某些局部路径。

优化器

最基本的是 SGD:

$$ \theta \leftarrow \theta - \eta \nabla_{\theta}\mathcal{L} $$

但实际训练中更常见的是带动量的 SGD、Adam、AdamW 等。

SGD

优点是简单直接,有时泛化也很好;缺点是对学习率比较敏感,调参不一定轻松。

Adam

Adam 会同时利用梯度的一阶矩和二阶矩信息,自适应地调整不同参数的学习率,因此通常更容易训起来。

很多入门任务直接上 Adam 往往就能得到一个还不错的结果。

不过“更容易优化”不一定等于“最终泛化一定更好”,具体还是得看任务。

一个最小的 PyTorch 训练例子

下面给一个非常小的二分类例子,用纯 torch 生成二维数据并训练一个简单的 MLP。这个例子主要是为了把训练流程串起来。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
import torch
from torch import nn
from torch.utils.data import DataLoader, TensorDataset

torch.manual_seed(42)

# 生成二维点,判断它是否落在单位圆外
n = 4000
x = torch.randn(n, 2)
y = ((x[:, 0] ** 2 + x[:, 1] ** 2) > 1.0).float().unsqueeze(1)

train_x, valid_x = x[:3200], x[3200:]
train_y, valid_y = y[:3200], y[3200:]

train_loader = DataLoader(
    TensorDataset(train_x, train_y),
    batch_size=128,
    shuffle=True
)
valid_loader = DataLoader(
    TensorDataset(valid_x, valid_y),
    batch_size=256,
    shuffle=False
)

model = nn.Sequential(
    nn.Linear(2, 32),
    nn.ReLU(),
    nn.Linear(32, 32),
    nn.ReLU(),
    nn.Linear(32, 1)
)

criterion = nn.BCEWithLogitsLoss()
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)

for epoch in range(20):
    model.train()
    total_loss = 0.0

    for batch_x, batch_y in train_loader:
        logits = model(batch_x)
        loss = criterion(logits, batch_y)

        optimizer.zero_grad()
        loss.backward()
        optimizer.step()

        total_loss += loss.item() * batch_x.size(0)

    model.eval()
    correct = 0
    total = 0

    with torch.no_grad():
        for batch_x, batch_y in valid_loader:
            logits = model(batch_x)
            pred = (torch.sigmoid(logits) > 0.5).float()
            correct += (pred == batch_y).sum().item()
            total += batch_y.numel()

    train_loss = total_loss / len(train_loader.dataset)
    valid_acc = correct / total
    print(f"epoch={epoch + 1}, train_loss={train_loss:.4f}, valid_acc={valid_acc:.4f}")

这段代码里最关键的几步分别是:

  1. 定义模型
  2. 定义损失函数
  3. 定义优化器
  4. 前向传播得到 logits
  5. loss.backward() 反向传播
  6. optimizer.step() 更新参数

如果是分类任务,需要特别注意最后一层和损失函数是否匹配。

例如:

  • 多分类常见组合是 Linear + CrossEntropyLoss
  • 二分类常见组合是 Linear + BCEWithLogitsLoss

BCEWithLogitsLoss 这种接口已经把 sigmoid 合进去了,所以训练时不要手动再加一层 sigmoid 再送进去,不然数值上反而不太好。

平时写模型代码时到底在关注什么

如果只是为了把程序跑起来,深度学习代码很容易写成“面向 API 编程”,但真正训练模型的时候,通常要一直盯着下面这些东西:

loss 有没有正常下降

如果 loss 完全不动,优先检查:

  • 学习率是否合适
  • 标签是否错位
  • 输出层和损失函数是否匹配
  • 数据预处理是否有问题

训练集和验证集是否分离

如果训练效果很好,但验证效果很差,多半是过拟合,或者数据分布不一致。

梯度是否异常

有时 loss 是 nan,或者训练突然炸掉,可能是:

  • 学习率过大
  • 数值溢出
  • 梯度爆炸
  • 输入尺度不稳定

这时可以考虑梯度裁剪、归一化输入、减小学习率等方法。

数据往往比模型更重要

很多时候并不是模型结构不够“高级”,而是:

  • 数据量不够
  • 标签质量不高
  • 特征分布有偏
  • 训练目标定义不清楚

这个问题在实际项目里比“模型选哪一篇 paper 的结构”更常见。