☰
基于深度学习的文本分类项目实战:从数据准备到模型上线
2026/10/6 8:11:30 网站建设 项目流程

简介:这份资源面向具备一定Python基础、希望入门自然语言处理与深度学习文本分类的学习者,围绕“基于深度学习的文本分类”这一核心任务,提供可运行的代码实践方案,帮助读者理解从文本预处理到模型训练评估的完整流程。压缩包共6个文件,全部为py脚本,整体约11KB,涵盖数据加载、TextCNN与TextRNN两类模型定义、训练运行及预测推理等模块,结构精简,便于按模块阅读与调试。内容涉及词嵌入、卷积神经网络、循环神经网络及LSTM/GRU等典型文本分类技术路线,读者可借此对比CNN与RNN在文本任务上的实现差异,掌握数据加载、模型构建、训练与预测的代码组织方式。目前已有224人学习,适合作为NLP入门阶段的练手项目,也可作为课程实验或小型文本分类任务的参考实现。

1. 文本分类项目拿到手,先别急着跑:三个问题决定成败

拿到「基于深度学习的文本分类.zip」这类项目,多数人的第一反应是解压、装依赖、跑train.py,然后被一堆版本报错和路径问题卡住半天。但真正决定这个项目能不能落地、能不能改成你自己业务的,是三个更靠前的问题:数据长什么样、分类粒度是句子级还是文档级、上线时是批处理还是单条实时推理。文本分类听起来是深度学习里最成熟的任务之一,可它恰恰是最容易「跑通 demo、上不了线」的方向——因为 demo 用的是 THUCNews、IMDB 这种干净均衡的数据集,而你的真实数据往往短文本多、类别不均衡、标注噪声大。这篇笔记按「数据准备 → 模型选型 → 训练调参 → 踩坑排查 → 进阶技巧」的顺序,把这类项目从压缩包到可用服务的关键环节拆开讲,适合已经会 PyTorch 基础、想把这个方向做扎实的工程师。

2. 数据管线:文本分类项目里最容易被低估的 60% 工作量

2.1 先看清压缩包里通常有什么

这类项目压缩包解压后,目录结构大同小异,常见形态是:

text_classification/ ├── data/ │ ├── train.txt │ ├── dev.txt │ └── test.txt ├── models/ ├── utils/ ├── config.py ├── train.py ├── predict.py └── requirements.txt

train.txt一般是「标签 + 制表符 + 文本」的格式,比如体育\t姚明今天出席了...。第一步不是改模型,而是把数据读进来做统计:类别数、每类样本量、文本长度分布。这三项直接决定后面 loss 怎么设、max_length 取多少、要不要做重采样。

import pandas as pd def load_data(path): # 按行读取,制表符切分,兼容空格分隔的脏数据 rows = [] with open(path, encoding='utf-8') as f: for line in f: line = line.strip() if not line: continue parts = line.split('\t') if len(parts) < 2: parts = line.split(' ', 1) # 兜底:空格分隔 if len(parts) == 2: rows.append({'label': parts[0], 'text': parts[1]}) return pd.DataFrame(rows) df = load_data('data/train.txt') print(df['label'].value_counts()) # 类别分布 print(df['text'].str.len().describe()) # 长度分布

这段代码的关键在兜底逻辑:真实项目里分隔符不统一是常态,硬编码split('\t')会在某些行直接抛异常或产生空标签。value_counts()出来后如果最大类是最小类的 10 倍以上,就要在训练时考虑加权或分层采样,否则模型会倾向于全预测成多数类,准确率看着高但 F1 很难看。

2.2 分词与词表:中文和英文走两条路

英文用空格切分基本够用,中文必须分词。常见做法是 jieba 分词后建词表,或者干脆按字切分。按字切分在短文本分类上经常不输分词,而且省掉了分词器版本和自定义词典的麻烦,我一般先用字级别跑一版 baseline。

