Skip to content

文本分类

一、项目介绍

1. 项目背景

入职了一家大模型公司,现在公司需要你们团队开发一个功能,可以自动识别不同类别的文档然后推送给不同的部门,将文本自动进行多分类, 然后像快递一样的"投递"给对应的用户。

你在团队中只负责文本分类这个功能模块

2. 项目架构

  1. 数据集和数据读取分析、前后端效果测试

  2. 模型选择(简单到复杂、从手工到自动、从全量训练到微调到无需训练)

    • 随机森林

    • FastText

    • Bert分类模型

    • LLM接口

  3. 模型压缩

    • 低秩因式分解
    • 模型量化
    • 模型剪枝
    • 模型蒸馏

3. 数据集介绍

3.1 按业务来源划分

日志数据:系统自动记录/生成的用户或系统事件数据 -> 高频、数据量大、噪声较多

业务数据:业务流程产生的数据,通常存储在MySQL/Hive数据库 -> 字段清晰、更新频率相对低、质量较稳定

第三方数据:由合作方、公开数据集或API接口提供 -> 来源多样、格式不统一、需要额外校验、可能要付费

3.2 按数据格式划分

结构化数据

半结构化数据

非结构化数据

3.3 本项目数据概览

  • train.txt:训练集,18w
  • test.txt:测试集,1w
  • dev.txt:验证集,1w
  • class.txt:类别标签文件,10个类别
  • stopwords.txt:无实际意义的符号和词,对文本分类无作用,用于过滤

4. 代码总体结构和设计原则

  • config.py :配置文件,定义常用的数据集路径等,方便统一修改
  • process.py :从路径读取数据或者数据处理相关,把数据变成模型需要的格式
  • train.py : 模型训练相关,用处理好的数据训练得到模型文件
  • predict.py : 模型预测相关,用训练好的模型对文本进行分类
  • server.py : 后台服务器,接收到前端发来的文本时,调用模型预测函数
  • ui.py : 前端界面,把用户在界面上输入的文本,发给后台服务器

设计原则:

  1. 高内聚:模块内部的元素(代码,功能)应该紧密相连,共同完成一个单一且明确的职责
  2. 低耦合:模块之间的依赖关系应该尽可能少、尽可能弱,一个模块发生时尽量不牵连其他模块

二、项目实现

0. 数据读取处理与前后端

拓展:

  1. Flask 是一个用 Python 编写的轻量级 Web 后台框架。中文教程 https://www.runoob.com/flask/flask-tutorial.html
  2. Streamlit 是一个用 Python 编写的可快速构建交互式 Web 页面。中文教程 https://cw.hubwiz.com/card/c/streamlit-manual/

0.1 config.py文件

说明文档 config 常用的读取和保存的路径,方便统一修改

python
# 路径相关常用库
from pathlib import Path

current_path = Path(__file__)  # 当前文件路径
# print(current_path.parent)  # 当前文件父目录(上一层)

# 1. 定义一个类,包含全部路径
class Config:
    def __init__(self):
        self.root_path = current_path.parent  # 根路径
        self.train_path = self.root_path / 'data' / 'raw_data' / 'train.txt'
        self.text_path = str(self.train_path).replace('train.txt', 'text.txt')  # 替换所有的
        self.dev_path = str(self.train_path).replace('train.txt', 'dev.txt')

# 2. 打印路径,测试
if __name__ == '__main__':
    config = Config()
    # obj.__dict__ -> 获取对象所有实例属性,以字典形式展示
    for key, value in config.__dict__.items():
        print(key, value)

0.2 process.py文件

说明文档 process 读取文件 对数据进行分析或处理

python
import pandas as pd
from config import Config
import matplotlib
import matplotlib.pyplot as plt

matplotlib.use('TkAgg')
plt.rcParams['font.sans-serif'] = ['SimHei']
plt.rcParams['axes.unicode_minus'] = False

# 1. 获取路径
config = Config()

# 2. 读取文件
# names 指定列名
train_df = pd.read_csv(config.train_path, sep='\t', names=['text', 'label'])

# 3. 数据处理
# 统计标签相关信息
label_count = train_df['label'].value_counts()  # 每种标签数量
label_count_norm = train_df['label'].value_counts(normalize=True)  # 每种标签百分比
# 统计文本信息
text_len = train_df['text'].str.len().head()  # 每一行文本长度(前五行)
text_len_avg = train_df['text'].str.len().mean()  # 平均长度
text_len_max = train_df['text'].str.len().max()  # 最大长度
text_len_min = train_df['text'].str.len().min()  # 最小长度
text_len_min_index = train_df['text'].str.len().argmin()  # 最小长度的索引,只能返回第一个最小的索引
text_len_min_value = train_df['text'][text_len_min_index]  # 对应的文本
text_len_min_nindex = train_df['text'].str.len().nsmallest(10)  # 最小长度的10个索引

if __name__ == '__main__':
    # print(train_df.head())
    # print(len(train_df))
    # print(label_count)
    # print(label_count_norm)
    # print(text_len)
    # print(f'平均长度:{text_len_avg:.2f}')
    # print(f'最大长度:{text_len_max}')
    # print(f'最小长度:{text_len_min}')
    # print(f'最小长度索引:{text_len_min_index}')
    # print(f'最小长度对应的文本:{text_len_min_value}')
    # print(f'最小长度的10个索引:\n{text_len_min_nindex}')
    # 画图 - 直方图
    plt.hist(train_df['text'].str.len(), bins=100)
    plt.show()

0.3 server.py 文件

说明文档 server 用于接收和处理前端界面发过来的文本内容

python
from flask import Flask  # 常用一个轻量级服务器框架

# 1. 跑起来服务器框架
server = Flask(__name__)  # 实例化一个服务器

# 2. 功能1:用浏览器访问时 可以显示一句话
# '/':表示网站的根路径(例如 http://127.0.0.1:10086)
@server.route('/', methods=['GET'])  # 浏览器访问127.0.0.1/10086时 会发送一个get方法
def hello():
    return 'hello'

# 3. 功能2:用程序访问时 可以返回'经济'
@server.route('/', methods=['POST'])
def get_label():
    result = '经济'
    return result

if __name__ == '__main__':
    server.run(host='127.0.0.1', port=10086, debug=True)  # debug=True代码修改实时显示服务器

0.4 ui.py文件

说明文档 ui 实现前端界面 用户输入的文本发送给后台服务器 显示后台服务器返回的结果

python
import streamlit as st  # 轻量级前端框架 代码实时修改 界面f5
import requests  # 发送和接收相关消息
import time

# 1. 一些图片 文本框 输入框
st.title('张益达帮你分类文件')
# 本地网络图片都行
st.image('D:\TTXS\Pictures\Saved Pictures\专业团队.jpg')
text = st.text_input('请输入要分类的文本')  # 返回用户输入的文本给text

# 2. 一个发送按钮 把用户输入的文本发送给服务器 显示服务器返回的文字
result = st.button('发送')  # 按下按钮后返回True
if result:
    start_time = time.time()  # 获取当前时间
    r = requests.post('http://127.0.0.1:10086', data=text)  # 把text发送给服务器,接收服务器的返回值

    st.write(f'伟大的张益达大人已经帮你判断好了:{r.text}')  # r中包含了许多其他信息,用r.text
    st.write(f'聪明的张益达大人只用了{time.time() - start_time:.2f}秒就帮你判断出来了,赞美益达')

# streamlit有自带的运行环境 不能直接运行
# 运行方式:终端输入 streamlit run 000_dataEDA/ui.py 根据自己实际路径 终端ctrl c可以清空一切

1. 随机森林模型

学习目标:特征工程 + 基础文本表示(TF-IDF)

基线模型:

① 快速验证任务可行性:基线模型通常简单高效,能够快速验证数据和任务的可行性,为后续优化提供方向。 ② 提供性能参考:基线模型的性能为后续复杂模型(如深度学习模型)提供对比基准,衡量改进效果。 ③ 降低开发成本:基线模型实现简单,能在资源有限的情况下快速构建一个可用的解决方案。 ④ 发现问题:通过基线模型的训练和评估,可以发现潜问题,例如数据质量问题。

代码思路:ui.py 前端界面把文本传给后台服务器

-> server.py 后台服务器收到文本后,需要调用预测函数对文本进行分类

-> predict.py 模型预测函数需要训练好的模型

-> train.py 用sklearn库的随机森林模型,这个模型的训练函数需要特定的数据集格式,需要进行数据处理

-> process.py 句子转向量的这个函数需要传入pandas格式文本,但是要求是要经过jieba分词的

1.1 config.py 配置文件

说明文档 config 常用的读取和保存的路径,方便统一修改

python
# 路径相关常用库
from pathlib import Path

current_path = Path(__file__)  # 当前文件路径


# 1. 定义一个类,包含全部路径
class Config:
    def __init__(self):
        self.root_path = current_path.parent  # 根路径
        self.train_path = self.root_path / 'data' / 'raw_data' / 'train.txt'
        self.text_path = str(self.train_path).replace('train.txt', 'text.txt')  # 替换所有的
        self.dev_path = str(self.train_path).replace('train.txt', 'dev.txt')
		
        self.class_path = str(self.train_path).replace('train.txt', 'class.txt')
        self.stop_path = str(self.train_path).replace('train.txt', 'stopwords.txt')

        # 随机森林模型保存路径
        self.model_path = self.root_path / 'model' / 'random_forest.pkl'  # sklearn框架保存模型后缀为.pkl
        # 保存向量化器
        self.tfidf_path = self.root_path / 'model' / 'tfidf.pkl'

