ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

LSTM中文情感分析毕设实战:数据预处理、词向量与模型训练全解析

LSTM中文情感分析毕设实战:数据预处理、词向量与模型训练全解析 简介一份基于LSTM网络实现的中文文本情感分析高分毕设项目面向计算机、人工智能、通信等专业学生及入门开发者配套积极/消极文本各约8000条可完成从分词、词向量到模型训练与预测的完整流程。压缩包共8个文件约6.62MB主要包含pos.txt、neg.txt两类文本数据集、Python训练脚本lstm.py、训练好的LSTM模型lstm.h5、Word2Vec.pkl词向量、yml参数配置以及README说明文档结构紧凑便于按模块复现。已有380人学习下载源码经作者多轮测试运行成功答辩评审平均分96分适合直接作为毕业设计、课程设计参考也可在既有代码上扩展新功能。借助jieba分词和Word2Vec可以清晰理解中文情感分析的特征构造方式进而掌握LSTM建模、训练与调参的常用技巧文件中附带的模型与说明还能帮助离线加载运行快速验证效果。1. LSTM文本情感分析毕设资源先看清数据、词向量和权重三件套拿到这份 LSTM 文本情感分析毕设资源时我第一反应不是解压跑lstm.py而是先去数文件data目录里放着积极和消极各 8000 条左右的中文语料model目录里有Word2Vec.pkl和lstm.h5旁边还有一个lstm.yml模型结构文件。这三样东西意味着什么意味着最难啃的数据采集和预训练已经做完了你要做的是正确地把它们串起来。这套资源适合毕设、课程设计以及想快速复现一条完整中文情感分析流程的 Python 入门者。前提是你得先理解LSTM 的输入不是文本而是定长的、被编成索引的词序列。后面每章就按这条路挨个拆。2. 中文语料预处理8000积极8000消极如何变成LSTM能吃的序列2.1 data目录的原始格式文件名即标签打开data目录后里面不是CSV也没有带标签的Excel只有两个纯文本文件pos.txt和neg.txt。每行是一条完整的评论或句子积极文本全部堆在pos.txt消极文本全部堆在neg.txt标签隐含在文件名里。这种组织方式在毕设代码里很常见省去手工标记但读取时必须写对路径和编码。我一般会把读取逻辑封装成一个函数避免训练脚本和预测脚本各写一套导致标签打反。def load_data(pos_path, neg_path): texts, labels [], [] for path, label in [(pos_path, 1), (neg_path, 0)]: with open(path, encodingutf-8-sig) as f: for line in f: line line.strip() if line: texts.append(line) labels.append(label) return texts, labels这里有两个细节值得说。第一encoding参数我故意写成了utf-8-sig而不是utf-8因为用记事本在Windows下保存过的txt文件通常会带一个不可见的BOM头\ufeff用utf-8读出来后第一行第一个字符就是这个乱码符号如果直接喂给jieba它会被当成一个独立的token污染整个词表。第二strip()之后必须判断空行否则文件末尾的空行也会被当作一条空语料padding之后变成一条全零向量训练时它会不断把模型往中立方向拽。读取顺序是每读一行积极、一行消极交替混合因此整个labels列表天然是1和0错开的后面做train_test_split时不需要额外担心顺序问题。2.2 分词与分词还是分字的选型中文不像英文那样自带空格LSTM和Embedding层只能消费token序列所以原始句子必须先切分成词或字。这个资源用的是jieba分词也是中文NLP里最常用、最省事的方案。最小切分代码只有一行import jieba def cut_text(text): # 精确模式返回词列表cut的可选参数不考虑HMM时更快 return list(jieba.cut(text.strip()))这里真正的坑不在jieba本身而在切到什么粒度。jieba默认词典对网络流行语和垂直领域词支持很差比如绝绝子下头这类词会被切成绝/绝/子或者单独一个下头结果在语料里出现次数很少训练出来的词向量基本是噪声。常见做法是先把全部语料过一遍jieba打开词频表看那些出现次数高、但明显被切得过碎的片段手动加进用户词典jieba.load_userdict(user_dict.txt)一行一个词一个词频。另一种更稳的选择是干脆不分词直接按字切分每个汉字一个token。很多毕设在语料只有一两万条时分字方案反而效果更好因为词典小、没有未登录词、也不会出现不喜欢被切成不/喜欢又因为停用词表误删不字这种低级事故。分词方案的优势是能保留词组的语义边界代价是OOV会随时出现。就这个资源的体量来说我的建议是先跑jieba看词频如果OOV比例高于5%果断切分字。顺便说一句停用词表的事。通用NLP的停用词表里通常会收录不没太这类词但在情感分析里不是决定情感方向的关键词不喜欢和喜欢方向完全相反把不删掉等于把输入语义直接反转。所以我做情感分析时基本不用通用停用词表最多手工过滤啊、呢、的、了这类明显语气词。这个点我在第5章的踩坑里还会再提到。2.3 序列化、定长padding与数据集划分分词之后的序列长度不一而Keras的Embedding层要求输入shape固定所以要做两件事把词转成整数索引再把序列padding到统一长度。最常见的是用Keras自带的Tokenizer配合pad_sequencesfrom keras.preprocessing.text import Tokenizer from keras.preprocessing.sequence import pad_sequences from sklearn.model_selection import train_test_split import numpy as np MAXLEN 64 VALIDATION_SPLIT 0.2 # cut_texts是分词后的二维list例如 [[这,家,店,很,好], ...] tokenizer Tokenizer(num_words20000) tokenizer.fit_on_texts(cut_texts) sequences tokenizer.texts_to_sequences(cut_texts) X pad_sequences(sequences, maxlenMAXLEN, paddingpost, truncatingpost) y np.array(labels) X_train, X_test, y_train, y_test train_test_split( X, y, test_sizeVALIDATION_SPLIT, stratifyy, random_state42)MAXLEN64不是随手填的。正确做法是先统计语料分词后长度的分布取95%分位数的值作为maxlen这样既不会丢掉大多数句子的尾部信息也不会让padding把每句话都拉得过长。取64在短评类数据集上通常够用但如果语料里有很多超过80词的长文本就该往上调。paddingpost表示在序列尾部补0truncatingpost表示超长时从尾部截断这两个参数必须记录清楚训练和预测两个阶段要严格一致否则训练时模型的语义位置习惯和预测时不匹配结果就是预测全部偏向某个阈值附近。stratifyy保证切分之后训练集和验证集里积极消极比例仍然是1:1这是防止模型偷懒的重要手段。如果不加这个参数随机切分可能让验证集恰好抽到大量难样本最后误以为模型过拟合。混合的重要一环Tokenizer的fit_on_texts必须在完整语料上执行一次不能先切训练集再在训练集上fit。否则验证集里出现训练集没见过的词时它会被直接忽略或映射到错误位置评估结果乐观得没有意义。所有预处理状态——tokenizer的word_index、MAXLEN、padding方式——在训练完成后都要保存下来预测阶段原封不动地复用。3. Word2Vec词向量加载embedding矩阵对齐是第一个玄学关口3.1 为什么选择预训练词向量而不是随机EmbeddingEmbedding层的本质是一个查找表把词索引映射到稠密向量。你可以让它随机初始化然后随训练更新但纯靠16000条采样文本去从零学语义关系数据量远远不够。所谓喜欢和喜爱在语义上相近这件事模型在这么少语料里几乎学不出来。Word2Vec在这里的价值是把外部语料里学到的语义先验注入模型使得很好和不错的向量天然距离近。资源里的Word2Vec.pkl正是为这一步准备的它保存的就是训练好的词向量表。拿到这个文件后我们要做的不是重新训练而是正确地把它对齐到Tokenizer的词索引上这个对齐工作在我看来是整个工程里最玄学、最容易悄悄出错的地方。3.2 训练Word2Vec时关键参数怎么设如果要从头训一份词向量我用的参数通常是这一组。注意中文分词后的句子要作为句子列表传给gensim也就是sentences是一个二维list每个元素是一次分词后的词列表。from gensim.models import Word2Vec import pickle w2v_model Word2Vec(sentencescut_texts, vector_size200, window5, min_count2, sg1, workers4) with open(model/Word2Vec.pkl, wb) as f: pickle.dump(w2v_model.wv, f)参数推荐值理由vector_size200语义容量与训练时间、内存开销的折中window5能覆盖很/不/喜欢这类局部搭配min_count2过滤只出现一次的生僻词减少噪声向量sg1skip-gram对样本量不大的中文语料更稳workers4多进程加速结果可复现性略下降这里有一个必须留意的坑pickle.dump(w2v_model.wv, f)保存的是KeyedVectors对象而不是完整Word2Vec模型反过来如果别人保存的是整个Word2Vec对象加载时就要用w2v.wv才能取到向量。资源里的Word2Vec.pkl我没法预先知道是哪一种所以加载之后第一件事是打印类型做判断。3.3 加载Word2Vec.pkl先判断对象类型再取向量加载已有词向量时最稳妥的写法是先打开文件探一下类型再决定取值方式。gensim 4.x里词向量的索引从w2v.vocab换成了w2v.key_to_index如果你在加载或取值时报AttributeError先怀疑版本不匹配处理办法是重新训练一份当前版本能读的pkl不要硬去改老接口。with open(model/Word2Vec.pkl, rb) as f: w2v pickle.load(f) if hasattr(w2v, wv): # 完整Word2Vec对象 w2v w2v.wv print(type(w2v)) # 应当是 KeyedVectorshasattr(w2v, wv)这个判断救了我很多次。很多人拿到pkl后不先打印类型直接w2v[开心]遇到完整模型就报错然后在模型结构上浪费一下午。加载成功后可以顺手验证一个词向量存在assert 开心 in w2v.key_to_index, Word2Vec词表里找不到测试词确认能正常取向量继续往下做Embedding矩阵才是有意义的。3.4 Embedding矩阵索引对齐与未登录词兜底词向量加载完成后要把Keras Tokenizer的词索引映射到Word2Vec向量上。思路清晰但实现细节多Tokenizer自己的word_index从1开始计数0要预留给paddingWord2Vec里没有出现的词要随机初始化兜底矩阵的行数必须比词表大小多1。import numpy as np EMBEDDING_DIM 200 vocab_size len(tokenizer.word_index) 1 # 0留给padding embedding_matrix np.zeros((vocab_size, EMBEDDING_DIM)) for word, i in tokenizer.word_index.items(): try: embedding_matrix[i] w2v[word] except KeyError: # 未登录词用小的随机向量兜底 embedding_matrix[i] np.random.normal(scale0.1, sizeEMBEDDING_DIM)vocab_size加1的原因最容易被忽略。Tokenizer的索引从1开始1到N分别是语料里的词0这个位置是pad_sequences补0对应的隐式token所以Embedding层的input_dim必须传len(word_index)1否则当模型看到某个索引恰好等于input_dim时直接报索引越界。兜底随机向量的scale0.1也不能随手改大Word2Vec的词向量范围通常很小如果这里用一个标准差是1的随机向量等于给未登录词强行塞了异常大的值训练初期模型会被这些词带偏。这一步做完后可以顺手验证一下对齐结果idx tokenizer.word_index.get(开心) if idx is not None: print(np.linalg.norm(embedding_matrix[idx]))打印出来的向量范数应该在合理的取值范围内。如果整个embedding_matrix有大量行是全零说明Tokenizer词表和Word2Vec词表重叠率很低问题大概率出在jieba分词不一致或Word2Vec训练时用了另一套分词结果这时候先去统一分词而不是继续改模型。3.5 Embedding层如何用上这份矩阵结构代码里Embedding层要显式传入weights[embedding_matrix]否则Keras会完全随机初始化前面做的所有对齐工作全部白费。更讲究一点的做法是训练时把Embedding层先冻结一到两个epoch让其他层先稳住再解冻微调避免预训练向量在训练初期被大步长更新直接冲乱。这个先冻结后解冻的习惯我在多个毕设项目里验证过在语料只有一两万条的场景下比全量微调平均能多两三个点的验证集准确率。4. 模型结构与训练从lstm.yml还原网络并用lstm.h5复现结果4.1 先看lstm.ymlKeras的YAML序列化资源里model目录下同时出现lstm.yml和lstm.h5这是很典型的Keras 2.x时代的保存方式YAML文件保存模型结构h5文件只保存权重。很多人一看到.h5后缀就以为能直接用load_model(model/lstm.h5)结果报错。正确的加载顺序是先用YAML重建结构再装载权重from keras.models import model_from_yaml with open(model/lstm.yml, encodingutf-8) as f: model model_from_yaml(f.read()) model.load_weights(model/lstm.h5, by_nameFalse) model.compile(lossbinary_crossentropy, optimizeradam, metrics[accuracy])load_weights默认按层名逐个匹配权重只要YAML结构和训练时的结构一致就能正确装载。如果报Unknown layer: CuDNNLSTM这类错误说明这份YAML是用GPU版本的Keras保存的当前CPU环境不认识CuDNNLSTM层最简单的处理是把YAML里的层名替换成LSTM注意保持return_sequences参数一致权重装载仍然可以成功。这一步做完完整的LSTM神经网络就重建好了。4.2 网络结构Embedding LSTM Dense的每一层为什么这么放这个规模的中文情感分类模型结构通常是这样from keras.models import Sequential from keras.layers import Embedding, LSTM, Dense, Dropout model Sequential() model.add(Embedding(vocab_size, EMBEDDING_DIM, weights[embedding_matrix], input_lengthMAXLEN, trainableTrue)) model.add(LSTM(64, return_sequencesFalse)) model.add(Dropout(0.5)) model.add(Dense(1, activationsigmoid))第一层Embedding接收的是pad_sequences输出即(样本数, MAXLEN)的整数矩阵输出变成(样本数, MAXLEN, 200)的三维张量。input_lengthMAXLEN是必须的它告诉模型每条样本带多少个时间步也约束了输入维度。中间的LSTM层用64个单元return_sequencesFalse表示只输出最后一步的隐藏状态这个向量代表整个序列的压缩语义正好对接后面的全连接层。如果写成了return_sequencesTrue输出会变成(样本数, MAXLEN, 64)三维张量Dense层没法直接处理。Dropout放在LSTM输出之后、Dense之前随机丢弃50%的神经元目的就是防过拟合。对16000条样本的训练量来说这个比例不算高也不算低验证集一直掉点时可以考虑提到0.6。为什么用LSTM而不是GRULSTM的门控结构对这种带明显情感转折词的短文本更敏感虽然参数多一倍但语料小时反而不容易欠拟合换GRU能提速但验证集准确率通常略低。4.3 编译与训练loss、optimizer、早停策略二分类任务里输出层用sigmoidloss用binary_crossentropy这是标配。优化器我用adam起步但如果发现loss震荡厉害再切RMSprop并降低学习率。训练不能只靠设置epochs硬跑必须加EarlyStopping和模型检查点from keras.callbacks import EarlyStopping, ModelCheckpoint callbacks [ EarlyStopping(monitorval_loss, patience3, restore_best_weightsTrue), ModelCheckpoint(model/lstm.h5, monitorval_accuracy, save_best_onlyTrue, save_weights_onlyTrue) ] history model.fit(X_train, y_train, validation_data(X_test, y_test), batch_size64, epochs20, callbackscallbacks)batch_size64配合16000条训练样本每轮大约200步CPU上跑也就几十秒到几分钟。patience3表示验证集loss连续三轮不下降就提前终止并恢复最佳权重防止训练后期过拟合。这里最容易被忽略的是save_best_onlyTrue和save_weights_onlyTrue的组合——它保存的永远是验证集指标最好的那一轮权重而不是最后一轮。这和你拿去预测的lstm.h5是同一份产物代码里的路径别写错否则预测时加载了一个不完整或最差的权重还以为是模型问题。4.4 训练完成后的第一件事抽样看预测概率而不是只看accuracy训练结束后我习惯把验证集预测概率打印出来而不是只盯着val_accuracy这个数字。model.predict(X_test)返回的是每条的0到1概率值把这些值和y_test并排打出来看那些预测错误样本的文本长什么样。很多真实标注是存在噪声的一句这个价格还可以在标注者眼里可能是中性的但被硬标成了积极1模型预测0.55觉得是积极其实并没有错。这种噪声是训练集里反复出现、无法靠调参消除的。遇到这种情况我会回到训练数据里检查标注质量而不是继续加层数。这项检查也是后面第6章做阈值调节的依据之一。5. 避坑LSTM情感分析从训练到预测的5个高频翻车点5.1 加载lstm.h5报Unable to load weights现象解压资源后直接from keras.models import load_model; load_model(model/lstm.h5)报错提示找不到权重或者模型结构无法加载。原因这个h5只存了权重没有保存计算图结构。Keras里model.save保存的才是完整模型而model.save_weights保存的只是权重字典。资源里同时给了lstm.yml说明结构已经单独序列化了不能把h5当完整模型用。解决先用model_from_yaml读取lstm.yml重建模型再执行model.load_weights(model/lstm.h5)。如果YAML读取抛异常多半是环境里Keras版本跟保存时的版本不一致把YAML里带版本号或不认识的层名换成当前环境的写法权重匹配按层名和shape只要层名不变一般都能加载成功。5.2 预测时重新fit_tokenizer词索引全变现象训练时准确率90%以上拿训练好的模型对单句新文本预测结果要么全部接近0.5要么概率分布跟真实文本完全对不上。原因预测脚本里又写了一遍Tokenizer()并对新文本执行了fit_on_texts。每执行一次fit_on_textsTokenizer都会重新统计词频并生成一套全新的word_index同一个词在训练和预测时可能被分配两个不同的整数索引Embedding层自然查不到正确向量。这是我在多个项目里见过最隐蔽、也最常见的一处翻车。解决训练阶段把Tokenizer实例用pickle保存下来预测阶段直接加载同一个实例只调用texts_to_sequences绝不调用fit_on_texts。同时把MAXLEN和paddingpost写死在配置里训练和预测用同一个函数生成输入序列。保存Tokenizer在毕设答辩时还能省去不少解释成本直接说你复用了训练阶段的状态机随机性被完全排除。5.3 jieba分词不一致导致OOV失控现象训练时用的是jieba.cut默认词典预测时换了新版本jieba或加了用户词典新文本里的绝绝子没被正确切开词表里根本不存在这个token于是被填充成随机向量模型预测自然不准。原因词向量和Tokenizer词表都依赖训练时的分词结果。只要预测阶段的分词行为与训练时不一致哪怕只是jieba版本升级分词边界都可能变化进而导致词表命中率下降。解决把jieba的词典、用户词典、分词模式全部锁死。更进一步的兜底是在Embedding矩阵填充时对所有未登录词给一个小的随机向量这样即使出现新词模型至少不会因查表报错而崩溃。如果反复出现大量OOV干脆改用按字切分方案汉字作为基本token的泛化能力在这种小语料场景往往比词级更强代价是词序信息会被压缩LSTM层数可能要再加一层。5.4 loss不降或出现NaN先查数据再查参数现象训练时loss在前几个epoch内纹丝不动甚至直接输出NaN验证集准确率始终在50%附近。原因首先排查的不是学习率而是数据。语料里有全角空格、不可见字符或空序列时padding后的整条样本变成全零模型在这种输入上学不到任何东西另外Embedding矩阵兜底随机向量的scale如果设成1和预训练向量尺度差距过大梯度很容易爆炸成NaN。解决训练前先检查X.sum(axis1)0的行占比有全零样本就删掉或回溯原始文本清洗一遍。清洗时把全角空格统一成半角去掉零宽字符。参数上先把学习率降到1e-3以下优化器从adam换成rmsprop如果还没改善检查Embedding层是否真的收到了weights[embedding_matrix]而不是被Keras的默认初始化覆盖了。5.5 验证集越训越差embedding全量解冻的代价现象训练集loss持续下降验证集loss却从某个epoch开始陡升准确率倒挂。原因16000条样本太小Embedding层如果trainableTrue从第一轮就开始微调模型会迅速记住训练集里的具体词组合而对没见过的词失去泛化能力。这和LSTM单元数过大是同一类过拟合问题。解决把Embedding层先冻结两个epoch让LSTM和Dense先适应预训练向量的分布再解冻做整体微调并配一个更小的学习率比如整体lr1e-3解冻后降为5e-4。另外Dropout从0.5提到0.6验证集指标往往会有几个点的回升。判断是否解冻过度的办法很直接回看训练历史如果第一个epoch验证准确率报表之后一直在掉说明Embedding微调方向不对果断回滚重度冻结策略。6. 单句预测与阈值调节用最短路径验证模型可用性训练完不要急着写长篇大论的预测类先做一条最短路径加载结构、装载权重、预处理一句话、输出概率。我一般会把预处理和预测封装成隔离函数彻底和训练代码分开避免混用变量。def predict_sentiment(text, model, tokenizer, w2v): tokens cut_text(text) # 和训练时完全相同的分词函数 seq tokenizer.texts_to_sequences([tokens]) x pad_sequences(seq, maxlenMAXLEN, paddingpost, truncatingpost) prob model.predict(x, verbose0)[0][0] return prob这段代码里最有价值的是和训练时完全相同这七个字。分词函数必须复用Tokenizer必须复用MAXLEN和padding参数必须复用任何一处改动都会让上一章里的翻车点重新出现。跑通最短路径后再去做实时接口或者Web演示都只是套壳核心逻辑已经验证完毕。阈值调节是我每次做情感分类最后一步的习惯动作。模型输出的是0到1的概率默认阈值0.5意味着模型认为稍微倾向积极就算积极。但在真实场景里模型在0.4到0.6这个区间往往是犹豫的这段样本的标注质量也最差。我会从没进过训练集的真实评论里抽20条让模型输出概率看落在0.5附近的样本原文长什么样再把阈值从0.5往0.45或0.55方向调比较F1而不是准确率。积极类和消极类比例不是1比1时准确率会骗人F1不会。还有一个快速验证模型有没有学反的土办法把同一句话里的很好改成很差模型输出的概率应该从高值翻到低值反之亦然。如果服务很差反而输出0.8说明标签或预处理某个环节把方向写反了这种方向性错误是准确率看不出来的必须用定向样本来检查。从那以后我每次拿到别人给的一个模型和权重文件都强制先走一遍加载结构、装载权重、预测一句话的最短路径确认方向和阈值都合理再考虑去动训练代码。这份资源里的数据、Word2Vec和lstm.h5正好把这套流程完整串了一遍希望帮到你。本文还有配套的精品资源点击获取
返回列表