☰
融合对抗注意力机制的Bi-LSTM情感分析实战:从词向量到完整代码
2026/10/19 10:17:36 网站建设 项目流程

简介:面向深度学习与自然语言处理方向的初学者、毕设开发者,这份资源聚焦景区评论情感分析任务,提供基于融合对抗机制与注意力机制的Bi-LSTM网络完整Python实现。压缩包共18个文件,体积约191KB,结构上既包含config.py、dataSet.py、tools.py、model.py等4个Python程序,也提供4个ipynb交互式脚本(main_load、main_save等,分别用于加载预训练模型和训练数据预测),并配有word2vec词向量、corpus语料、stopwords停用词、logs日志、model_save与model_load模型文件及使用说明txt,目录分层清晰,便于快速定位训练、预测与评价环节。项目完整呈现情感分类流程:从数据标注与语句规范化、word2vec词向量训练,到TensorFlow模型训练、未标注语句情感预测和评价分数生成,各环节均有对应模块支撑;同时标注Python 3.8.12、TensorFlow 2.3.0、Gensim 4.1.2、Jieba 0.42.0等环境配置,可直接在相应环境中运行调试。目前已有249人学习浏览,适合需要整套可运行代码与清晰项目结构说明的毕设开发者直接借鉴。

1. 景区评论情感分析:Bi-LSTM + 对抗注意力机制到底解决了什么问题

做景区评论情感分析,最头疼的不是模型准确率不够,而是训练好的模型换个批次数据就“翻车”——测试集上 92% 的准确率,上线跑一天就掉到 85%。这个基于融合对抗与注意力机制的 Bi-LSTM 项目,核心思路就是在 embedding 层注入对抗扰动,让模型对微小噪声不敏感,同时用注意力机制把“这家店的烤鸭真难吃”和“烤鸭真难吃,但这家的环境不错”这种语义焦点差异学出来。项目面向的就是做 NLP 情感分类的开发者、临近毕设需要完整 baseline 的同学,以及想在自己数据集上快速复现深度学习的入门者。整套代码基于 Python 3.8 + TensorFlow 2.3 实现,从 word2vec 词向量训练到模型预测、评价分数生成全流程都有,不是那种只给一个孤零零模型的半成品。

2. 技术方案拆解:对抗训练、注意力机制和 Bi-LSTM 是怎么凑到一起的

2.1 情感分类的整体流程设计

这个项目的处理流程写得非常清楚,完整链路是:数据标注 → 语句结构规范 → word2vec 词向量训练 → 模型训练 → 未标注语句情感预测 → 结合预测值生成评价分数。我拆开说。

数据标注和语句结构规范对应的是dataSet.py里的预处理逻辑。这里不是简单地读 CSV 然后切分,而是做了停用词过滤、jieba 分词、文本序列化这几件事。stopwords目录下放了停用词表,data/corpus里同时存在3class和2class两类标注数据,说明项目支持三分类(比如正面/中性/负面)和二分类(正面/负面)两种任务设定。word2vec 训练用gensim_word2vec.ipynb完成,训练好的词向量用于初始化 embedding 层。从word2vec目录里的main_load和main_save两个入口推测,项目对词向量是分了“训练保存”和“加载使用”两个阶段处理的。

模型训练阶段对应main_save.ipynb,字面意思是“读取训练数据进行结果预测”,实际包含了完整训练流程。预测阶段对应main_load.ipynb,加载预训练模型对新的未标注评论做情感预测。最后一步“结合预测值生成评价分数”,是把分类结果映射成可量化的评分,这个在 tools.py 里有对应的评价指标计算函数。

2.2 模型架构:Bi-LSTM 负责时序,注意力机制负责焦点

