主题
文本分类
一、项目介绍
1. 项目背景
入职了一家大模型公司,现在公司需要你们团队开发一个功能,可以自动识别不同类别的文档然后推送给不同的部门,将文本自动进行多分类, 然后像快递一样的"投递"给对应的用户。
你在团队中只负责文本分类这个功能模块
2. 项目架构
数据集和数据读取分析、前后端效果测试
模型选择(简单到复杂、从手工到自动、从全量训练到微调到无需训练)
随机森林
FastText
Bert分类模型
LLM接口
模型压缩
- 低秩因式分解
- 模型量化
- 模型剪枝
- 模型蒸馏
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 : 前端界面,把用户在界面上输入的文本,发给后台服务器
设计原则:
- 高内聚:模块内部的元素(代码,功能)应该紧密相连,共同完成一个单一且明确的职责
- 低耦合:模块之间的依赖关系应该尽可能少、尽可能弱,一个模块发生时尽量不牵连其他模块
二、项目实现
0. 数据读取处理与前后端
拓展:
- Flask 是一个用 Python 编写的轻量级 Web 后台框架。中文教程 https://www.runoob.com/flask/flask-tutorial.html
- 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])
break3.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
- 写提示词让ds对我们的文本进行分类
- 预测过程封装成一个函数 输入文本 输出分类结果
- 调用函数测试一下
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. 低秩因式分解
主要用于将模型参数大矩阵分解为两个小矩阵乘法形式。算出来的两个小矩阵乘法并不能完全等于原来大矩阵,我们试图用某种算法(SVD 深度学习等等),尽量让误差小。用总的更少的参数去近似大矩阵。
实现思路:
- 读图片然后转为大矩阵X
- 定义两个随机的小矩阵
- 两个小矩阵乘法得到的大矩阵和原版计算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),通过运行一个校准数据集,提前统计好量化参数并固定下来。推理时直接使用这个固定的参数,无需再计算。
注:
- 无论是动态量化还是静态量化,在模型加载或转换时,权重(weight)都会被立刻从FP32离线量化为INT8,并且这个INT8权重和它的量化参数(scale,zero_point)被固定下来,保存在模型文件中
- 动态量化更方便,实时计算也更准。静态量化提前算好,需要直接用,速度更快,但是用的训练集不一定很准。
量化感知训练 :在模型训练的时候,就告诉模型"你将来会被量化",让模型提前适应这种"粗糙"的表示。这就是量化感知训练
经过量化感知训练的模型参数,和正常训练的模型参数会略有不同。量化感知训练的模型在普通场景效果不如正常训练,但是在量化的场景下,效果会更好。
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,而不是真正删除。