简介:一套完整的微博评论文本分类工程包,基于PyTorch搭建,面向nlp入门学习者与文本情感分析实践者。数据采用ChineseNlpCorpus中的weibo_senti_100k,含119988条带情感标注的微博评论,正负样本各约6万条,类别均衡,可直接用于训练评估。包内共17个文件,以7个py脚本、5个txt说明、2个npz数据文件、1个ckpt权重、1个pkl文件及1个md文档为主,涵盖BiLSTM_Att、TextRCNN、FastText三种模型,并附models目录与saved_dict保存训练结果,压缩包共19.81MB,结构清晰便于查用。目前已有31人学习下载。资源提供完整的数据预处理、训练评估与预测代码,超参数和模型定义集中在各文件内,适合复现实验。其中BiLSTM_Att准确率97.92%,TextRCNN准确率97.87%,FastText准确率97.65%,三种方法分别采用注意力机制、循环网络池化及n-gram特征,既能直接加载权重进行推理,也可在此基础上调参优化。
1. 微博评论文本分类:先洗数据还是先调模型?多数人顺序搞反了
微博评论区大概是中文文本分类里最“脏”的语料:一条“哈哈哈哈哈哈笑死我了”在不同上下文里能读出好几种情绪,一条广告能伪装成普通网友发言,一条负面反馈可能藏在表情包里。做舆情和内容风控的人大多有同感——真正卡住项目的不是选哪种模型,而是数据和标签是否靠谱。标题里的“完整数据和代码”,指的正是一条能照跑的链路:数据字段、清洗规则、标签体系、基线模型、BERT微调、推理落地。这篇文章按这个顺序从头拆到尾,适合手里有评论数据但不知从何下手,或被准确率虚高欺骗过的从业者。
2. 数据先行:先把评论数据集的字段、标签和划分口径定下来
2.1 一份能落地的微博评论数据集:字段、规模与标签体系
一份可以直接开跑的微博评论分类数据集,不只是一张带着文本和标签的表格。我经手过的项目里,字段设计是第一个分水岭:少了关键字段后面补数据很麻烦,多了无关字段又会让人在预处理时犹豫。常见的做法是保留下面这五个字段。
| 字段 | 示例 | 用途 |
|---|---|---|
| comment_id | 882134 | 主键,用于去重和回溯原始数据 |
| user_id | u_10293 | 按用户划分数据集,避免训练测试泄漏 |
| content | 这也太假了吧 | 分类的主要特征来源 |
| created_at | 2023-08-01 12:30:00 | 时间维度划分,舆情项目基本必用 |
| label | 质疑 | 训练目标 |
规模上没有绝对门槛。我见过两三千条标注跑出可用结果的项目,也见过几万条数据因为标签体系混乱而废掉的。实践经验是:二分类任务三千条起步,五分类以上建议准备一万条以上。相比总量,每条样本的标注一致性更重要——这个坑在后面的避坑章节里细说。
标签体系的设计直接决定模型上限。单标签多分类是最常用的形态,先把业务问题拆成互斥的几类,比如“支持、中立、反对”,或者“广告、情绪宣泄、事实陈述、其他”。不要一上来就搞多标签,微博评论本身短,多标签对标注员和模型都不友好。另一个要点是标签必须覆盖住绝大多数样本,如果标注时频繁出现“不知道归哪类”,说明标签体系在设计阶段就没立住。
2.2 预处理流水线:清洗、去重与字段映射
拿到原始数据后,第一步不是写模型,而是做一轮能稳定复现的清洗。微博评论的噪声集中在 URL、@用户、话题词和 HTML 实体这几类。下面这段代码是我习惯的起点,直接把清洗逻辑写成函数,后续对训练集和测试集用同一个函数处理,避免两边规则漂移。
import re import pandas as pd def clean_comment(text: str) -> str: # 去掉 URL,评论里的链接对分类几乎没有贡献 text = re.sub(r"https?://\S+", "", text) # 去掉 @用户,保留“回复”语义的同时去掉用户名本身 text = re.sub(r"@[\w\u4e00-\u9fa5\-]+", "", text) # 去掉 ## 话题,话题词有时是强特征,按任务决定保不保留 text = re.sub(r"#[\u4e00-\u9fa5]+#", "", text) # 压缩连续空白字符 text = re.sub(r"\s+", " ", text).strip() return text df = pd.read_csv("data/train.csv") df["content"] = df["content"].astype(str).apply(clean_comment) # 按文本去重,文本相同的评论只保留一条 df = df.drop_duplicates(subset=["content"])这段代码里有几个取舍要说明。去 URL 和 @用户基本是安全的,但话题词要按业务判断:如果你做的是事件舆情分类,“#某某事件#”本身可能就是最强特征,去掉反而丢掉信息。我一般的习惯是先在清洗前随机读五十条样本,肉眼看一遍噪声长什么样,再决定规则,而不是凭感觉堆正则。去重这一步经常被忽略,微博评论里营销号刷屏会导致同一条文本出现几十次,不去重的话模型会被重复样本带偏。
标签映射建议在清洗之后统一做。原始数据里标签可能是中文、英文缩写或数字编号,先统一成字符串,再映射成从 0 开始的整数索引,最后保存一份label_map.json。训练脚本只认数字标签,但排查 bad case 时一定要能映射回中文。
2.3 数据划分口径:按时间还是按用户,决定了模型上线后的真实表现
微博评论天然带时间属性,舆情事件里评论内容会随时间漂移。如果随机打乱后切分训练集和测试集,相当于默认未来和过去同分布,这在舆情项目里通常不成立。我一般会按时间排序后切分,前 80% 做训练,后 20% 做测试,模拟“用历史预测未来”的真实场景。
df = df.sort_values("created_at") split_idx = int(len(df) * 0.8) train_df = df.iloc[:split_idx] test_df = df.iloc[split_idx:]按时间切分不是唯一选择。如果同一个用户发了多条评论,随机切分可能导致同一用户的评论同时出现在训练集和测试集,模型记住用户 ID 就能拿分,上线后却没见过新用户。这种情况要按user_id分桶,保证同一用户的评论全部落在同一侧。时间切分和用户分桶也可以叠加:先在时间上切一刀,再按用户去重。整个切分过程要在清洗之前还是之后做?我的经验是:先做基础清洗(去 URL、去重),再做划分,最后做任何涉及全量统计的操作。这个顺序能避免不少泄漏问题,第 5 章会展开讲。
3. 线性基线:用 TF-IDF 加逻辑回归跑通第一版分类器
3.1 为什么先跑朴素基线:成本、可解释性和可比性
拿到干净数据后,最常见的冲动是直接上 BERT。但如果跳过基线直接上预训练模型,会失去一个重要的参照系:你不知道模型的提升到底来自语义理解,还是仅仅因为数据量够大、标签够干净。先跑一个 TF-IDF + 逻辑回归的基线,成本极低,CPU 上几分钟就能出结果,同时能验证特征工程、标签设计和数据划分有没有硬伤。
线性基线还有一个价值:可解释。逻辑回归的系数可以按权重排序,直接看到模型学到了哪些词。如果权重最高的词是“哈哈”“转发”“链接”,说明数据清洗没做干净;如果某个类别的强特征词是业务上完全不相关的词,说明标签有噪声。这些信号在黑盒模型里很难直接看到。我自己做文本分类项目的流程里,基线模型的 F1 值就是后续所有模型的起跑线,BERT 跑不过它,问题多半不在模型而在数据。
3.2 特征工程与模型训练:TF-IDF + 逻辑回归最小可跑代码
中文文本分类的特征工程绕不开分词。jieba 在评论这种短文本上表现不稳定,但作为基线工具完全够用。下面这套代码用 Pipeline 把分词、向量化和分类器串在一起,避免在测试阶段忘掉某个预处理步骤。
import joblib import jieba from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.linear_model import LogisticRegression from sklearn.pipeline import Pipeline def tokenize(text: str): # 分词时去掉空白字符 return [w for w in jieba.lcut(text) if w.strip()] pipe = Pipeline([ ("tfidf", TfidfVectorizer( tokenizer=tokenize, max_features=50000, ngram_range=(1, 2) )), ("clf", LogisticRegression( max_iter=1000, class_weight="balanced" )) ]) pipe.fit(train_df["content"], train_df["label"]) joblib.dump(pipe, "baseline.joblib")参数按这个顺序调。max_features控制特征维度,微博评论分词后词表通常在三万到五万之间,设 50000 是一个不太会出错的起点;显存或内存吃紧时降到 20000,效果损失通常很小。ngram_range=(1, 2)把相邻两个词的组合也纳入特征,能捕捉到“不是”“太假”这类否定结构,这是单字词袋做不到的。class_weight="balanced"让模型自动按类别频率加权,对标签不平衡的评论数据几乎是必开项。
max_iter=1000是逻辑回归的收敛迭代上限,默认 100 在特征维度高时有时不收敛,会报 warning。出现这种情况不用慌,加大 iter 数值就行,不会改变模型结构。训练完成后把整个 Pipeline 存成baseline.joblib,后续加载这个文件做推理,不要重新训练。
3.3 评估口径:先看分类报告,再看混淆矩阵
基线的评估不要只盯准确率。微博评论这类数据类别天然不平衡,“转发抽奖”这类评论可能占了大头,模型全预测成这一类就能拿到很高的准确率,但业务上毫无用处。用classification_report看每个类别的精确率、召回率和 F1,比看整体准确率可靠得多。
from sklearn.metrics import classification_report, confusion_matrix y_pred = pipe.predict(test_df["content"]) print(classification_report(test_df["label"], y_pred)) import seaborn as sns import matplotlib.pyplot as plt cm = confusion_matrix(test_df["label"], y_pred) sns.heatmap(cm, annot=True, fmt="d", cmap="Blues") plt.show()读报告时先看每个类别的样本量,再找 F1 最低的类别。F1 最低的类别通常有两类原因:一是该类别的训练样本太少,二是该类别与另一个类别在语言上高度相似,比如“质疑”和“负面情绪”在很多评论里边界模糊。这两种情况解决方式完全不同:前者是采样问题,后者是标签定义问题。混淆矩阵能直观看出模型倾向于把哪些类混淆在一起,如果主要混淆发生在语义确实接近的类别之间,可以接受;如果模型把“广告”和“支持”混淆,说明数据里可能存在系统性标注错误。
4. 把模型换成预训练模型:BERT 微调与推理落地
4.1 为什么 TF-IDF 搞不定微博评论:三个语言现象
TF-IDF 基线能覆盖不少场景,但微博评论里三类语言现象会把它按在地上摩擦。第一是语义等价,表达“不同意”可以说“我反对”“这也太扯了”“你在逗我”,这三句话没有任何共享的词汇,但表达的是同一个意思,TF-IDF 只能把它们当三个独立特征。第二是反讽,“真棒”“太感动了”在特定语境下表达的是完全相反的情绪,词袋模型看不到上下文。第三是谐音和变体,“栓Q”“绝绝子”“yyds”这类网络用语在标注数据里出现频率低,TF-IDF 几乎没有机会学到。
这一小节不是让你直接跳过 TF-IDF,而是说明为什么在基线之上值得再加一层预训练模型。BERT 这类模型通过上下文建模,能把“这也太假了吧”和“这也太扯了吧”映射到相近的语义空间,对短文本尤其有效。代价是训练和推理成本直线上升,这也是为什么它们通常只做第二轮优化,而不是第一版。
4.2 微调训练脚本:从加载模型到保存最优 checkpoint
用预训练模型做中文短文本分类,主流做法是拿一个中文预训练模型做微调。下面的训练脚本以bert-base-chinese为例,用transformers库自带的Trainer封装,省去自己写循环、梯度累积和日志的重复工作。
from datasets import Dataset from transformers import ( AutoTokenizer, AutoModelForSequenceClassification, TrainingArguments, Trainer ) model_name = "bert-base-chinese" num_labels = 3 tokenizer = AutoTokenizer.from_pretrained(model_name) model = AutoModelForSequenceClassification.from_pretrained( model_name, num_labels=num_labels ) # 把 DataFrame 转成 HF Dataset,tokenizer 的 truncation 可以截断长文本 def tokenize_fn(batch): return tokenizer( batch["content"], truncation=True, max_length=64, padding="max_length" ) train_ds = Dataset.from_pandas(train_df).map(tokenize_fn, batched=True) valid_ds = Dataset.from_pandas(valid_df).map(tokenize_fn, batched=True) args = TrainingArguments( output_dir="./checkpoints", learning_rate=2e-5, per_device_train_batch_size=32, per_device_eval_batch_size=64, num_train_epochs=3, warmup_ratio=0.1, logging_steps=50, eval_strategy="epoch", save_strategy="epoch", load_best_model_at_end=True, metric_for_best_model="f1", ) trainer = Trainer( model=model, args=args, train_dataset=train_ds, eval_dataset=valid_ds, ) trainer.train()这段代码里的关键参数可以直接照抄,但要理解每个值为什么这么设。max_length=64对微博评论这种短文本够用,除非你的数据里长评论占比很高。learning_rate从 2e-5 起步,这是预训练模型微调的常见区间,不要按常规深度学习的 1e-3 量级去试,否则大概率发散。warmup_ratio=0.1意思是前 10% 的训练步数里学习率从 0 线性升到目标值,对稳定收敛有帮助。
官方库较新版本里evaluation_strategy改名为eval_strategy,如果你用的版本还在用旧名字,跑起来会有告警。load_best_model_at_end=True配合metric_for_best_model="f1",会在每个 epoch 验证集 F1 提升时保存最优模型,这是防止后期过拟合的后悔药,训练结束后直接从./checkpoints目录加载最好的那一份 checkpoint。
4.3 推理落地:加载 checkpoint 与运行速度的权衡
训练得到的最优模型只是一个权重文件,上线前还需要一个推理脚本来加载它。下面这段代码演示了最轻量的推理方式:用和训练时相同的 tokenizer,把单条文本转成输入张量,前向传播得到 logits。
import torch from transformers import AutoTokenizer, AutoModelForSequenceClassification ckpt_path = "./checkpoints/checkpoint-XXXX" model = AutoModelForSequenceClassification.from_pretrained(ckpt_path) model.eval() tokenizer = AutoTokenizer.from_pretrained("bert-base-chinese") text = "这条评论有点阴阳怪气" inputs = tokenizer(text, return_tensors="pt", truncation=True, max_length=64) with torch.inference_mode(): logits = model(**inputs).logits pred = logits.argmax(dim=-1).item()推理阶段有两点要注意。一是with torch.inference_mode()必须加,不加的话 PyTorch 默认打开梯度追踪,显存占用会高出数倍。二是预测类别用argmax拿到的只是离散的类别号,实际业务里更需要的是概率。如果你想控制“拿不准就不判断”,可以改用torch.softmax(logits, dim=-1)拿到各类别概率,然后设定一个阈值,低于阈值时返回“待人工审核”。这个操作对评论审核场景很实用,后面第 6 章还会回到这个点上。
bert-base-chinese模型本身在 CPU 上推理还算可以接受,但高并发场景下还是建议导出成 ONNX 格式做优化。这一步不是必须的,如果只是做离线舆情分析,每天跑一批数据,直接用现在的 PyTorch 模型就行。
5. 微博评论文本分类避坑:五个翻车现场与排查清单
文本分类项目的坑大多不在模型,而在数据。下面五个问题是我在评论分类项目里真实遇到过、且反复出现的类型,每条按现象、原因、解决的顺序拆开,方便你对号入座。
5.1 分词把“哈哈哈哈哈哈”切碎,特征直接失效
现象:TF-IDF 基线的分类报告里,所有带情绪倾向的评论类别 F1 都很低,bad case 里大量出现连续语气词。 原因:jieba 默认词典把连续出现的“哈”拆成单个字,“哈哈哈哈哈哈”被切成“哈”“哈”“哈”的重复序列,TF-IDF 的 ngram_range 即使取到 (1,2) 也只能看到“哈哈”这种碎片化特征,区分度极低。 解决:在清洗函数里加一条规则,把连续重复的语气词合并成一个标记。
text = re.sub(r"(哈){2,}", "哈_重复", text) text = re.sub(r"(笑死){2,}", "笑死_重复", text)合并之后“哈哈哈哈哈哈”变成“哈_重复”,成为一个稳定的特征词,分类器能真正学到它的含义。这套思路可以扩展:连续重复的“哭”“泪”“赞”等词同样处理。注意不要把所有叠词都合并,“好好好”和“都都都”在不同语境下的语义差别很大,只合并高频语气词更安全。
5.2 标签体系自相矛盾:同一句话不同标注员给出不同标签
现象:模型训练后 loss 不下降,或者验证集 F1 在 0.6 附近徘徊,再怎么调参都上不去。翻看训练数据,发现“这也太假了吧”被标注成了“质疑”,过几条又被标成“负面情绪”。 原因:标签定义没有明确的互斥边界。“质疑”和“负面情绪”在自然语言里天然有交集,标注员只能凭感觉选择,结果就是同一类语义被分散到多个类别里。 解决:在标注规范里加一条决策规则:优先按意图判断,不按情绪判断。例如先问“这句话是否在表达某种诉求”,再问“是否包含事实性描述”,最后才考虑情绪。把不确定的样本单独放一个“无法判断”类别,模型对这个类别的预测置信度通常很低,可以在后处理环节把它当作“待人工审核”的候选。
5.3 类别不平衡被准确率掩盖:负例占 95% 时模型全猜负例也有 0.95 分
现象:分类报告里整体准确率 0.95,但业务要识别的那个类别 recall 是 0,模型什么都没学到。 原因:微博评论里“正常评论”压倒性多于“垃圾评论”,模型只需要把所有样本都预测成多数类就能拿到高准确率,而准确率这个指标掩盖了一切。 解决:第一层在训练时用class_weight="balanced",让少数类样本的 loss 权重加大。第二层在后处理时调阈值:拿到预测概率后,不取 argmax,而是单独为少数类找最优阈值,在验证集上遍历 0.3 到 0.7,选 F1 最高的那个点。
5.4 验证集泄漏:清洗规则偷看了全量数据的统计值
现象:离线验证 F1 做到 0.9,上线后直接掉到 0.6,bad case 大量出现。 原因:清洗或特征处理阶段用了全量数据的统计信息。比如用全量数据统计高频词后过滤低频词,再随机切分训练验证集,验证集已经“见过”高频词统计结果了。更隐蔽的泄漏是按文本去重时,先用全部数据算了一遍重复率再做切分。 解决:严格按“先切分、再清洗”的顺序执行。原始数据先按时间或用户分成 train / valid / test 三份,之后每一份独立清洗。测试集在模型调参和选型完成前,不应该以任何形式参与统计,这是数据科学里的基本纪律,也是最容易被赶进度时省略的一步。
5.5 BERT 截断位置不对:长微博的后半段才是关键信息
现象:BERT 微调后 F1 反而低于 TF-IDF,检查训练数据发现大量样本超长,模型只看了前 64 个 token,而关键的负面词汇全在长文本后半段。 原因:max_length=64对短评够用,但微博评论也有相当比例的长文本。直接从头截断导致后半段信息丢失,而评论类文本的“槽点”往往在最后一句。 解决:先统计训练集文本长度分布,看 95 分位在哪,再设置max_length。如果评论集中在 30 字以内,64 没问题;如果有不少 100 字以上的长评,把max_length提到 128 或 256。另一种做法是把长文本从后往前截断,让模型优先看到结尾部分,但这样会丢开头,不通用。最稳妥的还是统计长度分布后再决定截断策略。
6. 让项目发挥余热:主动学习、弱监督与阈值校准
6.1 用规则捞一批高置信度样本,减少人工标注量
已经跑通的分类器不只是业务工具,它还能反哺数据标注。常见做法是拿当前最好的模型去预测未标注数据,只撷取预测置信度最高的样本让人工确认。模型非常有把握的样本,人工只需扫一眼确认即可;模型置信度中等偏下的样本,才是人工标注的重点。
proba = pipe.predict_proba(unlabeled_df["content"]) max_proba = proba.max(axis=1) low_confidence_idx = max_proba < 0.60.6这个阈值可以按业务资源配置调:确认成本低就放宽到 0.5,预算紧就收紧到 0.7。主动学习的价值在于把有限的人力花在模型最吃不准的样本上,而不是随机抽几千条重标一遍。
6.2 上线前再校准一次阈值,别直接用 argmax 输出
分类模型训练完成后,argmax 给出的只是概率最大的类别,它不关心这个最大概率到底有多高。在评论审核这类场景里,明确区分“有把握的预测”和“拿不准的预测”很重要。我的习惯是拿验证集跑一遍预测,画出每个类别的置信度分布,再按业务容忍度定阈值:某条评论预测为“负面情绪”的概率只有 0.51,直接推给审核人员即可,不要自动处置。
这个项目做到最后,我的体会是:把训练脚本、最优 checkpoint、阈值配置和数据划分逻辑一起打包存档,比单存一个模型文件有用得多。两个月后业务侧反馈某个新事件预测不准,你能快速复现当时的实验过程,而不是对着一个孤立模型文件发呆。这点血泪经验值得每一位做文本分类的人记住,希望帮到你。
本文还有配套的精品资源,点击获取