深度学习四大基石:反向传播、CNN、Embedding 与注意力

中等 🟡AI 学习
12 个标签
预计阅读时间:89 分钟
深度学习反向传播CNNEmbedding注意力机制Transformer梯度消失批归一化残差连接多头注意力位置编码word2vec

深度学习四大基石:反向传播、CNN、Embedding 与注意力

如果把深度学习比作一座大厦,那么反向传播、卷积神经网络(CNN)、词嵌入(Embedding)和注意力机制就是支撑起这座大厦的四根承重柱。无论后来的模型多么庞大、多么"智能",追根溯源都绕不开这四个基础概念。本文尝试用尽量直白的语言,把这四块基石讲透彻——不追求覆盖所有数学细节,而是讲清楚"为什么这样设计"和"这样设计解决了什么问题"。

为了方便循序渐进地阅读,每一块基石我们都尽量按同一条脉络展开:先给出概念定义,再解释它为什么重要,接着讲背后的原理,然后配上尽量可运行的代码示例,再补充真实案例数据对比,最后总结常见坑最佳实践,每一节结尾都有一个小结帮你回顾。全文以 Python + numpy 为主,代码尽量做到可复制粘贴运行,注释也写得比较啰嗦,目的就是让你"看着代码理解原理"。

📌 先建立整体地图

在钻进细节之前,先花一分钟看清这四根柱子之间的关系,避免"只见树木不见森林":

反向传播回答的是"网络怎么学"这个问题——它是所有深度网络训练的通用引擎,无论 CNN 还是 Transformer,最终都靠反向传播来更新参数。
CNN回答的是"如何高效处理图像这类带空间结构的数据",它引入了卷积这个针对局部相关性的巧妙先验。
Embedding回答的是"如何把离散的符号(词、商品、用户)变成能做数学运算的稠密向量",是连接符号世界与数值世界的桥梁。
注意力 / Transformer回答的是"如何让模型在处理某个元素时动态地聚焦到最相关的其他元素",它几乎重塑了整个 NLP 乃至多模态领域。

这四者不是孤立的:现代大模型里,Embedding 把词变成向量,注意力机制在这些向量之间建立联系,整个网络靠反向传播训练,而 CNN 的很多设计思想(层层抽象、参数共享、残差连接)也被 Transformer 继承了下来。理解了这四块,你就掌握了读懂几乎所有主流深度学习论文的"通用语言"。

---

🧠 第一根柱子:多层网络与反向传播

概念:什么是神经网络与反向传播

神经网络是一种由大量简单计算单元(神经元)分层连接组成的函数拟合器;反向传播(Backpropagation)则是训练这个函数拟合器的核心算法——它利用微积分的链式法则,高效计算出损失函数对每一个参数的梯度,从而指导参数往"让预测更准"的方向调整。

一句话概括:神经网络负责"表达"复杂函数,反向传播负责"学出"这个函数的参数。

为什么重要:没有反向传播就没有深度学习

在反向传播被广泛应用之前,训练多层网络几乎是不可能完成的任务——参数动辄成千上万,靠人工推导每个参数的梯度不现实。反向传播提供了一套通用、自动、高效的梯度计算框架:无论网络多深、结构多复杂,只要每一层的局部运算可导,就能自动把梯度从输出层一路传回输入层。今天所有深度学习框架(PyTorch、TensorFlow、JAX)的"自动微分"(autograd)本质上都是反向传播的工程化实现。可以说,反向传播是深度学习从理论走向实用的分水岭。

原理一:单层网络的天花板——只会画直线

一个最简单的神经元(感知机)做的事情其实很朴素:把输入按权重加权求和,再套一个非线性函数,判断结果落在哪一边。几何上看,一个单层神经元本质上是在特征空间里画一条"分界线"(更高维时是一个超平面),凡是落在线一侧的判为类别 A,另一侧判为类别 B。

这带来一个著名的局限:单层网络只能解决线性可分的问题。历史上最典型的反例是异或(XOR)问题——四个点 (0,0)→0、(0,1)→1、(1,0)→1、(1,1)→0,无论怎么画一条直线,都不可能把两类点正确分开。这曾经在上世纪 60 年代末给感知机研究泼了一盆冷水,导致神经网络研究一度陷入低谷(这段历史常被称为"第一次 AI 寒冬"的导火索之一)。

我们可以用几行代码直观感受"单层线性模型搞不定 XOR":

pythonCode
import numpy as np

# XOR 数据集:输入两位,输出它们的异或
X = np.array([[0, 0],
              [0, 1],
              [1, 0],
              [1, 1]], dtype=float)
y = np.array([0, 1, 1, 0], dtype=float)

# 尝试用一个单层线性模型(逻辑回归)去拟合
def sigmoid(x):
    return 1 / (1 + np.exp(-x))

np.random.seed(0)
w = np.random.randn(2)   # 只有一条分界线的权重
b = 0.0
lr = 0.5

for step in range(5000):
    z = X @ w + b
    pred = sigmoid(z)
    error = pred - y
    # 梯度下降更新
    w -= lr * (X.T @ error) / len(y)
    b -= lr * error.mean()

pred = sigmoid(X @ w + b)
print("单层模型预测:", np.round(pred, 2))
# 输出大致是 [0.5 0.5 0.5 0.5],四个点全被压到0.5附近,完全分不开——
# 这就是"一条直线切不开XOR"的直接体现

原理二:多层带来的转机——逐层抽象

解决办法是"叠层"。如果在输入和输出之间插入一个隐藏层,网络就能先用第一层画出若干条直线,把空间切割成若干个区域,再用第二层把这些区域重新组合,就能拟合出任意复杂的非线性边界。这就是多层感知机(MLP)能解决 XOR 问题、并且理论上可以逼近任意连续函数(万能近似定理,Universal Approximation Theorem)的原因。

更重要的是,多层结构天然带来了"层层抽象"的能力。以图像识别为例:

第一层可能学会识别边缘、颜色斑块这类最原始的模式;
第二层在边缘的基础上组合出纹理、角点、简单形状;
第三层再组合出眼睛、轮子、门把手这类"部件";
更深的层则把部件组合成"这是一只猫"、"这是一辆车"这样的整体概念。

这种从低级特征到高级语义的层层抽象,正是深度学习"深度"二字的含义所在——层数越多,理论上能表达的抽象层级就越丰富。当然层数不是越多越好,层数一多就会带来梯度消失/爆炸、训练难度陡增等新问题,这也是后来 ResNet 残差连接等技术要解决的问题。

原理三:反向传播——网络怎么知道该怎么调整

网络结构解决了"能不能表达复杂函数"的问题,但还有一个问题:给定成千上万个参数(权重),网络怎么知道往哪个方向调整才能让预测更准?答案就是反向传播算法,本质是链式法则在多层复合函数上的巧妙应用。

整个过程可以拆成两个阶段:

前向传播(Forward Pass):输入数据从第一层开始,逐层做"加权求和 + 非线性激活"的运算,一路传到输出层,得到预测值。然后用损失函数(Loss Function)衡量预测值和真实标签之间的差距,得到一个标量的"误差"。

反向传播(Backward Pass):这是关键。我们希望知道"每一个权重对最终误差的贡献有多大",也就是误差对每个权重的偏导数(梯度)。由于网络是层层复合的函数,根据微积分的链式法则,输出层的误差可以一层一层往回"传":先算出误差对最后一层输出的梯度,再乘以最后一层对倒数第二层输出的梯度,依次往前传递,最终得到误差对每一层每一个权重的梯度。因为梯度是从输出往输入反向流动的,所以叫"反向传播"。

拿到梯度之后,配合梯度下降(Gradient Descent)之类的优化算法,把每个权重沿着"能让误差变小"的方向挪动一小步(步长由学习率控制)。重复"前向传播算误差 → 反向传播算梯度 → 更新权重"这个循环成千上万次,网络的预测就会越来越准。

代码示例:手写一个隐藏层的反向传播

下面用一段简化的 numpy 代码演示一个只有一个隐藏层的网络如何做反向传播(为了让链式法则的过程一目了然,激活函数用简单的 sigmoid):

pythonCode
import numpy as np

def sigmoid(x):
    return 1 / (1 + np.exp(-x))

def sigmoid_derivative(a):
    # a 是sigmoid的输出,导数可以直接用输出表示
    return a * (1 - a)

# 前向传播
def forward(X, W1, b1, W2, b2):
    z1 = X @ W1 + b1
    a1 = sigmoid(z1)          # 隐藏层输出
    z2 = a1 @ W2 + b2
    a2 = sigmoid(z2)          # 最终预测
    cache = (X, z1, a1, z2, a2)
    return a2, cache

# 反向传播:链式法则逐层往回传误差
def backward(y_true, W2, cache):
    X, z1, a1, z2, a2 = cache

    # 第一步:输出层的误差梯度(损失对a2的偏导 * a2对z2的偏导)
    d_loss_a2 = a2 - y_true                     # 均方误差简化后的梯度
    d_a2_z2 = sigmoid_derivative(a2)
    delta2 = d_loss_a2 * d_a2_z2                # 输出层的"误差信号"

    # 第二步:用delta2反推W2、b2的梯度
    dW2 = a1.T @ delta2
    db2 = delta2.sum(axis=0)

    # 第三步:把误差信号通过W2传回隐藏层(链式法则的关键一步)
    d_loss_a1 = delta2 @ W2.T
    d_a1_z1 = sigmoid_derivative(a1)
    delta1 = d_loss_a1 * d_a1_z1                # 隐藏层的"误差信号"

    # 第四步:反推W1、b1的梯度
    dW1 = X.T @ delta1
    db1 = delta1.sum(axis=0)

    return dW1, db1, dW2, db2

# 训练循环:不断前向算误差、反向算梯度、更新参数
def train_step(X, y_true, W1, b1, W2, b2, lr=0.1):
    a2, cache = forward(X, W1, b1, W2, b2)
    dW1, db1, dW2, db2 = backward(y_true, W2, cache)

    W1 -= lr * dW1
    b1 -= lr * db1
    W2 -= lr * dW2
    b2 -= lr * db2
    return W1, b1, W2, b2