# 2. 打印路径,测试
if __name__ == '__main__':
    config = Config()
    # obj.__dict__ -> 获取对象所有实例属性,以字典形式展示
    for key, value in config.__dict__.items():
        print(key, value)

1.2 process.py 数据处理

说明文档 process 把数据集句子改成随机森林模型训练需要的格式

TF-IDF向量化介绍

TF-IDF 是计算一个词对它所在的句子的语义有多大影响的算法。越大表明这个词对这句话语义影响越大。

TF:一个词在当前句子中出现的次数,出现越多通常说明这个词对这个句子影响越大

​ 简化计算:词在当前句子中出现的次数 / 当前句子总词数

IDF:一个词的独特性。一个词在数据集中出现的次数越少,独特性越大,这个词很可能对句子影响较大。

​ 简化计算:数据集总句子数量 / 数据集中包含该词的句子数

​ 实际计算: $$ \text{IDF}(t) = \log \left( \frac{N}{\text{df}(t) + 1} \right) + 1 $$ TF-IDF:TFIDF = IDF*TF

python
import jieba
from config import Config
import pandas as pd
from sklearn.feature_extraction.text import TfidfVectorizer

config = Config()

# 1. 读取原始数据
data = pd.read_csv(config.train_path, sep='\t', names=['text', 'label'])


# 2. 每个句子先jieba分词
# pd格式进行结巴分词:.apply + 结巴分词函数
def cut_sentence(text):
    return ' '.join(jieba.cut(text))


data['text'] = data['text'].apply(cut_sentence)

# 3. 然后调用文本转向量函数 将其转化为向量
# 读取特殊字符 按\n划分得到一个列表
with open(config.stop_path, 'r', encoding='utf-8') as f:
    stop_words = [line.strip() for line in f]
# 实例化向量化器,设置过滤器
tfidf = TfidfVectorizer(stop_words=stop_words)
# 将文本向量化
# 向量化做了三件事 1 滤掉停用词 2 每个不重复的词都分配一个索引 3 把句子变成向量
text_vector = tfidf.fit_transform(data['text'])  # 返回值text_vector是一个稀疏矩阵,形状为 (样本数,词汇表大小)。

# 以下仅了解 看看向量长啥样 与代码无关
if __name__ == '__main__':
    # 向量化做了三件事 1 滤掉停用词 2 每个不重复的词都分配一个索引 3 把句子变成向量
    print('索引:\n', pd.DataFrame(tfidf.vocabulary_.items()))  # 看一眼索引
    pd.set_option('display.max_columns', None)  # 设置最大显示数量
    pd.set_option('display.width', 1000)
    df = pd.DataFrame(text_vector.toarray(), columns=tfidf.get_feature_names_out())
    print('向量:\n', df.to_string(justify='left', max_colwidth=10))

1.3 train.py 模型训练

说明文档 train 用之前转化好的向量 调用函数 训练模型

python
import pickle  # 模型保存读取相关库
from sklearn.ensemble import RandomForestClassifier  # 随机森林模型训练相关
from config import Config
# 1. 加载process处理好的文本向量 已经初始化的向量器
from process import text_vector, tfidf, data  # 加载文本向量 向量化器 数据(拿标签)

config = Config()

# 2. 训练模型
model = RandomForestClassifier()  # 随机森林 实例化
model.fit(text_vector, data['label'])  # 参数:文本向量 标签

# 3. 保存模型和向量化器
with open(config.model_path, 'wb') as f:  # w:写入,没有则创建文件,b:二进制
    pickle.dump(model, f)
with open(config.tfidf_path, 'wb') as f:
    pickle.dump(tfidf, f)

1.4 predict.py

说明文档 predict 加载之前训练好的模型,输入一个文本,调用模型进行预测 封装成一个函数,方便给服务器调用

accuracy_score, 准确率:n个样本 模型预测正确的比例 precision_score, 精确率:模型预测某类n个 其中确实是某类的比例 recall_score, 召回率:有某类n个 模型预测出来的比例 f1_score, F1分数:综合精确率和召回率 2 * (精 * 召) / (精 + 召) confusion_matrix 混淆矩阵:一行表示同一个标签被预测成了什么,一列表示预测出的类别实际上是什么

python
import jieba
import pickle
import pandas as pd

from config import Config

config = Config()

# 1. 加载之前保存的模型
with open(config.model_path, 'rb') as f:  # r:读取  b:二进制
    model = pickle.load(f)
with open(config.tfidf_path, 'rb') as f:
    tfidf = pickle.load(f)


# 2. 预测过程封装成一个函数:输入文本 输出分类结果
def predict_fun(text):
    text = ' '.join(jieba.cut(text))
    text_vector = tfidf.transform([text])  # 文本转向量需要pd或列表格式
    r = model.predict(text_vector)[0]  # 只要数字不要列表
    # 数字转文字
    id_to_name = [i.strip() for i in open(config.class_path, encoding='utf-8')]
    name = id_to_name[r]
    return name


# 3. 调用函数测试
if __name__ == '__main__':
    data = pd.read_csv(config.test_path, sep='\t', names=['text', 'label'])
    for i in data['text'][0:10]:
        name = predict_fun(i)
        print(i, name)

    # 看看模型在测试集上评估指标如何 以下流程无需掌握 看效果即可 实际上就是读取测试集,用模型推理出结果,计算结果的指标,可视化打印
    from sklearn import metrics  # 模型评估指标相关
    import pandas as pd
    from sklearn.metrics import ConfusionMatrixDisplay
    import matplotlib.pyplot as plt
    import matplotlib

    matplotlib.rcParams['font.sans-serif'] = ['SimHei']
    matplotlib.rcParams['axes.unicode_minus'] = False
    matplotlib.use('TKAgg')

    test_data = pd.read_csv(config.test_path, sep='\t', names=['text', 'label'])


    def cut_sentence(s):
        return ' '.join(jieba.cut(s))


    test_data['text'] = test_data['text'].apply(cut_sentence)  # 修改pd表格的语法 需要传一个函数
    text_vector = tfidf.transform(test_data['text'])  # 最终结果
    r = model.predict(text_vector)
    print("准确率:", metrics.accuracy_score(test_data['label'], r))
    print("精确率 (macro):", metrics.precision_score(test_data['label'], r, average='macro'))
    print("召回率 (macro):", metrics.recall_score(test_data['label'], r, average='macro'))
    print("F1分数 (macro):", metrics.f1_score(test_data['label'], r, average='macro'))
    labels = ['finance', 'realty', 'stocks', 'education', 'science', 'society', 'politics', 'sports', 'game',
              'entertainment']
    disp = ConfusionMatrixDisplay(confusion_matrix=metrics.confusion_matrix(test_data['label'], r),
                                  display_labels=labels)
    fig, ax = plt.subplots(figsize=(12, 10))
    disp.plot(ax=ax, xticks_rotation='vertical')
    plt.subplots_adjust(bottom=0.25)
    plt.title('Confusion Matrix')
    plt.show()

1.5 server.py

说明文档 server 用于接收和处理前端界面发过来的文本内容

python
from flask import Flask, request  # 常用一个轻量级服务器框架
from predict import predict_fun

# 1. 跑起来服务器框架
server = Flask(__name__)  # 实例化一个服务器


# 2. 功能1:用浏览器访问时 可以显示一句话
# '/':表示网站的根路径(例如 http://127.0.0.1:10086)
@server.route('/', methods=['GET'])  # 浏览器访问127.0.0.1/10086时 会发送一个get方法
def hello():
    return 'hello'


# 3. 功能2:用程序访问时 
@server.route('/', methods=['POST'])
def get_label():
    text = request.get_data(as_text=True)
    result = predict_fun(text)
    return result


if __name__ == '__main__':
    server.run(host='127.0.0.1', port=10086, debug=True)  # debug=True代码修改实时显示服务器

1.6 ui.py

说明文档 ui 实现前端界面 用户输入的文本发送给后台服务器 显示后台服务器返回的结果

python
import streamlit as st  # 轻量级前端框架 代码实时修改 界面f5
import requests  # 发送和接收相关消息
import time

# 1. 一些图片 文本框 输入框
st.title('张益达帮你分类文件')
# 本地网络图片都行
st.image('D:\TTXS\Pictures\Saved Pictures\专业团队.jpg')
text = st.text_input('请输入要分类的文本')  # 返回用户输入的文本给text

# 2. 一个发送按钮 把用户输入的文本发送给服务器 显示服务器返回的文字
result = st.button('发送')  # 按下按钮后返回True
if result:
    start_time = time.time()  # 获取当前时间
    r = requests.post('http://127.0.0.1:10086', data=text)  # 把text发送给服务器,接收服务器的返回值

    st.write(f'伟大的张益达大人已经帮你判断好了:{r.text}')  # r中包含了许多其他信息,用r.text
    st.write(f'聪明的张益达大人只用了{time.time() - start_time:.2f}秒就帮你判断出来了,赞美益达')

# streamlit有自带的运行环境 不能直接运行
# 运行方式:终端输入 streamlit run 000_dataEDA/ui.py 根据自己实际路径 终端ctrl c可以清空一切

2. FastText模型

输入 -> 词嵌入曾 -> 平均池化 -> 线性分类器

  • 词嵌入层:将词语映射为向量
  • 平均池化:将句子的每个词取平均得到句子向量
  • 线性分类器:对句子向量进行分类

特点:

N-gram特征

层次softmax

负采样

2.1 config.py

