尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

中文影评情感分析实战:从数据清洗到模型部署全流程解析

中文影评情感分析实战:从数据清洗到模型部署全流程解析 简介一份面向零基础学习者的中文情感分析实战资源包聚焦影评数据的情感倾向判别适合正在入门自然语言处理的学生、转行者或需要快速搭建分析原型的开发者通过完整示例数据与可直接运行的代码降低了上手门槛。压缩包共14个文件、约3.57MB包含6张图示、3个Python脚本、3个文本说明、1份Markdown文档及配套的版本控制忽略配置脚本覆盖机器学习和深度学习两条建模路线另有数据预处理模块文本说明和文档提供了必要使用指引。目前已有50人学习下载按包内目录结构可完整走通从数据读取、文本清洗、特征构造到模型训练与评估的落地流程。这份资源最大的价值在于省去环境配置与数据整理时间读者可直接对照代码和图示理解情感分析核心步骤在此基础上二次开发或迁移到其他场景用于课程设计、毕业设计或小型业务验证都较为合适。1. 中文情感分析为什么不能照搬英文那套流程点进这个 zip 的人多半见过英文情感分析教程清洗、TF-IDF、分类器或拿 BERT 微调。换到中文影评数据第一步就卡住——中文没有空格分词「烂片」和「这片子真烂」是两种表达「牛」可能是夸也可能是嘲讽。中文情感分析要解决的事其实很具体给一段短文本判断正面还是负面。在影评、电商评论、客服工单里准确率和可解释性比模型是否高级更重要。这篇按「数据→基线→部署→迭代」的路径讲代码最小可运行参数为什么这么设、失败看什么日志都会交代。适合做课程设计的学生、要给业务加评论打标能力的后端工程师、刚接触 NLP 的数据分析岗。目标是拿到影评数据当天跑出基线一周内上线可调用的接口。2. 影评数据的获取与清洗先把语料变成能训练的样子2.1 公开语料和自采数据怎么搭配做中文情感分析第一步不是选模型而是确认手里的影评数据长什么样。公开渠道里最常见的有两类一类是整理好的中文情感分类基准语料ChnSentiCorp 这类由酒店、电商评论构成的集合另一类是豆瓣、猫眼等平台影评构成的爬取语料后者更贴近真实短评的口语风格。行业里的通用做法是公开语料用来快速验证 pipeline 是否走得通真正上线前再按目标平台补一批自采数据因为平台之间的语言习惯差异比想象中更大——猫眼短评里的「特效炸裂」和知乎长评里的「叙事结构失衡」根本不是一套词表。自采数据有几个点要先定下来合法性先看目标站点 robots.txt 和服务条款有公开接口优先走接口个人学习少量样本问题不大商用前务必确认授权来源。字段设计至少保留user_id、review_text、rating、create_time四项。rating是天然的弱标签平台星级 4 星以上算正面、2 星以下算负面3 星先不急着删后面当候选中性样本。存储格式CSV 或 Parquet 都可以别存 Excel。影评文本里常有换行和引号CSV 必须指定quotingcsv.QUOTE_ALL否则字段错位会让你后面所有代码都在排查脏数据。公开数据集里「好评多、差评少」是常态豆瓣短评尤其明显。做正负二分类前先按 5:5 做下采样这比任何模型技巧都更管用也会直接影响到 3.1 里class_weight参数怎么设。2.2 清洗规则去重、HTML、全角与 emoji拿到原始 JSON 后我一般先写一个通用清洗函数而不是边训练边补。影评噪声高度集中在那几类重复刷评、HTML 转义、全角字符、URL 和 提及。下面这个函数覆盖大部分情况import re import unicodedata import pandas as pd def clean_review(text: str) - str: if not isinstance(text, str): return # 去掉 HTML 标签和实体 text re.sub(r[^], , text) text re.sub(r[a-zA-Z];, , text) # 统一换行与空白 text text.replace(\u3000, ).replace(\r, \n) # NFKC 归一化全角字母数字转半角 text unicodedata.normalize(NFKC, text) # URL 和 提及在影评里通常没有情感信息 text re.sub(rhttps?://\S|www\.\S, , text) text re.sub(r\w, , text) return text.strip() df pd.read_csv(reviews.csv, encodingutf-8) df[clean_text] df[review_text].map(clean_review)逻辑说明NFKC归一化会把全角数字字母转成半角也能把部分兼容字符拆开方便后续分词和统计URL 和 置成空格而不是直接删除是为了避免相邻字符拼接成一个错误的词。注意这里不要着急去 emoji——影评里的笑哭表情、呕吐表情是明确的情感信号后面做 token 化时单独保留比删掉再靠模型猜含义要可靠得多。去重这步容易被漏掉。影评平台存在大量同一用户对同一部电影的重复短评以及营销号批量刷的相似文案。直接drop_duplicates(review_text)不够只有个别字不同的刷评会漏网# 按文本归一化后的前 50 个汉字做去重滤掉大部分复制粘贴的评论 df[dedup_key] df[clean_text].str.replace(r[^\u4e00-\u9fa5], , regexTrue).str[:50] df df.drop_duplicates(subset[user_id, dedup_key], keepfirst)参数说明[:50]控制去重粒度。取太长会把同一部电影下的正常重复表达也删掉取太短又容易误伤「这部电影真的很好看」这类高频短句。50 个汉字对短评来说刚好覆盖一句话的长度。数据量在百万级以上时先直接drop_duplicates再按dedup_key去重执行速度会快很多。2.3 没有标注团队时怎么定正负样本影评数据和电商评论最大的差异平台星级和文本情感并不总是一致。有人给三星是因为「特效可以但剧情拉胯」也有人给五星纯粹是粉丝控评。所以纯评分映射会产生标签噪声影响后面所有环节。我常用的做法是「评分 长度」双重过滤做弱标注评分 1-2 星且文本长度大于 10 个字的归负例4-5 星且长度大于 10 个字的归正例3 星单独留出来不参与训练等模型初版跑完再拿去做半监督候选。最小实现如下def weak_label(row): if row[rating] 2 and len(row[clean_text]) 10: return 0 if row[rating] 4 and len(row[clean_text]) 10: return 1 return None # 不确定样本先丢弃 df[label] df.apply(weak_label, axis1) df_labeled df.dropna(subset[label]).copy() df_labeled[label] df_labeled[label].astype(int) print(df_labeled[label].value_counts())长度阈值 10 的作用是滤掉「好看」「烂」这类极端短评。它们不是没有情感而是缺乏上下文作为训练样本会让模型学到「短 极端」的假规律。如果发现正负样本比超过 2:1对多出的那一类做随机下采样尽量让训练集接近 1:1。下表是三种标注策略的对比策略优点缺点适合阶段纯评分映射零成本标签噪声高3 星难处理第一版基线评分 长度过滤噪声明显下降丢失短评样本基线到微调之间人工抽检 规则修正准确率最高耗费人力上线前最后一步后续如果要引入人工标注不要全部重标。抽 500 条让两个人背对背标算 Cohens Kappa低于 0.6 说明标注标准本身有歧义得先统一标准再扩大标注量否则花再多钱标出来的数据都是模型的上限天花板。3. 从零训练中文情感分析模型基线、分词与预训练微调3.1 先用 TF-IDF 逻辑回归把基线钉死很多教程一上来就微调 BERT这是对计算资源的最大浪费。正确顺序是先用词袋模型跑通数据流得到一个准确率基线。后续任何复杂模型都必须能打过这个基线才有意义。对中文影评这种短文本TF-IDF 逻辑回归通常能到 85% 左右BERT 系模型能到 90-93%差距绝对值不大但训练和推理成本差了一个数量级。先跑基线还有一个作用验证数据清洗和标注逻辑是否正确如果基线的准确率低于 80%大概率问题出在数据而不是模型。from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.linear_model import LogisticRegression from sklearn.pipeline import Pipeline from sklearn.model_selection import train_test_split X_train, X_test, y_train, y_test train_test_split( df_labeled[clean_text], df_labeled[label], test_size0.2, random_state42, stratifydf_labeled[label] ) pipeline Pipeline([ (tfidf, TfidfVectorizer( max_features20000, ngram_range(1, 2), min_df2, token_patternr(?u)\b\w\b, sublinear_tfTrue )), (clf, LogisticRegression(C1.0, class_weightbalanced, max_iter1000)) ]) pipeline.fit(X_train, y_train) print(fbaseline accuracy: {pipeline.score(X_test, y_test):.4f})参数里值得解释的是这几个sublinear_tfTrue用 1log(tf) 替代原始词频压制「的」「了」这类高频虚词对权重的干扰ngram_range(1, 2)让模型看到「不好」这种二连词——单看「不」和「好」都容易判错合在一起才是负向信号min_df2过滤只出现一次的罕见词宁可漏掉片名也别让噪声进词表。class_weightbalanced会根据类别频率自动放大少数类的损失权重这一步在正负样本不完全均衡时比手动调分类阈值更省事。这里没有对影评分词而是直接按词袋切。对逻辑回归来说jieba 分词后再做 TF-IDF 通常比字级别高 1-2 个百分点但代价是分词错误会直接传导给模型。影评里有大量片名、演员名加载自定义词典后分词收益才明显这正好引出下一节。3.2 中文分词到底做不做影评场景的取舍把分词单独拿出来讲是因为它是中文情感分析和英文流程最大的分岔路。英文有天然空格中文没有。jieba 按词典和隐马尔可夫模型切分「这部电影的结局太仓促了」会被切成「这部电影/的/结局/太/仓促/了」大概率没问题但遇到网络新词「yyds」「下头」就切不开变成单字流对情感判断没有帮助。我的取舍规则分场景基线模型TF-IDF / 朴素贝叶斯做分词同时加载用户词典新词用jieba.add_word加进去预训练模型BERT 系不分词直接按字输入。BERT 的 tokenizer 自带按字切分能力额外分词反而破坏词的上下文信息。import jieba # 影评领域词典把高频复合词直接加固 for word in [特效炸裂, 演技在线, 剧情拖沓, 烂尾, 催泪]: jieba.add_word(word) text 这部片的特效炸裂但结局烂尾了 print(/.join(jieba.lcut(text))) # 输出: 这部片/的/特效炸裂//但/结局/烂尾/了jieba.add_word的本质是把自定义词以最高优先级插入前缀词典所以业务词表要按「先长后短」的顺序加载否则「特效炸裂」会被先匹配成「特效」「炸裂」。这套词表对短评还有一个额外好处切出来的复合词可以直接作为可解释性分析的证据。业务方问「为什么判为负面」时你能把「烂尾」这个词摆到桌面上而不是丢给他一串无法解释的向量。3.3 预训练模型微调选型与关键超参数当影评数据量超过 1 万条换到预训练模型的收益就开始超过调参。中文场景里最常作为底座的模型有三个bert-base-chinese通用、兼容性最好、hfl/chinese-roberta-wwm-ext全词掩码短语语义建模更好、uer/roberta-mini速度快适合 CPU 推理。影评短文本我一般选hfl/chinese-roberta-wwm-ext全词掩码对「特效炸裂」这类连续短语的语义建模更完整评测里通常比原版 BERT 高 1-2 个点。微调用 HuggingFace Trainer 是最省事的写法from transformers import AutoTokenizer, AutoModelForSequenceClassification, Trainer, TrainingArguments from datasets import Dataset tokenizer AutoTokenizer.from_pretrained(hfl/chinese-roberta-wwm-ext) model AutoModelForSequenceClassification.from_pretrained( hfl/chinese-roberta-wwm-ext, num_labels2 ) def tokenize(batch): return tokenizer(batch[text], truncationTrue, max_length128, paddingmax_length) train_ds Dataset.from_pandas(df_train[[clean_text, label]].rename(columns{clean_text: text})) train_ds train_ds.map(tokenize, batchedTrue) args TrainingArguments( output_dir./output, learning_rate2e-5, per_device_train_batch_size32, num_train_epochs3, weight_decay0.01, logging_steps50, save_strategyepoch, ) trainer Trainer(modelmodel, argsargs, train_datasettrain_ds) trainer.train()超参数的设置依据整理成一张表照着调基本不会出大问题参数常见取值调大/调小的影响learning_rate2e-5 3e-5超过 5e-5 容易训飞loss 直接变 NaN低于 1e-5 收敛极慢max_length128影评平均 30-60 字128 足够调到 256 会让训练和推理都变慢batch_size16 3216G 显存跑 base 模型 32 没问题太小会导致梯度更新震荡epochs2 3影评任务过拟合通常出现在第 4 个 epoch 之后盯验证 lossweight_decay0.01正则项数据量小的时候调到 0.05 能压过拟合提示如果训练时 loss 不下降先检查 label 是否从 0 开始编号。HuggingFace 默认类别标签是 0/1如果你的业务值正好相反loss 会在训练后期震荡准确率卡在 50% 上下这种问题看代码日志往往比看模型结构更快定位。推理阶段另有一个坑max_length128是 padding 到的长度不是截断长度。代码里同时写了truncationTrue超出部分才截断。如果部署阶段用 CPU 推理可以换成uer/roberta-mini或做 ONNX 量化速度能提升 3-5 倍准确率只掉 1-2 个点对线上场景是划算的取舍。4. 把中文情感分析模型封装成落地服务FastAPI 接口与性能优化4.1 模型导出ONNX 的性价比分析notebook 里跑通的模型只是半成品落地意味着要把它变成能被业务方调用的接口。最省事的方式是直接把模型权重和分词器打进镜像用pipeline加载。问题有两个一是transformers在 CPU 上的初始化要几秒二是每次请求都过一遍 Python 动态图吞吐上不去压测时第一个请求的延迟经常是后面的 10 倍。把模型转成 ONNX、交给onnxruntime推理是我常用的做法。转换本身不复杂from transformers import AutoModelForSequenceClassification, AutoTokenizer import torch model AutoModelForSequenceClassification.from_pretrained(./output/checkpoint-1000) tokenizer AutoTokenizer.from_pretrained(hfl/chinese-roberta-wwm-ext) model.eval() dummy_input tokenizer(这是一条测试影评, return_tensorspt) torch.onnx.export( model, (dummy_input[input_ids], dummy_input[attention_mask], dummy_input.get(token_type_ids)), sentiment_model.onnx, input_names[input_ids, attention_mask, token_type_ids], output_names[logits], dynamic_axes{input_ids: {0: batch}, attention_mask: {0: batch}}, opset_version14, )dynamic_axes把 batch 维标记为动态接口可以同时接收单条和批量请求不用固定 batch 重新导出。opset_version14是 onnxruntime 支持很稳的版本太新的算子在某些 CPU 上反而有兼容性问题。注意dummy_input.get(token_type_ids)要传进去中文 BERT 系模型在导出时缺少这个输入推理时会报输入个数不匹配。4.2 FastAPI 推理接口的最小实现与参数说明服务框架选型上FastAPI 已经是事实标准自带请求校验和 OpenAPI 文档异步支持对短文本推理这种高并发低计算量的任务很合适。下面是一个可直接启动的最小服务from fastapi import FastAPI from pydantic import BaseModel from transformers import AutoTokenizer import onnxruntime as ort import numpy as np app FastAPI(title中文影评情感分析服务) tokenizer AutoTokenizer.from_pretrained(hfl/chinese-roberta-wwm-ext) class ReviewRequest(BaseModel): text: str seq_length 128 # 与训练时的 max_length 保持一致 session ort.InferenceSession( sentiment_model.onnx, providers[CPUExecutionProvider], ) def predict(text: str) - dict: inputs tokenizer(text, truncationTrue, max_lengthseq_length, paddingmax_length) input_ids np.array([inputs[input_ids]], dtypenp.int64) attention_mask np.array([inputs[attention_mask]], dtypenp.int64) token_type_ids np.array([inputs.get(token_type_ids, [0]*seq_length)], dtypenp.int64) logits session.run(None, { input_ids: input_ids, attention_mask: attention_mask, token_type_ids: token_type_ids, })[0] prob 1 / (1 np.exp(-logits[0][1])) # sigmoid 转概率 label 1 if prob 0.5 else 0 return {label: label, positive_prob: round(float(prob), 4)} app.post(/predict) async def predict_endpoint(req: ReviewRequest): return predict(req.text) app.get(/health) async def health(): return {status: ok}需要注意的参数在三个地方。paddingmax_length会把每条文本都补到 128虽然浪费一点内存但换来 batch 推理时不用动态 paddingORT 的算子优化能吃满。positive_prob用 sigmoid 而不是 softmax二分类时两者等价sigmoid 得到的分数可以直接对接业务阈值调整——你没必要为了 0.5 还是 0.6 的阈值去改模型重训。/health健康检查接口在容器编排里是必须的K8s 的 ReadinessProbe 只有收到 200 才把流量放进来没有这个接口滚动发布时会有一段时间请求打到正在启动的 Pod 上。4.3 并发、缓存与降级策略上线前有四个点建议一起处理按优先级排预热服务启动后先跑一次predict(预热)让 ONNX Runtime 的线程池和算子 kernel 完成初始化否则线上第一个请求会慢得离谱。结果缓存影评短评的重复率比想象中高同一部电影上映期大量短评是相似文案。用functools.lru_cache按清洗后的文本哈希缓存结果命中率能到 15% 左右QPS 压力直接降一截。超时控制上游调用方不可控时网关给/predict设置 500ms 超时超过就返回默认标签通常保守判中性不要让调用方无限等下去。长文本保护在服务入口判断len(text) 512直接返回 400而不是等 tokenizer 截断后再推理省掉无谓的计算。from functools import lru_cache lru_cache(maxsize4096) def cached_predict(text: str): return predict(text)maxsize4096对应约 40 万字符的文本缓存对短评场景足够。这里有个容易踩的细节lru_cache按字符串做 key如果缓存 key 用的是原始文本里面有用户 ID 或时间戳这类每次不同的噪声命中率会急剧下降。所以缓存 key 必须用清洗之后的结果和训练时的预处理保持完全一致——这也是为什么 2.2 里那个清洗函数值得单独抽出来复用的原因。5. 影评情感分类的最后一公里混淆矩阵与错误样本迭代5.1 先看分类型 F1而不是整体准确率影评数据把正负样本做到 1:1 后整体准确率会变得很好看但它掩盖了一个事实模型对负面短评的召回往往偏低因为负面表达更依赖隐含语义。训练结束后先把测试集结果导出用classification_report看每一类的 precision、recall、F1from sklearn.metrics import classification_report y_pred pipeline.predict(X_test) print(classification_report(y_test, y_pred, target_names[负面, 正面]))如果负面类的 recall 比正面低 5 个百分点以上说明模型把不少负面样本判成了正面。这时不急着换模型先把错误样本翻出来逐条看大概率会发现是下面三类问题。5.2 影评场景里最容易翻车的三类文本把错误样本拉出来你会发现影评误判高度集中在三类。第一类是转折结构「虽然剧本一般但演员演技真的救了这部电影」——模型在「演技真的」处输出高概率正面忽略了前面的让步。处理这类问题常见做法是训练一个转折检测器或者在输入前用规则把「虽然…但是…」切成两个分句分别编码再加权实测能把这类误判降低三成。第二类是反讽「烂到这种程度也是一种才华」「真是谢谢导演了」。反讽目前是公认的 NLP 难点不建议投入过多成本规则层面把「这也是一种」「真是谢谢了」这类固定句式加入情感词典即可能兜住一部分典型表达。第三类是短评省略「绝了」「离谱」「就这」。这类词脱离上下文几乎无法判断极性只能靠附带星级信息辅助。如果接口允许传入评分把评分拼到文本前面再送进模型比单独加一个特征向量更简单有效。5.3 一个实用的融合技巧词典分数兜底模型输出最后分享一个我常用的兜底手段。预训练模型在小样本或极端输入上偶尔会有异常输出但情感词典的分数总是稳定。把两者按权重融合能压低模型在冷门表达上的方差DICT_WEIGHT 0.3 # 词典分数权重业务噪音大时可以调低 def fused_score(model_prob: float, dict_score: float) - float: return DICT_WEIGHT * dict_score (1 - DICT_WEIGHT) * model_probdict_score用现成的中文情感词典如大连理工大学情感词汇本体库对文本逐词打分归一化到 0-1。DICT_WEIGHT0.3表示模型输出占主导、词典兜底。这个参数不要超过 0.5否则模型学到的上下文信息会被词典覆盖。融合后的分数再走一个双阈值低于 0.45 判负面高于 0.55 判正面中间归入待人工审核队列。影评这种充满反讽和省略的模糊场景留一个人工兜底队列比让模型硬着头皮输出一个置信度只有 0.52 的结论要负责任得多。本文还有配套的精品资源点击获取
返回列表