【实战】自然语言处理--长文本分类(3)HAN算法

HAN算法

1. 算法定义

HAN 是一种专门用于文档级文本分类的深度学习模型,通过"先词后句"的层次结构和注意力机制,自动学习出对分类最重要的词和句子表示。

  • 层次化:模拟人类阅读理解流程,先关注词--->形成句子表示,再关注句子--->形成文档表示。
  • 注意力:在每一层对不同粒度(词/句子)分配可学习的权重,以突出关键成分。

2. 算法原理

  1. 分层表示

    • 词级表示:先对句子中每个词做嵌入,然后通过双向 GRU(或 LSTM)编码上下文信息,得到每个词的上下文向量。
    • 句子级表示:再对句子表示序列(由词级注意力池化得到)做双向 GRU 编码,得到每个句子的上下文向量。
  2. 注意力机制

    • 词级注意力:对词级上下文向量进行两层全连通映射,计算得到每个词的注意力权重,池化为句子向量。
    • 句子级注意力:同理,对句子级上下文向量计算注意力权重,池化为文档向量。
  3. 分类

    • 将最终的文档向量输入全连接层,输出类别分布。

3. 模型结构示意

复制代码
文档(多句)  
 └─ 句子i  
     └─ 词j  
         └─ Embedding  
             └─ BiGRU  
                 └─ 词级注意力池化 → 句子向量  
 └─ 句子级 BiGRU  
     └─ 句子级注意力池化 → 文档向量  
 └─ Dropout → 全连接 → Softmax → 类别概率

4. 关键模型代码

python 复制代码
class MyHAN(nn.Module):
    def __init__(...):
        # 词嵌入
        self.embedding = nn.Embedding(vocab_size, embedding_dim, padding_idx=pad_id)
        # 词级 BiGRU + 注意力
        self.word_gru = nn.GRU(embedding_dim, hidden_size, bidirectional=True, batch_first=True)
        self.word_attn_fc = nn.Linear(2*hidden_size, hidden_size)
        self.word_ctx    = nn.Linear(hidden_size, 1, bias=False)
        # 句子级 BiGRU + 注意力
        self.sent_gru = nn.GRU(2*hidden_size, hidden_size, bidirectional=True, batch_first=True)
        self.sent_attn_fc = nn.Linear(2*hidden_size, hidden_size)
        self.sent_ctx    = nn.Linear(hidden_size, 1, bias=False)
        # 输出层
        self.classifier = nn.Linear(2*hidden_size, num_classes)

    def forward(self, x):
        # x: [B, S, W]
        B, S, W = x.size()
        # 词级处理
        x = self.embedding(x).view(B*S, W, -1)            # → [B*S, W, emb]
        H_w, _ = self.word_gru(x)                         # → [B*S, W, 2H]
        u_w    = torch.tanh(self.word_attn_fc(H_w))       # → [B*S, W, H]
        a_w    = F.softmax(self.word_ctx(u_w), dim=1)     # → [B*S, W, 1]
        s      = torch.bmm(a_w.transpose(1,2), H_w)       # → [B*S, 1, 2H] → [B, S, 2H]
        # 句子级处理
        H_s, _ = self.sent_gru(s)                         # → [B, S, 2H]
        u_s    = torch.tanh(self.sent_attn_fc(H_s))       # → [B, S, H]
        a_s    = F.softmax(self.sent_ctx(u_s), dim=1)     # → [B, S, 1]
        v      = torch.bmm(a_s.transpose(1,2), H_s).squeeze(1)  # → [B, 2H]
        # 分类
        out    = self.classifier(v)                       # → [B, num_classes]
        return out

5. 训练过程

  1. 数据准备

    • 分句、分词、去停用词
    • 构建/加载词表,将每个句子填充/截断到固定长度 sent_maxlen,文档填充/截断到 doc_maxlen
  2. DataLoader

    • get_loader('train',True)get_loader('val',False) 返回迭代器
  3. 优化配置

    • 损失函数:CrossEntropyLoss
    • 优化器:AdamW(权重衰减防过拟合)
    • 学习率调度:分段衰减(Epoch < 0.3EPOCHS → 1.0;0.3--0.6 → 0.5;> 0.6 → 0.1)
  4. 训练循环

    plain 复制代码
    for epoch in 1...E:
        for batch in train_loader:
            forward → 损失 → backward → 更新参数
        for batch in val_loader:
            forward → 计算验证损失/准确率
        保存最佳模型;记录并绘制训练/验证曲线
  5. 输出

    • 最佳模型权重 .pt
    • training_history.csv(记录每个 epoch 的 loss/acc)
    • han_loss.pnghan_acc.png(训练 & 验证曲线)