可以看到,backward 函数里的核心思路就是:先算出"输出层错在哪、错多少"(delta2),再顺着连接权重 W2 把这份"责任"分摊到隐藏层的每个神经元上(delta1),层数再多也是同样的套路不断往前传递。这正是"链式法则"在工程上的具体体现——不需要对每一层单独推导公式,只需要知道"上一层的误差信号 × 连接权重 × 本层激活函数导数"就能算出这一层的误差信号,一路传到输入层为止。

代码示例:用两层网络真正解决 XOR

光讲原理还不够,我们把上面的两层网络跑起来,看它是不是真能学会前面单层模型搞不定的 XOR:

pythonCode
import numpy as np

def sigmoid(x):
    return 1 / (1 + np.exp(-x))

def sigmoid_derivative(a):
    return a * (1 - a)

X = np.array([[0, 0], [0, 1], [1, 0], [1, 1]], dtype=float)
y = np.array([[0], [1], [1], [0]], dtype=float)

np.random.seed(42)
# 隐藏层4个神经元,输出层1个神经元
W1 = np.random.randn(2, 4)
b1 = np.zeros((1, 4))
W2 = np.random.randn(4, 1)
b2 = np.zeros((1, 1))

lr = 0.5
for epoch in range(10000):
    # 前向
    a1 = sigmoid(X @ W1 + b1)
    a2 = sigmoid(a1 @ W2 + b2)

    # 反向
    delta2 = (a2 - y) * sigmoid_derivative(a2)
    dW2 = a1.T @ delta2
    db2 = delta2.sum(axis=0, keepdims=True)

    delta1 = (delta2 @ W2.T) * sigmoid_derivative(a1)
    dW1 = X.T @ delta1
    db1 = delta1.sum(axis=0, keepdims=True)

    # 更新
    W2 -= lr * dW2
    b2 -= lr * db2
    W1 -= lr * dW1
    b1 -= lr * db1

pred = sigmoid(sigmoid(X @ W1 + b1) @ W2 + b2)
print("两层网络预测:", np.round(pred.ravel(), 2))
# 输出大致是 [0.02 0.98 0.98 0.02],四个点被完美分开——
# 隐藏层让网络学会了非线性的分界,这就是"深度"的威力

原理四:梯度消失——深层网络训练的第一个大坑

理解了反向传播机制,也就能理解为什么深层网络训练早期会遇到梯度消失问题:如果每一层的导数都小于 1,经过很多层连续相乘后梯度会指数级衰减,导致靠近输入层的参数几乎学不到东西。sigmoid 函数的导数最大值只有 0.25,堆叠十几层之后,回传到最前面的梯度可能已经小到 0.25 的十几次方,接近于零。

我们可以做一个对比实验,直观看到 sigmoid 和 ReLU 在深层网络里梯度回传的巨大差异:

pythonCode
import numpy as np

def sigmoid(x):
    return 1 / (1 + np.exp(-x))

def sigmoid_grad(x):
    s = sigmoid(x)
    return s * (1 - s)

def relu(x):
    return np.maximum(0, x)

def relu_grad(x):
    return (x > 0).astype(float)

np.random.seed(0)
depth = 20            # 20层深的网络
x = np.random.randn(1, 64)

# 用sigmoid做20层前向+反向,观察回传到第一层的梯度幅度
def measure_gradient(activation, activation_grad, scale=1.0):
    a = x.copy()
    caches = []
    for _ in range(depth):
        W = np.random.randn(a.shape[1], 64) * scale
        z = a @ W
        caches.append((a, W, z))
        a = activation(z)
    # 从输出往回传一个全1的梯度信号
    grad = np.ones_like(a)
    first_layer_grad_norm = None
    for a_prev, W, z in reversed(caches):
        grad = grad * activation_grad(z)   # 乘上本层激活导数
        grad = grad @ W.T                  # 传回上一层
        first_layer_grad_norm = np.linalg.norm(grad)
    return first_layer_grad_norm

np.random.seed(0)
g_sigmoid = measure_gradient(sigmoid, sigmoid_grad, scale=1.0)
np.random.seed(0)
g_relu = measure_gradient(relu, relu_grad, scale=0.5)

print(f"20层后 sigmoid 回传梯度范数: {g_sigmoid:.2e}")   # 极小,梯度几乎消失
print(f"20层后 relu    回传梯度范数: {g_relu:.2e}")      # 明显大得多
# 典型结果:sigmoid 是 1e-8 级别,relu 能保持在 1e-1 到 1e0 级别

这个实验直观解释了为什么 2012 年后 ReLU 迅速取代 sigmoid/tanh 成为隐藏层的默认激活函数:ReLU 在正区间导数恒为 1,梯度回传时不会被反复"打折扣",大大缓解了梯度消失。当然 ReLU 也有自己的问题("神经元死亡"——一旦输入长期为负,梯度恒为 0 再也更新不了),于是后来又衍生出 LeakyReLU、PReLU、GELU、Swish 等改进版本。

原理五:批归一化——让训练更稳更快

批归一化(Batch Normalization,BN)是另一个对付深层网络训练难题的利器。它的思路很简单:在每一层激活之前,先把这一批数据的每个特征维度归一化成"均值 0、方差 1",再用两个可学习的参数(缩放 gamma 和平移 beta)把分布调整回网络需要的样子。这样做能显著缓解"内部协变量偏移"(每层输入分布随训练不断变化导致的训练不稳定),让我们可以用更大的学习率、更快地收敛。

pythonCode
import numpy as np

def batch_norm_forward(x, gamma, beta, eps=1e-5):
    """
    x: 形状 (N, D),N是batch大小,D是特征维度
    对每一个特征维度,在batch方向上做归一化
    """
    mu = x.mean(axis=0)                     # 每个特征的均值 (D,)
    var = x.var(axis=0)                     # 每个特征的方差 (D,)
    x_hat = (x - mu) / np.sqrt(var + eps)   # 归一化到均值0方差1
    out = gamma * x_hat + beta              # 再缩放平移,恢复表达能力
    cache = (x, x_hat, mu, var, gamma, eps)
    return out, cache

# 验证:归一化后每列均值接近0、方差接近1
np.random.seed(0)
x = np.random.randn(128, 16) * 5 + 3       # 一批分布很偏的数据
gamma = np.ones(16)
beta = np.zeros(16)
out, _ = batch_norm_forward(x, gamma, beta)
print("归一化后均值:", np.round(out.mean(axis=0)[:4], 3))   # 接近 0
print("归一化后方差:", np.round(out.var(axis=0)[:4], 3))    # 接近 1

批归一化之外,还有层归一化(Layer Normalization,LN)——它在特征维度而不是 batch 维度上做归一化。Transformer 里用的正是 LN,因为它不依赖 batch 大小、对变长序列更友好,这也是后面讲 Transformer 时会再提到的一个细节。

原理六:残差连接——让网络可以真正"深"起来

即便有了 ReLU 和 BN,当网络深到几十上百层时,还是会出现一个反直觉的现象:更深的网络训练误差反而更高(注意是训练误差,不是过拟合导致的测试误差)。这说明深层网络"更难优化"了。2015 年何恺明等人提出的 残差连接(Residual Connection) 优雅地解决了这个问题:与其让每一层直接学习目标映射 H(x),不如让它学习"残差" F(x) = H(x) - x,然后输出 F(x) + x。

这个"+x"的捷径(skip connection)意义重大:反向传播时,梯度可以顺着这条捷径直接跳过中间的层往回传,相当于给梯度开了一条"高速公路",从根本上缓解了梯度消失,让上百层甚至上千层的网络也能顺利训练。

pythonCode
import numpy as np

def relu(x):
    return np.maximum(0, x)

def residual_block_forward(x, W1, W2):
    """
    一个最简化的残差块:out = relu(x + F(x))
    F(x) 是两层变换,x 通过捷径直接加到输出上
    """
    h = relu(x @ W1)      # 第一层变换
    f = h @ W2            # 第二层变换(残差 F(x))
    out = relu(x + f)     # 关键:把输入x直接加回来
    return out

# 对比:观察残差连接如何让深层梯度不衰减
np.random.seed(0)
d = 64
x = np.random.randn(1, d)

def deep_forward_backward(use_residual, depth=30):
    a = x.copy()
    caches = []
    for _ in range(depth):
        W = np.random.randn(d, d) * 0.1
        z = a @ W
        h = relu(z)
        out = a + h if use_residual else h   # 有无捷径的区别
        caches.append((a, W, z, use_residual))
        a = out
    grad = np.ones_like(a)
    for a_prev, W, z, res in reversed(caches):
        g = grad * (z > 0)                    # relu导数
        g = g @ W.T
        grad = g + grad if res else g          # 捷径让梯度多一条直达路径
    return np.linalg.norm(grad)

np.random.seed(1)
g_plain = deep_forward_backward(False)
np.random.seed(1)
g_res = deep_forward_backward(True)
print(f"30层无残差 梯度范数: {g_plain:.2e}")
print(f"30层有残差 梯度范数: {g_res:.2e}")   # 明显更大、更健康

残差连接的思想影响极其深远——从 ResNet 到 Transformer,几乎所有现代深层架构都离不开它。可以说,ReLU、批/层归一化、残差连接这"三件套",共同奠定了训练超深网络的工程基础。

真实案例:从 LeNet 到 ResNet 的深度竞赛

反向传播这套机制配合上面这些技巧,直接推动了网络越来越深、能力越来越强。下面这张表对比了深度学习史上几个里程碑网络的关键数据:

| 网络 | 年份 | 层数 | 参数量 | ImageNet Top-5 错误率 | 关键创新 |

| --- | --- | --- | --- | --- | --- |

| LeNet-5 | 1998 | 7 | 约6万 | 无(用于手写数字) | 卷积+池化雏形 |

| AlexNet | 2012 | 8 | 约6000万 | 15.3% | ReLU、Dropout、GPU训练 |

| VGG-16 | 2014 | 16 | 约1.38亿 | 7.3% | 统一3×3小卷积核堆叠 |

| GoogLeNet | 2014 | 22 | 约700万 | 6.7% | Inception多尺度并行 |

| ResNet-152 | 2015 | 152 | 约6000万 | 3.6% | 残差连接 |