说明文档 config 常用的读取和保存的路径,方便统一修改

python
# 路径相关常用库
from pathlib import Path

current_path = Path(__file__)  # 当前文件路径


# 1. 定义一个类,包含全部路径
class Config:
    def __init__(self):
        self.root_path = current_path.parent  # 根路径
        self.train_path = self.root_path / 'data' / 'raw_data' / 'train.txt'
        self.test_path = str(self.train_path).replace('train.txt', 'test.txt')  # 替换所有的
        self.dev_path = str(self.train_path).replace('train.txt', 'dev.txt')

        self.class_path = str(self.train_path).replace('train.txt', 'class.txt')
        self.stop_path = str(self.train_path).replace('train.txt', 'stopwords.txt')

        # 处理过的数据保存路径(六个)
        self.char_train = self.root_path / 'data' / 'processed_data' / 'char_train.txt'
        self.char_test = str(self.char_train).replace('char_train.txt', 'char_test.txt')
        self.char_dev = str(self.char_train).replace('char_train.txt', 'char_dev.txt')
        self.jieba_train = self.root_path / 'data' / 'processed_data' / 'jieba_train.txt'
        self.jieba_test = str(self.jieba_train).replace('jieba_train.txt', 'jieba_test.txt')
        self.jieba_dev = str(self.jieba_train).replace('jieba_train.txt', 'jieba_dev.txt')

        # FastText模型保存路径
        self.model_path = self.root_path / 'model'


# 2. 打印路径,测试
if __name__ == '__main__':
    config = Config()
    # obj.__dict__ -> 获取对象所有实例属性,以字典形式展示
    for key, value in config.__dict__.items():
        print(key, value)

2.2 process.py

说明文档 process 修改数据集格式,保存到data目录里 '我喜欢看电影 0' -> fasttext训练集要求的格式 '__label__0 我喜欢看电影'

python
# 1. 读取原数据
# 2. 修改格式
# 3. 保存到data目录
import jieba
from config import Config

config = Config()

# 按每个字切分 - 训练集
with open(config.train_path, 'r', encoding='utf-8') as r, open(config.char_train, 'w', encoding='utf-8') as w:
    for line in r:
        text, label = line.strip().split('\t')
        text = ' '.join(text)
        # 格式化输出并保存
        w.write(f'__label__{label} {text}\n')
# 按每个字切分 - 测试集
with open(config.test_path, 'r', encoding='utf-8') as r, open(config.char_test, 'w', encoding='utf-8') as w:
    for line in r:
        text, label = line.strip().split('\t')
        text = ' '.join(text)
        # 格式化输出并保存
        w.write(f'__label__{label} {text}\n')
# 按每个字切分 - 验证集
with open(config.dev_path, 'r', encoding='utf-8') as r, open(config.char_dev, 'w', encoding='utf-8') as w:
    for line in r:
        text, label = line.strip().split('\t')
        text = ' '.join(text)
        # 格式化输出并保存
        w.write(f'__label__{label} {text}\n')

# 按jieba切分 - 训练集
with open(config.train_path, 'r', encoding='utf-8') as r, open(config.jieba_train, 'w', encoding='utf-8') as w:
    for line in r:
        text, label = line.strip().split('\t')
        text = ' '.join(jieba.cut(text))
        # 格式化输出并保存
        w.write(f'__label__{label} {text}\n')
# 按每个字切分 - 测试集
with open(config.test_path, 'r', encoding='utf-8') as r, open(config.jieba_test, 'w', encoding='utf-8') as w:
    for line in r:
        text, label = line.strip().split('\t')
        text = ' '.join(jieba.cut(text))
        # 格式化输出并保存
        w.write(f'__label__{label} {text}\n')
# 按每个字切分 - 验证集
with open(config.dev_path, 'r', encoding='utf-8') as r, open(config.jieba_dev, 'w', encoding='utf-8') as w:
    for line in r:
        text, label = line.strip().split('\t')
        text = ' '.join(jieba.cut(text))
        # 格式化输出并保存
        w.write(f'__label__{label} {text}\n')

2.3 train.py

说明文档 train 用之前处理好的数据集 训练FastText模型 保存模型

python
import fasttext
from config import Config

config = Config()

# # 默认参数-按字分词------------------------------------------------
# # 1. 读取对应处理好的数据集
# data_path = str(config.char_train)  # 是pathlib类型 要转为字符串
# # 2. 训练模型
# model = fasttext.train_supervised(data_path)
# # 3. 保存模型
# model_path = str(config.model_path / 'char_default.bin')
# model.save_model(model_path)
# # 4. 模型指标
# print(model.test(config.char_test))
# # (10000, 0.8742, 0.8742)
# # (样本数,精确率,召回率)
# # 默认参数-jieba分词------------------------------------------------
# # 1. 读取对应处理好的数据集
# data_path = str(config.jieba_train)  # 是pathlib类型 要转为字符串
# # 2. 训练模型
# model = fasttext.train_supervised(data_path)
# # 3. 保存模型
# model_path = str(config.model_path / 'jieba_default.bin')
# model.save_model(model_path)
# # 4. 模型指标
# print(model.test(config.jieba_test))
# # (10000, 0.9076, 0.9076)
# # (样本数,精确率,召回率)
# # 自动参数-按字分词------------------------------------------------
# # 1. 读取对应处理好的数据集
# data_path = str(config.char_train)  # 是pathlib类型 要转为字符串
# # 2. 训练模型
# model = fasttext.train_supervised(
#     data_path,
#     autotuneValidationFile=config.char_dev,  # 自动调参用哪些数据
#     autotuneDuration=120,  # 自动调参时长
#     verbose=3  # 输出自动调参过程
# )
# # 3. 保存模型
# model_path = str(config.model_path / 'char_auto.bin')
# model.save_model(model_path)
# # 4. 模型指标
# print(model.test(config.char_test))
# # (10000, 0.9201, 0.9201)
# # (样本数,精确率,召回率)
# 自动参数-jieba分词------------------------------------------------
# 1. 读取对应处理好的数据集
data_path = str(config.jieba_train)  # 是pathlib类型 要转为字符串
# 2. 训练模型
model = fasttext.train_supervised(
    data_path,
    autotuneValidationFile=config.jieba_dev,
    autotuneDuration=120,
    verbose=3
)
# 3. 保存模型
model_path = str(config.model_path / 'jieba_auto.bin')
model.save_model(model_path)
# 4. 模型指标
print(model.test(config.jieba_test))
# (10000, 0.9223, 0.9223)
# (样本数,精确率,召回率)

2.4 predict.py

python
import jieba
import fasttext
from config import Config

# 1. 加载之前保存的模型
config = Config()
model_path = str(config.model_path / 'jieba_auto.bin')
model = fasttext.load_model(model_path)


# 2. 预测过程封装成一个函数:输入文本 输出分类结果
def predict_fun(text):
    if 'jieba' in model_path:
        text = ' '.join(jieba.cut(text))
    elif 'char' in model_path:
        text = ' '.join(text)
    else:
        raise Exception('模型加载错误')
    r = model.predict([text])  # ([['__label__4']], [array([0.9664382], dtype=float32)])
    r = r[0][0][0][9:]
    # 数字转文字
    id_to_name = [i.strip() for i in open(config.class_path, encoding='utf-8')]
    name = id_to_name[int(r)]
    return name


# 3. 调用函数测试
if __name__ == '__main__':
    label = predict_fun('词汇阅读是关键 08年考研暑期英语复习全指南')
    print(label)

2.5 server.py

说明文档 server 用于接收和处理前端界面发过来的文本内容

python
from flask import Flask, request  # 常用一个轻量级服务器框架
from predict import predict_fun

# 1. 跑起来服务器框架
server = Flask(__name__)  # 实例化一个服务器


# 2. 功能1:用浏览器访问时 可以显示一句话
# '/':表示网站的根路径(例如 http://127.0.0.1:10086)
@server.route('/', methods=['GET'])  # 浏览器访问127.0.0.1/10086时 会发送一个get方法
def hello():
    return 'hello'


# 3. 功能2:用程序访问时 可以返回'经济'
@server.route('/', methods=['POST'])
def get_label():
    text = request.get_data(as_text=True)
    result = predict_fun(text)
    return result


if __name__ == '__main__':
    server.run(host='127.0.0.1', port=10086, debug=True)  # debug=True代码修改实时显示服务器

2.6 ui.py

说明文档 ui 实现前端界面 用户输入的文本发送给后台服务器 显示后台服务器返回的结果

python
import streamlit as st  # 轻量级前端框架 代码实时修改 界面f5
import requests  # 发送和接收相关消息
import time

# 1. 一些图片 文本框 输入框
st.title('张益达帮你分类文件')
# 本地网络图片都行
st.image('D:\TTXS\Pictures\Saved Pictures\专业团队.jpg')
text = st.text_input('请输入要分类的文本')  # 返回用户输入的文本给text

# 2. 一个发送按钮 把用户输入的文本发送给服务器 显示服务器返回的文字
result = st.button('发送')  # 按下按钮后返回True
if result:
    start_time = time.time()  # 获取当前时间
    r = requests.post('http://127.0.0.1:10086', data=text)  # 把text发送给服务器,接收服务器的返回值

    st.write(f'伟大的张益达大人已经帮你判断好了:{r.text}')  # r中包含了许多其他信息,用r.text
    st.write(f'聪明的张益达大人只用了{time.time() - start_time:.2f}秒就帮你判断出来了,赞美益达')

