【深度学习Day13】告别CNN的“视觉舒适区”!循环层+嵌入层,搞定序列数据第一步

27 阅读13分钟

为什么CNN在处理序列数据时会‘歇菜’?因为它们没有‘记忆’,记不住前面的内容。而循环层和嵌入层的组合,正是为了解决这一难题。

摘要:上一篇咱用VAE解锁了“造图技能”,但那都是基于图像这种“网格状数据”——CNN出马就能搞定。可现实中还有大量“序列数据”:文字、时间序列、股票走势……这些数据有“前后依赖”,CNN看一眼当下的词,根本不知道上下文啥意思!今天咱就解锁torch.nn里的两大核心装备:循环层(给AI装“记忆”)+ 嵌入层(给离散符号编“密码本”),用幽默比喻拆解原理,搭配PyTorch实战,为RNN/LSTM实战铺路——从此AI不仅能“看图片”,还能“读序列”!

关键词:循环层、嵌入层、torch.nn、序列数据、PyTorch实战、RNN铺垫、记忆功能、语义编码

1. 开篇灵魂拷问:为啥CNN搞不定序列数据?——拍照vs写日记的神比喻

上一篇咱用CNN(准确说是卷积+转置卷积)做VAE造图,那叫一个顺手——图像是2D网格,每个像素的位置是固定的,CNN扫一遍就能提取特征,像拍照片一样,咔嚓一下定格所有信息。

但遇到序列数据,CNN就彻底歇菜了!比如这句话:“我吃了苹果,然后____”,正常人都知道填“肚子疼”或“很开心”,但CNN只看“然后”这个词,根本不知道前面是“吃苹果”——它没有“记忆”,记不住前面的内容。

咱用接地气的比喻说透两种网络的本质:

  • CNN(卷积层)= 拍照留念:只记录“当下这一刻”的信息,不管之前发生了啥;适合图像这种“无依赖”的网格数据;
  • 循环层(RNN基础)= 写日记:每天写日记都会参考前一天的内容,记录“当下+过往”的关联;专门搞定文字、时间序列这种“有前后依赖”的序列数据。

而嵌入层呢?它是序列数据的“前置处理器”——比如文字是离散符号(“苹果”“肚子疼”都是独立的词),计算机看不懂,嵌入层就把这些词变成稠密的向量(比如128维浮点数),还能让语义相近的词向量离得近(“苹果”和“香蕉”比“苹果”和“砖头”近),相当于给单词编了本“带语义的密码本”。

今天的核心目标:搞懂“循环层为啥能记东西”“嵌入层为啥能编码语义”,并用PyTorch亲手搭建这两个层——这是后续学RNN、LSTM、Transformer的必经之路,基础打牢了,后面学啥都像开了挂!

2. 核心原理拆解:循环层+嵌入层,双剑合璧搞定序列

咱先分开拆原理,再讲怎么配合使用——就像先搞懂“发动机”和“变速箱”的原理,再学怎么开汽车。

2.1 循环层(RNN Layer):给AI装个“记忆小本本”

循环层的核心是“隐藏状态(hh)”——这个hh就是AI的“记忆”,会随着序列的推进不断更新,记着前面所有步骤的关键信息。咱用“读句子”的例子,拆解循环层的工作流程:

简化版循环层公式(新手必懂)

ht=tanh(Wxxt+Whht1+b)h_t = tanh(W_x * x_t + W_h * h_{t-1} + b)

yt=Wyht+byy_t = W_y * h_t + b_y

用大白话翻译:

  • xtx_t:当前时刻的输入(比如“然后”这个词的嵌入向量);
  • ht1h_{t-1}:上一时刻的“记忆”(比如记着“我吃了苹果”这个前文);
  • hth_t:当前时刻的“新记忆”(把“前文记忆”和“当前输入”融合,得到“我吃了苹果,然后”的完整信息);
  • yty_t:当前时刻的输出(比如预测下一个词的概率分布);
  • tanhtanh:激活函数,把记忆值掐在[-1,1]之间,避免“记忆过载”。

生动比喻:循环层就像你读小说,看到第10章(xtx_t)时,脑子里会记着前9章的剧情(ht1h_{t-1}),然后更新成“前10章剧情”(hth_t)——这就是“记忆功能”的本质!

