Skip to content

深度学习核心入门 — 洞悉AI的学习之道

如果你觉得深度学习是玄学,觉得神经网络就是个黑盒,觉得那些公式推导是天书,那这篇就是为你写的。

我是怕浪猫,在大模型开发一线踩过无数坑的工程师。过去两年里,我训过百亿参数的模型,也调过无数次学习率,踩过梯度爆炸的坑,也经历过损失不下降的绝望。今天我把深度学习最核心的概念掰开揉碎,从生物神经元一路讲到反向传播的完整流程,每一步都有代码、有图解、有实战踩坑经验。

这一章标记为选看,但怕浪猫强烈建议你别跳过。因为后面讲微调策略、讲训练参数调优的时候,你会频繁碰到梯度下降、学习率调度、反向传播这些概念。现在花两小时搞懂,后面能少走一半的弯路。更重要的是,当训练出问题的时候,你能定位到底是前向传播有bug还是学习率设错了,而不是只能干瞪眼。

"不理解梯度下降的工程师,调参就像蒙眼射箭。偶尔命中,但永远不知道为什么命中。"

7.1 基础概念 — 从生物神经元到深度学习

7.1.1 AI、ML、DL 到底什么关系

先把三个最容易混淆的概念理清楚。AI(Artificial Intelligence,人工智能)、ML(Machine Learning,机器学习)、DL(Deep Learning,深度学习),这三个词在日常语境里经常混用,但它们是严格的包含关系。

AI 是最外层概念,一切让机器表现出智能行为的技术都算。从 1956 年达特茅斯会议提出这个概念以来,AI 经历了符号主义、连接主义、统计学习等多个浪潮。你在手机上用的小爱同学、推荐系统里的协同过滤、甚至早期的专家系统,都属于 AI 的范畴。AI 这个领域经历了多次起伏,经历过两次所谓的 AI 寒冬,每次都是因为技术承诺无法兑现导致研究经费大幅缩减。但每一次寒冬之后,都会因为新技术的突破而迎来更大的复兴。深度学习的崛起就是最近一次复兴的推动力。

ML 是 AI 的子集,核心思想是让机器从数据中自动学习规律,而不是靠人写规则。传统编程是你写好逻辑让机器执行,机器学习是你给机器数据,让它自己找规律。这个范式的转变是 AI 发展史上最重要的转折点。传统方法中,工程师需要根据领域知识手动编写规则,比如垃圾邮件过滤需要定义各种关键词匹配规则。但规则写多了容易冲突,维护成本高,而且面对新类型的垃圾邮件时反应迟缓。机器学习换了个思路:你给机器一堆标注好是否为垃圾邮件的样本,让它自己学出区分规则。当数据量足够大的时候,机器学到的规律往往比人类专家手写的规则更好,而且能自动适应新出现的变化。

DL 是 ML 的子集,用多层神经网络来学习数据的层次化表示。深度学习的"深"指的是网络层数多。传统机器学习算法(决策树、支持向量机、随机森林等)在处理结构化表格数据时表现不错,但在处理图像、文本、语音这些非结构化数据时,效果远不如深度学习。原因在于深度学习能够自动学习数据的层次化特征——低层提取简单特征如边缘和纹理,高层组合低层特征得到更抽象的语义信息。这种层次化的特征学习能力,是传统方法做不到的,也是深度学习在计算机视觉和自然语言处理领域取得统治地位的根本原因。

┌─────────────────────────────────────────┐
│           AI 人工智能 (1956-)             │
│  ┌───────────────────────────────────┐  │
│  │      ML 机器学习 (1980-)           │  │
│  │  ┌─────────────────────────────┐  │  │
│  │  │   DL 深度学习 (2006-)        │  │  │
│  │  │  ┌───────────────────────┐  │  │  │
│  │  │  │  Transformer (2017-)   │  │  │  │
│  │  │  └───────────────────────┘  │  │  │
│  │  └─────────────────────────────┘  │  │
│  └───────────────────────────────────┘  │
└─────────────────────────────────────────┘

我们现在用的 GPT、LLaMA、Qwen 这些大模型,核心架构都是 Transformer,而 Transformer 又是深度学习的一个分支。所以当你跟别人说"我做大模型开发"的时候,准确的说法应该是:你在用基于 Transformer 架构的深度神经网络,做自然语言处理任务。

一个简单的判断标准:如果你的模型用了神经网络(哪怕只有一层),那就是深度学习。如果你的系统是从数据中自动学习参数(不管用什么算法),那就是机器学习。如果只是写了一堆 if-else 规则让机器看起来有智能,那就是传统 AI,也叫规则引擎。

7.1.2 从生物神经元到 M-P 模型

深度学习的灵感来源是人脑中的神经元。人脑大约有八百六十亿个神经元,每个神经元通过树突接收其他神经元传来的信号,在细胞体内对这些信号进行加权累加,当累加值超过某个阈值时,神经元被"激活",通过轴突将信号传递给下游的神经元。这个"加权累加加阈值判断"的过程,就是人工神经元的数学原型。

生物神经元结构简化图:

    树突(Dendrites)                    轴突(Axon)
         |                                |
         v                                v
    ┌─────────┐  ┌──────┐  ┌──────┐  ┌─────────┐
    │ 信号输入 │->│ 细胞体 │->| 阈值  |->| 信号输出 │
    │ (多个)   │  │(处理) │  │ 判断  │  │ (单个)   │
    └─────────┘  └──────┘  └──────┘  └─────────┘
                          ^
                     细胞核

1943年,心理学家 McCulloch 和数学家 Pitts 把这个生物过程抽象成了数学模型,这就是著名的 M-P 模型(McCulloch-Pitts Model)。M-P 模型的核心思想是:把神经元的输入看作二值信号(0 或 1),权重固定,当加权求和的结果超过阈值时输出 1,否则输出 0。

M-P 模型的数学表达非常简洁:给定输入向量 x 和权重向量 w,计算加权求和 z = w·x,然后与阈值比较,输出 0 或 1。

python
# M-P 模型的 Python 实现
import numpy as np

def mp_neuron(x, w, threshold):
    """M-P 神经元模型: 加权求和加阈值判断"""
    z = np.dot(x, w)  # 加权求和
    return 1 if z >= threshold else 0  # 阈值判断

