
简介这是一份基于CNN-LSTM模型的季季红客户评价情感分析项目源码适合自然语言处理入门学习者、数据挖掘实践者以及需快速搭建情感分析流程的研究人员。项目将深度学习技术应用于品牌客户反馈通过CNN提取局部特征、LSTM捕捉序列依赖对在线评价进行正面、负面、中性分类。压缩包共45个文件约82.34MB涵盖17个Jupyter Notebook实验记录、9个CSV训练数据、4个Python源码、4个Keras模型及2个HDF5模型等。Notebook可逐步复现数据处理、模型构建与效果评估Python脚本承担加载、训练、预测等核心逻辑CSV与模型文件分别提供样本集和保存权重便于二次训练。目前已有341人学习下载。资源包含完整数据处理、多模型对比如Bert、LSTM、CNN-LSTM、实验可视化图表及Xmind思维导图能帮助读者理解从文本清洗、情感标注到模型调优的全过程也为毕业设计或企业舆情分析提供可扩展的工程参考。1. 基于CNN-LSTM模型的季季红客户评价情感分析设计源码这个项目到底解决什么问题拿到一个「基于CNN-LSTM模型的季季红客户评价情感分析设计源码」的项目标题先别被名字吓住。它其实就是一件事把季季红火锅店的线上用户评价文本用深度学习模型自动分成「正向」和「负向」并且给你一套能跑的完整代码。做课程设计、毕业设计或者想给自己攒一个文本分类baseline的人都会卡在这个题上——词典规则太糙单用LSTM偏慢单用CNN又抓不住转折语义CNN和LSTM拼起来才是性价比最高的组合。这个项目最实在的价值是让你不用从头造轮子。分词、词表、模型结构、训练、评估、接口一条链路全打通。新手照着代码一步步跑能看见每条评价从中文变成向量再变成概率的完整过程熟手则可以直接把模型结构换掉试试注意力、试试预训练向量拿这一套当实验台。下面从选型逻辑讲起把源码里每段关键代码和参数都拆开最后按我踩过的坑给你排一遍雷。2. 为什么是CNN-LSTM而不是单模型结构选型与三条判断依据很多人上来就问情感分析用LSTM不就行了吗为什么要叠一个CNN我一开始也这么想但拿季季红这种真实评价数据跑过之后才发现单模型的瓶颈非常明显。中文短评里同时存在两种信号一种是局部短语比如「分量足」「上菜快」「服务员态度差」这些关键词密集出现的地方就是强特征另一种是句子层面的转折和递进比如「味道不错但等位太久了」只看前半句会判成正向。这两种信号恰好分别是CNN和LSTM的看家本领。2.1 CNN在中文评价里提取的是什么特征CNN处理文本时把embedding之后的句子当作一个二维矩阵每个词对应一行向量。Conv1D的卷积核沿着词序方向滑动本质上是在做n-gram匹配。kernel_size3就相当于看三个连续词的组合kernel_size5就看五个词。对中文评价来说「分量足」「口味好」这些短语一旦出现卷积核能立刻把它们激活并且通过MaxPooling把最强的那一路信号挑出来。这个特性让CNN对局部情感词特别敏感训练也快。但它有个硬伤MaxPooling做完之后词的先后顺序被打乱了。你只知道句子某个位置出现了强特征却不知道这个特征在整句话里处于什么语境。「不像以前那么好吃了」这句话「好吃」会被卷积核识别成强正向特征可前面的「不像」「以前」把语义整个翻转了CNN单干就会翻车。2.2 LSTM补上CNN缺的上下文依赖LSTM用门控机制记住长距离信息。输入门决定记什么遗忘门决定丢什么「不像以前那么好吃」这种句子LSTM在处理到「好吃」的时候仍然保留着「不像」这个前置状态的痕迹。它的隐藏状态是一条连续的语义流顺序信息天然被编码在里面。但纯LSTM的问题在于训练慢、收敛难尤其是长文本。评价里经常有一百多字的情况前面的信息传到后面已经衰减得差不多了反而把短评里那些强信号给稀释掉。CNN在进LSTM之前先做一轮特征提取把关键短语先捞出来再把带有位置信息的特征序列交给LSTM等于把LSTM要学的东西预处理了一部分序列变短、信号变纯训练速度和效果都能上去。2.3 三种结构对比与参数起步值下面这个对比表是我在本地拿季季红评价数据跑出来的直观感受不是benchmark但足够说明选型理由。结构局部特征提取顺序语义建模训练速度典型短板单CNN强几乎无最快转折句误判率高单LSTM弱强慢长文本信息衰减CNN-LSTM强强中等参数多需要调结构如果你决定走CNN-LSTM路线我建议的起步参数是一组保守值先跑通再往上加embedding维度取128卷积核同时用3、4、5三种尺寸各128个filtersMaxPooling之后接一个64单元的单向LSTM想提升效果可以换双向Dropout设为0.3。这套组合参数量不算大在消费级GPU上几分钟就能完成一个epoch先把baseline拿到手后续所有魔改都有对照物。3. 数据准备与预处理把中文评论变成模型能吃的张量模型结构定下来之后最耗时间的其实是数据。季季红这种项目通常拿到的原始数据是爬来的美团或大众点评评论也可能是一份csv导出。现实情况是字段乱七八糟、带HTML标签、表情符号、重复评论、缺失值甚至标注口径都不一样。我一般会先做一轮清洗和标注复核再进分词和编码。这一步偷懒后面模型效果一定会反噬你。3.1 标注数据从哪里来采集清洗与正负向标注策略如果是课程设计数据量不用追求大几千条足矣但类别分布要真实。常见做法是写爬虫抓公开评论或者用已有的开源中文评论数据集拼一部分。关键在标注环节——很多人直接按评分高低一刀切四星五星标正向一星二星标负向三星扔掉这样出来的标签噪声很大。更好的策略是抽样人工复核把「评价内容和评分明显矛盾」的样本清理掉比如打了五星但吐槽卫生的这类样本会让模型学出错误的对应关系。数据准备好之后先做简单的数据探查看总条数、类别分布、平均长度、最大最小长度。这一步的目的不是走形式而是为后面定max_len和词表大小提供依据。我用pandas做这一步的典型代码如下import pandas as pd df pd.read_csv(review.csv, encodingutf-8) print(df.shape) print(df[label].value_counts()) # 正向/负向占比 print(df[review].str.len().describe()) # 评论长度分布决定max_len逻辑说明先确认数据规模和类别平衡性再看长度分布。如果负向占比低于20%后面训练时必须考虑class_weight否则模型会倾向把所有句子都判成正向。长度分布的P90值通常就是max_len的首选值比拍脑袋定的128或256靠谱得多。3.2 jieba分词与停用词过滤的落地代码中文不像英文天然按空格分词必须用工具先切词。jieba是目前最通用的选择但分词结果里有大量噪声标点、单字语气词、「的」「了」「就」这类停用词。清理之后句子变得更干净卷积核学到的n-gram也更聚焦。下边这段是我每次都会用的清洗管线import re import jieba stopwords set() with open(stopword.txt, encodingutf-8) as f: stopwords set(f.read().split()) def clean_text(text): text re.sub(r[^], , text) # 去HTML标签 text re.sub(r[^\u4e00-\u9fa5a-zA-Z0-9], , text) # 去表情和特殊符号 return text def cut_words(text): words jieba.lcut(clean_text(text)) return [w for w in words if w.strip() and w not in stopwords and len(w) 1] df[words] df[review].apply(cut_words)参数说明stopword.txt需要自己准备网上一搜能下到通用中文停用词表但建议往里加餐饮场景特有的词比如「这家」「感觉」「我们」。len(w) 1这步会把单字词全部滤掉好处是减少噪声坏处是「好」「差」这种高信息量单字也会被误杀——这是刻意取舍在情感分类里碰到这类单字强信号的情况不多靠短语_features基本能覆盖。如果你的数据里「好」字大量出现且影响显著就把这个条件去掉单独实验对比一次。3.3 词表构建与序列填充vocab、padding、截断的取舍分词完成之后要把每个词映射成数字ID然后把长度不一的句子对齐成相同长度。这里有两个容易出错的地方一是词表大小怎么定二是max_len怎么定。我见过不少人词表建了七万多词embedding层占显存不说低频词全是噪声训练时间暴涨。正确做法是只保留高频词多出来的统一映射成UNK同时把句子截断或填充到统一长度。参考代码如下from collections import Counter from sklearn.model_selection import train_test_split # 只看训练集构建词表防止测试集信息泄露 train_texts, test_texts, train_labels, test_labels train_test_split( df[words], df[label], test_size0.2, random_state42, stratifydf[label]) word_count Counter(w for words in train_texts for w in words) vocab {w: i 2 for i, (w, c) in enumerate(word_count.most_common(5000))} vocab[PAD] 0 vocab[UNK] 1 def encode(words, max_len64): ids [vocab.get(w, 1) for w in words][:max_len] return ids [0] * (max_len - len(ids)) train_X [encode(words) for words in train_texts] test_X [encode(words) for words in test_texts]逻辑说明most_common(5000)只保留全语料最高频的5000个词不够就在vocab.get时落到UNK。stratifydf[label]保证切分前后正负向比例一致。max_len64是起步值如果长度分布探查发现P90在100左右就改成100填充部分用0后面embedding层会配合mask_zero把填充位忽略掉。这里有个细节值得注意vocab的索引从2开始0和1分别留给PAD和UNK。很多新手用enumerate直接生成从0开始的索引导致PAD的真实词混入模型训出来效果差一截而且极难排查。4. 模型构建与训练Keras实现CNN-LSTM的完整代码数据准备好了模型部分反而是最不需要创造力的。用Keras搭CNN-LSTM非常直接Sequential模型二十行以内就能写完。但每个层的参数怎么设、为什么这么设直接决定训练是几分钟跑完还是反复不收敛。这一章我按embedding、卷积池化、LSTM、全连接四段拆开讲清楚。4.1 Embedding层的选择随机初始化还是预训练词向量Embedding层把每个词的离散ID映射成稠密向量。第一个要考虑的是向量从哪里来。随机初始化的做法是自己训练一个词嵌入矩阵适合数据量够大、领域词比较集中的场景用预训练词向量比如Word2vec或腾讯词向量则能借助外部语料的语义知识但下载几GB的向量文件、对齐词表都是额外工作。课程设计级别我建议直接用随机初始化让embedding跟着任务一起训练数据量在几千条以上时效果足够用。第二个要考虑的是embedding维度。128是一个兼顾效果和显存的中位数太小的64表达能力受限太大的300以上会明显增加显存和训练时间。还有一个参数容易被忽略mask_zeroTrue。它告诉Keras输入里的0是填充位计算时跳过这能避免LSTM被大量填充位干扰。import tensorflow as tf model tf.keras.Sequential([ tf.keras.layers.Embedding( input_dim5002, # vocab_size PAD UNK output_dim128, mask_zeroTrue, nameembedding ), tf.keras.layers.Conv1D( filters128, kernel_size3, paddingsame, activationrelu, nameconv_3 ), tf.keras.layers.MaxPooling1D(pool_size2, namepool_3), tf.keras.layers.LSTM( units64, dropout0.3, return_sequencesFalse, namelstm ), tf.keras.layers.Dense(32, activationrelu, namedense_1), tf.keras.layers.Dropout(0.3, namedropout_1), tf.keras.layers.Dense(1, activationsigmoid, nameoutput) ]) model.compile( optimizertf.keras.optimizers.Adam(learning_rate1e-3), lossbinary_crossentropy, metrics[accuracy] ) model.summary()参数说明input_dim要等于词表长度加2忘了加会直接报索引越界。paddingsame保证卷积后序列长度不变再加MaxPooling1D把长度减半这样进入LSTM的序列比原始句子短训练效率更高。LSTM的dropout0.3是作用在循环连接上防止时序方向过拟合和后面的Dropout层是两个位置。4.2 核心模型代码Conv1D、池化、LSTM、全连接的组合顺序上面这段代码里我刻意只放了kernel_size3一种卷积核。很多教程会教你同时用3、4、5三个尺寸并联然后concat这是经典TextCNN的做法。但放在CNN-LSTM结构里多尺寸卷积并联会让输出维度变复杂对新手不友好先跑通再升级是更稳的路。全连接层前面接LSTM输出这里有个容易困惑的点return_sequencesFalse表示LSTM只返回最后一个时间步的隐藏状态把它当成整个句子的语义向量再接Dense。如果改成TrueLSTM会返回每个时间步的输出那时通常需要再接Flatten或全局池化。这个项目用False更自然。训练之前的最后一步是转换标签格式。Keras二分类要求标签是0和1的数值数组不能用字符串。pd.get_dummies不行需要直接mapimport numpy as np train_y np.array(train_labels, dtypenp.float32) test_y np.array(test_labels, dtypenp.float32)4.3 训练参数与回调早停、学习率、batch_size怎么给训练参数不是固定的但有几条经验可以让新手少走弯路。batch_size默认32是安全值显存充裕再上64epochs给20但不建议真的跑满要配合EarlyStopping。学习率用Adam默认的1e-3起步如果loss震荡不降降到3e-4再试。最关键的早停条件我习惯监控val_loss而不是val_accuracy——准确率在类别不平衡时会骗人loss的下降更能真实反映模型改善。early_stop tf.keras.callbacks.EarlyStopping( monitorval_loss, patience3, restore_best_weightsTrue ) history model.fit( train_X, train_y, validation_data(test_X, test_y), batch_size64, epochs20, callbacks[early_stop], verbose1 )参数说明patience3表示连续3个epoch验证loss不下降就停配合restore_best_weightsTrue回到最优权重。这套组合能有效防止过拟合也是让训练时间可控的关键。verbose1是打印进度条调试时用跑批实验时可以改成2或0减少日志刷屏。提示训练前最好设置tf.random.set_seed(42)和np.random.seed(42)否则每次跑出来的结果都不同调参时会让你误判改动效果。4.4 评估与混淆矩阵准确率不是唯一指标模型训练完千万别只打印一个accuracy就完事。类别不平衡下准确率会出现「全预测正向也有85%正确率」的迷惑性表现。正确做法是看混淆矩阵每类别的精确率和召回率尤其关心负向评价的召回率——情感分析场景里漏判一条负评比误判一条正评代价大得多。from sklearn.metrics import classification_report, confusion_matrix pred (model.predict(test_X) 0.5).astype(int) print(confusion_matrix(test_y, pred)) print(classification_report(test_y, pred, target_names[负向, 正向]))逻辑说明预测输出是sigmoid概率用0.5当阈值切分为0和1。classification_report给出每类的precision、recall、f1比单一accuracy信息量大得多。看报告时重点看负向的recall如果明显低于正向说明负样本没学好接下来要动class_weight而不是盲目加模型复杂度。这一步恰恰是答辩和评审最常追问的地方能讲清楚混淆矩阵项目说服力高很多。5. 常见问题与避坑训练这类情感分析模型最容易翻车的5个位置这部分是血泪经验。我在这个项目上前后跑了三轮第一轮教训是数据泄露第二轮是类别不平衡第三轮才是模型结构问题。下面这五条是新手最常踩、也最影响结果的坑每一条我都按现象、原因、解决给列清楚了。5.1 数据不平衡导致模型全预测「负向」现象、原因、解决现象是训练过程loss正常下降验证集准确率看着有85%但把预测结果导出来一看几乎所有评论都被判成正向负向一条都没抓到。查classification_report发现负向的recall是0。原因是餐饮评论正向比例本来就比负向高模型发现全预测正向就能拿到不错的loss走捷径不学负向特征。解决方法是给少数类加权class_weight {0: 2.5, 1: 1.0} history model.fit( train_X, train_y, validation_data(test_X, test_y), batch_size64, epochs20, class_weightclass_weight, callbacks[early_stop] )参数说明class_weight让负向样本的loss权重放大2.5倍逼着模型重视少数类。权重数值按正负比来定如果负向占20%权重给2.5到3之间比较合理。加了权重之后准确率表面会掉一点但负向召回率会明显涨这是正确方向。5.2 长短评论混训导致LSTM收敛慢截断长度与分桶策略现象是每个epoch耗时是预期的两倍loss还要十几个epoch才下降。原因是不管评论多长都fill到256大量填充位浪费计算而且长短评论混在一起LSTM在长序列上要吃很多无意义的时间步。解决是先看长度分布设置一个合理的max_len而非凭感觉lengths df[review].str.len() print(lengths.describe()) print(lengths.quantile(0.9))如果P90是87就把max_len设为100超过100的部分直接截断因为尾部信息对情感判断的价值远低于头部。截断带来的信息损失远比模型在超长序列上硬扛要划算。5.3 词表过大拖慢训练低频词截断与min_count设置现象是embedding层内存爆炸16G显存直接OOM或者每个epoch慢到无法忍受。原因是词表没限长切出来的词全部进vocab几万个低频词每个都占用128维向量其中大部分是只出现过一两次的人名、错别字、数字组合。解决是把词表限制在5000到8000之间同时加最小词频过滤word_count Counter(w for words in train_texts for w in words) word_count {w: c for w, c in word_count.items() if c 2} vocab {w: i 2 for i, (w, c) in enumerate( sorted(word_count.items(), keylambda x: -x[1])[:5000])}逻辑说明c 2过滤掉只出现一次的词再按频率取前5000。这样做低频词全部落到UNK虽然损失了个别有用词但换来的是训练速度和稳定性性价比很高。5.4 GPU显存不足batch_size与embedding维度的联动调整现象是ResourceExhaustedError代码一跑就OOM。原因通常是batch_size和embedding维度同时给得太大再加上序列长度偏长单batch的数据塞不进显存。解决思路是调参而不是换机器先把batch_size降到32或16再把embedding维度降到100如果还OOM把序列长度砍到64。这三样是显存消耗的三大来源轮流降参直到能跑。OOM本身不会损坏数据重启kernel再跑就行这是所有深度学习者都经历过的玄学时刻。5.5 过拟合严重Dropout位置比数值更重要现象是训练集准确率最后到99%验证集却停滞在80%上下每个epoch的差距越来越大。原因是模型参数量远超数据量把训练集背下来了。这时调Dropout比加数据更立竿见影。记住两个位置LSTM里加dropout参数作用于循环连接全连接层前加Dropout层作用于特征向量。两个位置都加比只加一个效果更稳定。数值从0.2起步过拟合明显就提到0.4。如果0.4还不够问题多半不在Dropout而是数据量太少回去检查数据清洗流程有没有把有重复评论也留进来了。6. 把模型用起来导出、接口与一个最小可展示的Demo模型的最终归宿不是训练脚本里那些打印出来的指标而是一个别人能用的入口。课程设计和毕设最常见的要求是做一个可视化界面或者接口把模型包装起来。这里我给一套最省事的做法模型保存成h5文件用Flask起一个HTTP接口前端随便写个文本框就能演示。这一章最后有一个我习惯加的升级点对答辩和实际落地都有用。6.1 模型导出为ONNX并部署到Flask接口训练完的模型保存后加载并暴露成接口。Flask是这个场景最通用、代码量最小的选择不用引额外框架。保存和推理代码import numpy as np import tensorflow as tf from flask import Flask, request, jsonify model.save(sentiment_model.h5) app Flask(__name__) model tf.keras.models.load_model(sentiment_model.h5) app.route(/predict, methods[POST]) def predict(): data request.get_json() words cut_words(data[review]) # 沿用前面定义的分词函数 ids encode(words) # 沿用前面的encode函数 prob float(model.predict(np.array([ids]), verbose0)[0][0]) sentiment 正向 if prob 0.5 else 负向 return jsonify({sentiment: sentiment, prob: round(prob, 4)}) app.run(host0.0.0.0, port5000)逻辑说明这里最关键的细节是预测输入必须和训练时的预处理完全一致分词、停用词、encode顺序都不能变否则模型等于在看另一种语言。接口返回里同时给硬标签和概率值方便前端做展示——这是比只返回标签更实用的做法评审看到概率会立刻觉得项目完整度高。6.2 单条评论推理的最小代码如果你不想起web服务只想临时测一条评论可以用这个精简脚本def predict_single(text, model, max_len64): words cut_words(text) ids encode(words, max_lenmax_len) prob model.predict(np.array([ids]), verbose0)[0][0] return 正向 if prob 0.5 else 负向, prob print(predict_single(锅底味道很正牛肉新鲜下次还来, model)) print(predict_single(等位一小时上菜还慢体验很差, model))这里有一个我愿意多写两句的细节把输出阈值从0.5往上抬到0.6甚至0.65可以显著减少「漏判负评」。情感分析业务里把一条中性的评价误判成正向问题不大但把真实负评漏过去会让运营误以为口碑没问题。阈值是可调参数调它不需要重训模型只改一行代码——这是这整套方案里性价比最高的可操作旋钮也是我每次交付前必做的一步。我现在的习惯是拿到任何新的文本分类数据集先按这套CNN-LSTM结构跑通记录准确率、混淆矩阵和预测样例再决定要不要升级注意力、换成预训练模型。这套组合虽然不是当前效果的天花板但它是最稳的起点能保证你在一小时内拿到一个可解释、可改进、可演示的完整方案。希望帮到你。本文还有配套的精品资源点击获取