简介:这是一份面向Python开发者与NLP初学者的深度学习文本分类实战代码包,聚焦自然语言处理中的核心技术任务,帮助读者掌握从文本预处理、词嵌入到模型训练与评估的完整流程。压缩包内共6个文件,全部为.py脚本,涵盖数据加载、CNN与RNN模型构建、预测调用及训练入口等模块,结构紧凑适合快速上手。包体大小仅11KB,轻量易用,便于直接阅读和修改。目前已有224人学习下载,适合希望通过实际代码理解TextCNN、TextRNN以及LSTM/GRU在中文分类任务中应用的人群。资源中的脚本与NLP-beginner-Task2实践项目对应,可支撑学习者完成基于深度学习的文本分类实验,并在此基础上进行调参和模型对比。}
1. 基于深度学习的文本分类.zip:从压缩包到可复现的分类工程
基于深度学习的文本分类.zip 这个压缩包,通常不是给你一个训练好的模型,而是一整套训练链路:数据、代码、依赖清单、权重文件,都压在一个包里。它要解决的是真实业务里最常见的分类需求:把评论按情感分正负、把工单自动派给对应团队、把客服投诉自动打标签。适合谁用?做毕设的学生、接二手项目的新人,以及想在真实数据上验证深度学习文本分类那套流程的工程师。一个常见误判是拿到 zip 就直接解压跑训练,结果被编码、依赖版本和样本划分问题卡住半天。这篇文章从解压开始,把“把压缩包变成一套能复现、能调参、能交付的文本分类系统”的完整路径讲清楚。
2. 解压前的盘点和环境准备:先看清 zip 包里装的什么
提示:zip 文件名不带版本号时,先翻 README,再动代码。
2.1 压缩包内部该有什么:从 README 找入口
我拿到这种 zip 的第一件事不是解压,是先看压缩包列表。常见做法是用unzip -l列目录,先知道里面有几层文件夹、有没有 README、数据文件用什么后缀。这类包的目录结构大概率长成下面这张表这样:
| 路径 | 常见内容 | 需要确认的点 |
|---|---|---|
| README.md | 运行环境、入口命令 | 描述和实际代码是否一致 |
| requirements.txt | Python 依赖 | 版本是否锁定、torch 是否在里面 |
| data/ | 训练/验证/测试数据 | 分隔符、编码、标签取值 |
| src/ | train.py、model.py、utils.py | 入口文件到底叫什么 |
| models/ | 训练好的权重 | 用什么框架保存的 |
| logs/ | 训练日志 | 有没有跑通过的记录 |
先执行下面命令,把包里的内容列出来:
cd 基于深度学习的文本分类 unzip -l 基于深度学习的文本分类.zip | head -30unzip -l不实际解压,只读 zip 的中央目录,输出每一行的文件名、压缩前大小、压缩方式。head -30是限制只打印前 30 行,防止日志文件把列表刷屏。这一步能确认两件事:顶层是不是套了一个多余的文件夹,以及 requirements.txt 用的什么格式。顶部多套一层目录在项目交付里很常见,如果解压后直接找路径容易找不到入口。
2.2 用 conda 建一个干净的深度学习环境:Python 版本与 PyTorch 匹配
环境配置的坑通常不在 torch 本身,在于你装了一堆全局包后版本互相打架。我一般不会用系统 Python 直接装,而是用 conda 开一个干净环境,把 Python 版本固定下来。
conda create -n text-cls python=3.9 -y conda activate text-cls # 先把 PyTorch 装好,再装其他依赖 pip install torch --index-url https://pypi.tuna.tsinghua.edu.cn/simple pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple为什么先把 torch 装好,再装 requirements?因为很多 zip 里的 requirements.txt 写了torch==1.10.0这种旧版本,如果你按顺序直接装,可能装上老版本,和后面要用的 transformers、torchtext 对不上。先不指定版本装 torch,保证拿到当前 Python 3.9 下能用的稳定版,再让其余依赖去适配它。
python=3.9是我建议的中间值:3.8 太老,新版本的一些库已经放弃;3.10 虽然没问题,但个别老项目里 torch 的扩展可能编译不过。如果你机器的 GPU 驱动已经装好,先跑nvidia-smi看一眼支持的 CUDA 版本,再按 PyTorch 官网给出的对应命令重新装 GPU 版。CPU 版先把链路跑通,后续换 GPU 版不用改业务代码,只重装 torch 就行。
2.3 解压就翻车:zip 伪加密、CRLF 换行、目录名不一致
交付 zip 最容易翻车的第一个点是“伪加密”。表现是解压到一半弹窗要密码,但你根本没有密码。真加密的文件必须拿到密码才能解,伪加密只是压缩工具把加密标志位改了,文件内容本身没加密。这时候去搜“zip 密码移除”多半白费劲,先确认是不是伪加密再决定下一步。
用一段 Python 就能识别:
import zipfile with zipfile.ZipFile("基于深度学习的文本分类.zip") as z: for info in z.infolist(): print(info.filename, info.file_size, info.compress_type, "encrypted" if info.flag_bits & 0x1 else "plain")flag_bits是 zip 文件条目的通用位标志,第 0 位是 1 表示加密。如果打印出来大部分条目都显示 encrypted,但交付方明确说没设密码,那基本就是伪加密。用 7-Zip 这类工具解压时,点“确定”跳过密码通常就能解开。当然如果确实带密码,直接找交付方要,比暴力破解靠谱得多。
第二个坑是换行符 CRLF。Windows 下编辑过的脚本文件,行尾是\r\n,放到 Linux 或 macOS 上跑,会报出类似python3\r: No such file or directory的错误。先用file src/train.py看输出里有没有 "CRLF" 字样,再用下面命令批量转掉:
sed -i 's/\r$//' src/train.pysed的替换规则把每行结尾的\r去掉,-i原地修改,跑完再用file确认变成 LF。如果 zip 里文件多,可以在解压后对整个目录跑find . -name "*.py" -exec sed -i 's/\r$//' {} \;。
第三个坑是顶层目录名不一致。zip 包叫文本分类,解压出来的文件夹可能叫TextCls_v3,你按 zip 名去找路径会扑空。解压后先ls -la看一下顶层,再用mv修正成自己习惯的名字,避免后面所有绝对路径都跟着错。
3. 文本数据长什么样:清洗、分词与标签映射
3.1 CSV / JSON / TXT:三种常见存放格式与读取代码
文本分类项目里,数据格式是第一个分叉口。同一个 zip 包里常见三种情况:CSV 按列存、JSONL 逐行存、TXT 用分隔符切。我习惯写一个统一读取函数,避免每个脚本各读一遍:
import pandas as pd import json def load_dataset(path): if path.endswith(".csv"): df = pd.read_csv(path, encoding="utf-8-sig") return df["text"].tolist(), df["label"].tolist() if path.endswith(".jsonl"): text, label = [], [] with open(path, encoding="utf-8") as f: for line in f: obj = json.loads(line) text.append(obj["text"]) label.append(obj["label"]) return text, label if path.endswith(".txt"): labels, texts = [], [] for line in open(path, encoding="utf-8"): label, text = line.strip().split("\t", 1) labels.append(label) texts.append(text) return texts, labelsCSV 用utf-8-sig而不是utf-8,是为了把 BOM 头去掉。Windows 下 Excel 另存的 CSV 常带 BOM,用utf-8读会让第一列列名变成\ufefftext,后续按列名取数直接 KeyError。JSONL 适合大文件,逐行解析不会一次性把全量数据压进内存;TXT 按“标签 \t 文本”存,是最简单的交换格式,但注意split("\t", 1)里的第二个参数 1,意思是只按第一个 tab 切一刀,防止文本里还藏着 tab。
3.2 中文分词选 jieba 的精确模式还是全模式:两个关键参数
中文文本和英文不一样,词之间没有空格,所以要分词。这个 zip 里如果处理的是商品评论、新闻稿、工单描述,常见做法是直接上 jieba。我给的默认配置是精确模式:
import jieba import re STOPWORDS = set("的 了 是 在 我 你 他 它".split()) def tokenize(text, max_len=128): text = re.sub(r"\s+", "", text) words = jieba.lcut(text, cut_all=False) words = [w for w in words if w not in STOPWORDS] return words[:max_len]cut_all是 jieba 最关键的参数。cut_all=False是精确模式,切出来的词最接近自然语义,比如“研究生命科学”会切成“研究/生命科学”;cut_all=True是全模式,会把所有可能的词都切出来,变成“研究/研究生/生命/生命科学/科学”,召回高但噪声大,文本分类场景基本不用全模式。另一个参数HMM默认是 True,它负责识别词典里没有的新词,在医疗、法律这类专业文本里建议保持开启。
max_len=128用来截断长文本。这个值不是越大越好,因为文本分类任务里决定性特征通常分布在开头和结尾。BERT 类模型最长支持 512,TextCNN 用 128 已经覆盖大部分场景。去停用词这步看起来简单,但我建议第一版先别做太狠,只去掉高频无意义的虚词,因为有些情感词恰好是短词,比如“服了”“绝了”,去多了反而丢信息。
3.3 类别不均衡:过采样、欠采样与 class_weight
文本分类里“投诉”可能只占 1%,如果直接训练,模型学到的就是把所有文本都预测成“正常”,因为这样准确率也有 99%。这个 zip 如果没给你预处理好的均衡样本,你就要自己处理。常见做法三选一:class_weight 加权、过采样少数类、欠采样多数类。我一般先用 class_weight,因为它不动数据分布:
from sklearn.utils.class_weight import compute_class_weight import numpy as np classes = np.array(sorted(set(labels))) weights = compute_class_weight(class_weight="balanced", classes=classes, y=np.array(labels)) print(dict(zip(classes, weights)))class_weight="balanced"会自动按类别频率反比算权重:少数类样本少,权重就大。后续把weights转成 tensor 传给CrossEntropyLoss(weight=...),少数类的 loss 会被放大,梯度更新的力度也更大。注意这个方案只在单标签多分类下直接用,如果是多标签分类——一篇文章同时属于“科技”和“政策”——不能用 softmax 加 CrossEntropyLoss,要换成 sigmoid 加BCEWithLogitsLoss,此时类别不均衡要按每个标签单独算正负样本比例。
如果用采样器做过采样:
from torch.utils.data import WeightedRandomSampler label_ids = [label2id[l] for l in labels] counts = np.bincount(label_ids) sample_weights = 1.0 / counts[label_ids] sampler = WeightedRandomSampler(sample_weights, num_samples=len(labels), replacement=True)replacement=True表示允许重复采样同一个样本,少数类会在每个 epoch 被多次抽到。过采样和 class_weight 建议二选一,两个叠加会让少数类梯度更新过猛,容易过拟合。
4. 从 FastText 到 TextCNN 再到 BERT:三个基线怎么选
4.1 FastText:先把数据链路验证通,再训练模型
文本分类项目拿到手,最快验证数据链路的方式不是直接上 BERT,而是 FastText。它训练快、占用内存低,还能在训练过程中顺便学到子词信息,对中文这种词形变化不明显的语言来说,是一个够用的基线。我用 gensim 版本比较多,因为和 sklearn、pandas 配合没有编译负担:
from gensim.models import FastText sentences = [jieba.lcut(t) for t in texts] model = FastText(sentences, vector_size=128, window=5, min_count=2, workers=4, epochs=20) def encode(text): words = [w for w in jieba.lcut(text) if w in model.wv] return np.mean([model.wv[w] for w in words], axis=0) if words else np.zeros(128)逻辑上,FastText 的每一条词向量由内部的字符 n-gram 叠加而成,词典外的词也能通过子词组合得到近似向量,这是它对中文未登录词兜底的原因。encode函数把一句话里每个词的向量取平均,得到句子向量,然后喂给 sklearn 的逻辑回归分类器。取平均会丢掉词序信息,所以它上限不高,但作为第一个版本已经足够验证整条数据链路是不是通的。
参数上,vector_size=128在几万到几十万条文本规模下够用,没必要一上来就 300;min_count=2表示出现次数低于 2 的词直接丢弃,太低会引入噪声;window=5是上下文窗口,短文本场景可以降到 3。如果你的目标是先拿指标,可以把编码后的向量交给逻辑回归,等这个分数出来了,再决定要不要上更复杂的深度模型。很多 case 里逻辑回归的 F1 已经很高,说明特征线性可分,后续深度模型的增量有限。
4.2 TextCNN:PyTorch 实现的核心代码与调参要点
TextCNN 是文本分类里最稳的深度模型,参数少、训练快,在中小规模数据上不容易过拟合。它和 FastText 的本质区别是会保留词序,用多组卷积核提取局部短语特征。下面是最小可跑的模型定义:
import torch.nn as nn class TextCNN(nn.Module): def __init__(self, vocab_size, embed_dim=128, num_filters=100, filter_sizes=(2, 3, 4), num_classes=2, dropout=0.5): super().__init__() self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0) self.convs = nn.ModuleList([ nn.Conv1d(embed_dim, num_filters, size) for size in filter_sizes ]) self.dropout = nn.Dropout(dropout) self.fc = nn.Linear(num_filters * len(filter_sizes), num_classes) def forward(self, x): x = self.embedding(x) # (B, L, E) x = x.transpose(1, 2) # (B, E, L) pooled = [] for conv in self.convs: h = torch.relu(conv(x)) # (B, F, L-k+1) pooled.append(h.max(dim=2).values) x = torch.cat(pooled, dim=1) x = self.dropout(x) return self.fc(x)forward 里发生了什么:embedding 把每个 token 映射成稠密向量,得到(B, L, E);transpose(1, 2)把维度换成(B, E, L),因为 Conv1d 期望的输入是“通道维在第二维”;每个卷积核在序列方向滑动,提取对应 n-gram 模式;max(dim=2)是全局最大池化,取每个特征图最大的激活值,相当于“这条文本里最强烈的特征信号”。最后把多个卷积核的输出拼起来过一层全连接。
参数上,filter_sizes=(2, 3, 4)对应 2-gram、3-gram、4-gram,让模型同时看不同长度的短语;num_filters=100是每组卷积核的输出通道数,越大拟合能力越强,但也更容易过拟合;padding_idx=0保证 padding 位置不参与 embedding 更新。训练循环里有两个点值得单独说明:
criterion = nn.CrossEntropyLoss(weight=class_weight_tensor) optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3) for epoch in range(15): model.train() for x, y in train_loader: logits = model(x) loss = criterion(logits, y) optimizer.zero_grad() loss.backward() optimizer.step()CrossEntropyLoss的weight参数直接接上一章算出的类别权重,少数类每个 batch 的损失被放大。优化器用AdamW而不是Adam,因为 AdamW 把权重衰减从梯度更新中解耦,在文本分类里普遍更稳。lr=1e-3是随机初始化模型的标准起点,如果你的 loss 曲线在前 5 个 epoch 里震荡,降到3e-4重跑,不是一个玄学问题,是学习率超过了这个数据集的合理范围。
4.3 BERT 微调:什么时候才值得上预训练模型
BERT 这类预训练模型在文本分类上的效果上限高,但它有成本:显存占用大、推理慢、训练时间以小时计。用 transformers 库微调的代码很短:
from transformers import AutoTokenizer, AutoModelForSequenceClassification, Trainer, TrainingArguments tokenizer = AutoTokenizer.from_pretrained("bert-base-chinese") model = AutoModelForSequenceClassification.from_pretrained( "bert-base-chinese", num_labels=2 ) trainer = Trainer( model=model, args=TrainingArguments( output_dir="./bert-out", learning_rate=2e-5, per_device_train_batch_size=16, num_train_epochs=3, warmup_ratio=0.1, evaluation_strategy="epoch", ), train_dataset=train_ds, eval_dataset=val_ds, ) trainer.train()learning_rate=2e-5是预训练权重微调的标准起点,比随机初始化模型的 1e-3 小两个数量级。原因是 BERT 的权重已经收敛过了,学习率太大会把学到的基础语言表示冲刷掉。warmup_ratio=0.1的意思是前 10% 的训练步数把学习率从 0 线性升到设定值,减少刚开始训练时的震荡。如果下载bert-base-chinese太慢,可以换成 ModelScope 上对应的中文模型路径,模型结构一样,代码几乎不用改。
什么时候不值得上 BERT?训练集不足 5000 条时,BERT 很容易过拟合到训练集上,验证集指标反而不如 TextCNN 加早停;推理延迟要求控制在几百毫秒以内时,BERT 的参数量也未必划算。严格说,LLM 也属于深度学习,但文本分类这种任务没必要一上来就用最贵的模型。我的判断顺序是:FastText 拿基线,TextCNN 看有没有提升空间,BERT 只在数据量和预算都撑得住的时候才出手。
5. 避坑记录:这个 zip 里最常见的 4 个翻车现场
5.1 中文乱码:编码探测与统一转 UTF-8
现象:pd.read_csv读完中文全是乱码,或者直接报UnicodeDecodeError;代码文件在 Linux 下跑起来后,中文注释变成一片乱码。
原因:Excel 另存的 CSV 默认是 GBK,而 pandas 在 Linux 下默认按 UTF-8 解码;反过来,如果文件真的是 UTF-8 但带了 BOM,有些解析器会把 BOM 当成字符读进去。
解决:先探测编码,再决定用什么参数读文件。
import chardet with open(path, "rb") as f: raw = f.read(10000) enc = chardet.detect(raw)["encoding"] print(enc) df = pd.read_csv(path, encoding=enc if enc else "utf-8")chardet.detect会扫描字节统计给出最可能的编码,但它在样本太小时会误判,所以只取前 10000 字节就够。拿到编码后,我一般会顺手把文件重存成 UTF-8,后续所有脚本统一用encoding="utf-8",避免每个文件都要探测一次。记住:给 Python 源码加# -*- coding: gbk -*-只代表解释器按 GBK 读源码,不代表运行时文件读写会自动转码。
5.2 训练集验证集重叠:虚假的高准确率
现象:训练集 F1 接近 0.99,验证集一跑掉到 0.6,线上效果更差,整个过程表现出典型的“训练越好,验证越差”。
原因:数据划分用了随机切分,但没去重。同一个文本被复制多次,或者同一用户的文本同时分到训练集和验证集。深度模型有很强的记忆能力,同一句话在训练集见过了,验证集再遇到就直接输出“正确”答案。
解决:先按文本内容去重,再去切分。如果样本还带用户 ID,考虑按用户分组切分。
df = df.drop_duplicates(subset=["text"], keep="first") df = df.sample(frac=1, random_state=42) train = df.iloc[:int(0.8 * len(df))] val = df.iloc[int(0.8 * len(df)):]drop_duplicates(subset=["text"])只按 text 列去重,keep="first"保留第一次出现的那条。random_state=42固定随机种子,保证每次跑出来的划分一致。如果数据有时间属性,比如工单按日期到达,用时间切分比随机切分更接近真实上线场景,因为模型永远只能拿过去预测未来。这个坑在别人交付的 zip 里几乎必然存在,跑通第一版后第一件事就是检查它。
5.3 小样本训飞了:学习率、warmup 和模型复杂度
现象:loss 反复横跳,准确率卡在多数类占比上,验证 F1 只有零点几。
原因:数据量太小时模型直接“背题”,学习率过大让梯度更新不稳定,随机初始化的 embedding 在前几个 epoch 里梯度噪声很大,甚至把原有的语义信息冲掉。
解决:先把学习率降到1e-4或3e-5,再冻结 embedding 层,让模型先学卷积层。
for param in model.embedding.parameters(): param.requires_grad = False这段代码把 embedding 参数设为不更新。前几个 epoch 里,模型只能调整卷积层和全连接层的参数,embedding 保持初始状态,梯度噪声大幅减少。等 loss 曲线开始平滑下降,再把requires_grad改回 True,对全模型微调。如果你嫌这个操作麻烦,还有一个更朴素的选择:直接把词表截到 top 20000,去掉长尾词,模型要学的参数一下就少了很多。
小样本还有一个反直觉的坑:模型复杂度越高,验证集指标越差。如果 TextCNN 都过拟合,换 BERT 只会更糟。先加早停、加 dropout,再考虑换模型。
5.4 GPU 显存不足:batch size 与梯度累积
现象:训练到第二个 batch 直接报CUDA out of memory,调用torch.cuda.empty_cache()也没用。
原因:显存被 batch 里最长的那条序列占满,empty_cache()只能释放缓存块,不能降低单次 forward 的峰值显存。文本分类的数据长尾分布很明显,一条 5000 字的样本会把整个 batch 的显存顶爆。
解决:梯度累积,用时间换显存。
accumulation_steps = 4 optimizer.zero_grad() for i, (x, y) in enumerate(train_loader): loss = criterion(model(x), y) loss = loss / accumulation_steps loss.backward() if (i + 1) % accumulation_steps == 0: optimizer.step() optimizer.zero_grad()accumulation_steps=4的意思是每 4 个 batch 才更新一次参数,等效于把 batch size 放大 4 倍。注意loss要除以累积步数,否则每个 batch 的梯度都被放大 4 倍,学习率就得跟着降。如果梯度累积到 8 还爆,那就直接做序列截断,把max_len压到 64。文本分类的关键特征往往在开头和结尾,中间大段内容对分类贡献有限,短文本截断对指标的伤害通常远小于显存不足带来的折腾。
6. 验证模型的通用做法:混淆矩阵、宏平均 F1 与坏样本复盘
6.1 一次跑全:混淆矩阵与分类报告
文本分类的评估不能只看 accuracy。类别不平衡时,accuracy 会被多数类带着走,模型全预测成“正常”也能拿 0.9 的准确率。我每跑完一个模型都会固定输出这两行:
from sklearn.metrics import classification_report, confusion_matrix preds = [] model.eval() with torch.no_grad(): for x in test_loader: preds.extend(model(x).argmax(dim=1).tolist()) print(classification_report(test_y, preds, digits=4)) print(confusion_matrix(test_y, preds))classification_report里最值得看的是 macro F1,它把每个类别的 F1 单独算出来再平均,不受多数类影响,能真实反映少数类到底被分对了多少。confusion_matrix能看出两个类别总被混淆——比如“投诉”和“咨询”边界模糊,它们互相错分的数值会一眼暴露。
6.2 坏样本复盘比调参更重要
指标看完,我会把错分样本导出来:
import pandas as pd bad = pd.DataFrame({"text": test_texts, "true": test_y, "pred": preds}) bad = bad[bad["true"] != bad["pred"]] bad.to_csv("bad_cases.csv", index=False, encoding="utf-8-sig")encoding="utf-8-sig"保证 Excel 打开这个 CSV 时中文正常。逐条看坏样本,通常只有两种结果:标签本身标错了,或者文本确实有歧义。前者说明是数据质量问题,修数据比重训模型更有用;后者才是模型真正该优化的方向。大部分团队在这个 zip 上花的时间不是训练,而是被数据问题和评估方式误导,绕了一大圈。
我现在拿到任何文本分类项目,第一件事一定是先跑基线、看坏样本,确认数据没问题,再谈深度学习算法。真正把模型往前推的,往往是那句“标签定义不对齐”而不是谁的神经网络更复杂。这个 zip 跑通了,后面的路就顺畅了。希望帮到你。
本文还有配套的精品资源,点击获取