Base LLM | 从 NLP 到 LLM 的算法全栈教程 第三天

RNN

如何处理序列信息

词向量取均值:忽略语序信息,将所有词语视为同等重要。

全连接网络(Fully Connected Network, FCN):每个词元孤立处理,没有考虑上下文的顺序关系和依赖关系。

1D-CNN:可以获取局部依赖关系,但感受野固定,无法设定所有句子的最佳窗口大小。

RNN 的思想是在处理序列的每一步时,网络不仅接收当前时间步的输入 xt,还会接收一个来自上一步的"记忆",即隐藏状态 ht−1。

所有时间步的U和W是共享的,可以处理任意长度的时间序列。

实践代码

python 复制代码
import torch
import torch.nn as nn
import numpy as np

# 约定: (B, T, E, H) 分别表示 批次/序列长度/输入维度/隐藏维度
B, E, H = 1, 128, 3


def prepare_inputs():
    """
    使用 NumPy 准备输入数据
    使用示例句子: "播放 周杰伦 的 《稻香》"
    构造最小词表和随机(可复现)词向量, 生成形状为 (B, T, E) 的输入张量。
    """
    np.random.seed(42)
    vocab = {"播放": 0, "周杰伦": 1, "的": 2, "《稻香》": 3}
    tokens = ["播放", "周杰伦", "的", "《稻香》"]
    ids = [vocab[t] for t in tokens]

    # 词向量表: (V, E)
    V = len(vocab)
    emb_table = np.random.randn(V, E).astype(np.float32)

    # 取出序列词向量并加上 batch 维度: (B, T, E)
    x_np = emb_table[ids][None]
    return tokens, x_np


def manual_rnn_numpy(x_np, U_np, W_np):
    """
    使用 NumPy 手动实现 RNN(无偏置): h_t = tanh(U x_t + W h_{t-1})
    
    Args:
        x_np: (B, T, E)
        U_np: (E, H)
        W_np: (H, H)
    Returns:
        outputs: (B, T, H)
        final_h: (B, H)
    """
    B_local, T_local, _ = x_np.shape
    h_prev = np.zeros((B_local, H), dtype=np.float32)
    steps = []
    for t in range(T_local):
        x_t = x_np[:, t, :]
        h_t = np.tanh(x_t @ U_np + h_prev @ W_np)
        print(h_t,x_t,h_prev,W_np)
        steps.append(h_t)
        h_prev = h_t
    outputs = np.stack(steps, axis=1)
    return outputs, h_prev


def pytorch_rnn_forward(x, U, W):
    """
    使用api nn.RNN (tanh, bias=False)。
    Returns:
        outputs: (B, T, H)
        final_h: (B, H)
    """
    rnn = nn.RNN(
        input_size=E,
        hidden_size=H,
        num_layers=1,
        nonlinearity='tanh',
        bias=False,
        batch_first=True,
        bidirectional=False,
    )
    with torch.no_grad():
        # PyTorch 内部存放的是转置后的权重
        rnn.weight_ih_l0.copy_(U.T)
        rnn.weight_hh_l0.copy_(W.T)
    y, h_n = rnn(x)
    return y, h_n.squeeze(0)



_, x_np = prepare_inputs()

# PyTorch 张量,用于 nn.RNN 模块
x = torch.from_numpy(x_np).float()

# 使用可学习参数 U, W(无偏置)
torch.manual_seed(7)
U = torch.randn(E, H)
W = torch.randn(H, H)

# --- 手写 RNN (使用 NumPy) ---
U_np = U.detach().numpy()
W_np = W.detach().numpy()

print("--- 手写 RNN (NumPy) ---")
out_manual_np, hT_manual_np = manual_rnn_numpy(x_np, U_np, W_np)
print("输入形状:", x_np.shape)
print("手写输出形状:", out_manual_np.shape)
print("手写最终隐藏形状:", hT_manual_np.shape)

print("\n--- PyTorch nn.RNN ---")
out_torch, hT_torch = pytorch_rnn_forward(x, U, W)
print("模块输出形状:", out_torch.shape)
print("模块最终隐藏形状:", hT_torch.shape)