可以看到,正是残差连接让层数从 22 一举跃升到 152,而错误率还在持续下降——AlexNet 在 2012 年把 ImageNet Top-5 错误率从传统方法的 26% 直接砍到 15.3%,被公认为深度学习复兴的标志性事件;而 ResNet 的 3.6% 已经低于人类专家的约 5% 水平。

常见坑

学习率没调好:太大导致损失震荡甚至发散(NaN),太小导致收敛极慢。这是新手最常踩的坑,建议从 1e-3 附近开始试,配合学习率衰减。
忘记做数据归一化:输入特征量纲差异大(比如一维是 0-1、另一维是 0-10000),会让损失曲面极度扭曲,训练难以收敛。
权重初始化不当:全 0 初始化会让所有神经元学到一样的东西(对称性问题);初始化过大过小都会引发梯度爆炸/消失。用 Xavier / He 初始化。
深层网络不加残差/归一化:直接堆几十层的"朴素"网络往往训不动,前面的实验已经说明原因。

最佳实践

隐藏层激活优先用 ReLU 家族(ReLU / GELU),配合 He 初始化。
深层网络标配"残差连接 + 归一化(BN 或 LN)"。
用 Adam / AdamW 这类自适应优化器起步,比朴素 SGD 更省心。
训练时监控梯度范数和损失曲线,出现 NaN 或损失不降先怀疑学习率和数据预处理。

小结

反向传播用链式法则把"误差"从输出层一路分摊回每个参数,是深度学习的通用训练引擎。多层结构提供了逐层抽象、逼近任意函数的表达能力,但也带来了梯度消失、训练不稳、深层难优化等问题,而 ReLU、批/层归一化、残差连接这三件套正是为解决这些问题而生。理解这一节,你就掌握了所有深度网络"如何学习"的共同底座。

---

👁️ 第二根柱子:计算机如何"看"——卷积神经网络 CNN

概念:什么是卷积神经网络

卷积神经网络(Convolutional Neural Network,CNN)是一类专门处理具有网格状拓扑结构数据(最典型的就是图像)的神经网络。它的核心构件是卷积层——用一组小小的、参数共享的"滤波器"在输入上滑动扫描,自动提取局部特征。CNN 通常还搭配池化层、归一化层和全连接层,构成完整的识别流水线。

为什么重要:把图像交给全连接网络是灾难

一张 224×224 的彩色图片有 224×224×3 约 15 万个像素值。如果直接把它拉平成一个向量丢进全连接网络,第一层如果有 1000 个神经元,仅这一层就有 1.5 亿个参数,不但训练代价惊人,还完全忽略了图像最重要的一个特性——空间局部性:一个像素和它周围的像素密切相关,和图片另一角的像素基本无关。全连接网络对空间位置一视同仁,既浪费参数,又学不到"局部模式"这种关键先验。卷积神经网络正是针对这个问题设计的,它用两个关键思想大幅降低了参数量:局部连接(每个神经元只看一小块区域)和权重共享(同一个滤波器扫遍全图)。

原理一:卷积核——一个滑动的"特征探测器"

卷积操作的核心是一个很小的矩阵,叫卷积核(Kernel / Filter),常见尺寸是 3×3 或 5×5。这个小矩阵会在整张图片上滑动,每滑到一个位置,就把卷积核里的数值和图片上对应区域的像素值做逐元素相乘再求和,得到一个输出值。滑完整张图,就得到一张新的"特征图"(Feature Map)。

关键的巧妙之处在于:同一个卷积核在整张图片上是共享参数的(权重共享)。这意味着:如果一个卷积核学会了"识别垂直边缘",它在图片左上角能识别垂直边缘,挪到右下角照样能识别——不需要为每个位置单独学一套参数。这既大幅减少了参数量,也符合"同一种视觉模式可能出现在图片任何位置"的常识(平移不变性)。

一个网络里通常会有很多个卷积核并行工作,各自学习探测不同的模式:有的对垂直边缘敏感,有的对水平边缘敏感,有的对某种颜色过渡敏感……这些卷积核的输出堆叠在一起,就构成了这一层的输出。

代码示例:手写一个二维卷积

下面用 numpy 手写一个最基础的二维卷积操作,帮助建立直观理解:

pythonCode
import numpy as np

def conv2d(image, kernel, stride=1):
    """
    对单通道灰度图做一次简单卷积(不做padding)
    image: 形状 (H, W) 的二维数组
    kernel: 形状 (kh, kw) 的卷积核
    """
    H, W = image.shape
    kh, kw = kernel.shape
    out_h = (H - kh) // stride + 1
    out_w = (W - kw) // stride + 1
    output = np.zeros((out_h, out_w))

    for i in range(0, out_h):
        for j in range(0, out_w):
            row_start = i * stride
            col_start = j * stride
            # 取出当前卷积核覆盖的图像区域
            region = image[row_start:row_start + kh, col_start:col_start + kw]
            # 逐元素相乘再求和,这就是一次"卷积"
            output[i, j] = np.sum(region * kernel)
    return output

# 一个经典的垂直边缘检测卷积核(Sobel算子的简化版)
vertical_edge_kernel = np.array([
    [-1, 0, 1],
    [-1, 0, 1],
    [-1, 0, 1]
])

# 造一张左黑右白的图,卷积后应该在中间边界处产生强响应
img = np.zeros((6, 6))
img[:, 3:] = 1
print("边缘检测结果:\n", conv2d(img, vertical_edge_kernel))
# 会看到中间列出现明显的非零值,正好对应黑白交界处

把卷积核换成不同的数值,就能探测出不同的模式——这也是为什么说 CNN 的训练过程,本质上就是在自动"学习"一套最适合当前任务的卷积核参数。

原理二:padding 与 stride——控制输出尺寸

上面的 conv2d 每卷积一次输出就会缩小一圈(6×6 变成 4×4),如果堆很多层,特征图很快就缩没了。解决办法是 padding(在图像四周补零),让输出尺寸保持不变("same"卷积)。而 stride(步长)则相反,它控制卷积核每次滑动的距离,stride 越大输出越小,常用来做下采样。

pythonCode
import numpy as np

def conv2d_pad(image, kernel, stride=1, padding=0):
    """支持 padding 和 stride 的卷积"""
    if padding > 0:
        image = np.pad(image, padding, mode='constant', constant_values=0)
    H, W = image.shape
    kh, kw = kernel.shape
    out_h = (H - kh) // stride + 1
    out_w = (W - kw) // stride + 1
    output = np.zeros((out_h, out_w))
    for i in range(out_h):
        for j in range(out_w):
            r, c = i * stride, j * stride
            output[i, j] = np.sum(image[r:r+kh, c:c+kw] * kernel)
    return output

img = np.random.randn(5, 5)
k = np.ones((3, 3)) / 9.0    # 3x3均值模糊核
same = conv2d_pad(img, k, stride=1, padding=1)  # padding=1保持5x5
print("输入尺寸:", img.shape, " same卷积输出尺寸:", same.shape)
# 输入 (5,5) -> 输出 (5,5),尺寸被保住了

输出尺寸的通用公式是:out = (H + 2·padding − kernel) / stride + 1。记住这个公式,设计网络时就能精确控制每一层特征图的大小。

原理三:im2col——卷积为什么能算得飞快

朴素的多重 for 循环卷积在真实网络里慢得无法接受。工业级框架用一个叫 im2col 的技巧把卷积转化成一次大矩阵乘法:把每个卷积窗口覆盖的区域"拉直"成一列,所有窗口拼成一个大矩阵,再和展平的卷积核做一次矩阵乘法。矩阵乘法在 GPU 上有极致优化,于是卷积也就飞快了。

pythonCode
import numpy as np

def im2col(image, kh, kw, stride=1):
    """把每个卷积窗口展平成一行,堆成矩阵"""
    H, W = image.shape
    out_h = (H - kh) // stride + 1
    out_w = (W - kw) // stride + 1
    cols = np.zeros((out_h * out_w, kh * kw))
    idx = 0
    for i in range(out_h):
        for j in range(out_w):
            r, c = i * stride, j * stride
            patch = image[r:r+kh, c:c+kw].ravel()  # 窗口拉直成一行
            cols[idx] = patch
            idx += 1
    return cols, out_h, out_w

def conv2d_im2col(image, kernel, stride=1):
    kh, kw = kernel.shape
    cols, out_h, out_w = im2col(image, kh, kw, stride)
    # 卷积核也拉直,卷积变成一次矩阵乘法
    out = cols @ kernel.ravel()
    return out.reshape(out_h, out_w)

# 验证 im2col 版本和朴素版本结果一致
img = np.random.randn(8, 8)
k = np.random.randn(3, 3)
def conv2d_naive(image, kernel, stride=1):
    H, W = image.shape; kh, kw = kernel.shape
    oh, ow = (H-kh)//stride+1, (W-kw)//stride+1
    o = np.zeros((oh, ow))
    for i in range(oh):
        for j in range(ow):
            o[i, j] = np.sum(image[i*stride:i*stride+kh, j*stride:j*stride+kw] * kernel)
    return o

print("两种实现是否一致:",
      np.allclose(conv2d_naive(img, k), conv2d_im2col(img, k)))
# 输出 True:结果完全相同,但矩阵乘法版本可以被BLAS/GPU大幅加速

理解 im2col 有助于你明白:为什么深度学习框架把几乎一切运算都尽量表达成矩阵乘法——因为矩阵乘法是硬件优化得最好的运算。

原理四:池化——压缩信息、扩大感受野

卷积层之间通常会插入池化层(Pooling),最常见的是最大池化(Max Pooling):把特征图切成一个个小块(比如 2×2),每块只保留最大值,其余丢弃。池化的作用主要有两个:

1.降维压缩:特征图尺寸迅速缩小,大幅减少后续计算量和参数量;
2.增强鲁棒性:只保留局部区域里响应最强的特征,让网络对目标的小幅平移、形变不那么敏感——猫的耳朵稍微挪动一点位置,最大池化后的结果基本不受影响。

除了最大池化,还有平均池化(Average Pooling,保留区域平均值,更平滑)和全局平均池化(Global Average Pooling,把整张特征图压成一个数,现代网络常用它替代最后的全连接层以减少参数)。下面把几种池化都实现一遍做对比:

