使用 Transformer BERT 模型进行 Twitter 数据多维度情感分类
作为一个常年和文本数据打交道的开发者,我越来越觉得“情感分析”这件事被很多人做窄了。大多数入门教程都在教你怎么判断一条评论是“好评”还是“差评”,但真实业务里,我们需要的远不止这些。用户对某个产品的反馈里,可能同时包含对价格的抱怨、对客服的认可、对物流速度的期待,这几个维度的情感可能是并存的。单纯一个“积极/消极”标签根本不够用,这就是我这次做“多维度情感分类”的初衷。
这次的项目是在 Twitter 数据上做的,选型上用到了 Transformer 架构的 BERT 模型。标题里提到的几个关键词——Transformer、BERT、Twitter数据、多维度情感分类,我先把整个项目的落地过程、踩过的坑、以及一些值得借鉴的设计思路完整记录下来。项目本身不算复杂,但里面有大量细节值得咀嚼,尤其是“多维度”这三个字,做起来和普通的单标签分类完全是两码事。
1. 项目整体设计与思路拆解
1.1 为什么选 BERT 而不是其他模型
做情感分类,可选的方案从简到繁有很多。最早的方案是词典匹配,比如按词打分汇总;然后是传统机器学习,TF-IDF 加 SVM、朴素贝叶斯之类;再后来是 Word2Vec、LSTM 这一路。BERT 相对它们的核心优势在于双向上下文理解和预训练语义基座。
用生活类比来解释的话,传统模型读句子像人读书一样从前往后逐字看,前面的词不理解后面的词;BERT 则是把一句话当成一个整体,每个词的语义都通过注意力机制同时关联到句子里的其他所有词。比如”这个设计真傻”和”傻得可爱”这两句话,单纯看“傻”这个字无法判断情感极性和强度,必须看它和谁在“互动”。注意力机制就是让模型学会这种互动的。
另外,BERT 在海量语料上预训练过后,已经具备了通用的语法和语义理解能力。我们做 Twitter 情感分类时,只需要在它的基础上用领域数据做微调,而不是从零训练一个模型。这就像招了一个懂语言基础的高材生,你只需要教他行业黑话,不需要教他怎么写字。
1.2 多维度情感分类的含义与难点
多维度情感分类,这个“多维度”是项目的灵魂。常规情感分析输出的是单一极性类别(积极、消极、中性),或者一个打分。多维度意味着我们同时从多个视角对文本做情感画像。
这次设计的标签体系是三个维度(实际项目可以根据业务扩展):
- 情感倾向:积极、消极、中性
- 情感强度:高、中、低
- 情感对象:产品本身、价格、服务、物流、以及其他
这个设计解决的实际问题是:单标签分类会丢失大量信息。比如“这个手机真棒,但太贵了”这句话,单标签分类只能二选一,多维分类则能同时捕捉到对品牌(积极)和价格(消极)两种情感。
多维度带来的直接难点是数据标注工作量成倍增加,以及模型输出结构需要重新设计。后面我会给出详细的方案。
1.3 技术方案全貌
先交代工程背景:数据量是 5 万条已经脱敏的 Twitter 文本,模型用的是预训练 BERT-base 版本,微调框架是 HuggingFace 的 Transformers。整体流程是“数据清洗 -> 标签标注 -> 数据编码 -> 模型微调 -> 评估 -> 推理应用”。整套流程跑下来,单卡 GPU 上微调一个 epoch 大概 20 多分钟,属于完全可控的工程规模。
这里也提一下什么场景下可以考虑用 BERT 而不是其它轻量方案。如果你的数据量只有几千条、句子长度很短、语境比较简单,用 LSTM 甚至朴素贝叶斯可能效率更高。但如果你面对的是社交平台数据这种语言随意、表达多样、上下文依赖强的文本,BGRT( BERT )这类预训练模型的优势就非常明显了。
2. 数据获取与预处理实战
2.1 Twitter 数据的特点与清洗思路
Twitter 数据是出了名的“脏”,除了常规的噪声字符以外,它还有几个独特之处:
- 超链接和缩略词多:
http://...、www、RT、cc、DM等。 - 用户标签和话题标签混在正文里:
@username、#hashtag,这些对情感判断既有用也可能干扰。 - 非规范拼写盛行:
w8等于 wait,btw等于 by the way,u等于 you。 - 表情符号携带大量情感信息:直接用正则清掉会丢失宝贵的信号。
清洗的目标不是把所有非字母字符都删掉,而是要区分“噪声”和“信息”。表情符号和否定词缩写需要保留或转换成特殊标记,比如把:)转成EMO_POS,把:(转成EMO_NEG。这个环节我在初版时吃过亏——用了一个非常激进的正则把所有表情、数字、特殊符号全干掉,结果模型 F1 直接掉了 3 个点,因为很多推文里表情才是情感的核心载体。
2.2 数据预处理的标准操作流程
下面是我在实际项目中最终采用的预处理步骤,顺序很重要,先大写转小写再处理用户标签:
import re import pandas as pd from tqdm import tqdm def clean_tweet(text: str) -> str: # 1. 处理 URL text = re.sub(r'http\S+|www\.\S+', ' URL ', text) # 2. 处理用户标签(保留标记) text = re.sub(r'@\w+', ' USER ', text) # 3. 处理话题标签(去掉#号,保留词本身) text = re.sub(r'#(\w+)', r'\1', text) # 4. 处理表情符号,转化为特殊标记 text = re.sub(r':\)|:-\)|:D', ' EMO_POS ', text) text = re.sub(r':\(|:-\(|:\(|:\'\(', ' EMO_NEG ', text) # 5. 转小写 text = text.lower() # 6. 压缩重复字符(如 wooow -> w ow) text = re.sub(r'(.)\1{2,}', r'\1\1', text) # 7. 去除多余空白 text = re.sub(r'\s+', ' ', text).strip() return text没有直接调 NLTK 之类的重型库,因为社交短文本不需要复杂的词形还原。(.)\1{2,}这个正则很有用,可以处理“sooooo good”这种数据,避免模型被无意义的重复字符干扰。
2.3 数据标注:多维度标签体系的设计与落地
数据标注对多维度分类来说是最耗时也最关键的环节。我的经验是,如果没有预算请专业标注团队,就不要自己一个人硬标,容易标到后面标准漂移。我建议的设计方式是:
标签结构设计:把三个维度分别做成独立字段,并制定明确的标注规则,例如“情感倾向”中,只要文本中没有明确表达出积极或消极倾向,就标注为中性;“情感强度”通过程度副词和情绪激烈程度来判断,出现“非常”“极度”“太”等词时至少标为高。
这个阶段如果条件允许,可以让两到三个人各标一部分样本,然后计算标注一致性。虽然不要求像学术研究那样达到很高的 Kappa 值,但至少能发现自己制定的规则是否有歧义。
下面是我用的标签编码方案示例:
def build_labels(row): return { 'polarity': {'positive': 0, 'neutral': 1, 'negative': 2}[row['polarity']], 'intensity': {'low': 0, 'medium': 1, 'high': 2}[row['intensity']], 'aspect': {'product': 0, 'price': 1, 'service': 2, 'logistics': 3, 'other': 4}[row['aspect']] }注意这里“情感对象”不是互斥的吗?一条推文完全可能既提到价格又提到物流。用什么策略处理这个问题,直接决定模型结构设计。如果设置为互斥,模型就是单标签多分类;如果不互斥,就需要用多标签分类(多个二分类头),这个我后面会详细说。我这次为了简化标注,把情感对象设为互斥,即取文本中占比最高、或者最后决定用户购买行为的那个对象作为标签,但如果你业务上有更复杂的要求,建议直接用多标签。
3. BERT 模型原理与微调工程实现
3.1 BERT 是怎么读懂句子的
BERT 的核心是 Transformer 的 Encoder 部分,通过多头自注意力机制捕捉文本中的上下文关系。对这个项目来讲,它的优势在于可以处理 Twitter 文本里的灵活语序和复杂情感表达。比如“这款耳机降噪一流,可惜夹头”这句,传统词袋模型会认为“一流”和“可惜”互相矛盾导致整体预测不准,BERT 的注意力机制能很好地区分这两个情感要素分属不同对象。
BERT 在预训练阶段两个任务是 Masked Language Model(随机盖住词的预测)和 Next Sentence Prediction(判断句子关系)。微调阶段,我们保留它理解语言的能力,只替换输出层,用我们自己的情感标签做监督学习。这里常用的做法是取[CLS]token 的最终隐藏状态作为整句话的语义向量,然后接一个全连接分类层。[CLS]之所以可以作为句子表示,是因为在预训练时它被设计为聚合整个输入序列的信息。
3.2 模型与分词器的加载细节
这里先用 HuggingFace Transformers 库加载模型和分词器。有几个细节值得注意:
from transformers import BertTokenizer, BertForSequenceClassification model_name = 'bert-base-uncased' tokenizer = BertTokenizer.from_pretrained(model_name) # 这里的 id2label 仅用于第一个头(情感倾向),其他头需要单独维护 model = BertForSequenceClassification.from_pretrained( model_name, num_labels=3, output_hidden_states=False )如果你使用的是国内可访问的模型源,可以提前把模型文件下载到本地路径,从本地目录加载。加载之前检查一下本地缓存,能够省掉重复下载的等待。
分词器加载时有几个参数是必须定的:max_length我设为 64。Twitter 文本大多比较短,64 基本够用,比默认的 512 小很多,能够显著提升训练速度。truncation=True表示超过长度截断,padding=True表示批量填充到相同长度。如果在数据处理时没有做 padding,DataLoader 里 batch 拼不起来,训练时会直接报错。
3.3 数据集类与动态填充的实现
在 PyTorch 的训练流程中,数据集需要继承Dataset类。这里要特别注意返回的数据结构要和模型 forward 函数接收的参数对齐。
import torch from torch.utils.data import Dataset class TweetDataset(Dataset): def __init__(self, df, tokenizer, max_len=64): self.df = df.reset_index(drop=True) self.tokenizer = tokenizer self.max_len = max_len def __len__(self): return len(self.df) def __getitem__(self, idx): row = self.df.iloc[idx] text = str(row['cleaned_text']) encoding = self.tokenizer( text, truncation=True, padding='max_length', max_length=self.max_len, return_tensors='pt' ) return { 'input_ids': encoding['input_ids'].squeeze(0), 'attention_mask': encoding['attention_mask'].squeeze(0), 'labels': torch.tensor(row['polarity'], dtype=torch.long), }上面这个 Dataset 类一次只返回一个样本的标签(polarity),实际使用时要构造三份(或者改造输出三个标签)。因为每个头都有自己的全连接层,所以我们通常在一个模型内做多任务输出,而不是分别训练三个模型。下文会给出具体的模型定义。
3.4 自定义多维度分类模型结构
如果要实现三个维度的同时预测,最直接的方法是在 BERT 的[CLS]输出后面接三个并行的分类头。HuggingFace 自带的BertForSequenceClassification只支持一个num_labels,我们需要自定义一个模型类:
import torch.nn as nn from transformers import BertModel class MultiAspectBert(nn.Module): def __init__(self, num_polarity=3, num_intensity=3, num_aspect=5): super().__init__() self.bert = BertModel.from_pretrained('bert-base-uncased') self.dropout = nn.Dropout(0.3) self.polarity_head = nn.Linear(768, num_polarity) self.intensity_head = nn.Linear(768, num_intensity) self.aspect_head = nn.Linear(768, num_aspect) def forward(self, input_ids, attention_mask): outputs = self.bert(input_ids=input_ids, attention_mask=attention_mask) pooled = outputs.pooler_output pooled = self.dropout(pooled) polarity_logits = self.polarity_head(pooled) intensity_logits = self.intensity_head(pooled) aspect_logits = self.aspect_head(pooled) return polarity_logits, intensity_logits, aspect_logits用pooler_output还是用last_hidden_state[:, 0, :]?两者差不太多。pooler_output经过了一个全连接层和 tanh 激活,通常认为它更适合分类任务,我实际使用时也是前者效果略好。如果追求微调性能,可以把 BERT 的若干底层参数冻结,只训练顶部几层和分类头,能省不少显存,但精度可能略降。
3.5 训练参数与完整训练流程
训练参数是一个靠经验反复调的东西,我这次采用的是一组比较稳妥的默认值:
from transformers import AdamW from transformers import get_linear_schedule_with_warmup epochs = 3 batch_size = 32 learning_rate = 2e-5 optimizer = AdamW(model.parameters(), lr=learning_rate, weight_decay=0.01) total_steps = len(train_dataloader) * epochs scheduler = get_linear_schedule_with_warmup( optimizer, num_warmup_steps=int(0.1 * total_steps), num_training_steps=total_steps )学习率 2e-5 是 BERT 微调的一个常用起跳点。如果太大,很容易把预训练学到的知识冲掉;如果太小,微调半天没动静。一个更稳妥的做法是先用 1e-5 跑一个小 batch 的试探训练,观察 loss 有没有下降,再决定要不要调大。
下面的代码是一个训练 epoch 的核心函数:
from tqdm import tqdm def train_epoch(model, dataloader, optimizer, scheduler, device): model.train() total_loss = 0 for batch in tqdm(dataloader, desc='Training'): input_ids = batch['input_ids'].to(device) attention_mask = batch['attention_mask'].to(device) polarity = batch['polarity'].to(device) intensity = batch['intensity'].to(device) aspect = batch['aspect'].to(device) optimizer.zero_grad() pol_logits, inten_logits, asp_logits = model(input_ids, attention_mask) loss_pol = nn.CrossEntropyLoss()(pol_logits, polarity) loss_inten = nn.CrossEntropyLoss()(inten_logits, intensity) loss_asp = nn.CrossEntropyLoss()(asp_logits, aspect) # 三个损失等权相加,实际使用中可以根据任务重要性加权 loss = loss_pol + loss_inten + loss_asp loss.backward() optimizer.step() scheduler.step() total_loss += loss.item() return total_loss / len(dataloader)三个损失直接相加,隐含假设了它们同等重要。实际项目中,如果你更关心情感倾向的准确性,可以设置加权,比如loss = 1.0 * loss_pol + 0.5 * loss_inten + 0.8 * loss_asp。权重的调整要靠验证集表现来倒推。
3.6 验证与评估指标的选取
多标签分类的评估不能只用 accuracy,因为如果某个维度类别特别不均衡(比如“情感对象”里“其他”占 60%),模型只要全都预测“其他”,准确率也会很高,但没有实际价值。
from sklearn.metrics import classification_report def evaluate(model, dataloader, device): model.eval() all_pol = [] all_inten = [] all_asp = [] pred_pol = [] pred_inten = [] pred_asp = [] with torch.no_grad(): for batch in dataloader: input_ids = batch['input_ids'].to(device) attention_mask = batch['attention_mask'].to(device) pol_logits, inten_logits, asp_logits = model(input_ids, attention_mask) all_pol.extend(batch['polarity'].numpy()) all_inten.extend(batch['intensity'].numpy()) all_asp.extend(batch['aspect'].numpy()) pred_pol.extend(torch.argmax(pol_logits, dim=1).cpu().numpy()) pred_inten.extend(torch.argmax(inten_logits, dim=1).cpu().numpy()) pred_asp.extend(torch.argmax(asp_logits, dim=1).cpu().numpy()) print('=== Polarity ===') print(classification_report(all_pol, pred_pol, target_names=['positive', 'neutral', 'negative'])) print('=== Intensity ===') print(classification_report(all_inten, pred_inten, target_names=['low', 'medium', 'high'])) print('=== Aspect ===') print(classification_report(all_asp, pred_asp, target_names=['product', 'price', 'service', 'logistics', 'other']))这里建议打印出每个维度的混淆矩阵,尤其是“情感倾向”维度的混淆矩阵会暴露很多问题。比如“消极”经常被预测成“中性”,那大概率是标注质量有问题,或者训练数据不平衡,需要通过加权采样解决。
4. 结果分析与典型 failure case 复盘
4.1 从指标到实际效果的差距
模型在验证集上的 Macro F1 大概在 0.74 左右,作为多维度分类任务来说中规中矩。分开来看,“情感倾向”的 F1 最高,“情感强度”次之,“情感对象”最低,主要原因是后者的类别边界模糊。
比如一条推文说“这个 app 的设计很好看,但闪退”,标注情感对象时,应该标“产品”还是“其他”?不同标注者的判断可能完全不同。数据里的标签不一致直接限制了模型可以达到的性能上限。
所以,如果你的项目也非常关注某几个维度,建议在做数据标注时引入更细的规则说明,比如“只要提到产品使用过程中的功能表现,就属于产品;提到金钱数额或性价比相关表述,就属于价格”。
4.2 一个典型的情绪分析误判
我复现了几个错误例子,最有代表性的是一条“I finally got my refund after waiting 3 weeks. Not too bad I guess”。模型把这条推文的情感倾向预测为“中性”,情感对象预测为“服务”。但实际上用户经历了长时间等待,语气里带着明显不满,只是用“Not too bad I guess”这种反讽方式表达。
这说明模型对讽刺和反讽的表达方式仍然不够敏感。Twitter 上的“Not bad”“I guess”“as expected”这类表达方式往往携带相反的情感,需要更多的数据让模型学习这种叙事模式。如果项目需要重点处理这种情况,可以考虑在文本预处理时把这类短语作为整体单元保留下来,例如将“not too bad”转换为“mild_sarcasm_negation”这样的特殊标记。
4.3 从错误样本反推数据改进方向
分析错误样本时,我发现一个规律:带有表情符号的推文,如果表情符号被替换成了EMO_POS/EMO_NEG标记,准确率明显更高。这坚定了我在预处理阶段保留表情符号信息的做法。相反,我最初尝试把所有表情统一替换为EMO(不区分正负),效果就差很多。正负表情符号本身携带了很强的情感倾向信号,丢失这个信息等于丢失了模型的一个重要线索。
另一个规律是带有“多情感对象”的推文(比如既提到价格又提到服务)错误率远高于单一对象的。这印证了前面提到的“互斥标签”策略的局限性。如果项目预算允许,强烈建议把这个维度改成多标签输出。
5. 常见问题与排查技巧实录
在实际训练和调试过程中,这个项目踩过的坑还是挺多的。下面挑几个最典型的记录一下,遇到类似问题可以少走很多弯路。
5.1 显存不足(OOM)的解决方案
BERT 模型加上 batch size 较大时,很容易碰到CUDA out of memory。我的第一反应是直接把 batch size 从 32 降到 16,如果还不行就降到 8。但其实有几个更优雅的做法:
- 使用
gradient_accumulation_steps=2,在 batch size 为 16 的情况下,每两步再做一次梯度更新,等效于 batch size 为 32 的效果。 - 在
bert的 forward 参数中不需要保存所有 embedding 的梯度。 - 使用混合精度训练,也就是 PyTorch 的
amp自动混合精度,能够明显减少显存占用并加速训练速度。
下面这段代码展示了一种常用的训练写法:
scaler = torch.cuda.amp.GradScaler() for batch in dataloader: optimizer.zero_grad() with torch.cuda.amp.autocast(): pol_logits, inten_logits, asp_logits = model(input_ids, attention_mask) loss = loss_pol + loss_inten + loss_asp scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()5.2 数据不平衡的处理方法
三个维度里,“情感对象”的不均衡最明显,因为“其他”这个类别很容易占一半以上。模型在训练初期会倾向于把所有样本都预测成“其他”,以获得较低的整体 loss。这时候有两个处理方向:
- 对损失函数加权:把
CrossEntropyLoss的weight设为类别频率的倒数,迫使模型关注少数类。 - 数据重采样:把少数类的样本复制或者用回译(翻译成其他语言再翻译回来)做数据增强。
我实验下来,损失函数加权的收敛更稳定,回译增强虽然有效但耗时较长,适合数据量极少的时候使用。
5.3 模型不收敛或 loss 为 NaN 的处理
如果你发现训练 loss 不降,或者直接变成nan,优先检查以下几点:
- 学习率是否过大,BERT 微调建议控制在 5e-5 以下。
- 标签值是否从 0 开始连续编码。如果标签是
[1, 2, 3]而不是[0, 1, 2],CrossEntropyLoss 会直接计算错误。 - 分词器是否加载了和模型不匹配的版本。比如中文场景误用了 uncased 英文模型,会导致词表对不上,但这在 Twitter 英文数据里一般不会出现。
5.4 训练速度太慢的排查
如果你觉得训练速度让人难以接受,先检查是不是在 CPU 上跑——这是最容易被忽略的问题。GPU 上运行时,可以使用nvidia-smi看一下 GPU 利用率。如果 GPU 利用率一直很低,而 CPU 占用率很高,大概率是 DataLoader 的num_workers设得太低,瓶颈在数据加载上。
对于 5 万条左右的数据,BERT-base 的微调在单卡 V100 上大约需要 30 分钟一个 epoch。如果超过这个时间太多,建议检查是否开启了torch.backends.cudnn.benchmark,以及是否合理使用混合精度。
5.5 推演阶段的坑
训练结束后,如果你想把模型放在服务里做推理,有几个细节必须处理:
- 分词器在推理时不需要
padding到最大长度,只要return_tensors='pt'即可,模型可以自己处理变长输入。 - 在批量推理时,不同样本长度不同,需要使用
pad操作补齐,同时构造对应的attention_mask,将 padding 位置标记为 0。 - 在服务化部署时,注意多个 worker 并发加载同一个模型可能导致显存翻倍,建议使用共享内存或单 worker 多线程。
6. 经验总结与扩展思路
做完整个项目,我自己最大的体会是:模型结构只是整个系统里最容易的一部分,真正决定效果上限的是数据质量和标签设计。BERT 再强大,如果数据标注本身有歧义,分类边界模糊,模型最后也只能学到标注者的平均判断标准。所以如果你的项目时间有限,宁可多花时间在标签规则上,也不要急着把模型跑起来。
另外一个比较实用的经验是,不要一开始就追求完美的多维度结构。如果你是第一次处理情感分类,建议先跑通单标签分类,再逐步扩展到多维度。一来是代码调试难度低很多,二来你可以利用单标签模型的结果辅助多维度标注,比如先用一个预标注模型给出候选标签,人工只做确认和修正,效率至少提升一倍。
最后来说说后续可以怎么扩展。这个项目的多维度框架可以迁移到其他平台的数据上,比如商品评论、应用商店评论,只要调整情感对象的具体类别即可。如果你的数据是中文的,切换成bert-base-chinese等中文预训练模型,或者使用针对特定领域进一步预训练过的模型版本,都能直接复用这套流程。文本数据的语言风格变化很大,但只要预处理、标签体系、微调流程是完整的,迁移成本并不高。
做技术项目最容易陷入的误区是把模型当黑盒——只关心指标,不关心模型到底学到了什么。这次项目里,有一类典型错误给了我很大启发:模型会学到标签分布,而不是文本语义。如果你发现验证集指标高、实际业务场景表现差,大概率是训练数据和真实数据分布不一致导致的。这时候最该做的不是调模型参数,而是回头审视数据来源和标签定义。
好的,项目的完整复盘就到这里。如果你也在做类似的情感分析任务,希望这篇内容能给你一些实际的参考。有任何细节上的疑问,可以按这个流程自己跑一遍试试,踩坑之后得到的经验,往往比看十篇文章都管用。