ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

中文短文本分类实战:从数据清洗到模型部署的完整项目链路

中文短文本分类实战:从数据清洗到模型部署的完整项目链路 如果你经常跟代码和数据打交道多半见过这种看起来像乱码的文件夹名ch4_1、final_v2、test_0623……很多人会直接跳过但对我来说ch4_1不是一个随手敲的目录而是一整套从数据清洗、特征工程到模型训练、接口部署的短文本分类项目。这名字其实有规律它代表“第 4 轮实验的第 1 个阶段”。我们在做机器学习项目时经常要不断换方案、调参数同一个模型反复改十几次如果都用model_new这种名字过两周就分不清谁是谁了。所以我给自己定了个规则每一轮实验单独建目录按ch轮数_阶段数编号。ch4_1就对应我做的第 4 轮实验里面跑的是第一版完整的文本分类方案。整个项目做的事很简单把一段用户反馈或客服工单自动分类到预定义的 5 个类别里比如“咨询”“投诉”“售后”“建议”“其他”。这个需求在现实里特别普遍电商平台要看售后留言运营团队要整理投诉工单社区要过滤垃圾评论本质上都是同一件事。我选的落地方式是中文短文本分类技术上覆盖了 NLP 中最常见的完整链路读数据、洗数据、分词、向量化、建模、调参、部署。如果你刚学完机器学习基础想找个能把“理论变成能跑的东西”的练手项目这一篇可以直接照着做如果你已经有经验也可以重点看后面的调参记录和避坑清单那里才是最有价值的部分。在开始拆解之前先把整条数据流理一遍原始文本从 CSV 读进来经过清洗和分词转成模型能读的数字序列再用一个带 embedding 的神经网络训练分类器最后通过 FastAPI 暴露成接口。整个流程不依赖复杂框架环境只需要 Python 3.8 以上加上常见的pandas、jieba、scikit-learn、torch就能跑。下面我把每个环节的设计思路和实操细节都摊开讲。1. 项目概述ch4_1 到底是什么样的项目这一节先把这个项目的全貌讲清楚包括它长什么样、核心要解决什么问题、最后交付了什么以及为什么值得你完整走一遍。1.1 核心需求拆解做项目的第一件事不是写代码而是把需求拆到能落地的粒度。我在ch4_1这个实验里需要解决的核心问题是怎么让机器根据一小段中文文字判断它属于哪个业务类别。这个需求听起来简单但落到数据上会有几个麻烦。首先是文本太短好的分类结果依赖关键词和上下文纯靠简单规则很难覆盖所有表达其次是类别分布可能不均衡比如投诉留言远多于建议留言再次是用户写的内容夹杂表情符号、口语、错别字直接丢给模型效果会很差。所以整个项目不是只“训练一个模型”就完了而是要把数据质量、特征表达、模型结构、评估方式串起来。我在这一轮实验里把目标切成四个子任务数据读取与质量探查搞清楚原始 CSV 的字段分布、脏数据比例、类别数量。文本清洗与特征工程写一套统一的中文文本预处理流程让模型输入尽量干净。模型搭建与训练先跑一个传统机器学习基线再做深度模型对比效果。模型部署与验证把训练好的模型包装成 HTTP 服务能实时返回分类结果。四个子任务全都跑通之后ch4_1这个目录才算真正闭环。很多人做练手项目只做到“训练完了打印出准确率”就停实际上后面那半步——把模型用起来——才是工作中最常被问到的部分。1.2 适合谁来参考如果你符合下面任一情况这篇文章会比较对胃口刚学完 Python 和机器学习基础想看一个“不是 Kaggle 练习题”的真实项目怎么做。做数据分析或后端开发经常要处理中文短文本想找一个可以直接复用的分类流程。想了解模型训练之外的东西目录怎么组织、参数怎么调、模型怎么部署。反过来如果你完全没碰过 Python建议先补一下列表、字典、函数和基础 Pandas 操作再来看不然有些代码会跟得吃力。我不会把每个语法都解释一遍但所有关键步骤的设计原因都会讲清楚。1.3 最终交付物ch4_1实验结束后我留下了四样东西一份干净的代码目录包含数据处理脚本、训练脚本、预测脚本。两个可对比的模型文件一个逻辑回归基线模型一个 TextCNN 深度模型。一份记录调参过程的训练日志方便回溯哪个参数对结果影响最大。一个用 FastAPI 写的接口服务输入一句话就能返回类别和置信度。这四样东西合在一起就是一个比较完整的“从文本到服务”的最小工程样本。2. 整体设计与技术选型为什么用这套方案很多人一上来就用最复杂的模型结果数据没洗干净模型再深也白费。我的习惯是先把整体方案画清楚再决定每一步用什么工具。2.1 技术栈的选择逻辑这一轮实验的技术栈我特意选了“够用但不炫技”的组合。数据操作直接用pandas处理表格数据最方便读 CSV、统计类别分布、做分组聚合都是几行代码的事。分词用jieba中文分词的事实标准虽然对新词不太敏感但对短文本分类任务足够用。传统基线模型选scikit-learn里的LogisticRegression配合 TF-IDF 特征能很快看出数据的可分离程度。深度模型我选了torch自己写 TextCNN不直接用 HuggingFace 的预训练模型。为什么不直接用 BERT这背后有个很现实的权衡。预训练模型效果确实好但对环境要求高推理慢模型文件大在小项目里属于“杀鸡用牛刀”。先跑通基线模型确认数据质量没问题再用深度模型提升是更稳妥的路径。后续如果你想把精度再往上拉换成 BERT 系列也不难只要把特征编码部分替换掉就行。2.2 项目目录结构做实验项目最忌讳的就是把所有脚本堆在一个文件里。我见过不少人写了一个train.py里面有洗数据、建模、训练、画图的全套代码两千多行改一个参数要找半天。ch4_1的目录结构我刻意分成了下面这样ch4_1/ ├── data/ │ ├── raw/ # 原始 CSV 文件 │ ├── processed/ # 清洗后的中间文件 │ └── labels.txt # 类别映射表 ├── src/ │ ├── clean.py # 文本清洗函数 │ ├── features.py # 分词、构建词汇表、序列化 │ ├── models.py # 模型定义 │ ├── train.py # 训练主脚本 │ ├── predict.py # 单条预测脚本 │ └── server.py # FastAPI 接口服务 ├── checkpoints/ │ ├── baseline_lr.pkl │ └── textcnn.pt ├── logs/ │ ├── train_lr.log │ └── train_cnn.log └── requirements.txt如果只跑一次实验这个结构显得冗余但只要你想复现结果、改参数、加数据这种分层的目录能帮你省下大量时间。数据、代码、模型、日志分开出问题时能快速定位是数据还是模型的问题。2.3 命名规范的由来ch4_1 背后的实验管理习惯上面说到的“按轮次编号”其实是从实际教训里长出来的。早先我有个项目叫model_new_final_2半年后回看已经完全不知道里面跑的什么配置重跑花了一个下午。后来我强制自己用一个简单的规则ch表示一次大的实验方向。比如换模型结构、换特征方式就开新的一轮。第一位数字是轮次编号ch4就是第 4 轮。下划线后的数字是这个轮次里的阶段编号_1表示第 1 个可运行版本。这样当我在ch4_2里调整了学习率我依然能明确知道ch4_1是上一版配置。模型实验的“可复现性”不是靠记忆而是靠这种操作习惯。3. 数据清洗与特征工程让文本变成模型能读的数字任何 NLP 项目里最花时间的都不是建模而是数据清洗。模型学到什么完全取决于你喂进去什么。3.1 原始数据的常见形态这次用的原始数据是一份客服工单 CSV字段大致包括工单编号、提交时间、反馈内容、人工标注的类别。content字段里能看到大量真实用户输入比如我想问下你们快递什么时候发货订单号是 20240715 垃圾产品用了三天就坏了再也不买了 可以改进一下APP的搜索功能吗总是搜不到需要的商品这些文本的问题是口语化严重、夹杂数字和标点、大小写英文混用、有些还带表情符号。如果不处理模型会把“发货”“发 货”“运单”当成完全不同的东西。所以清洗的第一步就是统一格式。3.2 清洗步骤与对应代码清洗模块我写成了独立的src/clean.py核心函数是这样的import re import pandas as pd def clean_text(text: str) - str: if not isinstance(text, str): return # 去掉 HTML 标签 text re.sub(r[^], , text) # 统一小写避免大小写干扰 text text.lower() # 去掉 URL text re.sub(rhttps?://\S|www\.\S, , text) # 把连续空白压缩成单个空格 text re.sub(r\s, , text) # 去除特殊符号保留中英文和数字 text re.sub(r[^\u4e00-\u9fa5a-zA-Z0-9], , text) return text.strip()注意最后一行正则我把所有非中文、非英文字母、非数字的字符都删掉了包括标点。对短文本分类来说标点往往不是分类特征删掉能显著减小特征空间。如果你做的是情感分析可能需要保留感叹号但这里不需要。清洗之后我顺手做了一轮数据探查。data[label].value_counts()的结果很能说明问题投诉类占了快 47%咨询类占 30%建议类只有 8%。这种不均衡如果不处理模型会学成“永远猜投诉”也能拿到不错的准确率但实际毫无用处。后面专门说怎么处理。3.3 分词、停用词与序列化文本变成数字序列前要先分词。jieba用法很简单import jieba def tokenize(text: str) - list: return [w for w in jieba.lcut(text) if w.strip()]但分词结果里有很多“的、了、吗、啊、在、是”这类停用词。这些词对分类几乎没有判别力还会增加词汇表大小。我这里准备了一个常用中文停用词表在分词后直接过滤。分词完成后下一步是构建词汇表和序列化。这里有个容易踩的坑必须先基于训练集构建词汇表再把训练集、验证集、测试集统一映射成数字序列。如果先拿所有数据建词汇表再切分会造成数据泄露模型评估结果会虚高。我用一个简单的方式处理from collections import Counter import json def build_vocab(tokenized_texts, min_count2, max_vocab20000): counter Counter() for tokens in tokenized_texts: counter.update(tokens) vocab {pad: 0, unk: 1} idx 2 for word, count in counter.most_common(max_vocab - 2): if count min_count: break vocab[word] idx idx 1 with open(data/processed/vocab.json, w, encodingutf-8) as f: json.dump(vocab, f, ensure_asciiFalse) return vocabpad用来补长到统一长度unk代表词汇表外的新词。设置min_count2是避免那些只出现一次的噪声词进入模型。3.4 类别不均衡的处理思路处理不均衡业界常用的方法包括过采样、欠采样、换损失函数。我这轮实验用了最直接有效的方式对训练集中的少数类样本做随机复制让每个类别的样本量大致持平。from sklearn.utils import resample def balance_dataset(df, target_col, random_state42): max_size max(df[target_col].value_counts().values) balanced_dfs [] for label in df[target_col].unique(): sub df[df[target_col] label] # 对少数类做上采样 sub resample(sub, replaceTrue, n_samplesmax_size, random_staterandom_state) balanced_dfs.append(sub) return pd.concat(balanced_dfs, ignore_indexTrue)但这只是治标。少数类样本本身信息量有限复制再多模型能学到的只是重复记忆。更治本的方式是数据增强比如对文本做近义词替换。不过这会增加训练时间我作为第一版方案没做留在后面的迭代轮次里。4. 模型训练与调优从基准模型到深度模型数据跑通之后马上进入建模环节。我坚持一个原则先跑一个简单模型拿到可解释的基线再考虑复杂模型。4.1 基准模型TF-IDF 加逻辑回归第一步我把分词后的文本用 TF-IDF 转成稀疏向量然后交给逻辑回归。逻辑回归是分类任务的绝佳起点训练快、可解释性强、对特征线性组合能用权重一眼看出哪些词影响大。from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.linear_model import LogisticRegression from sklearn.metrics import classification_report vectorizer TfidfVectorizer( tokenizerlambda x: x, preprocessorlambda x: x, max_features30000, ngram_range(1, 2), ) train_texts [ .join(seq) for seq in train_tokens] val_texts [ .join(seq) for seq in val_tokens] X_train vectorizer.fit_transform(train_texts) X_val vectorizer.transform(val_texts) clf LogisticRegression(max_iter1000, C1.0) clf.fit(X_train, y_train) y_pred clf.predict(X_val) print(classification_report(y_val, y_pred))这里的关键参数是ngram_range(1, 2)。只保留单个词会丢掉很多组合语义比如“客服 电话”和“电话 客服”虽然词相同但含义不同加入二元词组能捕捉一部分这种搭配。在短文本分类里这个参数往往比调模型本身收益更大。这一版跑下来验证集上的宏平均 F1 大概在 0.84 左右。对于第一版来说已经不错但还远没到上限因为逻辑回归学不到词序和上下文信息。4.2 升级到 TextCNN核心结构与参数选择接下来上深度模型。中文短文本分类里TextCNN 是个非常经典的架构。它用一组不同大小的卷积核扫描词向量序列相当于在“连续 n 个词组成的短语”里寻找关键模式。我用torch实现了 TextCNN模型定义放在src/models.py里核心部分如下import torch import torch.nn as nn class TextCNN(nn.Module): def __init__(self, vocab_size, embed_dim100, num_classes5, kernel_sizes(2, 3, 4), num_filters64, max_len64): super().__init__() self.embedding nn.Embedding(vocab_size, embed_dim, padding_idx0) self.convs nn.ModuleList([ nn.Conv1d(embed_dim, num_filters, kernel_sizek) for k in kernel_sizes ]) self.dropout nn.Dropout(0.3) self.fc nn.Linear(len(kernel_sizes) * num_filters, num_classes) def forward(self, x): # x shape: (batch, seq_len) x self.embedding(x) # (batch, seq_len, embed_dim) x x.transpose(1, 2) # (batch, embed_dim, seq_len) conv_outputs [] for conv in self.convs: c torch.relu(conv(x)) # (batch, num_filters, seq_len - k 1) c torch.max_pool1d(c, c.size(2)).squeeze(2) # (batch, num_filters) conv_outputs.append(c) x torch.cat(conv_outputs, dim1) x self.dropout(x) return self.fc(x)为什么用三个不同尺寸的卷积核每个尺寸对应不同的 n-gram 范围size2抓二元词组size3抓三元组合size4抓更长一点的固定搭配。短文本里这些局部特征非常重要TextCNN 的设计思路就是用卷积核去扫描这些局部模式再通过最大池化提取最明显的信号。4.3 训练循环与参数设置训练脚本我单独写在src/train.py里。关键参数设置如下嵌入维度embed_dim100句子最大长度max_len64批次大小batch_size64学习率lr1e-3优化器Adam损失函数CrossEntropyLoss早停轮数patience3这里说一下几个重要参数的来历。max_len不是随便定的我统计了训练集分词后长度的分布超过 64 个词的样本不到 2%所以截断到 64 不影响大多数样本。学习率选1e-3是 Adam 的默认推荐值先用这个跑到稳定再说。训练循环里我加了三样东西习惯上很推荐学习率衰减、早停、日志记录。from torch.optim.lr_scheduler import ReduceLROnPlateau optimizer torch.optim.Adam(model.parameters(), lr1e-3) scheduler ReduceLROnPlateau(optimizer, modemax, factor0.5, patience1)ReduceLROnPlateau的意思是验证集指标连续 1 轮不涨就把学习率减半。这个机制比固定学习率省心不用人为盯训练曲线。早停则进一步防止过拟合连续 3 轮验证集 F1 没有刷新新高就停止训练并恢复最优权重。训练日志长这样epoch1, loss0.6871, val_f10.8712, lr0.0010 epoch2, loss0.3482, val_f10.9023, lr0.0010 epoch3, loss0.2167, val_f10.9131, lr0.0010 epoch4, loss0.1540, val_f10.9188, lr0.0010 epoch5, loss0.1135, val_f10.9102, lr0.0005从日志能清楚看到第 4 轮验证 F1 最高第 5 轮开始往下掉说明模型开始过拟合了。早停机制会保留第 4 轮的权重这也是为什么日志记录这么重要——光看最后一个 epoch 的结果会误判模型好坏。4.4 模型保存与加载训练完不能只留在内存里。PyTorch 的保存方式有讲究官方推荐同时保存模型参数和优化器状态方便续训但在部署场景只需要模型参数就够了。checkpoint { model_state_dict: model.state_dict(), vocab_size: vocab_size, num_classes: num_classes, embed_dim: embed_dim, max_len: max_len, } torch.save(checkpoint, checkpoints/textcnn.pt)加载时要注意必须先按照max_len把输入文本做同样的清洗、分词、映射、补长再喂给模型。很多人在部署阶段效果差就是因为省略了预处理直接在原始字符串上请求模型。最终 TextCNN 在验证集上的宏平均 F1 到了 0.9175比逻辑回归基线提升了约 8 个点主要是提升在“投诉”和“建议”这两个容易混淆的类别上。说明卷积结构确实捕捉到了短文本中的局部组合特征。5. 部署与使用把训练结果变成可用的接口模型训练得再漂亮如果不给别人调用价值就打折了。这一步我做了个极轻量的接口服务用 FastAPI 把模型包起来。5.1 搭建最小接口服务src/server.py的完整思路很短加载模型和词汇表加载预处理器接收 POST 请求返回类别和置信度。from fastapi import FastAPI from pydantic import BaseModel import torch app FastAPI() class PredictRequest(BaseModel): text: str class PredictResponse(BaseModel): label: str confidence: float ckpt torch.load(checkpoints/textcnn.pt, map_locationcpu) model TextCNN( vocab_sizeckpt[vocab_size], num_classesckpt[num_classes], embed_dimckpt[embed_dim], max_lenckpt[max_len], ) model.load_state_dict(ckpt[model_state_dict]) model.eval() app.post(/predict, response_modelPredictResponse) def predict(req: PredictRequest): seq text_to_sequence(req.text, vocab, max_len) with torch.no_grad(): logits model(torch.tensor([seq])) probs torch.softmax(logits, dim1).squeeze(0) label_idx probs.argmax().item() return PredictResponse( labelid_to_label[label_idx], confidenceprobs[label_idx].item(), )需要注意model.eval()这一步很容易被漏掉。PyTorch 模型默认是训练模式Dropout 层仍然会随机丢弃节点直接用于推理会让结果抖动。评估和部署前务必切到eval()模式。5.2 本地验证接口启动服务后我用命令行验证一次curl -X POST http://127.0.0.1:8000/predict \ -H Content-Type: application/json \ -d {text: 这个充电器用了一周就坏了客服能不能尽快处理}返回结果{label:投诉,confidence:0.9202}这个置信度说明模型对这个判断很有把握。我特意拿几条训练里没见过的句子去测效果符合预期。如果你拿到一个低置信度的结果比如 0.45建议在业务方加一个人工复核通道而不是完全相信模型。5.3 项目可复现性准备为了让别人能一键跑起来requirements.txt里我固定住了核心依赖版本pandas2.0.3 jieba0.42.1 scikit-learn1.3.0 torch2.1.0 fastapi0.104.1 uvicorn0.24.0这里刻意没有写成torch2.0这种宽松形式。机器学习依赖间存在大量隐式兼容问题版本固定是最省事的安全策略。如果你后续要在这个项目上继续迭代建议直接用pip freeze requirements.lock把完整环境导出来。6. 常见问题与避坑清单这些坑我替你先踩了做完这一整个ch4_1实验我攒了一堆“看着是小问题、实际很耽误时间”的坑集中整理在这里。6.1 训练集和验证集特征泄漏很多新手会犯同一个错用全部数据做 TF-IDF 拟合再切训练集和验证集然后用验证集评测。这样会让验证集上出现原本不应该见到的词频信息指标虚高换到新数据后立刻掉链子。正确顺序永远是先切分再fit_transform训练集、transform验证集。6.2 分词结果不一致我发现同一句话在训练脚本里和在线预测脚本里的分词结果可能不一样。原因多半是混用了不同版本的jieba或者没有使用同一个词表。解决办法是分词逻辑写成一个独立模块训练和预测都从同一个模块导入别复制粘贴到两处。6.3 训练指标好但线上效果差的两大原因第一个原因是线上样本和训练样本分布不一致。你在投诉工单上训练拿新用户的短评去测效果必然差。第二个原因是新词太多。词汇表是训练集构建的遇到真实环境里没见过的缩写、品牌名、新词都会被映射成unk模型只能靠上下文猜。想缓解可以在预处理阶段加一个专有名词词典并在分词前替换。6.4 超参数速查表我把这次实验中关键的超参数和调参经验汇总成一张表后面迭代时可以对照着看参数本次取值调参经验max_len64先统计样本长度分布取 95% 分位数batch_size64显存不足就降到 32不要顺手降学习率embed_dim100数据集小就用 50~100预训练词向量需要用 300kernel_sizes(2,3,4)文本越短小卷积核越重要num_filters64增大能提升表达力但要防过拟合dropout0.3过拟合明显时升到 0.5optimizerAdam换训练困难时先调学习率不轻易换优化器lr1e-3波动很大时用 ReduceLROnPlateau 衰减6.5 从本次实验里得到的经验最大的经验不是模型结构而是“数据先行”这四个字。与其一开始上复杂模型不如先把清洗、分词、类别平衡做好这些操作对指标的提升往往比换模型更明显。TextCNN 相比逻辑回归只提升了 8 个点而数据清洗和加入二元词组带来的提升其实占了一大半功劳。另外一个体会是日志的价值。训练时把每轮的 loss、验证指标、学习率写进日志宁可多写不要少写。后来回看ch4_1里的日志我能在十分钟内搞清当时的每一个决策这在排错时太重要了。如果后续要扩展这个项目我建议朝三个方向走一是接入 BERT 类预训练模型做更强特征二是把接口服务包装成 Docker 镜像方便多人部署三是给模型加持续的线上数据回流机制每隔一段时间用真实预测结果微调。ch4_1只是第一步但这个完整链路跑通之后后面的迭代就都顺了。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进