pythonCode
import numpy as np

def max_pool2d(feature_map, pool_size=2, stride=2):
    H, W = feature_map.shape
    out_h = (H - pool_size) // stride + 1
    out_w = (W - pool_size) // stride + 1
    output = np.zeros((out_h, out_w))
    for i in range(out_h):
        for j in range(out_w):
            r, c = i * stride, j * stride
            region = feature_map[r:r+pool_size, c:c+pool_size]
            output[i, j] = np.max(region)     # 保留最大值
    return output

def avg_pool2d(feature_map, pool_size=2, stride=2):
    H, W = feature_map.shape
    out_h = (H - pool_size) // stride + 1
    out_w = (W - pool_size) // stride + 1
    output = np.zeros((out_h, out_w))
    for i in range(out_h):
        for j in range(out_w):
            r, c = i * stride, j * stride
            region = feature_map[r:r+pool_size, c:c+pool_size]
            output[i, j] = np.mean(region)    # 保留平均值
    return output

def global_avg_pool(feature_map):
    return feature_map.mean()                 # 整张图压成一个数

fm = np.arange(16, dtype=float).reshape(4, 4)
print("原特征图:\n", fm)
print("最大池化:\n", max_pool2d(fm))
print("平均池化:\n", avg_pool2d(fm))
print("全局平均池化:", global_avg_pool(fm))

原理五:感受野——一层看到的"视野"有多大

感受野(Receptive Field)指的是输出特征图上一个点,对应到原始输入图像上,是由哪一块区域的像素共同决定的。第一层卷积核只能看到 3×3 这样的局部区域,感受野很小;但随着层数加深,后面每一层的一个点都是综合了前面很多个"局部区域"的结果,感受野会像滚雪球一样越滚越大。到了网络的最深层,一个输出点的感受野可能已经覆盖了输入图像的绝大部分甚至全部区域——这就是为什么深层网络能够"看到"物体的整体结构,而不仅仅是局部纹理。池化层由于会压缩尺寸,也是感受野快速扩大的重要推手。

有一个简单的估算规律:连续堆叠 n 个 3×3 卷积(stride=1),感受野大约是 (2n+1)×(2n+1)。这也解释了 VGG 的一个经典设计洞见:两个 3×3 卷积的感受野等价于一个 5×5 卷积,三个 3×3 等价于一个 7×7,但参数量更少、非线性更多,所以用小卷积核堆叠反而更划算。

pythonCode
def receptive_field(kernels, strides):
    """
    根据每层的卷积核大小和步长,逐层递推感受野
    kernels: 每层卷积核大小列表,比如 [3,3,3]
    strides: 每层步长列表,比如 [1,1,1]
    """
    rf = 1            # 初始感受野
    jump = 1          # 相邻输出点在原图上的间隔
    for k, s in zip(kernels, strides):
        rf = rf + (k - 1) * jump
        jump = jump * s
    return rf

print("三个3x3卷积堆叠的感受野:", receptive_field([3, 3, 3], [1, 1, 1]))
# 输出 7,正好等价于一个7x7卷积的视野
print("加入一次步长2的池化后:", receptive_field([3, 2, 3], [1, 2, 1]))

从边缘到物体:层层递进的特征提取

和第一节讲的"层层抽象"呼应,CNN 的多层结构让它天然形成一条从简单到复杂的特征提取流水线:

浅层卷积核(靠近输入):学到的是边缘、颜色斑块、简单纹理这类"低级特征";
中层卷积核:把边缘和纹理组合成更复杂的图案,比如格纹、圆弧、简单形状;
深层卷积核:进一步组合出眼睛、车轮、窗户这类"物体部件";
最深层:把部件组合成完整物体的语义表示,比如"这是一张猫脸"。

这种层级结构不是人为设计出来的规则,而是网络在训练数据上自动学出来的——这也是深度学习相比传统人工设计特征(如 SIFT、HOG)最大的优势:特征本身也是学出来的。

真实案例:ImageNet 与 AlexNet 的历史时刻

CNN 真正封神是在 2012 年的 ImageNet 大规模视觉识别挑战赛(ILSVRC)上。这个竞赛的数据集有 1000 个类别、约 120 万张训练图片。在此之前,冠军队伍普遍用"人工设计特征 + SVM"的传统流水线,Top-5 错误率长期卡在 26% 左右。2012 年,Hinton 团队的 AlexNet 横空出世,用一个 8 层的卷积网络(配合 ReLU、Dropout、双 GPU 训练、数据增强)把错误率一举压到 15.3%,领先第二名近 10 个百分点。这一战直接引爆了深度学习浪潮,此后 ImageNet 冠军清一色是 CNN。

下面这张表展示了 CNN 家族在处理不同任务时的典型规模,帮助你建立"参数量和算力"的量级直觉:

| 模型 | 输入尺寸 | 参数量 | 单张推理算力(FLOPs) | 典型应用 |

| --- | --- | --- | --- | --- |

| LeNet-5 | 32×32 | 约6万 | 约60万 | 手写数字识别 |

| AlexNet | 227×227 | 约6000万 | 约7.2亿 | 通用图像分类 |

| VGG-16 | 224×224 | 约1.38亿 | 约155亿 | 分类/特征提取骨干 |

| ResNet-50 | 224×224 | 约2500万 | 约41亿 | 分类/检测骨干 |

| MobileNetV2 | 224×224 | 约350万 | 约3亿 | 移动端轻量部署 |

注意 MobileNet:它用"深度可分离卷积"把参数和算力砍到 VGG 的几十分之一,专门为手机等边缘设备设计,说明 CNN 的演进不只是"越大越好",在算力受限场景下"如何更省"同样是重要方向。

常见坑

忘记 padding 导致特征图缩太快:深层网络若层层都不 padding,特征图很快缩到 1×1,后面没法再卷。
通道数与卷积核形状对不上:真实卷积核是四维的(输出通道 × 输入通道 × 高 × 宽),初学者常忽略输入通道维度。
池化用得过猛:过度下采样会丢失细节信息,对需要精确定位的任务(如分割、检测)尤其不利。
只在小数据集上训超大 CNN:参数远多于样本极易过拟合,需要数据增强、正则化或迁移学习(用 ImageNet 预训练权重)。

最佳实践

优先用小卷积核(3×3)堆叠,而非单个大卷积核,参数更省、非线性更足。
用批归一化稳定训练、用残差连接支持更深网络。
数据不多时,直接拿 ImageNet 预训练模型做迁移学习,往往比从头训练效果好得多。
现代网络多用全局平均池化替代末端全连接层,大幅减少参数、降低过拟合风险。

小结

CNN 用局部连接和权重共享两大先验,专门高效地处理图像这类带空间结构的数据。卷积核是可学习的特征探测器,池化负责压缩信息、增强鲁棒性,感受野随层数滚雪球式扩大,最终实现从边缘到物体的层层抽象。从 AlexNet 到 ResNet 再到 MobileNet,CNN 既在追求更高精度,也在探索更高效率,至今仍是视觉领域的重要骨干。

---

🔤 第三根柱子:词变成数字——Embedding 与向量空间

概念:什么是 Embedding

Embedding(嵌入 / 词向量)是把离散的符号(词、字、商品 ID、用户 ID 等)映射成低维稠密实数向量的技术,并且让这个向量空间的几何结构(距离、方向)能够反映符号之间的语义或关联关系。对文本而言,它就是"词嵌入"(Word Embedding)。

为什么重要:神经网络只认识数字

神经网络本质上是一堆矩阵乘法和非线性函数的组合,它只认识数字,不认识"苹果""国王"这样的文字符号。最直接的想法是给词表里每个词分配一个独一无二的编号,再用 One-Hot 编码——一个词对应一个只有一位是 1、其余全是 0 的超长向量。但这样做有两个致命问题:

1.维度灾难:词表通常有几万到几十万个词,每个词的向量就有几万到几十万维,极其稀疏、浪费空间;
2.丢失语义关系:在 One-Hot 编码下,"猫"和"狗"的向量距离,与"猫"和"汽车"的向量距离完全一样——因为任意两个不同词的 One-Hot 向量都是正交的,模型完全看不出"猫和狗都是动物"这种相似性。

Embedding 就是为了解决这个问题而生的:把每个词映射成一个低维(通常是几十到几百维)的稠密向量,并且让这个向量空间里的几何距离能够反映词与词之间的语义关系。它是连接"离散符号世界"和"连续数值世界"的桥梁,几乎所有处理文本、推荐、搜索的深度模型第一步都是 Embedding。

代码示例:One-Hot 到 Embedding 的对比

先直观看看 One-Hot 有多浪费、Embedding 有多紧凑:

pythonCode
import numpy as np

vocab = ["猫", "狗", "汽车", "飞机"]
vocab_size = len(vocab)
word2id = {w: i for i, w in enumerate(vocab)}

# One-Hot:每个词一个超稀疏向量,维度=词表大小
def one_hot(word):
    v = np.zeros(vocab_size)
    v[word2id[word]] = 1
    return v

print("猫的One-Hot:", one_hot("猫"))     # [1 0 0 0]
print("狗的One-Hot:", one_hot("狗"))     # [0 1 0 0]
# 任意两个One-Hot点积都是0(正交),看不出"猫和狗更相似"

# Embedding:一张查找表,每个词对应一个低维稠密向量
embedding_dim = 3
np.random.seed(0)
embedding_table = np.random.randn(vocab_size, embedding_dim)

def embed(word):
    return embedding_table[word2id[word]]   # 直接查表

print("猫的Embedding:", np.round(embed("猫"), 2))
# 训练之后,语义相近的词向量会靠得更近,这是One-Hot永远做不到的

Embedding 本质上就是一张可学习的查找表(矩阵),训练开始时随机初始化,随着网络训练,语义相近的词会逐渐在向量空间里聚拢。

原理:分布式语义假设——"观其伴,知其义"

Embedding 是怎么学出来的?背后依赖一个语言学假设,叫分布式语义假设(Distributional Hypothesis),通俗说就是:"观其伴,知其义"——一个词的含义,很大程度上由经常出现在它周围的词决定。如果两个词经常出现在相似的上下文中,那么它们的含义大概率是相似的。