6. 算法作用与优势

  • 长文本适应性:分层处理避免一次性 RNN 长序列导致的梯度消失/爆炸。
  • 可解释性:注意力权重可视化,能够展示哪些词和句子对分类贡献最大。
  • 性能稳定:实验表明 HAN 在多种文档分类任务上超越传统 CNN/RNN。

代码分析

python 复制代码
class MyHAN(nn.Module):
    """
    Hierarchical Attention Network for document classification.

    Args:
        max_word_num (int): 最大词数 (句子长度)
        max_sents_num (int): 最大句子数 (文档长度)
        vocab_size (int): 词表大小
        hidden_size (int): GRU 隐藏单元维度
        num_classes (int): 分类数目
        embedding_dim (int): 词嵌入维度
        dropout_p (float): dropout 概率
    """
    def __init__(self,
                 max_word_num: int,
                 max_sents_num: int,
                 vocab_size: int,
                 hidden_size: int,
                 num_classes: int,
                 embedding_dim: int,
                 dropout_p: float = 0.5):
        super(MyHAN, self).__init__()
        self.max_word_num = max_word_num
        self.max_sents_num = max_sents_num
        self.hidden_size = hidden_size
        self.num_classes = num_classes
        self.embedding_dim = embedding_dim

        # 词嵌入层
        self.embedding = nn.Embedding(vocab_size, embedding_dim, padding_idx=pad_id)
        self.dropout_embed = nn.Dropout(dropout_p)

        # 词级别双向 GRU & 注意力
        self.word_gru = nn.GRU(
            input_size=embedding_dim,
            hidden_size=hidden_size,
            bidirectional=True,
            batch_first=True,
            dropout=0.2 if dropout_p < 1 else 0.0
        )
        self.word_attn_fc = nn.Linear(2 * hidden_size, hidden_size)
        self.word_context_vector = nn.Linear(hidden_size, 1, bias=False)

        # 句子级别双向 GRU & 注意力
        self.sent_gru = nn.GRU(
            input_size=2 * hidden_size,
            hidden_size=hidden_size,
            bidirectional=True,
            batch_first=True,
            dropout=0.2 if dropout_p < 1 else 0.0
        )
        self.sent_attn_fc = nn.Linear(2 * hidden_size, hidden_size)
        self.sent_context_vector = nn.Linear(hidden_size, 1, bias=False)

        # 文档级输出
        self.dropout = nn.Dropout(dropout_p)
        self.classifier = nn.Linear(2 * hidden_size, num_classes)

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        # x: [batch, max_sents_num, max_word_num]
        batch_size, S, W = x.size()

        # 词嵌入
        x = self.embedding(x)               # [batch, S, W, emb]
        x = self.dropout_embed(x)
        x = x.view(batch_size * S, W, self.embedding_dim)

        # 词级别 GRU
        self.word_gru.flatten_parameters()
        H_w, _ = self.word_gru(x)          # [batch*S, W, 2*hidden]
        # 注意力权重
        u_w = torch.tanh(self.word_attn_fc(H_w))  # [batch*S, W, hidden]
        a_w = self.word_context_vector(u_w)      # [batch*S, W, 1]
        a_w = F.softmax(a_w, dim=1).transpose(1, 2)  # [batch*S, 1, W]
        # 句子向量
        s = torch.bmm(a_w, H_w).squeeze(1)         # [batch*S, 2*hidden]
        s = s.view(batch_size, S, 2 * self.hidden_size)

        # 句子级别 GRU
        self.sent_gru.flatten_parameters()
        H_s, _ = self.sent_gru(s)         # [batch, S, 2*hidden]
        # 注意力权重
        u_s = torch.tanh(self.sent_attn_fc(H_s))  # [batch, S, hidden]
        a_s = self.sent_context_vector(u_s)      # [batch, S, 1]
        a_s = F.softmax(a_s, dim=1).transpose(1, 2)  # [batch, 1, S]
        # 文档向量
        v = torch.bmm(a_s, H_s).squeeze(1)        # [batch, 2*hidden]

        # 分类
        out = self.classifier(self.dropout(v))   # [batch, num_classes]
        return out

