☰
Kaggle Twitter情感分析实战:从数据清洗到BERT微调的完整流程
2026/10/14 8:13:05 网站建设 项目流程

Kaggle上的Twitter情感分析,在近年的NLP赛题里属于性价比很高的入门选择。表面看就是给一段推文判断情感倾向,但真跑一遍提交流程,你会发现最花时间的往往不是模型,而是数据清洗、验证集设计和提交格式这些容易被忽略的环节。最近我在矩池云上用GPU实例把这个案例完整复现了一遍,从逻辑回归基线一路做到预训练模型微调,踩了不少坑,也总结出一套能直接迁移的流程。这篇文章就是那次复现的记录,适合准备打第一场Kaggle文本赛的同学,也适合想系统梳理文本分类流程的从业者。

1. 从赛题文本中读出真正的需求

1.1 一个短文本分类任务的真实难度

情感分析的本质是让模型判断一段文本包含的情感倾向。但在Twitter这种场景下,难点不只是“分类”本身,而是文本太短、噪声太大、语义太碎。

首先是信息量不足。普通新闻文本有几百个词,一条推文常常只有十几个词,模型能依赖的上下文非常有限。其次是口语化和缩写问题,“im good”“u r the best”“gonna”“nvr”这些词在传统词袋模型里基本就是未登录词,在预训练模型里也容易被切成奇怪的子词。第三是否定和反讽,“not bad”实际偏向正面,“i love waiting in line”结合语境可能是抱怨。最后是噪声比例高,URL、@用户名、主题标签、emoji混在一起,如果不做清洗,模型会把大量容量浪费在无意义字符上。

我看过一个模拟项目X里的统计,未清洗数据中大约18%的文本残留URL和HTML实体。这些噪声在特征层表现为一批稀疏且几乎不重复的词,对分类没有帮助,却会让TF-IDF词典变大、训练变慢。正因为短文本噪声高,数据清洗和特征工程的收益反而比模型结构调参更大,这也是我坚持先把数据层做扎实的原因。

1.2 为什么评价指标和提交格式要先看清楚

大多数Kaggle版本的Twitter情感分析以Accuracy作为评价指标,有些变体会用AUC。二者差别很大:Accuracy要求模型在固定阈值下尽量猜对,AUC更关心正负样本的分数排序。如果你拿AUC的思路去优化Accuracy问题,很容易做出一个整体排序不错但阈值位置错误的模型。

提交文件通常是两列,列名一般是Id和Sentiment:

Id,Sentiment 1,0 2,1 3,1

有些版本要求输出“Positive/Negative”字符串,有些要求输出0/1离散值,还有的允许提交概率。这些信息在比赛页面的“Evaluation”和“Submission File”说明里都写得很清楚。我第一次做类似任务时,把多出的索引列也提交上去了,线上直接报格式错误,白白浪费一次提交机会。所以拿到赛题后,我建议先做三件事:

  1. 下载sample_submission,看清列名和ID顺序是否固定。
  2. 确认评价指标是准确率还是排序指标。
  3. 让本地验证指标和比赛指标保持一致,不要在本地用F1调参,线上却按Accuracy排名。

2. 矩池云上准备可复现的运行环境

2.1 镜像、GPU和持久化目录的选择

这次复现我使用的是在矩池云上开的GPU实例。选型思路比较简单:由于要跑BERT-base级别的微调,训练和推理都需要显存,单卡T4档位已经足够;如果只跑逻辑回归,其实CPU都能胜任,但后面要对比Transformer模型,干脆从一开始就放在GPU环境里。

环境准备有几个关键点:

  • 选择包含PyTorch的标准深度学习镜像,避免自己手动安装一堆CUDA依赖。
  • 数据放到实例的持久化目录,不要放到临时目录。矩池云的容器停止后,临时目录会被重置,持久化目录会保留,这个区别在多次调试时尤其关键。
  • 记录镜像和依赖版本。我习惯在项目根目录放一份requirements.txt,把torch、transformers、scikit-learn的版本固定下来,否则过几周再回看,环境可能已经对不上。