举个例子:"我今天喝了一杯___"这句话里,空格处填"咖啡""茶""果汁"都很自然,填"石头""椅子"就很突兀。如果在海量文本中统计,"咖啡""茶""果汁"这几个词出现的上下文高度重合,那么一个训练目标是"根据上下文预测中心词"(或反过来"根据中心词预测上下文")的模型,自然会把这几个词的向量学得彼此靠近——因为只有这样,模型才能用相近的向量在相近的上下文里做出相近的预测。这正是经典的 word2vec(Skip-gram / CBOW)背后的训练思路。

代码示例:Skip-gram 的核心训练步骤

用一段简化的代码来表达 Skip-gram 的核心思想(根据中心词预测周围词),关键在于"正样本拉近、负样本推远":

pythonCode
import numpy as np

def sigmoid(x):
    return 1 / (1 + np.exp(-x))

def skipgram_training_step(center_word_id, context_word_id,
                            center_embeddings, context_embeddings,
                            vocab_size, lr=0.01):
    """
    center_embeddings: 形状 (vocab_size, dim),每个词的"中心词"向量
    context_embeddings: 形状 (vocab_size, dim),每个词的"上下文词"向量
    目标:让 中心词向量 与 真实上下文词向量 的点积尽量大,
         与随机采样的"负样本"词向量的点积尽量小。
    """
    v_center = center_embeddings[center_word_id]
    v_context = context_embeddings[context_word_id]

    # 正样本:真实共现的词对,希望相似度(点积)越大越好
    pos_score = sigmoid(np.dot(v_center, v_context))
    grad_pos = (pos_score - 1) * v_context

    # 负样本:随机采样几个"不该出现在这个上下文里"的词
    negative_ids = np.random.choice(vocab_size, size=5)
    grad_neg_total = np.zeros_like(v_center)
    for neg_id in negative_ids:
        v_neg = context_embeddings[neg_id]
        neg_score = sigmoid(np.dot(v_center, v_neg))
        grad_neg_total += neg_score * v_neg
        context_embeddings[neg_id] -= lr * neg_score * v_center

    # 用梯度更新中心词向量和上下文词向量(简化的梯度下降)
    grad_center = grad_pos + grad_neg_total
    center_embeddings[center_word_id] -= lr * grad_center
    context_embeddings[context_word_id] -= lr * (pos_score - 1) * v_center

    return center_embeddings, context_embeddings

在数以亿计的这种"正样本拉近、负样本推远"的调整之后,词向量空间会自动"沉淀"出丰富的语义结构——同义词、近义词会聚集在相近的区域,不同词性、不同语义类别的词会分布在空间的不同角落。这里用到的"负采样"(Negative Sampling)是 word2vec 的一大工程亮点:完整 softmax 要对整个词表算归一化,代价高昂,而负采样每次只随机挑几个负样本来对比,把计算量从"和全词表比"降到"和几个词比",训练速度提升了几个数量级。

代码示例:一个能跑通的迷你 Skip-gram

把上面的单步拼成一个完整的小训练循环,在一个玩具语料上看它能不能学出"语义相近的词向量靠得近":

pythonCode
import numpy as np

# 玩具语料:两类主题的句子(饮品 / 交通)
corpus = [
    "我 喝 咖啡", "我 喝 茶", "我 喝 果汁",
    "他 开 汽车", "他 开 飞机", "他 开 火车",
]
tokens = [w for s in corpus for w in s.split()]
vocab = sorted(set(tokens))
word2id = {w: i for i, w in enumerate(vocab)}
V, dim = len(vocab), 8

np.random.seed(1)
center_emb = np.random.randn(V, dim) * 0.1
context_emb = np.random.randn(V, dim) * 0.1

def sigmoid(x):
    return 1 / (1 + np.exp(-x))

# 构造(中心词, 上下文词)训练对,窗口大小1
pairs = []
for s in corpus:
    ws = s.split()
    for i, w in enumerate(ws):
        for j in (i - 1, i + 1):
            if 0 <= j < len(ws):
                pairs.append((word2id[w], word2id[ws[j]]))

lr = 0.05
for epoch in range(3000):
    for c, ctx in pairs:
        vc, vo = center_emb[c], context_emb[ctx]
        # 正样本梯度
        g = (sigmoid(vc @ vo) - 1)
        center_emb[c] -= lr * g * vo
        context_emb[ctx] -= lr * g * vc
        # 负采样
        for neg in np.random.choice(V, 3):
            vn = context_emb[neg]
            gn = sigmoid(vc @ vn)
            center_emb[c] -= lr * gn * vn
            context_emb[neg] -= lr * gn * vc

def cos(a, b):
    return a @ b / (np.linalg.norm(a) * np.linalg.norm(b) + 1e-9)

print("咖啡-茶 相似度:", round(cos(center_emb[word2id["咖啡"]], center_emb[word2id["茶"]]), 3))
print("咖啡-汽车 相似度:", round(cos(center_emb[word2id["咖啡"]], center_emb[word2id["汽车"]]), 3))
# 训练后通常会看到 咖啡-茶 的相似度明显高于 咖啡-汽车

代码示例:余弦相似度与最近邻检索

拿到词向量后,最常见的操作就是"找相似"——用余弦相似度衡量两个向量方向的接近程度,进而做最近邻检索(这是搜索、推荐、RAG 的基础动作):

pythonCode
import numpy as np

def cosine_similarity(a, b):
    """余弦相似度:只看方向不看长度,范围[-1,1],越大越相似"""
    return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b) + 1e-9)

def nearest_neighbors(query_vec, embedding_table, id2word, topk=3):
    """在词向量表里找出和query最相似的topk个词"""
    sims = []
    for i, vec in enumerate(embedding_table):
        sims.append((id2word[i], cosine_similarity(query_vec, vec)))
    sims.sort(key=lambda x: x[1], reverse=True)
    return sims[:topk]

# 构造一个玩具向量表(真实场景里是训练出来的)
id2word = {0: "国王", 1: "女王", 2: "男人", 3: "女人", 4: "苹果"}
np.random.seed(0)
table = np.random.randn(5, 4)
# 人为让"国王"和"女王"方向接近
table[1] = table[0] + np.random.randn(4) * 0.1

q = table[0]  # 用"国王"作为查询
print("和'国王'最相似的词:", nearest_neighbors(q, table, id2word))
# "女王"会排在前面,因为我们让它俩方向接近了

为什么用余弦相似度而不是欧氏距离?因为在高维语义空间里,我们通常更关心两个向量"指向的方向"(语义),而不是它们的绝对长度(往往和词频等无关因素相关)。余弦相似度天然忽略长度,只看方向夹角,因此成为语义检索的默认度量。

原理进阶:向量空间里的语义代数

Embedding 最令人惊艳的性质是,词向量之间的加减运算,竟然能对应真实世界的语义关系。经典的例子是:

vector("国王") − vector("男人") + vector("女人") ≈ vector("女王")

直觉上可以这样理解:"国王"和"女王"的差别,主要就体现在"性别"这个维度上,而"男人"和"女人"的向量差,恰好也主要编码了"性别"这个语义方向。因为 Embedding 是在海量真实语料里,通过大量类似"国王统治王国""女王统治王国""男人是父亲""女人是母亲"这样的上下文共现关系训练出来的,性别这个语义特征就会被模型不知不觉地编码成向量空间里一个相对稳定的"方向"。当我们做"国王 − 男人"这个减法时,相当于把"国王"里"性别=男"的成分抵消掉,只留下"王室、统治者"这些共性语义;再加上"女人"的向量,相当于把"性别=女"的成分加回去,结果自然就落在"女王"附近。

我们可以用代码把这个"语义代数"跑一遍:

pythonCode
import numpy as np

def cosine_similarity(a, b):
    return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b) + 1e-9)

# 手工构造一组"带性别方向"的向量来演示原理
# 维度含义假设为: [王室程度, 性别(男正女负), 其他]
vec = {
    "国王": np.array([1.0,  1.0, 0.2]),
    "女王": np.array([1.0, -1.0, 0.2]),
    "男人": np.array([0.0,  1.0, 0.1]),
    "女人": np.array([0.0, -1.0, 0.1]),
}

# 语义代数:国王 - 男人 + 女人
result = vec["国王"] - vec["男人"] + vec["女人"]
print("类比结果向量:", result)

# 看它和谁最接近
for w, v in vec.items():
    print(f"  与 {w} 的相似度: {cosine_similarity(result, v):.3f}")
# "女王"的相似度最高,验证了 国王-男人+女人 ≈ 女王

类似的例子还有很多,比如"北京 − 中国 + 法国 ≈ 巴黎"(首都关系)、"游泳 − 游泳者 + 弹钢琴 ≈ 钢琴家"(动作与执行者的关系)。这些看似"智能"的代数运算,本质上都是分布式语义假设在向量空间里的自然推论——语义相近或存在某种规律关系的词,在训练数据里会展现出相似的上下文分布模式,这种规律性最终会以几何结构(方向、距离)的形式沉淀在向量空间里。

word2vec 与 GloVe:两条技术路线

word2vec 之外,斯坦福提出的 GloVe(Global Vectors) 是另一条重要路线。二者的核心差别在于:word2vec 是"预测式"的(用一个滑动窗口局部地预测上下文),而 GloVe 是"计数式"的(先统计整个语料的全局词共现矩阵,再对这个矩阵做分解,让词向量的点积去拟合共现次数的对数)。GloVe 的一个优点是充分利用了全局统计信息,训练也可以更好地并行。

| 对比维度 | word2vec (Skip-gram) | GloVe |

| --- | --- | --- |

| 训练信号 | 局部滑动窗口预测 | 全局共现矩阵分解 |

| 是否用全局统计 | 隐式利用 | 显式利用 |

| 训练方式 | 逐样本随机梯度下降 | 拟合共现次数对数 |

| 语义类比表现 | 很强 | 很强 |

| 典型词向量维度 | 100-300 | 100-300 |