1. 类定义与参数

python 复制代码
class MyHAN(nn.Module):
    """
    Hierarchical Attention Network for document classification.

    Args:
        max_word_num (int): 最大词数 (句子长度)
        max_sents_num (int): 最大句子数 (文档长度)
        vocab_size (int): 词表大小
        hidden_size (int): GRU 隐藏单元维度
        num_classes (int): 分类数目
        embedding_dim (int): 词嵌入维度
        dropout_p (float): dropout 概率
    """
  • max_word_num(W)
    每个句子的最大词数,用于固定输入长度。
  • max_sents_num(S)
    每篇文档的最大句子数,同样用于固定层次输入。
  • vocab_size、embedding_dim
    用于构造词嵌入矩阵。
  • hidden_size
    GRU 隐藏层的维度,注意是单向的维度,双向后实际输出维度为 2*hidden_size
  • num_classes
    最终分类的类别个数。
  • dropout_p
    各层 dropout 率,帮助防止过拟合。

2. 构造函数 __init__

python 复制代码
# 词嵌入 + Dropout
self.embedding       = nn.Embedding(vocab_size, embedding_dim, padding_idx=pad_id)
self.dropout_embed   = nn.Dropout(dropout_p)
  • Embedding :将词索引映射到稠密向量空间;padding_idx 保证填充位置不更新。
  • dropout_embed:对嵌入后向量进行 dropout。
python 复制代码
# 词级别双向 GRU
self.word_gru        = nn.GRU(
    input_size=embedding_dim,
    hidden_size=hidden_size,
    bidirectional=True,
    batch_first=True,
    dropout=0.2 if dropout_p < 1 else 0.0
)
# 词级注意力:先映射到 hidden_size 空间,再投影到标量
self.word_attn_fc        = nn.Linear(2 * hidden_size, hidden_size)
self.word_context_vector = nn.Linear(hidden_size, 1, bias=False)
  • word_gru :对每个句子的词序列做双向编码,输出维度 [batch⋅S, W, 2*hidden_size]
  • word_attn_fc:将每个词的上下文向量投影到 attention 空间。
  • word_context_vector:将 attention 空间向量映射到一个标量权重。
python 复制代码
# 句子级别双向 GRU
self.sent_gru         = nn.GRU(
    input_size=2 * hidden_size,
    hidden_size=hidden_size,
    bidirectional=True,
    batch_first=True,
    dropout=0.2 if dropout_p < 1 else 0.0
)
self.sent_attn_fc        = nn.Linear(2 * hidden_size, hidden_size)
self.sent_context_vector = nn.Linear(hidden_size, 1, bias=False)
  • 与词级几乎相同,区别在于输入维度是词级编码后的 2*hidden_size,输出形状 [batch, S, 2*hidden_size]
python 复制代码
# 文档级输出层
self.dropout     = nn.Dropout(dropout_p)
self.classifier  = nn.Linear(2 * hidden_size, num_classes)
  • dropout:在最终文档向量上再做一次随机失活。
  • classifier :将 2*hidden_size 维的文档表示映射为类别 logits。

3. 前向传播 forward

python 复制代码
batch_size, S, W = x.size()  # x.shape = [B, S, W]
  • B:批大小
  • S:句子数
  • W:每句词数

3.1 词嵌入与重排

python 复制代码
x = self.embedding(x)        # → [B, S, W, emb]
x = self.dropout_embed(x)
x = x.view(batch_size * S, W, self.embedding_dim)
  • 嵌入后重塑为 [B⋅S, W, emb],方便送入同一个 GRU 做词级编码。

3.2 词级别双向 GRU

python 复制代码
self.word_gru.flatten_parameters()
H_w, _ = self.word_gru(x)    # → [B⋅S, W, 2*hidden_size]
  • flatten_parameters():优化多卡/多线程时 GRU 权重布局。
  • H_w 每个词都有一个上下文表示。

