简介面向景区评论情感分析场景的 Python 源码项目以融合对抗训练与注意力机制的双向长短期记忆Bi-LSTM网络为核心完整覆盖评论文本预处理、情感分类、词向量训练、模型预测与评价分数生成等环节适合自然语言处理方向的本科生毕业设计也适合深度学习初学者进行项目实践。压缩包共 18 个文件包含 4 个 Jupyter Notebook 交互式脚本、4 个 Python 模块、预训练 Word2Vec 词向量、语料库、停用词表、日志与结果目录等整体仅 191KB结构紧凑、分层清晰便于快速部署与调试。目前已有 249 人学习下载。代码按 config 配置、dataSet 数据预处理、tools 工具函数、model 基线模型等模块拆分并分别提供 main_load 与 main_save 两个预测入口项目说明明确列出 Python 3.8.12、TensorFlow 2.3.0、Gensim 4.1.2 等依赖环境使用者可直接复现从数据标注、规范语句结构到生成评价分数的完整流程同时也能基于现有模块替换自己的评论语料开展对抗样本验证或注意力可视化等拓展实验。1. 景区评论情感分析这份Bi-LSTM融合注意力源码按什么逻辑落地景区评论看起来只是几句话但真正做情感分析时你会发现最大的麻烦不是“怎么把好评差评分出来”而是“怎么让模型在短文本里抓住真正的情绪”。这份以融合对抗与注意力机制的Bi-LSTM网络为核心的Python毕设源码恰好把这条链路完整走了一遍word2vec词向量训练、Bi-LSTM序列编码、注意力加权、对抗训练增强再到预测结果直接映射成评价分数。我拆完后的感受是它并不追求网络结构上的花哨而是把工程环节收得很干净适合正在做景区评论情感分析、又不想从零搭建pipeline的人直接对照复现。新手能顺着notebook跑通全流程熟手也能直接改config和model.py做交叉实验。拿到包后建议先别急着跑把目录结构和两个数据版本3class、2class的加载逻辑看一遍再决定从哪个入口入手。2. 模型原理与选型word2vec、Bi-LSTM、注意力与对抗训练为什么这样组合2.1 情感分类基线词向量表示加序列编码再加分类头一份景区评论比如“风景很美但排队太久了”机器拿到的是字符串。要做情感分析第一件事是把它变成能进神经网络的数字表示。常见路线是先分词再用word2vec把每个词映射为稠密向量然后把整条评论的向量序列交给序列模型编码最后接softmax分类头输出各情感类别的概率。这套表示-编码-分类的三段式结构是这类任务的基线。项目里没有直接用别人训练好的词向量而是单独放了一个gensim_word2vec.ipynb在自己的景区评论语料上重新训练word2vec。好处很明显景区评论里常见的“门票”“索道”“民宿”这类领域词通用词向量覆盖往往不好自己训练可以拿到更贴合的语义空间。缺点是训练语料量不够时低频词向量会不太稳。所以我一般会在数据预处理时统计词频把出现次数过低的词直接并到UNK里而不是强行保留。这个决策直接影响后面的vocab_size和embedding矩阵尺寸属于先定下来再往后走的参数。2.2 Bi-LSTM编码层双向信息聚合与参数边界LSTM擅长处理序列依赖但单向LSTM对后文信息是无感的。“虽然景色不错但是设施陈旧”这类转折句前面的“不错”会被后面“陈旧”修正只看前文就很容易判错。Bi-LSTM的做法是把句子正着读一遍、倒着读一遍每个时间步同时拿到前文和后文的信息再拼接成该位置的隐状态。对短文本评论来说这种双向编码比单向LSTM稳定不少。项目名里的ABiLSTM_Att核心就是Bidirectional LSTM加Attention。整体配置上Embedding层输出200维向量Bi-LSTM的hidden_units设在128左右dropout取0.5。选这些参数时有个度hidden_units太小转折句的上下文关系装不下太大小数据集上又会过拟合。景区评论单条通常几十个字序列长度截到64基本够用。过长句子的尾部信息注意力会自己分配权重不用靠硬截这一点后面会在注意力部分展开。提示不要一上来就把hidden_units拉高到256以上。先跑通小规模训练确认loss能降下来再逐步放大。2.3 注意力机制让模型知道“哪几个词决定了评价”评论里不是每个词都对情感等价。“很美”“太差”“居然”这类词对预测的贡献远大于“我们”“然后”。注意力机制就是给每个时间步算一个权重分数再按权重把Bi-LSTM的隐状态加权求和让分类头主要依据情感词所在的隐状态做判断而不是把整句平均掉。常见做法是加性注意力或点积注意力。对128维的隐状态先做线性变换映射到打分空间softmax归一化成权重再对隐状态做加权平均。实现上如果直接拿Bi-LSTM输出做self-attention要注意mask的处理PAD位置没有实际含义打分时要被mask掉否则填充的0也会参与权重计算预测结果会漂。这也是TensorFlow里mask_zeroTrue存在的意义模型在Embedding层就知道哪些位置是填充位。2.4 对抗训练对Embedding加扰动给模型“打预防针”“融合对抗”是这份源码区别于普通Bi-LSTM的关键点。对抗训练不是换网络结构而是训练策略对Embedding层的输入表示加一个微小扰动让模型在“被干扰”的情况下仍然分得对。这样得到的模型对同义词替换、输入噪声更有韧性在小样本评论数据上通常能把F1拉高几个点。在文本分类里最常用的是FGMFast Gradient Method按梯度方向给embedding叠加一个delta。delta太小没有扰动效果太大反而破坏语义。epsilon我一般放在0.3到0.8之间试先固定其他超参单独调这一个值看验证集变化。下面这段就是核心的扰动计算片段# FGM 对 embedding 参数施加梯度扰动 import tensorflow as tf def fgm_perturb(model, inputs, labels, epsilon0.5): # 定位 embedding 层这里以 model.layers[1] 代替实际要按模型结构确认 embed_layer model.layers[1] with tf.GradientTape() as tape: logits model(inputs, trainingTrue) loss tf.reduce_mean( tf.keras.losses.sparse_categorical_crossentropy(labels, logits) ) grads tape.gradient(loss, embed_layer.trainable_variables) # 归一化梯度方向乘以 epsilon 得到扰动 delta delta [ epsilon * g / (tf.norm(g) 1e-8) for g in grads if g is not None ] # 先记录原值后续加扰动计算对抗 loss 后要恢复 backup [tf.identity(v) for v in embed_layer.trainable_variables] for v, d in zip(embed_layer.trainable_variables, delta): v.assign_add(d) return backup, delta这段代码只负责“加扰动”这一步。训练循环里的完整做法是先用正常batch算一次loss并更新梯度再对embedding加扰动、用同一个batch再算一次loss然后恢复embedding原始参数把两次梯度合并更新。delta的计算里加了一个1e-8是为了防止梯度全零时除零报错。embedding层索引在Keras里不一定是固定数字我用model.layers[1]只是示意实际复现时先打印model.summary()确认哪个层是Embedding再改。需要说明的是对抗训练并不改变分类头的输出维度也不影响换2class还是3class数据集它只是让embedding空间的表示更稳。如果加上对抗后验证集反而震荡优先把epsilon降一半再看学习率是不是没跟着调低。3. 工程结构与复现config、dataSet、model、tools四个文件怎么协作3.1 先看清目录里的角色分配一个完整的实训工程最怕的是代码和文件名对不上号。这个包里没有把全部逻辑塞进一个main脚本而是按职责拆成配置、数据、模型、工具四个模块外加三个notebook作为不同入口。我第一遍复现时就是照着文件清单一个个确认角色的。文件 / 目录角色关键输出ABiLSTM_Att主代码目录收纳所有模块与notebookconfig.py全局配置路径、超参数、类别数dataSet.py数据预处理分词、去停用词、序列填充、word2idtools.py工具函数序列长度计算、评价指标model.py模型定义Bi-LSTM Attention 网络gensim_word2vec.ipynb词向量训练word2vec模型main_save.ipynb训练入口保存训练好的模型main_load.ipynb预测入口加载模型做情感预测data/corpus标注数据3class、2class两份语料stopwords停用词表过滤无意义词model_save / model_load模型保存与加载目录训练产物与预测加载对象logs / result训练产出日志、预测结果这里有一个值得注意的点data目录下同时放了3class和2class两份标注数据说明项目从二分类扩展到了三分类。前者只分好评差评后者加了中评或一般。切换分类数时不只是改config里的NUM_CLASSES还要确认语料标签文件和预处理后的标签编码对齐否则训练和预测根本对不上。3.2 config.py所有改动的总开关config.py是复现时最先要看、也最先要改的文件。路径写死会导致换机器就报FileNotFoundError超参数写错则模型完全跑飞。我一般会先把所有路径改成相对项目根目录的形式再逐项核对超参。# config.py 配置文件常见布局 import os BASE_DIR os.path.dirname(os.path.abspath(__file__)) # 数据路径corpus 下按 3class / 2class 分目录存放 TRAIN_DATA os.path.join(BASE_DIR, data, corpus, 3class, train.txt) TEST_DATA os.path.join(BASE_DIR, data, corpus, 3class, test.txt) STOPWORDS_PATH os.path.join(BASE_DIR, stopwords, stopwords.txt) # 产出路径word2vec、模型、日志、结果各归各的 WORD2VEC_PATH os.path.join(BASE_DIR, word2vec, wv.model) MODEL_SAVE_DIR os.path.join(BASE_DIR, model_save) LOG_DIR os.path.join(BASE_DIR, logs) RESULT_DIR os.path.join(BASE_DIR, result) # 模型超参数 EMBEDDING_DIM 200 # 词向量维度 HIDDEN_UNITS 128 # Bi-LSTM 隐藏单元数 NUM_CLASSES 3 # 3class0/1/2换 2class 时改成 2 MAX_LEN 64 # 单条评论统一截断填充长度 BATCH_SIZE 64 EPOCHS 30 LEARNING_RATE 0.001 DROPOUT 0.5参数说明放在这里。EMBEDDING_DIM要和word2vec训练时的vector_size保持一致不一致的话加载词向量后维度直接报错。NUM_CLASSES是这里最容易翻车的字段。MAX_LEN不要拍脑袋定应该先跑tools.py里的compute_max_len统计语料长度分布再取一个分位数。EPOCHS设30不是让你一定跑满配合早停回调验证集连续几轮不升就停。提示换2class数据集时先改NUM_CLASSES再跑一次compute_max_len别让MAX_LEN沿用旧值。3.3 dataSet.py把中文评论变成序号序列dataSet.py是数据流水线的核心。它要干四件事分词、去停用词、构建词表、把词序列转成定长序号序列。这里的处理质量直接决定后面训练能不能收敛。很多“模型训练不上去”的求助最后都发现是数据侧出了问题比如全角半角混用、停用词表没加载、标签文本混在评论里没拆开。# dataSet.py 数据预处理模块核心逻辑示意 import jieba from collections import Counter from tensorflow.keras.preprocessing.sequence import pad_sequences def load_stopwords(path): with open(path, r, encodingutf-8) as f: return {line.strip() for line in f if line.strip()} def tokenize(text, stopwords): # jieba 分词去掉停用词和空白字符 return [w for w in jieba.lcut(text.strip()) if w.strip() and w not in stopwords] def build_vocab(tokenized_texts, min_count1): counter Counter() for words in tokenized_texts: counter.update(words) # 按词频过滤低频词过滤后的词统一走 UNK word2id {w: i 1 for i, (w, c) in enumerate(counter.items()) if c min_count} word2id[UNK] len(word2id) 1 return word2id def text_to_sequence(tokenized_texts, word2id, max_len): seqs [[word2id.get(w, word2id[UNK]) for w in words] for words in tokenized_texts] # 统一到 max_len后面填充前面保留 return pad_sequences(seqs, maxlenmax_len, paddingpost, truncatingpost)这段逻辑里有两个细节容易看走眼。一是word2id从1开始编号把0留给了PAD这样模型在Embedding层设置mask_zeroTrue后填充位置就不会参与后续注意力计算。二是build_vocab时用min_count过滤低频词低频词统一映射到 这样测试集里出现没见过的词也不会崩溃。min_count不建议设太大景区评论语料本身不大设1到2比较稳。3.4 model.py与tools.py模型定义和评估工具model.py里的模型是baseline也就是整个工程的主网络。它从Embedding层进去经过双向LSTM拿到每个位置的隐状态序列再用注意力机制把序列加权成一个句子向量最后接Dropout和softmax分类。这个结构不复杂但每一步的shape都要对得上尤其是Bi-LSTM的return_sequences必须设为True否则注意力层拿到的是最后一个时间步的向量没法做时间步加权。tools.py承担序列长度计算和评价指标计算。compute_max_len用于生成MAX_LEN配置compute_metrics则输出accuracy、precision、recall、F1这些指标比只看accuracy更能反映类别不平衡时的表现。比如某景区“好评”占了百分之七八十一个全预测成好评的模型accuracy也好看但F1会直接暴露问题。# tools.py 工具函数常见实现 import numpy as np from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score def compute_max_len(tokenized_texts, quantile0.95): # 按序列长度分位数确定 MAX_LEN避免被超长文本拉高 lengths [len(t) for t in tokenized_texts] return int(np.quantile(lengths, quantile)) def compute_metrics(y_true, y_pred): return { accuracy: accuracy_score(y_true, y_pred), precision: precision_score(y_true, y_pred, averagemacro), recall: recall_score(y_true, y_pred, averagemacro), f1: f1_score(y_true, y_pred, averagemacro), }复现时注意compute_max_len的输入是分词后的token列表不是原始字符串直接用原文会得到偏大很多的长度分布。另外precision这些指标在换到3class时仍然适用但average方式要看数据分布类别特别不均时用macro比weighted更严格能看出模型在小类上的真实表现。4. 训练与预测全流程从gensim词向量到main_save与main_load两个入口4.1 gensim_word2vec.ipynb先训练一份自己的词向量整个流程的起点是词向量。运行gensim_word2vec.ipynb时会用当前语料的所有分词结果训练Word2Vec得到每个词的向量表示。注意Gensim 4.1.2版本的API和3.x时代差别很大model.wv才是词向量本体旧教程里model[word]这种写法现在会报KeyError。# gensim_word2vec.ipynb 核心片段 import jieba from gensim.models import Word2Vec def load_train_texts(path): texts [] with open(path, r, encodingutf-8) as f: for line in f: # 标签与评论文本用 \t 分隔只取文本部分 parts line.strip().split(\t) if len(parts) 2: texts.append(parts[1]) return texts raw_texts load_train_texts(data/corpus/3class/train.txt) stopwords {w.strip() for w in open(stopwords/stopwords.txt, encodingutf-8)} sentences [jieba.lcut(t) for t in raw_texts if t.strip()] # sg1 用 skip-gram景区评论语料小vector_size 设 200 即可 w2v_model Word2Vec(sentences, vector_size200, window5, min_count1, sg1, epochs10) w2v_model.save(word2vec/wv.model)这段代码里window控制共现窗口大小窗口越大越偏主题信息越小越偏词法信息评论类短文本我一般用5。sg1即skip-gram对低频词更友好。训练后不要忘了执行w2v_model.wv.vectors.shape确认词表大小和向量维度这个shape要和config.py里的vocab_size、EMBEDDING_DIM对齐。这里有个常见误区word2vec训练和主模型训练是两回事。词向量一旦训练完成后面主模型是把每个词替换成对应的向量序号再查表而不是在训练主模型时单独跑word2vec。所以语料中有新词时要回到这一步重新训练词向量或者把新词映射到 不能直接往词表里硬塞。4.2 main_save.ipynb训练主流程与模型保存main_save是训练入口。它的职责是读取训练数据、执行预处理、构建模型、跑batch训练、保存模型和日志。项目里这个文件叫save意思是训练结束后把权重落盘供另一个入口load去预测。# main_save.ipynb 训练主流程示意按实际调用简写 import numpy as np import tensorflow as tf from config import * from dataSet import load_stopwords, tokenize, build_vocab, text_to_sequence from model import build_model from tools import compute_max_len texts, labels read_dataset(TRAIN_DATA) # 按实际数据格式解析 stopwords load_stopwords(STOPWORDS_PATH) tokenized [tokenize(t, stopwords) for t in texts] max_len compute_max_len(tokenized, quantile0.95) # 替代手写 MAX_LEN word2id build_vocab(tokenized, min_count1) X text_to_sequence(tokenized, word2id, max_len) y np.asarray(labels) model build_model(len(word2id) 1, EMBEDDING_DIM, max_len, HIDDEN_UNITS, NUM_CLASSES, DROPOUT) model.compile(optimizertf.keras.optimizers.Adam(LEARNING_RATE), losssparse_categorical_crossentropy, metrics[accuracy]) model.fit(X, y, batch_sizeBATCH_SIZE, epochsEPOCHS, validation_split0.1) model.save(MODEL_SAVE_DIR)这里的read_dataset是示意函数实际在dataSet.py里要从train.txt按分隔符解析出标签和文本注意标签要做成0、1、2这样的整数对应分类头的输出顺序。损失函数用sparse_categorical_crossentropy意味着标签不转one-hot直接传整数就行。validation_split0.1表示训练集里自动切10%做验证如果数据集已经分好train/test建议改成从train里手动均匀抽样验证集避免分布倾斜。模型保存用model.save(MODEL_SAVE_DIR)保存完整模型包括结构、权重和优化器状态。下次加载时用tf.keras.models.load_model(MODEL_SAVE_DIR)就可以直接预测。要注意保存路径下不能混入旧版本模型文件否则load容易加载到结构不一致的h5或SavedModel报错信息还不太直观。4.3 main_load.ipynb加载模型对新评论做情感预测训练完成后main_load承担预测任务。它的输入是未标注语句输出是情感类别和概率。这里的处理必须和训练时完全一致分词方式、停用词表、word2id构建、序列填充顺序任何一个不对预测结果都是乱的。特别是word2id训练时是临时构建的加载预测时也要按同样规则重建不能拿训练时的词表对象硬套。# main_load.ipynb 预测入口核心逻辑 import numpy as np import tensorflow as tf from config import * from dataSet import load_stopwords, tokenize, build_vocab, text_to_sequence model tf.keras.models.load_model(MODEL_SAVE_DIR) def predict_single(text, word2id, max_len): stopwords load_stopwords(STOPWORDS_PATH) words tokenize(text, stopwords) seq text_to_sequence([words], word2id, max_len) prob model.predict(seq, verbose0)[0] label int(np.argmax(prob)) return label, prob预测函数返回的label是类别索引prob是各类别概率数组。要输出给人看的“好评/中评/差评”文字还需要在函数外面做一次索引映射比如{0: 差评, 1: 中评, 2: 好评}。注意word2id必须在训练和预测两侧保持一致最好在训练后保存成json预测时直接加载而不是临时重建。之前遇到过一次很隐蔽的问题训练时word2id的构建顺序基于词频Counter同样的语料每次运行顺序是稳定的但换机器或换Python版本后Counter顺序可能有差异生成的word2id不一致导致训练和预测词表错位。5. 复现必读的五个坑数据、维度、环境与结果排查记录5.1 Gensim 4.x的API差异model[word]报KeyError现象按老教程写的w2v_model[风景]取词向量结果抛出KeyError换成w2v_model.wv[风景]就正常。原因Gensim 4.1.2调整了接口词向量统一收在model.wv里旧版可直接索引的方式被移除。解决把取向量、查相似词等操作全部切到w2v_model.wv上比如w2v_model.wv.vectors、w2v_model.wv.most_similar(风景)。保存推荐用model.save(wv.model)加载用Word2Vec.load不用word2vec_format的二进制否则还要额外传词表文件多一道出错机会。5.2 TensorFlow 2.3与高版本Numpy的兼容冲突现象加载模型或执行predict时报numpy相关错误比如维度无法相乘或属性不存在。原因TensorFlow 2.3.0对numpy 1.24及以上版本的兼容有限制环境里numpy版本偏高就会在运算时踩到不兼容。解决按项目列出的环境配置建独立conda环境锁Python 3.8.12再安装tensorflow2.3.0和numpy1.19.5。如果已经有高版本环境不要直接降级当前环境新建一个环境花五分钟比排查半天numpy报错省时间。5.3 embedding维度或vocab_size对不上模型尺寸报错现象模型训练第一轮就报维度不匹配比如Incompatible shapes或者词查找时找不到某个key。原因word2vec训练的vector_size是200但config里EMBEDDING_DIM被改成100或者vocab_size少算了UNK这一项。解决打印三个值核对——w2v_model.wv.vectors.shape、len(word2id)、config里的EMBEDDING_DIM。vector_size和EMBEDDING_DIM必须一致Embedding层的input_dim要填vocab_size也就是len(word2id)加1因为索引从1开始且UNK占用一个位置0留给了PAD。打印确认后再改config不要凭感觉猜。5.4 2class和3class切换后标签维度对不上现象数据换成2class目录后训练时loss正常下降但预测时概率数组长度还是3或者准确率忽高忽低。原因只改了数据路径没改NUM_CLASSES分类头还是3个输出2class的标签0和1对应的语义在输出层错位了。解决换数据目录时同步改config里的NUM_CLASSES并且确认dataSet.py里标签编码时用的映射和输出层顺序一致。我习惯在main_save开头打印model.output.shape和y里的类别集合一眼就能看出是否对齐。5.5 直接用训练集评估F1虚高现象日志里F1接近0.95但换一批新评论预测结果却惨不忍睹。原因不少notebook为了演示方便直接在训练集上算指标模型已经记住了训练数据评估结果自然虚高。解决从train.txt里单独抽15%到20%作为验证集训练时只用剩下部分或者用validation_split0.15然后用tools.py的compute_metrics在验证集上算指标。如果用的是分好的2class/3class目录确保test数据没有混进训练过程。6. 进阶用法用预测概率生成景区评价分数并校验置信度6.1 把情感概率映射成1到5分分类输出的概率本身信息量比标签大。同样是好评概率0.99和0.51的置信度完全不同。我通常会在这里加一个转换层把3类概率加权映射成景区常用的五分制评价分数好评给高分权重差评给低分权重中评按中性处理。这样批量评论分析后可以直接得到一个景区的综合满意度分。# 示例把预测概率映射为五分制评价分 def prob_to_score(prob, label_map{0: 1.0, 1: 3.0, 2: 5.0}): return float(np.dot(prob, [label_map[i] for i in range(len(prob))])) # 使用示例 label, prob predict_single(缆车排队两小时风景确实不错但体验太差, word2id, max_len) score prob_to_score(prob) if max(prob) 0.6: score None # 置信度过低交给人工复核映射权重的设计要结合业务差评权值给1分、中评3分、好评5分模型输出概率后做加权平均。放在真实景区评论场景里我不建议直接输出小数分而是按0.5分一档取整这样对外展示时更符合习惯。6.2 置信度阈值与批量落盘置信度小于0.6的样本先标记成待人工复核而不是硬给一个分数这是上线前很重要的护栏逻辑。项目里的result目录就是用来放这些预测结果和评分输出的。批量场景下我会把每条评论的原始文本、预测标签、三项概率、映射分数写进同一行便于回溯哪条样本被阈值拦下来了。我从这套源码包里拿到的核心收获是对抗训练和注意力不是玄学而是有明确收益的工程选择。需要复现这份资源时记得先把data目录和config对齐再开跑按第5章的排查顺序走完一遍能省不少时间。从那以后我每次处理这类带对抗训练的文本分类工程都会先花十分钟检查config、两个notebook的入口路径和类别配置再决定要不要动网络结构。这个习惯帮我少翻了很多次车希望帮到你。本文还有配套的精品资源点击获取