需要说明的是,这里讲的是 Embedding 本身"是什么、为什么有效"的原理;而拿到这些向量之后如何高效存储、如何在海量向量里做近似最近邻检索、如何构建向量数据库支撑 RAG 检索等工程问题,不是本文的重点,可以参考本知识库中专门讲向量数据库的文章。

此外,现代的词向量早已从静态的 word2vec / GloVe 演进到基于 Transformer 的上下文相关 Embedding(同一个词在不同句子中会有不同的向量表示,比如"苹果"在"吃苹果"和"苹果发布会"里的向量截然不同),但"用向量空间的几何结构编码语义关系"这一底层逻辑至今没有变过。

真实案例:Embedding 在推荐与搜索里的应用

Embedding 的威力远不止于词。它的思想可以推广到任何离散实体:

推荐系统:把用户和商品都映射成向量,用户向量和商品向量的点积就代表"用户对该商品的兴趣程度"。YouTube、淘宝等的召回阶段大量使用这种"双塔"Embedding 模型——用户塔算出用户向量,商品塔算出海量商品向量,再用最近邻检索从上亿商品里秒级召回几百个候选。
搜索引擎:把查询词和文档都嵌入到同一个向量空间(语义检索 / 向量检索),即使查询和文档没有字面重叠,只要语义相近就能被检索到,弥补了传统关键词匹配"对不上词就搜不到"的短板。
item2vec / node2vec:把 word2vec 的"上下文"概念迁移到"用户点击序列""社交网络邻居"上,学出商品向量、节点向量,被广泛用于推荐和图挖掘。

一个经典数据点:Airbnb 曾把用户的房源点击/预订序列当作"句子"、房源当作"词"训练房源 Embedding,用于相似房源推荐和实时个性化排序,公开报告显示这套 Embedding 方案给平台带来了可观的转化提升。这类实践都印证了同一件事:只要能把"什么东西经常和什么东西一起出现"表达成序列,就能用 Embedding 学出有意义的向量表示。

常见坑

Embedding 维度选择不当:太小承载不了足够语义,太大浪费显存还容易过拟合,常见取值 50-300(词)到几百上千(大模型 token)。
未登录词(OOV)问题:静态词向量对训练时没见过的词束手无策,需要用子词(subword / BPE)切分或字符级方案缓解。
直接用欧氏距离比语义:高维语义检索几乎都用余弦相似度或归一化后的内积,直接用欧氏距离常常效果不佳。
忽略上下文:静态词向量给"苹果"只有一个向量,无法区分水果和公司,对歧义敏感的任务要改用上下文相关 Embedding。

最佳实践

文本任务优先用预训练词向量或预训练语言模型的 Embedding 层做初始化,站在巨人肩膀上。
向量检索前先做 L2 归一化,让内积等价于余弦相似度,方便用高效的内积检索库。
推荐/搜索的召回阶段用双塔 Embedding + 近似最近邻(ANN)检索,兼顾效果和速度。
对多义词、专业领域,考虑用上下文相关 Embedding 或在领域语料上微调。

小结

Embedding 把离散符号变成稠密向量,靠的是"观其伴,知其义"的分布式语义假设——经常出现在相似上下文里的词,向量会被训练得彼此靠近。这让向量空间的几何结构(距离、方向)承载了语义信息,甚至支持"国王−男人+女人≈女王"这样的语义代数。从 word2vec、GloVe 到上下文相关 Embedding,从词到商品、用户、节点,Embedding 是连接符号世界与数值世界、支撑搜索推荐与大模型的通用桥梁。

---

🎯 第四根柱子:注意力机制与 Transformer

概念:什么是注意力机制

注意力机制(Attention)是一种让模型在处理某个元素时,动态地为序列中所有其他元素分配"关注权重",再按权重加权聚合信息的机制。当查询者和被查询者来自同一个序列时,就叫自注意力(Self-Attention)Transformer 则是完全以注意力为核心引擎、彻底抛弃循环结构的神经网络架构,是当今几乎所有大语言模型的基础骨架。

为什么重要:理解一个词,该看上下文里的哪些词

考虑这样一句话:"他把钥匙放在桌子上,因为它太重了。""它"指代的是"钥匙"还是"桌子"?人类几乎瞬间就能判断出更可能指"钥匙"——这种"理解一个词的含义,需要参考句子中其他相关的词"的需求,在处理长句子、长文档时尤为关键,相关的线索可能出现在几十个词之外。

早期的 RNN / LSTM 按顺序逐词处理文本,把之前所有信息压缩进一个固定长度的"隐藏状态"向量里再往后传,句子一长,越靠前的信息就越容易被"稀释"或遗忘。这就是注意力机制要解决的核心问题:让模型在处理某个词时,能够直接"回头看"整个上下文里所有的词,并自动判断哪些词更相关、更需要重点关注,而不是依赖一个逐步累积、容易遗忘的中间状态。

原理一:Query / Key / Value 的直觉——一场信息检索

理解 Self-Attention 最好用的比喻是"图书馆检索"。假设你(当前正在处理的词)想知道该重点参考哪些资料:

Query(查询):你此刻脑子里的问题,即"我想找什么样的信息";
Key(键):图书馆里每本书书脊上贴的标签,概括了"这本书是关于什么的";
Value(值):书本身的实际内容。

检索过程是:拿你的 Query 和每一本书的 Key 做对比,算出一个"匹配度"分数——越匹配分数越高;然后用这些匹配度分数(归一化成加起来等于 1 的权重)对所有书的 Value 做加权平均,匹配度高的书内容占的比重更大,匹配度低的书内容占比很小甚至可以忽略。这个加权平均的结果,就是 Query(这个词)最终"融合了上下文信息"之后的新表示。

在 Self-Attention 里,句子中的每一个词都会同时扮演三个角色:它会生成自己的 Query(去查询别的词),也会生成自己的 Key 和 Value(被别的词查询)。这样一来,每个词都能根据自己的"问题",从全句所有词(包括自己)里挑出最相关的信息,重新组装出一个融合了上下文的新向量表示。这就是 Self-Attention 中"Self"的含义——查询者和被查询者来自同一个序列。

代码示例:单头自注意力

pythonCode
import numpy as np

def softmax(x):
    x = x - np.max(x, axis=-1, keepdims=True)  # 数值稳定性处理
    exp_x = np.exp(x)
    return exp_x / np.sum(exp_x, axis=-1, keepdims=True)

def simple_self_attention(X, Wq, Wk, Wv):
    """
    X: 形状 (seq_len, d_model),句子中每个词的初始向量表示
    Wq, Wk, Wv: 分别把X投影成 Query、Key、Value 的权重矩阵
    """
    Q = X @ Wq   # (seq_len, d_k) 每个词的"问题"
    K = X @ Wk   # (seq_len, d_k) 每个词的"标签"
    V = X @ Wv   # (seq_len, d_v) 每个词的"内容"

    d_k = K.shape[-1]
    # 第一步:算出每个词的Query与所有词的Key的匹配度(点积衡量相似度)
    scores = Q @ K.T / np.sqrt(d_k)     # 除以sqrt(d_k)防止数值过大导致softmax梯度消失

    # 第二步:把匹配度分数变成"加起来等于1"的注意力权重
    attention_weights = softmax(scores)  # 形状 (seq_len, seq_len)

    # 第三步:用注意力权重对所有词的Value做加权求和
    output = attention_weights @ V       # 每个词都得到融合了上下文的新表示

    return output, attention_weights

举个直观的例子:如果句子是"猫 追 老鼠 因为 它 饿 了",处理"它"这个词时,理想情况下"它"生成的 Query 应该和"猫"的 Key 算出较高的匹配分数,于是 attention_weights 里"它→猫"这一项的权重会明显高于"它→老鼠"或"它→追",最终"它"融合上下文之后的新向量,就会带有更多"猫"的语义信息。这正是注意力机制"划重点"能力的体现——不需要人工规则,模型通过训练自动学会该在什么场景下把注意力分配给哪些词。

原理二:为什么要除以根号 d_k

上面代码里有个容易被忽略的细节:scores 要除以 sqrt(d_k)。原因是当维度 d_k 较大时,Q 和 K 的点积会随维度增大而数值变大,导致 softmax 的输入落到很"尖"的区域,梯度趋近于 0(softmax 饱和),训练就学不动了。除以 sqrt(d_k) 把点积的方差拉回到 1 附近,让 softmax 工作在梯度健康的区间。这个小小的缩放因子,是 Transformer 能稳定训练的关键细节之一,论文里把这种注意力称为"缩放点积注意力"(Scaled Dot-Product Attention)。

pythonCode
import numpy as np

np.random.seed(0)
d_k = 128
q = np.random.randn(d_k)
k = np.random.randn(d_k)

raw = q @ k                      # 未缩放的点积
scaled = (q @ k) / np.sqrt(d_k)  # 缩放后的点积
print(f"未缩放点积: {raw:.2f}")      # 数值较大,方差约等于d_k
print(f"缩放后点积: {scaled:.2f}")   # 方差被拉回1附近

# 用一组分数看softmax的"尖锐"程度差异
scores = np.random.randn(5) * 10   # 模拟未缩放的大分数
print("大分数softmax:", np.round(np.exp(scores)/np.exp(scores).sum(), 3))
# 会看到几乎所有权重集中在一个位置,其余接近0——梯度消失

代码示例:完整的多头注意力

实际的 Transformer 会用"多头注意力"(Multi-Head Attention),也就是并行跑多组独立的 Query / Key / Value 投影,让不同的"头"学会关注不同类型的关系(比如有的头专注于指代消解,有的头专注于语法结构),最后把多个头的结果拼接起来,得到更丰富的表示。下面是一个完整可运行的多头注意力实现:

pythonCode
import numpy as np

def softmax(x):
    x = x - np.max(x, axis=-1, keepdims=True)
    e = np.exp(x)
    return e / e.sum(axis=-1, keepdims=True)