3.3 词级注意力池化

python 复制代码
u_w = torch.tanh(self.word_attn_fc(H_w))      # → [B⋅S, W, hidden_size]
a_w = self.word_context_vector(u_w)           # → [B⋅S, W, 1]
a_w = F.softmax(a_w, dim=1).transpose(1, 2)   # → [B⋅S, 1, W]
s   = torch.bmm(a_w, H_w).squeeze(1)          # → [B⋅S, 2*hidden_size]
s   = s.view(batch_size, S, 2*self.hidden_size)
  1. 映射H_wu_w(tanh 激活)
  2. 打分u_wa_w(标量注意力分数)
  3. 归一化:按词位置做 softmax
  4. 加权和:得到每个句子的固定维度向量
  5. 恢复层次 :重塑回 [B, S, 2*hidden_size]

3.4 句子级别双向 GRU

python 复制代码
self.sent_gru.flatten_parameters()
H_s, _ = self.sent_gru(s)   # → [B, S, 2*hidden_size]
  • 将句子向量序列编码为上下文相关的句子表示。

3.5 句子级注意力池化

python 复制代码
u_s = torch.tanh(self.sent_attn_fc(H_s))       # → [B, S, hidden_size]
a_s = self.sent_context_vector(u_s)            # → [B, S, 1]
a_s = F.softmax(a_s, dim=1).transpose(1, 2)    # → [B, 1, S]
v   = torch.bmm(a_s, H_s).squeeze(1)           # → [B, 2*hidden_size]
  • 与词级注意力类似:计算每个句子的权重并做加权和,得到文档级向量 v

3.6 分类输出

python 复制代码
out = self.classifier(self.dropout(v))  # → [B, num_classes]
return out
  • 对文档表示 v 做 dropout 再线性变换,输出各类别的对数概率(logits)。

补充问题

词级别和句级别不同?

示例回顾
  • 文档 0:"今天天气很好。我喜欢散步。晚上吃饭。"
  • 文档 1:"机器学习有趣"
  • 词表索引同前:
    今天天气→2, 好→3, 我→4, 喜欢→5, 散步→6, 晚上→7, 吃饭→8, 机器学习→9, 有趣→10, <pad>→1

参数:

  • doc_maxlen = 3(最多 3 句)
  • sent_maxlen = 4(每句 4 词)
  • 批大小 B = 2
1. 层次化输入 [B, S, W] = [2, 3, 4]
复制代码
batch_tensor =
[
  # 文档 0
  [
    [2, 3, 1, 1],   # 今天天气 好 <pad> <pad>
    [4, 5, 6, 1],   # 我 喜欢 散步 <pad>
    [7, 8, 1, 1],   # 晚上 吃饭 <pad> <pad>
  ],
  # 文档 1
  [
    [9,10, 1, 1],   # 机器学习 有趣 <pad> <pad>
    [1, 1, 1, 1],   # <pad> ×4
    [1, 1, 1, 1],   # <pad> ×4
  ]
]
  • 第 1 维(B)是文档
  • 第 2 维(S)是句子
  • 第 3 维(W)是词

模型先按句子(W 维)做词级 BiGRU + 注意力,然后按文档(S 维)做句子级 BiGRU + 注意力。

2. 普通词级平铺输入 [B, L]

常见做法是把整个文档看成一串词,拼成定长序列,比如 L = doc_maxlen * sent_maxlen = 12

复制代码
# 对文档 0:"今天天气 很好 我 喜欢 散步 晚上 吃饭"
flat0 = [2,3,4,5,6,7,8]    # 共 7 词
# pad 到长度 12:
flat0_padded = [2,3,4,5,6,7,8, 1,1,1,1,1]

# 对文档 1:"机器学习 有趣"
flat1 = [9,10]
flat1_padded = [9,10, 1,1,1,1,1,1,1,1,1,1]

合成批次:

