一、问题引入:为什么"训练完模型"远远不够?
上一篇复盘里,我们用 RNN 跑通了中文垃圾短信的训练---评估 闭环。但跑完会发现一个尴尬的现实:模型权重躺在本地 .pt 文件里,除了你自己,没人能用它。
真实场景下,一个文本分类项目要"落地",至少得回答三个问题:
-
别人怎么调用你的模型? ------ 不能让产品经理去跑 Jupyter。
-
非技术同学怎么验证效果? ------ 不能每次都贴一段代码让他
python predict.py。 -
训练规则和推理规则是否一致? ------ 训练时用
jieba.lcut,推理时忘了分词,效果直接崩。
本项目要解决的就是这条从脚本到系统的落地路径。主线技术选型很克制:
-
jieba做中文分词 -
TF-IDF做文本特征向量化 -
随机森林完成分类
-
Flask 暴露预测接口
-
Streamlit 搭可交互页面
一句话总结:不堆深度学习,先用一条轻量、清晰、能跑通的链路,把"模型训练"做成"可调用、可演示的小系统"。
💡 刻意不用 Transformer:在中小型文本分类任务里,
TF-IDF + 传统分类器依然是非常好的基线方案------训练快、结果稳定、调试容易、对流程理解帮助极大。不是所有问题都必须从 Transformer 开始。
二、环境与版本说明
| 组件 | 版本 | 说明 |
|---|---|---|
| Python | 3.9 + | 推荐 3.9 及以上 |
| jieba | 0.42 + | 中文分词首选 |
| scikit-learn | 1.2 + | 提供 TfidfVectorizer / RandomForestClassifier / Pipeline |
| Flask | 2.3 + | 轻量 Web 框架,暴露 /predict 接口 |
| Streamlit | 1.24 + | 几行代码搭出可交互页面 |
| joblib | 内置 | 模型持久化,sklearn 配套 |
一行装齐:
pip install jieba scikit-learn flask streamlit joblib
三、核心原理浅析:为什么是 TF-IDF + 随机森林?
在写代码前,先想清楚两个"为什么"。
为什么用 TF-IDF 而不是直接 one-hot?
-
one-hot 只表达"这个词出现过",但"领取""奖品"这种强信号词 和"的""是"这种停用词权重一样,模型会被噪声淹没。
-
TF-IDF = 词频(TF)× 逆文档频率(IDF)。它的核心思想是:一个词在某篇文档里出现得多,同时在整个语料里出现得少,那它对这篇文档的区分度就高。 "领取奖品"只出现在垃圾短信里 → IDF 大 → 权重高;"的"哪儿都有 → IDF 小 → 权重低。
一句话:TF-IDF 让模型自动把注意力分配给有区分力的词,而不需要你手写关键词规则。
为什么用随机森林而不是神经网络?
| 维度 | 随机森林 | 神经网络(RNN/Transformer) |
|---|---|---|
| 训练速度 | 快,CPU 秒级 | 慢,常需 GPU |
| 数据需求 | 几百条就能起步 | 通常要上千条 |
| 可解释性 | 可输出特征重要性 | 黑盒 |
| 调试难度 | 低,超参少 | 高,调参玄学 |
对入门和中小型任务,随机森林是性价比极高的基线。先把它跑通,再决定要不要上深度学习,这是更稳的工程节奏。
整条链路一句话:
原始文本 → EDA → jieba分词 → TF-IDF向量化 → 随机森林训练 → joblib保存 → Flask API → Streamlit前端
四、实战步骤(全链路代码 + 注释)
步骤 1:EDA------先看数据,再谈模型
很多模型效果问题,本质上不是模型不行,而是数据结构没先看清。第一步永远先做 EDA。
import pandas as pd
from collections import Counter
def quick_eda(texts, labels):
"""快速数据体检:看样本量、类别均衡、长度分布、异常文本。"""
print("样本总量:", len(texts))
print("类别分布:", dict(Counter(labels))) # 不均衡就要考虑加权或重采样
lengths = [len(t) for t in texts]
print("文本长度 min/avg/max:", min(lengths), sum(lengths) // len(lengths), max(lengths))
# 空文本和异常长文本是常见的脏数据来源,必须先揪出来
empty = [i for i, t in enumerate(texts) if not t.strip()]
too_long = [i for i, t in enumerate(texts) if len(t) > 500]
print("空文本数量:", len(empty), " 超长文本数量:", len(too_long))
# ---- 演示 ----
texts = ["您的验证码是3812", "点击链接领取奖品", "明天十点开会", "恭喜中奖请回短信",
"项目文档已发送", "免费奖品领取", "", "会议延期通知"]
labels = [0, 1, 0, 1, 0, 1, 0, 0]
quick_eda(texts, labels)
预期控制台输出:
样本总量: 8
类别分布: {0: 5, 1: 3}
文本长度 min/avg/max: 0 9 12
空文本数量: 1 超长文本数量: 0
看到空文本就回去清洗,看到类别极度不均衡就考虑
class_weight='balanced'。这一步不炫,但能省后面一半的调试时间。
步骤 2:分词 + TF-IDF 向量化
中文模型不能直接吃句子,必须先分词 + 向量化 。这里有一个新手最容易踩的坑:TfidfVectorizer 默认按空格切分,对中文不友好,所以必须先 jieba.lcut 再用空格拼回去。
import jieba
from sklearn.feature_extraction.text import TfidfVectorizer
def tokenize(text):
"""jieba 分词后用空格拼接,交给 TfidfVectorizer 才能正确切分。"""
return " ".join(jieba.lcut(text))
corpus = [tokenize(t) for t in texts if t.strip()] # 顺手清掉空文本
# ngram_range=(1,2):同时考虑单字和二字组合,让模型能捕捉"领取奖品"这种词组特征
# 单用 unigram 会把"领取"和"奖品"拆开,丢失组合信号
vectorizer = TfidfVectorizer(ngram_range=(1, 2))
X = vectorizer.fit_transform(corpus)
print("特征矩阵形状:", X.shape)
print("部分特征词:", vectorizer.get_feature_names_out()[:10])
预期控制台输出:
特征矩阵形状: (7, 24)
部分特征词: ['3812' '中奖' '奖品' '奖品 领取' '点击' '点击 链接' '免费' '免费 奖品' '会议' '会议 延期']
步骤 3:用 Pipeline 把预处理和模型绑死------本文最关键的一步
单独训练 vectorizer 和 classifier 是最大的坑 :训练时用一套 TF-IDF 规则,保存时只存了模型权重,推理时重新 fit 一遍 vectorizer,规则全变了,效果直接崩。
正确做法是用 sklearn.pipeline.Pipeline 把预处理 + 模型绑成一个整体,训练和推理用同一套规则。
from sklearn.ensemble import RandomForestClassifier
from sklearn.pipeline import Pipeline
from sklearn.model_selection import train_test_split
from sklearn.metrics import classification_report
# Pipeline 的价值:保证新文本按训练时相同的 TF-IDF 规则转换,再交给随机森林预测
# 训练和推理走同一条管线,杜绝"规则不一致"的隐蔽 bug
model = Pipeline([
("tfidf", TfidfVectorizer(preprocessor=tokenize, ngram_range=(1, 2))),
("clf", RandomForestClassifier(n_estimators=100, random_state=42, class_weight="balanced")),
])
# 划分训练/测试集
X_train, X_test, y_train, y_test = train_test_split(
texts, labels, test_size=0.25, random_state=42, stratify=labels
)
model.fit(X_train, y_train)
y_pred = model.predict(X_test)
print(classification_report(y_test, y_pred, target_names=["正常", "垃圾"]))
预期控制台输出(演示数据,真实项目样本量更大):
precision recall f1-score support
正常 1.00 1.00 1.00 2
垃圾 1.00 1.00 1.00 1
accuracy 1.00 3
macro avg 1.00 1.00 1.00 3
weighted avg 1.00 1.00 1.00 3
class_weight="balanced":自动按类别频率反向加权,缓解样本不均衡。这是 EDA 看到类别失衡后的标准应对动作。
步骤 4:用 joblib 把整条 Pipeline 存下来
很多人只存 model.named_steps['clf'],那是错的。必须存整条 Pipeline,否则推理时 TF-IDF 规则丢失。
import joblib
# 存的是 Pipeline,而不是单独的 RandomForest —— 预处理规则和模型是一套,缺一不可
joblib.dump(model, "spam_clf_pipeline.pkl")
print("Pipeline 已保存: spam_clf_pipeline.pkl")
步骤 5:Flask 暴露 /predict 接口
训练完的模型现在要变成可被外部调用的 HTTP 服务。
# app.py
from flask import Flask, request, jsonify
import joblib
app = Flask(__name__)
# 服务启动时一次性加载,避免每次请求都重新读盘——否则 QPS 一高磁盘 IO 直接拖垮服务
model = joblib.load("spam_clf_pipeline.pkl")
LABEL_MAP = {0: "正常", 1: "垃圾"}
@app.route("/predict", methods=["POST"])
def predict():
data = request.get_json()
if not data or "text" not in data:
# 显式校验入参,避免 KeyError 把整个请求打成 500,排查起来很痛
return jsonify({"error": "缺少 text 字段"}), 400
text = data["text"]
pred = int(model.predict([text])[0])
proba = model.predict_proba([text])[0].tolist() # 同时返回置信度,方便前端做阈值过滤
return jsonify({"label": LABEL_MAP[pred], "probabilities": proba})
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000, debug=True)
启动后用 curl 验证:
curl -X POST http://localhost:5000/predict -H "Content-Type: application/json" -d "{\"text\": \"点击链接领取奖品\"}"
预期返回:
{
"label": "垃圾",
"probabilities": [0.12, 0.88]
}
步骤 6:Streamlit 搭可交互页面
非技术同学不会用 curl,给他们一个能直接打字的页面才是真落地。
# frontend.py
import streamlit as st
import requests
st.title("中文短信分类系统")
text = st.text_area("输入短信内容", "点击链接领取奖品")
if st.button("预测"):
# 前端不直接加载模型,而是调用 Flask API —— 保持单一推理入口,避免前端后端各跑一份模型导致结果不一致
resp = requests.post("http://localhost:5000/predict", json={"text": text}).json()
st.write("预测结果:", resp.get("label", "未知"))
if "probabilities" in resp:
st.write("置信度:", resp["probabilities"])
一行命令启动:
streamlit run frontend.py
浏览器自动打开页面,输入文本点"预测"就能看到分类结果。
五、踩坑记录(避坑指南)
坑 1:训练和推理用了两套 TF-IDF 规则
现象: 训练准确率 90%,上线后准确率掉到 60%。
根因: 训练时 vectorizer.fit_transform(),保存时只存了模型权重;推理时又 vectorizer.fit() 了一遍,词表和 IDF 全变了,模型拿到的是完全不同的特征。
修复: 用 Pipeline 把 vectorizer 和 classifier 绑成一个整体,joblib.dump 存整条 Pipeline。见步骤 3、4。
坑 2:配置文件有两套,路径一乱全崩
现象: 换个目录运行就 FileNotFoundError。
根因: 项目里同时存在根目录的 config.py 和 data/HF_config.py 两套配置,不同脚本入口引用不同,路径写死、容易冲突。
修复原则: 配置统一收口到一个 config.py,所有路径用相对项目根目录 的写法,或用 pathlib.Path(__file__).parent 动态计算。做大之后这一步省下的调试时间按天算。
坑 3:把"接口能返回结果"当成"上线完成"
现象: 本地 curl 通了就交付,线上跑两天就开始 500。
根因: 缺版本管理、异常处理、监控、回滚。一个未处理异常就能让整个服务挂掉,还没有日志可查。
修复: 至少补四件事------
-
用
try/except包住预测逻辑,异常时返回 5xx + 错误码而不是直接崩; -
加结构化日志(请求时间、文本、预测结果);
-
给接口加版本号(
/v1/predict),方便模型升级时灰度; -
模型文件带版本号,支持回滚到上一版。
六、运行结果验证:跑通从文本到预测的最小系统
把上面六步压成一段最小可运行代码,方便你直接拷走验证 Pipeline 的核心价值------训练和推理走同一套规则。
from sklearn.ensemble import RandomForestClassifier
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.pipeline import Pipeline
import joblib
texts = ["验证码 请查收", "会议 明天 十点", "中奖 点击 链接",
"免费 奖品 领取", "项目 文档 已发送", "恭喜 获得 大奖"]
labels = [0, 0, 1, 1, 0, 1]
# 训练阶段:整条 Pipeline 一起 fit
model = Pipeline([
("tfidf", TfidfVectorizer(analyzer="char", ngram_range=(1, 2))),
("clf", RandomForestClassifier(n_estimators=50, random_state=7)),
]).fit(texts, labels)
# 保存 → 重新加载(模拟"另一个脚本里推理")
joblib.dump(model, "mini_pipeline.pkl")
loaded = joblib.load("mini_pipeline.pkl")
# 推理阶段:用加载后的 Pipeline 直接 predict,规则与训练完全一致
for text, pred in zip(["点击链接领取奖品", "明天查看项目文档"],
loaded.predict(["点击链接领取奖品", "明天查看项目文档"])):
print(text, "->", "垃圾" if pred else "正常")
预期控制台输出:
点击链接领取奖品 -> 垃圾
明天查看项目文档 -> 正常
这段代码的价值在于:它证明了保存/加载后的 Pipeline 仍然能正确推理。如果你把 vectorizer 和模型分开存,这里大概率会报维度不匹配。
七、常见误区
-
"接口能返回结果就算上线完成" ------ 错。还需要版本、异常处理、监控和回滚,否则线上挂了都不知道。
-
"训练时效果好,线上一定稳定" ------ 错。输入分布变化会造成数据漂移,需要持续评估、定期重训。
-
"传统特征方案过时了,必须上深度学习" ------ 错。中小数据量下
TF-IDF + 随机森林经常吊打调不好的神经网络,先跑通基线再谈升级。
八、这套方案的局限与下一步
| 局限 | 说明 | 优化方向 |
|---|---|---|
| 语义理解偏浅 | TF-IDF 只看词频统计,不懂上下文和同义关系 | 数据量上来后切到 Word2Vec / BERT |
| 长文本效果一般 | 随机森林对高维稀疏特征处理尚可,但捕捉不到长距离依赖 | 改用 TextCNN / GRU / Transformer |
| 冷启动表现差 | 训练集没见过的词全是零特征 | 加字符级 n-gram 或预训练词向量 |
如果继续迭代,优先级是:
-
配置统一:把两套 config 合并成一套;
-
补全线上能力:加日志、监控、版本、回滚;
-
错误样本分析:找出模型常错的样本类型,针对性补数据;
-
模型升级:数据量到万级再考虑切 BERT,别盲目上重模型。
九、总结与思维拓展
这次整理让我越来越确信一件事:一个项目真正值钱的地方,不是模型本身,而是有没有形成一条完整链路。
从 EDA 到预处理,从训练到保存模型,从接口到前端------这些环节串起来之后,项目才开始有了**"可演示、可复用、可继续迭代"**的感觉。
思维拓展: 这条链路(EDA → 特征 → 模型 → Pipeline 持久化 → API → 前端)其实和具体模型无关 。今天你用 TF-IDF + 随机森林,明天换成 BERT,除了步骤 2、3 要改,后面的保存、API、前端几乎一行不用动。掌握这套"落地骨架",比记住任何一个算法都更值钱------因为算法会迭代,但"把模型变成服务"的工程能力永远稀缺。
动手练习
-
用
joblib.dump保存整条 Pipeline,再在另一个脚本中加载并对新文本预测(验证规则一致性)。 -
给 Flask 接口加
try/except和结构化日志,让异常不再直接 500。 -
进阶:把 Streamlit 页面改成批量预测模式,一次上传 10 条短信返回表格结果。
如果这篇文章帮你把模型从"本地脚本"跑成了"可调用的服务" ,或省下了搭全链路的踩坑时间,不妨点个赞 👍 支持一下。
更建议先收藏 ⭐:动手搭系统时直接对照着改,比临时翻官方文档快得多。
如果在 Flask / Streamlit / Pipeline 集成上遇到问题,欢迎评论区交流,我会逐条回复。
相关推荐:
上一篇:垃圾短信识别项目深度复盘:RNN 中文文本分类全流程实战 + 3 个数据泄漏避坑指南 ------ 本系列开篇,偏训练侧
下一篇预告:把 TF-IDF 升级成 BERT 后,准确率能涨多少?成本又涨多少? 实测对比即将更新
本文首发于「去你想去的地方」:我怎样把一个中文文本分类项目,从训练脚本一路做到了 API 和前端 | 去你想去的地方