# streamlit有自带的运行环境 不能直接运行
# 运行方式:终端输入 streamlit run 000_dataEDA/ui.py 根据自己实际路径 终端ctrl c可以清空一切

3. Bert模型

Bert模型主体就是transformer架构的编码器堆叠,这里默认是12个

Bert输入内部有三个不同的embedding层,将句子变成三个不同的词嵌入向量,Token Embeddings(词向量)、Segment Embeddings(句子分段)和 Position Embeddings(位置编码),最后加起来。

Bert模型每个位置的向量输入后,都会得到这个向量的输出,默认输出维度是768维度,这个输出通常表示当前位置词语的语义。但是第一个位置CLS标志位的输出,可以更好的聚合整个序列的信息。因此对文本分类来说,我们只需要Bert模型第一个位置的输出向量,然后使用全连接层将它映射到类别概率。

3.1 config.py

python
# 说明文档
# config 常用的读取和保存的路径,方便统一修改

# 路径相关常用库
from pathlib import Path

current_path = Path(__file__)  # 当前文件路径


# 1. 定义一个类,包含全部路径
class Config:
    def __init__(self):
        self.root_path = current_path.parent  # 根路径
        self.train_path = self.root_path / 'data' / 'raw_data' / 'train.txt'
        self.test_path = str(self.train_path).replace('train.txt', 'test.txt')  # 替换所有的
        self.dev_path = str(self.train_path).replace('train.txt', 'dev.txt')

        self.class_path = str(self.train_path).replace('train.txt', 'class.txt')
        self.stop_path = str(self.train_path).replace('train.txt', 'stopwords.txt')

        # Bert模型保存路径
        self.model_path = self.root_path / 'model' / 'bert_model.pt'  # pytorch模型后缀 pt


# 2. 打印路径,测试
if __name__ == '__main__':
    config = Config()
    # obj.__dict__ -> 获取对象所有实例属性,以字典形式展示
    for key, value in config.__dict__.items():
        print(key, value)

3.2 process.py

将数据处理成特定的格式:

input_ids -> 句子向量,固定长度(填充或截取)

attention_mask -> 标记那部分是原始内容(1)那部分是填充内容(0)的向量

labels -> 标签

python
# 说明文档
# process 把数据集句子改成bert模型训练需要的格式 并且封装成迭代器dataloader
# 迭代器dataloader 基于pytorch框 固定流程 需要实现几个固定的函数或类
import torch
import pandas as pd
from torch.utils.data import Dataset, DataLoader  # 数据读取相关
from config import Config
from transformers.utils import PaddingStrategy  # 句子向量化相关
from transformers import BertTokenizer  # 句子向量化相关

config = Config()
device = 'cuda' if torch.cuda.is_available() else 'cpu'


# 1. 数据读取类 提供给dataloader数据读取的功能
class TextDataset(Dataset):
    def __init__(self, data_path):
        data = pd.read_csv(data_path, sep='\t', names=['text', 'label'])[:10000]
        self.texts = data['text'].tolist()  # .tolist -> pd列表转列表
        # .astype(int) 是 NumPy 和 Pandas 中最常用的类型转换方法之一,用于将数组或 Series 中的元素转换为整数类型。
        self.label = data['label'].astype(int).tolist()

    def __len__(self):  # 告诉别人有多少数据
        return len(self.texts)

    def __getitem__(self, index):  # 别人给下标可以返回对应的文本和标签
        text = self.texts[index]
        label = self.label[index]
        return text, label


train_dataset = TextDataset(config.train_path)  # 实例化,上述方法会自动调用
# print(train_dataset.__len__(), train_dataset[0])
test_dataset = TextDataset(config.test_path)
dev_dataset = TextDataset(config.dev_path)

# 2. 格式转化函数 提供给dataloader数据改格式的功能
tokenizer = BertTokenizer.from_pretrained(config.root_path / 'model' / 'bert-base-chinese')


# dataloader会自动调用  collate_fn的输入是Dataloader输入的 有他固定的格式
def collate_fn(data):  # 传进来的data [('我喜欢看电影',2),('王者荣耀好玩',1),(.....]
    # 按照Dataloader输入的格式 把data两部分拆开
    texts, label = zip(*data)  # *去括号,zip再压缩
    text_tokens = tokenizer(
        texts,
        padding=PaddingStrategy.MAX_LENGTH,  # 每一句都填充到max_length
        truncation=True,
        max_length=32,
        return_tensors='pt'
    )
    return text_tokens.input_ids.to(device), text_tokens.attention_mask.to(device), torch.tensor(label).to(device)


# 3. 把以上功能传入pytorch框架 Dataloader类中实例化 得到迭代器dataloader
train_dataloader = DataLoader(train_dataset, collate_fn=collate_fn, batch_size=64)
test_dataloader = DataLoader(test_dataset, collate_fn=collate_fn, batch_size=64)
dev_dataloader = DataLoader(dev_dataset, collate_fn=collate_fn, batch_size=64)

# 4. for i in 迭代器dataloader 测试 是否能正常使用
if __name__ == '__main__':
    for i in train_dataloader:
        input_ids, attention_mask, label = i
        print(input_ids.tolist()[0])  # tensor 转 列表 , 共64个样本,取第一个
        print(attention_mask.tolist()[0])
        print(label.tolist()[0])
        break

3.3 train.py

使用迭代器加载处理好的数据来训练bert分类模型 保存模型 测试效果 1 pytorch框架下训练模型 定义pytorch框架模型结构 2 pytorch模型训练经典步骤 3 模型指标 -> 查看验证集指标,更好就保存模型

python
# 说明文档
# train 使用process里定义好的迭代器 训练bert模型 保存模型 测试效果
import torch
import torch.nn as nn
from torch.optim import AdamW  # adam优化版 大模型常用
from sklearn.metrics import f1_score
from config import Config
from process import train_dataloader, dev_dataloader
from transformers import BertModel

config = Config()


# 1. pytorch框架下训练bert模型 定义pytorch框架模型结构 也就是加载的bert预训练模型本体+fc全连接层
class BertLinear(nn.Module):
    def __init__(self):
        super().__init__()
        # bert-base 默认768维
        self.bert = BertModel.from_pretrained(config.root_path / 'model' / 'bert-base-chinese')  # 加载预训练模型
        self.fc = nn.Linear(768, 10)  # 10个类别

    def forward(self, input_ids, attention_mask):
        _, first = self.bert(input_ids=input_ids, attention_mask=attention_mask, return_dict=False)
        # bert 模型输出是两个向量,第一个是全部词对应的向量,第二个是cls对应的向量
        out = self.fc(first)
        return out


# 2. pytorch训练步骤
device = 'cuda' if torch.cuda.is_available() else 'cpu'
epoch = 1  # 训练轮数
model = BertLinear().to(device)
# print(model)  # 模型结构
# 优化器
optimizer = AdamW(model.parameters(), lr=5e-5)
# 损失函数
loss_fn = nn.CrossEntropyLoss()  # 交叉熵损失函数


def train():
    best_f1 = 0  # 更新最高的f1分数
    # 遍历训练轮数
    for i in range(epoch):
        # 遍历数据迭代器,一次64个样本
        for j, batch in enumerate(train_dataloader):
            input_ids, attention_mask, label = batch
            optimizer.zero_grad()
            output = model(input_ids, attention_mask)
            loss = loss_fn(output, label)
            loss.backward()
            optimizer.step()
            # 3. 模型指标
            class_result = output.argmax(dim=1)  # output形状为(64,10) -> (64,)
            # average='macro' 取64个样本平均
            f1 = f1_score(label.cpu(), class_result.cpu(), average='macro')  # 先标签 再预测
            print(f'Epoch:{i + 1}\t\tBatch:{j + 1}\t\t当前F1:{f1}\t\tloss:{loss.item()}')

            # 定期跑一次验证集 10000条
            if j % 15 == 0:
                print('正在验证中...')
                f1score = dev_f1()  # 获得验证集的f1分数
                print(f'验证集F1:{f1score}')

                if f1score > best_f1:  # 如果验证集的f1分数比之前的高 保存模型参数
                    best_f1 = f1score
                    # 保存模型
                    torch.save(model.state_dict(), config.model_path)
                    print('保存模型,最佳F1:', best_f1)


# 用模型跑一边验证集,获得f1分数
def dev_f1():
    model.eval()  # 模型验证模式 禁用dropout 跑得更快
    with torch.no_grad():  # 禁用梯度计算 -> 更快
        # 计算整个验证集(10000条数据)的f1分数
        # 存储每个batch模型的输出结果和label -> 方便计算总的f1score
        all_output, all_label = [], []
        for i, batch in enumerate(dev_dataloader):  # 一次64个样本,跑完10000条数据再计算f1score
            input_ids, attention_mask, label = batch
            # 模型预测 = 模型前向过程
            output = model(input_ids, attention_mask)
            # 获取模型最终输出的类别
            class_result = output.argmax(dim=1)
            all_output.extend(class_result.cpu().tolist())
            all_label.extend(label.cpu().tolist())
        f1score = f1_score(all_label, all_output, average='macro')  # 10000行总f1分数
    model.train()  # 恢复训练模式
    return f1score


if __name__ == '__main__':
    train()

3.4 predict.py

predict 封装一个预测函数 加载train训练好的模型 输入文本返回一个分类结果 1 加载保存的模型 2 预测过程封装成一个函数 输入文本返回分类结果 3 调用测试一下

python
# 说明文档
# predict 封装一个函数 加载之前训练好的模型输入一个文本 进行处理之后 调用模型进行预测 给服务器调用
import torch
from config import Config
from train import BertLinear
from process import collate_fn