def multi_head_attention(X, Wq, Wk, Wv, Wo, num_heads):
    """
    X: (seq_len, d_model)
    Wq, Wk, Wv: (d_model, d_model) 投影矩阵
    Wo: (d_model, d_model) 输出融合矩阵
    num_heads: 头的数量,d_model 必须能被它整除
    """
    seq_len, d_model = X.shape
    d_head = d_model // num_heads   # 每个头的维度

    # 先整体投影,再切成多个头
    Q = X @ Wq   # (seq_len, d_model)
    K = X @ Wk
    V = X @ Wv

    outputs = []
    all_weights = []
    for h in range(num_heads):
        # 取出第h个头对应的那一段维度
        s = h * d_head
        e = (h + 1) * d_head
        Qh, Kh, Vh = Q[:, s:e], K[:, s:e], V[:, s:e]

        scores = Qh @ Kh.T / np.sqrt(d_head)   # 每个头独立算注意力
        w = softmax(scores)
        outputs.append(w @ Vh)                  # (seq_len, d_head)
        all_weights.append(w)

    # 把所有头的输出在特征维度上拼接起来
    concat = np.concatenate(outputs, axis=-1)   # (seq_len, d_model)
    out = concat @ Wo                           # 再做一次线性融合
    return out, all_weights

# 跑一个小例子
np.random.seed(0)
seq_len, d_model, heads = 4, 8, 2
X = np.random.randn(seq_len, d_model)
Wq = np.random.randn(d_model, d_model)
Wk = np.random.randn(d_model, d_model)
Wv = np.random.randn(d_model, d_model)
Wo = np.random.randn(d_model, d_model)
out, weights = multi_head_attention(X, Wq, Wk, Wv, Wo, heads)
print("多头注意力输出形状:", out.shape)          # (4, 8)
print("头数:", len(weights), " 每个头的注意力矩阵形状:", weights[0].shape)

多头的核心价值在于"分工":把高维空间切成若干子空间,每个头在自己的子空间里独立地学一种关系模式,最后拼起来,模型就能同时捕捉句子里多种不同层面的依赖关系,表达能力远强于单头。

代码示例:位置编码——给注意力补上"顺序感"

自注意力有一个"缺陷":它本身对词的顺序完全不敏感——把句子里的词打乱,每个词算出的注意力结果几乎不变(因为它只是对所有词做加权求和)。但语言显然是有顺序的,"狗咬人"和"人咬狗"意思天差地别。解决办法是位置编码(Positional Encoding):给每个位置生成一个独特的向量,加到词向量上,让模型能感知"这个词排在第几位"。原始 Transformer 用的是一组不同频率的正弦/余弦函数:

pythonCode
import numpy as np

def sinusoidal_position_encoding(seq_len, d_model):
    """
    生成正弦位置编码
    返回形状 (seq_len, d_model),可直接加到词向量上
    """
    pe = np.zeros((seq_len, d_model))
    position = np.arange(seq_len)[:, np.newaxis]           # (seq_len, 1)
    # 不同维度用不同频率:偶数维用sin,奇数维用cos
    div_term = np.exp(np.arange(0, d_model, 2) * (-np.log(10000.0) / d_model))
    pe[:, 0::2] = np.sin(position * div_term)   # 偶数维
    pe[:, 1::2] = np.cos(position * div_term)   # 奇数维
    return pe

pe = sinusoidal_position_encoding(seq_len=6, d_model=8)
print("位置编码形状:", pe.shape)
print("第0个位置的编码:", np.round(pe[0], 2))
print("第1个位置的编码:", np.round(pe[1], 2))
# 每个位置得到一个独一无二的向量,且相邻位置编码相似、远处位置差异大

# 用法:词向量 + 位置编码 一起送进注意力
np.random.seed(0)
word_emb = np.random.randn(6, 8)
X = word_emb + pe   # 现在X既带语义又带位置信息

用正弦函数的巧妙之处在于:不同频率的组合让每个位置都有唯一编码,而且模型可以通过三角函数的和差化积公式,方便地表达"相对位置"关系(位置 pos+k 的编码可以由位置 pos 的编码线性表示)。后来也出现了可学习位置编码、相对位置编码、旋转位置编码(RoPE)等改进,但目的都是同一个:把顺序信息注入本来"无序"的注意力机制。

代码示例:因果掩码——让模型只能看过去

在做文本生成(如 GPT)时,模型是"从左到右"逐词生成的,预测第 t 个词时绝对不能偷看第 t+1 及之后的词,否则就是"作弊"。实现方式是因果掩码(Causal Mask):在算注意力分数时,把当前位置之后所有位置的分数设成负无穷,softmax 之后这些位置的权重就变成 0,等于"看不见未来"。

pythonCode
import numpy as np

def softmax(x):
    x = x - np.max(x, axis=-1, keepdims=True)
    e = np.exp(x)
    return e / e.sum(axis=-1, keepdims=True)

def causal_self_attention(X, Wq, Wk, Wv):
    Q, K, V = X @ Wq, X @ Wk, X @ Wv
    d_k = K.shape[-1]
    scores = Q @ K.T / np.sqrt(d_k)          # (seq_len, seq_len)

    seq_len = X.shape[0]
    # 构造上三角掩码:i行j列,j>i的位置(未来)设为负无穷
    mask = np.triu(np.ones((seq_len, seq_len)), k=1).astype(bool)
    scores[mask] = -np.inf                    # 屏蔽未来位置

    weights = softmax(scores)
    return weights @ V, weights

np.random.seed(0)
seq_len, d = 4, 6
X = np.random.randn(seq_len, d)
Wq, Wk, Wv = np.random.randn(d, d), np.random.randn(d, d), np.random.randn(d, d)
out, w = causal_self_attention(X, Wq, Wk, Wv)
print("因果注意力权重(下三角,上三角为0):")
print(np.round(w, 2))
# 你会看到权重矩阵是下三角的:每个位置只关注自己和之前的位置

因果掩码是理解 GPT 这类自回归生成模型的关键——正是它保证了"预测下一个词"这个训练目标不会因为偷看答案而失效。而 BERT 这类模型则不加因果掩码(双向注意力,每个词能看到全句),因为它的目标是"完形填空"式的理解,而非从左到右生成。

原理三:Transformer 整体架构

前面讲的注意力机制,正是 2017 年那篇著名论文《Attention Is All You Need》提出的 Transformer 架构的核心引擎。一个标准 Transformer 模块通常由这几个部件堆叠而成:

多头自注意力层:负责让每个位置聚合全局上下文信息;
位置编码:给原本不带顺序信息的注意力机制补上"词在句子中排第几"的信息;
前馈网络(Feed-Forward Network):对每个位置的表示做非线性的进一步加工(通常是"升维→激活→降维"两层全连接);
残差连接与层归一化:贯穿始终,保证深层堆叠时梯度能够顺畅回传,训练更稳定(这里正好复用了第一节讲的残差和归一化思想)。

多个这样的模块堆叠起来,就构成了完整的 Encoder 或 Decoder。下面把这些部件拼成一个完整的单层 Transformer Encoder Block:

pythonCode
import numpy as np

def softmax(x):
    x = x - np.max(x, axis=-1, keepdims=True)
    e = np.exp(x)
    return e / e.sum(axis=-1, keepdims=True)

def layer_norm(x, eps=1e-6):
    mu = x.mean(axis=-1, keepdims=True)
    var = x.var(axis=-1, keepdims=True)
    return (x - mu) / np.sqrt(var + eps)

def self_attention(X, Wq, Wk, Wv):
    Q, K, V = X @ Wq, X @ Wk, X @ Wv
    d_k = K.shape[-1]
    w = softmax(Q @ K.T / np.sqrt(d_k))
    return w @ V

def feed_forward(x, W1, b1, W2, b2):
    # 升维 -> ReLU -> 降维
    return np.maximum(0, x @ W1 + b1) @ W2 + b2

def transformer_encoder_block(X, params):
    # 子层1:自注意力 + 残差 + 层归一化
    attn = self_attention(X, params['Wq'], params['Wk'], params['Wv'])
    X = layer_norm(X + attn)                       # 残差连接后归一化

    # 子层2:前馈网络 + 残差 + 层归一化
    ff = feed_forward(X, params['W1'], params['b1'], params['W2'], params['b2'])
    X = layer_norm(X + ff)
    return X

# 组装参数跑一遍
np.random.seed(0)
seq_len, d_model, d_ff = 5, 16, 64
X = np.random.randn(seq_len, d_model)
params = {
    'Wq': np.random.randn(d_model, d_model) * 0.1,
    'Wk': np.random.randn(d_model, d_model) * 0.1,
    'Wv': np.random.randn(d_model, d_model) * 0.1,
    'W1': np.random.randn(d_model, d_ff) * 0.1,
    'b1': np.zeros(d_ff),
    'W2': np.random.randn(d_ff, d_model) * 0.1,
    'b2': np.zeros(d_model),
}
out = transformer_encoder_block(X, params)
print("Transformer编码块输出形状:", out.shape)  # (5, 16),形状不变,可无限堆叠

注意输出形状和输入完全一样——这正是 Transformer 能像搭积木一样堆几十上百层的原因:每个块的输入输出接口一致,堆叠时无缝衔接。

原理四:Transformer 相比 RNN / CNN 的关键优势

相比 RNN 的优势——可并行:RNN 处理序列必须严格按时间步一个接一个算,前一步不算完后一步无法开始,训练时天然难以并行,长序列训练极慢。Transformer 完全抛弃了这种递归结构,一个序列里所有位置的注意力计算可以同时展开,天然适配 GPU 大规模并行运算,这也是它能训练到今天动辄千亿参数规模的重要前提之一。

相比 RNN 的优势——长距离依赖:RNN 里两个相隔很远的词,信息要经过很多个时间步的层层传递才能相互影响,链条一长信息就容易衰减或丢失。而 Self-Attention 让任意两个位置之间只需要"一步"就能直接建立联系,无论两个词隔多远,理论上的路径长度都是 O(1),这从根本上缓解了长距离依赖的问题。

相比 CNN 的优势——感受野:前文提到 CNN 要靠堆叠很多层才能让感受野覆盖整张图/整个序列,而 Self-Attention 每一层天然就是"全局感受野"——第一层就能让某个位置看到序列中所有其他位置,不需要靠堆层数去换取全局视野。

下面这张表把三种主流架构放在一起对比:

| 对比维度 | RNN / LSTM | CNN | Transformer |

| --- | --- | --- | --- |

