TF-IDF + 随机森林中文文本分类全链路实战:从训练脚本到 Flask API + Streamlit 前端

一、问题引入:为什么"训练完模型"远远不够?

上一篇复盘里,我们用 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 把预处理和模型绑死------本文最关键的一步

单独训练 vectorizerclassifier 是最大的坑 :训练时用一套 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.pydata/HF_config.py 两套配置,不同脚本入口引用不同,路径写死、容易冲突。

修复原则: 配置统一收口到一个 config.py,所有路径用相对项目根目录 的写法,或用 pathlib.Path(__file__).parent 动态计算。做大之后这一步省下的调试时间按天算。

坑 3:把"接口能返回结果"当成"上线完成"

现象: 本地 curl 通了就交付,线上跑两天就开始 500。

根因:版本管理、异常处理、监控、回滚。一个未处理异常就能让整个服务挂掉,还没有日志可查。

修复: 至少补四件事------

  1. try/except 包住预测逻辑,异常时返回 5xx + 错误码而不是直接崩;

  2. 加结构化日志(请求时间、文本、预测结果);

  3. 给接口加版本号(/v1/predict),方便模型升级时灰度;

  4. 模型文件带版本号,支持回滚到上一版。


六、运行结果验证:跑通从文本到预测的最小系统

把上面六步压成一段最小可运行代码,方便你直接拷走验证 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 和模型分开存,这里大概率会报维度不匹配。


七、常见误区

  1. "接口能返回结果就算上线完成" ------ 错。还需要版本、异常处理、监控和回滚,否则线上挂了都不知道。

  2. "训练时效果好,线上一定稳定" ------ 错。输入分布变化会造成数据漂移,需要持续评估、定期重训。

  3. "传统特征方案过时了,必须上深度学习" ------ 错。中小数据量下 TF-IDF + 随机森林 经常吊打调不好的神经网络,先跑通基线再谈升级。


八、这套方案的局限与下一步

局限 说明 优化方向
语义理解偏浅 TF-IDF 只看词频统计,不懂上下文和同义关系 数据量上来后切到 Word2Vec / BERT
长文本效果一般 随机森林对高维稀疏特征处理尚可,但捕捉不到长距离依赖 改用 TextCNN / GRU / Transformer
冷启动表现差 训练集没见过的词全是零特征 字符级 n-gram 或预训练词向量

如果继续迭代,优先级是:

  1. 配置统一:把两套 config 合并成一套;

  2. 补全线上能力:加日志、监控、版本、回滚;

  3. 错误样本分析:找出模型常错的样本类型,针对性补数据;

  4. 模型升级:数据量到万级再考虑切 BERT,别盲目上重模型。


九、总结与思维拓展

这次整理让我越来越确信一件事:一个项目真正值钱的地方,不是模型本身,而是有没有形成一条完整链路。

从 EDA 到预处理,从训练到保存模型,从接口到前端------这些环节串起来之后,项目才开始有了**"可演示、可复用、可继续迭代"**的感觉。

思维拓展: 这条链路(EDA → 特征 → 模型 → Pipeline 持久化 → API → 前端)其实和具体模型无关 。今天你用 TF-IDF + 随机森林,明天换成 BERT,除了步骤 2、3 要改,后面的保存、API、前端几乎一行不用动。掌握这套"落地骨架",比记住任何一个算法都更值钱------因为算法会迭代,但"把模型变成服务"的工程能力永远稀缺。


动手练习

  1. joblib.dump 保存整条 Pipeline,再在另一个脚本中加载并对新文本预测(验证规则一致性)。

  2. 给 Flask 接口加 try/except 和结构化日志,让异常不再直接 500。

  3. 进阶:把 Streamlit 页面改成批量预测模式,一次上传 10 条短信返回表格结果。


如果这篇文章帮你把模型从"本地脚本"跑成了"可调用的服务" ,或省下了搭全链路的踩坑时间,不妨点个赞 👍 支持一下。

更建议先收藏 ⭐:动手搭系统时直接对照着改,比临时翻官方文档快得多。

如果在 Flask / Streamlit / Pipeline 集成上遇到问题,欢迎评论区交流,我会逐条回复。

相关推荐:

  • 上一篇:垃圾短信识别项目深度复盘:RNN 中文文本分类全流程实战 + 3 个数据泄漏避坑指南 ------ 本系列开篇,偏训练侧

  • 下一篇预告:把 TF-IDF 升级成 BERT 后,准确率能涨多少?成本又涨多少? 实测对比即将更新

本文首发于「去你想去的地方」:我怎样把一个中文文本分类项目,从训练脚本一路做到了 API 和前端 | 去你想去的地方

相关推荐
AI服务老曹1 小时前
摄像机安装AI视频分析参数配置说明:POC现场交付全流程指南
人工智能·音视频
FriendshipT1 小时前
Ultralytics:解读C3模块
人工智能·pytorch·python·深度学习·目标检测
极光代码工作室1 小时前
基于深度学习的异常行为检测系统
人工智能·python·深度学习·神经网络·机器学习
承渊政道1 小时前
【Python学习】(了解Python背景知识、搭建 Python 环境以及安装PyCharm)
python·学习·pycharm·搭建python环境
川石课堂软件测试1 小时前
性能测试|Nginx中间件监控与调优
linux·python·nginx·中间件·单元测试·压力测试·harmonyos
阿拉斯攀登1 小时前
RAG 性能优化:缓存、批量与并发
人工智能·缓存·性能优化·embedding·agent·loop·rag
次旅行的库1 小时前
解决ValueError: too many values to unpack (expected 2)
linux·人工智能·深度学习·机器学习·推荐算法
小呀小叮当~1 小时前
OpenAI Codex 桌面版(Windows / macOS)体验分享:AI 编程效率再次提升
人工智能·windows·macos
syty20201 小时前
对深度学习的理解
人工智能·深度学习