config = Config()

# 1 加载之前保存的模型
# torch.save(model.state_dict(), config.model_path)  # 保存的时候只有模型参数 没有模型结构
model = BertLinear()  # 未训练的模型
model.load_state_dict(torch.load(config.model_path))  # 往未训练的模型中传入模型参数
model = model.eval().to('cuda')


# 2 预测过程封装成一个函数 输入文本 输出分类结果
def predict_fun(text):
    # 用之前的collate_fn来实现文本转model需要的格式,但是输入格式不同,所以需要修改
    text = [(text, 0)]  # 凑输入格式 0无意义
    input_ids, attention_mask, label = collate_fn(text)
    with torch.no_grad():  # 禁用梯度计算只预测结果 -> 更快
        output = model(input_ids, attention_mask)  # 10维
        class_result = output.argmax(dim=1)  # 10维转1类别 tensor
        r = class_result.item()  # tensor转数字

        # 数字转文字
        id_to_name = [i.strip() for i in open(config.class_path, encoding='utf-8')]
        name = id_to_name[int(r)]
        return name


# 3 调用函数测试一下
if __name__ == '__main__':
    text = '词汇阅读是关键 08年考研暑期英语复习全指南'
    label = predict_fun(text)
    print(label)

3.5 server.py

python
from flask import Flask, request  # 常用一个轻量级服务器框架
from predict import predict_fun

# 1. 跑起来服务器框架
server = Flask(__name__)  # 实例化一个服务器


# 2. 功能1:用浏览器访问时 可以显示一句话
# '/':表示网站的根路径(例如 http://127.0.0.1:10086)
@server.route('/', methods=['GET'])  # 浏览器访问127.0.0.1/10086时 会发送一个get方法
def hello():
    return 'hello'


# 3. 功能2:用程序访问时 可以返回'经济'
@server.route('/', methods=['POST'])
def get_label():
    text = request.get_data(as_text=True)
    result = predict_fun(text)
    return result


if __name__ == '__main__':
    server.run(host='127.0.0.1', port=10086, debug=True)  # debug=True代码修改实时显示服务器

3.6 ui.py

AI优化后

python
# 说明文档
# ui 实现前端界面 用户输入的文本发送给后台服务器 显示后台服务器返回的结果
import base64
import html
import os
import time

import requests
import streamlit as st

# ============ 1. 页面基础配置 ============
st.set_page_config(
    page_title='专业团队 · 张益达帮你分类文件',
    page_icon='🧠',
    layout='wide',
    initial_sidebar_state='collapsed',
)

SERVER_URL = 'http://127.0.0.1:10086'
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
TEAM_IMAGE = os.path.join(BASE_DIR, '专业团队.jpg')

# 类别信息:英文名 -> (emoji, 中文名, 主题色)
CATEGORY_INFO = {
    'finance': ('💰', '财经', '#38bdf8'),
    'realty': ('🏠', '房产', '#fbbf24'),
    'stocks': ('📈', '股市', '#f87171'),
    'education': ('🎓', '教育', '#a78bfa'),
    'science': ('🔬', '科技', '#22d3ee'),
    'society': ('🌆', '社会', '#94a3b8'),
    'politics': ('🏛️', '政治', '#60a5fa'),
    'sports': ('⚽', '体育', '#4ade80'),
    'game': ('🎮', '游戏', '#f472b6'),
    'entertainment': ('🎬', '娱乐', '#fb923c'),
}