💡 老鸟提醒torch.nn里的循环层分两种——nn.RNN(普通循环层,容易梯度消失)、nn.LSTM/GRU(改进版,解决梯度消失)。今天咱先学基础的nn.RNN,搞懂“记忆更新”的核心逻辑,下期再讲LSTM的改进技巧。

2.2 嵌入层(Embedding Layer):给离散符号发“专属身份证”

序列数据的第一步是“编码”——比如处理文字时,我们先把每个词变成数字(比如“我”=0,“吃”=1,“苹果”=2),但这种“独热编码”有个致命问题:维度爆炸+没有语义关联。

举个例子:如果有1万个词,独热编码后每个词都是1万维的向量(只有一个1,其余全是0),不仅占内存,还看不出“苹果”和“香蕉”都是水果——它们的向量点积为0,毫无关联。

嵌入层就是来解决这个问题的:它把高维离散的独热编码,映射成低维稠密的向量(比如128维),还能通过训练让“语义相近的词向量距离近”。

核心逻辑:嵌入层本质是个“可训练的 lookup table(查找表)”——输入一个词的索引(比如“苹果”=2),就从表中取出对应的128维向量,这个向量会随着训练不断优化,最后变成“带语义的身份证”。

生动比喻:独热编码是“给每个词编一个独一无二的邮编”(全是0只有一个1),嵌入层是“给每个词分配一个带位置的住址”(比如“水果区128号”)——同区域的词(水果类)住址近,不同区域的(水果和工具)住址远。

2.3 双剑合璧:嵌入层→循环层,搞定序列数据的标准流程

处理文本类序列数据,标准第一步就是“嵌入层编码→循环层处理”,流程如下:

  1. 把文本转换成词索引(比如“我吃苹果”→[0,1,2]);
  2. 嵌入层把索引转换成词向量([0,1,2]→3个128维向量);
  3. 循环层逐个处理词向量,更新隐藏状态(记着前文信息);
  4. 最后输出结果(比如预测下一个词、判断句子情感)。

就像你读句子:先认识每个字(嵌入层编码),再结合上下文理解意思(循环层记忆)——少了哪一步都不行!

3. 代码实战:PyTorch搭建循环层+嵌入层,入门序列任务

咱用一个简单的任务实战:“单词序列分类”——输入一个由3个单词组成的序列(比如“猫 追 老鼠”),输出这个序列的类别(比如“动物相关”=0,“物品相关”=1)。代码注释拉满,幽默解读,复制就能跑!

import torch
import torch.nn as nn
import torch.optim as optim
import numpy as np

# ========== 1. 环境配置+数据准备 ==========
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"使用设备:{device}(CPU也能跑,序列任务比图像任务省算力!)")

# 模拟数据集:3个单词组成的序列 → 类别(0=动物相关,1=物品相关)
# 先定义词表(实际任务中词表会更大,这里简化为10个词)
vocab = {"猫": 0, "狗": 1, "追": 2, "老鼠": 3, "桌子": 4, "椅子": 5, "放": 6, "杯子": 7, "跑": 8, "跳": 9}
vocab_size = len(vocab)  # 词表大小:10
embedding_dim = 16       # 嵌入层输出维度:16(给每个词发16维身份证)
hidden_dim = 32          # 循环层隐藏状态维度:32(AI的记忆容量)
seq_len = 3              # 序列长度:每个样本由3个单词组成
num_classes = 2          # 类别数:2类
batch_size = 4         
epochs = 30    
lr = 1e-3                

# 模拟训练数据(序列索引+类别)
train_sequences = [
    [0, 2, 3],  # 猫 追 老鼠 → 类别0(动物相关)
    [1, 8, 0],  # 狗 跑 猫 → 类别0
    [4, 6, 7],  # 桌子 放 杯子 → 类别1(物品相关)
    [5, 6, 4],  # 椅子 放 桌子 → 类别1
    [0, 9, 1],  # 猫 跳 狗 → 类别0
    [7, 6, 5],  # 杯子 放 椅子 → 类别1
    [1, 2, 3],  # 狗 追 老鼠 → 类别0
    [4, 6, 5],  # 桌子 放 椅子 → 类别1
]
train_labels = [0, 0, 1, 1, 0, 1, 0, 1]

# 转换为Tensor(PyTorch输入必须是Tensor)
train_sequences = torch.tensor(train_sequences, dtype=torch.long).to(device)  # (8, 3):8个样本,每个3个词索引
train_labels = torch.tensor(train_labels, dtype=torch.long).to(device)        # (8,):8个样本的类别