# 示例: 实现与门(AND)
x = np.array([1, 1])        # 两个输入都为1
w = np.array([0.5, 0.5])    # 权重各0.5
print(mp_neuron(x, w, 1.0))  # 输出: 1 (都为1时才激活)

# 示例: 实现或门(OR)
x = np.array([0, 1])        # 一个输入为1
w = np.array([0.5, 0.5])    # 权重各0.5
print(mp_neuron(x, w, 0.5))  # 输出: 1 (任一为1即激活)

M-P 模型看起来很简单,但它确立了神经网络最核心的范式:加权求和加激活函数。后面所有的神经网络,不管是有上百层Transformer还是有几十亿参数的大模型,单个神经元的基本结构都是这个。变化只在于激活函数的选择、网络连接的方式、以及权重的学习算法。

"1943年的论文,到今天还在影响每一个大模型的训练。基础理论的寿命远比框架长。PyTorch 可能三年后就过时了,但 M-P 模型八十年了还在用。"

7.1.3 感知机 Perceptron

1958年,Frank Rosenblatt 提出了感知机(Perceptron),在 M-P 模型基础上加入了学习规则。这是神经网络发展史上的里程碑事件——感知机不仅能做判断,还能根据错误自动调整权重,这是机器"学习"的真正雏形。

感知机与 M-P 模型的核心区别在于权重的来源:M-P 模型的权重是固定的,需要人工设定;感知机的权重是通过学习算法自动更新的。感知机引入了损失函数的概念,当预测错误时,根据错误的方向和大小调整权重,使得下次面对同样的输入时更可能预测正确。

感知机的学习规则非常直观:如果预测正确,权重不变;如果预测为正但实际为负,减小权重;如果预测为负但实际为正,增大权重。这个规则在数学上可以表达为 w = w + lr * (y - y_hat) * x,其中 lr 是学习率,y 是真实标签,y_hat 是预测值,x 是输入。

python
# 感知机学习算法完整实现
class Perceptron:
    def __init__(self, lr=0.1, epochs=10):
        self.lr = lr      # 学习率(Learning Rate)
        self.epochs = epochs

    def fit(self, X, y):
        self.w = np.zeros(X.shape[1])  # 初始化权重为0
        self.b = 0                     # 初始化偏置为0
        for _ in range(self.epochs):
            for xi, yi in zip(X, y):
                pred = 1 if np.dot(xi, self.w) + self.b >= 0 else 0
                error = yi - pred  # 误差驱动权重更新
                self.w += self.lr * error * xi
                self.b += self.lr * error

    def predict(self, X):
        return [1 if np.dot(xi, self.w) + self.b >= 0 else 0 for xi in X]

感知机的核心逻辑就这几行:预测、算误差、更新权重。这就是"学习"的本质——根据错误修正自己。这个思想贯穿了整个深度学习,从感知机到 ResNet 到 GPT,底层都是"预测-算误差-更新参数"的循环。

但感知机有一个致命局限:它只能解决线性可分问题。所谓线性可分,就是存在一个超平面(在二维空间中就是一条直线)能把不同类别的数据分开。1969年,Marvin Minsky 和 Seymour Papert 在《Perceptrons》一书中严格证明了感知机无法解决 XOR(异或)问题。这个证明直接导致了神经网络的第一次寒冬,史称"AI Winter",研究经费大幅削减,整个领域沉寂了将近二十年。

XOR 问题为什么搞不定?因为 XOR 的决策边界不是直线。对于输入 (0,0) 输出 0,(0,1) 输出 1,(1,0) 输出 1,(1,1) 输出 0——你无法画一条直线把这四个点中的两类分开。

XOR 真值表:         决策边界(非线性):
x1  x2  |  y       1 |   | 1
 0   0  |  0       0 | 1 | 0
 0   1  |  1       1 | 0 | 1
 1   0  |  1       
 1   1  |  0       一条直线无法把0和1分开

7.1.4 多层神经网络

解决 XOR 问题的思路其实很自然:一个感知机搞不定,那就用两个组合起来,再加一层输出层。这就是多层神经网络(Multi-Layer Neural Network)的起源。

多层网络的关键创新是引入了隐藏层(Hidden Layer)。隐藏层位于输入层和输出层之间,它的作用是学习数据的中间表示。以 XOR 问题为例,我们可以设计两个隐藏神经元:一个实现 OR 功能(任一输入为1则激活),另一个实现 NAND 功能(不同时为1则激活)。然后把这两个隐藏神经元的输出送给一个 AND 神经元做最终判断。这样就能完美实现 XOR。

多层神经网络(解决XOR):

  输入层        隐藏层         输出层
  x1 ─────┬──> h1(OR) ──┐
           ╲              ╲
            ╲              ──> y(AND)
             ╲            ╱
  x2 ─────┴──> h2(NAND)─┘

h1 实现 OR: x1或x2为1则激活
h2 实现 NAND: x1和x2不同时为1则激活
y 实现 AND: h1和h2同时为1则激活

隐藏层的意义远不止解决 XOR。它能学习到数据的中间表示,每一层都在做特征的变换。原始数据在一个空间中可能线性不可分,但经过几层非线性变换后,就变成了线性可分的。这正是深度学习强大的根源——通过多层非线性变换,网络能学习到极其复杂的数据表示。

现代深度学习中,我们说"深度"指的就是隐藏层很多。ResNet 有 152 层,GPT-3 有 96 层 Transformer block,一些最新的模型甚至有上千层。但在这一节,你只需要理解一个核心概念:多层网络能解决单层网络解决不了的问题,深度是一种能力,而不仅仅是复杂度。

"一层的感知机学不出异或,两层的网络可以。深度不是炫技,是解决问题的必然选择。模型够深,才能看到数据中足够抽象的规律。"

7.1.5 监督、无监督、半监督学习

机器学习按数据标注情况分为三大类,这个分类面试必考,也直接决定了你在项目中用什么训练策略。

监督学习(Supervised Learning)就像老师带学生:给你题目和标准答案,你学会做题的规律。训练数据中每个样本都有对应的标签,模型学习的是从输入到标签的映射关系。分类和回归是最典型的监督学习任务。在 LLM 开发中,指令微调(SFT,Supervised Fine-Tuning)就是监督学习——你给模型一批"指令-回答"对,让它学会怎么回答问题。