from collections import Counter def build_vocab(texts, min_freq=2, max_size=20000): counter = Counter() for t in texts: counter.update(list(t)) # 字级别:直接按字符切 # 保留特殊符号,pad 用于对齐,unk 处理未登录字 vocab = {'<pad>': 0, '<unk>': 1} for char, freq in counter.most_common(max_size): if freq >= min_freq: vocab[char] = len(vocab) return vocab vocab = build_vocab(df['text'].tolist()) print('词表大小:', len(vocab))

min_freq=2是过滤只出现一次的字,降低词表噪声;max_size=20000对字级别中文通常够用,超过这个数说明数据里可能有大量特殊符号或乱码。词表建好后要固定下来存成 json,训练和推理必须用同一份,否则线上会出现大量<unk>,预测结果直接崩。

2.3 把文本变成定长张量的三个参数

文本进模型前要统一长度。max_length取多少不是拍脑袋,看长度分布的 95 分位数。截断和补齐的策略也要想清楚:短文本补<pad>,长文本截断时是掐头还是掐尾,对分类结果有影响——新闻类关键信息常在开头,评论类情绪词常在结尾。

import torch from torch.utils.data import Dataset class TextDataset(Dataset): def __init__(self, df, vocab, max_length=128): self.texts = df['text'].tolist() self.labels = df['label'].tolist() self.vocab = vocab self.max_length = max_length def __len__(self): return len(self.texts) def __getitem__(self, idx): chars = list(self.texts[idx])[:self.max_length] ids = [self.vocab.get(c, self.vocab['<unk>']) for c in chars] # 不足补 pad,超出已截断 ids = ids + [self.vocab['<pad>']] * (self.max_length - len(ids)) return torch.tensor(ids), torch.tensor(self.labels[idx])

max_length=128对多数短文本分类够用,长文档分类要提到 512 甚至分段处理。注意__getitem__里没有做 padding 的动态对齐,统一补到固定长度是为了能组成 batch,代价是短文本浪费算力。如果数据长度差异极大,可以用collate_fn做动态 padding,但实现复杂度会上升,第一版不建议。

3. 模型选型:从 TextCNN 到 BERT,什么场景用什么

3.1 别一上来就上预训练模型

很多人拿到文本分类项目直接上 BERT,结果发现训练慢、显存不够、调参复杂,最后效果还不如 TextCNN。选型要看三个条件:数据量、类别难度、推理延迟要求。数据量在几万条以内、类别区分度明显(比如情感二分类),TextCNN 或 BiLSTM+Attention 就能到 90% 以上的准确率,训练几分钟搞定。数据量十万级以上、类别语义接近(比如细粒度新闻分类),预训练模型才有明显优势。

模型适用数据量训练速度典型准确率场景推理延迟
TextCNN1万~10万快情感、意图低
BiLSTM+Attn1万~20万中长文本分类中
BERT-base5万以上慢细粒度分类高
蒸馏小模型不限中线上部署低

这张表是经验值,不是绝对边界。我一般先用 TextCNN 跑一版,看混淆矩阵里错在哪,如果错的是语义相近的类别,再换预训练模型,这样能省掉大量无效调参时间。

3.2 TextCNN 的最小实现与关键参数

import torch.nn as nn import torch.nn.functional as F class TextCNN(nn.Module): def __init__(self, vocab_size, embed_dim, num_classes, filter_sizes=(2,3,4), num_filters=128): super().__init__() self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0) # 多尺度卷积核,捕捉 2-4 字组合特征 self.convs = nn.ModuleList([ nn.Conv2d(1, num_filters, (fs, embed_dim)) for fs in filter_sizes ]) self.dropout = nn.Dropout(0.5) self.fc = nn.Linear(num_filters * len(filter_sizes), num_classes) def forward(self, x): x = self.embedding(x) # [B, L, E] x = x.unsqueeze(1) # [B, 1, L, E] x = [F.relu(conv(x)).squeeze(3) for conv in self.convs] x = [F.max_pool1d(i, i.size(2)).squeeze(2) for i in x] x = torch.cat(x, dim=1) x = self.dropout(x) return self.fc(x)