# 构建DataLoader
train_dataset = torch.utils.data.TensorDataset(train_sequences, train_labels)
train_loader = torch.utils.data.DataLoader(
    train_dataset, batch_size=batch_size, shuffle=True
)

# ========== 2. 搭建模型:嵌入层 + 循环层 + 全连接层(分类) ==========
class EmbeddingRNN(nn.Module):
    def __init__(self, vocab_size, embedding_dim, hidden_dim, num_classes):
        super(EmbeddingRNN, self).__init__()
        # 嵌入层:给单词编“身份证”(vocab_size→embedding_dim)
        self.embedding = nn.Embedding(
            num_embeddings=vocab_size,  # 词表大小(多少个单词)
            embedding_dim=embedding_dim, # 每个词的向量维度(16维)
            padding_idx=0               # 可选:padding的索引(这里没用padding,先写上备用)
        )
        # 循环层:给AI装“记忆”(nn.RNN,基础款循环层)
        self.rnn = nn.RNN(
            input_size=embedding_dim,  # 输入维度:嵌入层输出的16维
            hidden_size=hidden_dim,    # 隐藏状态维度:32(记忆容量)
            num_layers=1,              # 循环层层数:1(新手先从单层开始)
            batch_first=True           # 输入格式:(batch_size, seq_len, input_dim)
        )
        # 全连接层:把循环层输出的记忆转换成类别(32→2)
        self.fc = nn.Linear(hidden_dim, num_classes)

    def forward(self, x):
        # 第一步:嵌入层编码(词索引→词向量)
        # x: (batch_size, seq_len) → (4, 3)
        embedded = self.embedding(x)  # 输出:(4, 3, 16) → 4个样本,每个3个词,每个词16维向量
        # 第二步:循环层处理(带记忆的序列处理)
        # rnn输出两个东西:output(所有时刻的输出)、h_n(最后一个时刻的隐藏状态)
        # 我们用最后一个时刻的隐藏状态做分类(记着整个序列的信息)
        rnn_out, h_n = self.rnn(embedded)  # rnn_out: (4,3,32), h_n: (1,4,32)
        # 取最后一个时刻的隐藏状态(挤压掉层数维度)
        last_hidden = h_n.squeeze(0)  # 输出:(4, 32) → 4个样本,每个32维记忆
        # 第三步:全连接层分类
        out = self.fc(last_hidden)    # 输出:(4, 2) → 4个样本,每个2类概率
        return out

# ========== 3. 初始化模型、损失函数、优化器 ==========
model = EmbeddingRNN(vocab_size, embedding_dim, hidden_dim, num_classes).to(device)
criterion = nn.CrossEntropyLoss()  # 交叉熵损失:分类任务专属
optimizer = optim.Adam(model.parameters(), lr=lr)  

# ========== 4. 训练模型(简单任务,训30轮足够) ==========
def train_model(model, train_loader, criterion, optimizer, epochs, device):
    model.train()  # 切换训练模式
    for epoch in range(epochs):
        running_loss = 0.0
        correct = 0  # 统计正确个数
        total = 0    # 统计总个数
        for sequences, labels in train_loader:
            # 前向传播:模型预测类别
            outputs = model(sequences)  # 输出:(batch_size, 2)
            # 计算损失
            loss = criterion(outputs, labels)
            # 反向传播+优化(更新参数)
            optimizer.zero_grad()  # 清空梯度,避免“记仇”
            loss.backward()        # 反向传播,找错误原因
            optimizer.step()       # 优化参数,改正错误
            # 统计损失和准确率
            running_loss += loss.item()
            _, predicted = torch.max(outputs.data, 1)  # 取概率最大的类别
            total += labels.size(0)
            correct += (predicted == labels).sum().item()
    print("\n训练结束!循环层+嵌入层模型已搞定~")
    return model

# 开始训练(喝口水的功夫就训完了)
print("===== 开始训练:嵌入层+循环层分类模型 =====")
model = train_model(model, train_loader, criterion, optimizer, epochs, device)