选GPU档位也有个小经验:先跑通baseline,再上大模型。如果一开始就开最高档,结果代码有bug一直空转,钱和时间都浪费了。先用便宜配置把数据管道跑通,再换大显存微调,才是省力又省心的节奏。

2.2 固定随机种子与实验目录

一个竞赛项目前后跑几天,如果最后连哪次提交对应什么参数都分不清,基本等于白跑。我会提前把项目目录结构固定好:

data/ raw/ processed/ predictions/ experiments/ exp001_lr/ exp002_lstm/ exp003_bert/ scripts/

data/predictions存放每次实验生成的提交文件,后面对比不同提交时非常有用。experiments下每个实验目录里放一份参数JSON,例如:

{ "model": "logistic_regression", "vectorizer": "tfidf", "max_features": 50000, "ngram_range": [1, 2], "seed": 42 }

还要全局固定随机种子。深度学习里有多个随机源,包括Python、NumPy和PyTorch,可以写一个通用函数:

import random import numpy as np import torch def set_seed(seed=42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed)

最后,每次修改代码后,先记一行“这次改了什么”再开始训练。别高估自己的记忆力,尤其当比赛持续数周时,这种记录比模型本身更重要。

3. 数据清洗与验证集设计

3.1 清洗顺序不能乱

短文本分类的特征质量高度依赖清洗顺序。举个例子:如果先把“#hashtag”整体删掉,后面做emoji转写或否定处理时,就无法利用主题标签本身的语义;如果先删URL再做HTML实体解码,URL里夹带的&可能残留成脏字符。处理步骤之间有依赖关系,顺序错了,结果会偏离预期。

我常用的一套顺序如下:

import html import re def clean_tweet(text: str) -> str: # 1. 先统一空白和换行 text = re.sub(r"\s+", " ", text.strip()) # 2. 解码HTML实体,例如 & -> & text = html.unescape(text) # 3. 去掉链接 text = re.sub(r"http\S+|https\S+|www\.\S+", " ", text) # 4. 去掉@用户名,但保留主题标签中的文字 text = re.sub(r"@\w+", " ", text) # 5. 保留主题标签本身,也可以把#去掉后保留词 text = re.sub(r"#(\w+)", r"\1", text) # 6. 把部分表情符号区域保留为特殊token text = re.sub(r"[\U0001F600-\U0001F64F]", " emoji_positive ", text) return text

顺序逻辑是“先还原字符本体,再删噪声,再做语义标记”。URL放在HTML实体解码之后删除,能避免不少脏数据;@用户名在主题标签之前删除,因为用户名通常不含情感信息,而主题标签保留了话题语义。

我尤其想提醒的是,不要把emoji直接删光。部分推文里表情符号是比文字更稳定的情感信号,比如一句“finally nice to meet you”配一个笑脸,语气就完全不一样。直接把非字母字符全部抹掉,等于丢掉一个强特征。折中做法是把表情映射成POS、NEG这类token,再进入TF-IDF或BERT。

3.2 否定表达和用户级别验证切分

清洗之外,短文本还要处理否定表达。传统模型里“not”容易被当作独立词,模型分不清“not good”和“not bad”之间的区别。常见做法是做一个否定标记,把否定词后面的形容词拼起来:

NEG_WORDS = {"not", "never", "no", "none", "n't", "nt", "hardly"} def mark_negation(tokens): result = [] flag = False for token in tokens: lower = token.lower() if lower in NEG_WORDS: flag = True result.append(token) elif flag: result.append("not_" + lower) flag = False else: result.append(lower) return result

这样“not good”会变成["not", "not_good"],模型可以学到“否定词加形容词”这个组合特征。放在TF-IDF阶段效果明显,放在BERT阶段则不需要过度处理,因为预训练模型已经能部分理解否定结构。

