用PyTorch从零实现HAN:透彻理解注意力机制与层级文本分类

发布时间:2026/9/20 16:56:36
用PyTorch从零实现HAN:透彻理解注意力机制与层级文本分类 在做评论情感分类的时候我被“注意力机制”这四个字坑过很久。公式背得出来Q、K、V也能说上几句可一到自己写代码就卡在维度对不上、mask不知道往哪儿放、注意力权重全一样这种问题上。后来认真读了一遍HAN——Hierarchical Attention Network用PyTorch从零实现完才真正想明白注意力机制不是魔法它就是“给序列里每个位置打分然后按分数做加权求和”。HAN是理解这件事最好的入门教材因为它把注意力套用了两次先看看句子里哪些词重要再看看文档里哪些句子重要。这篇文章就用PythonPyTorch把HAN完整搭起来词级注意力、句子级注意力的原理和代码都会写到包括维度变化、mask处理、训练细节和踩坑记录。适合已经会PyTorch基础操作、但想真正搞懂注意力机制的人代码可以直接拷下来跑。1. 从“平均池化”到“注意力”长文本分类需要一次思路升级1.1 为什么词向量取平均会让评论分类“和稀泥”如果你写过文本分类最容易想到的基线就是把一条文本按词查embedding然后对整条序列取平均得到句子向量再接全连接层。这个流程在短文本上能用但到了长文本上会暴露一个很直观的问题——信息被稀释了。举个例子一条两星评论房间很干净位置也好但是前台的服务态度让人非常不舒服。“干净”“好”“舒服”都是正面词“非常不舒服”是负面表达。词向量取平均之后正面词和负面词在向量空间里互相抵消最后得到的句向量偏中性模型很可能判成三星。但人读这条评论时都知道“但是”之后才是重点前面全是铺垫。再进一步如果用LSTM把整条评论编码拿最后一个隐状态去做分类理论上是能建模上下文的但长距离依赖和信息瓶颈仍然存在最后一个隐状态要把整篇信息压缩成一个固定向量文本越长它越记不住前面的细节。注意力机制做的事情本质上就是放弃“一个固定向量装下所有信息”的想法转而在所有隐状态里挑选对任务最有用的部分。1.2 HAN的核心思路词组成句子句子组成文档HAN是2016年提出的文档分类模型结构上很好理解只有三句话文档由句子组成句子由词组成。先用一个双向GRU编码每个句子里的词再用词级注意力把句子压缩成句向量。然后把这些句向量按顺序当成另一个双向GRU的输入编码完整篇文档再用句子级注意力把整篇文档压缩成文档向量最后接分类器。这个结构和人类阅读长文本的方式高度一致。你读评论时本来就不会平均用力第一遍扫过每句话注意力会落在“服务态度”“非常不舒服”这种信息量大的词上读完所有句子后也不会把每句话对最终判断的贡献平等看待而是会圈出最关键的句子综合得出正负倾向。HAN里的两个注意力模块就是在训练过程中学这两套打分标准。词级注意力决定“一句话里哪些词贡献了这句话的语义”句子级注意力决定“一篇文档里哪些句子贡献了整篇文档的立场”。这也是标题里说“别再死记硬背注意力机制”的原因你需要的不是背公式而是理解它是在解决“如何挑选重要信息”这个问题然后把它变成一个可以学习、可以调试的模块。2. 词级注意力和句子级注意力HAN里两套打分器的原理2.1 词编码器双向GRU的意义HAN的输入不是一个扁平的词序列而是一个“文档→句子→词”的三级结构。假设一个句子长度为T每个词先经过embedding层得到向量e_t然后送进一个双向GRU。双向GRU会输出两个方向的隐状态前向从句子开头读到结尾h_t^f知道当前词之前的信息。后向从句子结尾读到开头h_t^b知道当前词之后的信息。最终每个位置的隐状态是双向拼接h_t [h_t^f; h_t^b]这时候整个句子的编码结果是(T, 2 * word_hidden_size)。注意这个维度是双倍的我在第5章会单独讲这个坑。为什么用双向而不是单向因为对话里的关键含义经常在转折之后才出现。比如“环境不错但是隔音很差”读到“不错”这个位置时前向GRU还没看到后面的“但是”所以当前时刻的隐状态不知道后面会翻转后向GRU从右往左编码倒是知道“隔音很差”这个信息。把两个方向的隐状态拼起来“不错”这个位置就同时携带了前文信息和对整句语义的后顾信息。对情感词、否定词、转折词来说这个设计很关键。2.2 词级注意力一个可学习的打分器拿到每个词的隐状态h_it之后词级注意力要做的是判断一个词到底重不重要然后用权重把所有词的隐状态合并成一个句子向量。公式是u_it tanh(W_w * h_it b_w) alpha_it softmax(u_it^T * u_w) s_i Σ_t alpha_it * h_it拆开看这三步第一步把隐状态h_it从编码空间线性映射到“注意力打分空间”然后过tanh。注意这不是随便加的直接拿原始隐状态和上下文向量做点积也能算分但会限制打分空间的表达用一层带tanh的变换模型就能学习“哪种形态的隐状态更容易被挑出来”。第二步把映射后的向量和一个可训练向量u_w做点积得到标量得分。这个u_w是上下文向量context vector一个随机初始化、随训练更新的参数。你可以把它理解成一个“评审标准”每个词先在打分空间里展示自己的特征然后和评审标准比对谁更匹配谁得分就高。第三步所有位置的得分做softmax归一化得到权重alpha_it最后对原始隐状态h_it加权求和得到这一句话的向量s_i。这里为什么加权的是原始隐状态而不是映射后的u_it因为u_it是专门用来打分的它在“保留语言信息”上不如原始隐状态完整打分和编码的职责分开效果更稳。这种打分方式和Bahdanau注意力几乎一样属于加性注意力核心是“用一个小网络算相关度”而不是Transformer那种直接点乘。2.3 句子级注意力同一套逻辑换一个层级把整篇文档的所有句向量收集起来会得到序列s_1, s_2, ..., s_L。这一串向量会再经过一个双向GRU得到每个句子的隐状态h_i^s [h_i^{s,f}; h_i^{s,b}]然后句子级注意力复用完全相同的公式u_i tanh(W_s * h_i^s b_s) alpha_i softmax(u_i^T * u_s) v Σ_i alpha_i * h_i^s区别只是打分对象从“单词隐状态”变成了“句子隐状态”。上下文向量从u_w换成了u_s代表“行业标准里重要句子长什么样”。最后得到的v就是整篇文档的向量再经过一个全连接分类层logits W_c * v b_c你会发现HAN其实没有发明什么复杂数学它就是把注意力机制当成一个通用模块在词级用一次在句子级再用一次。正因如此代码层面也可以真正的“复用同一个AttentionLayer”这点对实现非常友好。3. PyTorch从零搭建HAN维度、mask和前向传播3.1 数据形态与两层Padding策略HAN的实现难点不在网络结构本身在于数据怎么组织。一篇文档不能简单拉平成词序列因为要保留“句子”这个层级。我采用的格式是document三维张量(B, S, T)B是batch中的文档数S是固定句子数T是固定每句的词数。word_mask(B, S, T)每个位置是否为真实词。sentence_mask(B, S)每个句子是否至少包含一个真实词。Padding时统一用padding_idx0embedding层会对0位置输出全0向量并且反向传播时不会更新梯度这是PyTorch里最省事的做法。为什么不能像普通序列模型那样直接展平成(B, S*T)因为一旦展平句子边界就消失了后面没法做两级注意力。HAN必须让模型知道“这些词属于第1句另一些词属于第5句”所以三维张量是必须的。3.2 AttentionLayer词级和句子级共用的核心模块先实现最核心的注意力层。这个模块不关心输入序列到底是词还是句子它只做一件事算子序列里每个位置的权重然后加权求和。import torch import torch.nn as nn import torch.nn.functional as F class AttentionLayer(nn.Module): HAN中一个层级的注意力模块 线性映射 tanh - 打分 - mask屏蔽padding - softmax - 加权求和 def __init__(self, hidden_size): super().__init__() self.hidden_size hidden_size self.projection nn.Linear(hidden_size, hidden_size) self.context_vector nn.Parameter(torch.randn(hidden_size, 1)) self._init_weights() def _init_weights(self): nn.init.xavier_uniform_(self.projection.weight) nn.init.constant_(self.projection.bias, 0.0) nn.init.xavier_uniform_(self.context_vector) def forward(self, outputs, mask): # outputs: (batch, seq_len, hidden_size) # mask: (batch, seq_len)1表示有效位置0表示padding u torch.tanh(self.projection(outputs)) # context_vector: (hidden_size, 1)点积得到每个位置标量分数 scores torch.matmul(u, self.context_vector).squeeze(-1) if mask is not None: # padding位置不能参与softmax先填充一个极小值 scores scores.masked_fill(mask 0, -1e9) attention_weights torch.softmax(scores, dim-1) attended torch.sum(attention_weights.unsqueeze(-1) * outputs, dim1) return attended, attention_weights这里的masked_fill(mask 0, -1e9)是核心细节。padding位置如果填0softmax会把它们也当成有效位置分配权重只有填一个极大的负数softmax之后权重才趋近于0。另一个更稳妥的写法是torch.finfo(scores.dtype).min但一般-1e9在FP32下够用。context_vector初始化也很关键。nn.Parameter(torch.randn(hidden_size, 1))之后我用了xavier_uniform_如果初始化为全0所有位置得分相等注意力就会退化成平均池化这个坑放到第5章详细说。3.3 完整网络两级GRU与注意力的维度衔接有了AttentionLayer整个HAN就非常简洁class HierarchicalAttentionNetwork(nn.Module): def __init__(self, vocab_size, embedding_dim, word_hidden_size, sentence_hidden_size, num_classes2, dropout0.5): super().__init__() self.embedding nn.Embedding(vocab_size, embedding_dim, padding_idx0) # 词级编码器 self.word_gru nn.GRU(embedding_dim, word_hidden_size, bidirectionalTrue, batch_firstTrue) self.word_attention AttentionLayer(2 * word_hidden_size) # 句子级编码器 self.sentence_gru nn.GRU(2 * word_hidden_size, sentence_hidden_size, bidirectionalTrue, batch_firstTrue) self.sentence_attention AttentionLayer(2 * sentence_hidden_size) self.classifier nn.Sequential( nn.Dropout(dropout), nn.Linear(2 * sentence_hidden_size, num_classes) ) def forward(self, document, word_masksNone, sentence_masksNone): # document: (B, S, T)S为句子数T为每个句子的词数 B, S, T document.size() # 词级把每个句子当独立样本 document document.reshape(B * S, T) word_emb self.embedding(document) word_outputs, _ self.word_gru(word_emb) # word_outputs: (B*S, T, 2*word_hidden) if word_masks is not None: word_masks_flat word_masks.reshape(B * S, T) else: word_masks_flat None sentence_vecs, word_weights self.word_attention( word_outputs, word_masks_flat ) # sentence_vecs: (B*S, 2*word_hidden)这是每句话的向量 # 恢复成文档维度 sentence_vecs sentence_vecs.reshape(B, S, -1) # 句子级把句向量序列当成另一个GRU的输入 sentence_outputs, _ self.sentence_gru(sentence_vecs) # sentence_outputs: (B, S, 2*sentence_hidden) doc_vec, sentence_weights self.sentence_attention( sentence_outputs, sentence_masks ) # doc_vec: (B, 2*sentence_hidden) logits self.classifier(doc_vec) return logits, word_weights, sentence_weights几个维度衔接点需要特别记一下word_gru的输入是(B*S, T, embedding_dim)输出word_outputs的最后一维是2 * word_hidden_size因为双向。word_attention的hidden_size必须等于2 * word_hidden_size。sentence_vecs的feature维是2 * word_hidden_size所以sentence_gru的input_size也是2 * word_hidden_size。sentence_gru是双向的所以sentence_attention的hidden_size是2 * sentence_hidden_size。最终分类器的输入特征必须和doc_vec一致也就是2 * sentence_hidden_size。可以跑一个随机输入验证前向传播model HierarchicalAttentionNetwork( vocab_size1000, embedding_dim64, word_hidden_size32, sentence_hidden_size32, num_classes2, ) docs torch.randint(0, 1000, (4, 8, 16)) word_masks (docs ! 0) sentence_masks word_masks.any(dim-1) logits, word_weights, sentence_weights model(docs, word_masks, sentence_masks) print(logits.shape) # torch.Size([4, 2]) print(word_weights.shape) # torch.Size([32, 16])因为 B*S 32 print(sentence_weights.shape) # torch.Size([4, 8])3.4 为什么不直接pip install一个现成的HAN库市面上有一些NLP库封装了HAN直接调用也能跑。但我建议至少在项目里自己实现一遍原因很实际第一你能完全控制mask行为。很多封装库为了通用性对不同padding策略的支持不够灵活你业务里的文档可能既有“句子截断”又有“词截断”没有源码在手很难调试。第二可以方便地接入预训练词向量。用nn.Embedding.from_pretrained替换self.embedding再设置padding_idx0这种定制在封装库里反而要塞参数。第三中间权重可以直接导出。词级注意力和句子级注意力的权重就是word_weights和sentence_weights业务方问起来你可以把最重要的词和句子直接列出来。这也是HAN在评论分析这类任务里最大的卖点。4. 训练流程与注意力可视化跑通它才能验证理解4.1 从原始文本到张量数据处理最容易被低估模型结构只是一半数据处理决定了训练能不能收敛。我的处理流程是清洗文本去掉HTML标签、URL、多余空格英文统一小写。分句英文用re.split(r(?[.!?])\s, text)中文用句号、感叹号、问号切分。分词英文split()中文用jieba。构建词表padding_idx0OOV用1。截断每个句子最多保留max_tokens个词每篇文档最多保留max_sentences个句子。构造word_mask和sentence_mask。一个简单的Dataset骨架from torch.utils.data import Dataset class DocumentDataset(Dataset): def __init__(self, texts, labels, tokenizer, vocab, max_sentences32, max_tokens64): self.texts texts self.labels labels self.tokenizer tokenizer self.vocab vocab self.max_sentences max_sentences self.max_tokens max_tokens def __getitem__(self, idx): sentences split_sentences(self.texts[idx]) doc_ids [] for sent in sentences: word_ids [self.vocab.get(w, 1) for w in self.tokenizer(sent)] word_ids word_ids[: self.max_tokens] doc_ids.append(word_ids) doc_ids doc_ids[: self.max_sentences] sent_count len(doc_ids) padded torch.zeros((self.max_sentences, self.max_tokens), dtypetorch.long) word_mask torch.zeros((self.max_sentences, self.max_tokens), dtypetorch.long) for i, ids in enumerate(doc_ids): padded[i, : len(ids)] torch.tensor(ids, dtypetorch.long) word_mask[i, : len(ids)] 1 sentence_mask (word_mask.sum(dim-1) 0).long() return padded, word_mask, sentence_mask, self.labels[idx]注意max_sentences和max_tokens不要拍脑袋定先统计一下训练集里每篇文档的句子数分布、每句话的词数分布取90%分位点。把max_tokens设成512但实际句子平均只有20个词会导致大部分位置是padding训练又慢效果又差。4.2 训练超参数与观察Loss曲线训练部分和普通分类网络没有区别关键超参数参考参数推荐值说明embedding_dim100~300小数据集用64也可以word_hidden_size32~128双向后实际翻倍sentence_hidden_size32~128同上dropout0.3~0.5防止过拟合batch_size16~64第一次调试建议用16learning rate1e-3起步配ReduceLROnPlateau太大容易震荡epochs5~15配合早停训练循环model HierarchicalAttentionNetwork( vocab_sizelen(vocab), embedding_dim100, word_hidden_size64, sentence_hidden_size64, num_classes2, dropout0.5, ).to(device) loss_fn nn.CrossEntropyLoss() optimizer torch.optim.Adam(model.parameters(), lr1e-3) scheduler torch.optim.lr_scheduler.ReduceLROnPlateau( optimizer, modemax, factor0.5, patience2 ) for epoch in range(epochs): model.train() total_loss 0.0 for batch in train_loader: docs, word_masks, sent_masks, labels [x.to(device) for x in batch] logits, _, _ model(docs, word_masks, sent_masks) loss loss_fn(logits, labels) optimizer.zero_grad() loss.backward() optimizer.step() total_loss loss.item() val_acc evaluate(model, val_loader, device) scheduler.step(val_acc) print(fepoch {epoch}, loss {total_loss:.4f}, val_acc {val_acc:.4f})实测下来HAN在前2个epoch会快速下降验证集准确率随训练逐步提升。如果你的数据集不大从第5个epoch开始就可能过拟合需要用early stopping。另外learning rate设为1e-3对GRU是安全的但不建议改到1e-2梯度可能直接飘掉。4.3 把注意力权重打印出来验证模型学到了什么训练完别急着收工把注意力权重可视化一下这是检验模型是否真正“听懂”的重要一步。转换一条测试评论拿到word_weights和sentence_weights然后按权重排序打印出最重要的词和句子。这里不用写完整的UI代码核心逻辑就是把权重和token一一对齐def inspect_attention(text, model, tokenizer, vocab, device): model.eval() doc_ids, word_mask, sent_mask preprocess_one(text, tokenizer, vocab, device) with torch.no_grad(): _, word_weights, sent_weights model(doc_ids, word_mask, sent_mask) word_weights word_weights.squeeze(0).cpu().numpy() sent_weights sent_weights.squeeze(0).cpu().numpy() sentences split_sentences(text) for i, sent in enumerate(sentences): tokens tokenizer(sent) ids [vocab.get(w, 1) for w in tokens] word_scores dict(zip(tokens, word_weights[i, : len(ids)])) top_words sorted(word_scores.items(), keylambda x: x[1], reverseTrue)[:5] print(f句{i}: {sent}) print(f 重要词: {top_words}) print(f 句子权重: {sent_weights[i]:.4f})我跑评论分类时抽到的一条例子句子权重最高的是描述服务态度的那句而不是讲房间设施的那句词级别权重最高的不是“干净”“好”而是“非常”“不舒服”“服务”。这种解释能力对业务方来说是杀手级的他们看得懂而且愿意信。5. 踩坑记录从维度爆炸到mask失效的完整排查链路5.1 第一个坑双向GRU的维度对不上第一次搭建HAN时报错信息长这样RuntimeError: mat1 and mat2 shapes cannot be multiplied (8×16) and (8×2)这个报错的本质是矩阵乘法维度不匹配。mat1是8×16说明某个矩阵的第一个维度是8第二个维度是16mat2是8×2说明你要乘的矩阵第一个维度是8第二个维度是2。16和2对不上明白无误是“输入特征的维度写错了”。我当时排查的链路是在word_gru的输出后加一行print(word_outputs.shape)看到(8, 16, 16)最后一个16是2*word_hidden_size。在word_attention输出后加一行看到句向量维度也是(8, 16)。然后我去看分类器定义发现nn.Linear的第一个参数写成了word_hidden_size8而不是2*word_hidden_size16。修复方法其实就一句话凡是接在双向GRU后面的层输入维度都要写成2 * hidden_size而不是hidden_size。这个坑几乎每个手写RNN系模型的人都会踩一次打印各层shape是定位维度错误最快的方式。5.2 第二个坑mask失效导致padding位置也分到了注意力权重维度通了loss也在下降但我观察attention weights时发现padding位置的权重居然不是0。这个问题在训练集句子长度参差不齐时特别阴险因为loss下降正常模型看起来也在学习只是学出来的注意力权重不对。排查链路是这样的我先检查mask是不是传对了打印word_masks.sum()没发现问题。然后看AttentionLayer.forward里mask是怎么用的发现我早期写的是scores scores.masked_fill(mask 0, 0)这才是真正的元凶。masked_fill(mask 0, 0)把padding位置的分数设成0但后面softmax的输入仍然是0exp(0) 1padding位置依然拿到了非0的权重。padding位置的隐状态虽然是一个全0向量但加权求和时非0权重会把这些全0向量混进上下文相当于给句向量注入了噪声。让mask生效的写法是填负无穷scores scores.masked_fill(mask 0, -1e9)softmax(-1e9)的结果趋近于0padding位置才不会污染句向量。如果你发现训练很久验证集准确率都上不去优先检查注意力层里mask的填充值。不要总怀疑模型结构问题往往出在“padding位置也参与了计算”这种细节上。5.3 第三个坑注意力退化成平均池化项目里还有一次诡异的现象训练得很正常loss也在下降但把attention weights打印出来发现每个词、每个句子的权重都差不多几乎等于均匀分布。我先怀疑是数据量太少模型学不出差异性。后来把context_vector的初始值打印出来发现它全是0。原因是我当时图省事self.context_vector nn.Parameter(torch.zeros(hidden_size, 1))所有位置的得分都一样softmax之后权重自然就是均匀的。梯度更新对每个位置的效果也相同于是网络退化成了mean pooling。修复方式就是改用xavier_uniform_初始化nn.init.xavier_uniform_(self.context_vector)注意力权重的差异性需要初始分数有区分度xavier_uniform_能保证初始时各个位置得分不完全相同训练才有机会往“哪些位置重要”这个方向学习。如果初始化全0模型等于从一开始就输在了起跑线上。5.4 另外两个容易被忽略的细节第一个是batch_first的一致性。nn.GRU默认输入是(seq_len, batch, input_size)而我们训练时习惯batch_firstTrue。如果你在词级GRU设了batch_firstTrue句子级GRU却忘了设两个模块的输入输出维度会各自错开并且打印shape时特别隐蔽因为都是三维张量只有数据规模对不上时才报错。建议代码里所有GRU统一设为batch_firstTrue。第二个是截断策略。不要在数据预处理里把max_sentences32, max_tokens64写死先对训练集做统计。我见过一个项目把max_tokens设成512但实际上大多数句子只有20多个词padding占了95%。这样不仅训练慢而且过多的padding会让注意力学习变得困难因为真实词的数量实在太少。6. HAN的注意力和Transformer多头注意力到底差在哪里6.1 加性注意力和乘性注意力的对比HAN里的注意力是加性注意力核心打分公式是score v^T * tanh(W * h)Transformer里的缩放点积注意力是乘性注意力核心打分公式是score Q * K^T / sqrt(d_k)两种方式本质相同都是算查询向量和候选向量的相关度softmax之后得到权重再用权重对value做加权求和。差别在于打分函数的结构。HAN用一层可学习的投影加tanh再用一个固定的上下文向量去匹配Transformer直接让查询和键做矩阵内积分母的sqrt(d_k)用来防止维度增大时内积值方差过大导致softmax梯度消失。理解了这个你会发现“注意力机制”不是某一个公式而是一类方法的统称。关键步骤永远是那三件事打分、归一化、加权求和。6.2 HAN的上下文向量和Q/K/V是对应关系很多人学完Transformer再回头看HAN会迷茫HAN里没有Q没有K没有V那它还是注意力吗从注意力统一视角看HAN里的上下文向量u_w就扮演了查询的角色它可以理解为“对所有被观察位置使用同一个查询”。而隐状态序列h_t既做key又做valuekey用于计算相关的程度value携带被聚合的信息并没有像Transformer那样拆分出独立的value空间。Transformer最大的变化是把key和value分离并让查询也来自输入序列的线性变换。这样每个位置都能去充分关注其他位置不再是“所有位置共享同一个查询向量”表达能力更强也带来了完全并行计算的可能。所以你可以把HAN理解为“注意力机制的初代形态”用单个查询、同一个key/value空间完成信息挑选。它的表达能力不如Transformer但足够用在文档分类这类任务上而且因为查询向量固定训练出的注意力权重很稳定解释性反而更直观。6.3 HAN的层级建模思路能迁移到哪些场景HAN核心价值并不是那个GRU而是“分而治之注意力挑选”的结构。这套思路我在很多场景里都用得上长文档多标签分类政府公文、合同文本往往上千字Transformer自注意力的内存占用随序列长度平方增长HAN先把每个句子压缩成句向量再在句子层面做注意力显存友好很多。客服工单与投诉分类一段工单通常有“问题描述”“已做操作”“期望诉求”几个结构化部分HAN的句子权重能告诉你到底是哪一部分驱动了最终分类结果。可解释性要求高的业务评论分析、病历分类、法律文书辅助判定都要向业务方解释为什么。HAN可以直接输出重要词和重要句子比黑盒模型好落地得多。多模态层级注意力把“词”换成“图像区域”“句子”换成“局部特征序列”HAN的层级结构可以迁移到图像文本交叉任务上。下表是我个人对不同注意力实现的选择参考对比维度HAN层级注意力Transformer/多头注意力打分方式加性注意力需要一层MLP投影乘性注意力直接内积查询来源固定的上下文向量输入序列经过线性变换得到Key和Value是否分离不分离同一个隐状态序列分离表达式能力更强并行性依赖GRU/LSTM顺序编码完全并行可解释性能直接输出词和句子权重注意力权重只部分可解释超长文档显存占用分层压缩相对友好平方级增长容易爆显存HAN不是“过时”的模型至少作为长文档分类的基线和解释性方案它现在依然用得上。如果你要把一个动辄几千token的文档喂给BERT或Transformer光序列长度限制和显存就够喝一壶HAN的句子级压缩思路仍然值得借鉴。写到这里再分享一个我实际用过很多次的小技巧把训练好的HAN权重导出出来画一份“句子权重热力图”标出每句话的颜色深浅再在下面对应列出该句子中权重最高的几个词。做评论分析汇报时这份图比任何准确率数字都有说服力。如果你在搭HAN时也卡在某个维度或mask问题上建议先别急着换模型回到这6个环节里查一遍大概率能找到原因。