filter_sizes=(2,3,4)对应 2 到 4 个字的 n-gram 特征,中文短文本这个范围覆盖了大部分关键词组合。num_filters=128是每个尺度的卷积核数量,类别多或数据量大可以加到 256。padding_idx=0让 embedding 层不更新 pad 对应的向量,避免 pad 参与梯度计算。dropout=0.5在数据量小时很关键,能明显缓解过拟合。

3.3 用预训练模型时的两个必调项

如果决定上 BERT 类模型,学习率和 warmup 是必须调的。预训练模型微调的学习率通常在 2e-5 到 5e-5,比从头训练小一到两个数量级。warmup 比例设 0.1 左右,让模型前 10% 的步数慢慢升到目标学习率,避免一开始就把预训练权重冲坏。

from transformers import BertForSequenceClassification, AdamW, get_linear_schedule_with_warmup model = BertForSequenceClassification.from_pretrained('bert-base-chinese', num_labels=num_classes) optimizer = AdamW(model.parameters(), lr=2e-5, weight_decay=0.01) total_steps = len(train_loader) * epochs scheduler = get_linear_schedule_with_warmup( optimizer, num_warmup_steps=int(0.1 * total_steps), num_training_steps=total_steps )

weight_decay=0.01是 BERT 微调的常规设置,对 embedding 和 LayerNorm 参数通常不加权重衰减,但简化实现里统一加影响不大。num_labels必须和你的类别数一致,改完要检查分类头是否重新初始化——from_pretrained在num_labels变化时会自动重建分类层,但如果你手动加载权重就要注意别把旧分类头覆盖上去。

4. 训练与调参:让模型真正收敛的实操细节

4.1 损失函数与类别不均衡处理

交叉熵是默认选择,但类别不均衡时要用权重。权重可以按类别频率的倒数算,也可以直接给少数类一个经验权重。另一种做法是重采样,但重采样在文本任务里容易导致过拟合少数类,我一般优先用损失加权。

import numpy as np # 按类别频率计算权重,频率越低权重越高 class_counts = df['label'].value_counts().sort_index().values weights = 1.0 / class_counts weights = weights / weights.sum() * len(class_counts) class_weights = torch.tensor(weights, dtype=torch.float) criterion = nn.CrossEntropyLoss(weight=class_weights)

weights归一化到均值为 1 附近,避免整体 loss 尺度变化太大影响学习率。如果某个类样本极少(少于 50 条),加权效果有限,要考虑数据增强或直接合并到相近类别。

4.2 早停与模型保存的判断标准

验证集准确率不是唯一标准,类别不均衡时看 F1。早停的 patience 设 3 到 5 个 epoch,保存验证集 F1 最高的模型,而不是最后一个 epoch 的模型。

best_f1 = 0.0 patience = 3 wait = 0 for epoch in range(epochs): train_one_epoch(model, train_loader, optimizer, criterion) val_f1 = evaluate(model, dev_loader) if val_f1 > best_f1: best_f1 = val_f1 torch.save(model.state_dict(), 'best_model.pt') wait = 0 else: wait += 1 if wait >= patience: print(f'早停于 epoch {epoch}') break

evaluate里要用model.eval()和torch.no_grad(),否则 dropout 和 BN 会干扰验证结果,显存也会爆。保存state_dict而不是整个模型,加载时先实例化结构再load_state_dict,这样模型代码改动后旧权重还能用。

4.3 学习率与 batch size 的配合

学习率和 batch size 要一起调。batch size 增大时学习率可以适当增大,但文本分类里 batch size 通常 32 或 64 就够,再大对小数据集没有收益。学习率从 1e-3 开始试,TextCNN 这类小模型 1e-3 到 5e-4 都常见,BERT 微调则用 2e-5 到 5e-5。如果 loss 在前几个 epoch 就震荡不降,先降学习率;如果 loss 降得很慢,检查数据标签有没有错位。

