基于 BiLSTM 的微博情感四分类实战:数据处理、模型训练到 Web 部署
做舆情分析,最基础也最关键的一步,就是判断一条微博到底表达的是什么情绪。高兴还是愤怒,厌恶还是低落,如果能自动识别,对舆情监控、产品反馈分析都有直接的价值。
这篇文章我完整记录了用 PyTorch 实现的一个微博情感四分类系统,识别 “喜悦、愤怒、厌恶、低落” 四种情绪。从数据预处理、词表构建、模型搭建、训练调优到 Flask 部署上线,全流程跑通,并且给出了真实的实验结果和踩坑记录。
一、项目要做什么
技术栈很简单:Python + PyTorch + Flask。
整体分成两个阶段:
离线训练:用微博数据集训练一个双向 LSTM 模型,把验证集上表现最好的权重保存成本地文件
TextRNN.ckpt。在线预测:加载训练好的权重,用 Flask 提供
/predict接口,输入一句话,返回四类情绪各自的概率。
核心链路长这样:
微博文本 → 字符切分 → 词表映射 → 预训练词向量 → BiLSTM → 全连接 → 四分类概率模型选BiLSTM(双向 LSTM)是因为它同时能看每个字的前文和后文,对情感这种依赖上下文的任务很合适。用预训练词向量而不是随机初始化,则是希望借语义先验来提升效果。
二、数据集与预处理
用的数据集是简化版微博四情绪数据集,一共36.2 万条文本,每条包含一个标签和一个评论内容,标签 0 到 3 分别对应喜悦、愤怒、厌恶、低落。
中文没有天然的分词边界,分词又容易引入误差,这里直接采用字符级处理,把每个汉字当作一个基本单元,简单可靠。
2.1 构建词表
统计所有字符的出现频率,去掉低频字,保留出现最多的前 4760 个,再加上UNK和PAD两个特殊符号,最终词表大小 4762。
MAX_VOCAB_SIZE = 4760 UNK, PAD = '<UNK>', '<PAD>' def build_vocab(file_path, max_size, min_freq): tokenizer = lambda x: [y for y in x] # 分字 vocab_dic = {} with open(file_path, 'r', encoding='UTF-8') as f: i = 0 for line in tqdm(f): if i == 0: # 跳过表头 i += 1 continue lin = line[2:].strip() # 取评论内容,剔除标签 if not lin: continue for word in tokenizer(lin): vocab_dic[word] = vocab_dic.get(word, 0) + 1 # 统计字频 # 按频次排序,剔除低频字,取前 max_size 个 vocab_list = sorted([_ for _ in vocab_dic.items() if _[1] > min_freq], key=lambda x: x[1], reverse=True)[:max_size] vocab_dic = {word_count[0]: idx for idx, word_count in enumerate(vocab_list)} vocab_dic.update({UNK: len(vocab_dic), PAD: len(vocab_dic) + 1}) pkl.dump(vocab_dic, open('simplifyweibo_4_moods.pkl', 'wb')) return vocab_dic这里有两个细节值得注意:
UNK表示 “没见过的字”,预测时遇到词表外的字符,统一映射到它,避免索引越界。PAD用来补齐长度,后面 padding 全靠它。词表要存成 pkl 文件,因为 Web 部署阶段还要用同一个词表做向量化,两边必须完全一致。
2.2 数据加载与向量化
神经网络要求一个 batch 内的文本长度一致,所以要做 padding。这里把最大长度设为 70,短的补PAD,长的截断。
def load_dataset(file_path): vocab = pickle.load(open("simplifyweibo_4_moods.pkl", "rb")) tokenizer = lambda x: [char for char in x] max_len = 70 df = pd.read_csv(file_path) contents = [] for line in tqdm(df.itertuples(), desc="加载数据"): label = line.label content = line.review.strip() tokens = tokenizer(content) if len(tokens) == 0: # 过滤空文本,防止序列长度0报错 continue seq_len = len(tokens) if len(tokens) < max_len: tokens += [PAD] * (max_len - len(tokens)) else: tokens = tokens[:max_len] seq_len = max_len idx_list = [vocab.get(word, vocab[UNK]) for word in tokens] contents.append((idx_list, seq_len, label)) random.seed(666) random.shuffle(contents) # 8:1:1 划分训练/验证/测试 total = len(contents) train_num = int(0.8 * total) dev_num = int(0.1 * total) train_data = contents[:train_num] dev_data = contents[train_num:train_num+dev_num] test_data = contents[train_num+dev_num:] return vocab, train_data, dev_data, test_data划重点:每条样本除了存 token 的索引,还存了真实的seq_len。这个后面做变长序列打包要用,能让 LSTM 只对真实长度做计算,省掉大量无效计算。
最终数据集划分结果:
训练集:289394 条
验证集:36174 条
测试集:36175 条
三、模型结构:双向 LSTM
模型核心就三层:预训练词向量嵌入 → 双向 LSTM → 全连接分类。
class Model(nn.Module): def __init__(self, embedding_pretrained, n_vocab, embed_dim, num_classes, padding_idx=None): super(Model, self).__init__() # 预训练词向量,padding_idx 让 PAD 位置的向量不参与梯度更新 self.embedding = nn.Embedding.from_pretrained( embedding_pretrained, freeze=False, padding_idx=padding_idx ) self.lstm = nn.LSTM(embed_dim, 128, num_layers=2, bidirectional=True, batch_first=True, dropout=0.2) self.fc = nn.Linear(128 * 2, num_classes) def forward(self, x): texts, seq_len = x embed = self.embedding(texts) # 变长序列打包,只对真实长度做计算 packed_embed = nn.utils.rnn.pack_padded_sequence( embed, seq_len.cpu(), batch_first=True, enforce_sorted=True) out, _ = self.lstm(packed_embed) out, _ = nn.utils.rnn.pad_packed_sequence(out, batch_first=True) # 按每条样本的真实长度取末尾 hidden,避免取到 PAD 填充位置 lengths = (seq_len - 1).view(-1, 1, 1).expand(-1, 1, out.size(2)) last_hidden = out.gather(1, lengths).squeeze(1) logits = self.fc(last_hidden) return logits几个关键点:
用腾讯预训练词向量(200 维)初始化嵌入层,
freeze=False表示训练时继续微调。bidirectional=True开启双向,同时看前后文。全连接层输入维度是
128 * 2,因为双向 LSTM 输出维度是隐藏单元数的两倍。padding_idx让 PAD 位的向量不参与梯度更新,减少噪声。
这里有一个我踩过的大坑:原来last_hidden直接写out[:, -1, :],取的是 “最后一列”。但经过pack_padded_sequence打包再还原后,out的形状是(batch, max_len, hidden),只有 batch 里最长那条样本的最后一列才是真实句尾,其余短文本的最后一列全是 PAD 填充位,取到的根本是无效特征。导致训练了很久准确率就是上不去。改成按每条样本的seq_len精确定位真实末尾后,效果立刻不一样,这个后面踩坑部分再细讲。
四、训练细节
训练用 Adam 优化器,学习率 0.001,batch_size 128。这里加了两个很关键的机制:
按验证集损失保存最优模型,不是每轮都存。
类别权重:因为四类样本严重不平衡,喜悦占了超过一半,直接训练模型会 “偷懒” 全猜喜悦。这里统计各类样本数,按逆频率算权重,让损失更重视样本少的类。
def train(model, train_iter, dev_iter, test_iter, class_list): model.train() optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) # 统计训练集各类样本数,计算类别权重,缓解类别不平衡 num_classes = model.fc.out_features label_counts = [0] * num_classes for _, _, lbl in train_iter.data: label_counts[lbl] += 1 total = sum(label_counts) weight_list = [total / (num_classes * c) for c in label_counts] device = next(model.parameters()).device class_weight = torch.tensor(weight_list, dtype=torch.float).to(device) print("类别权重:", [round(w, 4) for w in weight_list]) total_batch = 0 dev_best_loss = float('inf') last_improve = 0 flag = False epochs = 5 for epoch in range(epochs): for i, ((trains, seq_len), labels) in enumerate(train_iter): outputs = model((trains, seq_len)) loss = F.cross_entropy(outputs, labels, weight=class_weight) model.zero_grad() loss.backward() optimizer.step() if total_batch % 500 == 0: predic = torch.max(outputs.data, 1)[1].cpu() train_acc = metrics.accuracy_score(labels.data.cpu(), predic) dev_acc, dev_loss = evaluate(class_list, model, dev_iter, class_weight=class_weight) if dev_loss != 0 and dev_loss < dev_best_loss: dev_best_loss = dev_loss torch.save(model.state_dict(), 'TextRNN.ckpt') last_improve = total_batch total_batch += 1 test(model, test_iter, class_list)评估时除了准确率,还会打印分类报告,看每个类的精确率、召回率、F1 值,这比只看一个准确率靠谱得多。
五、实验结果
训练设备是 CUDA。加上类别权重后,训练集算出的权重约为:
类别权重: [0.4533, 1.7506, 1.6351, 1.6367]喜悦权重最小,因为样本最多;愤怒、厌恶、低落权重更大,逼着模型重视它们。
5.1 训练过程
加权重后,验证集准确率从低值快速爬升,之后稳定在 50% 左右波动:
| 阶段 | Val Acc | 说明 |
|---|---|---|
| Epoch 1 起步 | 14.76% → 53.98% | 快速爬升 |
| Epoch 2 | 48%~53% | 波动上行 |
| Epoch 3 | 49%~52% | 高位震荡 |
| Epoch 4 | 49%~52% | 趋于稳定 |
| Epoch 5 | 47%~52% | 收敛 |
5.2 最终测试结果
5 轮训练完成后,在测试集上的最终结果为:
============ Test Result ============ Test Loss: 1.23640, Test Acc: 0.50994 precision recall f1-score support 喜悦 0.7719 0.6410 0.7004 19940 愤怒 0.3027 0.4082 0.3476 5108 厌恶 0.2859 0.6116 0.3897 5489 低落 0.2259 0.0396 0.0673 5638 accuracy 0.5099 36175 macro avg 0.3966 0.4251 0.3763 36175 weighted avg 0.5468 0.5099 0.5048 36175用表格看得更清楚:
| 类别 | 样本数 | 精确率 | 召回率 | F1 |
|---|---|---|---|---|
| 喜悦 | 19940 | 0.7719 | 0.6410 | 0.7004 |
| 愤怒 | 5108 | 0.3027 | 0.4082 | 0.3476 |
| 厌恶 | 5489 | 0.2859 | 0.6116 | 0.3897 |
| 低落 | 5638 | 0.2259 | 0.0396 | 0.0673 |
| 整体 | 36175 | — | — | 0.5099 |
5.3 结果分析
准确率 50.99%,说明加了类别权重后,模型确实不再一味偏向喜悦,愤怒和厌恶的召回率都上来了。但也要正视两个问题:
整体准确率还有提升空间,四分类任务里 50% 左右的水平只能说 “能用”。
“低落” 类特别难,召回率只有 0.0396,F1 只有 0.0673,几乎没识别出来。这跟类别数量少、以及 “低落” 和 “厌恶” 在语言表达上容易混淆都有关系,是后续最值得优化的方向。
如果把类别权重去掉直接训练,准确率能到 59% 左右,但 “低落” 召回率只有 1.7%,几乎全被当成了喜悦,整体指标是失衡的。所以加了类别权重,是用一点整体准确率换来了类别间的均衡,在真实业务里通常更合理。
六、踩坑记录
这些全是我实际跑的时候踩过的真坑,直接帮你绕开。
取错 last_hidden 位置,准确率死活上不去。这是最坑的一个。
pack_padded_sequence之后再pad_packed_sequence还原,out[:, -1, :]取的是最后一列,短样本那列是 PAD 填充位,特征取错。改成按seq_len用gather精确取每条样本的真实末尾后,效果立刻不一样。这个 bug 不看数据分布根本发现不了。Windows 终端 emoji 打印崩溃。训练代码里用了
✅、❌这类 emoji 打印,Windows 默认 GBK 编码不支持,直接UnicodeEncodeError崩溃。把 emoji 全部换成普通文字就好。类别不平衡,模型偷懒猜多数类。喜悦占了 55%,不加权重时模型几乎全猜喜悦。用逆频率类别权重解决,效果立竿见影。
预训练词向量 dtype 冲突。
np.load出来是 double,直接转 torch 会跟模型的 float 冲突,要.float()转一下。空文本报错。输入为空时序列长度为 0,模型直接报错。处理办法是过滤空文本,预测时用
PAD兜底。评估太稀疏看不到训练过程。原来每 10000 步才评估一次,总共才 11000 多个 batch,等于整场训练就看了两次。改成每 500 步评估一次,能清楚看到收敛过程。
七、Web 部署
模型训练好以后,用 Flask 封装成服务。关键是词表、预训练词向量、模型权重全部加载进来,然后提供/predict接口。注意预测时的预处理逻辑必须和训练时完全一致,否则结果对不上。
def predict(text): tokens = [c for c in text.strip()] if len(tokens) == 0: tokens = [PAD] # 空文本兜底 seq_len = len(tokens) if seq_len < MAX_LEN: tokens = tokens + [PAD] * (MAX_LEN - seq_len) else: tokens = tokens[:MAX_LEN] seq_len = MAX_LEN idx = [vocab.get(w, vocab[UNK]) for w in tokens] x = torch.LongTensor([idx]).to(device) sl = torch.LongTensor([seq_len]).to(device) with torch.no_grad(): logits = model((x, sl)) probs = F.softmax(logits, dim=1)[0].cpu().numpy() return int(np.argmax(probs)), probs @app.route('/predict', methods=['POST']) def api_predict(): data = request.get_json(silent=True) or {} text = data.get('text', '') if not text or not text.strip(): return jsonify({'ok': False, 'msg': '请输入文本'}), 400 pred_id, probs = predict(text) return jsonify({ 'ok': True, 'label': CLASS_LIST[pred_id], 'confidence': round(float(probs[pred_id]), 4), 'probs': [{'name': CLASS_LIST[i], 'value': round(float(probs[i]), 4)} for i in range(len(CLASS_LIST))], })运行python web_app.py,浏览器打开http://127.0.0.1:5000,输入一句话点识别,页面会返回最可能的情绪类别和四种情绪的概率分布。
一个重要的坑:web_app.py只在启动时加载一次模型权重。如果模型重新训练了,必须重启 web 服务才会加载新权重,光刷新页面没用。
八、总结与改进方向
到这里,一个完整的微博情感分类系统就跑通了:从词表构建、数据向量化,到 BiLSTM 模型训练,再到 Flask 网页部署。
核心技术点就两个:
双向 LSTM能同时捕捉前后文语义,非常适合情感分类这类任务。
预训练词向量 + 变长序列打包,既带来了语义先验,又省了计算量。
如果你想在这个基础上继续提升,可以往这几个方向尝试:
把
last_hidden换成 mean+max pooling 拼接,通常比只取最后一个 hidden 更稳。针对 “低落” 类单独做数据增强或重采样,它是最难识别的一类。
适当增加训练轮数,加权重后收敛更慢,更多轮次通常能进一步拉高准确率。
换更强模型,比如 TextCNN、BERT 微调,效果一般会比 BiLSTM 更好,代价是训练成本更高。