
简介面向酒店评论情感分析任务及NLP入门学习者提供一套基于LSTM模型的中文评论情感识别完整项目。项目以酒店真实评论为数据指导用户完成从文本清洗、分词、序列填充到LSTM模型构建、训练与评估的整套流程适合课程作业与个人实战练习。压缩包内共3个文件涵盖Python模型脚本、Markdown实验说明和CSV标注数据集其中说明文档对数据字段和训练要点做了注释代码采用可直接运行的结构便于快速复现实验。资源为zip格式整体大小仅887KB轻量易用。目前已有775人学习下载适合高校学生、算法爱好者以及参加竞赛的选手参考。通过对照代码与文档读者可深入理解中文文本向量化方法、LSTM网络结构原理与情感分类评估指标并能迁移到其他中文文本分析场景。1. LSTM酒店评论情感分析从一份可直接运行的数据集到能上线的判断依据酒店行业最不缺的是评论最缺的是把评论变成决策信号的人。运营每天要翻几百条“床垫太软”“卫生间地漏反味”“前台态度很好”这类中文短文本人工标注费时费力而且不同人对情感强弱的理解不一致。酒店评论情感分析要解决的就是这件事输入一段中文评论LSTM模型输出它属于正面还是负面让后续的服务改进、差评预警和舆情统计能自动化。适合三类人想快速在文本分类上跑通一个端到端流程的Python工程师需要给运营侧交付一个可落地的分析工具的算法新手以及正在准备NLP相关项目、希望有一份能直接运行的代码和数据集的在校生。它的价值不在于把准确率刷到多高而在于把“从原始文本到可解释的预测结果”这条路完整打通并且能在小样本场景下稳定复现。2. 数据集准备与预处理中文评论的分词和清洗决定LSTM的上限2.1 数据集组织与标签分布读入酒店评论并检查正负类比例拿到手的数据集通常是CSV格式里面至少包含两列label和commentlabel用0表示负面、1表示正面部分数据集还带星级、入住时间等额外字段。我拿到文件的第一件事不是急着建模型而是先用pandas做一次“体检”确认样本量、标签分布和数据质量。标签分布尤其重要因为直接影响后续损失函数要不要加class_weight也直接决定了模型在验证集上的可信度。import pandas as pd df pd.read_csv(hotel_review.csv, encodingutf-8) print(总样本数:, df.shape) print(df[label].value_counts()) print(df.head())这段代码做的事情很直接shape确认行数和列数value_counts统计正负类各自有多少条head抽查文本里有没有空值、乱码和异常标签。如果正负比例接近1:1模型训练会比较平稳如果达到5:1以上模型很容易把少数类全判错这时需要给少数类更高的权重。如果发现label里混入了2这样的值先过滤掉中性评论因为二分类的LSTM用sigmoid输出结构简单跑通的效率更高。实际项目里我会把清洗后的结果存成独立的hotel_review_cleaned.csv原因很简单每次调模型都重新跑一遍清洗和分词既浪费时间又容易因为数据状态不一致导致对比实验失真。数据版本管理是情感分析项目里最容易忽略的步骤没有之一。2.2 分词、停用词与噪声清洗用jieba加自定义词典控制词表质量中文评论不像英文有天然空格分词是绕不开的环节。最常用的是jieba它支持精确模式、全模式和搜索引擎模式情感分析场景下用jieba.lcut()做精确分词就够了。这里有个关键认知分词不是切得越碎越好切碎会导致同一个语义实体被拆成多个无效token词表膨胀模型学不到有用信息。酒店领域的“五星级酒店”“亲子房”“免费早餐”这类词建议提前加到自定义词典。import jieba import re import pandas as pd jieda.add_word(五星级酒店) jieda.add_word(亲子房) jieda.add_word(免费早餐) STOPWORDS_PATH stopwords.txt def load_stopwords(path): with open(path, encodingutf-8) as f: return set(line.strip() for line in f) stopwords load_stopwords(STOPWORDS_PATH) def clean_text(text): # 去掉URL、用户、连续空白保留中英文和数字 text re.sub(rhttp\S, , text) text re.sub(r\S, , text) text re.sub(r\s, , text) return text.strip() def text_to_tokens(text): text clean_text(text) tokens [] for word in jieba.lcut(text): word word.strip() if not word or word in stopwords: continue # 过滤掉纯符号、emoji等非中文英文数字片段 if re.fullmatch(r[^\u4e00-\u9fa5a-zA-Z0-9], word): continue tokens.append(word) return .join(tokens) df[tokens] df[comment].apply(text_to_tokens) df.to_csv(hotel_review_cleaned.csv, indexFalse, encodingutf-8)这段代码里有两个参数值得反复调。第一个是停用词表网络上能下到通用中文停用词表但酒店场景下“早餐”“位置”“卫生”这些词不能进停用词表否则模型会丢掉核心信号。第二个是jieba.add_word我一般是先跑一次全量分词把collections.Counter统计出来的高频实体看一遍哪些被切碎了就补进词典然后重跑。流程虽然多一步但词表质量会明显改善。过滤纯符号和emoji的规则也需要注意。re.fullmatch的作用是要求整个词完全匹配“非中文、非英文、非数字”的集合像“”、“…”这种标点符号会被丢掉。但Emoji是酒店评论里常见的情绪表达比如“房间很大”如果全部丢弃模型就失去了一个加分特征。我在实际项目里会保留正面和负面的Emoji映射成特殊token比如把“”映射为[POS]把“”映射为[NEG]这个映射表不大但对情感分类的提升是实打实的。2.3 词表构建与序列化用Tokenizer和pad_sequences把文本变成张量分词完成之后文本仍然是一串字符串需要先把每个词映射成整数索引再统一成长度一致的序列才能喂给LSTM。Keras的Tokenizer和pad_sequences是干这个活的标准组合。需要调的核心参数有三个num_words控制词表大小oov_token处理未登录词max_len决定序列长度。from tensorflow.keras.preprocessing.text import Tokenizer from tensorflow.keras.preprocessing.sequence import pad_sequences VOCAB_SIZE 20000 MAX_LEN 100 tokenizer Tokenizer(num_wordsVOCAB_SIZE, oov_tokenOOV) tokenizer.fit_on_texts(df[tokens]) sequences tokenizer.texts_to_sequences(df[tokens]) X_pad pad_sequences(sequences, maxlenMAX_LEN, paddingpost, truncatingpost) y df[label].values print(词表容量:, len(tokenizer.word_index)) print(序列矩阵形状:, X_pad.shape) print(标签形状:, y.shape)逻辑说明Tokenizer先拟合全量文本建立“词→索引”的映射表num_words20000表示只保留训练集中出现频率最高的前2万个词其余全部归入OOV。这个参数不能拍脑袋定词表太大模型容量就大小数据集容易过拟合词表太小很多语义丰富的低频词被牺牲。我通常根据tokenizer.word_index的长度反馈来调整一般酒店评论数据集在1万到3万之间比较合适。oov_tokenOOV是关键的后悔药它能保证模型在推理阶段遇到没见过的词时不至于崩溃而是统一映射到OOV索引。pad_sequences的maxlen是另一个影响训练效果的关键参数。我习惯先统计[len(seq) for seq in sequences]的分布看P80或P90位置在哪里再决定MAX_LEN。取100通常能覆盖绝大多数酒店评论但评论特别长时截断策略也要选对。truncatingpost表示从尾部截断——对LSTM来说越靠近末尾的词对最终情感判断的影响越大从尾部截断会丢掉重要信息所以如果评论普遍长应该用truncatingpre保留尾部。这里的取舍没有标准答案要基于你数据集的长度分布去定。3. 从词到序列Embedding维度与LSTM层参数的配合逻辑3.1 Embedding层的作用和词向量维度选择LSTM不能直接吃整数索引它需要把每个词索引映射成一个稠密向量。Embedding层本质上就是一个可训练的查找表每个词对应一行向量模型训练过程中向量会不断优化最终让语义相近的词在向量空间里距离更近。这个设计对中文短文本尤其友好因为不需要预先加载外部预训练词向量也能在小规模数据上学到不错的表示。from tensorflow.keras.models import Sequential from tensorflow.keras.layers import Embedding embedding_dim 128 model Sequential() model.add(Embedding( input_dimVOCAB_SIZE, output_dimembedding_dim, input_lengthMAX_LEN, mask_zeroFalse ))output_dim是这里最值得调的参数。我用过的经验范围是64到300之间。128是折中方案数据集只有几千条时128维的向量空间已经足够表示词义如果你的数据量超过5万条可以考虑提到256或300。提维度的代价不只是显存增加更关键的是训练参数变多小数据集上容易过拟合。另一个参数mask_zero需要注意如果设置mask_zeroTrueEmbedding层会把索引0对应的填充位在后续计算中跳过这对LSTM处理变长序列很有帮助但会占用额外计算资源。如果padding和truncating都选的postmask_zero建议打开如果序列长度固定且填充很少不开也行。这里还有一个容易被忽视的点Embedding层的输入是X_pad的整数索引矩阵每个词索引必须小于VOCAB_SIZE。如果Tokenizer的num_words设置得比实际词表小超出部分的词会被标记为OOV不会越界报错。3.2 LSTM层的参数调优units、dropout和recurrent_dropout的取舍LSTM的核心参数是units也就是隐藏状态维度。它决定了网络能记住多复杂的历史信息。对酒店评论这种短文本units设32也是能跑的但64通常会得到更好的效果128以上则开始出现收益递减。小数据集上把units堆到256训练时间翻倍准确率未必提升反而更容易过拟合。from tensorflow.keras.layers import LSTM, Dense, Dropout model Sequential() model.add(Embedding(VOCAB_SIZE, 128, input_lengthMAX_LEN)) model.add(LSTM( units64, dropout0.3, recurrent_dropout0.3, return_sequencesFalse )) model.add(Dense(1, activationsigmoid))参数说明里最值得展开的是dropout和recurrent_dropout的区别。dropout作用在输入到LSTM的线性变换上recurrent_dropout作用在隐藏状态的时间步递推上。对文本分类来说时间步之间的过拟合比输入层的过拟合更常见所以recurrent_dropout不能设太小我一般保持在0.2到0.4之间。两个dropout都设0.5以上会让模型欠拟合训练loss下降得很慢。return_sequencesFalse表示LSTM只输出最后一个时间步的隐藏状态这是分类任务的默认选择只有当你打算再接一层LSTM或者做序列标注时才改成True。3.3 单向LSTM还是双向LSTM中文评论里后文信息同样重要标准LSTM只能从前到后扫描序列最后一个时间步的隐藏状态承载了整个句子的语义。但人类读评论不是线性的“房间小但很干净”里“但很干净”才是态度转折点如果只靠正向扫描模型可能只记住了“房间小”的负面信息。双向LSTM同时跑正向和反向两个方向然后把两个方向的最后输出拼接或相加能同时利用前后文。from tensorflow.keras.layers import Bidirectional model Sequential() model.add(Embedding(VOCAB_SIZE, 128, input_lengthMAX_LEN)) model.add(Bidirectional(LSTM(units32, dropout0.3, recurrent_dropout0.3))) model.add(Dense(1, activationsigmoid))这里我把units降到了32因为双向LSTM实际参数量是单向的两倍如果单方向用64双向就有128个隐藏单元小数据集上很容易过拟合。常见做法是单向用64、双向用32模型容量基本持平但双向语义建模能力更强。要注意的是双向LSTM的计算时间接近单向的两倍如果数据集规模大、训练时间紧先跑通单向基线再换双向对比是性价比最高的路径。4. 训练与验证从compile到早停搭一套能直接运行的训练流程4.1 编译策略损失函数、优化器和学习率的选择LSTM模型在训练前需要明确三件事任务类型决定损失函数数据规模决定优化器收敛速度决定学习率。酒店评论情感分析是二分类任务loss用binary_crossentropy是标准选择输出层接一个sigmoid单元对应正面概率。优化器方面Adam是文本分类里最稳的选择它自带自适应学习率基本不需要手动调整动量参数适合新手快速跑通RMSprop在某些场景下收敛更稳定但Adam的设置更省心。from tensorflow.keras.optimizers import Adam model.compile( optimizerAdam(learning_rate1e-3), lossbinary_crossentropy, metrics[accuracy] )learning_rate1e-3是常见起步值但这不是固定值。如果训练过程中loss出现震荡说明学习率偏大降到3e-4或1e-4如果loss下降非常缓慢先确认数据预处理没出问题再考虑把学习率提到3e-3。我一般先跑3个epoch观察loss曲线的下降斜率再决定要不要调学习率而不是一开始就网格搜索。4.2 训练、早停与模型保存让模型在验证集上表现最优直接跑满固定epoch次数的做法在小数据集上很容易过拟合。更稳妥的是用EarlyStopping监控验证集loss当验证集loss连续几个epoch不再改善时自动停止训练并恢复到最优权重。这样既不会训练不足也不会训练过头。from tensorflow.keras.callbacks import EarlyStopping, ModelCheckpoint from sklearn.model_selection import train_test_split # 划分训练集和验证集 X_train, X_val, y_train, y_val train_test_split( X_pad, y, test_size0.2, random_state42, stratifyy ) early_stop EarlyStopping( monitorval_loss, patience3, restore_best_weightsTrue, verbose1 ) checkpoint ModelCheckpoint( best_lstm_model.keras, monitorval_loss, save_best_onlyTrue, verbose1 ) history model.fit( X_train, y_train, validation_data(X_val, y_val), epochs20, batch_size32, callbacks[early_stop, checkpoint] )参数说明patience3表示验证集loss连续3个epoch没有下降就停止这个值决定了模型愿意多熬多少轮。设置在2到5之间比较合理太小容易在loss短暂平缓时误停太大又会训练过久。random_state42保证每次划分一致实验可复现stratifyy保证划分后正负类比例和原始数据一致否则验证集可能全落到多数类。ModelCheckpoint保存验证集loss最优的那一个模型而不是保存最后一个epoch的模型这是给训练过程上的一道保险。训练完成后记得查看history里的val_loss和val_accuracy。如果发现val_loss在第5轮开始上升但训练loss还在下降说明模型进入过拟合阶段需要增加dropout或降低模型容量。4.3 模型评估准确率、精准率和召回率哪个更值得看酒店评论情感分析里准确率是会被高估的指标。假设数据集中90%是正面评论模型全预测为正面也有90%的准确率但这个模型没有任何实用价值。差评召回率对这个场景更关键——运营侧最怕的是漏掉差评。所以评估阶段至少要看三个值准确率、正面F1和负面F1。from sklearn.metrics import classification_report, confusion_matrix y_pred (model.predict(X_val) 0.5).astype(int32).flatten() print(classification_report(y_val, y_pred, labels[0, 1], target_names[负面, 正面])) print(confusion_matrix(y_val, y_pred))这里的预测逻辑是模型输出的是sigmoid概率值大于0.5判为正面小于0.5判为负面。这个阈值不是死的如果你的场景更在意差评不遗漏可以手动把阈值降到0.4让更多模糊样本被划为负面代价是误判也会增加。阈值调整属于业务决策需要在具体运营目标下权衡。classification_report能直接给出每一类的精确率、召回率和F1如果负面类的召回率明显低于正面类说明模型对负面评论的辨识力不足优先检查分词是否有问题其次是样本是否失衡。5. LSTM酒店评论情感分析的5个避坑清单数据、分词和训练中的常见翻车点5.1 模型loss不下降准确率一直停在初始值附近现象是训练过程中loss在N个epoch里纹丝不动准确率保持在value_counts里多数类占比附近。原因通常是数据预处理环节出了低级问题比如X_pad和y维度不匹配、标签没有对齐、Tokenizer没有fit_on_texts导致词表为空或者输入矩阵里全是0。另一个容易被忽略的原因是分词后得到的是空字符串texts_to_sequences自然就全是空序列pad_sequences后全变成0。解决办法是训练前打印X_pad的方差如果方差为0说明文本信息全丢了。检查一下text_to_tokens里停用词表是否把正文词误删了再用print(df[tokens].head())确认分词结果正常。5.2 训练loss正常下降但验证集和测试集几乎全预测成多数类这是情感分析里典型的“彩票模型”训练集表现很好一到验证集就崩。最常见原因是样本分布不均少数类样本太少模型根本学不到它的特征模式。解决路径有两种第一种是在model.fit里加class_weight参数给少数类更高的权重第二种是先用StratifiedKFold做交叉验证确认模型在不同划分上都稳定而不是某一次划分运气好。如果交叉验证后仍然偏向多数类说明少数类的文本特征本身就不够突出需要回到特征层面检查负样本里是不是混了很多中性评论或者标注本身有误。5.3 分词后词表异常膨胀或过分缩小出现了大量低频碎片现象是tokenizer.word_index超过5万或者反过来不足5000两种情况都要警惕。词表膨胀通常是停用词表太弱、符号过滤没生效导致“哈哈哈”“”这种碎片被当成有效词词表过小往往是停用词表过于激进把“干净”“舒服”“推荐”这类核心情感词都滤掉了。我一般会在预处理阶段跑一次词频统计人工扫一眼排在最前面的20个词和排在尾巴上的20个词确认没有明显的垃圾token和漏网情感词。自定义词典jieba.add_word的覆盖范围也要控制加太多模型会被领域词带偏加太少分词又切不准这是个需要反复试的平衡点。5.4 设置了较大的max_len显存占用高且训练速度显著下降max_len设得过长会带来双重惩罚显存占用增加训练时间变长但模型效果并没有相应提升因为绝大多数评论长度远小于你设置的值。与其拍脑袋设200不如先统计评论长度分布。如果P90在80左右max_len设100就是合理的如果P90只有50设70就够了。另外batch_size和序列长度对显存的影响是乘积关系序列长度大时适当调小batch_size到16或8能明显缓解显存压力但要注意batch_size太小时梯度估计不稳训练震荡需要配合学习率调整。5.5 “不太满意”“没有想象中好”这类否定句被模型判成正面这类问题是LSTM做中文情感分析的经典难题。“不太”和“满意”都是高频词模型训练时见过大量“满意”对应正面标签容易把“满意”这个词本身的权重拉高忽略了前面的否定修饰。解决思路有几种第一分词层面把“不太”“不怎么样”“一点也不好”这类常见否定结构合成为一个token比如jieba.add_word(不太)让模型把否定词和核心情感词当整体学习第二使用双向LSTM增强后文对前文否定词的感知第三如果数据允许在预处理阶段把常见否定结构抽出来作为额外特征拼进序列。最直接的做法还是先把否定结构的合成做掉再对比模型在“不太满意”这类样本上的预测结果。6. 进阶给LSTM加Attention和K折交叉验证把准确率从“看着行”变成“真稳定”6.1 给LSTM加一个简单的Attention层LSTM最后一个时间步的隐藏状态要压缩整个序列的信息当评论较长时中间的语义会被遗忘掉一部分。Attention机制让模型在最终输出时重新看一遍所有时间步的隐藏状态按重要性加权求和。实现上不需要自己手写反向传播用Attention层配合return_sequencesTrue就能做到。from tensorflow.keras.layers import Attention, Dense, Input, LSTM, Bidirectional from tensorflow.keras.models import Model inputs Input(shape(MAX_LEN,)) embedding Embedding(VOCAB_SIZE, 128, input_lengthMAX_LEN)(inputs) lstm_layer Bidirectional(LSTM(units32, dropout0.3, recurrent_dropout0.3, return_sequencesTrue))(embedding) # 注意力机制学习出一个权重分布加权所有时间步的隐状态 attention Attention()([lstm_layer, lstm_layer]) dense Dense(1, activationsigmoid)(attention) model Model(inputs, dense) model.compile(optimizerAdam(learning_rate1e-3), lossbinary_crossentropy, metrics[accuracy])逻辑说明return_sequencesTrue让每个时间步都输出隐藏状态不只在最后一步做汇总Attention层接收两组输入这里分别代表查询向量和键值向量用同一个LSTM输出作为两者即自注意力让模型自己决定哪个词对最终情感判断更重要。参数上不需要额外设置它会为每个时间步学习出一个权重。加入Attention后模型参数增加不多但表现力会提升唯一需要注意的是训练时间比不带Attention的版本长一些小数据集上仍然要保持dropout强度。6.2 用StratifiedKFold做5折交叉验证拒绝一次性划分的“运气成分”很多踩坑案例都出在train_test_split的一次划分上这次划分训练集里恰好没有难分样本测试集上分数虚高换一个random_state再跑分数又掉下来。为了避免对着一个“看起来不错”的结果自嗨我会用StratifiedKFold做5折交叉验证每折单独训练和评估最后取平均。from sklearn.model_selection import StratifiedKFold import numpy as np SK_FOLDS 5 skf StratifiedKFold(n_splitsSK_FOLDS, shuffleTrue, random_state42) fold_scores [] for fold, (train_idx, val_idx) in enumerate(skf.split(X_pad, y)): X_train_fold, X_val_fold X_pad[train_idx], X_pad[val_idx] y_train_fold, y_val_fold y[train_idx], y[val_idx] model build_lstm_model(vocab_sizeVOCAB_SIZE, max_lenMAX_LEN) model.fit( X_train_fold, y_train_fold, validation_data(X_val_fold, y_val_fold), epochs10, batch_size32, callbacks[EarlyStopping(monitorval_loss, patience2, restore_best_weightsTrue)], verbose0 ) acc model.evaluate(X_val_fold, y_val_fold, verbose0)[1] fold_scores.append(acc) print(fFold {fold1}: acc {acc:.4f}) print(f平均准确率: {np.mean(fold_scores):.4f} ± {np.std(fold_scores):.4f})交叉验证的价值在于你能看到模型在不同数据子集上的稳定性。如果5折的标准差超过0.02说明模型对数据划分非常敏感最可能的原因是样本量太小或数据分布不均匀这时候加再多的模型层数都没用回到数据收集和预处理才是有效的方向。代码里的build_lstm_model是一个封装函数我把Embedding、双向LSTM、Attention、Dense都封装在里面方便每折重建一个干净模型避免上一折的权重泄露到下一折。6.3 训练次数增多一定要做实验记录最后分享一个训练期间形成的习惯给每次实验起一个名字把数据版本、词表大小、MAX_LEN、units、dropout、学习率、batch_size、准确率、F1存成一条记录。酒店评论情感分析这个任务里我靠这个记录表格避开了三次“假进步”——看起来分数提高了0.03实际上是数据清洗版本换过了基线和对照不在同一水平线上。没有实验记录后面想复盘都不知道该信哪个结果。这个习惯比任何模型技巧都管用希望帮到你。本文还有配套的精品资源点击获取