无监督学习(Unsupervised Learning)没有标准答案,让机器自己发现数据中的结构。聚类把相似的样本分到一起,降维把高维数据投影到低维空间。在 LLM 领域,预训练阶段的自监督学习(Self-Supervised Learning)本质上是一种特殊的无监督学习——标签不是人工标注的,而是从数据自身生成的。比如把一个句子挖掉一个词,让模型预测被挖掉的词是什么,这就是 MLM(Masked Language Modeling,掩码语言建模),BERT 用的就是这个方法。

半监督学习(Semi-Supervised Learning)介于两者之间,用少量有标签数据和大量无标签数据一起训练。在实际项目中,标注数据成本极高,而未标注数据几乎免费。半监督学习方法如伪标签(Pseudo-Labeling)、一致性正则化(Consistency Regularization)能有效利用未标注数据提升模型效果。

类型训练数据典型任务代表算法LLM 中的应用
监督学习有标签分类、回归CNN、RNN、BERT指令微调(SFT)
无监督学习无标签聚类、降维K-Means、PCA预训练(自监督)
半监督学习部分有标签少样本学习伪标签、一致性正则奖励模型训练

7.2 数据与优化基础

7.2.1 训练集、验证集、测试集

深度学习的第一条铁律:不要用测试集训练。数据集的划分既是工程问题,也是原则问题,更是职业操守问题。

训练集(Training Set)用于模型参数的学习,占数据总量的百分之六十到八十。模型在这部分数据上反复训练,通过梯度下降不断调整权重,让损失函数逐渐下降。

验证集(Validation Set)用于超参数调优和模型选择,占百分之十到二十。超参数是你在训练前设定的、训练过程中不自动学习的参数,比如学习率、batch size、网络层数等。你可以在验证集上比较不同超参数组合的效果,选择表现最好的那组。

测试集(Test Set)仅在最终评估时使用一次,占百分之十到二十。测试集的作用是给你一个无偏的泛化性能估计。如果你在训练或调参过程中用到了测试集的数据,那评估结果就是有偏的,模型在测试集上的表现不能代表它在真实场景中的效果。

完整数据集
├── 训练集(Training Set) 60%-80%
│   └── 用于模型参数学习
├── 验证集(Validation Set) 10%-20%
│   └── 用于超参数调优和早停
└── 测试集(Test Set) 10%-20%
    └── 仅在最终评估时使用一次
python
# 标准数据划分方法
from sklearn.model_selection import train_test_split

# 先分出测试集,再从剩余中分出验证集
X_temp, X_test, y_temp, y_test = train_test_split(
    X, y, test_size=0.2, random_state=42, shuffle=True)
X_train, X_val, y_train, y_val = train_test_split(
    X_temp, y_temp, test_size=0.25, random_state=42)  # 0.25*0.8=0.2

print(f"训练集: {len(X_train)}, 验证集: {len(X_val)}, 测试集: {len(X_test)}")
# 最终比例约 6:2:2

怕浪猫在项目中见过最离谱的 bug:有人把验证集数据泄漏到了训练集里,模型在验证集上表现极好,loss 低到不可思议,结果上线后效果一塌糊涂。排查了三天才发现数据预处理阶段有个 shuffle 的 random_state 设错了,导致训练集和验证集有大量重叠。这种 bug 极其隐蔽,因为代码能跑、loss 在降、指标在涨,但一切都是假象。

在 LLM 开发中,数据划分有个额外的注意事项:如果数据有时间属性(比如新闻、对话记录),必须按时间切分,不能随机打乱。否则模型在训练时看到了"未来"的数据,评估结果会虚高。这是数据泄漏(Data Leakage)的典型场景。

7.2.2 过拟合与欠拟合

过拟合(Overfitting)和欠拟合(Underfitting)是模型训练中的两大顽疾,理解它们的本质对实际开发至关重要。

欠拟合的模型太简单,连训练集上的规律都没学到,表现为训练误差和验证误差都很高。本质是模型的偏差(Bias)太大,模型容量不足以捕捉数据中的复杂模式。比如用一个线性模型去拟合一个非线性的数据分布,不管训练多久都拟合不好。

过拟合的模型太复杂,把训练集学得太好了——好到把训练数据中的噪声和随机波动都背下来了。表现为训练误差极低,但验证误差远高于训练误差。这说明模型的方差(Variance)太大,它学到的不是数据的通用规律,而是这批训练数据的特有特征,换一批数据就不好使了。

欠拟合(高偏差)          恰好拟合              过拟合(高方差)

  o   x                    o   x                o   x
    x  o           ->        x  o        ->       x  o
  o     x                 o     x              o  x  x
  (学不到规律)           (泛化良好)            (死记硬背)

"过拟合就像考试背答案的学生,平时满分,换个题就懵。真正的学习是掌握规律,不是死记硬背。模型也是一样,我们要它学到的是分布,不是样本。"

实际开发中判断过拟合的方法很简单:监控训练损失和验证损失的变化曲线。如果训练损失持续下降,但验证损失在某个点之后开始上升,这就是过拟合的信号。那个转折点就是你应该停止训练的地方,这个技巧叫早停(Early Stopping)。

python
# 训练过程中监控过拟合
train_losses, val_losses = [], []

for epoch in range(100):
    train_loss = train_one_epoch(model, train_loader)
    val_loss = evaluate(model, val_loader)
    train_losses.append(train_loss)
    val_losses.append(val_loss)
    
    # 过拟合信号: 训练损失下降但验证损失开始上升
    if epoch > 10 and val_loss > min(val_losses[:-1]):
        print(f"Epoch {epoch}: 检测到过拟合趋势!")
        print(f"  训练损失: {train_loss:.4f}")
        print(f"  验证损失: {val_loss:.4f}")
        break  # 早停

对抗过拟合的常见手段包括:Dropout(随机丢弃一部分神经元,让网络不要太依赖某些特定路径)、L1/L2 正则化(在损失函数中加入权重的惩罚项,限制权重过大)、数据增强(通过各种变换扩充训练数据,让模型见到更多样的输入)、早停(在验证损失开始上升时停止训练)。这些技术在后面微调大模型时会频繁用到。

7.2.3 偏差-方差权衡

偏差-方差分解(Bias-Variance Tradeoff)是理解模型泛化能力的理论框架。它把模型的总误差分解为三个部分:偏差的平方、方差、以及不可避免误差。

