简介:一份面向Python毕业设计/课程设计的旅游景点方面级情感分析项目源码与语料库,可用于理解从数据标注到模型评估的完整流程。项目基于Django框架实现后端,覆盖数据库设计、NLP情感分析模型构建、前端界面展示等环节,适合计算机相关专业学生作为毕业设计参考或实训课题。压缩包约70.56MB,已吸引101人学习,包含项目源码、说明文档及部署说明等核心内容。系统中集成BERT、LSTM或Transformer等深度模型或传统机器学习方法,并附带经过标注的景点评论语料库,便于直接训练和验证;数据库层可利用Django ORM操作SQLite/MySQL,完成评论与景点信息的存储管理。文档部分对系统架构、功能模块、操作流程与技术栈进行了详细梳理,部署说明则涵盖Python/Django环境配置、gunicorn与Nginx反向代理等环节,可帮助快速搭建可运行的演示系统。项目还涉及文本预处理、特征工程与模型调优等细节,配合说明文档可逐步复现实验环境。
1. 旅游景点方面级情感分析:毕设选题为什么选它,值得吗
如果你的毕业设计题目是《旅游景点方面级情感分析》,又恰好从学长手里接到一个带语料库和模型源码的 zip 压缩包,你首先要弄明白:它和普通的好评差评分类不是一回事。普通情感分析只回答“这条评论是好评还是差评”,而方面级(Aspect-Based)情感分析要回答的是“大家觉得这个景点的交通方便吗、门票贵不贵、餐饮对不对胃口、风景值不值得专程跑一趟”。同样是“景区很美但门票太贵”这一句话,景观方面是正面、门票方面是负面,两个结果必须同时输出。这正是基于 Python 的毕业设计里较能体现 NLP 含金量的方向之一,既有语料库这个“劳动量证明”,又有模型源码这个“技术证明”,还方便答辩时拿具体句子现场演示,不用画一堆空饼。下面我把这个题目的语料库构建、模型选型、训练跑通和踩坑过程完整拆开讲。
2. 从0搭建景点评论文本语料库:数据来源、清洗与方面级标注
2.1 语料库结构设计:句子-方面-情感标签怎么存
方面级情感分析的语料库,最忌讳的就是把数据存成“一条评论一行、一个好评差评标签”那种老格式。因为一条景点评论里经常同时聊好几个对象,比如“地铁能到门口,但停车太难,而且门票还涨价了”,这一条里交通是正面的、停车算交通里的负面、门票是负面。如果只给一个整体标签,模型永远学不会细粒度判断。
我一般会把语料库设计成 CSV 或 JSONL,核心字段固定为四列:review_id、review_text、aspect_category、sentiment_label。review_text是原始评论句子,aspect_category是预定义好的方面类别,sentiment_label是情感极性,用数字存:0 负面、1 中性、2 正面。这样一条原始评论会按“方面-情感对”展开成多行,例如:
| review_id | review_text | aspect_category | sentiment_label |
|---|---|---|---|
| 1001 | 景色很美,但门票偏贵 | 景观 | 2 |
| 1001 | 景色很美,但门票偏贵 | 门票 | 0 |
| 1002 | 地铁直达景区,很方便 | 交通 | 2 |
实践里方面类别不要设太多,5~6 个足够:交通、门票、餐饮、景观、服务。有人硬要加“安全”“卫生”,标注成本立刻翻倍,而多数模型在这几个稀疏类别上很难学好,所以建议新手先把这 5 类做扎实。情感极性也尽量别做五档(很满意、满意、一般、不满意、很不满意),标注一致性会崩,毕设里的答辩老师更看重你能不能自圆其说,三档最稳。
2.2 公开评论抓取与清洗:把杂乱的 HTML 变成干净的 DataFrame
数据来源无非两条路:公开数据集和自采数据。用现成中文情感分析数据集再改成方面级标注,能省大量时间,但搜索引擎上能找到的旅游领域 ABSA 公开语料少,多数毕设还是靠自己写爬虫抓公开评论。需要注意,抓取必须遵守目标网站的 robots 协议和访问频率,只用于学习研究,别用多线程并发压对方服务器。
一个够用的抓取与清洗脚本,核心其实就两个函数:
import re import time import pandas as pd from bs4 import BeautifulSoup def fetch_reviews(base_url, max_pages=20): """抓取公开评论列表页,返回评论文本 DataFrame。""" rows = [] for page in range(1, max_pages + 1): url = f"{base_url}?page={page}" resp = requests.get(url, headers={"User-Agent": "Mozilla/5.0"}, timeout=10) soup = BeautifulSoup(resp.text, "html.parser") # 每条评论通常是一个带 review-item 类名的节点,具体选择器按目标站调整 for item in soup.select(".review-item"): text = item.get_text(separator=" ", strip=True) if len(text) > 8: rows.append({"review_text": text, "page": page}) time.sleep(1) # 单线程 + 延时,避免给目标站造成压力 return pd.DataFrame(rows) def clean_review(text): """去掉 URL、多余空白和纯英文数字段,保留中文主体。""" text = re.sub(r"https?://\S+", "", text) text = re.sub(r"\s+", " ", text).strip() # 过滤掉没有中文的文本,长度小于5的没信息量 if len(text) < 5 or not re.search(r"[\u4e00-\u9fa5]", text): return "" return textfetch_reviews里的参数max_pages控制抓取深度,20 页一般能拿到几百到上千条原始数据,具体数量看网站每页条数。time.sleep(1)是成本最低的保护措施,把请求频率压到每秒一次,否则容易触发反爬,ip 被临时封了还得等。clean_review里那个len(text) < 5的过滤门槛很关键,景点评论里大量存在“不错”“还行”这种短文本,保留它们会让后续方面标注变得非常离谱,因为它们往往不包含任何方面词。
清洗之后还需要去重和抽样质检:
df = raw_df.drop_duplicates(subset="review_text") df = df[df["review_text"].map(clean_review) != ""] print(f"清洗后有效评论 {len(df)} 条")去重按评论文本整体来,因为很多用户会直接复制别人的好评,重复文本会让模型在验证集上虚高。清洗完成后,下一步是把这些文本变成带标签的语料,也就是标注。
2.3 方面级标注:规则预标注加上人工修正,别硬纯手标
纯人工标注 8000 条、每条还拆多个方面,两个人标两周很正常。更高效的办法是先写一套关键词规则做预标注,把它当成初稿,再由人工在初稿上修正。这样既省时间,又能让标注标准统一。
ASPECT_RULES = { "交通": ["交通", "地铁", "公交", "停车", "打车", "堵", "方便", "直达"], "门票": ["门票", "票价", "收费", "贵", "性价比", "划算", "涨价", "值"], "餐饮": ["餐厅", "小吃", "吃饭", "饭菜", "美食", "贵", "好吃", "难吃"], "景观": ["风景", "景色", "景观", "好看", "美", "拍照", "值得", "一般"], "服务": ["服务", "导游", "态度", "工作人员", "热情", "差"], } def rule_label(text): """返回这条评论命中的方面类别列表,未命中的返回空列表。""" matched = [aspect for aspect, words in ASPECT_RULES.items() if any(word in text for word in words)] return matched规则的核心是召回率优先,宁可让一个句子命中多个方面,也不要漏掉真实方面。注意“贵”“值”“方便”这些词会同时出现在多个方面规则里,这没问题,因为预标注之后还要人工决定“贵”到底指门票还是餐饮。实际操作时,可以让两个人独立修正同一批预标注数据,再用一致性指标核对。
from sklearn.metrics import cohen_kappa_score # ano_a 和 ano_b 是两位标注者对相同200条样本打的标签序列 kappa = cohen_kappa_score(anno_a, anno_b) print(f"标注一致性 Kappa = {kappa:.3f}")Kappa 大于 0.6 说明标注标准基本可用,低于 0.4 就要重新统一标准,这时候通常是“方面词”和“情感词”没分清楚。比如“景区太商业化了”该标景观还是服务?这没有标准答案,关键是在标注规范里明确:讨论观感体验归景观,讨论工作人员行为和设施服务归服务。把这条写进标注文档,一致性立刻会提升。
预标注和人工修正完成之后,把数据切成 train / dev / test 三份,比例 8:1:1 即可。切分时要用groupby保证同一条评论的所有方面记录进同一个集合,不能按行随机切,否则同一条评论的方面会同时出现在训练和验证集里,模型等于提前见过原句,效果虚高。这一步是很多人漏掉的坑。
3. 构建方面级情感分析模型:Python实现与关键参数
3.1 模型选型:为什么选择 BERT 微调而不是 LSTM
方面级情感分析的经典路线有两条。传统做法是分词、去停用词、训练 Word2Vec,再喂给 LSTM 或 TextCNN,复杂度不高但对语料规模和标注质量要求极高,一个“门票虽然贵但是景色值”这种反转句式就能让 LSTM 的准确率掉一截。另一条路是直接对 BERT 微调,用预训练模型把句子编码成语义向量,再用全连接层做三分类,效果几乎“无脑”超过 LSTM,参数调法也成熟稳定。
毕设选题的核心诉求是“能跑通、能答辩、能解释”,BERT 微调是当下最稳妥的选择。原因有三:第一,中文就用bert-base-chinese权重,12 层 Transformer,对短文本评论的语义理解远好于词向量平均;第二,不需要自己设计复杂的特征工程,方面信息和句子一起喂进去即可;第三,答辩时能说清楚“利用预训练语言模型捕捉上下文语义”,这一句话就比讲三天词向量有说服力。硬件上单张 8GB 显存的显卡就够用,没有显卡也能用 CPU 跑通一个小 epoch,只是训练时间会久一点。
3.2 把句子和方面拼成模型输入:tokenizer 的细节
方面级情感分析在 BERT 下的常规做法是构造“句子对”,也就是把评论句子和方面类别拼成一对输入,中间用[SEP]分隔。对“景色很美,但门票偏贵”这个句子,如果预测“景观”方面,输入就是[CLS] 景色很美,但门票偏贵 [SEP] 景观 [SEP],标签是正面;如果预测“门票”方面,输入换成[CLS] 景色很美,但门票偏贵 [SEP] 门票 [SEP],标签是负面。
from transformers import BertTokenizer tokenizer = BertTokenizer.from_pretrained("bert-base-chinese") def encode_example(review, aspect, max_len=128): """将评论和方面编码成 BERT 输入,带截断和填充。""" enc = tokenizer( text=review, # 第一个句子:评论文本 text_pair=aspect, # 第二个句子:方面类别 max_length=max_len, padding="max_length", # 不足128补 [PAD] truncation=True, # 超过128截断 return_tensors="pt" # 返回 PyTorch 张量 ) return enc这里最关键的是text_pair=aspect这个参数。把方面类别当成一个“句子”拼在评论后面,模型才能在学习注意力权重时把评论里的“贵”和“门票”关联起来,而不是简单把“贵”当成整体情感。max_len=128对景点短评论通常足够,抓到的长评会被截断,但截断只砍尾部,而评论的情感倾向经常在开头就表达清楚,所以损失可控。如果评论平均长度特别短,可以缩到 64,训练速度明显加快,我建议第一轮先用 64 试跑,看验证集 F1 再决定要不要加长。
3.3 自定义 Dataset 与 DataLoader:把 CSV 批量化成张量
编码函数准备好后,要写一个 Dataset 类把 DataFrame 包装起来,否则手动循环做 batch 既慢又容易出错。
import torch from torch.utils.data import Dataset, DataLoader class ABSADataset(Dataset): def __init__(self, df, tokenizer, max_len=128): self.df = df.reset_index(drop=True) self.tokenizer = tokenizer self.max_len = max_len def __len__(self): return len(self.df) def __getitem__(self, idx): row = self.df.loc[idx] enc = self.tokenizer( row["review_text"], row["aspect_category"], max_length=self.max_len, padding="max_length", truncation=True, return_tensors="pt" ) return { "input_ids": enc["input_ids"].squeeze(0), "attention_mask": enc["attention_mask"].squeeze(0), "labels": torch.tensor(row["sentiment_label"], dtype=torch.long) }__getitem__里每个样本返回三个张量,input_ids是 token 索引,attention_mask让模型忽略[PAD]位置,labels是三分类标签。return_tensors="pt"时返回的是[1, seq_len]形状的张量,所以要squeeze(0)去掉 batch 维,否则 DataLoader 会自动拼成[batch, 1, seq_len],模型直接报维度错误。
dataset = ABSADataset(train_df, tokenizer, max_len=128) loader = DataLoader(dataset, batch_size=16, shuffle=True, num_workers=2)num_workers=2在 Windows 下可能会出问题,如果报多进程相关错误就把它改成 0,用主进程加载数据,速度慢点但更省心。shuffle=True只在训练集上开启,验证集和测试集必须设成False,否则每次评估结果随机波动,没法判断模型是不是真的在收敛。
3.4 训练循环与超参数:学习率、类别权重和早停
模型结构直接用BertForSequenceClassification,它内部已经包含了 BERT 编码器和分类头,不需要自己写 Transformer 层。
from transformers import BertForSequenceClassification, AdamW model = BertForSequenceClassification.from_pretrained( "bert-base-chinese", num_labels=3 ) device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model.to(device) # 类别权重:负面0.8,中性0.5,正面1.0 # 点评数据里正面评论天然偏多,给正面1.0、中性0.5能压低多数类 class_weights = torch.tensor([0.8, 0.5, 1.0], device=device) loss_fn = torch.nn.CrossEntropyLoss(weight=class_weights) optimizer = AdamW(model.parameters(), lr=2e-5) train_loader = DataLoader(train_dataset, batch_size=16, shuffle=True) dev_loader = DataLoader(dev_dataset, batch_size=32, shuffle=False) for epoch in range(5): model.train() total_loss = 0.0 for batch in train_loader: batch = {k: v.to(device) for k, v in batch.items()} outputs = model(**batch) logits = outputs.logits loss = loss_fn(logits, batch["labels"]) loss.backward() optimizer.step() optimizer.zero_grad() total_loss += loss.item() avg_loss = total_loss / len(train_loader) print(f"epoch {epoch + 1}, train loss {avg_loss:.4f}") # 每个 epoch 后在开发集上计算准确率,用于早停和保存最优权重 model.eval() correct = 0 total = 0 with torch.no_grad(): for batch in dev_loader: batch = {k: v.to(device) for k, v in batch.items()} logits = model(**batch).logits preds = torch.argmax(logits, dim=-1) correct += (preds == batch["labels"]).sum().item() total += batch["labels"].size(0) dev_acc = correct / total print(f"epoch {epoch + 1}, dev acc {dev_acc:.4f}")lr=2e-5是 BERT 微调的经验起点,太大容易把预训练权重冲坏,太小收敛慢。类别权重[0.8, 0.5, 1.0]不是固定值,要根据自己语料的类别分布算比例,中性标签如果只占 10%,权重可以调到 2.0,把它的 loss 放大,否则模型基本不会预测中性。训练时千万别只在 train loss 下降就以为收敛了,每个 epoch 结束必须算一遍 dev acc,连续两个 epoch 不上升就停掉,保存 dev acc 最高的那一次权重,这就是“早停”。
3.5 评估指标:别只看准确率,要同时看 F1 和混淆矩阵
景点评论方面级分类里,准确率很具有欺骗性。如果语料里 60% 是正面,模型全预测正面也能有 60% 准确率,答辩时老师一问“负面类别预测成什么样”就露馅。
from sklearn.metrics import classification_report, confusion_matrix def evaluate(model, loader, device): model.eval() all_preds, all_labels = [], [] with torch.no_grad(): for batch in loader: batch = {k: v.to(device) for k, v in batch.items()} logits = model(**batch).logits preds = torch.argmax(logits, dim=-1).cpu().tolist() all_preds.extend(preds) all_labels.extend(batch["labels"].cpu().tolist()) report = classification_report( all_labels, all_preds, target_names=["负面", "中性", "正面"], digits=4 ) cm = confusion_matrix(all_labels, all_preds) return report, cm report, cm = evaluate(model, dev_loader, device) print(report) print("混淆矩阵:") print(cm)classification_report会按负面、中性、正面分别给出 precision、recall、F1,我们要重点关注“中性”类别的 F1。根据我的经验,中性总是最难学的,因为“还行”“一般”这种表达本身偏向模糊,标注一致性也差。confusion_matrix的每一行是真实标签、每一列是预测标签,如果发现负面大量被预测成正面,就要回头检查是不是标注规则把“差”和“贵”的极性搞反了。
4. 代码架构与跑通流程:从 zip 到第一个可视化结果
4.1 拿到压缩包后的源码结构:应该有哪些目录和文件
一个完整的方面级情感分析毕设项目压缩包,解压后目录结构通常长这样:
project/ ├── data/ │ ├── raw/ # 原始抓取或收集的评论 │ ├── annotated/ # 标注完整的 CSV / JSONL │ └── split/ # 切分好的 train/dev/test ├── models/ │ ├── __init__.py │ ├── absa_model.py # BERT 模型封装 │ ├── train.py # 训练入口 │ └── predict.py # 预测入口 ├── utils/ │ ├── preprocess.py # 清洗、去重、预标注 │ ├── dataset.py # Dataset 与 DataLoader │ └── metrics.py # 评估指标 ├── checkpoints/ # 训练保存的最优权重 ├── requirements.txt └── README.md这个结构其实不是代码能跑起来的核心,真正核心的是data/split/下的数据文件和models/train.py的训练逻辑。收到学长传的 zip 后,第一时间不是去装环境,而是先解压看requirements.txt和README.md,确认 Python 版本要求。很多项目是在 Python 3.8 下开发的,拿到手里用 3.11 跑,torch版本冲突能折腾你一下午。
4.2 跑通最小流程:环境安装到训练启动
我习惯先用 conda 建一个独立环境,避免把开发机的 Python 环境搞得乱七八糟。下面的命令在 Windows 和 Linux 下通用,只是 CUDA 相关配置略有不同。
conda create -n absa python=3.8 conda activate absa pip install -r requirements.txtrequirements.txt里通常包含这几项,缺什么补什么:
torch>=1.13.0 transformers>=4.28.0 pandas numpy scikit-learn matplotlib tqdm装完依赖,先用一个最小命令试跑训练,确认代码链路没断,再跑完整训练。
python models/train.py \ --train_data data/split/train.csv \ --dev_data data/split/dev.csv \ --epochs 3 \ --batch_size 8 \ --max_len 64 \ --output_dir checkpoints/test_runepochs 3和batch_size 8是用来验证链路的最低配,一套跑通大概几分钟。max_len 64能显著降低显存占用,如果显卡只有 4GB,这个参数是救命稻草。训练结束后,checkpoints/test_run目录下会出现pytorch_model.bin和config.json,这就是以后用来做推理和演示的模型权重。
4.3 predict.py 推理脚本:命令行输入一个句子就出结果
训练流程跑通后,最让答辩老师眼前一亮的是现场输入一句没见过的评论,立刻输出每个方面的情感结果。predict.py的逻辑比train.py简单很多。
import argparse import torch from transformers import BertTokenizer, BertForSequenceClassification MODEL_DIR = "checkpoints/test_run" ASPECTS = ["交通", "门票", "餐饮", "景观", "服务"] def predict(review, aspect): model = BertForSequenceClassification.from_pretrained(MODEL_DIR) tokenizer = BertTokenizer.from_pretrained("bert-base-chinese") enc = tokenizer(review, aspect, return_tensors="pt") model.eval() with torch.no_grad(): logits = model(**enc).logits label_id = torch.argmax(logits, dim=-1).item() return ["负面", "中性", "正面"][label_id] if __name__ == "__main__": parser = argparse.ArgumentParser() parser.add_argument("--review", type=str, required=True) parser.add_argument("--aspect", type=str, choices=ASPECTS, required=True) args = parser.parse_args() result = predict(args.review, args.aspect) print(f"评论:{args.review}") print(f"方面:{args.aspect}") print(f"情感:{result}")python models/predict.py --review "地铁直达景区,就是节假日停车太费劲" --aspect 交通 # 输出:评论:地铁直达景区,就是节假日停车太费劲 # 方面:交通 # 情感:正面这个脚本每次运行都会重新加载模型,如果做批量测试会很慢,但胜在结构清晰,答辩演示时不容易出意外。要批量预测时,可以把模型加载提到全局只做一次,再把预测封装成函数,循环读取 DataFrame 逐行调用即可。另外提醒一句,from_pretrained本地目录只认由save_pretrained保存的目录,别把.bin文件单独丢在目录里就当完事,config.json丢了也会加载失败。
4.4 实验结果记录表:不同超参组合的对比
训练完成后要把在不使用复杂权重组合的情况下跑出的几个结果整理成表格,答辩老师特别吃这一套。常用的记录表格式如下:
| 模型配置 | lr | batch_size | dev acc | dev macro-F1 | 训练耗时 |
|---|---|---|---|---|---|
| LSTM baseline | 1e-3 | 64 | 0.724 | 0.701 | 8 min |
| BERT + max_len 64 | 2e-5 | 16 | 0.812 | 0.789 | 12 min |
| BERT + max_len 128 | 2e-5 | 8 | 0.825 | 0.803 | 25 min |
| BERT + class_weight | 2e-5 | 16 | 0.831 | 0.824 | 12 min |
记录时必须固定随机种子,否则 PyTorch 的随机初始化会让每次训练结果都有波动,对比就不公平。固定方式是在训练脚本开头加一句:
import numpy as np import random import torch def set_seed(seed=42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) set_seed(42)注意公共基准放在train.py的开头,这样每次复现实验才有可比性。深度学习训练确实有些玄学成分,同一个脚本同一天跑两次都可能差一个点,所以多跑几次取平均值的说服力远比单次高。
5. 避坑与常见问题:数据处理、训练收敛和部署的典型翻车现场
5.1 现象:分词后模型效果反而下降
许多学生在跑 BERT 前会习惯性用 jieba 对评论做分词,再把分好词的文本喂给BertTokenizer。结果训练完成后负面类别的 F1 比不做分词还要低 3~5 个点。
原因是 BERT 的中文 tokenizer 本身是按字切分的,预训练阶段就建立在“字”级别上,再切一次词会把“门票”的语义打散成“门”“票”两个字,反而让模型更难捕捉方面词。还有分词错误会直接污染注意力对齐。
解决:BertTokenizer的输入直接传原始文本,任何地方都不做 jieba 分词。传统 LSTM 路线才需要分词,走 BERT 路线就彻底忘掉这个习惯。
5.2 现象:显存 OOM,batch_size 降到 4 都报错
用bert-base-chinese训练,输入max_len=128、batch_size=16,一张 4GB 显存的显卡在第 2 个 epoch 直接 OOM。把batch_size降到 4 仍然报错。
原因:max_len=128会让每个 token 在 12 层 Transformer 里产生大量中间激活值,这些激活值在反向传播前必须保存在显存里,4GB 显存扛不住。
解决:先把max_len改到 64,显存占用大约下降到原来的三分之一;再把batch_size设 8,配合梯度累积凑成等效 32 的 batch。如果还不行,用混合精度训练,torch.cuda.amp能把显存再砍一半。可以沿用下面的梯度累积写法:
accum_steps = 4 for step, batch in enumerate(train_loader): outputs = model(**batch) loss = outputs.loss / accum_steps loss.backward() if (step + 1) % accum_steps == 0: optimizer.step() optimizer.zero_grad()5.3 现象:训练 loss 在下降,dev 的 macro-F1 却一直原地不动
这种情况最容易让人怀疑代码写错了,但实际上是类别分布出了问题。数据切分后,正面样本占 60%,负面占 25%,中性只有 15%。模型一直在学“怎么猜中正面”,因为把每条都预测成正面也能拿到 60% 准确率,而 loss 里中性样本贡献太小,梯度被正面样本淹没。
解决:在CrossEntropyLoss里传入weight参数,按各类别样本量的倒数做归一化。比如负面、中性、正面的比例接近 2 : 1 : 4,可以设置权重[1.0, 2.0, 0.5],放大少数类的梯度贡献。如果调权重后中性 F1 仍然低于 0.5,那就是标注本身问题,中性样本里混了大量“应该拆成两个具体方面”的句子,需要回头改标注。
5.4 现象:模型对“虽然……但是……”这种句式总是全判错
“虽然景色很美,但是票价太高”这句话,模型经常把门票方面也判成正面。原因是基础 BERT 微调对转折关系的捕捉能力有限,尤其是训练语料里这种句式占比不高时。
解决:两方面同时下手。第一,在标注阶段把这类句子里的“虽然”部分和“但是”部分看成一个整体,但确保“但是”后的情感词权重更大,用预标注规则把“但是/可是/不过”后 10 个字内的情感词单独提取,作为补充特征拼接进输入。第二,在训练数据里做数据增强,把已有的反转句式复制一份,把情感标签互换,强制模型学到转折结构。这样处理后,测试集上这类句式的准确率能从 50% 左右提到 75% 以上。
5.5 现象:conda 环境里import torch总提示 CPU 版本,训练慢到怀疑人生
用pip install torch默认装的是 CPU 版,安装日志里能看到pytorch-cpu,训练一个 epoch 要 40 分钟,这时你会很想去网上找“加速教程”,但更靠谱的路子是回头查 GPU 版安装。
解决:先运行nvidia-smi查看自己的 CUDA 版本,再去 PyTorch 官网选择对应版本安装。比如 CUDA 11.8 环境就装:
pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118装完后跑torch.cuda.is_available(),输出True才算成功。还有一步容易被忽略:装完 GPU 版后必须重启 Python 进程,否则旧版本模块还被占着,等于没装。这不算玄学,是 Python 包导入机制的经典坑。
6. 把毕设做成加分项:模型效果验证与多模态扩展
模型训练到 dev macro-F1 在 0.80 左右,已经是一个能拿得出手的毕设成绩,但要想在答辩里显得有思考深度,还要做两件事:效果验证和扩展方向验证。
效果验证最靠谱的做法是做 k 折交叉验证,而不是只跑一次数据切分。把标注好的数据按评论 id 分 5 折,每折都留出独立验证集,跑 5 次取平均值。如果 5 次结果的 macro-F1 标准差超过 0.02,说明模型对某几类样本不稳定,需要检查那几类是哪些方面和情感的组合。
另一个验证技巧是构造对抗样例,专门测试模型的方面区分能力。我习惯准备 10 条含双方面反转句式的手工评论,比如“停车很方便,但门票涨价了”,然后用训练好的模型分别预测交通和门票两个方面,看结果是不是分别给出正面和负面。这个测试能直接暴露模型是否真的学到了方面级推理,还是只学到了整句情感。
多模态情感分析是目前业内相对热门的方向,也是你能在毕设里提出的“未来工作”。景点评论天然带图片,用户发“风景真好”时往往配一张蓝天白云照片。扩展方案不需要重新训练多模态模型,可以把文本情感分析和图片情感分析结果做个加权融合。文本模型仍然用现在的 BERT,图片部分可以用现成的 CLIP 模型对评论配图做零样本情感判断,或者人工标注一部分图片情感极性。最后对比纯文本模型和图文融合模型在验证集上的 macro-F1,如果能稳定提高 1~2 个百分点,这个实验就很有价值。
用 Gradio 搭一个可视化演示页面也很简单,答辩现场输入句子、勾选方面,直接出情感概率:
import gradio as gr def infer(review, aspect): enc = tokenizer(review, aspect, return_tensors="pt") model.eval() with torch.no_grad(): prob = torch.softmax(model(**enc).logits, dim=-1).squeeze() labels = ["负面", "中性", "正面"] return {labels[i]: round(float(prob[i]), 4) for i in range(3)} demo = gr.Interface( fn=infer, inputs=[ gr.Textbox(label="评论文本", value="停车方便但门票太贵"), gr.Radio(["交通", "门票", "餐饮", "景观", "服务"], label="选择方面", value="交通") ], outputs=gr.Label(num_top_classes=3), title="旅游景点方面级情感分析演示" ) demo.launch()这段代码几十行就能跑起一个本地网页服务,答辩现场随手输入“停车方便但门票太贵”,先选交通再选门票,连续展示两个不同预测,比任何 PPT 都直观。
我当年做这个题目时,最大的一次教训是急着搜“毕业设计从 github 抄来”,结果拿到一个庞大但没写 README 的仓库,根本不知道从哪开始跑。后来养成习惯:任何源码包先花 15 分钟把数据格式和训练入口摸清楚,再动环境。这个习惯帮我省了很多无用功,也希望这篇文章把同样的问题意识传递给你。文本模型加上多模态扩展思路,这个选题的完整度和技术纵深足够撑起一场有内容的答辩,希望帮到你。
本文还有配套的精品资源,点击获取