简介:这是一份基于Python实现方面级别情感分析(Aspect-Level Sentiment Analysis)的NLP课程作业资源,面向正在学习自然语言处理或情感分析的学生,可用于完成课程设计、实验报告或入门ALSA任务。方面级情感分析不同于普通文本情感分类,它需要针对评论中不同方面(如服务、价格、环境)分别判断情感极性,本作业围绕这一核心问题展开。资源以预训练语言模型为工具,完整实现了从数据清洗、特征处理到模型微调与预测输出的流程,配套运行脚本与依赖文件,能够复现基本的方面级情感分类效果。包内共有7个文件,主要包含两个Python源码文件、Shell运行脚本(方便一键执行)、requirements.txt依赖清单,以及LICENSE、README等说明文档,整体压缩包仅14KB,轻量且结构清晰。已有368人学习下载,适合作为NLP课程作业的参考实现,帮助你快速搭建实验框架并理解预训练模型在细粒度情感分析中的应用逻辑。
1. 方面级情感分析:三星评论里的“屏好、电池差”到底怎么打分
一条三星评论里藏着两个相互矛盾的方面:“屏幕色彩很好”是表扬,“电池一天三充”是批评。如果只对整个文本做一个正/负/中分类,模型会左右为难,用户也看不出问题出在哪。方面级情感分析(Aspect-Based Sentiment Analysis,ABSA)要做的就是把“屏幕”“电池”分别抽出来,各自给一个情感标签,输出更细的结构化结果。这也是它经常被布置成 NLP 课程作业的原因:能检验一个人是否真的理解实体、属性和主观表达是怎么绑在一起的。往下我用中文文本走一遍完整落地,不依赖 GPU,一台普通笔记本加 Python 环境就能跑完。
ABSA 和整句情感分类的差别不只是输出粒度,而是用户需求本身。餐馆评论里“菜好吃但等位太久”,电商评论里“质量可以但客服不理人”,本质都是多个方面各自评分。课程作业如果只做整句分类,展示不出 NLP 里的序列建模能力;ABSA 则逼着你把分词、候选抽取、分类、评估完整串起来。下面讲的顺序来自我实操时常用的套路:先把标注数据整理好,再用词典加词性把候选方面抽出来,然后对每个候选做三分类,最后对齐指标和错误案例。
环境准备很简单,Python 3.8 上下,装好 jieba、scikit-learn、joblib、numpy 就够了。后面所有代码都围绕这套基础环境展开。
2. 数据与标注格式:先把“方面-情感”这对关系管住
ABSA 项目的第一步不是写模型,而是确定数据格式。后续抽取、分类、评估都要不断回到标注数据上做对齐,格式一旦定错,后期每一步都像在黑匣子里调参。这里先说明为什么要用 JSON,再给出三个可以直接抄的脚本。
2.1 为什么是 JSON 而不是 CSV:字段怎么定才不别扭
CSV 是表格结构,但对“一句话挂多个方面”这个场景很不友好:多个方面要么塞进一个单元格,要么拆成多行引入重复,后期处理全是边界条件。JSON 和 Python 的 dict/list 天然对应,json.load 一行就能解析,所以在课程作业和中小型标注集里,这算是最顺手的组织方式。
数据字段是整套代码的地基,少一个字段后面就要回填。下面是一个可以直接套用的结构:
# absa_data.json 片段,整个数据集是一个 list [ { "id": "train_001", "text": "屏幕色彩很好,但电池一天三充。", "aspects": [ {"term": "屏幕", "start": 0, "end": 2, "polarity": "pos"}, {"term": "电池", "start": 8, "end": 10, "polarity": "neg"} ] } ]注意 start/end 是按字符计的偏移,不是按词计的。中文分词结果不稳定,同一个“屏幕”可能被 jieba 切成一个词,也可能被切成“屏/幕”;没有偏移字段,自动抽取的边界和人工标注就对不齐。课程作业里这个字段一开始觉得没用,等到写评估脚本时你就知道它是后悔药。
加载脚本这样写,重点是做一次断言,防止手滑把最外层写成 dict:
import json def load_absa_data(path): """读取 ABSA 标注集,最外层要求是 list。""" with open(path, "r", encoding="utf-8") as f: data = json.load(f) assert isinstance(data, list), "最外层请使用 list,每条样本是一个 dict" return data编码问题顺手说一下:Windows 上默认打开文件可能用 gbk,所以 open 里显式写 encoding="utf-8",否则中文数据大概率乱码。这是新手最容易在第一步就翻车的地方。
2.2 跑一个统计,看三个数字再决定模型方案
很多同学拿到数据直接开训,结果模型学了半天输出全是中性。实际上花两分钟看一眼分布,很多问题在训练前就能发现。这个统计脚本会告诉你三组数字:样本条数、极性分布、高频方面词。
from collections import Counter def inspect_absa(data): """输出样本数、极性分布、高频方面词,训练前必看。""" polarity_counter = Counter() term_counter = Counter() aspect_num = 0 for item in data: for asp in item.get("aspects", []): polarity_counter[asp["polarity"]] += 1 term_counter[asp["term"]] += 1 aspect_num += 1 print("样本条数:", len(data)) print("标注方面总数:", aspect_num) print("极性分布:", dict(polarity_counter)) print("高频方面词前10:", term_counter.most_common(10))这三个数字直接决定后续参数怎么设。样本少于 100 条,别碰神经网络,TF-IDF 加线性分类器足够;极性分布严重偏斜,比如“中性”占到 70%,模型会偷懒全部预测中性,后面必须开 class_weight 或者做重采样;高频方面词前 10 里如果出现“的”“很”这种虚词,说明标注时把无关词也圈进去了,需要回头清理。
看分布时留意另外一个信号:某个词频繁出现在 text 里却从来不出现在 aspects 中。比如 20 条评论都提到“价格”,但没有一条把它标为方面,十有八九是标注遗漏。漏标会直接压低抽取阶段的召回率,等到模型训完再发现就晚了。
2.3 jieba 与词性过滤:低成本得到候选方面词
方面词绝大多数是名词或名词性短语,所以第一版候选抽取不需要模型,用词性过滤就能拿下一大半。我一般用 jieba.posseg 切词后保留名词性 token,再叠一层停用词和长度过滤:
import jieba.posseg as pseg STOP_WORDS = {"的", "了", "是", "在", "有", "和", "就", "都"} def extract_candidate_aspects(text): """ 基于词性和停用词的第一版方面候选抽取。 返回候选词列表,可能有重复,后续交给统计合并。 """ words = pseg.cut(text) candidates = [] for word, flag in words: # 名词词性包括 n/nr/ns/nz/nt 等,flag 统一以 n 开头 if flag.startswith("n") and word not in STOP_WORDS and len(word) >= 2: candidates.append(word) return candidateslen(word) >= 2 是为了防呆。“屏”“幕”“电”“池”这种单字词单独出现时,既不是完整语义单元,也容易把情感上下文冲淡。如果发现“屏幕”被切成两个单字,优先把词加入 jieba 的自定义词典,不要反过来改过滤逻辑:
import jieba # 在数据加载完成后立即执行,一次加完 for w in ["屏幕", "电池", "续航", "拍照", "清晰度", "售后服务"]: jieba.add_word(w)自定义词表是 ABSA 项目里投入产出比最高的操作。它不改变算法,却能明显提升候选抽取的完整性,属于那种“改一行、爽半天”的配置。后面所有模型的输入都建立在这些候选词上,这一步的召回率上不去,后面分类再准也是白搭。
3. 两段式实现:先抽方面词,再做情感三分类
课程作业里 ABSA 的主流实现路径分两类:端到端联合建模,以及两段式流水线。端到端方法更像学术界论文的样子,但需要充足标注和数据支撑;两段式把问题拆成“抽取”和“分类”两个子任务,各自单独调试,最后串起来,是最容易在笔记本上跑通、也最容易讲清楚的做法。
3.1 两段式为什么比端到端更适合课程作业
端到端方案通常用序列标注或生成式模型,一句话里同时输出方面词边界和情感极性。听起来很酷,但你得面对三件事:标注量要求更高,训练时间更长,出问题难定位——你不知道是边界错了还是极性错了。两段式把错误切分成两类:没抽到方面词,或者抽到了却分错极性。哪一段出错,去看哪一段的指标,非常直白。
两段式的第一个阶段用规则或简单模型做方面抽取,第二阶段对每个候选方面做三分类。这个拆法还有一个额外好处:如果你只想验证“方面词给对了,情感模型够不够准”,可以跳过抽取,直接用标注里的 term 喂给分类器,把两个模块的误差隔离开。课程验收时,这种模块化的设计比一团黑盒更容易拿分。
3.2 构造“方面+上下文”的训练样本:窗口参数怎么定
情感分类的输入单位不是整句话,而是“方面词 + 它的上下文”。比如“屏幕色彩很好”里“很好”才是关键;整句输入会把“电池一天三充”带进来,直接污染特征。所以训练前要先做一次样本组装:为每个标注方面截取左右各 N 个词的窗口。
import jieba def aspect_context(text, aspect_term, left=2, right=2): """ 截取方面词在句子中的上下文窗口,返回 list[str]。 left/right 是方面词左右各取几个词,默认各 2 个。 """ words = jieba.lcut(text) idx = -1 for i, w in enumerate(words): if w == aspect_term: idx = i break if idx == -1: # 数据里 term 和 text 对不上时直接返回整句,避免丢失样本 return words start = max(0, idx - left) end = min(len(words), idx + right + 1) return words[start:end]left=2/right=2 是最常用的起点。窗口太小,看不到“一点也不清晰”里的“一点也”;窗口太大,“电池”旁边的“价格”会进来干扰判断。中文里属性词和被修饰语通常距离很近,左右各两个词基本够用。但否定结构属于例外,后文避坑章节会专门处理。
组装训练集时,把完整句子也拼接进去,别只留窗口。这样 TF-IDF 能同时吃到全局词频和局部上下文,分类器拿到的信息更完整。
def build_samples(data): """ 把标注数据组装成 (完整句子, 方面上下文, 情感极性) 三元组。 """ samples = [] for item in data: text = item["text"] for asp in item["aspects"]: ctx = aspect_context(text, asp["term"]) samples.append((text, " ".join(ctx), asp["polarity"])) return samples这里用“空格拼接”是因为后面 TfidfVectorizer 默认按空格切词。如果你用的 tokenizer 是 jieba.lcut,可以直接把 tokenizer 传进去,那就不用拼字符串,传 list 也可以。我的经验是拼成字符串最稳妥,避免不同 sklearn 版本对 token 类型(Samsung) 处理不一致的问题。
3.3 用 sklearn Pipeline 训练一个三分类器
分类器我常用 LinearSVC,而不是 RBF 核的 SVC(高计算开销)。文本特征本身高维稀疏,线性核已经能拿到不错的效果,训练速度还快。下面是一个完整的建模管道:
from sklearn.pipeline import Pipeline from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.svm import LinearSVC def build_aspect_polarity_model(): """ 构建 方面情感三分类器:TF-IDF + LinearSVC。 """ tfidf = TfidfVectorizer( tokenizer=jieba.lcut, ngram_range=(1, 2), max_features=8000, sublinear_tf=True ) svm = LinearSVC( C=0.5, class_weight="balanced", max_iter=3000 ) return Pipeline([ ("tfidf", tfidf), ("svm", svm) ])几个参数值得认真调。ngram_range=(1,2) 是为了抓到“不/清晰”“很/好”这类双词组合,单字 ngram 对否定结构没有区分度。C=0.5 比默认的 1.0 保守,课程作业数据量小,C 太大会把个别样本的噪声当成规律,泛化变差。class_weight="balanced" 会在少数类样本上自动提高权重,解决 2.2 里看到的类别不平衡。max_features=8000 是内存护栏,中文词表膨胀很快,8000 足够中小型数据集用。
train_test_split 时注意 stratify 参数,它能让三个类别的比例在训练集和测试集里保持一致:
import joblib from sklearn.model_selection import train_test_split def train_model(samples, save_path="absa_model.joblib"): """训练并保存模型,返回模型和测试集。""" X = [ctx for _, ctx, _ in samples] y = [pol for _, _, pol in samples] X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42, stratify=y ) model = build_aspect_polarity_model() model.fit(X_train, y_train) joblib.dump(model, save_path) return model, X_test, y_testrandom_state=42 不只是玄学,它保证多次实验拿到相同的切分,调参时才能前后对比。stratify=y 如果报错,说明某个类别在整体数据里只出现了一次或两次,没法同时放进训练和测试;这时先别急着删 stratify,回头看看 2.2 的统计输出,处理完不平衡再说。
3.4 预测新句子的入口:把抽取和分类接起来
模型训练完,需要一个对外接口。输入一句新评论,先抽候选方面词,再对每个候选做情感预测,最后把结果拼成结构化输出。下面这段就是你要直接交给“用户”的接口:
def predict_aspects(text, model): """ 对一条新评论完成 方面抽取 + 情感分类。 返回 [(方面词, 情感), ...] 列表。 """ candidates = extract_candidate_aspects(text) results = [] for term in candidates: ctx = aspect_context(text, term) # 单个样本的上下文,包装成 list 保持 sklearn 接口一致 pred = model.predict([" ".join(ctx)])[0] results.append((term, pred)) return results代码里把“从文本到标签”的流程收敛到 3 个函数:候选抽取、上下文截取、模型预测。每段都可以单独替换升级。比如候选抽取从词性规则换成 CRF 模型,或者上下文从窗口换成整句,接口不需要动,这也是两段式落地时最有价值的部分。
注意,这版 predict_aspects 会把每个候选都给出情感,包括那些根本不构成方面的噪声词。如果你想抑制这种“什么都输出”的尴尬,做法是加一个过滤阈值,比如用词典过滤掉高频但不是领域属性的词,或者要求至少出现两次才保留。这个留在最后一章讲改进时展开。
4. 评估:把指标拆开,才知道是哪一段拖后腿
很多同学跑完 train_model 只丢出一个准确率,就说“作业做完了”。但 AB 类型是精度、召回率、F1 的组合,还是方面抽取和情感分类两个模块各自的一组指标,混合在一起会掩盖真实问题。评估的原则是:精度不信任,只看分类报告和混淆矩阵。
4.1 分类报告怎么读:宏平均比加权平均更有参考价值
情感分类做的是三分类任务,直接看 classification_report:
from sklearn.metrics import classification_report, confusion_matrix def evaluate_model(model, X_test, y_test): """输出分类报告与混淆矩阵。""" pred = model.predict(X_test) report = classification_report(y_test, pred, digits=3) print(report) labels = ["pos", "neu", "neg"] cm = confusion_matrix(y_test, pred, labels=labels) print("混淆矩阵(行=真实, 列=预测):") print(cm)注意看 macro avg 而不是 weighted avg。加权平均会被大类别的正确率带跑,不正视小类别的失败。宏平均把三个类别一视同仁,当少数类被漏掉很多时,宏 F1 会明显下降,这时候再回头去看 class_weight 是否生效。
4.2 方面抽取的 F1:完全匹配和部分匹配怎么算
第二个模块指标是“方面词抽取得对不对”。课程作业容易忽略这一块,但它是 ABSA 的骨架。最简单的做法是比较预测集合和标注集合的交集,按完全匹配计算精确率、召回率和 F1:
def evaluate_aspect_extraction(label_items, pred_items): """ label_items: [(sample_id, 方面词)] pred_items: [(sample_id, 方面词)] 完全匹配方式:词完全相同才算命中。 """ gold = set(label_items) pred = set(pred_items) tp = len(gold & pred) precision = tp / len(pred) if pred else 0.0 recall = tp / len(gold) if gold else 0.0 f1 = 2 * precision * recall / (precision + recall) if (precision + recall) else 0.0 return {"precision": precision, "recall": recall, "f1": f1}完全匹配很严格,这也是它最适合写进作业报告的地方——查准和查漏用同一把尺子,不存在人为解释空间。但现实中“屏幕”和“屏”可能是同一个方面,完全匹配会误判为失败。如果你有精力,可以再做一版部分匹配:把预测词和标注词做包含关系判断,只要一方包含另一方就算命中。两个 F1 一起报告,结论会更完整。
4.3 混淆矩阵定位问题:是抽不到,还是判不对
混淆矩阵是整个 ABEST 项目里最有诊断价值的输出。建议把测试样本的错误案例按两种类型归档:一是真实标注有方面但候选抽取漏掉了,二是方面抽到了但极性判错。
归档方式很简单,预测时别只存结果,把上下文一起存下来:
import csv def write_error_samples(predictions, labels, output="error_samples.txt"): """ predictions: [(sample_id, 方面词, 预测极性, 上下文)] labels: { (sample_id, 方面词): 真实极性 } """ with open(output, "w", encoding="utf-8") as f: for sample_id, term, pred, ctx in predictions: true = labels.get((sample_id, term), "?") if true != pred: f.write(f"[{sample_id}] {term} | 真实:{true} 预测:{pred} | {ctx}\n")这份错误样本文件就是你答辩时的素材。面试或课程评审最反感你只说“我调了下模型”,但如果你能指着具体案例说“这个是反讽结构拆解失败,这个是形容词距离太远导致窗口没覆盖”,整份作业的可信度完全不同。
5. 避坑指南与常见问题:四个高频翻车点
下面四个坑是 ABST 落地时几乎躲不掉的问题,按“现象-原因-解决”写,遇到时对照着查就行。
5.1 jieba 把领域词切开,候选方面抽不到
现象:标注里明明是“屏幕”,候选抽取结果却是“屏”和“幕”两个单字,情感分类拿不到正确输入。原因:jieba 默认词表没有覆盖领域词,切分时按通用规则把复合词拆开。解决:把领域词加进自定义词典,在加载数据后立即执行 jieba.add_word。如果漏掉多个词,建议维护一个自定义词表文件,循环读入。
如果加了词典还切不对,检查自定义词表里是否混入了停用词或单字词。比如把“的”加进去,反而会让切分变混乱。自定义词典遵循一个原则:只加标注里出现过、并且确认是完整方面词的实体。
5.2 否定结构把“真不错”变成“真不怎么样”
现象:“电池一点也不耐用”被预测成“正面”。原因:训练数据里这类否定句式太少,而窗口里“耐用”的正面权重远高于“不”的权重,模型被词频带偏。解决:先从规则层兜底,在情感分类之前加一个否定开关。
def flip_by_negation(text, aspect_term, pred): """ 极简否定规则:方面词前后 3 个字符内出现否定词时,交换正负极性。 """ idx = text.find(aspect_term) if idx == -1: return pred window = text[max(0, idx - 3): idx + len(aspect_term) + 3] for neg in ["不太", "一点都不", "不", "没", "无"]: if neg in window: return {"pos": "neg", "neg": "pos"}.get(pred, pred) return pred这里的坑也很明显:“不太”必须在“不”之前匹配,否则“不太”被拆成“不”和“太”,照样误翻转。我的建议是把这个函数接在 predict_aspects 的输出后,作为规则兜底,而不是训练阶段做数据增强——规则在训练阶段反而会把样本搞乱。还要注意“不太贵”这类词会被误伤,所以翻转规则只对 pos/neg 生效,中性结果不参与翻转。
5.3 类别不平衡导致模型只会输出中性
现象:测试集准确率看起来有 75%,打开分类报告才发现“负面”类别的 F1 是 0。原因:数据里 70% 都是中性样本,模型把一切预测成中性就能拿到不错准确率,但情感分析业务真正关心的恰恰是负面反馈。解决分三层:先看 2.2 的分布,如果三类比例超过 5:3:2,就开 class_weight="balanced";效果不够再做少量多数类下采样,或者提高少数类的样本权重;最后还要看宏 F1,不要只看准确率。
负面样本不足的真正解法是补数据,而不是调参。课程作业时间有限,可以在评估报告里明确写出“负面类别样本仅 X 条,指标置信度受限”,这比装作不存在更让评审认可。
5.4 字符偏移和索引错位,评估结果对不上
现象:评估脚本里用 text.find(term) 找到下标,但拿到的是 8,人工核对看起来是对的,按这个下标切片却总是多一个字符。原因:文本里有标点符号或空格,find 返回的是字节位置或原始字符串位置,和你的人工视觉位置有偏差;另一种常见情况是 JSON 里存的 start 是从分词角度记的,不是字符角度。解决:统一用 UTF-8 字符偏移,并写一个对齐校验:
def validate_offsets(data): """ 校验标注里的 start/end 与 text 切片是否一致。 返回所有不一致的样本 id。 """ bad = [] for item in data: text = item["text"] for asp in item.get("aspects", []): start, end = asp["start"], asp["end"] if text[start:end] != asp["term"]: bad.append(item["id"]) return bad训练前跑一次这个函数,把不一致的样本全部修掉。这个错误最阴险的地方在它不影响模型训练,只让你的评估数据里混入错误样本,指标忽高忽低却找不到原因。
6. 进阶:从“能跑”到“能讲清楚”的三个小动作
到这里,你已经有了一个完整的 ABSA 流水线:JSON 标注、jieba 候选抽取、LinearSVC 情感分类、两个维度的 F1 评估。现在说三个让作业往上走的小动作。
第一个动作是把“否定词”从规则上升为特征。上一章的 flip_by_negation 作为兜底足够,但它每次只能救一类句式。更稳的做法是在 TfidfVectorizer 之前增加一个自定义特征列——当窗口内出现否定词时,给该样本额外加一个标记特征。具体实现可以用 FeatureUnion,一部分走 TF-IDF 文本特征,一部分走否定标记这类手工特征。这样模型自己学会权衡否定词和情感词,而不是靠你逐条写规则。
第二个动作是保存一份“分模块成绩单”。用三列记录:模块名、完全匹配 F1、部分匹配 F1。课程验收时,只展示端到端效果还不够,逐个模块的性能曲线才说明你理解问题边界。所有模型、误差样本、错误案例截图放同一个目录,文件名带日期和参数版本,改动后可回滚。
第三个动作是针对本地数据量小的情况做增强。把训练样本里每个方面的上下文做轻微替换,比如“屏幕色彩很好”改为“屏幕颜色很好”,“颜色”“色彩”之间互相替换,人工造一批同义表达,能显著提高“完全匹配”指标的真实覆盖面。注意别在测试集上做同类增强,否则指标虚高却经不起复用。
我做 ABSA 的习惯是任何一次重跑,都把标注样本和最终错误案例放在同一夹子下,前后对三次。多对齐一些错误案例,两句结束:这不是数据玄学,是让模型和学习者自己都能看得见“漏在哪、错在哪”。以上这些操作花半天时间就能在本地完成调试和复现,希望帮到你。
本文还有配套的精品资源,点击获取