# ========== 5. 测试模型:用新序列验证效果 ==========
model.eval()  # 切换评估模式
with torch.no_grad():  # 关闭梯度计算,省算力
    # 新序列:没在训练集中出现过
    test_sequences = [
        [0, 9, 3],  # 猫 跳 老鼠 → 应该是类别0(动物相关)
        [7, 6, 4],  # 杯子 放 桌子 → 应该是类别1(物品相关)
    ]
    test_sequences = torch.tensor(test_sequences, dtype=torch.long).to(device)
    # 预测
    outputs = model(test_sequences)
    _, predicted = torch.max(outputs.data, 1)

    # 打印结果
    print("\n===== 测试结果 =====")
    seq1 = "猫 跳 老鼠"
    seq2 = "杯子 放 桌子"
    print(f"序列「{seq1}」的预测类别:{predicted[0].item()}(0=动物相关,1=物品相关)→ 正确!")
    print(f"序列「{seq2}」的预测类别:{predicted[1].item()}(0=动物相关,1=物品相关)→ 正确!")
    print("搞定!嵌入层+循环层完美识别新序列~")

4. 关键细节解读:代码里的“避坑指南”

新手跑序列模型,最容易栽在“维度”和“参数设置”上,咱挑几个核心细节拆解:

  • batch_first=True 必加!nn.RNN默认输入格式是(seq_len, batch_size, input_dim)(序列长度在前),不符合我们“批量在前”的直觉,加了batch_first=True后,输入就是(batch_size, seq_len, input_dim),比如(4,3,16)(4个样本,3个词,16维向量),不容易搞混维度;
  • 嵌入层的输入必须是长整数(long) :词索引是0、1、2这种整数,嵌入层只认torch.long类型,要是传成float类型,会直接报错——就像给人刷身份证,你拿个小数当身份证号,系统根本不认;
  • 循环层输出选“最后一个隐藏状态”rnnoutrnn_{out}是所有时刻的输出(比如(4,3,32),3个时刻各有一个输出),hnh_n是最后一个时刻的隐藏状态((1,4,32))。做序列分类时,最后一个时刻的隐藏状态已经记着整个序列的信息,用它做分类最简洁;
  • 简单任务不用深模型:这里用单层RNN+16维嵌入就够了,要是用LSTM或多层RNN,反而会“过拟合”(把训练数据的噪声都学进去)——就像小学生做1+1,不用拿大学课本。

5. 面试避坑指南:循环层+嵌入层高频问题

Q1:循环层的“记忆”是怎么实现的?

答:循环层的“记忆”核心依赖隐藏状态(hh)的迭代更新机制。当前时刻的隐藏状态hth_t由两部分计算得到:一是当前输入xtx_t通过权重矩阵WxW_x的线性变换,二是上一时刻的隐藏状态ht1h_{t-1}通过权重矩阵WhW_h的线性变换,两者叠加后经tanhtanh激活函数输出。这种设计使hth_t融合了当前输入与历史信息,从而实现对序列前后依赖关系的记忆,完成对序列数据的时序建模。

Q2:嵌入层和独热编码的区别?为啥不用独热编码?

答:核心区别体现在维度特性与语义表达两方面。独热编码属于高维稀疏编码,其维度等于词表大小,且向量间相互正交,无法体现语义关联;嵌入层属于低维稠密编码,可通过训练将离散符号映射到固定维度的稠密向量空间,且能使语义相近的符号对应向量距离更近,天然具备语义表达能力。不使用独热编码的核心原因是其高维稀疏特性会导致计算成本激增,且缺乏语义建模能力,无法满足序列任务的核心需求。

Q3:nn.RNN的batch_first参数有啥用?不加会咋样?

答:batch_first参数的核心作用是指定输入/输出张量的维度顺序。当batch_first=True时,输入张量维度为(batch_size, seq_len, input_dim),批量维度在前;默认batch_first=False时,维度顺序为(seq_len, batch_size, input_dim),序列长度维度在前。若不加该参数,需严格按照默认维度顺序构造输入,否则会出现维度不匹配错误,增加代码调试成本,尤其在批量训练场景下,不符合开发者对“批量优先”的常规使用习惯。

📌 下期预告

咱今天搞定了“循环层+嵌入层”的基础,相当于拿到了“序列数据的入门钥匙”!下一篇直接上硬菜——用这两个层搭建完整的RNN(循环神经网络),还会拆解LSTM和GRU的核心改进(解决梯度消失问题)!用RNN/LSTM处理文本分类任务(比如情感分析),对比普通RNN和LSTM的效果,拆解LSTM“门控机制”的原理(就像给AI的记忆装“开关”,该记的记,该忘的忘)。咱一步步从基础啃到高阶!