| 并行能力 | 差(必须按时序) | 好 | 极好(全序列并行) |

| 长距离依赖路径 | O(n) | O(log n) 或更长 | O(1) |

| 每层感受野 | 局部(当前+历史状态) | 局部(卷积核大小) | 全局 |

| 计算复杂度 | O(n·d²) | O(n·k·d²) | O(n²·d) |

| 典型短板 | 长序列遗忘、难并行 | 全局关系需堆深 | 序列越长显存越吃紧(n²) |

需要注意 Transformer 也有代价:自注意力的计算和显存开销随序列长度呈平方增长(O(n²)),这也是为什么长文本、长上下文一直是研究热点,催生了各种稀疏注意力、线性注意力、FlashAttention 等优化方案。

真实案例:从机器翻译到大语言模型

Transformer 最初就是为机器翻译设计的。在它出现之前,主流的神经机器翻译用的是"带注意力的 Seq2Seq(RNN Encoder-Decoder)"。Transformer 在 2017 年的 WMT 英德翻译任务上,用更短的训练时间就取得了当时最高的 BLEU 分数(约 28.4),且训练速度远快于 RNN 方案,一举证明了"只用注意力"路线的优越性。

此后 Transformer 的影响力一路扩散:

BERT(2018):用双向 Transformer Encoder 做预训练,在 11 项 NLP 任务上刷新纪录,开启了"预训练+微调"范式。base 版约 1.1 亿参数,large 版约 3.4 亿参数。
GPT 系列:用 Transformer Decoder 做自回归语言模型,从 GPT-1 的约 1.17 亿参数,到 GPT-2 的 15 亿、GPT-3 的 1750 亿参数,参数规模三年翻了上千倍,涌现出惊人的少样本/零样本能力。
多模态与视觉:Vision Transformer(ViT)把图像切成小块当作"词",证明纯 Transformer 在视觉任务上也能超越 CNN;今天的图文、语音、视频大模型基本都以 Transformer 为骨架。

下面这张表直观展示了 Transformer 类模型的参数规模演进:

| 模型 | 年份 | 类型 | 参数量 | 标志性能力 |

| --- | --- | --- | --- | --- |

| Transformer(原始) | 2017 | Encoder-Decoder | 约6500万 | 机器翻译SOTA |

| BERT-large | 2018 | Encoder | 约3.4亿 | 双向语言理解 |

| GPT-2 | 2019 | Decoder | 约15亿 | 流畅长文本生成 |

| GPT-3 | 2020 | Decoder | 约1750亿 | 少样本/上下文学习 |

| ViT-Large | 2020 | Encoder | 约3亿 | 纯注意力做视觉 |

正是这几点优势的叠加,让 Transformer 迅速取代 RNN / CNN 成为 NLP 乃至多模态领域的主流骨干架构,后续的 BERT、GPT 系列、直到今天的各种大语言模型,基本都是在这套架构上做增量创新。

常见坑

忘记加位置编码:不加位置编码的自注意力对词序不敏感,句子打乱结果几乎不变,务必记得注入位置信息。
忘记缩放点积:不除以 sqrt(d_k),维度一大 softmax 就饱和,训练学不动。
该加因果掩码的地方没加:做生成任务时若不加掩码,模型会"偷看未来",训练指标虚高但推理时崩溃。
序列过长导致显存爆炸:n² 的复杂度让长序列极其吃显存,需要分块、稀疏注意力或专门的高效实现。

最佳实践

标准配置:多头注意力 + 位置编码 + 前馈网络 + 残差 + 层归一化,缺一不可。
生成模型用因果掩码(Decoder),理解模型用双向注意力(Encoder),按任务选对结构。
长上下文场景优先考虑 FlashAttention 等高效实现和稀疏/线性注意力变体。
大模型训练站在预训练模型肩膀上做微调(fine-tune / LoRA),而非从零训练。

小结

注意力机制用 Query-Key-Value 的"检索"范式,让模型在处理每个元素时都能动态地聚焦到最相关的其他元素,实现全局、一步可达、可并行的信息聚合。Transformer 以多头自注意力为核心,配合位置编码、前馈网络、残差与层归一化,彻底取代了循环结构,凭借可并行、长距离依赖、全局感受野三大优势,成为从机器翻译到 GPT、从文本到多模态的统一骨干架构。

---

🧩 总结:四根柱子如何撑起整座大厦

回顾全文,深度学习的四块基石各司其职又环环相扣:反向传播是所有网络学习的通用引擎;CNN 用卷积先验高效处理带空间结构的数据;Embedding 把离散符号变成可运算的稠密语义向量;注意力/Transformer 则让模型学会动态"划重点"、聚合全局信息。它们并非孤立——现代大模型正是用 Embedding 把 token 变成向量,用注意力在向量间建立联系,用残差、归一化保证深层可训,最后靠反向传播把这一切参数一并学出来。

理解了这四块,你就拥有了读懂几乎所有主流深度学习论文与模型的"通用词汇表"。后续无论是钻研某个具体架构,还是跟进最新的大模型进展,都能在这四根柱子上找到落脚点。

下面用一张总表把四块基石的要点一并收拢,方便随时回顾:

| 基石 | 核心问题 | 关键思想 | 代表技术 | 常见坑 | 一句话记忆 |

| --- | --- | --- | --- | --- | --- |

| 反向传播/多层网络 | 网络怎么学 | 链式法则逐层回传梯度 | ReLU、批归一化、残差连接 | 梯度消失、学习率失当 | 误差从输出层一路分摊回每个参数 |

| CNN | 如何高效看图 | 局部连接+权重共享 | 卷积、池化、感受野、迁移学习 | 特征图缩太快、小数据过拟合 | 一个滑动的特征探测器扫全图 |

| Embedding | 符号怎么变数字 | 观其伴知其义 | word2vec、GloVe、余弦检索 | 维度选择、未登录词、歧义 | 语义近的词向量靠得近 |

| 注意力/Transformer | 该关注哪些元素 | Query-Key-Value加权聚合 | 多头注意力、位置编码、因果掩码 | 忘加位置编码、n²显存 | 动态划重点、全局一步可达 |

从最朴素的感知机到今天的千亿参数大模型,技术的外表天翻地覆,但内核始终是这四块基石的组合与升级。打牢这四块地基,再高的大厦你都能看懂它是怎么盖起来的。

---

🔬 附录 A:用 PyTorch 把四块基石各写一遍

A.1 一个能反向传播的最小网络

pythonCode
import torch
import torch.nn as nn

class MLP(nn.Module):
    def __init__(self, in_dim, hidden, out_dim):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(in_dim, hidden),
            nn.ReLU(),              # 非线性,否则多层等价于一层
            nn.Linear(hidden, out_dim),
        )

    def forward(self, x):
        return self.net(x)

model = MLP(784, 256, 10)
loss_fn = nn.CrossEntropyLoss()
opt = torch.optim.Adam(model.parameters(), lr=1e-3)

# 一步训练:前向 -> 算损失 -> 反向 -> 更新
logits = model(x_batch)
loss = loss_fn(logits, y_batch)
opt.zero_grad()   # 清空上一步梯度,否则会累加
loss.backward()   # 反向传播:链式法则自动算出每个参数的梯度
opt.step()        # 沿负梯度方向更新参数

A.2 一个卷积块

pythonCode
conv_block = nn.Sequential(
    nn.Conv2d(3, 32, kernel_size=3, padding=1),  # 局部连接+权重共享
    nn.BatchNorm2d(32),                          # 批归一化,稳住分布
    nn.ReLU(),
    nn.MaxPool2d(2),                             # 下采样,扩大感受野
)
# 输入 (N,3,32,32) -> 输出 (N,32,16,16)

A.3 从零实现缩放点积注意力

pythonCode
import torch
import torch.nn.functional as F

def attention(Q, K, V, mask=None):
    d_k = Q.size(-1)
    # 打分:Q 和每个 K 做点积,再除以 sqrt(d_k) 防止梯度过小
    scores = Q @ K.transpose(-2, -1) / (d_k ** 0.5)
    if mask is not None:
        scores = scores.masked_fill(mask == 0, float('-inf'))
    weights = F.softmax(scores, dim=-1)  # 归一成注意力权重
    return weights @ V                   # 按权重聚合 V

📐 附录 B:四块基石的复杂度与显存要点

| 结构 | 时间复杂度 | 关键显存瓶颈 | 优化手段 |

| --- | --- | --- | --- |

| 全连接层 | O(d_in × d_out) | 权重矩阵 | 权重共享、剪枝 |

| 卷积 | O(H·W·C_in·C_out·k²) | 特征图缓存 | 深度可分离卷积 |

| 自注意力 | O(n²·d) | n×n 注意力矩阵 | FlashAttention、稀疏注意力 |

| Embedding | O(1) 查表 | 词表×维度 | 权重绑定、量化 |

其中自注意力对序列长度 n 是平方复杂度,这是长上下文的核心瓶颈:序列翻倍,注意力矩阵显存变 4 倍。FlashAttention 通过分块计算避免显式存下整个 n×n 矩阵,把显存从 O(n²) 降到 O(n),是当前长上下文训练的默认选择。

❓ 附录 C:高频疑问速答

Q:为什么要用残差连接?

A:深层网络里梯度回传时容易衰减到接近 0(梯度消失),残差 y = x + F(x) 给梯度提供一条"直通高速路",让上百层网络也能训得动。这是 ResNet 能做到 152 层、Transformer 能堆几十层的关键。

Q:位置编码为什么必须有?

A:自注意力本身对顺序不敏感(打乱输入词序,注意力打分不变)。位置编码把"第几个位置"的信息注入向量,模型才能区分"狗咬人"和"人咬狗"。

Q:Embedding 维度怎么选?

A:词表小、任务简单选 100-300 维;大规模语言模型 token embedding 常用 768-8192 维不等。维度越高表达力越强但越易过拟合、越占显存,需在评估集上权衡。

Q:什么时候该从零训练,什么时候微调?

A:绝大多数场景都应站在预训练模型肩膀上做微调或 LoRA——从零训练动辄需要海量数据和算力。只有当你的领域与通用预训练分布差异极大、且数据充足时,才考虑从头训练。