偏差衡量的是模型预测的期望值与真实值之间的差距。高偏差意味着模型太简单,无法捕捉数据的真实规律,对应欠拟合。比如用线性回归去拟合一个高度非线性的数据分布,偏差一定很大。

方差衡量的是用不同训练集训练出的模型预测结果的变化程度。高方差意味着模型对训练数据过于敏感,换一批数据模型就大变样,对应过拟合。比如一个很深的决策树,训练集稍微变一下它的分裂方式就完全不同。

误差
  ^
  |  ╲  总误差
  |   ╲         ╱
  |    ╲       ╱
  |  偏差╲    ╱  方差
  |      ╲  ╱
  |       ╲╱  <- 最优复杂度
  +──────────────> 模型复杂度
  欠拟合 <- | -> 过拟合

模型复杂度增加时,偏差下降但方差上升,总误差先降后升,存在一个最优点。这个最优点对应的模型复杂度,就是偏差和方差的最佳平衡。实际开发中,我们通过调整模型结构(层数、参数量)和正则化强度来寻找这个平衡点。

在 LLM 开发中,这个权衡无处不在。模型太小,理解不了复杂的语义关系,偏差太大;模型太大,在有限数据上容易过拟合,方差太大。LoRA(Low-Rank Adaptation,低秩适配)微调中 rank 参数的选择本质上就是在做偏差-方差权衡:rank 太小,可训练参数太少,表达力不够,偏差大;rank 太大,参数过多,容易过拟合训练数据,方差大。

7.2.4 代价函数

代价函数(Cost Function),也叫损失函数(Loss Function),是衡量模型预测值与真实值差距的指标。整个深度学习的训练过程,本质上就是在最小化代价函数。选择合适的代价函数,直接决定了模型能学到什么。

MSE(Mean Squared Error,均方误差)是回归任务最常用的代价函数。它计算预测值与真实值之差的平方的均值。之所以取平方而不是绝对值,有两个原因:平方使得误差为正,便于求导;平方对大误差惩罚更大,这通常是期望的行为。

python
# MSE 实现
def mse_loss(y_pred, y_true):
    """均方误差: 预测值与真实值差的平方的均值"""
    return ((y_pred - y_true) ** 2).mean()

# MSE 对异常值敏感, 因为误差被平方放大了
y_pred = np.array([2.0, 2.0, 2.0, 10.0])  # 最后一个预测严重偏离
y_true = np.array([2.0, 2.0, 2.0, 2.0])
print(f"MSE: {mse_loss(y_pred, y_true):.2f}")  # 16.0 (被异常值拉高)

MSE 的一个重要特性是它的梯度随误差线性增长(因为导数中有一个 2 倍因子),这意味着大误差会产生大梯度,模型会优先修正那些预测严重偏离的样本。但当数据中有异常值时,这个特性反而成了缺点——少数异常值会主导梯度方向。

交叉熵(Cross-Entropy)是分类任务的核心损失函数,也是 LLM 语言建模的基石。交叉熵衡量的是两个概率分布之间的差异:模型预测的概率分布和真实的概率分布。当模型预测的概率分布与真实分布完全一致时,交叉熵为零;差异越大,交叉熵越大。

python
# 交叉熵损失
import torch.nn as nn

loss_fn = nn.CrossEntropyLoss()
# logits: 模型未经softmax的原始输出
logits = torch.tensor([[2.0, 0.5, 0.3]])  # 3个类别的得分
# target: 真实类别的索引
target = torch.tensor([0])  # 第一个类别是正确的
loss = loss_fn(logits, target)
print(f"Cross-Entropy Loss: {loss.item():.4f}")  # 约0.17

在 LLM 训练中,交叉熵的具体形式是对每个 token 做分类,类别是整个词表。GPT 的词表大约五万到十万个 token,所以每个位置都是一个数万类的分类问题。模型预测下一个 token 的概率分布,与真实的下一个 token 做交叉熵,然后对所有位置取平均,就是整个序列的损失。

"代价函数定义了模型的学习目标。选错了代价函数,再好的优化算法也是在错误的方向上狂奔。"

7.3 梯度下降

7.3.1 梯度下降原理

梯度下降(Gradient Descent)是深度学习最核心的优化算法,没有之一。理解了梯度下降,你就理解了模型"学习"的本质。

想象你蒙着眼睛站在山上,目标是走到最低点。你看不到周围的环境,但能感受到脚下哪个方向更陡峭。于是你每次朝最陡的下坡方向迈一小步,重复这个过程,最终你会走到山谷底部。这就是梯度下降的直觉。

在数学上,梯度是损失函数对每个参数的偏导数组成的向量,指向损失增加最快的方向。负梯度就是损失减少最快的方向。我们沿着负梯度方向更新参数,就能逐步降低损失。

梯度下降示意:

  损失 J(theta)
    |
    |  *  <- 初始位置(高损失)
    |   ╲
    |    *  <- 每步沿负梯度方向走
    |     ╲
    |      *
    |       ╲
    |        *  <- 最低点(最优解)
    +──────────> 参数 theta

参数更新的数学表达非常简洁:theta_new = theta_old - alpha * gradient。其中 alpha 是学习率,控制每步走多大;gradient 是损失函数对参数的梯度。

python
# 梯度下降最简实现
def gradient_descent(X, y, lr=0.01, epochs=100):
    w = np.zeros(X.shape[1])
    for _ in range(epochs):
        pred = np.dot(X, w)
        # 计算梯度: dJ/dw = X^T * (pred - y) / n
        gradient = X.T.dot(pred - y) / len(y)
        w = w - lr * gradient  # 沿负梯度方向更新
    return w

7.3.2 批量、随机、小批量梯度下降

根据每次使用多少数据计算梯度,梯度下降分为三种变体。理解它们的区别对选择训练策略很重要。

批量梯度下降(Batch Gradient Descent, BGD)每次使用全部训练数据计算梯度。梯度方向最准确,但每次迭代需要遍历整个数据集,速度很慢。当数据集有数百万条时,一次参数更新就要几分钟甚至更久。对于 LLM 训练来说,数据集通常是几十 GB 甚至几百 GB,全量计算根本不现实。

随机梯度下降(Stochastic Gradient Descent, SGD)每次只使用一个样本计算梯度。速度极快,但梯度噪声很大——单个样本的梯度方向可能与全局最优方向相差甚远。这导致损失曲线震荡剧烈,但有趣的是,这种震荡有时反而能帮助模型跳出局部最优。

