【实战】自然语言处理--长文本分类(3)HAN算法
HAN算法
1. 算法定义
HAN 是一种专门用于文档级文本分类的深度学习模型,通过“先词后句”的层次结构和注意力机制,自动学习出对分类最重要的词和句子表示。
- 层次化:模拟人类阅读理解流程,先关注词—>形成句子表示,再关注句子—>形成文档表示。
- 注意力:在每一层对不同粒度(词/句子)分配可学习的权重,以突出关键成分。
2. 算法原理
-
分层表示
- 词级表示:先对句子中每个词做嵌入,然后通过双向 GRU(或 LSTM)编码上下文信息,得到每个词的上下文向量。
- 句子级表示:再对句子表示序列(由词级注意力池化得到)做双向 GRU 编码,得到每个句子的上下文向量。
-
注意力机制
- 词级注意力:对词级上下文向量进行两层全连通映射,计算得到每个词的注意力权重,池化为句子向量。
- 句子级注意力:同理,对句子级上下文向量计算注意力权重,池化为文档向量。
-
分类
- 将最终的文档向量输入全连接层,输出类别分布。
3. 模型结构示意
文档(多句) └─ 句子i └─ 词j └─ Embedding └─ BiGRU └─ 词级注意力池化 → 句子向量 └─ 句子级 BiGRU └─ 句子级注意力池化 → 文档向量 └─ Dropout → 全连接 → Softmax → 类别概率
4. 关键模型代码
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. 训练过程
-
数据准备:
- 分句、分词、去停用词
- 构建/加载词表,将每个句子填充/截断到固定长度
sent_maxlen,文档填充/截断到doc_maxlen
-
DataLoader:
get_loader('train',True)和get_loader('val',False)返回迭代器
-
优化配置:
- 损失函数:
CrossEntropyLoss - 优化器:
AdamW(权重衰减防过拟合) - 学习率调度:分段衰减(Epoch < 0.3EPOCHS → 1.0;0.3–0.6 → 0.5;> 0.6 → 0.1)
- 损失函数:
-
训练循环:
for epoch in 1…E:for batch in train_loader:forward → 损失 → backward → 更新参数for batch in val_loader:forward → 计算验证损失/准确率保存最佳模型;记录并绘制训练/验证曲线 -
输出:
- 最佳模型权重
.pt training_history.csv(记录每个 epoch 的 loss/acc)han_loss.png、han_acc.png(训练 & 验证曲线)
- 最佳模型权重
6. 算法作用与优势
- 长文本适应性:分层处理避免一次性 RNN 长序列导致的梯度消失/爆炸。
- 可解释性:注意力权重可视化,能够展示哪些词和句子对分类贡献最大。
- 性能稳定:实验表明 HAN 在多种文档分类任务上超越传统 CNN/RNN。
代码分析
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_numself.max_sents_num = max_sents_numself.hidden_size = hidden_sizeself.num_classes = num_classesself.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)# 词级别 GRUself.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)# 句子级别 GRUself.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. 类定义与参数
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__
# 词嵌入 + 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。
# 词级别双向 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 空间向量映射到一个标量权重。
# 句子级别双向 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]。
# 文档级输出层
self.dropout = nn.Dropout(dropout_p)
self.classifier = nn.Linear(2 * hidden_size, num_classes)
- dropout:在最终文档向量上再做一次随机失活。
- classifier:将
2*hidden_size维的文档表示映射为类别 logits。
3. 前向传播 forward
batch_size, S, W = x.size() # x.shape = [B, S, W]
- B:批大小
- S:句子数
- W:每句词数
3.1 词嵌入与重排
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
self.word_gru.flatten_parameters()
H_w, _ = self.word_gru(x) # → [B⋅S, W, 2*hidden_size]
- flatten_parameters():优化多卡/多线程时 GRU 权重布局。
H_w每个词都有一个上下文表示。
3.3 词级注意力池化
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)
- 映射:
H_w→u_w(tanh 激活) - 打分:
u_w→a_w(标量注意力分数) - 归一化:按词位置做 softmax
- 加权和:得到每个句子的固定维度向量
- 恢复层次:重塑回
[B, S, 2*hidden_size]
3.4 句子级别双向 GRU
self.sent_gru.flatten_parameters()
H_s, _ = self.sent_gru(s) # → [B, S, 2*hidden_size]
- 将句子向量序列编码为上下文相关的句子表示。
3.5 句子级注意力池化
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 分类输出
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]
合成批次:
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 的序列编码。
- 缺点:
- 丢失层次信息:无法区分句子边界,注意力只能在词级别分配。
- 长序列梯度问题:文档很长时,单层 RNN 易梯度消失/爆炸。
- 优点:实现简洁、输入结构单一。
对比总结
| 特性 | 层次化 HAN | 平铺词级 RNN/CNN |
|---|---|---|
| 输入形状 | [B, S, W] | [B, L] |
| 模型架构 | 词级 + 句子级 双层 GRU + 注意力 | 单层 GRU/CNN + 注意力/池化 |
| 参数共享 | 句内、句间分别有不同参数 | 所有词使用同一组参数 |
| 可解释性 | 词和句子层面都可视化注意力 | 只有词层面注意力 |
| 长文本处理 | 分段编码更稳定 | 长度 L 大时训练不稳定 |
句子级别的优势?
在 HAN 中引入“句子级”这一层,主要是为了解决纯词级模型在长文档处理和语义抽象层面上的局限。下面从几个角度说明句子级别相较于仅词级别的优势:
-
减少序列长度,缓解梯度问题
- 纯词级模型需要把整篇文档当成一条长序列(长度可能几百、上千词)来编码。
- RNN/LSTM/GRU 在处理极长序列时容易出现梯度消失或梯度爆炸,训练不稳定。
- HAN 先在“句子内部”做词级编码,把每一句浓缩成一个固定维度的句子向量;然后只在这些句子向量上再做一次 RNN,序列长度从“词数”降到“句子数”,大大缩短了第二层的序列长度,提升了模型对长文本的训练稳定性。
-
层次化语义抽象
- 语言本身是层次化的:词组成短语/句子,句子组成段落/文档。
- 词级注意力只能告诉你“哪些词重要”,但无法告诉你“哪些句子重要”。
- 加入句子级后,模型能够先在词层面提取重要信息,再在句子层面进一步抽象、筛选出核心句子,实现二次语义筛选,得到更精准的文档表示。
-
更强的可解释性
- 词级注意力权重告诉我们“句子里哪些词最关键”;句子级注意力权重则告诉我们“哪些句子对最终分类贡献最大”。
- 这种双层注意力可视化,使得模型不仅划出关键词,还能帮我们定位核心段落/句子,便于人机交互和结果验证。
-
参数与计算的有效分离
- 词级 GRU 和句子级 GRU 分工明确:前者关注同一句内部的短程依赖,后者关注句与句之间的长程依赖。
- 这样一来,同样的模型容量下,比起单层超长序列的编码,层次化结构能够更高效地分配参数资源,也更容易捕捉不同层面的特征。
-
适应不同粒度的任务需求
- 对于需要精细定位关键句子的应用(如文档摘要、观点抽取),句子级注意力能直接告诉我们要点句。
- 对于需要全文整体理解的任务(情感分析、主题分类),词级和句子级结合能更全面地融合各层面信息。
