☰
中文短文本分类实战:从数据清洗到模型部署的完整项目链路
2026/10/5 13:42:33 网站建设 项目流程

如果你经常跟代码和数据打交道,多半见过这种看起来像乱码的文件夹名: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(r"https?://\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_count=2, max_vocab=20000): 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", encoding="utf-8") as f: json.dump(vocab, f, ensure_ascii=False) return vocab

<pad>用来补长到统一长度,<unk>代表词汇表外的新词。设置min_count=2是避免那些只出现一次的噪声词进入模型。

3.4 类别不均衡的处理思路

处理不均衡,业界常用的方法包括过采样、欠采样、换损失函数。我这轮实验用了最直接有效的方式:对训练集中的少数类样本做随机复制,让每个类别的样本量大致持平。

from sklearn.utils import resample def balance_dataset(df, target_col, random_state=42): 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, replace=True, n_samples=max_size, random_state=random_state) balanced_dfs.append(sub) return pd.concat(balanced_dfs, ignore_index=True)

但这只是治标。少数类样本本身信息量有限,复制再多,模型能学到的只是重复记忆。更治本的方式是数据增强,比如对文本做近义词替换。不过这会增加训练时间,我作为第一版方案没做,留在后面的迭代轮次里。

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( tokenizer=lambda x: x, preprocessor=lambda x: x, max_features=30000, 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_iter=1000, C=1.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_dim=100, num_classes=5, kernel_sizes=(2, 3, 4), num_filters=64, max_len=64): super().__init__() self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0) self.convs = nn.ModuleList([ nn.Conv1d(embed_dim, num_filters, kernel_size=k) 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, dim=1) x = self.dropout(x) return self.fc(x)

为什么用三个不同尺寸的卷积核?每个尺寸对应不同的 n-gram 范围:size=2抓二元词组,size=3抓三元组合,size=4抓更长一点的固定搭配。短文本里这些局部特征非常重要,TextCNN 的设计思路就是用卷积核去扫描这些局部模式,再通过最大池化提取最明显的信号。

4.3 训练循环与参数设置

训练脚本我单独写在src/train.py里。关键参数设置如下:

  • 嵌入维度:embed_dim=100
  • 句子最大长度:max_len=64
  • 批次大小:batch_size=64
  • 学习率:lr=1e-3
  • 优化器:Adam
  • 损失函数:CrossEntropyLoss
  • 早停轮数:patience=3

这里说一下几个重要参数的来历。max_len不是随便定的,我统计了训练集分词后长度的分布,超过 64 个词的样本不到 2%,所以截断到 64 不影响大多数样本。学习率选1e-3是 Adam 的默认推荐值,先用这个跑到稳定再说。

训练循环里我加了三样东西,习惯上很推荐:学习率衰减、早停、日志记录。

from torch.optim.lr_scheduler import ReduceLROnPlateau optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) scheduler = ReduceLROnPlateau(optimizer, mode="max", factor=0.5, patience=1)

ReduceLROnPlateau的意思是:验证集指标连续 1 轮不涨,就把学习率减半。这个机制比固定学习率省心,不用人为盯训练曲线。早停则进一步防止过拟合:连续 3 轮验证集 F1 没有刷新新高,就停止训练并恢复最优权重。

训练日志长这样:

epoch=1, loss=0.6871, val_f1=0.8712, lr=0.0010 epoch=2, loss=0.3482, val_f1=0.9023, lr=0.0010 epoch=3, loss=0.2167, val_f1=0.9131, lr=0.0010 epoch=4, loss=0.1540, val_f1=0.9188, lr=0.0010 epoch=5, loss=0.1135, val_f1=0.9102, lr=0.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_location="cpu") model = TextCNN( vocab_size=ckpt["vocab_size"], num_classes=ckpt["num_classes"], embed_dim=ckpt["embed_dim"], max_len=ckpt["max_len"], ) model.load_state_dict(ckpt["model_state_dict"]) model.eval() @app.post("/predict", response_model=PredictResponse) 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, dim=1).squeeze(0) label_idx = probs.argmax().item() return PredictResponse( label=id_to_label[label_idx], confidence=probs[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里我固定住了核心依赖版本:

pandas==2.0.3 jieba==0.42.1 scikit-learn==1.3.0 torch==2.1.0 fastapi==0.104.1 uvicorn==0.24.0

这里刻意没有写成torch>=2.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,预训练词向量需要用 300
kernel_sizes(2,3,4)文本越短,小卷积核越重要
num_filters64增大能提升表达力,但要防过拟合
dropout0.3过拟合明显时升到 0.5
optimizerAdam换训练困难时先调学习率,不轻易换优化器
lr1e-3波动很大时用 ReduceLROnPlateau 衰减

6.5 从本次实验里得到的经验

最大的经验不是模型结构,而是“数据先行”这四个字。与其一开始上复杂模型,不如先把清洗、分词、类别平衡做好,这些操作对指标的提升往往比换模型更明显。TextCNN 相比逻辑回归只提升了 8 个点,而数据清洗和加入二元词组带来的提升,其实占了一大半功劳。

另外一个体会是日志的价值。训练时把每轮的 loss、验证指标、学习率写进日志,宁可多写不要少写。后来回看ch4_1里的日志,我能在十分钟内搞清当时的每一个决策,这在排错时太重要了。

如果后续要扩展这个项目,我建议朝三个方向走:一是接入 BERT 类预训练模型做更强特征;二是把接口服务包装成 Docker 镜像,方便多人部署;三是给模型加持续的线上数据回流机制,每隔一段时间用真实预测结果微调。ch4_1只是第一步,但这个完整链路跑通之后,后面的迭代就都顺了。

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

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

立即咨询