小批量梯度下降(Mini-Batch Gradient Descent, MBGD)是前两者的折中,每次使用一小批样本(通常 32 到 2048 个)计算梯度。既保证了梯度方向的相对准确,又控制了计算开销。这是深度学习训练的标准做法,几乎所有深度学习框架的默认模式都是小批量。

python
# 小批量梯度下降 (LLM 训练标准做法)
from torch.utils.data import DataLoader

# batch_size 是关键超参数
dataloader = DataLoader(dataset, batch_size=32, shuffle=True)

for batch_X, batch_y in dataloader:
    pred = model(batch_X)
    loss = loss_fn(pred, batch_y)
    loss.backward()        # 自动计算梯度
    optimizer.step()       # 更新参数
    optimizer.zero_grad()  # 清空梯度

LLM 训练中 batch size 的选择是个技术活。batch size 太小,梯度噪声大,训练不稳定;batch size 太大,显存不够用,而且泛化性能可能下降。受限于 GPU 显存,通常用梯度累积(Gradient Accumulation)来模拟大 batch size 的效果——做几次小 batch 的前向和反向传播,把梯度累加起来,再统一更新一次参数。

python
# 梯度累积: 显存不够时的实战Trick
accumulation_steps = 4  # 等效batch_size = 32 * 4 = 128
for i, (batch_X, batch_y) in enumerate(dataloader):
    loss = loss_fn(model(batch_X), batch_y) / accumulation_steps
    loss.backward()  # 梯度累积而不立即更新
    if (i + 1) % accumulation_steps == 0:
        optimizer.step()       # 累积够了才更新
        optimizer.zero_grad()  # 清空梯度

"梯度下降的灵魂不在于算法本身,而在于学习率的选择。步子太大跨过谷底,步子太小米到天亮。调参十次有八次在调学习率。"

7.3.3 线性回归的代价函数求导

用线性回归做一次完整的梯度下降推导,把理论到实践的路走通。

线性回归的模型很简单:预测值 y_hat = w * x + b。代价函数用 MSE:J = (1/n) * sum((y_hat_i - y_i)^2)。

对 w 求偏导,需要用链式法则。y_hat 对 w 的导数是 x,所以 dJ/dw = (2/n) * sum((y_hat_i - y_i) * x_i)。这个结果有很好的直觉解释:梯度与误差和输入的乘积成正比。如果某个样本的输入值很大且预测误差也很大,那么这个样本对梯度的贡献就很大,参数会朝着减小这个样本误差的方向调整。

对 b 求偏导类似,只是 y_hat 对 b 的导数是 1,所以 dJ/db = (2/n) * sum(y_hat_i - y_i)。

python
# 线性回归梯度下降完整实现
import numpy as np

def linear_regression_gd(X, y, lr=0.01, epochs=100):
    n = len(y)
    w, b = 0.0, 0.0
    for epoch in range(epochs):
        y_pred = w * X + b
        error = y_pred - y
        dw = (2/n) * np.sum(error * X)  # dJ/dw
        db = (2/n) * np.sum(error)      # dJ/db
        w = w - lr * dw                  # 更新w
        b = b - lr * db                  # 更新b
        if epoch % 20 == 0:
            loss = np.mean(error**2)
            print(f"Epoch {epoch}: loss={loss:.4f}, w={w:.4f}")
    return w, b

这个推导虽然简单,但它揭示了梯度下降的核心模式:前向计算预测值,算误差,对参数求偏导,沿负梯度更新参数。后面所有深度学习框架的反向传播,不管网络有多深多复杂,底层逻辑都是这四步。区别只在于求导的方式从手动变成了自动微分。

7.3.4 学习率与自适应学习率

学习率是深度学习中最关键的超参数,没有之一。学习率太大,损失会震荡甚至发散到无穷大;学习率太小,收敛速度慢得让人怀疑人生。在实际训练中,学习率的选择往往需要多次实验才能确定。

固定学习率的局限性很明显:训练初期需要大的学习率快速接近最优区域,训练后期需要小的学习率在最优区域精细调整。一个固定的学习率无法同时满足这两个需求。这催生了自适应学习率算法——让学习率根据训练状态自动调整。

AdaGrad(Adaptive Gradient,自适应梯度)是早期的自适应算法。它为每个参数维护一个历史梯度的平方和,用这个和来缩放学习率。梯度大的参数学习率衰减快,梯度小的参数学习率衰减慢。问题在于累积量只增不减,学习率会单调下降,后期往往衰减到接近零,导致训练停滞。

RMSProp(Root Mean Square Propagation,均方根传播)用指数移动平均代替了全量累积,只关注近期的梯度信息。这解决了 AdaGrad 学习率过早衰减的问题,使得学习率能够动态调整而不会一直下降。

Adam(Adaptive Moment Estimation,自适应矩估计)结合了动量法和 RMSProp 的优点。它同时维护梯度的一阶矩(均值,类似动量)和二阶矩(方差,类似 RMSProp),并做偏差修正。Adam 在大多数任务上都表现优秀,几乎成了默认选择。

python
# Adam 优化器使用(PyTorch)
import torch.optim as optim

optimizer = optim.Adam(model.parameters(), lr=1e-3,
                       betas=(0.9, 0.999),  # 一阶/二阶矩衰减率
                       eps=1e-8, weight_decay=0.01)

# Adam 更新逻辑(简化版):
# m = beta1 * m + (1-beta1) * g        # 一阶矩(动量)
# v = beta2 * v + (1-beta2) * g^2      # 二阶矩
# m_hat = m / (1 - beta1^t)            # 偏差修正
# v_hat = v / (1 - beta2^t)
# theta = theta - lr * m_hat / (sqrt(v_hat) + eps)

Adam 是目前 LLM 训练的标配优化器。GPT-3、LLaMA、Qwen 的预训练和微调几乎都用 Adam 或其变体 AdamW(Adam with Decoupled Weight Decay,带解耦权重衰减的 Adam)。AdamW 与 Adam 的区别在于权重衰减的处理方式——AdamW 将权重衰减与梯度更新解耦,理论上更合理,实际效果也通常更好。

7.4 逻辑回归与激活函数

7.4.1 逻辑回归模型

逻辑回归(Logistic Regression)虽然名字里有"回归",实际上做的是二分类任务。它在线性回归的基础上套了一层 sigmoid 函数,把线性输出的任意实数值压缩到 (0, 1) 区间,表示属于正类的概率。