# ============ 2. 全局样式 ============
st.markdown(
    """
    <style>
    /* ---------- 基础 ---------- */
    html, body, [class*="css"], [data-testid="stAppViewContainer"] * {
        font-family: -apple-system, BlinkMacSystemFont, "Segoe UI",
                     "Microsoft YaHei", "PingFang SC", "Hiragino Sans GB", sans-serif;
    }

    .stApp {
        background: #0b1020;
    }

    [data-testid="stAppViewContainer"] {
        background:
            radial-gradient(1100px 560px at 88% -8%, rgba(99, 102, 241, 0.20), transparent 60%),
            radial-gradient(900px 520px at -8% 18%, rgba(14, 165, 233, 0.14), transparent 55%),
            radial-gradient(900px 620px at 50% 118%, rgba(168, 85, 247, 0.14), transparent 60%),
            linear-gradient(160deg, #0b1020 0%, #0d1226 48%, #0a0f1e 100%);
        color: #e2e8f0;
    }

    .block-container {
        max-width: 1160px;
        padding-top: 2rem;
        padding-bottom: 2.5rem;
    }

    [data-testid="stHeader"] { background: transparent; }
    [data-testid="stToolbar"] { visibility: hidden; }
    [data-testid="stDecoration"] { display: none; }
    #MainMenu { visibility: hidden; }
    footer { visibility: hidden; }

    /* 滚动条 */
    ::-webkit-scrollbar { width: 9px; height: 9px; }
    ::-webkit-scrollbar-thumb { background: rgba(148, 163, 184, 0.28); border-radius: 8px; }
    ::-webkit-scrollbar-track { background: transparent; }

    hr { border-color: rgba(255, 255, 255, 0.10); }

    /* ---------- Hero 区 ---------- */
    .hero-chip {
        display: inline-block;
        background: rgba(129, 140, 248, 0.12);
        border: 1px solid rgba(129, 140, 248, 0.35);
        color: #a5b4fc;
        font-size: 0.82rem;
        font-weight: 600;
        padding: 0.28rem 0.8rem;
        border-radius: 999px;
        margin-bottom: 1rem;
        letter-spacing: 0.3px;
    }

    .gradient-title {
        font-size: 2.7rem;
        font-weight: 800;
        line-height: 1.2;
        letter-spacing: 1px;
        background: linear-gradient(92deg, #38bdf8 0%, #818cf8 48%, #e879f9 100%);
        -webkit-background-clip: text;
        background-clip: text;
        -webkit-text-fill-color: transparent;
    }

    .hero-sub {
        color: #94a3b8;
        font-size: 1.02rem;
        margin-top: 0.7rem;
        line-height: 1.7;
    }

    .hero-tags { margin-top: 1.1rem; }

    .tag {
        display: inline-block;
        background: rgba(255, 255, 255, 0.055);
        border: 1px solid rgba(255, 255, 255, 0.10);
        color: #cbd5e1;
        font-size: 0.82rem;
        padding: 0.3rem 0.7rem;
        border-radius: 999px;
        margin-right: 0.5rem;
    }

    .hero-card {
        position: relative;
        border-radius: 20px;
        overflow: hidden;
        border: 1px solid rgba(255, 255, 255, 0.14);
        box-shadow: 0 24px 64px rgba(2, 6, 23, 0.50);
    }

    .hero-img { width: 100%; display: block; }

    .hero-shine {
        position: absolute;
        inset: 0;
        pointer-events: none;
        background: linear-gradient(115deg, transparent 30%, rgba(255, 255, 255, 0.16) 48%, transparent 62%);
        transform: translateX(-120%);
        animation: shine 6s ease-in-out infinite;
    }

    @keyframes shine {
        0%, 55% { transform: translateX(-120%); }
        80%, 100% { transform: translateX(120%); }
    }

    .hero-badge {
        position: absolute;
        left: 14px;
        bottom: 14px;
        display: inline-flex;
        align-items: center;
        gap: 0.5rem;
        background: rgba(11, 16, 32, 0.74);
        border: 1px solid rgba(255, 255, 255, 0.18);
        backdrop-filter: blur(8px);
        color: #e2e8f0;
        font-size: 0.85rem;
        font-weight: 600;
        padding: 0.42rem 0.9rem;
        border-radius: 999px;
    }

    .dot {
        width: 8px;
        height: 8px;
        border-radius: 50%;
        background: #4ade80;
        animation: pulse 1.8s infinite;
    }

    @keyframes pulse {
        0% { box-shadow: 0 0 0 0 rgba(74, 222, 128, 0.45); }
        70% { box-shadow: 0 0 0 9px rgba(74, 222, 128, 0); }
        100% { box-shadow: 0 0 0 0 rgba(74, 222, 128, 0); }
    }

    .hero-fallback {
        display: flex;
        flex-direction: column;
        align-items: center;
        justify-content: center;
        min-height: 220px;
        background: rgba(255, 255, 255, 0.05);
    }

    /* ---------- 玻璃面板 ---------- */
    [data-testid="stVerticalBlockBorderWrapper"],
    [data-testid="stVerticalBlock"]:has(> [data-testid="stLayoutWrapper"] > [data-testid="stForm"]) {
        background: rgba(255, 255, 255, 0.045);
        border: 1px solid rgba(255, 255, 255, 0.10) !important;
        border-radius: 18px;
        padding: 1.15rem 1.25rem;
        backdrop-filter: blur(10px);
        box-shadow: 0 18px 44px rgba(2, 6, 23, 0.32);
    }

    [data-testid="stForm"] {
        background: transparent !important;
        border: none !important;
        padding: 0 !important;
    }

    .glass-card {
        background: rgba(255, 255, 255, 0.045);
        border: 1px solid rgba(255, 255, 255, 0.10);
        border-radius: 18px;
        padding: 1.15rem 1.25rem;
        backdrop-filter: blur(10px);
        box-shadow: 0 18px 44px rgba(2, 6, 23, 0.32);
        margin-bottom: 1rem;
    }

    .card-title {
        display: flex;
        align-items: center;
        gap: 0.55rem;
        font-size: 1.05rem;
        font-weight: 700;
        color: #f1f5f9;
        margin-bottom: 0.95rem;
    }

    .card-title::before {
        content: '';
        width: 4px;
        height: 18px;
        border-radius: 2px;
        background: linear-gradient(180deg, #38bdf8, #818cf8);
    }

    .stCaption, [data-testid="stCaptionContainer"] {
        color: #7c8aa0 !important;
    }

    /* ---------- 按钮 ---------- */
    .stButton > button,
    [data-testid="stButton"] > button,
    button[kind="secondary"],
    [data-testid="stBaseButton-secondary"] {
        background: rgba(255, 255, 255, 0.055);
        border: 1px solid rgba(255, 255, 255, 0.12);
        color: #cbd5e1;
        border-radius: 999px;
        font-size: 0.84rem;
        font-weight: 500;
        padding: 0.34rem 0.75rem;
        transition: all 0.2s ease;
    }

    .stButton > button:hover,
    [data-testid="stButton"] > button:hover,
    button[kind="secondary"]:hover,
    [data-testid="stBaseButton-secondary"]:hover {
        background: rgba(129, 140, 248, 0.16);
        border-color: rgba(129, 140, 248, 0.55);
        color: #ffffff;
        transform: translateY(-1px);
    }

    .stFormSubmitButton,
    [data-testid="stFormSubmitButton"] {
        width: 100%;
    }

    .stFormSubmitButton > button,
    [data-testid="stFormSubmitButton"] > button,
    button[kind="primaryFormSubmit"],
    button[kind="primary"],
    [data-testid="stBaseButton-primaryFormSubmit"] {
        width: 100% !important;
        background: linear-gradient(90deg, #2563eb 0%, #7c3aed 55%, #c026d3 100%);
        background-size: 170% 100%;
        border: none;
        color: #ffffff;
        font-weight: 700;
        font-size: 1rem;
        border-radius: 14px;
        padding: 0.72rem 1rem;
        box-shadow: 0 12px 32px rgba(124, 58, 237, 0.35);
        transition: all 0.25s ease;
    }

    .stFormSubmitButton > button:hover,
    [data-testid="stFormSubmitButton"] > button:hover,
    button[kind="primaryFormSubmit"]:hover,
    button[kind="primary"]:hover,
    [data-testid="stBaseButton-primaryFormSubmit"]:hover {
        background-position: 100% 0;
        box-shadow: 0 16px 38px rgba(192, 38, 211, 0.42);
        color: #ffffff;
        transform: translateY(-1px);
    }

    /* ---------- 输入框 ---------- */
    .stTextArea textarea,
    [data-testid="stTextArea"] textarea {
        background: rgba(255, 255, 255, 0.055);
        color: #e2e8f0;
        border: 1px solid rgba(255, 255, 255, 0.12);
        border-radius: 14px;
        font-size: 0.95rem;
        line-height: 1.65;
        transition: border-color 0.2s, box-shadow 0.2s;
    }

    .stTextArea textarea:focus,
    [data-testid="stTextArea"] textarea:focus {
        border-color: rgba(129, 140, 248, 0.70);
        box-shadow: 0 0 0 3px rgba(129, 140, 248, 0.16);
    }

    .stTextArea textarea::placeholder,
    [data-testid="stTextArea"] textarea::placeholder {
        color: #5c6b84;
    }

    [data-testid="stTextAreaRootElement"] {
        background: transparent !important;
        border: none !important;
    }

    /* ---------- 分类徽章 ---------- */
    .chip-grid {
        display: grid;
        grid-template-columns: 1fr 1fr;
        gap: 0.55rem;
    }

    .chip {
        display: flex;
        align-items: center;
        justify-content: space-between;
        background: rgba(255, 255, 255, 0.05);
        border: 1px solid rgba(255, 255, 255, 0.08);
        border-left: 4px solid var(--chip);
        border-radius: 12px;
        padding: 0.52rem 0.75rem;
        transition: all 0.2s ease;
    }

    .chip:hover {
        background: rgba(255, 255, 255, 0.09);
        transform: translateX(2px);
    }

    .chip b { color: #e2e8f0; font-size: 0.93rem; font-weight: 600; }
    .chip .en { color: #64748b; font-size: 0.76rem; }

    /* ---------- 状态卡片 ---------- */
    .status-card {
        display: flex;
        align-items: center;
        gap: 0.65rem;
        padding: 0.78rem 1rem;
        border-radius: 14px;
        font-weight: 600;
        font-size: 0.93rem;
    }

    .status-card.online {
        background: rgba(34, 197, 94, 0.12);
        border: 1px solid rgba(34, 197, 94, 0.38);
        color: #4ade80;
    }

    .status-card.offline {
        background: rgba(239, 68, 68, 0.12);
        border: 1px solid rgba(239, 68, 68, 0.38);
        color: #f87171;
    }

    .status-card .dot { background: currentColor; animation: pulse 1.8s infinite; }

    .status-card .sub {
        color: rgba(148, 163, 184, 0.85);
        font-weight: 400;
        font-size: 0.82rem;
        margin-left: auto;
    }

    /* ---------- 使用说明 ---------- */
    .step {
        display: flex;
        align-items: flex-start;
        gap: 0.7rem;
        margin-bottom: 0.65rem;
        font-size: 0.92rem;
        color: #cbd5e1;
        line-height: 1.6;
    }

    .step-num {
        flex: none;
        width: 22px;
        height: 22px;
        border-radius: 7px;
        background: linear-gradient(135deg, #2563eb, #7c3aed);
        color: #fff;
        font-size: 0.78rem;
        font-weight: 700;
        display: flex;
        align-items: center;
        justify-content: center;
        margin-top: 2px;
    }

    .step code {
        background: rgba(129, 140, 248, 0.14);
        border: 1px solid rgba(129, 140, 248, 0.30);
        color: #a5b4fc;
        border-radius: 6px;
        padding: 0.08rem 0.38rem;
        font-size: 0.82rem;
    }

    /* ---------- 提示条 ---------- */
    .alert {
        display: flex;
        align-items: flex-start;
        gap: 0.5rem;
        padding: 0.75rem 1rem;
        border-radius: 12px;
        font-size: 0.93rem;
        line-height: 1.6;
        margin-bottom: 0.6rem;
    }

    .alert code {
        background: rgba(255, 255, 255, 0.12);
        border-radius: 5px;
        padding: 0.05rem 0.3rem;
        font-size: 0.84rem;
    }

    .alert.warning { background: rgba(245, 158, 11, 0.12); border: 1px solid rgba(245, 158, 11, 0.35); color: #fbbf24; }
    .alert.error { background: rgba(239, 68, 68, 0.12); border: 1px solid rgba(239, 68, 68, 0.35); color: #f87171; }
    .alert.success { background: rgba(34, 197, 94, 0.12); border: 1px solid rgba(34, 197, 94, 0.35); color: #4ade80; }
    .alert.info { background: rgba(56, 189, 248, 0.12); border: 1px solid rgba(56, 189, 248, 0.35); color: #38bdf8; }

    /* ---------- 结果卡片 ---------- */
    .result-card {
        position: relative;
        margin-top: 0.5rem;
        padding: 1.1rem 1.3rem;
        border-radius: 16px;
        background: linear-gradient(135deg, rgba(56, 189, 248, 0.13), rgba(129, 140, 248, 0.13) 50%, rgba(232, 121, 249, 0.13));
        border: 1px solid rgba(129, 140, 248, 0.32);
        box-shadow: 0 12px 30px rgba(2, 6, 23, 0.25);
    }

    .result-label { font-size: 0.82rem; color: #8b9bb4; margin-bottom: 0.55rem; }

    .badge {
        display: inline-flex;
        align-items: center;
        gap: 0.5rem;
        font-size: 1.35rem;
        font-weight: 800;
        padding: 0.5rem 1.1rem;
        border-radius: 999px;
    }

    .badge-en { font-size: 0.8rem; font-weight: 500; opacity: 0.68; }
    .result-time { margin-top: 0.85rem; color: #94a3b8; font-size: 0.92rem; }

    /* ---------- 历史表格 ---------- */
    .history-table { width: 100%; border-collapse: collapse; font-size: 0.9rem; }

    .history-table th {
        text-align: left;
        color: #94a3b8;
        font-weight: 600;
        border-bottom: 1px solid rgba(255, 255, 255, 0.14);
        padding: 0.5rem 0.6rem;
    }

    .history-table td {
        color: #cbd5e1;
        border-bottom: 1px solid rgba(255, 255, 255, 0.06);
        padding: 0.5rem 0.6rem;
    }

    .history-table tr:hover td { background: rgba(129, 140, 248, 0.08); }

    /* ---------- 折叠面板 ---------- */
    [data-testid="stExpander"] {
        background: rgba(255, 255, 255, 0.04);
        border: 1px solid rgba(255, 255, 255, 0.10);
        border-radius: 14px;
    }

    [data-testid="stExpander"] summary { color: #cbd5e1; font-weight: 600; }

    /* ---------- 页脚 ---------- */
    .footer {
        text-align: center;
        color: #5c6b84;
        font-size: 0.8rem;
        margin-top: 2.4rem;
        padding-top: 1.2rem;
        border-top: 1px solid rgba(255, 255, 255, 0.07);
    }
    </style>
    """,
    unsafe_allow_html=True,
)


