简介:一份面向零基础学习者的中文情感分析实战资源包,聚焦影评数据的情感倾向判别,适合正在入门自然语言处理的学生、转行者或需要快速搭建分析原型的开发者,通过完整示例数据与可直接运行的代码降低了上手门槛。压缩包共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 必须指定
quoting=csv.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(r'https?://\S+|www\.\S+', ' ', text) text = re.sub(r'@\w+', ' ', text) return text.strip() df = pd.read_csv('reviews.csv', encoding='utf-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]', '', regex=True).str[:50] df = df.drop_duplicates(subset=['user_id', 'dedup_key'], keep='first')参数说明:[: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, axis=1) 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 条让两个人背对背标,算 Cohen's 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_size=0.2, random_state=42, stratify=df_labeled['label'] ) pipeline = Pipeline([ ('tfidf', TfidfVectorizer( max_features=20000, ngram_range=(1, 2), min_df=2, token_pattern=r'(?u)\b\w+\b', sublinear_tf=True )), ('clf', LogisticRegression(C=1.0, class_weight='balanced', max_iter=1000)) ]) pipeline.fit(X_train, y_train) print(f"baseline accuracy: {pipeline.score(X_test, y_test):.4f}")参数里值得解释的是这几个:sublinear_tf=True用 1+log(tf) 替代原始词频,压制「的」「了」这类高频虚词对权重的干扰;ngram_range=(1, 2)让模型看到「不好」这种二连词——单看「不」和「好」都容易判错,合在一起才是负向信号;min_df=2过滤只出现一次的罕见词,宁可漏掉片名也别让噪声进词表。class_weight='balanced'会根据类别频率自动放大少数类的损失权重,这一步在正负样本不完全均衡时,比手动调分类阈值更省事。
这里没有对影评分词,而是直接按词袋切。对逻辑回归来说,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_labels=2 ) def tokenize(batch): return tokenizer(batch['text'], truncation=True, max_length=128, padding='max_length') train_ds = Dataset.from_pandas(df_train[['clean_text', 'label']].rename(columns={'clean_text': 'text'})) train_ds = train_ds.map(tokenize, batched=True) args = TrainingArguments( output_dir='./output', learning_rate=2e-5, per_device_train_batch_size=32, num_train_epochs=3, weight_decay=0.01, logging_steps=50, save_strategy='epoch', ) trainer = Trainer(model=model, args=args, train_dataset=train_ds) trainer.train()超参数的设置依据整理成一张表,照着调基本不会出大问题:
| 参数 | 常见取值 | 调大/调小的影响 |
|---|---|---|
| learning_rate | 2e-5 ~ 3e-5 | 超过 5e-5 容易训飞,loss 直接变 NaN;低于 1e-5 收敛极慢 |
| max_length | 128 | 影评平均 30-60 字,128 足够;调到 256 会让训练和推理都变慢 |
| batch_size | 16 ~ 32 | 16G 显存跑 base 模型 32 没问题;太小会导致梯度更新震荡 |
| epochs | 2 ~ 3 | 影评任务过拟合通常出现在第 4 个 epoch 之后,盯验证 loss |
| weight_decay | 0.01 | 正则项,数据量小的时候调到 0.05 能压过拟合 |
提示:如果训练时 loss 不下降,先检查 label 是否从 0 开始编号。HuggingFace 默认类别标签是 0/1,如果你的业务值正好相反,loss 会在训练后期震荡,准确率卡在 50% 上下,这种问题看代码日志往往比看模型结构更快定位。
推理阶段另有一个坑:max_length=128是 padding 到的长度,不是截断长度。代码里同时写了truncation=True,超出部分才截断。如果部署阶段用 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_tensors='pt') 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_version=14, )dynamic_axes把 batch 维标记为动态,接口可以同时接收单条和批量请求,不用固定 batch 重新导出。opset_version=14是 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, truncation=True, max_length=seq_length, padding='max_length') input_ids = np.array([inputs['input_ids']], dtype=np.int64) attention_mask = np.array([inputs['attention_mask']], dtype=np.int64) token_type_ids = np.array([inputs.get('token_type_ids', [0]*seq_length)], dtype=np.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"}需要注意的参数在三个地方。padding='max_length'会把每条文本都补到 128,虽然浪费一点内存,但换来 batch 推理时不用动态 padding,ORT 的算子优化能吃满。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(maxsize=4096) def cached_predict(text: str): return predict(text)maxsize=4096对应约 40 万字符的文本缓存,对短评场景足够。这里有个容易踩的细节:lru_cache按字符串做 key,如果缓存 key 用的是原始文本,里面有用户 ID 或时间戳这类每次不同的噪声,命中率会急剧下降。所以缓存 key 必须用清洗之后的结果,和训练时的预处理保持完全一致——这也是为什么 2.2 里那个清洗函数值得单独抽出来复用的原因。
5. 影评情感分类的最后一公里:混淆矩阵与错误样本迭代
5.1 先看分类型 F1,而不是整体准确率
影评数据把正负样本做到 1:1 后,整体准确率会变得很好看,但它掩盖了一个事实:模型对负面短评的召回往往偏低,因为负面表达更依赖隐含语义。训练结束后,先把测试集结果导出,用classification_report看每一类的 precision、recall、F1:
from 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_WEIGHT=0.3表示模型输出占主导、词典兜底。这个参数不要超过 0.5,否则模型学到的上下文信息会被词典覆盖。融合后的分数再走一个双阈值:低于 0.45 判负面,高于 0.55 判正面,中间归入待人工审核队列。影评这种充满反讽和省略的模糊场景,留一个人工兜底队列,比让模型硬着头皮输出一个置信度只有 0.52 的结论要负责任得多。
本文还有配套的精品资源,点击获取