简介:这是一套面向自然语言处理初学者与文本分类实战者的微博评论文本分类完整资源,从数据预处理、模型训练到评估形成闭环,基于Python 3.6与PyTorch 1.6实现。数据采用ChineseNlpCorpus中的weibo_senti_100k数据集,含119,988条情感标注评论,正负样本均衡,文本被划分为负向、正向两类;覆盖BiLSTM+Attention、TextRCNN、FastText三种经典模型,实测准确率分别达97.92%、97.87%、97.65%,所有模型与超参定义均放在models目录,并附有对应训练结果的下载链接。压缩包共17个文件,以Python脚本为主(7个py文件),涵盖模型定义、训练评估、fasttext工具等模块,并给出训练入口与辅助函数;另有5个txt文本、2个npz数据文件、1个ckpt权重、1个pkl文件及1个md说明文档,整体大小19.81MB,目录结构清晰,便于对照源码逐模块复现实验。目前已有32人浏览学习,适合希望快速上手中文情感分类、比较不同网络结构或直接使用训练结果的读者下载使用。
1. 微博评论文本分类:为什么通用分类器在短文本上集体翻车
做文本分类的同行应该都有体会:拿新闻语料调出来的模型,放到微博评论上,准确率掉十个点都不稀奇。微博评论单条平均不到 30 个字,夹杂表情、话题标签、@用户和网络黑话,分词一拆就散,特征稀疏到让常规分类器无从下手。这篇笔记围绕“微博评论文本分类”这个任务,从数据清洗、特征表示、模型选型到训练调参,给你一条能直接跑通的路。适合刚接触短文本分类的工程师,也适合被线上效果折磨过、想系统性排查问题的老手。我不打算铺开讲理论,重点放在能复现的代码和参数上——毕竟这类任务,数据没处理好,模型再花哨也是白搭。
2. 先处理数据再谈模型:微博评论的清洗与标注策略
2.1 微博评论的噪声构成:表情、话题、@用户与超链接
微博评论不是干净的纯文本。一条典型评论长这样:#垃圾分类# 支持![赞]@环保先锋 转发这条 https://t.cn/xxxx。这里有三类噪声需要处理:话题标签(#...#)带主题信息但分词时会被切成奇怪片段,@用户对分类任务几乎无贡献,超链接是纯干扰项。表情符号更麻烦——[赞]、[泪]这类方括号表情在情感分类里其实是强信号,不能一刀切删掉。
我的处理顺序是:先去掉超链接和 @用户,再决定话题标签和表情的去留。话题标签如果和分类标签强相关(比如“#垃圾分类#”大概率指向环保类),可以单独拆出来作为一列特征,而不是混进正文。表情则统一替换为特殊 token,比如[赞]替换为EMOJI_THUMB_UP,这样既保留了情感信号,又避免分词器把它拆得七零八落。
import re def clean_weibo_comment(text: str) -> str: # 去除超链接 text = re.sub(r'https?://\S+|www\.\S+', '', text) # 去除 @用户 text = re.sub(r'@[\w\u4e00-\u9fa5\-]+', '', text) # 话题标签拆出:记录标签内容,正文中替换为特殊标记 topics = re.findall(r'#([^#]+)#', text) text = re.sub(r'#([^#]+)#', ' TOPIC_TOKEN ', text) # 方括号表情替换为语义占位符 emoji_map = {'[赞]': 'EMOJI_GOOD', '[泪]': 'EMOJI_SAD', '[哈哈]': 'EMOJI_HAPPY'} for k, v in emoji_map.items(): text = text.replace(k, f' {v} ') # 连续空格压缩 text = re.sub(r'\s+', ' ', text).strip() return text, topics这段代码的关键决策是把话题标签和表情转成 token 而非删除。实际项目中我踩过坑:最初把所有非中文字符全部剔除,结果模型把“支持[赞]”和“支持”当成完全相同的输入,但前者往往带更强的正面情绪。保留 token 后,模型能学到EMOJI_GOOD这类特征对分类的贡献。topics 单独返回,后续可以拼接到特征向量里。
2.2 分词与去停用词:短文本场景下不是越干净越好
微博评论分词推荐用 jieba 的搜索引擎模式或自定义词典。普通模式对网络新词召回太差,“集美”“yyds”“绝绝子”这类词会被切碎。做法是维护一个自定义词典文件weibo_dict.txt,每行一个词加词频和词性,比如:
集美 10 n yyds 10 n 绝绝子 10 a加载方式很简单:jieba.load_userdict('weibo_dict.txt')。去停用词要谨慎——通用停用词表里包含“不”“没”“太”这类否定词和程度词,直接删掉会反转情感极性。“这部电影不太好”删掉“不”就变成“太好”。我一般只去掉标点和单字语气词,“的、了、啊、吧”可以删,否定词和程度副词必须保留。
import jieba stopwords = set() with open('stopwords.txt', 'r', encoding='utf-8') as f: for line in f: stopwords.add(line.strip()) negations = {'不', '没', '别', '莫', '无', '非'} def tokenize(text: str): words = jieba.lcut(text) kept = [] for w in words: w = w.strip() if not w or w in stopwords: continue if w in negations: kept.append(w) elif len(w) == 1 and w not in negations: continue else: kept.append(w) return kept这里有个细节:单字词不一定都要删。比如“妙”“惨”“刚”在评论里可能是独立的情感表达,但为了控制特征维度,我倾向于删掉非否定类的单字。词性标注在短文本上不太可靠,不如直接按长度和停用词表过滤。分词结果可以缓存成 pickle 或 parquet,避免每次训练重复分词——评论数据量上了百万条,分词是很耗时的一步。
2.3 标注策略:主动学习比一次性人工标注更划算
微博评论分类的标注成本比长文本高得多。一条 20 字的评论,标注员要结合上下文才能判断“这也能拍?”是嘲讽还是赞扬。常见做法是先用规则或预训练模型做粗标注,再由人工修正。这里推荐一个流程:采样 5000 条评论,用关键词规则(正负面词表)打出 pseudo-label,人工只修正模型置信度在 0.4 到 0.8 之间的样本。这个区间是规则模型最不确定的区域,修正它们收益最高。
如果算力允许,用一个小号的 BERT 模型做粗标注效果会更好,但注意标注一致性——同一语义的评论不能一会儿标正一会儿标负。实际操作中我会准备一份标注规范文档,明确“反讽”“阴阳怪气”怎么处理。这类样本边界模糊,模型很难学,规范明确了才能保证标注质量。另外,每条评论至少让两个人标注,用 Cohen's Kappa 算一致性,低于 0.7 说明标注规范有问题,需要回头改规范而不是继续标。
3. 从 TF-IDF 到词向量:特征表示与模型选型
3.1 为什么在短文本上 TF-IDF 依然有竞争力
微博评论转成 TF-IDF 向量后极度稀疏——平均有效词数不到 10 个,向量维度却可能上万。这种稀疏性让线性模型反而占优,因为非线性模型(比如带隐藏层的神经网络)在这种高维稀疏输入上容易过拟合。我做过对比实验:在 5 万条评论、4 分类任务上,TF-IDF + 线性 SVM 的 F1 是 0.82,而 TF-IDF + 两隐藏层 MLP 只有 0.79。数据量不大时,传统特征加线性模型是性价比最高的起点。
另一个原因是可解释性。TF-IDF 的 top 权重词可以直接打印出来检查:“垃圾”“服了”“差评”这类词权重高,说明模型学到的是合理模式。BERT 这类模型在短文本上的优势要等数据量到 20 万条以上才明显。我给你的建议是:第一个版本永远跑 TF-IDF + 线性模型,拿到基线再考虑深度模型。基线模型还能作为后续升级的对照,防止深度模型调参调到比基线还差。
特征构建时注意:TF-IDF 的 min_df 在短文本场景下要设得很低,比如 min_df=1。因为每个词只在少数几条评论里出现,min_df 调高了特征就没了。但 max_features 要控制,我一般取 10 万以内,超出部分让哈希技巧处理。3.2 Word2Vec 与预训练模型的选择:数据量决定下限
微博评论用 Word2Vec 做词嵌入有个好处:可以直接用大规模微博语料预训练好的向量,比如腾讯 AI Lab 的中文词向量。这些向量覆盖了“集美”“YYDS”等网络词,比自己用小语料训练效果稳定。但要注意,Word2Vec 是静态向量,“苹果”这个词在手机话题和水果话题下是同一个向量,遇到一词多义就没办法了。
BERT 系模型(如哈工大的 RoBERTa-wwm-ext)在微博评论上的优势是动态上下文,劣势是推理速度。线上 QPS 要求高的话,BERT 很难扛住。折中方案是蒸馏后的 TinyBERT 或 AlBERT,效果接近大模型,速度能快 3 到 5 倍。我的选择逻辑很简单:离线分析用 BERT,线上实时分类用 TF-IDF + 线性模型或蒸馏小模型。文本分类这种任务,精度差两个点往往不如成本省一半来得实在。
3.3 分类模型选型:xgboost、FastText、TextRNN 的边界在哪里
三类模型在微博评论上的表现差异明显。xgboost擅长处理 TF-IDF 这类手工特征,能自动捕捉特征交叉,但对 10 万维稀疏输入的训练时间不友好,需要调tree_method和max_depth。FastText本质是词向量取平均加线性分类,训练速度极快,在短文本上效果出奇好,适合快速验证。TextRNN(BiLSTM + Attention)能捕捉词序信息,“虽然…但是…”这类转折结构对情感分类很关键,但训练时间长,小数据上容易过拟合。
我给的默认方案是双轨并行:先用 xgboost 跑 TF-IDF 特征,再用 FastText 跑原始文本。两个模型预测结果做加权融合,权重根据验证集调。如果融合后 F1 提升不到 0.01,说明两个模型相关性太高,融合没有意义——这时候该去看错误样本,而不是继续堆模型。
# xgboost 处理稀疏 TF-IDF 特征的配置要点 import xgboost as xgb dtrain = xgb.DMatrix(X_train_tfidf, label=y_train) params = { 'objective': 'multi:softprob', 'num_class': 4, 'max_depth': 6, 'eta': 0.05, 'subsample': 0.8, 'colsample_bytree': 0.6, 'min_child_weight': 5, 'tree_method': 'hist', # 稀疏矩阵用 hist 比 exact 快很多 } bst = xgb.train(params, dtrain, num_boost_round=300, evals=[(dtrain, 'train')])tree_method='hist'是稀疏高维特征的关键参数,默认的exact算法在 10 万维特征上会慢到难以接受。min_child_weight=5防止模型在稀疏特征上过度分裂。colsample_bytree=0.6减少特征采样,抑制过拟合。如果训练日志显示 train AUC 持续上涨而 eval 不涨,优先调低eta并增加num_boost_round,而不是加树的数量。
4. 训练与调参:跑通一个能对比的最小闭环
4.1 数据划分:时间切分比随机切分更接近线上
微博评论分类最容易犯的错误是随机切分训练集和测试集。评论数据有强烈的时间分布特性——某段时间的热点事件会产生大量相似表达。随机切分会把同一热点下的评论同时分到训练集和测试集,测试分数虚高。正确做法是按时间切分:取前 80% 时间的评论做训练,后 20% 做测试。
import pandas as pd df = pd.read_csv('weibo_comments.csv', parse_dates=['created_at']) df = df.sort_values('created_at') cut_idx = int(len(df) * 0.8) train_df = df.iloc[:cut_idx] test_df = df.iloc[cut_idx:] # 注意:测试集里的热点事件,训练集完全没见过,这才贴近线上这种切分方式暴露的问题是:模型对未见过的热点事件表达理解有限。如果测试集 F1 比随机切分低 0.05 以上,说明模型对热点敏感,需要增加领域自适应策略,比如在训练数据里做热点增强——把历史热点评论复制几份混进训练集。时间切分还有一个好处:观察模型在不同时间段的表现波动,能定位到具体是哪个热点事件导致效果下降。
4.2 FastText 与 PyTorch 版 TextRNN 的训练代码
FastText 用官方库训练非常简单,但转换成自己的代码要费点功夫。我用 PyTorch 重写了一个简化版,既支持 GPU 训练,也能看清楚内部结构——官方库的黑匣子排查问题很麻烦。
import torch import torch.nn as nn import torch.optim as optim class FastText(nn.Module): def __init__(self, vocab_size, embed_dim, num_classes): super().__init__() self.embedding = nn.Embedding(vocab_size, embed_dim) self.fc = nn.Linear(embed_dim, num_classes) def forward(self, x): # x: (batch, seq_len) emb = self.embedding(x) # (batch, seq_len, embed_dim) pooled = emb.mean(dim=1) # 平均池化 return self.fc(pooled) model = FastText(vocab_size=len(vocab), embed_dim=100, num_classes=4) criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=0.001)FastText 的池化层很简单但有效。有条件的项目可以升级用 TF-IDF 加权平均替代简单平均——高频非信息词(“哈哈”“真的”)的权重被压低,语义更准确。TextRNN 则在 FastText 基础上加一层 BiLSTM,能捕捉“虽然不怎么样但是值得看”这类转折结构。
class TextRNN(nn.Module): def __init__(self, vocab_size, embed_dim, hidden_size, num_classes): super().__init__() self.embedding = nn.Embedding(vocab_size, embed_dim) self.lstm = nn.LSTM(embed_dim, hidden_size, batch_first=True, bidirectional=True) self.attention = nn.Linear(hidden_size * 2, 1) self.fc = nn.Linear(hidden_size * 2, num_classes) def forward(self, x): emb = self.embedding(x) lstm_out, _ = self.lstm(emb) # (batch, seq_len, hidden*2) attn_weights = torch.softmax(self.attention(lstm_out), dim=1) attn_applied = torch.sum(attn_weights * lstm_out, dim=1) return self.fc(attn_applied)BiLSTM 输出拼接后过 attention,能定位到评论里最关键的几个词。对微博评论,这个机制很有用——模型把注意力放在“垃圾”“绝了”这类强情感词上,分类准确率显著提升。
4.3 类别不平衡处理:class weight 比过采样更省事
微博评论的类别分布天然倾斜。比如“色情广告”类别可能只占 0.5%,“正常评论”占 60%。直接用 CrossEntropyLoss 会让模型把所有目标都预测成多数类。我的做法是给每个类别赋予权重,少数类权重高、多数类权重低。
from sklearn.utils.class_weight import compute_class_weight class_weights = compute_class_weight('balanced', classes=np.array([0,1,2,3]), y=y_train) class_weights_tensor = torch.tensor(class_weights, dtype=torch.float32).to(device) criterion = nn.CrossEntropyLoss(weight=class_weights_tensor)compute_class_weight会自动计算:每个类别的权重 = 总样本数 / (类别数 × 该类别样本数)。这比手动调权重复现性好。如果加了 class weight 还是偏向多数类,再考虑 Focal Loss 或过采样。不过对 4 分类任务,class weight 能解决 90% 的不平衡问题。注意:用 class weight 时要监控验证集 F1 而不是 Accuracy——Accuracy 会被多数类主导,少数类的提升容易被掩盖。
4.4 训练循环里的三个稳定技巧
训练时的稳定性直接影响最终效果。第一个技巧是学习率预热:前 5 个 epoch 把学习率从 0 线性升到目标值,避免模型在开局就震荡。第二个是梯度裁剪:nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0),微博评论短文本的梯度范数很容易爆炸,裁剪后训练曲线平滑很多。第三个是早停判定:监控验证集 loss,连续 3 个 epoch 不下降就停止训练,保存最佳状态。
# 早停 + 学习率预热的核心逻辑片段 best_loss = float('inf') patience = 0 for epoch in range(30): model.train() for batch_x, batch_y in dataloader_train: if epoch < 5: lr = base_lr * (epoch + 1) / 5 # 预热 optimizer.param_groups[0]['lr'] = lr optimizer.zero_grad() outputs = model(batch_x) loss = criterion(outputs, batch_y) loss.backward() nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step() val_loss = evaluate(model, dataloader_val) if val_loss < best_loss: best_loss = val_loss torch.save(model.state_dict(), 'best_model.pt') patience = 0 else: patience += 1 if patience >= 3: breakclip_grad_norm_的 max_norm=1.0 对大多数文本分类任务都够。设置太大等于没设,太小会拖慢收敛。另外,保存模型时不要只存权重,还要存 vocab 和类别映射——我见过项目丢失 vocab 导致模型不能复用的糟心事。
5. 微博评论分类避坑指南:5 个真实踩坑记录
5.1 验证集准确率 92%,线上效果却惨不忍睹
现象:离线测试 F1 有 0.90,上线后实际效果只有 0.70。排查发现训练集里的“双十一”促销评论和测试集高度重合——随机切分把同一波活动的评论分到了两侧,模型背下了活动模板而非通用语义。原因就是数据划分时没有做时间隔离,模型学到的其实是活动期间的表达模式。解决:改成时间切分后 F1 掉到 0.83,但线上表现稳定匹配离线预估。这个教训之后,我做任何文本分类都会先按时间维度看一眼数据分布,再决定切分方式。
5.2 表情符号让模型产生“玄学”误判
现象:模型把“游戏真好玩[泪]”预测成负面。原因:[泪]在大多数语境下确实表达负面情绪,但游戏语境里“好玩到哭”是正面表达。分词器不能区分这两种“泪”的含义。解决:我在清洗阶段把表情替换成 token,并且把表情 token 和相邻词联合统计——在特征工程里加入 “(好玩, EMOJI_SAD)” 这样的二元组合特征,模型就能学到这种反常规搭配。数据量大时,这个模式可以让模型自己学,但数据量小时必须靠特征组合强拆出来。
5.3 xgboost 特征数量爆炸导致训练卡死
现象:用了sklearn.feature_extraction.text.TfidfVectorizer的默认 ngram_range=(1,2),特征维度到 300 万,xgboost 训练一晚上都没出结果。原因:短文本的二元词对组合数量极其庞大,大部分组合只出现一两次,是纯粹的噪声。解决:把 ngram_range 改成 (1,1),特征维度降到 20 万,训练时间从数小时缩短到十分钟,F1 甚至略有提升——二元特征在微博评论里带来的噪声远大于信号,除非你有足够多的数据让它“见多识广”。
5.4 训练 loss 完全不下降,卡在初始值附近
现象:FastText 训练时 loss 在 1.3 左右震荡,这个值正好等于 4 分类随机预测的交叉熵。原因:学习率太大导致梯度更新方向来回震荡,或者学习率太小导致更新幅度可以忽略。解决:把学习率从默认的 0.001 调至 0.0001,loss 开始稳步下降。另一个常见问题是 Embedding 层初始化不当——如果 embedding 全部初始化为 0,梯度会恒为 0。PyTorch 默认初始化没问题,但加载预训练 embedding 时如果忘了requires_grad=True,模型也会原地踏步。
5.5 少数类全被预测成多数类
现象:色情广告类别(占比 0.5%)的 F1 为 0,所有样本都被预测成正常评论。原因:加了 class weight 但验证时看的是 Accuracy,整体 Accuracy 依然很高,模型觉得“全预测成多数类”就足够好了。解决:评估指标换成 F1-macro,并且对少数类单独打印混淆矩阵。class weight 虽然生效,但少数类样本量太少,模型学习的梯度信号仍然微弱。更有效的做法是额外给少数类做数据增强——把原始评论里的字随机替换成同音字或拼音,生成更多变体。数据增强后该类别 F1 从 0 涨到 0.45,算是能用了。
6. 验证与上线:守住模型下限的最后一公里
6.1 留存测试集与评估口径:别让指标骗了你
线上模型和离线模型的最大差异在于数据分布漂移。微博的热点事件每天都在变,“某明星塌房”当天的评论表达方式,和三天后完全不一样。我维护一份固定留存的测试集,从三个月前开始每月采样一次,这个测试集不进训练流程,只在模型更新时跑一次。这样能直观看到模型对时间漂移的耐受度。
from sklearn.metrics import classification_report y_pred = model.predict(X_test) print(classification_report(y_test, y_pred, target_names=['正常', '负面', '广告', '色情']))重点是看macro avg的 F1,而非加权 F1。加权 F1 会被占比 60% 的正常评论拉高,少数类的恶化根本看不出来。季度留存测试集的评估报告我会保留每次的结果做对比——如果某次召回率整体下降 5 个百分点,那就说明线上新出现了一种模型没见过的表达范式,需要补充标注数据了。
6.2 模型热更新策略:新增阈值开关与反馈闭环
模型的最后一公里是快速失效检测和热更新通道。我的做法是给每个类别设置置信度阈值,低于阈值的预测一律丢弃,进入人工审核队列。这批人工审核结果回流到训练集,形成反馈闭环。这比定期重训模型靠谱得多——热点事件发酵期,模型几小时就失效,等不到周更。
def predict_with_threshold(model, text, thresholds): probas = model.predict_proba([text])[0] cls_id = probas.argmax() if probas[cls_id] < thresholds[cls_id]: return -1 # 进入人工队列 return cls_idthreshholds 的取值依赖验证集上的置信度分布。我会取每个类别验证集预测概率的 25% 分位数作为基准,再人工微调——设置过严会大量丢样本,过松则挡不住误判。这个阈值也需要和业务方对齐,可接受的人工审核量决定了阈值的具体位置。
说点我的习惯。每次训练完,我都会打印一份预测错误的样本列表,亲自读一遍。模型不会说话,但错误样本会——它们会说“训练数据里没有这个表达”,或者说“这个词表里根本没有”。认真检查错误样本,调整清洗逻辑和特征组合,远比换个更复杂的模型靠谱。希望你在这个方向上也少走几步弯路,先从基线模型跑通闭环,再一步步精调,这条路最稳也最快。希望帮到你。
本文还有配套的精品资源,点击获取