print("\n--- 对齐验证 ---")
# 将 NumPy 结果转回 PyTorch 张量以进行比较
out_manual = torch.from_numpy(out_manual_np)
hT_manual = torch.from_numpy(hT_manual_np)

print("逐步输出一致:", torch.allclose(out_manual, out_torch, atol=1e-6))
print("最终隐藏一致:", torch.allclose(hT_manual, hT_torch, atol=1e-6))
print("最后一步输出等于最终隐藏:", torch.allclose(out_torch[:, -1, :], hT_torch, atol=1e-6))
python 复制代码
h_w = W
h_u = U
ht = torch.zeros(B, H)

_, x_np = prepare_inputs()
x = torch.from_numpy(x_np)

T = x.shape[1]
for t in range(T):
    x_t = x[:, t,:]
    h_prev = ht
    ht = F.tanh(h_prev @ h_w + x_t @ h_u)
print(ht)
print(ht[-1])
print(F.softmax(ht[-1]))

双向神经网络BiRNN

同时训练从左向右的正向RNN和从右往左的反向RNN,其中参数独立但损失相加,能够同时被正向反向网络优化bidirectional参数设置为 True,hidden_size为原来的两倍,存储当前时间步的正向隐藏层和反向隐藏层。

BiRNN依旧没有解决长距离依赖问题并且无法用于实时预测场景(需要提前看到后文)。

反向传播:重点!

反向传播会有一个累乘导致梯度消失,影响的是比较靠后的时间步在表征时吃不到前面的信息。

LSTM与GRU

LSTM

双轨并行:细胞状态Ct用于长期记忆,按元素加权与相加,无连乘;Ht短期记忆与当前的输出,计算依赖当前的Ct。

**门结构:**sigmoid为激活函数的全连接层,输入是xt和ht-1的拼接向量,输出是一个元素值在0-1内的向量,该向量用于与其他向量的按元素乘法。

等价于

LSTM如何解决长距离依赖:重点!

ft的连乘由Uf和Wf掌控,这两部分参数是可以学习的,可以使得

模型 梯度回传的乘积项 长距离下的梯度变化
普通 RNN ∏(tanh′⋅Wh​),每一项都 < 1,被动衰减 指数衰减到 0,梯度消失
LSTM ∏fi​,每一项由模型控制,关键信息下接近 1 几乎不衰减,梯度稳定回传

GRU

不再区分ct和ht,只在ht上进行传播,另一个是简化门控结构 ,将 LSTM 的三个门简化为了两个门。其中更新门(Update Gate, ztzt​) 的作用类似于 LSTM 中耦合的遗忘门和输入门,同时决定了保留多少旧信息以及接收多少新信息;**重置门(Reset Gate, rtrt​)**则决定在计算候选状态时忽略多少旧信息。

LSTM变体

窥孔连接 ,允许门控单元直接访问细胞状态:遗忘门和输入门 在做决策时会"窥视" 前一时刻的细胞状态 ct−1​,而输出门在做决策时则会"窥视" **当前刚刚更新的细胞状态 ct**。

耦合输出门和遗忘门,遗忘门= 1-输出门

基于LSTM做文本分类

与之前dnn的dataset,dataloader基本一致,collate_fn加上返回长度张量,Trainer和Predictor在遍历解析dataloader也同样要做长度张量的解析。

python 复制代码
def collate_fn(batch):
    max_batch_len = max(len(item["token_ids"]) for item in batch)
    
    batch_token_ids, batch_labels, batch_lengths = [], [], []

    for item in batch:
        token_ids = item["token_ids"]
        # 新增:记录真实长度
        lengths = len(token_ids)
        padding_len = max_batch_len - lengths
        
        padded_ids = token_ids + [0] * padding_len
        batch_token_ids.append(padded_ids)
        batch_labels.append(item["label"])
        # 新增:将长度加入列表
        batch_lengths.append(lengths)
        
    return {
        "token_ids": torch.tensor(batch_token_ids, dtype=torch.long),
        "labels": torch.tensor(batch_labels, dtype=torch.long),
        # 新增:返回长度张量
        "lengths": torch.tensor(batch_lengths, dtype=torch.long),
    }