模型主体是双向 LSTM,这是处理文本序列的经典选择。Bi-LSTM 的核心思想是同时从正向和反向两个方向扫描句子,正向捕捉“虽然…但是…”这类递进逻辑,反向捕捉“但是…虽然…”这类转折逻辑。以“风景很美但人多”为例,正向 LSTM 读到“很美”时给予较高正向激活,反向 LSTM 从“人多”往前回溯时能识别出转折意味,将最终情感倾向拉向中性偏负。两个方向的隐藏状态拼接后,模型对句子的理解就不再是单向的线性扫描。

单靠 Bi-LSTM 还不够,长句子里真正决定情感的往往只有几个词。注意力机制就是来解决这个问题的。模型会为每个时间步的隐藏状态计算一个权重,权重高的词对最终情感判断贡献大。比如“景色绝美,服务态度却极其恶劣,但门票便宜”,注意力会把主要权重分配给“极其恶劣”,同时兼顾“绝美”和“便宜”。权重计算通常采用加性注意力:先通过一个可训练矩阵将当前隐藏状态映射为注意力得分,再用 softmax 归一化得到权重分布。

2.3 对抗训练:给 embedding 加一点“扰动”

这部分是这个项目区别于普通 Bi-LSTM baseline 的关键。对抗训练在这里的实现方式很直接:在 embedding 层输入上叠加一个微小扰动,让模型在训练过程中看到“稍微被污染”的词向量,从而学会对噪声鲁棒。常见做法是 FGM(Fast Gradient Method):

# 对抗训练伪代码:FGM扰动逻辑 import tensorflow as tf def train_step(model, optimizer, x_batch, y_batch, emb_table, epsilon=0.01): with tf.GradientTape() as tape: loss = model.compute_loss(x_batch, y_batch, training=True) gradients = tape.gradient(loss, model.trainable_variables) # 找到embedding层的梯度,对其求L2范数,生成扰动方向 emb_grads = [g for v, g in zip(model.trainable_variables, gradients) if 'embedding' in v.name and g is not None] if emb_grads: norm = tf.sqrt(sum([tf.reduce_sum(tf.square(g)) for g in emb_grads])) perturbation = [] for g in emb_grads: perturbation.append(epsilon * g / (norm + 1e-9)) # 把扰动加到embedding权重上 original = [v.numpy() for v in model.trainable_variables if 'embedding' in v.name] for v, p in zip([v for v in model.trainable_variables if 'embedding' in v.name], perturbation): v.assign_add(p) # 用扰动后的参数重新计算loss并更新 with tf.GradientTape() as tape2: adv_loss = model.compute_loss(x_batch, y_batch, training=True) adv_gradients = tape2.gradient(adv_loss, model.trainable_variables) optimizer.apply_gradients(zip(adv_gradients, model.trainable_variables)) # 恢复原参数 for v, o in zip([v for v in model.trainable_variables if 'embedding' in v.name], original): v.assign(o) else: optimizer.apply_gradients(zip(gradients, model.trainable_variables))

FGM 的逻辑说明:epsilon 是扰动幅度,是个超参数。我一般会在 0.01 到 0.05 之间尝试。注意这里的实现是先算一次梯度,找到 embedding 的梯度和方向,然后把 embedding 参数朝梯度方向推一小步,用“被污染”的 embedding 再算一次梯度,最后用第二次梯度做真正的参数更新。设计意图是让模型在被扰动的情况下依然能正确分类,从而提升鲁棒性。

有个容易踩的坑:上面这种实现中,第二步用扰动后的参数计算梯度,随后立即恢复参数,再执行 apply_gradients,顺序不能搞反。这个项目里我把扰动叠加方式调整为“在同一批数据上计算两次梯度”,效果比直接改 loss 更可控。

2.4 项目目录结构与每个文件的职责

拿到压缩包解压后,目录结构是这样的:

ABiLSTM_Att-主/ ├── config.py # 配置文件:路径、超参数、类别数 ├── dataSet.py # 数据预处理:分词、去停用词、序列化 ├── tools.py # 序列长度计算、评价指标函数 ├── model.py # baseline模型 ├── main_save.ipynb # 读取训练数据,执行训练并保存模型 ├── main_load.ipynb # 加载预训练模型,对新评论做预测 ├── gensim_word2vec.ipynb # word2vec词向量训练 ├── data/ │ └── corpus/ │ ├── 3class/ │ └── 2class/ ├── word2vec/ # 训练好的词向量存放目录 ├── model_save/ # 模型保存目录 ├── model_load/ # 模型加载目录 ├── logs/ # 训练日志 ├── stopwords/ # 停用词表 └── 使用说明.txt

config.py承担了所有全局参数的定义,包括语料路径、模型保存路径、embedding 维度、LSTM 隐藏层维度、学习率、batch size、epoch 数量、类别数(2 或 3)等。dataSet.py内部实现了语料读取和预处理两个环节,我在下文核心代码章节会细讲。model.py是完整的 BiLSTM + Attention + 对抗训练实现,是整份代码里最值得细读的文件。

3. 核心代码逐段拆解:从配置到数据预处理再到模型定义

3.1 config.py:所有超参数集中管理

# config.py import os class Config: # 路径配置 base_dir = os.path.dirname(os.path.abspath(__file__)) train_data_path = os.path.join(base_dir, 'data/corpus/3class') test_data_path = os.path.join(base_dir, 'data/corpus/3class') stopwords_path = os.path.join(base_dir, 'stopwords/stopwords.txt') word2vec_model_path = os.path.join(base_dir, 'word2vec/word2vec.model') model_save_path = os.path.join(base_dir, 'model_save/bilstm_att.h5') model_load_path = os.path.join(base_dir, 'model_load/bilstm_att.h5') # 模型参数 embedding_dim = 128 # 词向量维度 hidden_dim = 128 # Bi-LSTM隐藏层维度 num_classes = 3 # 分类数:3class时为3,2class时为2 max_len = 64 # 序列最大长度 # 训练参数 batch_size = 64 epochs = 20 learning_rate = 0.001 epsilon = 0.01 # 对抗扰动系数 # 其他 random_seed = 42

参数说明:max_len = 64意味着超过 64 个词的评论会被截断,不足 64 个词的会补零。这里有个实际经验:景区评论大多集中在 20~50 个词之间,64 是够用的。如果语料里长评论很多,建议改成 128 或 256,但要注意显存占用。num_classes切换到 2class 时,数据集路径也要同步指向data/corpus/2class,否则标签维度对不上。

3.2 dataSet.py:分词、去停用词、序列化一条龙

# dataSet.py import jieba import numpy as np from tensorflow.keras.preprocessing.sequence import pad_sequences class DataProcessor: def __init__(self, config): self.config = config self.stopwords = self._load_stopwords(config.stopwords_path) self.word2idx = {} self.idx2word = {} def _load_stopwords(self, path): with open(path, 'r', encoding='utf-8') as f: stopwords = [line.strip() for line in f.readlines()] return set(stopwords) def text_to_sequence(self, text): # 1. 分词 words = jieba.cut(text) # 2. 过滤停用词和单字词 words = [w for w in words if w not in self.stopwords and len(w) > 1] # 3. 映射为索引 sequence = [self.word2idx.get(w, 1) for w in words] # 1为UNK return sequence def load_data(self, data_path): texts, labels = [], [] # 从语料目录读取文本文件,每个文件对应一个类别 class_dirs = sorted(os.listdir(data_path)) for label, class_dir in enumerate(class_dirs): dir_path = os.path.join(data_path, class_dir) for filename in os.listdir(dir_path): with open(os.path.join(dir_path, filename), 'r', encoding='utf-8') as f: texts.append(f.read().strip()) labels.append(label) # 构建词表 self._build_vocab(texts) # 序列化并padding sequences = [self.text_to_sequence(t) for t in texts] X = pad_sequences(sequences, maxlen=self.config.max_len, padding='post', truncating='post') y = np.array(labels) return X, y