逻辑回归的模型可以拆成两步:先做线性变换 z = w·x + b,再做非线性变换 y_hat = sigmoid(z)。线性变换产生一个实数值,sigmoid 把它变成概率。当 z 很大时 sigmoid 接近 1,当 z 很负时 sigmoid 接近 0,当 z 等于 0 时 sigmoid 等于 0.5——这正好是决策边界。

python
# 逻辑回归实现
import torch.nn as nn

class LogisticRegression(nn.Module):
    def __init__(self, input_dim):
        super().__init__()
        self.linear = nn.Linear(input_dim, 1)
        self.sigmoid = nn.Sigmoid()

    def forward(self, x):
        return self.sigmoid(self.linear(x))

model = LogisticRegression(input_dim=10)

7.4.2 Sigmoid 与梯度消失

Sigmoid 函数 sigma(x) = 1/(1+e^(-x)) 是早期神经网络最常用的激活函数。它有两个严重的问题。

第一个问题是梯度消失。Sigmoid 的导数 sigma'(x) = sigma(x) * (1 - sigma(x)),最大值出现在 x=0 时,值为 0.25。这意味着在反向传播时,梯度每经过一层 sigmoid 就至少缩小到四分之一。一个十层的网络,梯度最多 0.25 的十次方,约百万分之一,几乎为零。靠近输入层的参数根本无法有效更新,网络等于没在学。

Sigmoid 函数及其导数:

  sigma(x)                  sigma'(x) = sigma(x)(1-sigma(x))
  1 |    ___                  |    ___
    |   /                     |   / | \
    |  /                      |  /  |  \  最大值仅0.25
  0.5|-|--------            0 |-|---|-----|----
    | /                       | /         \
  0 |__|___________           |__|_________\___
     -5  0  5                   -5  0       5

第二个问题是输出非零中心化。Sigmoid 的输出恒为正数,这会导致后续层的权重梯度恒为同号,更新时出现 zigzag 现象,收敛变慢。

7.4.3 对数似然代价函数

逻辑回归不使用 MSE 作为代价函数,而是使用对数似然(Log-Likelihood)代价函数,也叫二元交叉熵(Binary Cross-Entropy, BCE)。这个选择不是任意的,有深刻的数学原因。

用 MSE 作为逻辑回归的代价函数时,代价函数是非凸的——存在多个局部最小值,梯度下降可能陷入局部最优而找不到全局最优。而对数似然代价函数是凸函数,保证梯度下降能收敛到全局最优。

此外,对数似然代价函数与 sigmoid 搭配时,梯度的形式非常简洁:dL/dw = (y_hat - y) * x。梯度直接与预测误差成正比,误差大时梯度大,参数更新快;误差小时梯度小,参数更新慢。这个性质使得训练既高效又稳定。

python
# 二元交叉熵损失
loss_fn = nn.BCELoss()  # Binary Cross-Entropy Loss
y_pred = torch.tensor([[0.9], [0.1], [0.8]])  # 预测概率
y_true = torch.tensor([[1.0], [0.0], [1.0]])  # 真实标签
loss = loss_fn(y_pred, y_true)
print(f"BCE Loss: {loss.item():.4f}")  # 约0.144

7.4.4 链式法则求导

反向传播的数学基础是链式法则(Chain Rule)。怕浪猫用一个具体的例子把链式法则在神经网络中的应用讲清楚。

对于逻辑回归:y_hat = sigmoid(w·x + b),损失 L = BCE(y, y_hat)。要更新参数 w,需要知道 dL/dw。但 L 不直接依赖于 w,而是通过 y_hat 间接依赖。链式法则告诉我们:dL/dw = (dL/dy_hat) * (dy_hat/dz) * (dz/dw),其中 z = w·x + b。

逐个计算:dL/dy_hat = -y/y_hat + (1-y)/(1-y_hat),这是 BCE 对 y_hat 的导数;dy_hat/dz = y_hat * (1-y_hat),这是 sigmoid 的导数;dz/dw = x,这是线性变换对 w 的导数。

把三者相乘并化简,奇迹出现了:dL/dw = (y_hat - y) * x。所有复杂的中间项都约掉了,最终结果就是预测误差乘以输入。这个结果的优雅之处在于:梯度直接与误差成正比,误差越大参数更新幅度越大,这完全符合直觉。

"链式法则看起来复杂,但本质就是一层层拆解。就像剥洋葱,一层一层来,每层都不难。化简之后的结果往往出人意料地简洁。"

7.4.5 激活函数选择策略

不同的激活函数有不同的特性,选择合适的激活函数对训练效果影响很大。怕浪猫总结了一份实战选择指南:

激活函数优点缺点推荐场景
Sigmoid输出(0,1)可解释为概率梯度消失、非零中心输出层二分类
tanh零中心化仍有梯度消失隐藏层(旧)
ReLU计算快、缓解梯度消失神经元死亡隐藏层(默认)
Leaky ReLU解决神经元死亡需调斜率参数深层网络
GELU平滑、理论支撑计算量略大Transformer

ReLU(Rectified Linear Unit,修正线性单元)的定义极其简单:f(x) = max(0, x)。正值原样输出,负值置零。这个看似简陋的函数有两大优势:计算量极小(一次比较),正区间梯度恒为 1(不会梯度消失)。ReLU 是目前最常用的隐藏层激活函数,几乎所有现代 CNN 架构(ResNet、EfficientNet)都用它。

但 ReLU 有一个实际问题:神经元死亡。当某个神经元的输入持续为负时,它的输出恒为零,梯度也恒为零,这个神经元就永远不会被更新了,等于"死了"。Leaky ReLU 通过给负区间一个小的斜率(通常是 0.01)来解决这个问题,让死亡神经元有机会"复活"。

GELU(Gaussian Error Linear Unit,高斯误差线性单元)是 Transformer 的标配激活函数。GPT、BERT、LLaMA 用的都是 GELU。它的数学形式是 f(x) = x * Phi(x),其中 Phi 是标准正态分布的累积分布函数。GELU 比 ReLU 更平滑,在零点附近有平滑的过渡,而不是像 ReLU 那样硬性地截断负值。这种平滑性使得梯度在零点附近也更加连续,有助于训练的稳定性。理论上 GELU 更符合神经元的生物学特性,实际效果通常略优于 ReLU,尤其是在深层 Transformer 模型中。