模型构建

  • forward 函数现在额外接收 lengths 参数。
  • 打包 (Packing)pack_padded_sequence 是处理填充序列的关键。它会将一个填充过的批次数据(例如,多个句子被填充到相同长度)压缩成一个更紧凑的表示,LSTM 只需对真实的、非填充部分进行计算,大大提高了效率和准确性。
  • 最终状态提取 :LSTM 的输出 hidden 张量包含了所有层在最后一个时间步的隐藏状态。我们通常取最后一层(对于单向 LSTM 是 hidden[-1,:,:])作为整个序列的语义表示。如果是双向 LSTM,则需要拼接前向和后向的最终隐藏状态。
  • 最后,将这个代表序列的 hidden 向量送入分类器。
python 复制代码
class TextClassifierLSTM(nn.Module):
    def __init__(self, vocab_size, embed_dim, hidden_dim, num_classes, 
                 n_layers=1, dropout=0.3, bidirectional=False):
        super(TextClassifierLSTM, self).__init__()
        self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0)
        
        self.lstm = nn.LSTM(
            input_size=embed_dim, 
            hidden_size=hidden_dim, 
            num_layers=n_layers, 
            dropout=dropout,
            bidirectional=bidirectional,
            batch_first=True  # 关键参数:输入和输出张量的维度为 (batch, seq, feature)
        )
        
        num_directions = 2 if bidirectional else 1
        self.classifier = nn.Linear(hidden_dim * num_directions, num_classes)
        
    def forward(self, token_ids, lengths):
        embedded = self.embedding(token_ids)
        
        # 1. 打包序列
        packed_embedded = nn.utils.rnn.pack_padded_sequence(
            embedded, 
            lengths.cpu(),  # 长度必须在CPU上
            batch_first=True, 
            enforce_sorted=False
        )
        
        # 2. LSTM 前向传播
        #    hidden 和 cell 的形状: [n_layers * num_directions, batch_size, hidden_dim]
        packed_output, (hidden, cell) = self.lstm(packed_embedded)
        
        # 3. 提取最终隐藏状态用于分类
        if self.lstm.bidirectional:
            # 拼接最后一个时间步的前向和后向的隐藏状态
            # hidden[-2,:,:] 是前向的最后一个隐藏状态
            # hidden[-1,:,:] 是后向的最后一个隐藏状态
            hidden = torch.cat((hidden[-2,:,:], hidden[-1,:,:]), dim=1)
        else:
            # 只取最后一层的最后一个隐藏状态
            hidden = hidden[-1,:,:]
            
        # 4. 分类
        logits = self.classifier(hidden)
        return logits
相关推荐
刘 大 望2 小时前
MCP详细介绍以及IDE和Spring AI中应用
java·ide·人工智能·spring·ai·aigc·ai编程
无她022 小时前
AI为什么能“触类旁通”?万字拆解词嵌入(Embeddings)的底层逻辑
人工智能
广州赛远2 小时前
埃夫特ER6L码垛机器人防护服等级解析:避开选型误区与性能陷阱
网络·人工智能
Cx330❀2 小时前
Linux System V标准简介
大数据·linux·运维·服务器·人工智能
TG_yunshuguoji2 小时前
阿里云渠道商:百炼模型调优实战 5 步完成高效训练
人工智能·机器学习·阿里云·云计算
Zero2 小时前
机器学习线性代数--(9)叉积
人工智能·线性代数·机器学习
Zero2 小时前
机器学习线性代数--(8)点积与对偶性
人工智能·线性代数·机器学习
Mr.Winter`2 小时前
深度强化学习 | 基于Double DQN算法的移动机器人路径规划(附Pytorch实现)
人工智能·pytorch·深度学习·神经网络·机器人·自动驾驶·具身智能
凤山老林2 小时前
深度解析Skill机制:如何通过Spring AI + 阿里巴巴对接任意大模型实现智能技能调用?
java·人工智能·ai agent·skill·spring ai