# ============ 3. 工具函数 ============
@st.cache_data(show_spinner=False)
def image_data_uri(path):
    """把图片转成 base64 data URI,方便在 HTML 里直接渲染"""
    with open(path, 'rb') as f:
        b64 = base64.b64encode(f.read()).decode('ascii')
    ext = os.path.splitext(path)[1].lstrip('.').lower()
    mime = {
        'png': 'image/png',
        'jpg': 'image/jpeg',
        'jpeg': 'image/jpeg',
        'webp': 'image/webp',
    }.get(ext, 'image/jpeg')
    return f'data:{mime};base64,{b64}'


def hero_image_html():
    """专业团队合影卡片:圆角玻璃框 + 呼吸灯徽章"""
    if not os.path.exists(TEAM_IMAGE):
        return (
            '<div class="hero-card hero-fallback">'
            '<div style="font-size:3rem;">🧑‍💼</div>'
            '<div style="font-size:0.95rem;color:#94a3b8;margin-top:.4rem;">专业团队合影缺失</div>'
            '</div>'
        )
    src = image_data_uri(TEAM_IMAGE)
    return f'''
    <div class="hero-card">
        <img class="hero-img" src="{src}" alt="专业团队" />
        <div class="hero-shine"></div>
        <div class="hero-badge"><span class="dot"></span>专业团队 · 全栈在线</div>
    </div>
    '''


def alert_html(kind, message):
    """自定义提示条(warning / error / success / info)"""
    icons = {'info': '💡', 'warning': '⚠️', 'error': '❌', 'success': '✅'}
    return f'<div class="alert {kind}">{icons.get(kind, "💡")} {message}</div>'


@st.cache_data(ttl=10, show_spinner=False)
def check_server():
    """快速探测后端是否在线"""
    try:
        return requests.get(SERVER_URL, timeout=1.5).status_code == 200
    except requests.RequestException:
        return False


def classify(text):
    """把文本发送给后端,展示分类结果并记录历史"""
    text = text.strip()
    if not text:
        st.markdown(
            alert_html('warning', '输入不能为空,专业团队需要看到文本才能开工 🤔'),
            unsafe_allow_html=True,
        )
        return

    start_time = time.time()
    try:
        with st.spinner('专业团队正在全力分析…'):
            r = requests.post(SERVER_URL, data=text, timeout=10)
            r.raise_for_status()
        label = r.text.strip()
    except requests.exceptions.ConnectionError:
        st.markdown(
            alert_html('error', '连不上服务器… 请先在项目目录终端运行 <code>python server.py</code>'),
            unsafe_allow_html=True,
        )
        return
    except requests.exceptions.Timeout:
        st.markdown(alert_html('error', '服务器响应超时,请稍后再试'), unsafe_allow_html=True)
        return
    except requests.exceptions.RequestException as e:
        st.markdown(
            alert_html('error', f'请求出错:{html.escape(str(e))}'),
            unsafe_allow_html=True,
        )
        return

    if not label:
        st.markdown(alert_html('error', '服务器返回了空结果,请检查后端是否正常'), unsafe_allow_html=True)
        return

    elapsed = time.time() - start_time
    emoji, cn, color = CATEGORY_INFO.get(label, ('🏷️', label, '#94a3b8'))

    # 展示结果卡片
    st.markdown(
        f'''
        <div class="result-card">
            <div class="result-label">分类结果</div>
            <span class="badge" style="background:{color}1f;color:{color};border:1px solid {color}66;">
                {emoji} {cn} <span class="badge-en">{html.escape(label)}</span>
            </span>
            <div class="result-time">⚡ 专业团队仅用 <b>{elapsed:.2f}</b> 秒完成判断</div>
        </div>
        ''',
        unsafe_allow_html=True,
    )

    # 记录历史(最多保留 20 条)
    history = st.session_state.setdefault('history', [])
    history.insert(0, {
        '时间': time.strftime('%m-%d %H:%M:%S'),
        '文本': text if len(text) <= 40 else text[:40] + '…',
        '类别': f'{emoji} {cn}{label})',
        '用时/s': round(elapsed, 2),
    })
    if len(history) > 20:
        del history[20:]


# ============ 4. 顶部 Hero 区 ============
hero_left, hero_right = st.columns([1.25, 1], gap='large')

with hero_left:
    st.markdown(
        """
        <div class="hero">
            <div class="hero-chip">🧠 BERT 深度文本分类引擎</div>
            <div class="gradient-title">智能文本分类系统</div>
            <div class="hero-sub">由专业团队倾情打造 · 10 大新闻类别毫秒级精准识别</div>
            <div class="hero-tags">
                <span class="tag">⚡ 毫秒响应</span>
                <span class="tag">🎯 高准确率</span>
                <span class="tag">📊 十类新闻</span>
            </div>
        </div>
        """,
        unsafe_allow_html=True,
    )

with hero_right:
    st.markdown(hero_image_html(), unsafe_allow_html=True)

# ============ 5. 主区域:输入 + 结果 / 信息面板 ============
left, right = st.columns([7, 5], gap='large')

with left:
    with st.container(border=True):
        st.markdown('<div class="card-title">输入文本</div>', unsafe_allow_html=True)

        # 示例文本快捷按钮(放在输入框之前,才能安全写入输入框)
        samples = [
            '词汇阅读是关键 08年考研暑期英语复习全指南',
            'A股三大指数集体收涨 沪指重返3500点',
            '中国女足3比2战胜韩国队 夺得亚洲杯冠军',
        ]
        st.caption('懒得打字?点示例试试 👇')
        sample_cols = st.columns(len(samples))
        for i, sample in enumerate(samples):
            if sample_cols[i].button(f'📌 {sample[:8]}…', key=f'sample_{i}', help=sample):
                st.session_state['input_text'] = sample

        with st.form('classify_form'):
            text = st.text_area(
                '请输入要分类的文本',
                placeholder='例如:词汇阅读是关键 08年考研暑期英语复习全指南',
                height=150,
                label_visibility='collapsed',
                key='input_text',
            )
            submitted = st.form_submit_button('🚀 开始分类', type='primary')

        if submitted:
            classify(text)

with right:
    # 支持分类
    chips = ''.join(
        f'<div class="chip" style="--chip:{color};">'
        f'<b>{emoji} {cn}</b><span class="en">{en}</span></div>'
        for en, (emoji, cn, color) in CATEGORY_INFO.items()
    )
    st.markdown(
        f'''
        <div class="glass-card">
            <div class="card-title">支持分类</div>
            <div class="chip-grid">{chips}</div>
        </div>
        ''',
        unsafe_allow_html=True,
    )

    # 服务器状态
    if check_server():
        status = '<div class="status-card online"><span class="dot"></span>后端在线<span class="sub">127.0.0.1:10086</span></div>'
    else:
        status = '<div class="status-card offline"><span class="dot"></span>后端未启动<span class="sub">127.0.0.1:10086</span></div>'
    st.markdown(
        f'''
        <div class="glass-card">
            <div class="card-title">服务器状态</div>
            {status}
        </div>
        ''',
        unsafe_allow_html=True,
    )

    # 使用说明
    st.markdown(
        """
        <div class="glass-card">
            <div class="card-title">使用说明</div>
            <div class="step"><span class="step-num">1</span><span>启动后端:<code>python server.py</code></span></div>
            <div class="step"><span class="step-num">2</span><span>启动界面:<code>streamlit run ui.py</code></span></div>
            <div class="step"><span class="step-num">3</span><span>输入一段文本,点击「🚀 开始分类」</span></div>
        </div>
        """,
        unsafe_allow_html=True,
    )

# ============ 6. 分类历史 ============
st.markdown('---')
history = st.session_state.get('history', [])
with st.expander(f'📋 分类历史({len(history)} 条)'):
    if history:
        rows = ''.join(
            f'<tr>'
            f'<td>{item["时间"]}</td>'
            f'<td>{html.escape(item["文本"])}</td>'
            f'<td>{item["类别"]}</td>'
            f'<td>{item["用时/s"]}</td>'
            f'</tr>'
            for item in history
        )
        st.markdown(
            f'<table class="history-table"><thead>'
            f'<tr><th>时间</th><th>文本</th><th>类别</th><th>用时/s</th></tr>'
            f'</thead><tbody>{rows}</tbody></table>',
            unsafe_allow_html=True,
        )
        if st.button('🗑️ 清空历史'):
            st.session_state['history'] = []
    else:
        st.caption('还没有任何记录,快去试试吧~')

# ============ 7. 页脚 ============
st.markdown(
    '<div class="footer">🧠 专业团队 · 智能文本分类系统 | 基于 BERT + Streamlit 构建</div>',
    unsafe_allow_html=True,
)
# streamlit有自带的运行环境 不能直接运行
# 运行方式:终端输入 streamlit run ui.py 根据自己实际路径 终端ctrl c可以清空一切

4. 调用LLM模型

优点: 1、零样本学习,快速上手 2、无需训练,节省算力 3、一模多用,适配多任务 4、语义理解强,适合复杂语言

缺点: 1、结果依赖提示词,调优困难 2、成本高、速度慢 3、不可控、可解释性差 4、无法微调,难定制,缺乏针对性

4.1 predict.py

predict 封装一个函数 把需要分类的文本 通过api发送给deepseek

  1. 写提示词让ds对我们的文本进行分类
  2. 预测过程封装成一个函数 输入文本 输出分类结果
  3. 调用函数测试一下
python
from openai import OpenAI
import os

# deepseek发消息demo
client = OpenAI(api_key=os.getenv("OPENAI-API-KEY"),
                base_url="https://ws-7fxi94n23wuxy7l1.cn-beijing.maas.aliyuncs.com/compatible-mode/v1")

