简介:基于Python实现的电商平台商品评论情感分析与爬虫系统,面向数据采集、文本挖掘和电商运营分析人员,尤其适合需要完成课程设计、毕业设计或入门NLP与网络爬虫的开发者。包体共123个文件,压缩包约55.57MB,核心文件包括7个Python脚本(爬虫调度、情感分析、可视化生成)、37个CSV数据集(含淘宝、京东及中文商品评论样本)、LSTM模型与PT权重文件,以及多张PNG/JPG情感分布图和属性参考图,可用于复现完整项目流程。系统集成分布式多线程采集机制与基于深度学习的情感分类引擎,支持自适应反爬策略、多维情感评分和可视化报表输出;目录中包含数据清洗、模型训练、结果展示等模块,便于按步骤学习和二次开发。目前已有117人学习下载,适合具备Python基础、希望掌握“数据获取—文本处理—模型构建—可视化呈现”全链路的读者直接参考。
1. 先别急着撸代码:一套评论情感分析与爬虫系统到底在解决什么问题
「帮我把这几千条评论跑一遍,看看用户到底在骂什么」——这是我接过最多的一句需求。做过评论处理的人都知道,爬虫只是第一步,真正麻烦的是评论进库之后,十万条文本怎么快速变成「好评多还是差评多」的结论。基于 Python 的电商平台商品评论情感分析与爬虫系统,就是把抓取、清洗、情感分类、可视化串成一条自动化流水线。系统落地后,运营和分析师打开网页就能看到近 30 天正负占比和差评关键词,不需要懂代码。这条路径适合想系统练一遍爬虫、NLP 和数据可视化的 Python 从业者,也适合做竞品口碑监测的运营。目标就一个:让评论从不可搜索的文本,变成可统计、可对比、可告警的数字。
2. 搭建 Python 商品评论爬虫:用 requests + BeautifulSoup 抓可靠数据的最小实现
爬虫模块是最先能写完的,但也是最容易在数据量上去之后返工的部分。我习惯先用 requests 把单条评论的完整链路跑通,从请求到落库,看清楚接口返回什么,再决定要不要上 Scrapy。不然一上来就搭框架,最后往往在调试选择器上花掉一大半时间。
2.1 先别猜接口:用浏览器开发者工具定位评论数据源
现在多数电商商品页的评论是异步接口加载的,直接在商品页 HTML 里找评论,能找到的只有几个星星图案。评论内容通常在 XHR 请求的 JSON 响应里。我的做法:打开商品详情页,按 F12 进入开发者工具,切到 Network 面板,筛选 XHR,然后点击页面上的「商品评价」标签,让它发起一次请求。在请求列表里找名字带 comment 或者 review 的项,点开看 Response,如果看到一段 JSON 里包含评论内容,这就是评论数据源。
不要凭记忆猜接口地址。电商接口的 URL、参数名、返回字段调整得很频繁,今天能用的地址过两周可能就失效。所以第一步永远是「看浏览器实际请求了什么」。常见的返回结构是这样,真实环境里字段名和层级会有差异,以你抓到的为准:
{ "commentList": [ { "id": "123456", "content": "物流很快,质量不错", "score": 5, "creationTime": "2024-05-01 12:00:00" } ], "maxPage": 10 }这个 JSON 结构的意义在于:commentList 是当前页的评论数组,maxPage 是总页数。翻页循环就是不断把 page 参数加 1,直到超过 maxPage。字段 id 可以用来去重,content 是后续情感分析的输入,score 是用户打的星级,creationTime 是评论时间。拿到这几个字段,爬虫的数据模型已经成型,不需要再去解析嵌套 HTML。
2.2 requests 采集循环:翻页、限速、落盘
定位到接口之后,用 requests 写采集循环最快。下面是我常用的抓取函数,它按页翻动、控制请求间隔,并把每条评论整理成字典逐条 yield 出来:
import requests import time def fetch_comments(product_id, start_page=0, end_page=5, page_size=10, sleep_seconds=1.5): """翻页抓取某商品的评论,逐条 yield 字典。""" url = "https://club.jd.com/comment/productPageComments.action" params = { "productId": product_id, "score": 0, # 0=全部,1=差评,2=中评,3=好评,5=追评 "sortType": 5, # 5=默认排序,6=按时间排序 "page": 0, "pageSize": page_size, "isShadowSku": 0, "fold": 1, } headers = { "User-Agent": ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/120.0 Safari/537.36"), "Referer": f"https://item.jd.com/{product_id}.html", } for page in range(start_page, end_page): params["page"] = page resp = requests.get(url, params=params, headers=headers, timeout=10) if resp.status_code != 200: break data = resp.json() for comment in data.get("commentList", []): yield { "id": comment.get("id"), "content": comment.get("content"), "score": comment.get("score"), "creation_time": comment.get("creationTime"), "product_id": product_id, } if page >= data.get("maxPage", 0): break time.sleep(sleep_seconds)这段代码的关键点有三个。第一个是请求头:User-Agent 用来告诉服务端你是一个常见浏览器的版本,Referer 表示这个请求是从商品详情页发起的,很多接口会校验这个字段,漏了容易出现空返回。第二个是翻页终止条件,每次请求后拿当前页和 maxPage 比较,页数够了就退出,避免做无意义的空请求。第三个是 sleep_seconds,我一般设置在 1 到 2 秒,单线程抓几千条评论大概半小时,虽然不算快,但能把触发风控的概率压住。评论量小的时候,慢就是快。
抓下来的数据要落盘。纯 CSV 在数据量小的时候没问题,但你有几十个商品、每次增量抓取的场景下,CSV 的去重、断点续采都很别扭。我更推荐直接上 SQLite,单文件、零部署,一张表就够:
import sqlite3 def init_db(db_path="reviews.db"): """初始化评论表,以评论 id 为主键。""" conn = sqlite3.connect(db_path) conn.execute(""" CREATE TABLE IF NOT EXISTS reviews ( id TEXT PRIMARY KEY, content TEXT, score INTEGER, creation_time TEXT, product_id TEXT, crawled_at TEXT DEFAULT (datetime('now')) ) """) conn.commit() return conn def save_many(conn, rows): """批量写入,重复评论自动忽略。""" conn.executemany( """ INSERT OR IGNORE INTO reviews (id, content, score, creation_time, product_id) VALUES (?, ?, ?, ?, ?) """, [(r["id"], r["content"], r["score"], r["creation_time"], r["product_id"]) for r in rows], ) conn.commit()INSERT OR IGNORE 是这里最实用的一个点:电商评论 id 是唯一的,重复抓取时直接跳过,不需要先查一遍库再去重。批量写入用 executemany,每攒 50 条提交一次事务,比逐条 commit 快很多,也能避免程序中断时丢太多数据。主循环写起来就很简单:
conn = init_db() batch = [] for review in fetch_comments("100012043978", end_page=20): batch.append(review) if len(batch) >= 50: save_many(conn, batch) batch.clear()这里 product_id 先用一个商品做验证,跑通后再换成真实商品列表。batch 每满 50 条写一次,既能控制内存,也让数据库文件始终处于接近最新的状态。如果你在终端里看到某几页返回的 commentList 为空,但 maxPage 又没变小,多半是触发了风控,把 sleep_seconds 调大,等一会儿再继续。
2.3 多商品批量抓取与断点续采:为什么我放弃纯 CSV
单个商品验证通过后,批量抓取只是在外面再套一层循环。但这里有个容易忽略的问题:requests 是同步请求,串行抓几十个商品会非常慢。常见做法是先保持串行把数据链路跑稳,确认接口没有潜在问题,再决定是否改成 Scrapy 或使用并发请求。我一般是在单商品超过 5 万条评论、或者商品数量超过 20 个之后才换框架,在此之前 requests 完全够用。
CSV 在这个阶段暴露出的问题是并发和断点。多个脚本同时往一个 CSV 写,会出现行错位;抓取到一半崩溃,下次重跑无法知道哪些评论已经入库。SQLite 的主键约束天然解决了这两个问题,甚至你可以直接「不分页重抓」,反正 INSERT OR IGNORE 会把已经存在的评论扔掉。这就是断点续采的朴素实现,不需要额外维护抓取进度文件。
到这一步,评论数据已经躺在数据库里了,接下来要回答的是另一个问题:这些文本到底是在夸还是在骂。
3. 评论情感分析怎么做:从 SnowNLP 基线到 BERT 微调的两条路
爬虫解决的是「有没有数据」,情感分析解决的是「数据在表达什么」。我见过不少系统在爬虫上做得挺重,到了情感分析却只用一个第三方库直接打分,结果运营拿到的报表和真实口碑对不上。所以这一章多说一点方法和选型。
3.1 清洗与标签设计:把评论变成模型能吃的样子
评论文本不是干净语料。「京东物流很快」里的平台词、「质量不错,就是有点小」里的转折、还有广告评论、重复评论,都会干扰模型。清洗不是为了把句子变短,而是把和情感无关的噪声去掉。下面这组规则是我常用的起点:
import re def clean_comment(text): """基础清洗:去 HTML 标签、平台词、控制字符,并合并空白。""" text = re.sub(r"<[^>]+>", "", text) # HTML 标签 text = re.sub(r"京东超市|京东物流|自营", "", text) # 平台专有词 text = re.sub(r"[\x00-\x1f\x7f]", "", text) # 控制字符 text = re.sub(r"\s+", " ", text) # 连续空白 return text.strip()清洗规则要克制。过度清洗会损失口语信息,比如把「物流」删掉,可能会让「物流太慢了」这种句子失去主体,模型反而更难判断。平台词要不要删,取决于你的分析目标:如果只是判断正负向,保留也没关系;如果要做「物流」「质量」「价格」的细粒度情感,平台词反而要保留。清洗之后要做的第二件事是去重和过滤:评论 id 去重,内容长度小于 2 的删除,连续重复 3 遍以上的纯广告评论直接扔掉。这些操作在 pandas 里几行就能完成:
import pandas as pd df = pd.read_sql("SELECT * FROM reviews", conn) df["clean"] = df["content"].map(clean_comment) df = df.drop_duplicates(subset=["id"]) df = df[df["clean"].str.len() >= 2]接下来是标签设计。电商评论自带星级 score,这是天然的弱标签。我一般先把四星、五星映射成正向,三星映射成中性,一星、二星映射成负向。这样在不做任何人工标注的情况下,就能凑出一批训练数据。但必须知道这是弱标签:有人给三星是因为物流慢,内容本身可能是在夸产品;也有人给五星但写的是「一般」。所以后面一定要抽样本做人工验证,不能把弱标签直接当金标准。
3.2 先用 SnowNLP 跑通基线:30 分钟看到第一个可用结果
在没有标注数据的时候,SnowNLP 是最快能出结果的方案。它是一个纯 Python 的中文 NLP 库,内置的模型对电商购物评论有过训练,所以拿来给商品评论打分,比直接跑通用情感词典要靠谱一点。用法非常简单:
from snownlp import SnowNLP def snownlp_score(text): """返回 0~1 的情感倾向值,越接近 1 越正向。""" return SnowNLP(text).sentiments但这里有两个坎。第一,它返回的是连续值,没有给你分类阈值,需要自己试。第二,snownlp 内部是个贝叶斯模型,语料训练好后就不能再改,遇到新词、讽刺句、网络流行语,它的判断会显得很「钝」。所以我只把它当基线:先对库里几千条未标注评论打分,按分数从低到高排序,把最低 100 条和最高 100 条拉出来人工扫一眼。如果低分段确实都是差评、高分段确实都是好评,说明数据质量还行;如果低分段里混着「便宜没好货」「太便宜了反而担心」这种句子,就说明基线模型对这类表达不敏感,后面需要上更强的模型。
要做一个能批量预测、能保存、能返回概率的分类器,我一般用 TF-IDF 加逻辑回归。这套组合在几万条短文本上能快速跑出一个和 SnowNLP 持平甚至更好的效果,而且完全可控。下面是训练代码:
from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.linear_model import LogisticRegression from sklearn.pipeline import make_pipeline from sklearn.model_selection import train_test_split df["label"] = df["score"].map(lambda s: 2 if s >= 4 else (1 if s == 3 else 0)) X_train, X_test, y_train, y_test = train_test_split( df["clean"], df["label"], test_size=0.2, random_state=42, stratify=df["label"], ) model = make_pipeline( TfidfVectorizer(max_features=50000, ngram_range=(1, 2)), LogisticRegression(C=1.0, max_iter=1000), ) model.fit(X_train, y_train) print(model.score(X_test, y_test))stratify=df["label"] 是分层采样,保证训练集和测试集里三类评论的比例和全量一致,避免测试集里全是好评,导致准确率虚高。TfidfVectorizer 的关键参数是 ngram_range=(1,2),它会把「不」「不错」「不太好」这样的相邻词也作为特征,对否定表达有基本的识别能力。max_features 限制特征个数,防止短文本稀疏矩阵过大。逻辑回归的 C 是正则化强度,默认 1.0 在评论分类上通常不需要调。这套模型训练完,用 joblib.dump(model, "baseline.joblib") 保存,后面在接口里加载即可。
基线模型的准确率一般在 70% 到 80% 之间,瓶颈在于它看不到上下文。比如「虽然便宜但是太难用了」,TF-IDF 会同时抓到「便宜」和「难用」两个信号,模型很难判断哪个才是重点。这种转折句式,要靠预训练模型解决。
3.3 换 BERT 微调:把准确率从 70% 拉到 85% 以上的训练配置
BERT 这类中文预训练模型能理解上下文里「虽然…但是…」的转折,所以是评论情感分析升级的主流选择。用 transformers 做微调,核心工作是把评论转成模型输入,然后按标准 Trainer 流程训练。下面是一个可跑通的三分类训练代码,模型使用 bert-base-chinese,这个模型对中文短文本的通用理解能力够用,不需要一开始就上更大的模型。
import torch from transformers import ( BertTokenizer, BertForSequenceClassification, Trainer, TrainingArguments, ) from torch.utils.data import Dataset class ReviewDataset(Dataset): def __init__(self, texts, labels, tokenizer, max_len=128): self.texts = texts self.labels = labels self.tokenizer = tokenizer self.max_len = max_len def __len__(self): return len(self.texts) def __getitem__(self, idx): enc = self.tokenizer( self.texts[idx], truncation=True, padding="max_length", max_length=self.max_len, return_tensors="pt", ) return { "input_ids": enc["input_ids"].squeeze(0), "attention_mask": enc["attention_mask"].squeeze(0), "labels": torch.tensor(self.labels[idx], dtype=torch.long), } tokenizer = BertTokenizer.from_pretrained("bert-base-chinese") model = BertForSequenceClassification.from_pretrained( "bert-base-chinese", num_labels=3) train_ds = ReviewDataset(X_train.tolist(), y_train.tolist(), tokenizer) eval_ds = ReviewDataset(X_test.tolist(), y_test.tolist(), tokenizer) training_args = TrainingArguments( output_dir="./checkpoints", num_train_epochs=3, per_device_train_batch_size=16, per_device_eval_batch_size=32, learning_rate=2e-5, evaluation_strategy="epoch", save_total_limit=2, logging_steps=100, seed=42, ) trainer = Trainer( model=model, args=training_args, train_dataset=train_ds, eval_dataset=eval_ds, ) trainer.train() model.save_pretrained("./checkpoints") tokenizer.save_pretrained("./checkpoints")这段代码里,ReviewDataset 负责把每条评论编码成 input_ids 和 attention_mask。truncation=True 表示超长截断,max_length=128 意味着只保留前 128 个 token,大部分人评论不超过这个长度;padding="max_length" 把同一批样本都补齐到相同长度,方便矩阵运算。max_length 这个值很关键,设太大显存涨得快,设太小长评论信息丢得多,128 在电商评论上是性价比很高的起点。
训练参数方面,几个值得记录的经验。learning_rate=2e-5 是 BERT 微调最常见的区间,设 1e-4 以上容易让预训练权重被冲毁。num_train_epochs=3 在评论这种几万条数据上够了,跑 5 轮以上过拟合明显,观察 eval loss 不再下降就该停。per_device_train_batch_size 受显存约束,16G 显存跑 bert-base-chinese 用 16 基本是上限,如果 OOM 就降到 8,同时把 learning_rate 降到 1.5e-5 左右,效果不会差太多。如果你的机器没有 GPU,也可以把 device 设成 cpu 跑几百条样本验证链路,但全量训练还是建议至少一张 8G 以上显存的卡。
到这里,训练好的 BERT checkpoint 已经保存到 ./checkpoints。下一步就是把它封装成接口,接到可视化系统上。
4. 把情感分析结果变成可视化系统:Flask 接口 + ECharts 看板
模型只在训练脚本里跑没有价值,要做成系统就得提供接口和数据看板。这一章用一个 Flask 服务把训练好的模型暴露成 HTTP 接口,再用一份简单的 ECharts 页面把统计结果画出来,最后补一个定时任务,让系统可以自己增量更新。
4.1 推理接口:Flask 加载 BERT checkpoint
BERT 推理服务的核心问题是模型加载时机。最忌讳的是每个请求都 from_pretrained 一次,那样磁盘 IO 和显存申请会把服务拖垮。正确做法是在 Flask 进程启动时加载一次,之后所有请求复用。下面是一个最小可用的 predict 接口:
import torch from flask import Flask, request, jsonify from transformers import BertTokenizer, BertForSequenceClassification app = Flask(__name__) checkpoint = "./checkpoints" # 3.3 节保存的模型目录 tokenizer = BertTokenizer.from_pretrained(checkpoint) model = BertForSequenceClassification.from_pretrained(checkpoint) model.eval() @app.route("/predict", methods=["POST"]) def predict(): text = request.json.get("text", "") enc = tokenizer( text, truncation=True, max_length=128, padding="max_length", return_tensors="pt", ) with torch.no_grad(): logits = model(**enc).logits probs = torch.softmax(logits, dim=1)[0] label = int(probs.argmax()) confidence = float(probs[label]) return jsonify({"label": label, "confidence": confidence}) if __name__ == "__main__": app.run(host="0.0.0.0", port=8000)这个接口用 POST 接收一个 text 字段,返回预测的 label(0 负向,1 中性,2 正向)和该类别概率。torch.no_grad() 必须加上,否则 PyTorch 会保存中间梯度图,几万次请求之后内存越涨越高。model.eval() 同样重要,它关掉 dropout 和 batchnorm 的训练逻辑,让输出变得确定。如果你的系统还要用前面训练的 TF-IDF 基线,可以用 joblib 加载 pipeline,接口结构一模一样,只是把 bert 部分换成 model.predict_proba。
如果要给一批新增评论打分,不要循环请求 HTTP 接口。Flask 接口适合低频率调用,批量任务直接在脚本里加载模型,用 batch_encode_plus 一次编码几百条再 forward,速度是循环请求的几十倍。这也是我通常把预测逻辑从 Flask 里拆出去、单独放一个 predict_batch.py 的原因。
4.2 数据聚合接口与 ECharts 看板:让运营自己看趋势
predict 接口只能给单条评论打分,对运营没用。真正的看板要展示聚合结果:情感占比、近 30 天趋势、差评关键词。我一般在 Flask 里再写一个 /summary 接口,从已经打好标签的评论表里聚合数据:
@app.route("/summary") def summary(): df = pd.read_sql("SELECT creation_time, predict_label FROM reviews", conn) df["creation_time"] = pd.to_datetime(df["creation_time"], errors="coerce") stats = df["predict_label"].value_counts().to_dict() trend = ( df.set_index("creation_time") .resample("W")["predict_label"] .mean() .round(3) .to_dict() ) return jsonify({ "stats": { "negative": stats.get(0, 0), "neutral": stats.get(1, 0), "positive": stats.get(2, 0) }, "trend": {k.strftime("%Y-%m-%d"): v for k, v in trend.items()}, })这里的 conn 就是第 2 章 init_db 返回的数据库连接,predict_label 是定时任务写好预测结果后的字段。resample("W") 是按周聚合,计算每周评论情感倾向的均值,均值越接近 2 说明这一周口碑越好,越接近 0 则越差。这比直接画每天的数据更能看出趋势,不会被日粒度波动干扰。这个接口返回的数据,前端用 ECharts 两行代码就能画出来:
<div id="pie" style="width: 600px; height: 400px"></div> <script> fetch("/summary") .then(r => r.json()) .then(data => { const chart = echarts.init(document.getElementById("pie")); chart.setOption({ series: [{ type: "pie", data: [ { name: "负向", value: data.stats.negative }, { name: "中性", value: data.stats.neutral }, { name: "正向", value: data.stats.positive } ] }] }); }); </script>这套组合就是最朴素的 Python 爬虫可视化界面:Flask 负责数据接口,ECharts 负责图形渲染,前后端职责清晰。运营打开页面,看到的不再是一堆 CSV 文件,而是占比饼图和趋势折线图。如果你不想写前端,也可以换成 Streamlit,pandas 直接聚合后 plot 就能出图,适合内部工具;但要给更多人用,Flask 加 ECharts 的可控性更好。
4.3 定时增量更新:用 APScheduler 让系统自己长数据
评论是持续产生的,系统不能只跑一次。常见做法是用 APScheduler 在 Flask 进程里挂一个定时任务:每天凌晨抓取新增评论,跑一遍情感预测入库。这样 /summary 接口的数据会自动更新,无需人工干预:
from apscheduler.schedulers.blocking import BlockingScheduler def daily_job(): # 每天凌晨执行的完整链路 # crawl_new_reviews() : 增量抓取新评论 # predict_new_rows() : 对未打标的评论调用模型 # rebuild_summary_cache(): 刷新聚合结果 pass scheduler = BlockingScheduler(timezone="Asia/Shanghai") scheduler.add_job(daily_job, "cron", hour=2, minute=30) scheduler.start()这里不建议每天重训 BERT。增量表现在每天可能只有几百条新评论,用这些数据重训一次模型成本高且收益小。更稳妥的节奏是:每天增量预测入库,每周或每月用近 30 天的新增评论做一次微调,平时只跑推理。APScheduler 的 cron 触发器写法容易理解,hour=2、minute=30 表示每天凌晨 2 点 30 分执行。如果你的服务器上不方便常驻 Python 进程,也可以用系统 crontab 直接调用脚本:
0 2 * * * cd /opt/review-system && python3 crawl_and_predict.py这一章从接口到看板再到定时任务,系统已经能自己运转。但运转不等于可靠,下面这些坑如果不提前处理,上线第一天就会翻车。
5. 避坑与排查:电商评论从爬取到情感分析最容易翻车的 5 个问题
5.1 评论翻到一半突然全是空数据或滑块验证码
现象:前几页抓得好好的,到某个页码开始,commentList 返回空数组、maxPage 却仍然很大,或者响应直接变成一段验证码 HTML。
原因:请求频率超过服务端阈值,当前 IP 被临时限流;或者同一 IP 在短时间内连续抓多个商品,触发了风控。另一个常见原因是 Cookie 过期,登录态失效后评论接口不再返回增量数据。
解决:先停下来,把 sleep_seconds 从 1.5 提高到 3 到 5,等 10 分钟再继续。如果还是空,检查 headers 里的 User-Agent 和 Referer 是否完整。不要试图硬刚验证码,停一段时间往往就恢复了。出现这种情况时,应该在代码里记录「本次抓取实际获得多少条」,和上次对比,如果到某页突然为 0,立即中止任务而不是把空结果当成正常结束。
5.2 四星到底算好评还是中评:弱标签和真实情感不一致
现象:模型训练完,在测试集上准确率 78%,但人工抽查发现一些明显偏负面的评论被分到正向。
原因:score 标签不等于真实情感。很多用户习惯给三星、四星但评论内容仍然正面,也有给五星却抱怨快递的。直接用 score 当 label 会造成标签噪声,模型学到的边界和真实情感有偏差。
解决:不要直接用 4 星以上作为正向。先把 score 映射成弱标签跑一版,然后抽 200 条做人工标注,计算弱标签和人工标注的一致率。如果一致率低于 80%,再考虑两个办法:一是把四星单独放进中性,让三分类更接近用户真实表达;二是对不一致样本做清洗,把「给三星但内容正向」之类的典型样本挑出来单独看,必要时修正标签。弱标签只用来预筛,最终模型还是要靠一小撮人工标注对齐。
5.3 SnowNLP 把「质量不错」判成负向
现象:单条测试时,SnowNLP 对「质量不错,物美价廉」给分只有 0.3,明显是负向倾向。
原因:这个库内置训练语料偏向某种特定业务场景,对部分常见词的判断和电商语境不一致,而且模型不可控,你没法往里面追加新语料。
解决:把 SnowNLP 仅当基线,不做正式输出。要正式用,换成 TF-IDF 加逻辑回归或者 BERT 微调。这类问题也提醒你:任何情感分析模型上线前,都要准备一个 30 到 50 条的人工样例集,里面刻意放一些「不错」「还行」「一般般」这类容易翻车的表达,跑一遍看结果能不能接受。
5.4 长评论被截断、短评论信息不足,预测结果不稳定
现象:同一句话稍作修改,比如在开头加一句「总体还行」,模型预测就从负向跳到正向,长评论的后半段信息完全没被利用。
原因:BERT 的 max_length 默认 128,超过部分被直接截掉;短评论本身只有几个词,模型缺乏上下文,很容易被一个语气词带偏。
解决:对长评论,不要一刀切截断,而是先用句号、感叹号拆句,对每句单独预测,再把句子分数按长度加权汇总。对短评,可以在接口里做规则兜底:命中「太差」「垃圾」「差评」等强负面词的直接判负,命中「完美」「超值」「回购」的判正。规则不解决根本问题,但能把边角案例兜住,让报表看起来更符合直觉。
5.5 BERT 微调显存不足或训练 loss 不下降
现象:训练刚开始就报 CUDA out of memory;或者跑起来之后 loss 一直不降,甚至训练集 acc 都不涨。
原因:显存不足通常是 batch size 和 max_length 同时设太大。loss 不降则要分几种情况:学习率设得过大导致权重更新异常;标签顺序和模型输出不对应,比如 num_labels=3 但数据里有超出范围的值;或者清洗阶段把大量文本变成了空字符串,输入到模型里全是一样的 padding。
解决:先清空代码里的 padding 干扰,打印 X_train 里长度为 0 的样本数量,空文本直接过滤。显存不够先降到 batch_size=8、max_length=64 跑通一次,确认链路无误再逐步加。观察 eval loss,如果训练 2 个 epoch 后还在原地,检查标签分布是否严重失衡,以及 train_test_split 有没有用 stratify。BERT 训练出问题时,第一件要查的是输入数据,不是模型结构。
6. 用人工标注的 50 条评论验证情感分析效果:评估指标与阈值校准
模型训练完不是终点,我习惯在交付前留一道「检查关」:不告诉模型,随机抽 50 条评论人工打标,然后用分类报告看它到底几斤几两。
6.1 抽样与人工标注的规则
这 50 条不能纯随机抽。纯随机在好评占九成的评论场景里,抽出来 45 条都是好评,验证结果没有参考价值。我一般按 score 分层:差评、中评、好评各抽 15 到 20 条,保证三类都有代表。标注时只给 label 选项,不给模型预测结果,避免锚定效应。标注规则也简单:贬义词明确是负向,明确表扬是正向,说不出倾向就算中性。对标注人不要求是 NLP 专家,但前后标准要一致。
6.2 用 F1 校准阈值,别只看准确率
人工标注完成后,对 50 条样本计算预测结果和人工结果。用 sklearn 一张报告就能看全:
from sklearn.metrics import classification_report y_true = [0, 2, 1, ...] # 人工标注 y_pred = [0, 2, 2, ...] # 模型预测 print(classification_report(y_true, y_pred, target_names=["负", "中", "正"]))重点看中性的 F1。电商评论里中性样本最少,模型最容易把中性分到两侧,F1 低于 0.5 是常事。如果中性 F1 实在上不去,可以把三分类退化成二分类,只区分正负向,把中性当低置信度处理。
如果模型对某条预测的正向概率只有 0.55,我通常不直接输出「正向」,而是打上「待人工复核」。线上系统里高置信度输出、低置信度标记,比强迫模型给出一个立场更可靠:
def safe_predict(probs): max_prob = max(probs) if max_prob < 0.6: return "待人工复核" return ["负", "中", "正"][probs.index(max_prob)]这套验证流程我每换一个数据源都会先做一遍。现在的习惯是:不管用什么模型,先把抽样标注跑完,再谈上线。做完文本情感分析之后,如果你想进一步处理评论里的晒图或视频,就进入多模态情感分析的范畴了,需要把图像、文本特征融合起来,那是另一个值得投入的方向,但先把文本基线做扎实,扩展才有意义。希望帮到你。
本文还有配套的精品资源,点击获取