python
# 激活函数对比
import torch.nn as nn

relu = nn.ReLU()           # 隐藏层默认选择
leaky_relu = nn.LeakyReLU(0.01)  # 解决ReLU神经元死亡
gelu = nn.GELU()          # Transformer 标配
sigmoid = nn.Sigmoid()     # 输出层二分类

实际开发中的建议:隐藏层默认用 ReLU,如果是 Transformer 架构用 GELU,输出层二分类用 Sigmoid,多分类用 Softmax,回归任务不加激活函数。这不是铁律,但是最安全的默认选择。

7.5 深度神经网络

7.5.1 前向传播

前向传播(Forward Propagation)是数据从输入层经过各隐藏层最终到达输出层的计算过程。每一层做两件事:先是线性变换(加权求和加偏置),然后是非线性变换(激活函数)。线性变换提供维度变换和特征组合的能力,非线性变换提供表达复杂函数的能力。没有非线性激活函数,再多层线性变换叠加在一起,效果也只相当于一层线性变换——这就是为什么激活函数不可或缺。

前向传播流程(3层网络):

  输入层        隐藏层1        隐藏层2        输出层
  x ──W1,b1──> z1 ──σ──> a1 ──W2,b2──> z2 ──σ──> a2 ──W3,b3──> y_hat

  z1 = W1·x + b1     线性变换
  a1 = σ(z1)          非线性激活
  z2 = W2·a1 + b2     线性变换
  a2 = σ(z2)          非线性激活
  y_hat = W3·a2 + b3  输出层
python
# 前向传播实现(手动版)
import numpy as np

def forward(x, W1, b1, W2, b2, W3, b3):
    # 第一层: 线性变换 + ReLU激活
    z1 = np.dot(W1, x) + b1
    a1 = np.maximum(0, z1)  # ReLU
    # 第二层: 线性变换 + ReLU激活
    z2 = np.dot(W2, a1) + b2
    a2 = np.maximum(0, z2)  # ReLU
    # 输出层: 线性变换 + Sigmoid
    z3 = np.dot(W3, a2) + b3
    y_hat = 1 / (1 + np.exp(-z3))
    # 保存中间值供反向传播使用
    return y_hat, (z1, a1, z2, a2, z3)

前向传播的一个关键实现细节:中间结果必须缓存。z1、a1、z2、a2 这些值在反向传播计算梯度时要用到。这也是为什么训练时的显存占用远大于推理——推理只需要前向传播,不用缓存中间值;训练需要反向传播,必须保存所有中间激活值。这直接解释了为什么推理可以用更大的 batch size,而训练时显存往往捉襟见肘。

7.5.2 反向传播与计算图

反向传播(Backpropagation)是前向传播的逆过程,利用链式法则从输出层向输入层逐层计算每个参数的梯度。要真正理解反向传播,需要先理解计算图(Computational Graph)。

计算图把前向传播的每一步运算画成有向无环图,每个节点代表一个运算操作(加法、乘法、激活函数等),边代表数据的流动。前向传播是数据在图上从左到右流动,反向传播是梯度在图上从右到左流动。

计算图的核心优势在于模块化:每个节点只需要知道两件事——怎么算前向输出,怎么算自己的局部梯度。全局的梯度计算通过链式法则自动完成,不需要对整个网络做手动求导。深度学习框架的自动微分(Automatic Differentiation)就是基于计算图实现的。

计算图示例: y_hat = sigmoid(w·x + b), L = BCE(y, y_hat)

  前向传播(左到右):
  x ─┐
     ├─(乘)─ z ─(加)─ z' ─ sigmoid ─ y_hat ─ BCE ─ L
  w ─┘         ^
               |
               b

  反向传播(右到左, 逐节点计算梯度):
  dL/dy_hat ──> dL/dz' = dL/dy_hat * dy_hat/dz'
           ──> dL/dz = dL/dz' (加法直接传递梯度)
           ──> dL/dw = dL/dz * dz/dw = dL/dz * x
           ──> dL/db = dL/dz * dz/db = dL/dz * 1
python
# PyTorch 自动微分演示
import torch

x = torch.tensor([2.0], requires_grad=False)
w = torch.tensor([3.0], requires_grad=True)  # 需要梯度
b = torch.tensor([1.0], requires_grad=True)

# 前向传播: PyTorch 自动构建计算图
z = w * x + b              # z = 3*2+1 = 7
y_hat = torch.sigmoid(z)   # sigmoid(7) 约等于 0.999
loss = -torch.log(y_hat)   # 假设真实标签为1

# 反向传播: PyTorch 自动计算所有梯度
loss.backward()
print(f"dL/dw = {w.grad}")  # 梯度自动算出
print(f"dL/db = {b.grad}")

PyTorch 的 autograd 机制做的事情就是:在前向传播时自动构建计算图并缓存中间值,在调用 backward() 时沿着计算图反向传播,用链式法则自动计算所有需要梯度的参数的梯度。你只需要定义前向传播的逻辑,梯度的计算完全是自动的。

"前向传播是花钱(算力),反向传播是记账(梯度)。PyTorch 的 autograd 就是最好的会计,你只管花钱,它帮你把账算得清清楚楚。"

7.5.3 链式法则在深度网络中的应用

把链式法则从单层网络推广到多层网络,就是反向传播的完整算法。算法的输入是前向传播缓存的中间值和输出层的误差,输出是每一层每个参数的梯度。

反向传播算法的核心是误差信号 delta 的逐层传播。对于第 l 层,误差信号 delta[l] 表示这一层的输出需要朝哪个方向调整才能减小总损失。从输出层开始,delta 逐层向前传播,每经过一层就乘以该层权重的转置和激活函数的导数。

算法的完整流程分为四步:第一步,前向传播计算并缓存所有层的线性输出 z 和激活输出 a。第二步,计算输出层的误差信号,对于交叉熵加 Softmax 的组合,误差信号可以直接简化为 a[L] - y,即预测值减真实值。第三步,从倒数第二层开始逐层向前传播误差信号:delta[l] = (W[l+1]^T * delta[l+1]) 乘以激活函数的导数。第四步,用误差信号和前向传播缓存的激活值计算参数梯度:dW[l] = delta[l] * a[l-1]^T。