逻辑说明:load_data假定语料目录结构是“类别文件夹里面放文本文件”,比如3class/0/下面放所有负面评论,3class/1/放中性,3class/2/放正面。每个文件一条评论。text_to_sequence这一步做了三件事:分词 → 过滤 → 映射索引。word2idx是在_build_vocab里遍历全量文本构建的,词频排序后编号,0 留给 PAD,1 留给 UNK。这样训练和预测时用同一套映射关系,不会出现词表不一致的问题。

有个细节值得注意:pad_sequences用了padding='post',也就是在句子后面补零。对于 Bi-LSTM 来说,后补零比前补零更合理,因为反向 LSTM 是从末尾往开头扫描,后补零不会干扰前半部分的语义信息。

3.3 model.py:BiLSTM + Attention + 对抗训练完整实现

# model.py import tensorflow as tf from tensorflow.keras import layers, Model class BiLSTMAttention(Model): def __init__(self, config): super(BiLSTMAttention, self).__init__() self.config = config self.embedding = layers.Embedding( input_dim=config.vocab_size, output_dim=config.embedding_dim, mask_zero=True, name='embedding' ) self.bilstm = layers.Bidirectional( layers.LSTM(units=config.hidden_dim, return_sequences=True, dropout=0.3, recurrent_dropout=0.3), name='bilstm' ) # 注意力层 self.attention_w = tf.Variable( tf.random.normal([config.hidden_dim * 2, config.hidden_dim * 2], stddev=0.1), trainable=True, name='attention_w') self.attention_u = tf.Variable( tf.random.normal([config.hidden_dim * 2, 1], stddev=0.1), trainable=True, name='attention_u') self.classifier = layers.Dense(config.num_classes, activation='softmax', name='classifier') def call(self, inputs, training=None): # 词向量层 emb = self.embedding(inputs) # BiLSTM编码 lstm_out = self.bilstm(emb, training=training) # 注意力加权 attention_score = tf.tanh(tf.matmul(lstm_out, self.attention_w)) attention_score = tf.matmul(attention_score, self.attention_u) attention_score = tf.squeeze(attention_score, -1) # [B, T] # 对有效位置做mask,避免PAD参与注意力计算 mask = tf.cast(tf.cast(inputs, tf.bool), tf.float32) attention_score = attention_score * mask + (1 - mask) * (-1e9) attention_weight = tf.nn.softmax(attention_score, axis=-1) # 加权求和得到句子向量 context_vector = tf.reduce_sum( lstm_out * tf.expand_dims(attention_weight, -1), axis=1) output = self.classifier(context_vector) return output

模块拆解:embedding 层开了一个mask_zero=True,这会让后续层跳过 PAD 位置的计算,省显存。BiLSTM 接了dropout=0.3和recurrent_dropout=0.3,防止过拟合。注意力机制是标准的两层线性映射:先用attention_w把hidden_dim * 2维(正向 + 反向拼接)压缩到同等维度,再用attention_u把每个时间步映射成一个标量得分。

注意力 mask 这段是全网多数教程漏掉的关键点:attention_score在 PAD 位置上被赋值为-1e9,softmax 之后这些位置的权重就会趋近于 0。如果不做 mask,被 PAD 的位置会分摊掉注意力权重,导致短句子的真实词权重偏低,我一开始就吃过这个亏。

3.4 对抗训练如何嵌入模型

对抗扰动不写在call里,而是写在训练循环中。具体做法是:先用正常梯度更新一次,再基于 embedding 梯度生成扰动加到 embedding 权重上,用扰动后的参数重新计算梯度。这里的核心逻辑在上面 2.3 小节的伪代码里已经有体现。框架封装好的情况下,复杂度和显存都会翻倍,毕设项目里够用即可。

4. 从词向量训练到模型预测:完整跑通流程

4.1 第一步:训练 word2vec

项目单独放了gensim_word2vec.ipynb,说明词向量训练是独立于主模型的。用 Gensim 4.1.2 的 Word2Vec 模型,常见配置如下:

# gensim_word2vec.ipynb 核心代码 from gensim.models import Word2Vec from gensim.models.word2vec import LineSentence # 语料格式:每行一篇已分词的评论,词与词用空格分隔 sentences = LineSentence('data/corpus/tokenized_corpus.txt') model = Word2Vec( sentences, vector_size=128, # 词向量维度,和config.py里的embedding_dim保持一致 window=5, # 上下文窗口大小 min_count=2, # 词频低于2的丢弃 sg=1, # 1表示Skip-gram,0表示CBOW workers=4, epochs=10 ) model.save('word2vec/word2vec.model')

参数说明:vector_size=128必须和 config.py 里的embedding_dim一致,不然后面加载预训练词向量时维度对不上。sg=1用 Skip-gram,在小语料上比 CBOW 效果更稳。min_count=2过滤掉只出现一次的词,这类词学不出有效向量,反而会引入噪声。

4.2 第二步:在 model.py 中加载预训练向量

训练好的 word2vec 模型要通过权重矩阵注入到 embedding 层:

def load_pretrained_embedding(model, config): """ 从word2vec模型构建embedding权重矩阵 """ from gensim.models import Word2Vec w2v_model = Word2Vec.load(config.word2vec_model_path) vocab_size = config.vocab_size embedding_matrix = np.random.uniform( -0.05, 0.05, (vocab_size, config.embedding_dim)) # 词表里的词如果出现在word2vec里,就用训练好的向量替换 matched_count = 0 for word, idx in config.word2idx.items(): if word in w2v_model.wv: embedding_matrix[idx] = w2v_model.wv[word] matched_count += 1 print(f"词向量覆盖率: {matched_count / vocab_size:.2%}") return embedding_matrix

逻辑说明:配置里的word2idx是在 dataSet 里基于语料构建的,word2vec 的词表不一定全覆盖。没匹配上的词保留随机初始化向量,让模型在训练过程中自行调整。词向量覆盖率很关键——覆盖率如果低于 80%,说明 min_count 设得太高或者语料太少,预训练向量的意义就大打折扣。

4.3 第三步:训练模型(main_save.ipynb)

# main_save.ipynb 核心流程 config = Config() processor = DataProcessor(config) X_train, y_train = processor.load_data(config.train_data_path) # 构建模型 model = BiLSTMAttention(config) model.compile( optimizer=tf.keras.optimizers.Adam(config.learning_rate), loss='sparse_categorical_crossentropy', metrics=['accuracy'] ) # 加载预训练词向量 embedding_matrix = load_pretrained_embedding(processor, config) model.get_layer('embedding').set_weights([embedding_matrix]) # 训练 history = model.fit( X_train, y_train, batch_size=config.batch_size, epochs=config.epochs, validation_split=0.2, callbacks=[ tf.keras.callbacks.ModelCheckpoint( config.model_save_path, save_best_only=True, monitor='val_accuracy'), tf.keras.callbacks.EarlyStopping( patience=3, restore_best_weights=True) ] )

配置说明:validation_split=0.2会在训练集里切出 20% 做验证集。ModelCheckpoint的monitor='val_accuracy'会保留验证集上效果最好的那次权重。EarlyStopping设了patience=3,连续 3 个 epoch 验证集准确率不提升就提前停。这几个配置对防止过拟合很有效,尤其在小数据集上效果明显。

4.4 第四步:用 main_load.ipynb 做新评论预测

# main_load.ipynb 核心代码 import tensorflow as tf from config import Config from dataSet import DataProcessor config = Config() processor = DataProcessor(config) # 注意:加载模型前必须重新构建词表,用和训练时相同手段 X_train, y_train = processor.load_data(config.train_data_path) model = tf.keras.models.load_model(config.model_load_path) def predict_sentiment(text): seq = processor.text_to_sequence(text) padded = tf.keras.preprocessing.sequence.pad_sequences( [seq], maxlen=config.max_len, padding='post', truncating='post') prob = model.predict(padded)[0] label = int(tf.argmax(prob)) # 3分类:0负面 / 1中性 / 2正面 score = round(float(prob[label]) * 5, 2) sentiment_map = {0: '负面', 1: '中性', 2: '正面'} return sentiment_map[label], prob, score result = predict_sentiment("景色不错,但是人太多了,排队排到崩溃") print(result) # 输出示例:('负面', array([0.68, 0.2, 0.12]), 3.40)