验证集切分方面,我强烈建议按用户级别做分组。很多Twitter数据集包含用户ID,如果随机切分,同一个用户的多条推文会同时出现在训练集和验证集里,模型可能学到“这个人喜欢用某种措辞”而不是“这条文本表达了什么”,导致本地分数虚高。使用GroupKFold可以避免:

from sklearn.model_selection import GroupKFold gkf = GroupKFold(n_splits=5) for train_idx, valid_idx in gkf.split(X, y, groups=user_ids): train_df = df.iloc[train_idx] valid_df = df.iloc[valid_idx]

这种切分方式的代价是验证集和训练集分布差异可能更大,但更接近真实线上场景。如果数据本身不提供用户ID,至少要在随机切分后检查一下同一段连续文本是否被切断,不要把一句话硬生生拆到训练和验证两份里。

4. 用一条清晰路径跑通建模流程

4.1 传统模型LR先定基线

我先说大家最容易上手的方案:TF-IDF加逻辑回归。这是很多比赛里性价比最高的基线。文本转成稀疏向量后,逻辑回归天然适合高维稀疏特征,训练速度快,一轮交叉验证通常几分钟就能跑完。

核心流程如下:

from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.linear_model import LogisticRegression from sklearn.model_selection import StratifiedKFold from sklearn.metrics import accuracy_score import numpy as np import pandas as pd train_df = pd.read_csv("data/processed/train.csv") test_df = pd.read_csv("data/processed/test.csv") vectorizer = TfidfVectorizer( max_features=50000, ngram_range=(1, 2), sublinear_tf=True, strip_accents="unicode" ) X_train = vectorizer.fit_transform(train_df["clean_text"]) X_test = vectorizer.transform(test_df["clean_text"]) cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42) oof_pred = np.zeros(X_train.shape[0]) for tr_idx, va_idx in cv.split(X_train, train_df["label"]): clf = LogisticRegression(C=1.0, max_iter=300, class_weight="balanced") clf.fit(X_train[tr_idx], train_df["label"].iloc[tr_idx]) va_pred = clf.predict(X_train[va_idx]) oof_pred[va_idx] = va_pred print("fold acc:", accuracy_score(train_df["label"].iloc[va_idx], va_pred)) print("oof acc:", accuracy_score(train_df["label"], oof_pred))

这里有一个重要提醒:TfidfVectorizer只能在全量训练集上fit,不要为了省事把训练集和测试集合在一起fit。合在一起拟合往往本地分数更高,但相当于让模型偷看了测试集文本分布,严格来说不够干净。正式提交时,我的习惯是只用训练集拟合。

逻辑回归的价值不只是出分,更在于提供一个标尺。如果后续花两小时跑LSTM、BERT,跑完只比这个基线高零点几个百分点,就要冷静判断投入产出是否值得。我见过不少项目上来就套大模型,连简单LR基线都没有,最后连改进方向都定位不到。

4.2 深度学习路线怎么选更稳

在LR之后,我建议按这样的顺序尝试:

  1. 双向LSTM加平均池化。输入采用预训练词向量,嵌入维度选100或300,训练2到3个epoch。这个方案在中长文本上迁移效果好,但在推文这种短文本上收益不一定比LR明显。
  2. BERT系列预训练模型。使用Hugging Face的AutoModelForSequenceClassification,序列长度一般设64到96就够,因为推文本身短。过长的截断会产生大量padding,白白浪费算力。
  3. 多模型预测平均。如果BERT和LSTM特征差异明显,可以把两者的预测概率按加权平均融合,通常能稳定提升零点几个百分点。

用BERT时有几个参数需要特别注意:

  • 学习率不要用默认值,常用区间是2e-5到5e-5。
  • Batch size在显存允许下尽量大,比如32或64,但不要盲目追求大batch。
  • 最多训练3到5个epoch,配合早停,否则很容易过拟合训练集。
  • 对推文做预处理时不要清洗过度,把表情符、#、@全部抹掉,会让BERT丢失原始语料里的关键信息。我的做法是轻度清洗,只去掉URL和不可见字符,其余尽量保持原样。