5. 避坑与排查:文本分类项目里最常见的五类翻车

5.1 训练准确率很高但验证集很差

现象:训练集准确率 99%,验证集只有 70% 左右。原因通常是数据泄漏或过拟合。数据泄漏常见于去重没做好,同一条文本同时出现在训练和验证集;过拟合则是模型容量相对数据量太大。解决:先按文本内容去重再划分数据集,划分时用分层抽样保证类别比例一致;过拟合就加 dropout、减小模型、加正则。

5.2 预测结果全是同一个类别

现象:推理时不管输入什么,输出都是多数类。原因一般是类别不均衡加上没做加权,或者学习率太大导致模型塌缩。解决:加类别权重、检查学习率、看训练 loss 是否在下降。如果 loss 一直不降,检查标签编码是不是从 0 开始连续,CrossEntropyLoss要求标签在[0, num_classes-1]范围内。

5.3 线上推理和离线评估结果不一致

现象:离线 F1 有 0.9,上线后用户反馈很差。原因通常是预处理不一致:离线用 jieba 分词,线上用了不同版本或没加载自定义词典;或者 max_length 不一致,线上截断策略不同。解决:把预处理代码封装成独立模块,离线和线上调用同一个函数,词表和参数存成配置文件,部署时一起打包。

5.4 显存溢出但 batch size 已经很小

现象:batch size 降到 8 还是 OOM。原因可能是 max_length 设得太大,或者 BERT 类模型没有用梯度累积和混合精度。解决:先打印一个 batch 的实际形状,确认 max_length 是否符合预期;用torch.cuda.amp做混合精度训练,显存能省 30% 到 50%;梯度累积可以在小 batch 下模拟大 batch 效果。

5.5 中文乱码或特殊符号导致词表爆炸

现象:词表大小远超预期,里面大量生僻字和符号。原因:数据清洗没做,混入了 HTML 标签、URL、表情符号。解决:训练前统一做清洗,去掉 HTML 标签、URL、连续空白,表情符号可以保留也可以过滤,但要在词表构建前决定好,避免训练和推理阶段处理方式不同。

6. 进阶技巧:把分类模型压到能上线的体积

模型训好只是第一步,真正上线要考虑体积和延迟。BERT-base 动辄 400MB,放在服务里推理一次几百毫秒,QPS 上不去。我一般用知识蒸馏把 BERT 的能力迁移到一个小模型上,学生模型用 TextCNN 或三层 Transformer,体积能压到几 MB,延迟降到 10ms 以内。

具体做法是:先用 BERT 在训练集上跑出软标签(logits 或概率分布),然后让学生模型同时拟合真实标签和软标签,损失函数是两者的加权和。温度参数 T 设 2 到 5,软标签的权重从 0.5 开始调。蒸馏后学生模型准确率通常比直接训练高 2 到 5 个点,接近 BERT 的 95% 以上。

另一个技巧是动态量化。PyTorch 的torch.quantization.quantize_dynamic可以把 Linear 层权重从 float32 压到 int8,模型体积减半,推理速度提升 20% 到 40%,准确率损失通常在 1 个点以内。对文本分类这种任务,量化后的模型完全够用。

import torch.quantization # 动态量化:只量化 Linear 层,适合 NLP 模型 quantized_model = torch.quantization.quantize_dynamic( model, {nn.Linear}, dtype=torch.qint8 ) torch.save(quantized_model.state_dict(), 'quantized_model.pt')

量化后的模型加载方式和普通模型一样,但要注意量化模型不支持某些操作,比如自定义的 attention 实现可能需要改写成标准层。上线前一定要用真实流量做 A/B 测试,离线指标和线上表现经常有差距。

我自己的习惯是:任何文本分类项目,先跑通 TextCNN baseline,确认数据管线和评估流程没问题,再决定要不要上预训练模型。这个顺序帮我省过很多次「调了半天 BERT 发现是数据标签错了」的后悔药。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询