# # 创建发送的信息
# response = client.chat.completions.create(
#     model="deepseek-v4-flash",
#     messages=[
#         {"role": "system", "content": "你是一个猜成语专家,能根据我的提示猜出正确的成语"},
#         {"role": "user", "content": "第一个字是旧的四字成语"},
#     ],
# )
# print(response.choices[0].message.content)

system_prompt = """
你是一名新闻分类专家。将给定新闻标题分类至以下类别之一:finance, realty, stocks, education, science, society, politics, sports, game, entertainment。仅输出小写英文类别名,不加任何标点、空格或解释。

分类规则(按优先级依次判断)
教育优先 – 标题涉及学校、考试、升学、培训、考研、高考、复习、备考、英语学习等 → education。
(即使包含年份、社会背景词,仍以此为准)

金融经济类(按主体区分)

楼市、房贷、开发商、物业、房价 → realty

股价、上市公司、A股/港股/美股、证券交易 → stocks

汇率、央行、债券、基金、宏观经济、大宗商品 → finance

体育 vs 游戏

实体体育运动(足球、篮球、田径等)、赛事、运动员、体育组织 → sports

电子游戏、网游、手游、电竞比赛、游戏公司动态 → game

科研 – 自然现象、技术突破、医学研究、科学发现 → science

政治 – 政治人物、法律法规、国际关系、政府机构行为 → politics

娱乐 – 影视、音乐、综艺、明星、演出等文娱活动 → entertainment

社会(兜底) – 以上均不符时,归为民生事件、意外事故、社会纠纷、公共安全等 → society

消歧要点
多义词(如“金科”“表演”)需结合整体语境,而非孤立词汇。

明确区分实体体育与电子游戏。
"""


# 2. 预测过程封装成一个函数 输入文本 输出分类结果
def predict_fun(text):
    response = client.chat.completions.create(
        model="deepseek-v4-flash",
        messages=[
            {"role": "system", "content": system_prompt},
            {"role": "user", "content": text},
        ],
    )
    return response.choices[0].message.content


if __name__ == '__main__':
    import pandas as pd
    from config import Config

    config = Config()

    data = pd.read_csv(config.test_path, sep='\t', names=['text', 'label'])[:100]
    idtoname = [i.strip() for i in open(config.class_path, encoding='utf-8')]

    count = 0  # 正确数
    num = 0  # 总数
    for i in range(len(data)):
        num += 1
        text = data['text'][i]
        result = predict_fun(text)
        if result == idtoname[data['label'][i]]:
            count += 1
    print('正确率:', count / num)

4.2 server.py

不变

4.3 ui.py

不变

三、模型压缩

模型可以资源占用更少运算速度更快,同时尽量不影响模型效果

模型的本质其实就是可以训练的参数矩阵 => 处理参数矩阵

模型压缩的四种主流方式:

  1. 低秩因式分解:把模型参数大矩阵拆成小矩阵
  2. 模型量化:把模型参数的数据格式改成低精度的格式
  3. 模型剪枝:把模型参数中不重要的参数去掉
  4. 模型蒸馏:用大模型训练出一个小模型替代大模型

1. 低秩因式分解

主要用于将模型参数大矩阵分解为两个小矩阵乘法形式。算出来的两个小矩阵乘法并不能完全等于原来大矩阵,我们试图用某种算法(SVD 深度学习等等),尽量让误差小。用总的更少的参数去近似大矩阵。

实现思路:

  1. 读图片然后转为大矩阵X
  2. 定义两个随机的小矩阵
  3. 两个小矩阵乘法得到的大矩阵和原版计算mse损失,对两个小矩阵进行训练
python
import torch
import torch.optim as optim
import torch.nn as nn
import numpy as np
from PIL import Image  # 图片库相关
import matplotlib.pyplot as plt
import matplotlib

matplotlib.use('TkAgg')
matplotlib.rcParams['font.sans-serif'] = ['SimHei']
matplotlib.rcParams['axes.unicode_minus'] = False

# 大矩阵
img = Image.open(r'D:\itheima\AI\large_model_project\011_lowRank\input.bmp').convert('L')  # 20*20简单黑白图片当大矩阵 可视化好理解
X = torch.tensor(np.array(img), dtype=torch.float32)
print(X.shape)
# # 仅用于可视化,无其他含义
# for row in X.int().tolist():
#     print(''.join(f'{val:3d}' for val in row))

# 拆分成两个小矩阵
X1 = torch.randn(20, 5, requires_grad=True)
X2 = torch.randn(5, 20, requires_grad=True)

opt = optim.AdamW([X1, X2])  # 优化器  默认学习率 优化两个小矩阵
loss_fn = nn.MSELoss()  # 损失函数

# 梯度下降 两个小矩阵相乘的结果和大矩阵计算误差
for i in range(20000):
    opt.zero_grad()  # 清空梯度
    loss = loss_fn(X1 @ X2, X)  # 计算损失
    if i % 1000 == 0:  # 100轮看一次
        print(f'loss:{loss.item()}')  # 输出损失
    loss.backward()  # 自动微分 反向传播
    opt.step()  # 更新梯度

# 可视化
# 原矩阵
plt.subplot(2, 2, 1)
plt.imshow(X.numpy(), cmap='gray')
plt.title('raw')
# 拆分后的矩阵相乘
plt.subplot(2, 2, 2)
plt.imshow((X1 @ X2).detach().numpy(), cmap='gray')
plt.title('new')
# 小矩阵
plt.subplot(2, 2, 3)
plt.imshow(X1.detach().numpy(), cmap='gray')
plt.title('X1')
plt.subplot(2, 2, 4)
plt.imshow(X2.detach().numpy(), cmap='gray')
plt.title('X2')
plt.show()

2. 模型量化

模型中的数据通常原来是float32也就是32位浮点格式,用更短的数据类型代替如int8也就是8位整数型,这就是模型量化

流程:模型训练好之后,对模型参数进行量化,此时模型内部参数矩阵已经从float32变为int8,保存模型即可。模型推理时,输入数据也要量化为int8,这样模型内部浮点运算就变成了整数之间的运算,速度快占用少。

min-max量化原理(把数轴上原本32位浮点表示的大范围数据,压缩到8位整形表示的范围里):

先计算缩放比例系数:scale = (max - min) / 256

然后: 原数据 / scale,相当于原始数据都缩小了scale倍

之后:将两者起点终点对齐,做一个平移:(原数据 / scale) + zero_point

最后还需要四舍五入成整数:round( (原数据 / scale) + zero_point )

动态量化:输入(激活值)是在推理时(Runtime),根据实际输入数据的数值范围,实时动态计算量化参数并转为INT8。

静态量化:输入(激活值)是在部署前(Offline),通过运行一个校准数据集,提前统计好量化参数并固定下来。推理时直接使用这个固定的参数,无需再计算。

  1. 无论是动态量化还是静态量化,在模型加载或转换时,权重(weight)都会被立刻从FP32离线量化为INT8,并且这个INT8权重和它的量化参数(scale,zero_point)被固定下来,保存在模型文件中
  2. 动态量化更方便,实时计算也更准。静态量化提前算好,需要直接用,速度更快,但是用的训练集不一定很准。

量化感知训练 :在模型训练的时候,就告诉模型"你将来会被量化",让模型提前适应这种"粗糙"的表示。这就是量化感知训练

经过量化感知训练的模型参数,和正常训练的模型参数会略有不同。量化感知训练的模型在普通场景效果不如正常训练,但是在量化的场景下,效果会更好。

python
# 说明文档
# quantization 模型量化效果展示
import torch
import torch.nn as nn
import time
import os

os.environ['KMP_DUPLICATE_LIB_OK'] = 'TRUE'


# 定义pytorch模型
class RawLinear(nn.Module):
    def __init__(self):
        super().__init__()
        self.fc = nn.Linear(10000, 10000)

    def forward(self, data):
        out = self.fc(data)
        return out


model = RawLinear()
print("原模型参数:\n", model.fc.weight)  # 未训练的模型参数
torch.save(model.state_dict(), 'model/raw_model.pt')

# 模型动态量化
quantized_model = torch.quantization.quantize_dynamic(model)
print("动态量化模型参数:\n", torch.int_repr(quantized_model.fc.weight()))
torch.save(quantized_model.state_dict(), 'model/quantized_model.pt')

# 模型测试
# 定义一个输入,形状符合模型输入即可
data = torch.randn(1, 10000)
# 运行时间对比
# 原始模型
start = time.time()
out1 = model(data)
print("原始模型运行时间:", time.time() - start)
# 动态量化模型
start = time.time()
out2 = quantized_model(data)
print("动态量化模型运行时间:", time.time() - start)
# 运行结果差异
loss_fn = nn.MSELoss()
print("运行结果差异:", loss_fn(out1, out2))

3. 模型剪枝

核心原理:参数矩阵中绝对值小的权重对输出的直接影响较小。那么我们可以直接将这种参数置零即可。

名词概念:

非结构化剪枝:非结构化剪枝并不关心模型结构,只是根据某种标准(例如,权重的绝对值大小)来决定是否移除这个权重。置零权重后,剩下的权重分布是稀疏的。通常需要特殊的硬件或软件支持来有效利用结果模型的稀疏性。

结构化剪枝:这种剪枝方法整体置零一组通道或者一组卷积,由于是留下的部分是完整的,因此框架和硬件加速支持更好。

注:pytorch框架中模型剪枝,本质上只是在将参数置0,而不是真正删除。