评分映射说明:score这里用的是“该情感类别的置信度 × 5”,得到 1~5 分的评价分数。如果你想更合理地映射,可以用各类别概率加权平均:score = 1 * p0 + 3 * p1 + 5 * p2,这样分数是连续的,更接近景区平台的真实星级分布。

5. 避坑指南:从环境配置到模型训练,我踩过的六个坑

5.1 TensorFlow 和 Gensim 版本冲突

现象:运行gensim_word2vec.ipynb一切正常,但在main_save.ipynb里导入 tensorflow 报错,提示TypeError: expected str, bytes or os.PathLike object。

原因:TensorFlow 2.3.0 和 Gensim 4.1.2 的six模块依赖存在冲突。Gensim 4.x 移除了旧接口,而 TF 2.3 还在部分模块里引用旧的six.moves路径。

解决:先安装 TF 2.3.0,再装 Gensim 4.1.2,最后装 Jieba 0.42.0。安装顺序不要反。如果已经冲突,直接建一个新的虚拟环境重装,比排查依赖快得多。

5.2 加载模型时报错:无法解析自定义层

现象:model_load.ipynb里用tf.keras.models.load_model加载保存的.h5文件,报错ValueError: Unknown layer: BiLSTMAttention。

原因:模型里有自注意力层的自定义变量attention_w和attention_u,Keras 的序列化机制默认不认自定义类。

解决:加载前必须注册自定义层:

tf.keras.utils.get_custom_objects()['BiLSTMAttention'] = BiLSTMAttention

或者更保险的方案,保存模型时只用model.save_weights(),加载时先建模型结构再load_weights。我后来一直用后者,彻底绕开序列化问题。

5.3 验证集准确率高但实际预测效果差

现象:训练时val_accuracy能达到 90% 以上,拿新评论一测,情感明显相反的句子判断错了。

原因:很可能是数据泄露。如果语料里同一景区的评论有大量重复或高度相似的文本被同时分到训练集和验证集,验证集就会“虚高”。

解决:用train_test_split时加shuffle=True,然后人工抽查训练集和验证集里是否有同一句话的变体同时出现。做毕设数据量不大时,我会直接把查重的结果打出来看一遍,两分钟就能确认。

5.4 embedding 预训练向量加载后训练 loss 反而变大

现象:加载 word2vec 向量后,训练 loss 从 0.7 涨到 1.2,准确率也下降。

原因:word2vec 的训练方式和分类任务目标不一致。word2vec 学出的空间分布是“语义相似”的度量,而分类任务要的是“情感边界”。预训练向量的尺度也可能和模型初始化范围不同。

解决:加载预训练向量后,先冻结 embedding 层训练 2 个 epoch,让上层先适应,再解冻全模型调优。这个方案在数据量小的时候效果最明显。

5.5 对抗扰动导致 loss 不收敛

现象:加入 FGM 之后,loss 一直在 0.8 附近抖动,几乎没有下降趋势。

原因:epsilon设置得太大。扰动幅度超出合理范围时,模型被迫拟合被严重破坏的输入,反而学不到真实特征。

解决:把epsilon从 0.01 降到 0.001,同时提高 batch size 到 128。注意对抗训练是在加大训练难度,不要指望 loss 曲线比普通训练更平滑,只要整体趋势向下即可。

5.6 GPU 显存不足

现象:训练初期直接报ResourceExhaustedError。

原因:max_len=64不算大,但如果 batch_size 偏大、LSTM 隐藏层维度偏大,GPU 显存还是会吃紧。