深度学习部分的实验记录比传统模型更重要。同一套预训练代码,不同随机种子都可能产生0.5%以上的分数波动。如果没记录种子,某次提交线上分数高,你都没法还原。所以我在每个实验目录里都会保存参数、种子、提交文件,以及一句备注。这听起来很机械,但竞赛经验就是这么积累出来的。

5. 提交评测与故障排查

5.1 本地分数和公开榜分数不一致

很多新人会遇到本地验证0.85,公开榜分数却只有0.80的情况。这不一定是代码错,可能是数据分布天然存在差异,也可能来自切分方式不同。常见原因包括:

  • 训练集和测试集采集时间段不同,词汇分布不一样。
  • 验证集切分没有考虑用户重叠,导致本地结果偏高。
  • 比赛官方对测试集做了重采样,样本先验概率发生变化。
  • 你用了测试集信息做特征工程,例如全量TF-IDF,本地看起来很强,线上反而变弱。

我的排查顺序是:先检查验证集是否分组正确,再看预处理是否有测试数据泄漏,最后才怀疑调参问题。不要一上来就换模型结构,否则很可能白忙一场。

5.2 常见问题排查速查表

这里整理一张我在情感分析任务中实际遇到过的排查表:

现象可能原因处理方式
训练loss很低,验证集准确率上不去模型过拟合降低学习率、增加dropout、早停
本地CV高,线上分数低切分或泄漏问题改用GroupKFold,检查是否全量fit
提交报格式错误列名或ID索引不一致对照sample_submission逐项检查
训练速度很慢固定padding过长动态padding,按batch取最大长度
BERT输出全为某一类学习率过大或样本不平衡降低学习率,设置类别权重
逻辑回归效果奇差文本没清洗或全量fit检查clean_tweet输出,重新切分

这张表不一定适用所有比赛,但大多数短文本分类问题,成因就那么几类。遇到问题先定位环节,再动手,比盲目换模型有效。

5.3 三个容易被忽略的操作习惯

最后说三个我在实际操作里坚持的习惯。

一是提交前做本地“假提交”检查。把sample_submission读进来,把预测结果的列名、顺序、索引全部对齐后再输出,即使之后还要改,也不会浪费提交次数。

二是给每个模型保留最小复现命令。类似这样:

python train_lr.py --seed 42 --c 1.0 python train_bert.py --seed 42 --lr 3e-5 --epochs 3

过几天再想复现时,不用翻代码注释。

三是保存预测概率而不是只保存标签。验证时比较不同模型融合效果,用概率值比离散标签平滑得多。每次跑完,我把y_pred_prob存成npy文件,后续融合才有材料。

6. 从这次复现里沉淀下的实操建议

如果你准备在矩池云或者其他云环境里复现这个Kaggle案例,我的建议是:先用逻辑回归跑通全流程,再把BERT当作增量优化,不要在第一天就直接上大模型。大模型确实是趋势,但竞赛能赢的人,很多时候不是模型最新,而是数据清洗、验证切分和提交管理这些基本功更扎实。

这次复现里我最大的收获,不是准确率比基线高了多少,而是把每个环节都整理成了可重复执行的步骤。清洗函数固定下来,验证集切分固定下来,随机种子固定下来,实验记录固定下来,剩下的模型优化反而变得很轻松。这也和我在矩池云上做实验的感受一致:环境可以随时开启和释放,但代码和实验记录必须留在持久化目录里,重新进入容器后还能继续之前的工作,这一点对长时间比赛尤其重要。

如果你现在正准备开始自己的情感分析项目,不用急着找复杂方案。先下载数据,把clean_tweet跑通,把验证集切分看清楚,再决定用什么模型。这个顺序走对了,后面的路会顺畅很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询