恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
基于 BiLSTM 的微博情感四分类实战:数据处理、模型训练到 Web 部署
首页
资讯中心
/
基于 BiLSTM 的微博情感四分类实战:数据处理、模型训练到 Web 部署
基于 BiLSTM 的微博情感四分类实战:数据处理、模型训练到 Web 部署
发布时间:2026/10/10 2:19:52
基于 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, encodingUTF-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], keylambda x: x[1], reverseTrue)[: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_numdev_num] test_data contents[train_numdev_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_idxNone): super(Model, self).__init__() # 预训练词向量padding_idx 让 PAD 位置的向量不参与梯度更新 self.embedding nn.Embedding.from_pretrained( embedding_pretrained, freezeFalse, padding_idxpadding_idx ) self.lstm nn.LSTM(embed_dim, 128, num_layers2, bidirectionalTrue, batch_firstTrue, dropout0.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_firstTrue, enforce_sortedTrue) out, _ self.lstm(packed_embed) out, _ nn.utils.rnn.pad_packed_sequence(out, batch_firstTrue) # 按每条样本的真实长度取末尾 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 维初始化嵌入层freezeFalse表示训练时继续微调。bidirectionalTrue开启双向同时看前后文。全连接层输入维度是128 * 2因为双向 LSTM 输出维度是隐藏单元数的两倍。padding_idx让 PAD 位的向量不参与梯度更新减少噪声。这里有一个我踩过的大坑原来last_hidden直接写out[:, -1, :]取的是 “最后一列”。但经过pack_padded_sequence打包再还原后out的形状是(batch, max_len, hidden)只有 batch 里最长那条样本的最后一列才是真实句尾其余短文本的最后一列全是 PAD 填充位取到的根本是无效特征。导致训练了很久准确率就是上不去。改成按每条样本的seq_len精确定位真实末尾后效果立刻不一样这个后面踩坑部分再细讲。四、训练细节训练用 Adam 优化器学习率 0.001batch_size 128。这里加了两个很关键的机制按验证集损失保存最优模型不是每轮都存。类别权重因为四类样本严重不平衡喜悦占了超过一半直接训练模型会 “偷懒” 全猜喜悦。这里统计各类样本数按逆频率算权重让损失更重视样本少的类。def train(model, train_iter, dev_iter, test_iter, class_list): model.train() optimizer torch.optim.Adam(model.parameters(), lr1e-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, dtypetorch.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, weightclass_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_weightclass_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 248%~53%波动上行Epoch 349%~52%高位震荡Epoch 449%~52%趋于稳定Epoch 547%~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喜悦199400.77190.64100.7004愤怒51080.30270.40820.3476厌恶54890.28590.61160.3897低落56380.22590.03960.0673整体36175——0.50995.3 结果分析准确率 50.99%说明加了类别权重后模型确实不再一味偏向喜悦愤怒和厌恶的召回率都上来了。但也要正视两个问题整体准确率还有提升空间四分类任务里 50% 左右的水平只能说 “能用”。“低落” 类特别难召回率只有 0.0396F1 只有 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, dim1)[0].cpu().numpy() return int(np.argmax(probs)), probs app.route(/predict, methods[POST]) def api_predict(): data request.get_json(silentTrue) 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换成 meanmax pooling 拼接通常比只取最后一个 hidden 更稳。针对 “低落” 类单独做数据增强或重采样它是最难识别的一类。适当增加训练轮数加权重后收敛更慢更多轮次通常能进一步拉高准确率。换更强模型比如 TextCNN、BERT 微调效果一般会比 BiLSTM 更好代价是训练成本更高。