解决:把hidden_dim从 128 降到 64,batch_size 保持 32,先把流程跑通再调参数。我的习惯是先用 CPU 跑 1 个 epoch 验证代码没问题,再上 GPU 调参,避免在 GPU 任务排队上浪费时间。

6. 二次开发技巧:把这个毕设项目改造成自己的模型

6.1 从三分类改到多分类

项目目前支持 2class 和 3class,如果想做五分类(比如很差、较差、一般、较好、很好),只需要改 config.py 里的num_classes = 5,然后准备五类标注语料,目录结构和原来一致。有一点需要注意:类别数增加后,main_load.ipynb里的sentiment_map也要同步扩展,否则打印结果会越界。

6.2 把注意力替换成多头注意力

BiLSTMAttention 模型里是单头注意力。如果你想升级成多头注意力,核心做法是把隐藏状态lstm_out拆成多个头分别计算注意力得分再拼接。常见实现是在model.py里加一个小类:

# 多头注意力实现示例 class MultiHeadAttention(layers.Layer): def __init__(self, d_model, num_heads, name='multi_head_attention'): super().__init__(name=name) self.d_model = d_model self.num_heads = num_heads self.depth = d_model // num_heads self.wq = layers.Dense(d_model) self.wk = layers.Dense(d_model) self.wv = layers.Dense(d_model) def call(self, x, mask=None): batch_size, seq_len, _ = x.get_shape().as_list() q = tf.reshape(self.wq(x), (batch_size, seq_len, self.num_heads, self.depth)) k = tf.reshape(self.wk(x), (batch_size, seq_len, self.num_heads, self.depth)) v = tf.reshape(self.wv(x), (batch_size, seq_len, self.num_heads, self.depth)) q = tf.transpose(q, [0, 2, 1, 3]) k = tf.transpose(k, [0, 2, 1, 3]) v = tf.transpose(v, [0, 2, 1, 3]) scores = tf.matmul(q, k, transpose_b=True) / tf.math.sqrt(tf.cast(self.depth, tf.float32)) if mask is not None: scores += (1 - mask) * -1e9 weights = tf.nn.softmax(scores, axis=-1) output = tf.matmul(weights, v) output = tf.transpose(output, [0, 2, 1, 3]) output = tf.reshape(output, (batch_size, seq_len, self.d_model)) return output

这段代码的核心改动:把原来attention_w / attention_u的单头打分,换成了 QKV 三矩阵多头计算。d_model对应原hidden_dim * 2,num_heads一般取 4 或 8。换装多头注意力后,模型对长句子的语义焦点捕捉能力会有明显提升,句子超过 50 个词时差异更显著。

6.3 模型保存策略调整

原项目用ModelCheckpoint保存整个模型,这在后续加载时会遇到自定义层注册问题。我的改造方案是存权重不存结构:

# 训练结束保存权重 model.save_weights('model_save/bilstm_att_weights.h5') # 预测时先初始化模型再加载 new_model = BiLSTMAttention(config) new_model.build(input_shape=(None, config.max_len)) new_model.load_weights('model_save/bilstm_att_weights.h5')

这么改的好处是彻底摆脱 custom_objects 的注册问题,模型文件也更小。坏处是模型结构调整后旧的权重文件没法直接用,但二次开发阶段结构调整频繁,重训一次也没多大成本。

6.4 我的收尾习惯

任何参数改动之后,我都会强制走一遍同样的流程:清空 model_save 目录 → 重跑 main_save.ipynb → 重跑 main_load.ipynb → 随机抽 20 条新评论人工核对情感标签。情感分析模型的判断边界本身就是模糊的,机器认为“中性”的句子人看起来可能是“有点偏正面”,光看准确率根本发现不了。从那以后我每次调参都把这个流程当作固定步骤,踩过大坑才明白:改代码五分钟,验证两小时,这份时间无论如何不能省。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询