微博评论文本分类实战:从数据清洗到textCNN完整实现

发布时间:2026/10/1 18:30:48
微博评论文本分类实战:从数据清洗到textCNN完整实现 简介面向中文微博情感分析入门者与NLPer这套资源提供基于PyTorch的评论文本分类完整实现涵盖BiLSTMAttention、TextRCNN、FastText三种典型模型在119988条新浪微博正负评论数据上分别达到97.92%、97.87%、97.65%的准确率。包内共17个文件以7个Python脚本为主含模型定义、训练评估、工具函数配合训练好的模型参数ckpt、词汇表与词向量npz/pkl、说明文档md/txt等压缩包仅19.81MB便于直接加载运行或二次开发。资源结构清晰模型超参与定义同文件存放还附有训练结果下载链接方便复现与对比。目前已有31人学习下载适合希望快速上手文本分类、研究注意力机制或池化结构差异的初学者。1. 微博评论文本分类完整数据与代码解决的不只是“训一个模型”做文本分类的人最容易卡住的不是模型选型而是从零攒一套能跑通的数据与代码。微博评论文本分类完整数据和代码这个标题点破的就是这个痛点拿一份标注好的评论语料配上从清洗到建模的全套可执行代码让分类任务从可复现开始。这套方案适合刚入门的 NLP 工程师、做舆情分析的数据分析师以及想参加文本分类比赛的学生。有了完整数据与代码才能真正评估哪些参数在微博短评论上有效哪些只是理论自洽而不是把时间和预算浪费在玄学调参上。2. 微博评论数据准备从原始语料到可训练样本的完整流程2.1 数据格式、标注规范与划分边界一份能直接做监督学习的数据集常见做法是整理成 CSV 文件至少包含 id、text、label 三列。id 最好是微博 ID 或评论 IDtext 是评论文本原样label 是类别整数或字符串。标注体系一般分两类情感三分类负面、中性、正面和话题/意图多分类娱乐、体育、财经、政务投诉等通常 5 到 10 个类。我见过不少初学者拿到数据后直接按行随机切分训练集和验证集这在短文本评论场景下是第一个坑——同一条微博下的评论区文本高度相似随机切分会让模型在验证集上分数虚高这一点在第 5 章会展开。数据量方面小型语料建议至少 2 万条。如果类别超过 5 个记下每个类别的样本量最少的类也不要低于 2000 条否则宏平均 F1 会被少数类拉着大幅波动。拿到原始 CSV 后第一步不是分词也不是建模而是先打印类别分布和文本长度分布快速判断数据是否干净、有没有重复行、有没有空文本。用一行 pandas 代码就能完成不需要可视化也能发现问题。2.2 清洗规则URL、用户、话题标签的顺序不能乱微博评论的预处理和新闻语料差别很大评论里有大量短链、用户 和 #话题#。清洗顺序错了后面的正则表达式可能误伤正文。下面这个清洗函数是常见做法注释里标明了每一步的意图import re def clean_weibo_comment(text: str) - str: # 去掉网页短链微博评论里常带 http 开头的跳转链接 text re.sub(rhttp\S|https\S, , text) # 去掉 用户 的引用用户昵称对分类没有意义 text re.sub(r[\u4e00-\u9fa5\w\-], , text) # 去掉话题标签两端的 #但保留话题内容 text re.sub(r#(.?)#, r\1, text) # 合并连续空白和换行 text re.sub(r\s, , text).strip() return text逻辑说明先处理 URL 再处理话题是因为链接可能包含 # 字符如果先执行话题正则会把 URL 的一部分截取出来用户 的昵称可能包含下划线、汉字和数字所以字符集里加了\w\-。话题标签里的内容往往携带事件关键词比如“#上海暴雨#”直接告诉模型这条评论在讨论什么事件所以要保留正文内容、只去掉两侧的井号。参数说明如果你的任务是对评论是否含举报信息做二分类链接本身是强信号可以替换成URL占位符而不是删除如果是情感分类链接没有情感倾向删除即可。占位符方式可以防止词表里出现大量不同 URL 文本避免产生稀疏特征。2.3 表情符号处理删除还是映射成占位 token微博评论几乎是表情符号的重灾区很多教程把表情和特殊符号一起正则删除但“哈哈[赞]”和“哈哈[怒]”的情感完全不同简单删除会抹掉强情感信号。我一般会把方括号表情映射成一个英文占位 token让模型把它当作普通词来学。emoji_map { [赞]: praise , [怒]: angry , [笑cry]: laugh_cry , [泪]: tear , [拜拜]: bye , [白眼]: roll_eyes , [吐]: puke , } def replace_emoji(text: str) - str: for k, v in emoji_map.items(): text text.replace(k, v) return text逻辑说明映射成英文单词是为了避免中文分词器把[赞]切碎。jieba 对带方括号的词处理不稳定[赞]有时被切成[、赞、]三个 token嵌入层学到的是无意义的字形片段。英文占位符在词表里是独立 token而且标签注释读起来直观。映射数量不必太多覆盖出现频率最高的 20 个表情就够冷门表情直接删除。参数说明这里用字符串替换而不是正则因为方括号在正则里需要转义直接遍历字典更安全。数据量大时可以先 build 一个正则替换函数用re.sub带回调函数批量替换避免 for 循环逐条 replace 的开销。2.4 繁体转简体、去重与空文本检查港澳台用户的评论会带来繁体字问题。繁体字和简体字在词表里是两套字符如果不统一同一个词会被建成两个特征稀释模型学到的语义。用 opencc 处理最方便from opencc import OpenCC cc OpenCC(t2s) df[text] df[text].map(lambda x: cc.convert(x))逻辑说明OpenCC 的t2s是繁体转简体比简单的 Unicode 码位映射更准确因为它基于词组上下文转换能正确处理“頭髮”和“發財”都转成“发”的不同义项场景。空文本检查很容易被忽略。清洗后可能会有不少评论只剩空白例如本来就是纯链接或纯 用户的评论。这类样本要么删除要么单独设一个“无效评论”类别。推荐删除但要在删除前打印占比如果超过 5%说明原始清洗规则过于激进应该放宽保留条件。2.5 分层划分数据并记录类别分布样本切分使用 sklearn 的 train_test_split 并设置 stratify 参数import pandas as pd from sklearn.model_selection import train_test_split df pd.read_csv(weibo_comments.csv, encodingutf-8) df df.drop_duplicates(subset[text]) train, temp train_test_split( df, test_size0.2, stratifydf[label], random_state42 ) val, test train_test_split( temp, test_size0.5, stratifytemp[label], random_state42 ) print(train size:, len(train), val size:, len(val), test size:, len(test)) print(train[label].value_counts(normalizeTrue))逻辑说明stratifydf[label]保证切分后每个集合内各类别比例与原数据一致避免验证集刚好缺少某个少数类把该类 F1 记成 0。drop_duplicates按评论文本去重防止爬虫重复抓取导致同一文本同时出现在训练和验证集里。random_state42固定下来后续模型调参时对比结果才公平。出现类别不平衡时先不急着过采样或欠采样记录下分布比例建模阶段在损失函数里设置类别权重或使用class_weight。重采样放在模型调不动之后再尝试因为过采样容易放大噪声。3. 特征工程与经典基线TF-IDF 调好参数也能打赢九成深度学习方案3.1 中文分词与自定义词典微博口语词不能拆错微博评论分词比新闻难在口语词和网络用语占比高。比如“绝绝子”“yyds”“破防了”这类词通用 jieba 词典很可能拆碎。常见做法是自己维护一个微博用户词典把高频网络用语、明星名、品牌名收进去避免分词器把它切成碎片。import jieba jieba.load_userdict(weibo_userdict.txt) stopwords set() with open(stopwords.txt, r, encodingutf-8) as f: for line in f: w line.strip() if w: stopwords.add(w) def tokenize(text: str) - list: words jieba.lcut(text) return [w for w in words if w not in stopwords and w.strip()]逻辑说明load_userdict的每一行格式是“词 词频 词性”词频可以不写但写了能提升优先识别度比如yyds 1000 n会被优先识别成单个词。tokenize返回过滤掉停用词后的词列表直接传给TfidfVectorizer的tokenizer参数就省去了先分词再拼回字符串的中间格式转换。参数说明停用词表要针对微博评论。通用表一般会删除“这”“那”“了”这类功能词没问题但如果把“笑死”“无语”“绝了”这种情绪词也删掉情感分类会直接掉 2 到 3 个点的 F1。判断标准很简单某个词在几乎所有类别里都高频出现才值得加入停用表。3.2 TF-IDF 的三个关键参数min_df、ngram_range、max_featuresTF-IDF 在微博短文本上依然稳固因为评论短、词表噪声大TF-IDF 的稀疏表示能把关键词权重突出。但TfidfVectorizer默认参数直接跑往往效果一般三个参数值得反复调from sklearn.feature_extraction.text import TfidfVectorizer tfidf TfidfVectorizer( tokenizertokenize, min_df5, max_df0.8, ngram_range(1, 2), max_features50000, sublinear_tfTrue ) X_train tfidf.fit_transform(train[text]) X_val tfidf.transform(val[text])参数说明min_df5表示一个词至少在 5 条评论里出现过才保留小于这个值的词大多是打字错误或个人化表述留着只会让特征矩阵膨胀。max_df0.8过滤掉出现率超过 80% 的词“哈哈哈”“的”“了”这类几乎每条都有的词没有区分度。ngram_range(1,2)保留单词和双词组合bigram 能抓住“不好”“不看好”这种局部否定trigram 在短文本上会让特征过于稀疏一般不建议。max_features50000是对高维特征的硬性截断。微博评论分词后词表可能到十几万但大部分低频词对分类没用限制在 5 万以内既能控制内存也能强迫模型聚焦高频稳定词。sublinear_tfTrue把 TF 值做对数缩放避免一条长评论里重复出现某个词时权重被过度放大。3.3 逻辑回归与朴素贝叶斯做基线先验证数据再谈深度学习完成 TF-IDF 特征后先训练两个经典模型作为基线。这一步不是为了拿高分而是确认数据清洗后没有引入明显问题。逻辑回归在稀疏高维特征上表现稳定朴素贝叶斯在短文本上常常出人意料地好。from sklearn.linear_model import LogisticRegression from sklearn.naive_bayes import MultinomialNB from sklearn.metrics import classification_report clf_lr LogisticRegression(max_iter200, C1.0, class_weightbalanced) clf_lr.fit(X_train, train[label]) pred_lr clf_lr.predict(X_val) print(Logistic Regression:) print(classification_report(val[label], pred_lr, digits3)) clf_nb MultinomialNB(alpha0.1) clf_nb.fit(X_train, train[label]) pred_nb clf_nb.predict(X_val) print(Multinomial NB:) print(classification_report(val[label], pred_nb, digits3))逻辑说明classification_report会打印每个类别的精确率、召回率、F1 和宏平均这是判断数据是否有问题的第一份体检报告。如果某个类别的召回率异常低比如低于 0.5先检查这个类别的样本量和标注一致性而不是急着换模型。class_weightbalanced让逻辑回归根据类别频率自动加权少数类的误分类代价变大对不平衡数据有效。参数说明逻辑回归的C1.0是默认值实际应用建议在 0.1、1.0、10 三档里扫一遍C 越小正则越强稀疏高维特征上推荐从 0.1 起步。MultinomialNB的alpha0.1是平滑系数越接近 0 越依赖原始词频alpha 太大比如 1.0会让所有特征权重趋向均匀短文本上效果反而变差。3.4 基线的验收标准与下一步决策如果你做的是三分类情感任务逻辑回归在验证集上的宏平均 F1 应该到 0.70 以上朴素贝叶斯通常有 0.68 到 0.72 的水平。如果低于 0.6别急着上深度学习先检查预处理清洗后文本是否空掉、标签是否错位、是否有大量重复数据。排除数据问题后再用小范围网格搜索扫一遍参数。一个有用的实践TF-IDF 特征加逻辑回归的推理速度极快适合做线上兜底模型深度学习模型负责离线精确预测两者可以共存。很多人一上来就训 BERT忽略了线性基线的价值结果模型效果有提升却说不清提升来自数据还是模型排查问题时两眼一抹黑。4. 深度学习文本分类用 textCNN 把微博评论的局部语义抓出来4.1 模型选型短文本场景为什么 textCNN 比 LSTM 更合适微博评论长度集中在 5 到 50 字分类信号来自几个关键词和短语的组合比如“笑死”“太离谱”“支持”。textCNN 的原理是用多个卷积核并行扫描文本窗口等价于同时提取 2-gram、3-gram、4-gram 的特征再通过 Max Pooling 挑选最强响应。整个过程适合 GPU 并行训练一个 2 万样本的评论分类模型只需要几分钟。LSTM 的优势在于长距离依赖但评论短到 50 字以内出现“虽然这里很糟糕但是我还是喜欢”这种转折句式的次数有限。LSTM 反向传播的梯度路径长小样本下更容易过拟合或训练不稳定。我通常只在评论平均长度超过 100 字的任务里考虑 BiLSTM短文本场景默认 textCNN 起步。4.2 词表构建与嵌入层初始化训练 textCNN 前要建立从词到整数 ID 的映射表。这里的min_count与上一章 TF-IDF 里的min_df是同一个思路低频词直接映射到 unk避免词表膨胀。from collections import Counter def build_vocab(tokenized_texts, min_count2, max_vocab50000): counter Counter() for words in tokenized_texts: counter.update(words) vocab {pad: 0, unk: 1} for word, cnt in counter.most_common(max_vocab): if cnt min_count: vocab[word] len(vocab) return vocab train_tokens train[text].map(tokenize).tolist() vocab build_vocab(train_tokens) print(vocab size:, len(vocab))逻辑说明min_count2意味着出现次数只有 1 的词全部归入 unk。这类词大概率是错别字或用户个人化表达专门为每个低频词分配一个嵌入向量没有意义反而是过拟合的来源。max_vocab50000与上一章 TF-IDF 的max_features保持一致方便在不同方案的参数量之间对比。嵌入层用nn.Embedding随机初始化是常见做法不依赖外部词向量。如果语料足够大比如 10 万条以上可以考虑在训练集上先跑 word2vec 预训练嵌入但 2 万条评论训练出来的词向量质量不高反而可能不如随机初始化后随模型一起优化。随机初始化加上padding_idx0让 padding token 的向量恒为 0不参与梯度更新。4.3 TextCNN 网络结构详解与超参数设置模型结构参考 Yoon Kim 的经典 textCNN过滤器尺寸覆盖 bigram 到 4-gram。代码如下import torch import torch.nn as nn class TextCNN(nn.Module): def __init__(self, vocab_size, embed_dim100, num_filters128, filter_sizes(2, 3, 4), num_classes3, dropout0.5): super().__init__() self.embedding nn.Embedding(vocab_size, embed_dim, padding_idx0) self.convs nn.ModuleList([ nn.Conv1d(embed_dim, num_filters, kernel_sizesize) for size in filter_sizes ]) self.fc nn.Linear(num_filters * len(filter_sizes), num_classes) self.dropout nn.Dropout(dropout) def forward(self, x): emb self.embedding(x) emb emb.transpose(1, 2) convs [] for conv in self.convs: out torch.relu(conv(emb)) out out.max(dim2).values convs.append(out) out torch.cat(convs, dim1) out self.dropout(out) return self.fc(out)参数说明embed_dim100是中文短文本任务的常规选择再往上增加对 F1 的边际收益很低但参数量明显增加。num_filters128是每个卷积核的输出通道数128 是一个安全值如果显存吃紧减到 64 损失很小。filter_sizes(2,3,4)分别对应窗口大小为 2、3、4 的过滤器覆盖相邻词与词之间的短语模式。dropout0.5是文本分类常用正则防止全连接层过拟合。代码里使用全局最大池化。Max Pooling 取每个特征图的最大值等价于“这个短语出现了就触发信号”非常适合长度极短的微博评论。如果把max换成mean关键词的强度会被稀释验证集 F1 通常会掉 1 到 2 个点。4.4 数据加载与训练循环早停和宏平均 F1 监控是底线训练时最需要注意的是验证指标。用准确率监控不平衡数据会掩盖少数类的崩溃推荐只记录宏平均 F1并按它做早停from torch.utils.data import DataLoader, Dataset from sklearn.metrics import f1_score class CommentDataset(Dataset): def __init__(self, texts, labels, vocab, max_len50): self.texts texts self.labels labels self.vocab vocab self.max_len max_len def __len__(self): return len(self.texts) def __getitem__(self, idx): words tokenize(self.texts[idx])[:self.max_len] ids [self.vocab.get(w, 1) for w in words] ids [0] * (self.max_len - len(ids)) return torch.tensor(ids), torch.tensor(self.labels[idx]) train_ds CommentDataset(train[text].tolist(), train[label].tolist(), vocab) val_ds CommentDataset(val[text].tolist(), val[label].tolist(), vocab) train_loader DataLoader(train_ds, batch_size64, shuffleTrue) val_loader DataLoader(val_ds, batch_size128, shuffleFalse) def evaluate_f1(model, loader): model.eval() preds, trues [], [] with torch.no_grad(): for x, y in loader: out model(x) preds.extend(out.argmax(dim1).tolist()) trues.extend(y.tolist()) return f1_score(trues, preds, averagemacro) def run_training(model, train_loader, val_loader, epochs20): optimizer torch.optim.Adam(model.parameters(), lr1e-3) criterion torch.nn.CrossEntropyLoss() best_f1, best_weights 0, None patience 0 for epoch in range(epochs): model.train() total_loss 0 for x, y in train_loader: optimizer.zero_grad() out model(x) loss criterion(out, y) loss.backward() optimizer.step() total_loss loss.item() val_f1 evaluate_f1(model, val_loader) print(fepoch {epoch1}: loss {total_loss/len(train_loader):.4f}, val f1 {val_f1:.4f}) if val_f1 best_f1: best_f1 val_f1 best_weights model.state_dict() patience 0 else: patience 1 if patience 5: break model.load_state_dict(best_weights) return model逻辑说明max_len50截断评论后补齐到定长。微博评论超过 50 字的比例不大截断损失的信息可忽略。损失函数用交叉熵类别不平衡时在CrossEntropyLoss里传weight参数给少数类加权。早停条件设为验证集宏平均 F1 连续 5 个 epoch 不增长就回滚到最佳权重。不要用训练 loss 做早停的依据它在过拟合之后依然会下降。参数说明Adam 学习率1e-3在这个任务上不容易翻车。如果换了大 batch 或加深网络学习率相应降到3e-4。batch_size64是短文本分类的常见起点太大收敛不稳定太小训练慢且梯度噪点大。4.5 训练结果阅读先看少数类单类召回再看总体指标训练结束后打印分类报告特别关注每个类别的单类召回率。如果某个类别召回率明显低比如负向情感只有 0.4可能是正负样本比例问题也可能是第 2 章的表情符号被清洗掉了。此时回头检查清洗流程比继续堆模型更有效。textCNN 并不是终点但它是验证数据和代码链路是否正确的最快路径。5. 微博评论分类避坑指南五个最容易翻车的环节5.1 按行切分数据导致信息泄漏验证集虚高之后线上打回原形现象训练时验证集宏平均 F1 达到 0.80上线后线上指标只有 0.65 左右。回查验证集错误样本发现很多“高正确率”其实是数据泄漏带来的假象。原因微博评论按行随机切分时同一条热门微博下的多条评论被拆开部分进训练集、部分进验证集。这些评论在话题词上高度相似模型在训练集里记住了事件特有的词在验证集遇到同类事件时直接命中。这就是事件或主题泄漏。解决按微博 id 分组切分数据保证同一条微博的所有评论只落在一个集合里from sklearn.model_selection import GroupShuffleSplit gss GroupShuffleSplit(n_splits1, test_size0.2, random_state42) train_idx, val_idx next(gss.split(df, df[label], groupsdf[weibo_id])) train_df df.iloc[train_idx] val_df df.iloc[val_idx]逻辑说明GroupShuffleSplit把groups参数里微博 id 相同的样本视为一个整体切分时不会拆散它们。这样得到的验证结果才反映模型在新事件上的真实水平。5.2 表情符号被粗暴清洗导致情感信号丢失负类召回率骤降现象模型在验证集上的负类召回率只有 0.4 左右翻开错误样本发现“厉害[白眼]”“牛[吐]”这类明显负面评论被分到正面或中性。原因预处理阶段用正则把所有非中文字符包括方括号表情全部删除而表情是微博评论区表达负向情绪的主要载体。删除之后文本只剩“厉害”“牛”这种字面正面的词模型自然学不到真实情感。解决按第 2 章的做法保留并映射常见表情为占位 token让模型自己学习表情和情感的关系。如果任务紧急来不及重新训练紧急办法是在清洗后把含[白眼] [吐] [怒] [跪了]等负面表情的文本收集起来人工确认后修正标签但这是应急手段不能作为最终方案。5.3 早停只监控 loss 不看 F1少数类在“假优化”中崩溃现象训练 loss 和验证 loss 同步下降看起来一切正常但宏平均 F1 从 0.72 跌到 0.66整体准确率反而在涨。原因类别不平衡时模型逐渐把所有样本都推向多数类。由于多数类在总样本中占比高交叉熵损失会继续下降准确率也会上升但少数类召回率已经跌到近 0。只看 loss 和 accuracy永远发现不了这个“假优化”。解决训练循环里监控验证集宏平均 F1并用它作为早停依据。保存模型时只保留验证 F1 最高的一次权重不用最后一轮权重。在验证集上打印少数类的单类召回率下降时立刻停下回去改数据或改损失权重。5.4 训练与部署的预处理函数不一致离线评估正常线上结果崩现象本地离线评估 F1 正常部署到服务端后单条预测经常出错甚至部分输入返回异常结果。原因训练时清洗函数用了一个版本部署时为了“优化”重写了代码或者线上走的是另一条数据处理链路。比如训练时把用户替换成USER占位符线上直接删除昵称词表里的USER在预测时永远匹配不上模型只能依赖 unk 输出。解决把整个预处理函数放进独立模块训练脚本和线上推理脚本共同引用同一份代码。上线前用 100 条真实线上样本做回归测试对比离线结果和线上结果是否一致。这个防坑措施花费的时间远小于线上事故排查的时间。5.5 验证集过小或未分层F1 波动超过 3 个点现象代码完全没变但每次运行验证集 F1 差异很大模型训练结果忽高忽低。原因验证集样本太少或者切分时没有按 label 分层。少数类样本在验证集里只有几十条随机波动就足以造成 3 个点以上的指标变化导致你无法判断调参是否真的有效。解决固定random_state42验证集样本量至少给到 2 万条并使用stratify按类别比例分层。如果总数据量不足 2 万改用 5 折交叉验证先让指标稳定下来再做结论。6. 进阶验证用预训练模型微调与混淆矩阵做最终体检基线和 textCNN 跑通后如果还想压榨效果最直接的做法是切换到中文预训练模型做微调。常见做法是用 RoBERTa-wwm-ext 这类模型宏平均 F1 比 textCNN 通常高 3 到 7 个点尤其在反讽和否定句上改善明显。但引入预训练模型的成本不只是显存还有推理延迟。我的习惯是先在测试集上把 textCNN 的混淆矩阵打印出来看错误集中在哪里再决定要不要上大模型。from transformers import AutoTokenizer, AutoModelForSequenceClassification tokenizer AutoTokenizer.from_pretrained(hfl/chinese-roberta-wwm-ext) model AutoModelForSequenceClassification.from_pretrained( hfl/chinese-roberta-wwm-ext, num_labels3 ) encoded tokenizer( val[text].tolist(), paddingTrue, truncationTrue, max_length64, return_tensorspt )逻辑说明max_length64对微博评论已经足够绝大多数评论在 20 个字以内。预训练模型的分词方式与 jieba 完全不同它用 WordPiece 按字和子词切分不能直接把 TF-IDF 阶段的 tokenize 结果喂进去。微调时的学习率通常用2e-5比 textCNN 低一个数量级否则预训练权重会被快速破坏。优化器用 AdamW配合线性预热和线性衰减调度器。我用一个习惯来收尾每完成一个版本就在 20 条验证集错误样本上逐条读一遍把错误按“标签噪音、分词问题、表情丢失、类别边界模糊”分类。如果错误集中在某一个类别的边界样本上改标注规范比改模型管用如果集中在表情和反讽上回第 2 章补清洗。希望帮到你。本文还有配套的精品资源点击获取