做内容相关需求时,经常被问到一个问题:手里这批文本的情绪到底是偏正还是偏负,能不能用数据说话。人工看几十条还行,几千条根本不现实,而且不同人标准不统一,结论经常互相冲突。本文就从这个真实场景出发,用 Python 完整走一遍中文文本情感分析的全流程,包括环境搭建、文本清洗、分词、关键词抽取、情感打分、可视化输出和批量导出。语料选择上一段情感类的短文本作为练手样本,因为这类文字情绪起伏明显、转折多,正好用来暴露情感分析的各种边界问题。不管你是刚接触 NLP 的学生,还是要在业务里做一版舆情统计的后端开发,跟着做下来都能直接复用到自己的项目里。
1. 从一段文字到一份数据:情感分析到底在解决什么问题
1.1 通俗理解与专业定义
先抛开术语说人话:情感分析就是让程序读完一段文字后,给出一个判断——这段话是正面的、负面的,还是中性的,甚至可以再细一层,是"遗憾"还是"释然"。人做这件事靠语感和生活经验,机器做这件事靠词表、规则或者训练出来的模型。
专业一点的描述是:文本情感分析(Sentiment Analysis)属于自然语言处理的一个子任务,目标是从非结构化文本中抽取主观性信息,并对其极性(Positive / Negative / Neutral)或强度进行量化。在工程实现上,它通常被拆成三个子问题:情感对象识别(说的是谁)、极性判断(好坏)、强度计算(有多好或多坏)。本文的实战部分主要聚焦后两个,即极性与强度,因为这两块最容易落地,也最容易验证效果。
需要提前说明的是,情感分析不是"理解"文本,它只是在做统计和模式匹配。所以它一定会出错,尤其是遇到反讽、双关、长距离转折的时候。把这一点想清楚,后面看到结果"不太准"时才不会怀疑代码写错了。
1.2 为什么中文处理比英文麻烦
很多人以为情感分析就是查个词典的事,英文确实相对简单,空格天然分词,一个词一个极性,累加就行。中文不行,至少有四个地方会让人踩坑。
第一是分词。中文句子没有空格,"江大"可能被切成"江"和"大","那年"可能被切成"那"和"年"。切错了后面全是错的,所以中文情感分析的第一步永远是分词质量。
第二是否定。英文有 not、never 这种显式标记,中文的"不""没""别""从未"位置灵活,还能连用,"不是不开心"这种双重否定人一眼就懂,程序很容易算成正。
第三是程度副词和转折词。"有点难过"和"非常难过"极性一样但强度差好几倍;"但是""却""只不过"这类转折词一旦出现,前半句的情绪权重就该被压低,后面那句才是作者真正想说的。
第四是网络用语和隐喻。这部分更新极快,词典永远追不上,只能靠模型或者人工维护词表补充。理解了这四点,你就明白为什么后面要写三套打分逻辑,而不是一个函数搞定。
1.3 典型应用场景与本文的练手语料
情感分析在工程里的落点其实很集中,常见的有这么几类:商品评论的情感分布统计、客服工单的情绪预警、社交媒体话题的正负倾向监控、内容平台的文章情绪标签、以及用户反馈的自动归类。这些场景的共同点是对精度的容忍度不一样——舆情监控更看重趋势而不是单条准确率,而工单预警则宁可多报也不能漏报,所以阈值设置会完全不同。
本文选用的练手语料是一段情感类短文,主题围绕年少时的约定和后来的遗憾展开,篇幅不长但情绪有起有伏,正好覆盖"正向词与负向词混杂""转折句""短句为主"这几种典型情况。为了避免版权问题,示例文本为自拟文本,只保留句式特征,不引用任何已有作品的原文。你在自己项目里替换成真实业务语料即可,代码不需要改动。
2. 环境准备与版本说明
2.1 运行环境与依赖清单
本文示例在 Python 3.9 及以上版本验证通过,3.8 也基本可用,但低于 3.8 的版本在部分依赖上会出现兼容问题,建议直接升到 3.9 或 3.10。操作系统 Windows、macOS、常见 Linux 发行版都行,唯一的差别在字体路径,后面第 5 节会专门讲这个问题。
需要用到的第三方库有四个:jieba 负责分词与关键词抽取,snownlp 提供开箱即用的情感打分,wordcloud 生成词云,matplotlib 负责柱状图输出。这四个库都很成熟,安装简单,但要注意 jieba 首次导入时会构建前缀词典,会有几秒钟的延迟,属于正常现象,不是卡死。
版本号方面,本文不锁定具体小版本,因为这几个库的公开 API 多年稳定,用pip install装最新版即可。如果你的项目对环境有强约束,建议在requirements.txt里写范围而不是写死,例如jieba>=0.42,这样既能升级修复,也不会因为某个小版本被钉死而无法更新。
2.2 一行命令装好依赖
推荐先建虚拟环境,避免污染全局解释器:
# 创建并激活虚拟环境(Windows / macOS / Linux 通用) python -m venv venv # Windows venv\Scripts\activate # macOS / Linux source venv/bin/activate # 安装依赖 pip install jieba snownlp wordcloud matplotlib安装完成后可以用一条命令验证是否都能正常导入:
python -c "import jieba, snownlp, wordcloud, matplotlib; print('ok')"如果这条命令输出ok,环境就没问题了。如果报ModuleNotFoundError,先确认当前终端激活的是不是刚才那个虚拟环境,这是最常见的原因,pip list一看就知道装到哪里去了。
2.3 项目目录结构
为了让代码分层清晰、方便后续扩展,我们按"数据 - 逻辑 - 输出"三层来组织目录:
text_sentiment/ ├── data/ │ ├── raw/ │ │ └── sample.txt # 原始语料,一行一段 │ └── stopwords.txt # 停用词表 ├── output/ # 运行产物:词云、图表、CSV ├── prepare_corpus.py # 读取与清洗 ├── analyze_words.py # 分词、词频、关键词 ├── sentiment.py # 三种情感打分实现 ├── visualize.py # 词云与柱状图 └── main.py # 串起全流程的入口这个结构的好处是每个文件职责单一,sentiment.py里只有打分逻辑,想换模型只改这一个文件,上层不用动。数据目录单独放,是因为语料和停用词属于配置类资源,不应该混在代码里,后面做多语料对比时会方便很多。
3. 三种技术路线:从词典到分类器
3.1 路线一:情感词典加规则打分
这是最古老也最容易解释的方案。核心思路是准备两个词表,一个正向词、一个负向词,每个词给一个基础分,然后把句子分词后逐个查表累加。它的优点是结果完全可追溯——为什么这句是负分,能精确到是哪个词贡献的;缺点也很明显,覆盖率完全取决于词表大小,遇到没收录的词就直接归零。
为了让结果更贴近真实语义,我们在这条路线上加两个修正:一是否定词翻转让"不喜欢"变成负分,二是程度副词加权让"非常难过"比"难过"分更低。这两条规则实现起来只有十几行代码,但能显著改善短文本的打分效果,性价比很高。
需要注意这条路线只适合做基线,不要指望它能处理反讽。像"他往前走了,真好"这种句子,词典法会看到"好"给正分,但人读起来是苦的。所以词典法更适合用在有明确情感词的业务场景,比如商品评论里的"好用""差评"。
3.2 路线二:开箱即用的情感分类器
如果你不想维护词表,snownlp 是个省事的选择。它内部带了一个基于电商评论语料训练的分类模型,调用方式极简:SnowNLP(text).sentiments返回一个 0 到 1 之间的浮点数,越接近 1 越正面,越接近 0 越负面,0.5 附近表示中性。
它的优势是零配置、上手快,缺点同样要说清楚:一是训练语料偏电商评论,迁移到文学、新闻、聊天记录上效果会打折;二是输出只是一个分数,没有解释性,你不知道它为什么这么判;三是对于特别短的句子(三五个字)稳定性较差。
所以在工程里更推荐的做法是组合使用:用 snownlp 做主力打分,用词典法做辅助校验,两者差异过大时把这条文本标记出来人工复核。这个策略在真实项目里比单纯追求模型精度更实用。
3.3 路线三:分句加权聚合
第三种不是独立算法,而是一种聚合策略。一篇几百字的短文,如果整段丢进去打分,里面正向句和负向句会互相抵消,最后得到一个毫无信息量的 0.5。正确做法是先按句号、问号、感叹号、分号、换行切句,逐句打分,再按句子长度加权平均。
为什么按长度加权?因为长句通常承载更完整的意思,短句可能是语气词或者过渡句,权重理应更低。这个策略实现简单,但对整体情绪判断的提升非常明显,尤其在转折较多的文本上。
下面这张表对比了三条路线的适用边界,你可以据此决定自己的项目用哪条:
| 路线 | 实现成本 | 可解释性 | 适用场景 | 主要短板 |
|---|---|---|---|---|
| 词典加规则 | 中,需维护词表 | 高,可定位到词 | 情感词明确的评论、工单 | 覆盖率依赖词表,难处理反讽 |
| 预训练分类器 | 低,几行代码 | 低,只给分数 | 快速验证、批量跑数据 | 领域迁移效果下降 |
| 分句加权聚合 | 低,属于策略层 | 中,可看单句得分 | 长文本、情绪起伏大的文章 | 依赖分句质量 |
4. 完整实战:给一段情感短文做全流程分析
4.1 准备语料与文本清洗
先准备示例语料。在data/raw/sample.txt里写一段自拟文本,一行一段,情绪上有起有伏:
十七岁那年我们说好要去同一所大学,后来他真的去了,我留在了原地。 消息一条条变少,从每天到每周,再到某个节日才有的一句问候。 我没有难过,只是有点不习惯,习惯这种东西最不讲道理。 他往前走了,走得很稳,我在原地学会了笑着说没关系。 有些约定像烟火,亮过就够了。然后是清洗代码。真实业务里的文本往往带 BOM、零宽字符、多余空格、重复换行,直接分词会产生大量噪声词,所以清洗这一步不能省:
# 文件路径:text_sentiment/prepare_corpus.py # -*- coding: utf-8 -*- """语料准备:读取原始文本并做基础清洗""" import re from pathlib import Path RAW_DIR = Path(__file__).parent / "data" / "raw" def load_text(file_name: str) -> str: """读取 UTF-8 文本,注意必须显式指定 encoding""" path = RAW_DIR / file_name if not path.exists(): raise FileNotFoundError(f"语料文件不存在:{path}") # Windows 默认编码是 GBK,读 UTF-8 文件会抛 UnicodeDecodeError with open(path, "r", encoding="utf-8") as f: return f.read() def clean_text(text: str) -> str: """去掉不可见字符,压缩多余空白,保留段落结构""" text = text.replace("\ufeff", "").replace("\u200b", "") text = re.sub(r"[ \t\u3000]+", " ", text) text = re.sub(r"\n{2,}", "\n", text) return text.strip() def split_sentences(text: str) -> list: """按中文常见句末标点切句,同时兼容换行""" parts = re.split(r"[。!?!?;;\n]+", text) return [p.strip() for p in parts if p.strip()] if __name__ == "__main__": raw = load_text("sample.txt") cleaned = clean_text(raw) sentences = split_sentences(cleaned) print("清洗后字符数:", len(cleaned)) print("切分出句子数:", len(sentences)) for i, s in enumerate(sentences, 1): print(f"{i}. {s}")这段代码里有两个细节值得说明。一是encoding="utf-8"必须显式写,很多人本地跑得好好的,一部署到 Windows 服务器就报UnicodeDecodeError,根源就是默认编码不一致。二是分句用的正则把换行也当成句末符,这样短句成行的文本不会因为缺少标点而被漏切。
4.2 分词、停用词过滤与词频统计
洗完之后进分词。jieba 有三种模式,我们日常用默认的精确模式就够了。分词之后必须过滤停用词,否则"的""了""是"这些高频虚词会占据词频榜首,让统计结果毫无意义。
停用词表放在data/stopwords.txt,一行一个词:
的 了 是 在 和 也 就 都 而 及 与 着 或 一个 我们 你们 他们 自己 这个 那个 什么 怎么这里有个坑要提前说:不要往停用词表里放"不""没""没有""别"。这些词在语法上是虚词,但在情感分析里是否定标记,一旦被过滤掉,"不喜欢"就会被当成"喜欢",极性直接反了。我在早期项目里踩过这个坑,排查了半天才发现问题在停用词表上。
分词与统计的代码如下:
# 文件路径:text_sentiment/analyze_words.py # -*- coding: utf-8 -*- """分词、词频统计与关键词抽取""" from collections import Counter from pathlib import Path import jieba import jieba.analyse STOPWORDS_PATH = Path(__file__).parent / "data" / "stopwords.txt" # 与情感计算相关的否定词和程度词,绝对不能被当停用词过滤掉 KEEP_WORDS = {"不", "没", "没有", "别", "未", "从未", "不再"} def load_stopwords() -> set: if not STOPWORDS_PATH.exists(): return set() with open(STOPWORDS_PATH, "r", encoding="utf-8") as f: words = {line.strip() for line in f if line.strip()} # 兜底保护:即使词表里误写了,也从停用词中剔除 return words - KEEP_WORDS def word_freq(text: str, top_n: int = 15) -> list: """返回 [(词, 次数), ...],已过滤停用词、单字和纯数字""" stopwords = load_stopwords() words = [ w for w in jieba.lcut(text) if len(w) > 1 and w not in stopwords and not w.isdigit() ] return Counter(words).most_common(top_n) def keywords(text: str, top_k: int = 10) -> dict: """同时给出 TF-IDF 与 TextRank 两种关键词结果,便于交叉验证""" tfidf = jieba.analyse.extract_tags(text, topK=top_k, withWeight=True) textrank = jieba.analyse.textrank(text, topK=top_k, withWeight=True) return { "tfidf": [{"word": w, "weight": round(s, 4)} for w, s in tfidf], "textrank": [{"word": w, "weight": round(s, 4)} for w, s in textrank], }这里为什么同时算 TF-IDF 和 TextRank?因为两者侧重不同。TF-IDF 偏向"这个词在本篇里出现多、在其他文章里出现少",适合找主题词;TextRank 基于词共现图,偏向"这个词和其它重要词经常一起出现",适合找核心概念。两者结果高度重合的词,基本可以确定是文章关键词;只在其中一个里出现的,多半是长尾噪声,人工扫一眼就能判断。
len(w) > 1这个条件是过滤单字。中文单字信息量太低,"走""留""说"单独看没有意义,过滤掉能明显提升词频表的可读性。
4.3 三套情感打分逻辑的实现
接下来是核心部分。sentiment.py里放三个函数,分别对应第 3 节的三种路线:
# 文件路径:text_sentiment/sentiment.py # -*- coding: utf-8 -*- """三种情感打分实现:词典规则、分类器、分句加权""" import jieba from snownlp import SnowNLP from prepare_corpus import split_sentences # 简化版情感词典,实际项目中应换成完整词表并外置为配置文件 POS_WORDS = {"喜欢", "开心", "温暖", "勇敢", "坚定", "温柔", "期待", "释然", "好"} NEG_WORDS = {"遗憾", "难过", "离开", "消失", "冷", "沉默", "错过", "痛", "永别", "不习惯"} NEGATIONS = {"不", "没", "没有", "别", "未", "从未", "不再"} DEGREE_WORDS = {"很": 1.5, "非常": 1.8, "特别": 1.6, "有点": 0.7, "稍微": 0.6} def dict_score(text: str) -> float: """词典法打分:正向 +1,负向 -1,叠加否定与程度修正""" tokens = [t for t in jieba.lcut(text) if t.strip()] score = 0.0 negation = 1.0 degree = 1.0 for tok in tokens: if tok in NEGATIONS: negation *= -1.0 continue if tok in DEGREE_WORDS: degree = DEGREE_WORDS[tok] continue base = 0.0 if tok in POS_WORDS: base = 1.0 elif tok in NEG_WORDS: base = -1.0 score += base * negation * degree # 极性词被消费后,修饰语必须重置,否则会污染后续无关词 if base != 0.0: negation, degree = 1.0, 1.0 return round(score, 4) def model_score(text: str) -> float: """分类器打分,返回 0~1,越接近 1 越正面""" if not text or not text.strip(): # 空串直接交给 SnowNLP 可能抛异常,这里先兜住 return 0.5 return round(SnowNLP(text).sentiments, 4) def weighted_score(text: str) -> dict: """分句打分后按句长加权平均,同时返回逐句明细""" sentences = split_sentences(text) if not sentences: return {"score": 0.5, "detail": []} total, total_weight, detail = 0.0, 0, [] for s in sentences: s_score = model_score(s) weight = len(s) detail.append({"sentence": s, "score": s_score, "weight": weight}) total += s_score * weight total_weight += weight return {"score": round(total / total_weight, 4), "detail": detail} def label_of(score: float, pos_line: float = 0.6, neg_line: float = 0.4) -> str: """把 0~1 的分数映射为可读标签""" if score >= pos_line: return "正面" if score <= neg_line: return "负面" return "中性"关于dict_score里的重置逻辑,值得单独展开一句。如果不重置,一句"我不难过,今天天气很好"会出现什么情况?"不"翻转成 -1,命中"难过"得 +1(负负得正),然后这个 -1 会一直挂着,后面"很好"被算成负分。虽然那种情况下结果碰巧还是正的,但逻辑是错的,句子一长必然出问题。这个 bug 非常隐蔽,因为短句测试时结果往往"看起来对"。
至于阈值 0.6 和 0.4,这是经验值,不是标准答案。业务上如果更怕漏报负面情绪(比如客服预警),就把neg_line提到 0.45;如果只关心极端情绪,就把它压到 0.3。阈值一定要用你自己的标注数据调,直接抄别人的数字大概率不合适。
4.4 词云与柱状图的可视化输出
可视化这块最大的坑是中文字体。wordcloud 和 matplotlib 默认字体不含中文字形,不加字体路径的话生成出来的图全是方框。所以要先做一个字体探测函数,按操作系统去常见路径找:
# 文件路径:text_sentiment/visualize.py # -*- coding: utf-8 -*- """词云与柱状图输出""" import platform from pathlib import Path import matplotlib matplotlib.use("Agg") # 无图形界面环境必须,否则 pyplot 可能卡住不动 import matplotlib.pyplot as plt from wordcloud import WordCloud BASE = Path(__file__).parent OUT_DIR = BASE / "output" OUT_DIR.mkdir(exist_ok=True) def pick_font() -> str: """按操作系统探测可用的中文字体路径""" system = platform.system() candidates = { "Windows": [ "C:/Windows/Fonts/msyh.ttc", "C:/Windows/Fonts/simhei.ttf", ], "Darwin": [ "/System/Library/Fonts/PingFang.ttc", "/Library/Fonts/Arial Unicode.ttf", ], "Linux": [ "/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc", "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", ], }.get(system, []) for p in candidates: if Path(p).exists(): return p raise FileNotFoundError( "未找到可用中文字体,请手动指定 font_path,或先安装中文字体包" ) def make_wordcloud(freq: dict, out_name: str = "wordcloud.png") -> Path: """freq 形如 {'遗憾': 3, '约定': 2},generate_from_frequencies 不接受词表外的权重""" if not freq: raise ValueError("词频为空,无法生成词云") wc = WordCloud( font_path=pick_font(), width=1200, height=800, background_color="white", max_words=150, ).generate_from_frequencies(freq) path = OUT_DIR / out_name wc.to_file(str(path)) return path def make_bar(pairs: list, out_name: str = "top_words.png", title: str = "词频 Top15") -> Path: """pairs 形如 [('遗憾', 3), ...],取自 Counter.most_common""" plt.rcParams["font.sans-serif"] = [ "Microsoft YaHei", "SimHei", "PingFang SC", "WenQuanYi Zen Hei" ] plt.rcParams["axes.unicode_minus"] = False # 修复负号显示为方块 labels = [p[0] for p in pairs][::-1] values = [p[1] for p in pairs][::-1] fig, ax = plt.subplots(figsize=(8, 5)) ax.barh(labels, values, color="#4C8BF5") ax.set_title(title) ax.set_xlabel("出现次数") fig.tight_layout() path = OUT_DIR / out_name fig.savefig(path, dpi=150) plt.close(fig) return pathmatplotlib.use("Agg")这行特别重要。如果你的代码最终跑在 Linux 服务器、Docker 容器或者 CI 流水线里,没有图形界面,用默认后端可能导致plt.show()卡死或者报no display name。提前切成 Agg 后端,只保存文件不弹窗,能省掉一整类线上问题。
axes.unicode_minus = False也是常见设置,虽然本场景是横向柱状图没有负数,但一旦你后面改成对比正负分的柱状图,不加这行负号会显示成方块。
4.5 批量分析并导出结果表
单个文本跑通了,就该考虑批量。真实业务通常是一批文本一起分析,结果要能落到文件里给下游用。main.py负责把所有环节串起来:
# 文件路径:text_sentiment/main.py # -*- coding: utf-8 -*- """串联全流程:清洗 -> 分词 -> 关键词 -> 情感 -> 可视化 -> 导出""" import csv import json from pathlib import Path import jieba from prepare_corpus import load_text, clean_text, split_sentences from analyze_words import word_freq, keywords from sentiment import dict_score, model_score, weighted_score, label_of from visualize import make_wordcloud, make_bar BASE = Path(__file__).parent OUT_DIR = BASE / "output" OUT_DIR.mkdir(exist_ok=True) def analyze_one(name: str, text: str) -> dict: """对单篇文本做完整分析,返回结构化结果""" cleaned = clean_text(text) freq_pairs = word_freq(cleaned, top_n=15) freq_dict = dict(freq_pairs) weighted = weighted_score(cleaned) result = { "name": name, "char_count": len(cleaned), "sentence_count": len(split_sentences(cleaned)), "score_dict": dict_score(cleaned), "score_model": model_score(cleaned), "score_weighted": weighted["score"], "label": label_of(weighted["score"]), "top_words": freq_pairs, "keywords": keywords(cleaned, top_k=8), "sentence_detail": weighted["detail"], } return result, freq_dict def export_csv(rows: list, path: Path) -> None: """导出汇总表,用 utf-8-sig 保证 Excel 打开不乱码""" fields = ["name", "char_count", "sentence_count", "score_dict", "score_model", "score_weighted", "label"] with open(path, "w", encoding="utf-8-sig", newline="") as f: writer = csv.DictWriter(f, fieldnames=fields) writer.writeheader() for r in rows: writer.writerow({k: r[k] for k in fields}) def main() -> None: jieba.setLogLevel(60) # 关掉 jieba 的加载日志,输出更干净 raw = load_text("sample.txt") result, freq_dict = analyze_one("sample", raw) # 1. 结构化结果落盘 json_path = OUT_DIR / "result.json" with open(json_path, "w", encoding="utf-8") as f: json.dump(result, f, ensure_ascii=False, indent=2) # 2. 汇总表 csv_path = OUT_DIR / "summary.csv" export_csv([result], csv_path) # 3. 可视化 make_wordcloud(freq_dict, "wordcloud.png") make_bar(result["top_words"], "top_words.png") # 4. 控制台摘要 print(f"字符数:{result['char_count']} 句数:{result['sentence_count']}") print(f"词典法得分:{result['score_dict']}") print(f"整篇模型分:{result['score_model']}") print(f"分句加权分:{result['score_weighted']} 标签:{result['label']}") print("Top5 高频词:", result["top_words"][:5]) print("产物目录:", OUT_DIR) if __name__ == "__main__": main()utf-8-sig这个编码选择是有讲究的。普通的utf-8导出的 CSV,用 Excel 直接双击打开会显示乱码,因为 Excel 认 BOM 头来判断编码。加上-sig就会在文件开头写入 BOM,Excel 就能正确识别。如果你的下游是程序读取而不是人看,用普通utf-8更干净,因为 BOM 会污染第一列字段名。
4.6 运行与结果说明
代码写完后按顺序运行:
cd text_sentiment python prepare_corpus.py python main.py第一步会打印清洗后的字符数和切分出的句子数,用来确认语料读取正常、分句逻辑符合预期。如果句子数明显偏少,说明你的语料用的分隔符不在正则覆盖范围内,需要补充。
第二步的预期输出大致如下(分数会因库版本和语料内容不同而浮动,仅供参考量级):
字符数:132 句数:5 词典法得分:-3.0 整篇模型分:0.42 分句加权分:0.38 标签:负面 Top5 高频词: [('约定', 2), ('往前', 2), ('原地', 2), ('烟火', 1), ('大学', 1)] 产物目录: .../text_sentiment/outputoutput目录下会生成四个文件:result.json是完整结构化结果,含逐句得分明细;summary.csv是汇总表,方便你批量跑多篇后拼成一张表;wordcloud.png和top_words.png是两张图。
这里最值得看的是result.json里的sentence_detail。分句加权分只在 0.3 到 0.5 之间徘徊的文本,往往是因为正负句混杂。打开明细你会发现,像"我没有难过,只是有点不习惯"这种带否定的句子,模型给的分通常偏高,因为它抓到了"难过"这个词却不一定处理好了否定。这正是需要人工复核的地方,也是第 5 节要讲的排查重点。
5. 常见问题与排查清单
5.1 高频报错对照表
下面这些是我在实际项目和读者提问里反复见到的报错,按现象整理成表,方便你快速定位:
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
UnicodeDecodeError: 'gbk' codec can't decode | 读文件没指定encoding="utf-8",Windows 默认 GBK | 所有open()显式加encoding="utf-8" |
| 词云图里全是方块 | 未指定中文字体,或字体路径错误 | 用pick_font()探测,或手动传font_path |
OSError: cannot open resource | 字体文件路径不存在 | 打印Path(p).exists()逐个确认 |
| matplotlib 图里中文变方块 | rcParams字体列表不含已装字体 | 设置font.sans-serif并清缓存 |
| 程序在某一步长时间无输出 | jieba 首次加载构建词典,或 matplotlib 等待弹窗 | 等待几秒;无界面环境设matplotlib.use("Agg") |
ValueError: We need at least 1 word | 词频字典为空,所有词都被停用词过滤了 | 检查停用词表是否过大,确认分词结果非空 |
| 情感结果永远是 0.5 | 传入的是空串或纯标点 | 在model_score里做空值兜底 |
| 否定词失效,极性反了 | 停用词表把"不""没"过滤掉了 | 从停用词表移除否定词,或用KEEP_WORDS兜底 |
| CSV 用 Excel 打开乱码 | 导出用了普通 utf-8 编码 | 改用utf-8-sig |
| 批量跑几万条后内存暴涨 | 一次性把全部结果留在列表里 | 改成分批写入 CSV,及时释放中间对象 |
表格里的最后一条值得多说一句。做小样本测试时感觉不到内存问题,但一旦语料量上去,sentence_detail这种明细字段会占用大量内存,因为每条文本都保留了逐句结果。生产环境里建议明细只在需要排查时开,常规批量跑只保留汇总字段。
5.2 结果"不准"的排查顺序
比报错更常见的是结果不准,而且没有异常信息,只能靠推理。推荐按下面这个顺序逐层排查,从底层往上层走,效率最高。
第一步看分词。把语料分词结果直接打印出来,肉眼扫一遍有没有明显的错切。像"江大"被切成两个字、"那年"被切成两段,这类问题在专有名词多的语料里很常见。解决办法是往jieba.add_word()里加自定义词,比如jieba.add_word("江大"),加完之后分词立刻正确。
第二步看停用词。把过滤前后的词表都打印出来,确认没有把关键情感词误删。这一步最容易被忽略,因为停用词表是别人给的,你默认它是对的,但它可能把"不""没"这类否定词也放进去了。
第三步看单句得分。打开sentence_detail,找出得分和你直觉相反的句子。如果是反讽或者隐喻,那属于模型能力边界,没办法通过调代码解决,只能标记出来不做自动判断。如果只是普通句子被判错,多半是训练语料的领域不匹配。
第四步看阈值。很多"不准"其实不是模型问题,而是label_of的阈值定得不合适。把你人工标注的 100 条样本跑一遍,画出分数分布,看正负样本的分界点在哪里,再回头改阈值。
第五步才考虑换方案。如果前面四步都排除了,再考虑换一个更强的模型。跳过前面四步直接换模型,通常是白花时间。
6. 工程最佳实践与落地建议
6.1 语料与词典的配置化管理
示例代码里把情感词表、程度词表直接写在sentiment.py顶部,这是为了演示方便。真实项目里必须外置,因为词表是运营和产品会频繁调整的东西,每次改都要发版是很糟糕的设计。推荐做法是放成 JSON 或 YAML 配置文件,启动时加载,甚至支持从配置中心动态刷新。
程度词表尤其需要外部可调。"有点""稍微""特别""极其"这些词的权重,是跟语料风格强相关的。电商评论里"有点"可能是弱化表达,情感类文本里"有点"往往带着克制的意味,实际强度反而不低。这些差异只能靠业务侧反馈来调,代码里写死就等于封死了优化空间。
另外建议给词表加版本标记。改词表会直接改变历史数据的分值,如果没有版本记录,前后数据就没法对比。加个version字段,每次分析结果里带上,出问题时能快速定位是哪次词表变更引入的。
6.2 结果可解释性设计
情感分析用在业务里,最怕的不是不准,而是不准还说不清为什么。所以从设计阶段就要考虑可解释性,具体有三点建议。
第一,永远保留中间产物。分词结果、过滤后的词表、逐句得分,这些中间态能极大缩短排查时间。存储成本很低,排查收益很高。
第二,把"分差"作为质量信号。词典法和模型法的分数接近时,说明两者一致,可信度相对高;差异很大时,标记为待复核。这个策略在实际项目里比单纯提升模型精度更有效,因为它把有限的人力集中在真正可疑的样本上。
第三,输出要带上量纲说明。0.38这个数字对业务方毫无意义,要同时输出"负面"标签和"接近中性下界"这类描述。文档里一句话的事,能减少大量沟通成本。
6.3 性能优化与批量处理要点
单篇分析耗时主要在三处:jieba 加载词典(首次几秒,之后缓存)、SnowNLP 逐句初始化(每句都要建对象)、以及词云渲染。针对这三处有不同的优化手段。
jieba 加载只发生一次,进程内复用即可,千万不要在循环里反复调用jieba.initialize()。SnowNLP 的对象创建开销不小,如果文本量很大,可以考虑把短句批量拼在一起送进模型,但这会破坏句级明细,需要权衡。词云渲染是最慢的一环,如果只是批处理出结果不需要图,把渲染步骤配置化关掉,速度能提升一个数量级。
批量处理时建议流式读写:读一条、算一条、写一条,不要把所有结果攒在内存里。用 Python 的生成器配合csv.writer就能做到,代码改动很小,但处理十万级数据时内存占用差距非常明显。
6.4 数据合规与使用边界
这一块虽然和技术无关,但在实际项目里必须提前明确,否则容易出问题。第一,语料来源要合规,抓取公开内容和抓取用户私密数据是两件性质完全不同的事,后者需要明确授权。第二,涉及用户个人信息的内容要做脱敏,昵称、手机号、地址这类字段在分析前就该剔除。第三,分析结果的使用范围要限定,情感标签用于内部统计和用于对个人做评价,风险和合规要求完全不同。
还有一点容易被忽略:如果分析的是文学作品、评论文章等有版权的文本,保存全文和仅保存分析结果,在合规上的要求也不一样。稳妥的做法是只留存统计特征和必要的短片段,全文分析完即弃。
7. 学习路线与下一步方向
7.1 本文涉及的几个关键能力点
走完整个流程,你实际上手了四件事:文本清洗与分句(正则表达式 + 编码处理)、中文分词与停用词治理(jieba + 词表设计)、情感极性的三种实现路径(词典规则、分类器、加权聚合)、以及结果的结构化输出与可视化(JSON、CSV、词云、图表)。这四块拼起来就是一个最小可用的中文文本分析流水线,换掉语料就能直接用在评论分析、工单归类、内容标签生成这些场景上。
真正需要带走的不是这段代码,而是几条判断:分词质量决定上限,停用词表可能悄悄毁掉否定逻辑,整篇打分不如分句加权,阈值必须用自己数据调,可解释性比精度更影响落地。
7.2 继续深入的几个方向
如果要把这套东西往生产级推进,建议按这个顺序补:
第一是标注数据。没有标注就没有评估,没有评估就谈不上优化。哪怕只标注 200 条,也能让你对当前方案的真实水平心里有数。
第二是换更强的分类模型。基于预训练语言模型的微调方案在中文情感任务上效果明显好于词典法和传统分类器,代价是需要 GPU 和标注数据。可以先从公开的中文情感分类模型试起,跑通推理流程,再考虑微调。
第三是构建评估体系。准确率、召回率、F1 只是基础,业务上更该关注的是"负面样本漏判率"这类和实际损失挂钩的指标。指标选错了,模型优化方向就会跑偏。
第四是把分析服务化。包成一个 HTTP 接口,前面加缓存,后面接消息队列,这样分析能力才能被多个业务系统复用,而不是每个项目复制一份脚本。
代码跑通只是起点,真正有价值的是你能根据业务反馈持续调优,能对"这个结果为什么不准"给出具体解释,能在数据量涨十倍时让它照样跑得动。这三件事没有捷径,只能在项目里一点点磨。