python
# 反向传播手动实现(教学版)
import numpy as np

def backward(x, y, params, cache):
    """两层网络的反向传播"""
    W1, b1, W2, b2 = params['W1'], params['b1'], params['W2'], params['b2']
    z1, a1, z2, a2 = cache['z1'], cache['a1'], cache['z2'], cache['a2']
    m = x.shape[1]  # 样本数
    
    # 输出层误差信号
    dz2 = a2 - y                         # 交叉熵+sigmoid简化形式
    dW2 = np.dot(dz2, a1.T) / m          # 输出层权重梯度
    db2 = np.sum(dz2, axis=1, keepdims=True) / m
    
    # 隐藏层误差信号(链式法则)
    da1 = np.dot(W2.T, dz2)              # 误差从输出层传到隐藏层
    dz1 = da1 * (z1 > 0)                 # ReLU导数: 正区间为1, 负区间为0
    dW1 = np.dot(dz1, x.T) / m           # 隐藏层权重梯度
    db1 = np.sum(dz1, axis=1, keepdims=True) / m
    
    return {'dW1': dW1, 'db1': db1, 'dW2': dW2, 'db2': db2}

这段代码揭示了反向传播的核心模式:先算输出层误差,再逐层向前传播误差信号,最后用误差信号和前向传播缓存的激活值计算参数梯度。注意隐藏层误差信号的计算——先通过下一层权重的转置把误差"反传"回来,再乘以激活函数的导数。这个模式无论多少层网络都一样。

7.5.4 前向传播与反向传播完整过程串讲

把前向传播和反向传播串在一起,就是一次完整的训练迭代。怕浪猫用最直白的方式走一遍完整流程,确保你彻底理解这个循环。

一次完整的训练迭代包括六个步骤。第一步,从数据加载器中取一个 batch 的数据。第二步,前向传播:数据从输入层经过各隐藏层到达输出层,每层做线性变换和激活函数变换,同时缓存所有中间值。第三步,用代价函数计算预测值与真实值之间的损失。第四步,反向传播:从输出层开始,利用链式法则逐层计算每个参数的梯度。第五步,优化器根据梯度更新参数。第六步,清空梯度缓存,准备下一次迭代。

一次训练迭代的完整流程:

1. 取一个 batch (x, y)
2. 前向传播: x -> Layer1 -> a1 -> Layer2 -> a2 -> y_hat
3. 计算损失: L = Loss(y_hat, y)
4. 反向传播: dL -> Layer2 -> dW2,db2 -> Layer1 -> dW1,db1
5. 参数更新: W -= lr * dW, b -= lr * db
6. 清空梯度, 准备下一轮
python
# 完整训练循环(PyTorch 版)
import torch.nn as nn
import torch.optim as optim

model = nn.Sequential(
    nn.Linear(784, 128),  # 输入层 -> 隐藏层1
    nn.ReLU(),
    nn.Linear(128, 64),   # 隐藏层1 -> 隐藏层2
    nn.ReLU(),
    nn.Linear(64, 10)     # 隐藏层2 -> 输出层
)

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

for epoch in range(10):
    for batch_x, batch_y in dataloader:
        output = model(batch_x)       # 1. 前向传播
        loss = criterion(output, batch_y)  # 2. 计算损失
        loss.backward()               # 3. 反向传播
        optimizer.step()              # 4. 参数更新
        optimizer.zero_grad()         # 5. 梯度清零
    print(f"Epoch {epoch}: loss={loss.item():.4f}")

这五行核心代码就是 PyTorch 训练的万能模板。不管模型是三层还是三百层,不管用的是 CNN 还是 Transformer,不管是在做预训练还是微调,训练循环的骨架都是这个。变化的只是模型定义、数据管道、损失函数和优化器的配置。

"深度学习的全部秘密就藏在 forward-loss-backward-step 这个循环里。理解了这个循环,你就理解了训练。剩下的都是工程细节。"

训练循环清单(建议收藏):

步骤PyTorch 代码作用说明
前向传播output = model(x)计算预测值自动缓存中间值
计算损失loss = criterion(output, y)衡量误差选择合适的损失函数
反向传播loss.backward()自动计算梯度基于链式法则
参数更新optimizer.step()用梯度更新参数优化器决定更新策略
梯度清零optimizer.zero_grad()防止梯度累积每次迭代必须调用

这个清单建议存下来。后面微调大模型的时候,虽然你调用的是 HuggingFace 的 Trainer API 或者 transformers 的训练流程,底层仍然是这五步。区别只在于模型结构更复杂、数据管道更精细、优化策略更多样,但核心循环不变。当训练出问题时,把流程拆解到这五步,问题定位就有的放矢了。

写在最后

这一章从生物神经元讲到 M-P 模型,从感知机讲到多层神经网络,从梯度下降讲到反向传播的完整流程,覆盖了深度学习最核心的基础概念。怕浪猫在这章没有堆砌太多数学公式,而是尽量用直觉解释和代码来辅助理解。因为这些概念在后面的实战章节会反复出现——微调时看到 loss 曲线你会想起偏差-方差权衡,调学习率时你会想起 Adam 的自适应机制,看 Transformer 源码时你会想起前向传播和反向传播的计算图。

如果你觉得这一章有些内容还没完全消化,没关系。先建立印象,后面实战中遇到问题时回来翻。深度学习的概念就是这样,第一次看觉得抽象,用多了就变直觉了。怕浪猫当年学反向传播的时候也是一脸懵,后来手动实现了一次完整的反向传播,又用 PyTorch 训了几个模型,突然就通了。

收藏引导: 这一章概念密集,前向传播与反向传播的流程图、激活函数选择表、训练循环清单都值得收藏。实战中遇到问题随时回来查。

互动引导: 梯度下降和反向传播,你第一次学的时候卡在哪里?是链式法则搞不懂,还是梯度消失想不通?评论区聊聊,怕浪猫帮你答疑。

追更引导: 基础概念搞定了,下一章怕浪猫带你深入训练参数的优化策略——学习率调度、warmup、余弦退火、混合精度训练,都是工业级训练的硬核技术。点个关注,别掉队。

系列进度 7/19

怕浪猫说: 深度学习不是玄学,是数学。但你不一定要把数学全搞懂才能用。理解原理,会用框架,剩下的在实践中补。下一章,咱们继续深入训练的细节。

热爱生活,喜好美食,追求未来!