python 复制代码
flat_batch = [
  [2,3,4,5,6,7,8,1,1,1,1,1],
  [9,10,1,1,1,1,1,1,1,1,1,1]
]  # shape [2,12]
  • 一层 BiGRU/LSTM + 注意力:直接对长度为 12 的序列编码。
  • 缺点
    1. 丢失层次信息:无法区分句子边界,注意力只能在词级别分配。
    2. 长序列梯度问题:文档很长时,单层 RNN 易梯度消失/爆炸。
  • 优点:实现简洁、输入结构单一。
对比总结
特性 层次化 HAN 平铺词级 RNN/CNN
输入形状 [B, S, W] [B, L]
模型架构 词级 + 句子级 双层 GRU + 注意力 单层 GRU/CNN + 注意力/池化
参数共享 句内、句间分别有不同参数 所有词使用同一组参数
可解释性 词和句子层面都可视化注意力 只有词层面注意力
长文本处理 分段编码更稳定 长度 L 大时训练不稳定

句子级别的优势?

在 HAN 中引入"句子级"这一层,主要是为了解决纯词级模型在长文档处理和语义抽象层面上的局限。下面从几个角度说明句子级别相较于仅词级别的优势:

  1. 减少序列长度,缓解梯度问题

    • 纯词级模型需要把整篇文档当成一条长序列(长度可能几百、上千词)来编码。
    • RNN/LSTM/GRU 在处理极长序列时容易出现梯度消失或梯度爆炸,训练不稳定。
    • HAN 先在"句子内部"做词级编码,把每一句浓缩成一个固定维度的句子向量;然后只在这些句子向量上再做一次 RNN,序列长度从"词数"降到"句子数",大大缩短了第二层的序列长度,提升了模型对长文本的训练稳定性。
  2. 层次化语义抽象

    • 语言本身是层次化的:词组成短语/句子,句子组成段落/文档。
    • 词级注意力只能告诉你"哪些词重要",但无法告诉你"哪些句子重要"。
    • 加入句子级后,模型能够先在词层面提取重要信息,再在句子层面进一步抽象、筛选出核心句子,实现二次语义筛选,得到更精准的文档表示。
  3. 更强的可解释性

    • 词级注意力权重告诉我们"句子里哪些词最关键";句子级注意力权重则告诉我们"哪些句子对最终分类贡献最大"。
    • 这种双层注意力可视化,使得模型不仅划出关键词,还能帮我们定位核心段落/句子,便于人机交互和结果验证。
  4. 参数与计算的有效分离

    • 词级 GRU 和句子级 GRU 分工明确:前者关注同一句内部的短程依赖,后者关注句与句之间的长程依赖。
    • 这样一来,同样的模型容量下,比起单层超长序列的编码,层次化结构能够更高效地分配参数资源,也更容易捕捉不同层面的特征。
  5. 适应不同粒度的任务需求

    • 对于需要精细定位关键句子的应用(如文档摘要、观点抽取),句子级注意力能直接告诉我们要点句。
    • 对于需要全文整体理解的任务(情感分析、主题分类),词级和句子级结合能更全面地融合各层面信息。
相关推荐
码界奇点2 小时前
解密AI语言模型从原理到应用的全景解析
人工智能·语言模型·自然语言处理·架构
余衫马2 小时前
你好,未来:零基础看懂大语言模型
人工智能·语言模型·自然语言处理·智能体
Shinom1ya_2 小时前
算法 day 46
数据结构·算法
夏鹏今天学习了吗3 小时前
【LeetCode热题100(64/100)】搜索旋转排序数组
算法·leetcode·职场和发展
2301_796512523 小时前
Rust编程学习 - 问号运算符会return一个Result 类型,但是如何使用main函数中使用问号运算符
开发语言·学习·算法·rust
小龙报3 小时前
算法通关指南:数据结构和算法篇 --- 队列相关算法题》--- 1. 【模板】队列,2. 机器翻译
c语言·开发语言·数据结构·c++·算法·学习方法·visual studio
晨非辰4 小时前
【数据结构初阶】--从排序算法原理分析到代码实现操作,参透插入排序的奥秘!
c语言·开发语言·数据结构·c++·算法·面试·排序算法
三川6985 小时前
排序算法介绍
数据结构·算法·排序算法
大大dxy大大9 小时前
机器学习实现逻辑回归-癌症分类预测
机器学习·分类·逻辑回归