Merge pull request #50 from xming521/dev

v0.2.2
This commit is contained in:
小铭
2025-05-11 19:54:00 +08:00
committed by GitHub
29 changed files with 1485 additions and 344 deletions
+12
View File
@@ -3,9 +3,21 @@ description:
globs:
alwaysApply: true
---
---
description:
globs:
alwaysApply: true
---
# Your rule content
- You can @ files here
- The project uses uv as the package manager and pyproject.toml as the project configuration file.
- Unless I ask you to, code comments don't need to be excessive.You should prioritize using Chinese to comment code.
- Prefer using the encapsulated logger `from weclone.utils.log import logger` for printing.
- When retrieving values from a parameter dictionary read from a configuration file, the `get` method should be preferred whenever possible.
+5 -1
View File
@@ -156,5 +156,9 @@ data/test
Spark-TTS-0.5B/
uv.lock
output*
*.out
Qwen*/
Qwen*/
settings.jsonc
settings.json
dataset/blocked_words.json
+70 -53
View File
@@ -5,26 +5,29 @@
[![GitHub stars](https://img.shields.io/github/stars/xming521/WeClone?style=for-the-badge&logo=github&label=Stars&logoColor=white&color=ffda65)](https://github.com/xming521/WeClone/stargazers)
[![GitHub release](https://img.shields.io/github/v/release/xming521/WeClone?style=for-the-badge&logo=github&label=Release&logoColor=white&color=06d094)](https://github.com/xming521/WeClone/releases)
<a href="https://qm.qq.com/cgi-bin/qm/qr?k=wNdgbOVT6oFOJ2wlMLsolUXErW9ESLpk&jump_from=webapi&authKey=z/reOp6YLyvR4Tl2k2nYMsLoMC3w9/99ucgKMX0oRGlxDV/WbYnvq2QxODoIkfxn" target="_blank">
<a href="https://qm.qq.com/cgi-bin/qm/qr?k=wNdgbOVT6oFOJ2wlMLsolUXErW9ESLpk&jump_from=webapi&authKey=z/reOp6YLyvR4Tl2k2nYMsLoMC3w9/99ucgKMX0oRGlxDV/WbYnvq2QxODoIkfxn" target="_blank" style="text-decoration: none;">
<img src="https://img.shields.io/badge/QQ群-708067078-12B7F5?style=for-the-badge&logo=qq&logoColor=white" alt="WeClone①" title="WeClone①">
</a>
[![Telegram](https://img.shields.io/badge/Telegram-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/+JEdak4m0XEQ3NGNl)
<a href="https://hellogithub.com/repository/12ab209b56cb4cfd885c8cfd4cfdd53e" target="_blank"><img src="https://abroad.hellogithub.com/v1/widgets/recommend.svg?rid=12ab209b56cb4cfd885c8cfd4cfdd53e&claim_uid=RThlPDoGrFvdMY5" alt="FeaturedHelloGitHub" style="width: 150px; height: 28px;" /></a>
<a href="https://deepwiki.com/xming521/WeClone"><img src="https://deepwiki.com/badge.svg" alt="Ask DeepWiki" style="width: 134px; height: 23px;margin-bottom: 3px;"></a>
</div>
## 核心功能✨
## ✨核心功能
- 💫 涵盖打造数字分身的全链路方案,包括聊天数据导出、预处理、模型训练、部署
- 💬 使用微信聊天记录微调LLM,让大模型有那味儿
- 🎙️ 使用微信语音消息➕0.5B大模型实现高质量声音克隆 👉[WeClone-audio](https://github.com/xming521/WeClone/tree/master/weclone-audio)
- 💬 使用微信聊天记录微调LLM,让大模型有"那味儿"
- 🔗 绑定到微信、QQ、Telegram、企微、飞书机器人,实现自己的数字分身
- 🛡️ 隐私信息过滤,本地化微调部署,数据安全可控
## 特性与说明📋
> [!TIP]
> 新特性:[WeClone-audio](https://github.com/xming521/WeClone/tree/master/weclone-audio) 模块,支持对微信语音进行克隆。
## 📋特性与说明
> [!IMPORTANT]
> <h3>0.2.0版本进行了全面重构,数据集目录和脚本路径全部进行了修改,拉取新代码后,`csv`文件夹放在`dataset`下,并且需要重新安装依赖。</h3>
> ### 0.2.1版本支持了命令行工具,使用前需要重新执行 `uv pip install -e .`
> [!IMPORTANT]
> 0.2.0版本进行了全面重构,数据集目录和脚本路径全部进行了修改,拉取新代码后,`csv`文件夹放在`dataset`下,并且需要重新安装依赖。
> [!IMPORTANT]
> - WeClone仍在快速迭代期,当前效果不代表最终效果。
@@ -46,88 +49,97 @@
| QLoRA | 2 | 4GB | 8GB | 16GB | 24GB | `x/4`GB |
### 环境搭建
cuda安装(已安装可跳过)[LLaMA Factory](https://llamafactory.readthedocs.io/zh-cn/latest/getting_started/installation.html#cuda)
## 环境搭建
1.cuda安装(已安装可跳过**要求版本12.4及以上**)[LLaMA Factory](https://llamafactory.readthedocs.io/zh-cn/latest/getting_started/installation.html#cuda)
建议使用 [uv](https://docs.astral.sh/uv/),这是一个非常快速的 Python 环境管理器。安装uv后,您可以使用以下命令创建一个新的Python环境并安装依赖项,注意这不包含音频克隆功能的依赖:
2.建议使用 [uv](https://docs.astral.sh/uv/)安装依赖,这是一个非常快速的 Python 环境管理器。安装uv后,您可以使用以下命令创建一个新的Python环境并安装依赖项,注意这不包含音频克隆功能的依赖:
```bash
git clone https://github.com/xming521/WeClone.git
cd WeClone
uv venv .venv --python=3.10
source .venv/bin/activate
source .venv/bin/activate # windows下执行 .venv\Scripts\activate
uv pip install --group main -e .
```
> [!TIP]
> 如果要使用最新的模型进行微调,需要手动安装最新版LLaMA Factory`uv pip install --upgrade git+https://github.com/hiyouga/LLaMA-Factory.git`,同时其他依赖版本也可能需要修改,例如vllm pytorch transforms
使用以下命令测试CUDA环境是否正确配置并可被PyTorch识别,Mac不需要
3.将配置文件模板复制一份并重命名为`settings.jsonc`,后续配置修改在此文件进行
```bash
cp settings.template.json settings.jsonc
```
> [!NOTE]
> 训练以及推理相关配置统一在文件`settings.jsonc`
4.使用以下命令测试CUDA环境是否正确配置并可被PyTorch识别,Mac不需要:
```bash
python -c "import torch; print('CUDA是否可用:', torch.cuda.is_available());"
```
(可选)安装FlashAttention,加速训练和推理:`uv pip install flash-attn --no-build-isolation`
5.(可选)安装FlashAttention,加速训练和推理:`uv pip install flash-attn --no-build-isolation`
## 数据准备
> [!NOTE]
> 训练以及推理相关配置统一在文件[settings.json](settings.json)
请使用[PyWxDump](https://github.com/xaoyaoo/PyWxDump)提取微信聊天记录。可以先将手机的聊天记录迁移(备份)到电脑,数据量更多一些。下载软件并解密数据库后,点击聊天备份,导出类型为CSV,可以导出多个联系人(不建议使用群聊记录),然后将导出的位于`wxdump_tmp/export``csv` 文件夹放在`./dataset`目录即可,也就是不同人聊天记录的文件夹一起放在 `./dataset/csv`
## 数据预处理
### 数据准备
- 项目默认去除了数据中的手机号、身份证号、邮箱、网址。还在`settings.jsonc`中提供了一个禁用词词库`blocked_words`,可以自行添加需要过滤的词句(会默认去掉包括禁用词的整句)。
> [!IMPORTANT]
> 🚨 请一定注意保护个人隐私,不要泄露个人信息!
请使用[PyWxDump](https://github.com/xaoyaoo/PyWxDump)提取微信聊天记录。可以先将手机的聊天记录迁移(备份)到电脑,数据量更多一些。下载软件并解密数据库后,点击聊天备份,导出类型为CSV,可以导出多个联系人或群聊,然后将导出的位于`wxdump_tmp/export``csv` 文件夹放在`./dataset`目录即可,也就是不同人聊天记录的文件夹一起放在 `./dataset/csv`
### 数据预处理
- 项目默认去除了数据中的手机号、身份证号、邮箱、网址。还提供了一个禁用词词库[blocked_words](dataset/blocked_words.json),可以自行添加需要过滤的词句(会默认去掉包括禁用词的整句)。
- 执行以下命令对数据进行处理,可以根据自己的聊天风格修改settings.json的`make_dataset_args`
- 执行以下命令对数据进行处理,可以根据自己的聊天风格修改settings.jsonc的`make_dataset_args`
```bash
python weclone/data/qa_generator.py
weclone-cli make-dataset
```
- 目前仅支持时间窗口策略,根据`single_combine_time_window`将单人连续消息通过逗号连接合并为一句,根据`qa_match_time_window`匹配问答对。后续将增加大模型清洗数据的功能。
- 目前仅支持时间窗口策略,根据`single_combine_time_window`将单人连续消息通过逗号连接合并为一句,根据`qa_match_time_window`匹配问答对。
- 可以启用`clean_dataset`中的`enable_clean`选项,对数据进行清洗,以达到更好效果。当前使用llm judge对聊天记录进行打分,使用vllm进行离线推理。在得到`llm打分分数分布情况`后,调整`accept_score`选择可以接受的分数,再适当降低`train_sft_args``lora_dropout`参数提升拟合效果。
### 模型下载
## 模型下载
```bash
git lfs install
git clone https://www.modelscope.cn/Qwen/Qwen2.5-7B-Instruct.git
```
### 配置参数并微调模型
## 配置参数并微调模型
- (可选)修改[settings.json](settings.json)的`model_name_or_path``template`选择本地下载好的其他模型。
- (可选)修改 `settings.jsonc``model_name_or_path``template` 选择本地下载好的其他模型。
- 修改`per_device_train_batch_size`以及`gradient_accumulation_steps`来调整显存占用。
- 可以根据自己数据集的数量和质量修改`lora_rank``lora_dropout`等参数。
#### 单卡训练
- 可以根据自己数据集的数量和质量修改`train_sft_args``num_train_epochs``lora_rank``lora_dropout`等参数。
### 单卡训练
```bash
python weclone/train/train_sft.py
weclone-cli train-sft
```
多卡环境单卡训练,需要先执行 `export CUDA_VISIBLE_DEVICES=0`
#### 多卡训练
取消`settings.json``deepspeed`行代码注释,使用以下命令多卡训练:
### 多卡训练
取消`settings.jsonc``deepspeed`行代码注释,使用以下命令多卡训练:
```bash
uv pip install deepspeed
deepspeed --num_gpus=使用显卡数量 weclone/train/train_sft.py
```
### 使用浏览器demo简单推理
可以在这一步测试出合适的temperature、top_p值,修改settings.json的`infer_args`后,供后续推理时使用。
可以在这一步测试出合适的temperature、top_p值,修改settings.jsonc`infer_args`后,供后续推理时使用。
```bash
python weclone/eval/web_demo.py
weclone-cli webchat-demo
```
### 使用接口进行推理
```bash
python weclone/server/api_service.py
weclone-cli server
```
### 使用常见聊天问题测试
有些答案比较抽象,主要原因是训练数据没有覆盖,后续通过RAG来解决。测试结果在test_result-my.txt。
不包含询问个人信息的问题,仅有日常聊天。测试结果在test_result-my.txt。
```bash
python weclone/server/api_service.py
python weclone/eval/test_model.py
weclone-cli server
weclone-cli test-model
```
### 微调效果
## 🖼️ 微调效果
使用Qwen2.5-14B-Instruct模型,大概3万条处理后的有效数据,loss降到了3.5左右的效果。
<details>
<summary>截图</summary>
@@ -140,14 +152,14 @@ python weclone/eval/test_model.py
</details>
### 部署到聊天机器人
## 🤖 部署到聊天机器人
[AstrBot](https://github.com/AstrBotDevs/AstrBot) 是易上手的多平台 LLM 聊天机器人及开发框架 ✨ 平台支持 QQ、QQ频道、Telegram、微信、企微、飞书。
使用步骤:
1. 部署 AstrBot
2. 在 AstrBot 中部署消息平台
3. 执行 `python weclone/server/api_service.py ` 启动api服务
3. 执行 `weclone-cli server` 启动api服务
4. 在 AstrBot 中新增服务提供商,类型选择OpenAIAPI Base URL 根据AstrBot部署方式填写(例如docker部署可能为http://172.17.0.1:8005/v1 ,模型填写gpt-3.5-turbo,API Key随意填写一个
5. 微调后不支持工具调用,请先关掉默认的工具,消息平台发送指令: `/tool off all`,否则会没有微调后的效果。
6. 根据微调时使用的default_system,在 AstrBot 中设置系统提示词。
@@ -157,21 +169,26 @@ python weclone/eval/test_model.py
7. 调整采样参数,例如temperature、top_p、top_k等
[配置自定义的模型参数](https://astrbot.app/config/model-config.html#%E9%85%8D%E7%BD%AE%E8%87%AA%E5%AE%9A%E4%B9%89%E7%9A%84%E6%A8%A1%E5%9E%8B%E5%8F%82%E6%95%B0)
## 📌 路线图
- [ ] 更丰富的上下文:包括上下文对话、聊天对象信息、时间等 + 思考
- [ ] Memory 支持
- [ ] 支持多模态
- [ ] 数据增强
- [ ] 支持GUI
## 问题解决
- 微调问题:[LLaMA-Factory| FAQs | 常见问题](https://github.com/hiyouga/LLaMA-Factory/issues/4614) 或者更方便的 [![更方便的Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hiyouga/LLaMA-Factory)
### 问题解决
- 微调问题:[LLaMA-Factory| FAQs | 常见问题](https://github.com/hiyouga/LLaMA-Factory/issues/4614)
### ❤️ 贡献代码
## ❤️ 贡献代码
欢迎任何 Issues/Pull Requests
你可以通过查看Issues或帮助审核 PR(拉取请求)来贡献。对于新功能的添加,请先通过 Issue 讨论。
运行`uv pip install --group dev -e .`安装开发依赖。
项目使用`pytest`测试,`pyright`检查类型,`ruff`检查代码格式。
项目使用`pytest`测试(测试脚本待完善)`pyright`检查类型,`ruff`检查代码格式。
### 免责声明
## ⚠️ 免责声明
> [!CAUTION]
> 请勿用于非法用途,否则后果自负。
<details>
@@ -215,7 +232,7 @@ python weclone/eval/test_model.py
<br>
<br>
### ⭐ Star History
## ⭐ Star History
> [!TIP]
> 如果本项目对您有帮助,或者您关注本项目的未来发展,请给项目 Star,谢谢
@@ -226,4 +243,4 @@ python weclone/eval/test_model.py
</div>
<div align="center"> 克隆我们,保留灵魂的芬芳 </div>
<div align="center"> 克隆我们,保留灵魂的芬芳 </div>
-7
View File
@@ -1,7 +0,0 @@
{
"blocked_words": [
"例如 姓名",
"例如 地址",
"//....."
]
}
+224
View File
@@ -0,0 +1,224 @@
{
"questions": [
[
"你多大了?"
],
[
"你有什么爱好吗?"
],
[
"你的理想是什么?",
"你觉得你离你的理想还有多远?"
],
[
"你最近在忙什么?",
"工作/学习顺利吗?",
"有什么有趣的事情发生吗?"
],
[
"你喜欢看什么类型的电影?",
"最近看过什么好看的电影吗?",
"你最喜欢的电影是什么?"
],
[
"你平时喜欢听什么音乐?",
"有推荐的歌手或乐队吗?",
"最近有喜欢的歌曲吗?"
],
[
"你喜欢旅游吗?",
"去过哪些地方?",
"最喜欢的旅游地是哪里?"
],
[
"你喜欢读书吗?",
"最近在读什么书?",
"最喜欢的书是哪本?"
],
[
"你平时喜欢运动吗?",
"喜欢做哪些运动?",
"有固定去锻炼吗?"
],
[
"周末一般都做些什么?",
"有没有什么特别的计划?",
"周末喜欢宅在家还是出去玩?"
],
[
"你喜欢宠物吗?",
"有养宠物吗?",
"最喜欢什么动物?"
],
[
"你喜欢吃什么类型的食物?",
"有推荐的餐厅吗?",
"最喜欢的菜是什么?"
],
[
"你喜欢什么样的天气?",
"最喜欢的季节是哪一个?",
"你觉得今天的天气怎么样?"
],
[
"你有看电视剧的习惯吗?",
"最近在追哪部剧?",
"最喜欢的电视剧是哪部?"
],
[
"你喜欢玩游戏吗?",
"最近在玩什么游戏?",
"有推荐的好玩的游戏吗?"
],
[
"你会做饭吗?",
"平时喜欢做哪些菜?",
"有没有特别拿手的菜?"
],
[
"你喜欢购物吗?",
"最近买了什么新东西?",
"有推荐的购物网站或店铺吗?"
],
[
"你平时怎么放松自己?",
"有特别的解压方式吗?",
"最喜欢的放松活动是什么?"
],
[
"你喜欢和朋友出去玩吗?",
"平时会和朋友去哪玩?",
"最近有没有和朋友聚会的计划?"
],
[
"你喜欢喝咖啡还是茶?",
"有没有特别喜欢的咖啡馆或茶馆?",
"最喜欢的饮品是什么?"
],
[
"你有兄弟姐妹吗?",
"和他们关系怎么样?",
"经常联系吗?"
],
[
"你喜欢读什么类型的杂志?",
"最近有看什么有趣的文章吗?",
"有订阅的杂志吗?"
],
[
"你喜欢看体育比赛吗?",
"最喜欢的运动项目是什么?",
"有没有特别支持的球队或运动员?"
],
[
"你会说其他语言吗?",
"最想学的语言是什么?",
"学习语言有什么技巧吗?"
],
[
"你对科技产品感兴趣吗?",
"最近有没有关注什么新科技?",
"最喜欢的电子产品是什么?"
],
[
"你喜欢喝什么样的饮料?",
"有没有自己调饮料的习惯?",
"最喜欢的饮品品牌是什么?"
],
[
"你平时用社交媒体吗?",
"常用哪些平台?",
"在社交媒体上做什么?"
],
[
"你对艺术感兴趣吗?",
"最喜欢的艺术家是谁?",
"有去过哪些艺术展览?"
],
[
"你喜欢DIY吗?",
"平时做些什么手工?",
"有没有完成的作品可以分享?"
],
[
"你喜欢种植植物吗?",
"有养什么植物?",
"最喜欢的植物是什么?"
],
[
"你喜欢拍照吗?",
"喜欢拍什么样的照片?",
"有没有用什么特别的摄影设备?"
],
[
"你喜欢听播客吗?",
"常听哪些主题的播客?",
"有没有推荐的播客?"
],
[
"你对历史感兴趣吗?",
"最喜欢哪个历史时期?",
"有没有特别喜欢的历史人物?"
],
[
"你喜欢画画吗?",
"平时画什么类型的画?",
"有参加过画展吗?"
],
[
"你喜欢写作吗?",
"平时写什么类型的文章?",
"有没有发表过作品?"
],
[
"你喜欢钓鱼吗?",
"平时去哪里钓鱼?",
"有没有钓到过什么大鱼?"
],
[
"你喜欢露营吗?",
"平时会去哪里露营?",
"有没有什么难忘的露营经历?"
],
[
"你喜欢摄影吗?",
"最喜欢拍什么题材?",
"有没有特别喜欢的摄影师?"
],
[
"你喜欢喝酒吗?",
"喜欢什么类型的酒?",
"有没有推荐的酒吧或品牌?"
],
[
"你喜欢滑雪吗?",
"平时去哪里滑雪?",
"有没有什么滑雪技巧分享?"
],
[
"你喜欢海边还是山里?",
"最喜欢去哪个地方度假?",
"有没有什么特别推荐的景点?"
],
[
"你喜欢参加音乐节吗?",
"参加过哪些音乐节?",
"最喜欢的音乐节是哪一个?"
],
[
"你喜欢跑步吗?",
"平时跑多长距离?",
"有没有参加过马拉松?"
],
[
"你喜欢参加聚会吗?",
"平时和朋友聚会做什么?",
"有没有什么有趣的聚会游戏?"
],
[
"你喜欢收集东西吗?",
"收集什么类型的物品?",
"有没有什么特别的收藏?"
]
]
}
+76 -150
View File
@@ -8,224 +8,150 @@
"可以请我吃吗"
],
[
"你多大了?"
"干嘛呢?",
"等会准备干什么去"
],
[
"你有什么爱好吗?"
"在忙什么呢?",
"今天有什么特别的安排吗?",
"感觉怎么样?"
],
[
"你的理想是什么",
"你觉得你离你的理想还有多远"
"最近有什么新鲜事发生吗",
"有没有什么有趣的故事可以分享"
],
[
"你最近在忙什么",
"工作/学习顺利吗?",
"有什么有趣的事情发生吗?"
"周末过得怎么样",
"做了什么好玩的?"
],
[
"你喜欢看什么类型的电影",
"最近看过什么好看的电影吗?",
"你最喜欢的电影是什么"
"最近看了什么好看的电影或电视剧吗",
"有什么推荐的吗?",
"大概讲了什么内容呀"
],
[
"你平时喜欢听什么音乐",
"有推荐的歌手或乐队吗?",
"最近有喜欢的歌曲吗?"
"今天天气怎么样",
"你那里呢?"
],
[
"你喜欢旅游吗?",
"去过哪些地方?",
"最喜欢的旅游地是哪里?"
"最近工作/学习顺利吗?",
"有没有遇到什么挑战?"
],
[
"你喜欢读书吗",
"最近在读什么书",
"最喜欢的书是哪本"
"嗨,这会儿在忙啥呢",
"今天有什么特别的安排不",
"一切都还顺利吧"
],
[
"你平时喜欢运动吗",
"喜欢做哪些运动",
"有固定去锻炼吗"
"你那边现在天气咋样啊",
"是大晴天还是有点阴沉沉的",
"冷不冷,或者热不热呀"
],
[
"周末一般都做些什么",
"有没有什么特别的计划",
"周末喜欢宅在家还是出去玩"
"到饭点儿了没呀",
"今天打算犒劳一下自己,吃点啥好吃的",
"有没有啥特别想吃的,或者想去哪家馆子尝尝鲜"
],
[
"你喜欢宠物吗?",
"有养宠物吗?",
"最喜欢什么动物?"
"最近网上有啥好玩儿的新闻或者梗吗?",
"刷到啥有意思的视频或者段子没?分享一下呗!"
],
[
"你喜欢吃什么类型的食物",
"有推荐的餐厅吗?",
"最喜欢的菜是什么?"
"待会儿有啥打算呀",
"今天剩下的时间准备怎么过呢?"
],
[
"你喜欢什么样的天气",
"最喜欢的季节是哪一个?",
"你觉得今天的天气怎么样?"
"今天有没有碰到啥让你眼前一亮的小事儿",
"随便聊聊呗,有啥轻松点的话题不?"
],
[
"你有看电视剧的习惯吗",
"最近在追哪部剧?",
"最喜欢的电视剧是哪部?"
"今天有啥新发现或者小感悟没",
"感觉今天过得快不快?节奏怎么样?"
],
[
"你喜欢玩游戏吗",
"最近在玩什么游戏",
"有推荐的好玩的游戏吗"
"你现在周围环境咋样,吵不吵",
"今天出门溜达了没,外面人多不多呀",
"瞅瞅窗外,有啥特别的景儿不"
],
[
"你会做饭吗",
"平时喜欢做哪些菜?",
"有没有特别拿手的菜?"
"吃饭了没啊",
"吃的啥呀?合胃口不?"
],
[
"你喜欢购物吗",
"最近买了什么新东西?",
"有推荐的购物网站或店铺吗?"
"今天怎么样啊?累不累",
"有啥事儿不?"
],
[
"你平时怎么放松自己",
"有特别的解压方式吗?",
"最喜欢的放松活动是什么?"
"最近身体还好吧",
"没什么不舒服的地方吧?"
],
[
"你喜欢和朋友出去玩吗",
"平时会和朋友去哪玩?",
"最近有没有和朋友聚会的计划?"
"今天忙不忙啊",
"都干啥了呀?"
],
[
"你喜欢喝咖啡还是茶",
"有没有特别喜欢的咖啡馆或茶馆?",
"最喜欢的饮品是什么?"
"家里都挺好的吧",
"有啥需要帮忙的不?"
],
[
"你有兄弟姐妹吗",
"和他们关系怎么样?",
"经常联系吗?"
"今天出门了没",
"外面冷不冷/热不热啊?多穿点/注意防暑。"
],
[
"你喜欢读什么类型的杂志?",
"最近有看什么有趣的文章吗?",
"有订阅的杂志吗?"
"最近有啥开心的事儿不?说来听听!",
"或者有啥烦心事儿,跟我说说?"
],
[
"你喜欢看体育比赛吗?",
"最喜欢的运动项目是什么?",
"有没有特别支持的球队或运动员?"
"晚上早点休息啊,别熬太晚。",
"睡得好不好啊最近?"
],
[
"你会说其他语言吗?",
"最想学的语言是什么?",
"学习语言有什么技巧吗?"
"缺啥东西不?跟我说。",
"钱够不够花呀?"
],
[
"你对科技产品感兴趣吗",
"最近有没有关注什么新科技?",
"最喜欢的电子产品是什么?"
"今天看到啥有意思的了没",
"或者有啥想跟我分享的?"
],
[
"你喜欢喝什么样的饮料",
"有没有自己调饮料的习惯?",
"最喜欢的饮品品牌是什么?"
"周末有啥安排啊",
"要不要一起吃个饭/出去转转?"
],
[
"你平时用社交媒体吗",
"常用哪些平台?",
"在社交媒体上做什么?"
"最近常联系的那些朋友都还好不",
"有空多聚聚。"
],
[
"你对艺术感兴趣吗",
"最喜欢的艺术家是谁?",
"有去过哪些艺术展览?"
"工作/学习上还顺利吧",
"别太给自己压力啊。"
],
[
"你喜欢DIY吗",
"平时做些什么手工?",
"有没有完成的作品可以分享?"
"今天做了啥好吃的呀",
"下次也给我尝尝呗!"
],
[
"你喜欢种植植物吗",
"有养什么植物?",
"最喜欢的植物是什么?"
"有啥新闻没有啊最近",
"跟我讲讲。"
],
[
"你喜欢拍照吗",
"喜欢拍什么样的照片?",
"有没有用什么特别的摄影设备?"
"那谁谁谁最近怎么样了",
"好久没听到他/她消息了。"
],
[
"你喜欢听播客吗",
"常听哪些主题的播客?",
"有没有推荐的播客?"
"今天心情好不好呀",
"看你气色不错/有点疲惫。"
],
[
"你对历史感兴趣吗?",
"最喜欢哪个历史时期?",
"有没有特别喜欢的历史人物?"
"有啥想吃的没?下次给你做/带。",
"或者想去哪儿玩,我陪你。"
],
[
"你喜欢画画吗",
"平时画什么类型的画?",
"有参加过画展吗?"
"最近有没有看啥电视剧/电影啊",
"有啥好看的推荐给我呗。"
],
[
"你喜欢写作吗?",
"平时写什么类型的文章?",
"有没有发表过作品?"
],
[
"你喜欢钓鱼吗?",
"平时去哪里钓鱼?",
"有没有钓到过什么大鱼?"
],
[
"你喜欢露营吗?",
"平时会去哪里露营?",
"有没有什么难忘的露营经历?"
],
[
"你喜欢摄影吗?",
"最喜欢拍什么题材?",
"有没有特别喜欢的摄影师?"
],
[
"你喜欢喝酒吗?",
"喜欢什么类型的酒?",
"有没有推荐的酒吧或品牌?"
],
[
"你喜欢滑雪吗?",
"平时去哪里滑雪?",
"有没有什么滑雪技巧分享?"
],
[
"你喜欢海边还是山里?",
"最喜欢去哪个地方度假?",
"有没有什么特别推荐的景点?"
],
[
"你喜欢参加音乐节吗?",
"参加过哪些音乐节?",
"最喜欢的音乐节是哪一个?"
],
[
"你喜欢跑步吗?",
"平时跑多长距离?",
"有没有参加过马拉松?"
],
[
"你喜欢参加聚会吗?",
"平时和朋友聚会做什么?",
"有没有什么有趣的聚会游戏?"
],
[
"你喜欢收集东西吗?",
"收集什么类型的物品?",
"有没有什么特别的收藏?"
"没事儿就早点回家/休息。",
"注意安全啊。"
]
]
}
+29 -12
View File
@@ -1,20 +1,34 @@
[project]
name = "WeClone"
version = "0.2.0"
version = "0.2.2"
description = "从聊天记录创造数字分身的一站式解决方案"
authors = [{ name = "xming521" }]
readme = "README.md"
requires-python = ">=3.10,<3.11"
dependencies = [
"pandas",
"commentjson",
"click",
"pydantic==2.10.6",
"setuptools>=78.1.0",
"loguru>=0.7.3",
"torch>=2.5.1",
"torch>=2.6.0",
"transformers==4.49.0",
"tomli; python_version < '3.11'",
"langchain",
]
[tool.weclone]
# 配置文件的版本号,当配置文件结构或重要默认值发生变化时,应增加此版本号
config_version = "0.2.2"
# 配置文件更新日志
config_changelog = """
[0.2.1] - 2025-04-29 - 初始配置版本。
[0.2.2] - 2025-05-01 - 增加llm清洗数据配置,blocked_words迁移到settings.jsonc统一配置文件。
"""
[dependency-groups]
# xcodec = ["xcodec2==0.1.3"]
sparktts = [
@@ -26,12 +40,15 @@ sparktts = [
"safetensors>=0.5.2",
"soundfile>=0.12.1",
"soxr>=0.5.0.post1",
"torchaudio>=2.5.1",
"torchaudio>=2.6.0",
"tqdm>=4.66.5",
]
main = ["llamafactory>=0.9.2", "openai==0.28.0"]
main = ["llamafactory>=0.9.2", "openai==1.76.0", "vllm==0.8.2"]
dev = ["pytest", "pyright", "ruff"]
[project.scripts]
weclone-cli = "weclone.cli:cli"
[tool.uv]
conflicts = [
# [{ group = "wx" }, { group = "xcodec" }],
@@ -39,16 +56,16 @@ conflicts = [
[tool.uv.sources]
torch = [
{ index = "pytorch-cu121", marker = "platform_system == 'Windows'" },
{ index = "pytorch-cu121", marker = "platform_system == 'Linux'" },
{ index = "pytorch-cu124", marker = "platform_system == 'Windows'" },
{ index = "pytorch-cu124", marker = "platform_system == 'Linux'" },
]
torchaudio = [
{ index = "pytorch-cu121", marker = "platform_system == 'Windows'" },
{ index = "pytorch-cu121", marker = "platform_system == 'Linux'" },
{ index = "pytorch-cu124", marker = "platform_system == 'Windows'" },
{ index = "pytorch-cu124", marker = "platform_system == 'Linux'" },
]
torchvision = [
{ index = "pytorch-cu121", marker = "platform_system == 'Windows'" },
{ index = "pytorch-cu121", marker = "platform_system == 'Linux'" },
{ index = "pytorch-cu124", marker = "platform_system == 'Windows'" },
{ index = "pytorch-cu124", marker = "platform_system == 'Linux'" },
]
@@ -57,8 +74,8 @@ url = "https://pypi.tuna.tsinghua.edu.cn/simple/"
default = true
[[tool.uv.index]]
name = "pytorch-cu121"
url = "https://download.pytorch.org/whl/cu121"
name = "pytorch-cu124"
url = "https://download.pytorch.org/whl/cu124"
explicit = true
[tool.setuptools.packages.find]
+37 -22
View File
@@ -1,5 +1,39 @@
{
"version": "0.2.2",
"common_args": {
"model_name_or_path": "./Qwen2.5-7B-Instruct",
"adapter_name_or_path": "./model_output", //train_sft_argsoutput_dir
"template": "qwen",
"default_system": "请你扮演一名人类,不要说自己是人工智能",
"finetuning_type": "lora",
"trust_remote_code": true
},
"make_dataset_args": {
//
"include_type": [
"文本"
],
"blocked_words": [ //
"例如 姓名",
"例如 密码",
"//....."
],
"single_combine_strategy": "time_window", //
"qa_match_strategy": "time_window", // qa
"single_combine_time_window": 2, // ,
"qa_match_time_window": 5, // qa,
"combine_msg_max_length": 256, // cutoff_len 使
"prompt_with_history": false, // prompt
"clean_dataset": {
"enable_clean": true,
"clean_strategy": "llm",
"llm": {
"accept_score": 2, //llm,15,
}
}
},
"train_pt_args": {
//
"stage": "pt",
"dataset": "wechat-pt",
"dataset_dir": "./dataset/res_csv/pt",
@@ -19,6 +53,7 @@
"fp16": true
},
"train_sft_args": {
//
"stage": "sft",
"dataset": "wechat-sft",
"dataset_dir": "./dataset/res_csv/sft",
@@ -27,11 +62,11 @@
"lora_rank": 4,
"lora_dropout": 0.4,
"weight_decay": 0.1,
"overwrite_cache": true,
"overwrite_cache": true,
"per_device_train_batch_size": 8,
"gradient_accumulation_steps": 4,
"lr_scheduler_type": "cosine",
"cutoff_len": 256,
"cutoff_len": 256,
"logging_steps": 10,
"save_steps": 100,
"learning_rate": 1e-4,
@@ -47,25 +82,5 @@
"temperature": 0.5,
"max_length": 50,
"top_p": 0.65
},
"make_dataset_args": {
// "enable_vision_model": false,//
// "include_type": [
// "文本"
// ],
"single_combine_strategy": "time_window", //
"qa_match_strategy": "time_window", // qa
"single_combine_time_window": 2, // ,
"qa_match_time_window": 5, // qa,
"combine_msg_max_length": 256, // cutoff_len 使
"prompt_with_history": false // prompt
},
"common_args": {
"model_name_or_path": "./Qwen2.5-7B-Instruct",
"adapter_name_or_path": "./model_output", //train_sft_argsoutput_dir
"template": "qwen",
"default_system": "请你扮演一名人类,不要说自己是人工智能",
"finetuning_type": "lora",
"trust_remote_code": true
}
}
+176
View File
@@ -0,0 +1,176 @@
import pytest
from unittest.mock import patch, MagicMock, call
from langchain_core.prompts import PromptTemplate
from datetime import datetime
import pandas as pd # 导入 pandas
# 确保可以正确导入被测试的模块和依赖项
# 可能需要根据你的项目结构调整导入路径
try:
from weclone.data.clean.strategies import LLMCleaningStrategy
from weclone.data.models import QaPair
from weclone.prompts.clean_data import CLEAN_PROMPT
except ImportError:
# 如果直接运行脚本时找不到模块,尝试添加项目根目录到 sys.path
import sys
import os
# 获取当前脚本文件所在的目录 (tests/data/clean)
current_dir = os.path.dirname(os.path.abspath(__file__))
# 获取 tests 目录
tests_dir = os.path.dirname(os.path.dirname(current_dir))
# 获取项目根目录 (weclone 的父目录)
project_root = os.path.dirname(tests_dir)
sys.path.insert(0, project_root)
from weclone.data.clean.strategies import LLMCleaningStrategy
from weclone.data.models import QaPair
from weclone.prompts.clean_data import CLEAN_PROMPT
@pytest.fixture
def sample_qa_pairs():
"""提供一些测试用的 QaPair 数据"""
# now = datetime.now() # 不再需要 datetime
return [
QaPair(id=1, instruction="问题1", output="答案1", system="", history=[], time=pd.Timestamp.now(), score=0), # 使用 pd.Timestamp
QaPair(id=2, instruction="问题2", output="答案2", system="", history=[], time=pd.Timestamp.now(), score=0), # 使用 pd.Timestamp
]
@pytest.fixture
def mock_make_dataset_config():
"""提供模拟的 make_dataset_config"""
return {
"model_name_or_path": "mock_model",
"template": "mock_template",
# 可以根据需要添加其他配置
}
@patch("weclone.data.clean.strategies.infer") # 模拟 infer 函数
def test_llm_cleaning_strategy_clean(mock_infer, sample_qa_pairs, mock_make_dataset_config):
"""测试 LLMCleaningStrategy.clean 方法"""
# 1. 准备
print("--- 开始测试 test_llm_cleaning_strategy_clean ---")
strategy = LLMCleaningStrategy(make_dataset_config=mock_make_dataset_config)
prompt_template = PromptTemplate.from_template(CLEAN_PROMPT)
# 预期 infer 函数的输入
expected_inputs = []
for qa in sample_qa_pairs:
expected_inputs.append(prompt_template.invoke({"id": qa.id, "Q": qa.instruction, "A": qa.output}))
print(f"预期 infer 输入: {expected_inputs}")
# 设置模拟 infer 函数的返回值
mock_cleaned_outputs = ["cleaned_output_1", "cleaned_output_2"]
mock_infer.return_value = mock_cleaned_outputs
print(f"设置 mock infer 返回值: {mock_cleaned_outputs}")
# 2. 执行
print("调用 strategy.clean...")
# 注意:原始的 clean 方法没有 return 语句。如果需要测试返回值,
# 需要在 weclone/data/clean/strategies.py 中取消注释 'return cleaned_data'
cleaned_data = strategy.clean(sample_qa_pairs)
# strategy.clean(sample_qa_pairs) # 暂时只调用,不获取返回值
print(f"获取的 cleaned_data: {cleaned_data}") # 如果有返回值,取消注释此行
# 3. 断言
print("执行断言...")
# 验证 infer 函数是否以正确的参数被调用
try:
mock_infer.assert_called_once_with(
expected_inputs,
mock_make_dataset_config["model_name_or_path"],
template=mock_make_dataset_config["template"],
temperature=0,
)
print("infer 函数调用断言成功!")
except AssertionError as e:
print(f"infer 函数调用断言失败: {e}")
raise # 重新抛出异常,以便 pytest 能捕获
# 验证 clean 方法的返回值(基于假设)
# 如果原始 clean 方法确实没有 return,可以移除这个断言或者修改 clean 方法添加 return
assert cleaned_data == mock_cleaned_outputs
print("返回值断言成功!")
print("--- 测试 test_llm_cleaning_strategy_clean 结束 ---")
if __name__ == "__main__":
print("直接运行测试脚本进行调试...")
# 手动准备依赖项 (代替 pytest fixtures)
# now_main = datetime.now() # 不再需要 datetime
qa_pairs = [
QaPair(id=101, instruction="调试问题1", output="调试答案1", system="", history=[], time=pd.Timestamp.now(), score=0), # 使用 pd.Timestamp
QaPair(id=102, instruction="调试问题2", output="调试答案2", system="", history=[], time=pd.Timestamp.now(), score=0), # 使用 pd.Timestamp
]
config = {
"model_name_or_path": "debug_model",
"template": "debug_template",
}
# 方案1:直接调用被测代码逻辑 (更简单)
print("\n--- 方案1:直接调用被测代码逻辑 ---")
try:
from weclone.data.clean.strategies import infer # 需要导入 infer
except ImportError:
# 处理导入错误的代码已在文件顶部
from weclone.data.clean.strategies import infer
with patch("weclone.data.clean.strategies.infer") as mock_infer_main:
strategy = LLMCleaningStrategy(make_dataset_config=config)
prompt_template = PromptTemplate.from_template(CLEAN_PROMPT)
inputs_main = []
for qa in qa_pairs:
inputs_main.append(prompt_template.invoke({"id": qa.id, "Q": qa.instruction, "A": qa.output}))
mock_return = ["debug_cleaned_1", "debug_cleaned_2"]
mock_infer_main.return_value = mock_return
print(f"设置 main 中的 mock infer 返回值: {mock_return}")
print("在 main 中调用 strategy.clean...")
cleaned_result_main = strategy.clean(qa_pairs) # 如果 clean 有返回值
# strategy.clean(qa_pairs) # 如果 clean 没有返回值
print(f"Main 中获取的 cleaned_result: {cleaned_result_main}") # 如果有返回值
print("在 main 中进行断言...")
try:
mock_infer_main.assert_called_once_with(
inputs_main,
config["model_name_or_path"],
template=config["template"],
temperature=0,
)
print("Main 中的 infer 函数调用断言成功!")
if cleaned_result_main == mock_return: # 如果有返回值
print("Main 中的返回值断言成功!")
else:
print(f"Main 中的返回值断言失败: 预期 {mock_return}, 得到 {cleaned_result_main}")
except AssertionError as e:
print(f"Main 中的 infer 函数调用断言失败: {e}")
# # 方案2:手动调用测试函数(稍微复杂,需要手动创建 mock)
# print("\\n--- 方案2:手动调用测试函数 ---")
# # 创建一个 mock 对象手动传递
# mock_infer_manual = MagicMock()
# # 为手动创建的 mock 设置返回值 (如果需要)
# mock_return_manual = ["debug_cleaned_1_manual", "debug_cleaned_2_manual"]
# mock_infer_manual.return_value = mock_return_manual
# print(f"设置 manual mock infer 返回值: {mock_return_manual}")
# try:
# print("手动调用 test_llm_cleaning_strategy_clean...")
# # 注意:直接调用被 @patch 装饰的函数可能导致 TypeError
# # 因为装饰器期望由测试运行器(如 pytest)注入 mock 对象
# test_llm_cleaning_strategy_clean(mock_infer_manual, qa_pairs, config)
# print("手动调用测试函数完成。请检查上面的打印输出。")
# # 检查手动传入的 mock 是否被调用 (可能不会,因为 @patch 可能覆盖了它)
# print("检查 manual mock 调用次数:", mock_infer_manual.call_count)
# except TypeError as e:
# print(f"\\n手动调用测试函数时捕获到 TypeError: {e}")
# print("这通常发生在直接运行脚本时,@patch 装饰器未能正确处理 mock 注入。")
# print("建议使用方案1'with patch(...)' 上下文管理器)进行调试,因为它在 __main__ 块中更可靠。")
print("\n调试脚本运行结束。")
+1 -1
View File
@@ -52,7 +52,7 @@ step_identifiers = {
# Order for fallback logic
step_order = [STEP_QA, STEP_TRAIN, STEP_COPY_CKPT, STEP_API_START, STEP_EVAL, STEP_WEB_DEMO]
#todo 需要测试前替换成测试的settings.json 测试完再替换回来
#todo 需要测试前替换成测试的settings.jsonc 测试完再替换回来
class PipelineStepError(Exception):
"""自定义异常类,用于表示 Pipeline 步骤执行失败。"""
+72
View File
@@ -0,0 +1,72 @@
import pytest
from weclone.data.clean.get_score import adjust_score_tiered
# 定义通用的参数
THRESHOLDS = [0.6, 0.3] # 置信度阈值:>=0.6 高, >=0.3 中, <0.3 低
DOWNGRADE_LEVELS = [0, 1, 2] # 对应降级幅度:高->0级, 中->1级, 低->2级
THRESHOLDS_FINE = [0.7, 0.5, 0.3]
DOWNGRADE_LEVELS_FINE = [0, 1, 2, 3] # 对应 >=0.7, >=0.5, >=0.3, <0.3
test_cases = [
# 案例 1: 高置信度
(5, [0.05, 0.05, 0.1, 0.1, 0.7], THRESHOLDS, DOWNGRADE_LEVELS, 5, "高置信度"),
# 案例 2: 中等置信度
(4, [0.1, 0.15, 0.2, 0.45, 0.1], THRESHOLDS, DOWNGRADE_LEVELS, 3, "中等置信度"),
# 案例 3: 低置信度
(4, [0.15, 0.2, 0.25, 0.25, 0.15], THRESHOLDS, DOWNGRADE_LEVELS, 2, "低置信度"),
# 案例 4: 低置信度,但原始分较低
(2, [0.3, 0.2, 0.2, 0.15, 0.15], THRESHOLDS, DOWNGRADE_LEVELS, 1, "低置信度,原始分较低"),
# 案例 5: 边界情况 - 刚好等于高阈值
(3, [0.1, 0.1, 0.6, 0.1, 0.1], THRESHOLDS, DOWNGRADE_LEVELS, 3, "边界情况 - 等于高阈值"),
# 案例 6: 边界情况 - 刚好等于中阈值
(3, [0.2, 0.2, 0.3, 0.15, 0.15], THRESHOLDS, DOWNGRADE_LEVELS, 2, "边界情况 - 等于中阈值"),
# 案例 7: 细分阈值 - 中高置信度
(4, [0.1, 0.1, 0.2, 0.55, 0.05], THRESHOLDS_FINE, DOWNGRADE_LEVELS_FINE, 3, "细分阈值 - 中高置信度"),
# 案例 8: 细分阈值 - 中低置信度
(4, [0.15, 0.15, 0.2, 0.35, 0.15], THRESHOLDS_FINE, DOWNGRADE_LEVELS_FINE, 2, "细分阈值 - 中低置信度"),
# 案例 9: 概率和异常 (预期行为是打印警告并继续计算)
(3, [0.1, 0.1, 0.5, 0.1, 0.1], THRESHOLDS, DOWNGRADE_LEVELS, 3, "概率和异常"),
]
@pytest.mark.parametrize("initial_score, probabilities, thresholds, downgrade_levels, expected_score, description", test_cases)
def test_adjust_score_tiered(initial_score, probabilities, thresholds, downgrade_levels, expected_score, description):
""" 测试 adjust_score_tiered 函数在各种情况下的表现 """
print(f"测试案例: {description}")
print(f" 输入: score={initial_score}, probs={probabilities}, thresholds={thresholds}, levels={downgrade_levels}")
adjusted_score = adjust_score_tiered(initial_score, probabilities, thresholds, downgrade_levels)
print(f" 输出: adjusted_score={adjusted_score}, 预期: {expected_score}")
assert adjusted_score == expected_score
# 测试非法输入
def test_adjust_score_invalid_input():
""" 测试非法输入是否按预期引发 ValueError """
# initial_score 无效
with pytest.raises(ValueError, match="initial_score 必须在 1 到 5 之间"):
adjust_score_tiered(0, [0.2]*5, THRESHOLDS, DOWNGRADE_LEVELS)
with pytest.raises(ValueError, match="initial_score 必须在 1 到 5 之间"):
adjust_score_tiered(6, [0.2]*5, THRESHOLDS, DOWNGRADE_LEVELS)
# probabilities 长度无效
with pytest.raises(ValueError, match="probabilities 列表必须包含 5 个元素"):
adjust_score_tiered(3, [0.2]*4, THRESHOLDS, DOWNGRADE_LEVELS)
with pytest.raises(ValueError, match="probabilities 列表必须包含 5 个元素"):
adjust_score_tiered(3, [0.1]*6, THRESHOLDS, DOWNGRADE_LEVELS) # 总和也不为1
# # probabilities 和不为 1 (现在是警告,不抛异常)
# with pytest.raises(ValueError, match="probabilities 中元素的和必须接近 1.0"):
# adjust_score_tiered(3, [0.1]*5, THRESHOLDS, DOWNGRADE_LEVELS)
# downgrade_levels 长度无效
with pytest.raises(ValueError, match="downgrade_levels 的长度必须比 thresholds 的长度多 1"):
adjust_score_tiered(3, [0.2]*5, THRESHOLDS, [0, 1])
with pytest.raises(ValueError, match="downgrade_levels 的长度必须比 thresholds 的长度多 1"):
adjust_score_tiered(3, [0.2]*5, THRESHOLDS, [0, 1, 2, 3])
# thresholds 不是降序
with pytest.raises(ValueError, match="thresholds 列表必须是降序排列的"):
adjust_score_tiered(3, [0.2]*5, [0.3, 0.6], DOWNGRADE_LEVELS)
# downgrade_levels 包含负数
with pytest.raises(ValueError, match="downgrade_levels 中的降级幅度不能为负数"):
adjust_score_tiered(3, [0.2]*5, THRESHOLDS, [0, -1, 2])
+3 -3
View File
@@ -41,7 +41,7 @@ class TestWeclonePipeline(unittest.TestCase):
# 创建简单的测试CSV数据
cls._create_test_csv(os.path.join(chat_folder, "test_chat.csv"))
# 创建测试用的settings.json
# 创建测试用的settings.jsonc
cls._create_test_settings()
# 创建测试用的test_data.json用于模型评估
@@ -77,7 +77,7 @@ class TestWeclonePipeline(unittest.TestCase):
@classmethod
def _create_test_settings(cls):
"""创建测试用的settings.json"""
"""创建测试用的settings.jsonc"""
# 简化版的设置文件,只包含测试所需的最小配置
settings = {
"train_sft_args": {
@@ -121,7 +121,7 @@ class TestWeclonePipeline(unittest.TestCase):
}
# 保存到临时目录
with open(os.path.join(cls.test_dir, "settings.json"), "w", encoding="utf-8") as f:
with open(os.path.join(cls.test_dir, "settings.jsonc"), "w", encoding="utf-8") as f:
json.dump(settings, f, indent=4)
@classmethod
+181 -1
View File
@@ -1 +1,181 @@
from weclone.data.qa_generator import DataProcessor
import click
import commentjson
from pathlib import Path
import os
import sys
import functools
from weclone.utils.log import logger
try:
import tomllib # type: ignore Python 3.11+
except ImportError:
import tomli as tomllib
def clear_argv(func):
"""
装饰器:在调用被装饰函数前,清理 sys.argv,只保留脚本名。调用后恢复原始 sys.argv。
用于防止参数被 Hugging Face HfArgumentParser 解析造成 ValueError。
"""
@functools.wraps(func)
def wrapper(*args, **kwargs):
original_argv = sys.argv.copy()
sys.argv = [original_argv[0]] # 只保留脚本名
try:
return func(*args, **kwargs)
finally:
sys.argv = original_argv # 恢复原始 sys.argv
return wrapper
@click.group()
def cli():
"""WeClone: 从聊天记录创造数字分身的一站式解决方案"""
_check_project_root()
_check_versions()
@cli.command("make-dataset", help="处理聊天记录CSV文件,生成问答对数据集。")
@clear_argv
def qa_generator():
"""处理聊天记录CSV文件,生成问答对数据集。"""
from weclone.data.qa_generator import DataProcessor
processor = DataProcessor()
processor.main()
@cli.command("train-sft", help="使用准备好的数据集对模型进行微调。")
@clear_argv
def train_sft():
"""使用准备好的数据集对模型进行微调。"""
from weclone.train.train_sft import main as train_sft_main
train_sft_main()
@cli.command("webchat-demo", help="启动 Web UI 与微调后的模型进行交互测试。") # 命令名修改为 web-demo
@clear_argv
def web_demo():
"""启动 Web UI 与微调后的模型进行交互测试。"""
from weclone.eval.web_demo import main as web_demo_main
web_demo_main()
# TODO 添加评估功能 @cli.command("eval-model", help="使用从训练数据中划分出来的验证集评估。")
@clear_argv
def eval_model():
"""使用从训练数据中划分出来的验证集评估。"""
from weclone.eval.eval_model import main as evaluate_main
evaluate_main()
@cli.command("test-model", help="使用常见聊天问题测试模型。")
@clear_argv
def test_model():
"""测试"""
from weclone.eval.test_model import main as test_main
test_main()
@cli.command("server", help="启动API服务,提供模型推理接口。")
@clear_argv
def server():
"""启动API服务,提供模型推理接口。"""
from weclone.server.api_service import main as server_main
server_main()
def _check_project_root():
"""检查当前目录是否为项目根目录,并验证项目名称。"""
project_root_marker = "pyproject.toml"
current_dir = Path(os.getcwd())
pyproject_path = current_dir / project_root_marker
if not pyproject_path.is_file():
logger.error(f"未在当前目录找到 {project_root_marker} 文件。")
logger.error("请确保在WeClone项目根目录下运行此命令。")
sys.exit(1)
try:
with open(pyproject_path, "rb") as f:
pyproject_data = tomllib.load(f)
project_name = pyproject_data.get("project", {}).get("name")
if project_name != "WeClone":
logger.error("请确保在正确的 WeClone 项目根目录下运行。")
sys.exit(1)
except tomllib.TOMLDecodeError as e:
logger.error(f"错误:无法解析 {pyproject_path} 文件: {e}")
sys.exit(1)
except Exception as e:
logger.error(f"读取或处理 {pyproject_path} 时发生意外错误: {e}")
sys.exit(1)
def _check_versions():
"""比较本地 settings.jsonc 版本和 pyproject.toml 中的配置文件指南版本"""
if tomllib is None: # Skip check if toml parser failed to import
return
ROOT_DIR = Path(__file__).parent.parent
SETTINGS_PATH = ROOT_DIR / "settings.jsonc"
PYPROJECT_PATH = ROOT_DIR / "pyproject.toml"
settings_version = None
config_guide_version = None
config_changelog = None
if SETTINGS_PATH.exists():
try:
with open(SETTINGS_PATH, "r", encoding="utf-8") as f:
settings_data = commentjson.load(f)
settings_version = settings_data.get("version")
except Exception as e:
logger.error(f"错误:无法读取或解析 {SETTINGS_PATH}: {e}")
logger.error("请确保 settings.jsonc 文件存在且格式正确。")
sys.exit(1)
else:
logger.error(f"错误:未找到配置文件 {SETTINGS_PATH}")
logger.error("请确保 settings.jsonc 文件位于项目根目录。")
sys.exit(1)
if PYPROJECT_PATH.exists():
try:
with open(PYPROJECT_PATH, "rb") as f: # tomllib 需要二进制模式
pyproject_data = tomllib.load(f)
weclone_tool_data = pyproject_data.get("tool", {}).get("weclone", {})
config_guide_version = weclone_tool_data.get("config_version")
config_changelog = weclone_tool_data.get("config_changelog", "N/A")
except Exception as e:
logger.warning(f"警告:无法读取或解析 {PYPROJECT_PATH}: {e}。无法检查配置文件是否为最新。")
else:
logger.warning(f"警告:未找到文件 {PYPROJECT_PATH}。无法检查配置文件是否为最新。")
if not settings_version:
logger.error(f"错误:在 {SETTINGS_PATH} 中未找到 'version' 字段。")
logger.error("请从 settings.template.json 复制或更新您的 settings.jsonc 文件。")
sys.exit(1)
if config_guide_version:
if settings_version != config_guide_version:
logger.warning(
f"警告:您的 settings.jsonc 文件版本 ({settings_version}) 与项目建议的配置版本 ({config_guide_version}) 不一致。"
)
logger.warning("这可能导致意外行为或错误。请从 settings.template.json 复制或更新您的 settings.jsonc 文件。")
# TODO 根据版本号打印更新日志
logger.warning(f"配置文件更新日志:\n{config_changelog}")
elif PYPROJECT_PATH.exists(): # 如果文件存在但未读到版本
logger.warning(
f"警告:在 {PYPROJECT_PATH} 的 [tool.weclone] 下未找到 'config_version' 字段。"
"无法确认您的 settings.jsonc 是否为最新配置版本。"
)
if __name__ == "__main__":
cli()
+137
View File
@@ -0,0 +1,137 @@
# Copyright 2025 the LlamaFactory team.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import json
from typing import List, Optional, Union
from llamafactory.data import get_dataset, get_template_and_fix_tokenizer
from llamafactory.extras.constants import IGNORE_INDEX
from llamafactory.extras.misc import get_device_count
from llamafactory.extras.packages import is_vllm_available
from llamafactory.hparams import get_infer_args
from llamafactory.model import load_tokenizer
from pydantic import BaseModel
from vllm.sampling_params import GuidedDecodingParams
if is_vllm_available():
from vllm import LLM, SamplingParams
from vllm.lora.request import LoRARequest
def infer(
inputs: Union[str, List[str]],
model_name_or_path: str,
adapter_name_or_path: Optional[str] = None,
dataset: str = "alpaca_en_demo",
dataset_dir: str = "data",
template: str = "default",
cutoff_len: int = 2048,
max_samples: Optional[int] = None,
vllm_config: str = "{}",
save_name: str = "generated_predictions.jsonl",
temperature: float = 0.95,
top_p: float = 0.7,
top_k: int = 50,
guided_decoding_class: Optional[type[BaseModel]] = None,
bad_words: Optional[List[str]] = None,
logprobs: Optional[int] = None,
max_new_tokens: int = 1024,
repetition_penalty: float = 1.0,
skip_special_tokens: bool = True,
seed: Optional[int] = None,
pipeline_parallel_size: int = 1,
image_max_pixels: int = 768 * 768,
image_min_pixels: int = 32 * 32,
):
r"""Perform batch generation using vLLM engine, which supports tensor parallelism."""
if pipeline_parallel_size > get_device_count():
raise ValueError("Pipeline parallel size should be smaller than the number of gpus.")
model_args, data_args, _, generating_args = get_infer_args(
dict(
model_name_or_path=model_name_or_path,
adapter_name_or_path=adapter_name_or_path,
dataset=dataset,
dataset_dir=dataset_dir,
template=template,
cutoff_len=cutoff_len,
max_samples=max_samples,
preprocessing_num_workers=16,
vllm_config=vllm_config,
temperature=temperature,
top_p=top_p,
top_k=top_k,
max_new_tokens=max_new_tokens,
repetition_penalty=repetition_penalty,
)
)
tokenizer_module = load_tokenizer(model_args)
tokenizer = tokenizer_module["tokenizer"]
template_obj = get_template_and_fix_tokenizer(tokenizer, data_args)
template_obj.mm_plugin.expand_mm_tokens = False # for vllm generate
if guided_decoding_class:
json_schema = guided_decoding_class.model_json_schema()
guided_decoding_params = GuidedDecodingParams(json=json_schema)
else:
guided_decoding_params = None
sampling_params = SamplingParams(
repetition_penalty=generating_args.repetition_penalty or 1.0, # repetition_penalty must > 0
temperature=generating_args.temperature,
top_p=generating_args.top_p or 1.0, # top_p must > 0
top_k=generating_args.top_k or -1, # top_k must > 0
stop_token_ids=template_obj.get_stop_token_ids(tokenizer),
max_tokens=generating_args.max_new_tokens,
skip_special_tokens=skip_special_tokens,
seed=seed,
guided_decoding=guided_decoding_params,
bad_words=bad_words,
)
if model_args.adapter_name_or_path is not None:
lora_request = LoRARequest("default", 1, model_args.adapter_name_or_path[0])
else:
lora_request = None
engine_args = {
"model": model_args.model_name_or_path,
"trust_remote_code": True,
"dtype": model_args.infer_dtype,
"max_model_len": cutoff_len + max_new_tokens,
# "tensor_parallel_size": 1,
# "pipeline_parallel_size": pipeline_parallel_size,
# "data_parallel_size": get_device_count(), // vllm0.8.5版本支持DP
"disable_log_stats": True,
"enable_lora": model_args.adapter_name_or_path is not None,
"enable_prefix_caching": True, # 是否启用前缀缓存
}
if template_obj.mm_plugin.__class__.__name__ != "BasePlugin":
engine_args["limit_mm_per_prompt"] = {"image": 4, "video": 2, "audio": 2}
if isinstance(model_args.vllm_config, dict):
engine_args.update(model_args.vllm_config)
results = LLM(**engine_args).generate(inputs, sampling_params, lora_request=lora_request)
return results
# preds = [result.outputs[0].text for result in results]
# with open(save_name, "w", encoding="utf-8") as f:
# for text, pred, label in zip(prompts, preds, labels):
# f.write(json.dumps({"prompt": text, "predict": pred, "label": label}, ensure_ascii=False) + "\n")
# print("*" * 70)
# print(f"{len(prompts)} generated results have been saved at {save_name}.")
# print("*" * 70)
View File
+64
View File
@@ -0,0 +1,64 @@
import math
# TODO 未使用
def adjust_score_tiered(
initial_score: int, probabilities: list[float], thresholds: list[float], downgrade_levels: list[int]
) -> int:
"""
根据大模型给出评分时的概率,对原始评分进行分级置信度调整。
Args:
initial_score: 大模型给出的原始评分 (整数 1 到 5)。
probabilities: 包含 5 个评分 (1 到 5) 概率的列表。
例如 [P(1), P(2), P(3), P(4), P(5)]。
thresholds: 一个降序排列的概率阈值列表,定义置信度区间边界。
例如 [0.6, 0.3]。
downgrade_levels: 与 thresholds 对应的降级幅度列表,长度比 thresholds 多 1。
定义了每个置信度区间的降级数。例如 [0, 1, 2]。
Returns:
经过置信度调整后的最终评分 (整数 1 到 5)。
Raises:
ValueError: 如果输入参数不合法(例如概率列表长度不对,阈值未降序等)。
"""
# --- 输入校验 ---
if not (1 <= initial_score <= 5):
raise ValueError("initial_score 必须在 1 到 5 之间。")
if len(probabilities) != 5:
raise ValueError("probabilities 列表必须包含 5 个元素。")
# 检查概率和是否接近 1 (允许小的浮点误差)
if not math.isclose(sum(probabilities), 1.0, abs_tol=1e-6):
print(f"警告: 概率之和 {sum(probabilities)} 不接近 1.0。请检查概率来源。") # 打印警告而非直接报错
# raise ValueError("probabilities 中元素的和必须接近 1.0。")
if len(downgrade_levels) != len(thresholds) + 1:
raise ValueError("downgrade_levels 的长度必须比 thresholds 的长度多 1。")
if any(thresholds[i] < thresholds[i + 1] for i in range(len(thresholds) - 1)):
raise ValueError("thresholds 列表必须是降序排列的。")
if any(level < 0 for level in downgrade_levels):
raise ValueError("downgrade_levels 中的降级幅度不能为负数。")
# --- 算法核心 ---
# 1. 获取选中分数的概率
# 列表索引从0开始,所以评分 s 对应的索引是 s-1
try:
p_chosen = probabilities[initial_score - 1]
except IndexError:
# 这个错误理论上不应发生,因为 initial_score 已校验在 1-5 之间
raise ValueError(f"无法从 probabilities 列表获取索引 {initial_score - 1} 的值。")
# 2. 确定降级幅度
downgrade = downgrade_levels[-1] # 默认为最低置信度区间的降级幅度
# 遍历阈值列表 (从高到低)
for i in range(len(thresholds)):
if p_chosen >= thresholds[i]:
downgrade = downgrade_levels[i] # 找到对应的置信度区间
break # 停止遍历
# 3. 计算调整后的评分
preliminary_score = initial_score - downgrade
adjusted_score = max(1, preliminary_score) # 确保分数不低于 1
# 4. 返回结果
return adjusted_score
+96
View File
@@ -0,0 +1,96 @@
import json
import pandas as pd
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any, Dict, List, Union
from langchain_core.prompts import PromptTemplate
from weclone.data.models import QaPair, CutMessage, QaPairScore
from weclone.prompts.clean_data import CLEAN_PROMPT
from weclone.core.inference.vllm_infer import infer as infer
from weclone.utils.log import logger
@dataclass
class CleaningStrategy(ABC):
"""数据清洗策略的抽象基类"""
make_dataset_config: Dict
@abstractmethod
def clean(self, data: Any) -> Any:
"""
执行数据清洗操作。
Args:
data: 需要清洗的数据。
Returns:
清洗后的数据。
"""
pass
@dataclass
class LLMCleaningStrategy(CleaningStrategy):
"""使用大模型进行数据清洗的策略"""
def judge(self, data: List[QaPair]) -> None:
"""
调用llm打分,并将分数直接赋值给传入的QaPair。
"""
logger.info("开始使用llm对数据打分")
inputs = []
prompt_template = PromptTemplate.from_template(CLEAN_PROMPT)
for qa in data:
inputs.append(prompt_template.invoke({"id": qa.id, "Q": qa.instruction, "A": qa.output}).text) # type: ignore
outputs = infer(
inputs,
self.make_dataset_config["model_name_or_path"],
template=self.make_dataset_config["template"],
temperature=0,
guided_decoding_class=QaPairScore,
repetition_penalty=1.2,
bad_words=[r"\n"],
)
parsed_scores: List[QaPairScore] = []
for result in outputs:
try:
score_data = json.loads(result.outputs[0].text)
qa_score = QaPairScore(**score_data)
parsed_scores.append(qa_score)
except json.JSONDecodeError:
logger.error(f"Error decoding JSON: {result.outputs[0].text}")
score_map = {score.id: score.score for score in parsed_scores}
for qa in data:
if qa.id in score_map:
qa.score = score_map[qa.id]
else:
logger.warning(f"Warning: Score not found for QaPair with id {qa.id}. Assigning default score.")
scores = [qa.score for qa in data if qa.score is not None]
score_series = pd.Series(scores)
score_counts = score_series.value_counts().sort_index()
score_percentages = score_series.value_counts(normalize=True).sort_index() * 100
pd.set_option('display.unicode.east_asian_width', True) # 尝试修正对齐问题
distribution_df = pd.DataFrame( # 合并数量和百分比到一个 DataFrame 中以便打印
{
"数量": score_counts,
"占比(%)": score_percentages.round(2),
}
)
distribution_df.index.name = "分数" # 给第一列加上列名:分数
printable_df_str = distribution_df.reset_index().to_string(index=False)
logger.success(f"llm打分分数分布情况:\n{printable_df_str}")
def clean(self, data: List[QaPair]) -> List[QaPair]:
"""
根据打分结果,删除分数低于阈值的数据。
"""
return [
qa
for qa in data
if qa.score is not None
and qa.score >= self.make_dataset_config.get("clean_dataset", {}).get("llm", {}).get("accept_score", 1)
]
+17
View File
@@ -1,5 +1,6 @@
from dataclasses import dataclass
from pandas import Timestamp
from pydantic import BaseModel
@dataclass
@@ -22,6 +23,22 @@ class CutMessage:
CreateTime: Timestamp
@dataclass
class QaPair:
id: int
system: str
instruction: str
output: str
history: list[list[str]]
time: Timestamp
score: int
class QaPairScore(BaseModel):
id: int
score: int
skip_type_list = [
"添加好友",
"推荐公众号",
+172 -54
View File
@@ -1,15 +1,18 @@
import os
from typing import Dict, List
import sys
import subprocess
from typing import Dict, List, Union
import re
import pandas as pd
import json
from pandas import Timestamp
from weclone.data.clean.strategies import LLMCleaningStrategy
from weclone.utils.config import load_config
from weclone.utils.log import logger
from weclone.data.models import ChatMessage, CutMessage, skip_type_list
from weclone.data.models import ChatMessage, CutMessage, skip_type_list, QaPair
from weclone.data.strategies import TimeWindowStrategy, LLMStrategy
from weclone.utils.length_cdf import length_cdf
class DataProcessor:
@@ -33,6 +36,24 @@ class DataProcessor:
"粘贴的文本", # 无法解析的分享链接
]
# blocked_words
config_blocked_words = self.config.get("blocked_words", [])
file_blocked_words = []
try:
with open("./dataset/blocked_words.json", encoding="utf-8") as f:
file_blocked_words = json.load(f).get("blocked_words", [])
except (FileNotFoundError, json.JSONDecodeError):
pass
self.blocked_words = list(set(config_blocked_words + file_blocked_words))
logger.info(f"聊天记录禁用词: {self.blocked_words}")
if self.config.get("clean_dataset", {}).get("enable_clean", False) and self.config.get(
"prompt_with_history", False
):
logger.warning("开启 prompt_with_history 不支持 clean_dataset 功能")
exit()
if self.config["single_combine_strategy"] == "time_window":
self.single_combine_strategy = TimeWindowStrategy(
time_window=self.config["single_combine_time_window"] * 60,
@@ -51,6 +72,9 @@ class DataProcessor:
elif self.config["qa_match_strategy"] == "llm":
self.qa_match_strategy = LLMStrategy(is_single_chat=False)
if self.config.get("clean_dataset", {}).get("enable_clean", False):
if self.config.get("clean_dataset", {}).get("clean_strategy", "llm") == "llm":
self.clean_strategy = LLMCleaningStrategy(make_dataset_config=self.config)
self.c = self.config
def main(self):
@@ -67,14 +91,56 @@ class DataProcessor:
qa_res = self.match_qa(message_list)
if self.c["prompt_with_history"]:
qa_res = self.add_history_to_qa(qa_res)
else:
qa_res = [item for item in qa_res if isinstance(item, QaPair)]
if self.c.get("clean_dataset", {}).get("enable_clean", False):
self.clean_strategy.judge(qa_res)
qa_res = self.clean_strategy.clean(qa_res)
self.save_result(qa_res)
length_cdf(
model_name_or_path=self.c["model_name_or_path"],
dataset=self.c["dataset"],
dataset_dir=self.c["dataset_dir"],
template=self.c["template"],
interval=self.c["cutoff_len"],
)
self._execute_length_cdf_script()
logger.success(f"聊天记录处理成功,共{len(qa_res)}条,保存到 ./dataset/res_csv/sft/sft-my.json")
def _execute_length_cdf_script(self):
"""执行 length_cdf.py 脚本来计算cutoff_len。"""
try:
python_executable = sys.executable
# 脚本路径是相对于项目根目录的
script_path = os.path.join("weclone", "utils", "length_cdf.py")
command_parts = [
python_executable,
script_path,
f'--model_name_or_path="{self.c["model_name_or_path"]}"',
f'--dataset="{self.c["dataset"]}"',
f'--dataset_dir="{self.c["dataset_dir"]}"',
f'--template="{self.c["template"]}"',
f"--interval={self.c['cutoff_len']}",
]
child_env = os.environ.copy()
child_env["CUDA_VISIBLE_DEVICES"] = "0"
child_env["LLAMAFACTORY_VERBOSITY"] = "ERROR"
process = subprocess.Popen(
command_parts,
env=child_env,
stdout=None, # 使用 None 表示使用父进程的标准输出(即终端)
stderr=None, # 使用 None 表示使用父进程的标准错误(即终端)
text=True,
bufsize=1, # 行缓冲
)
return_code = process.wait()
if return_code != 0:
logger.error(f"命令 '{' '.join(command_parts)}' 执行失败,返回码 {return_code}")
except FileNotFoundError:
# command_parts[0] 是 python_executable, command_parts[1] 是 script_path
logger.error(f"命令执行失败: 找不到可执行文件 '{command_parts[0]}' 或脚本 '{command_parts[1]}'")
except KeyError as e:
logger.error(f"执行 length_cdf.py 脚本失败:配置项缺失 {str(e)}")
except Exception as e:
logger.error(f"执行 length_cdf.py 脚本时发生未知错误: {str(e)}")
def get_csv_files(self):
"""遍历文件夹获取所有CSV文件路径"""
@@ -89,7 +155,7 @@ class DataProcessor:
csv_files.append(csvfile_path)
return csv_files
def match_qa(self, messages: List[ChatMessage]) -> List[Dict]:
def match_qa(self, messages: List[ChatMessage]) -> List[Union[QaPair, CutMessage]]:
"""
匹配问答对
@@ -97,19 +163,19 @@ class DataProcessor:
messages: 消息列表
Returns:
List[Dict]: 包含指令和输出的问答对列表
List[Union[QaPair, CutMessage]]: 包含指令和输出的问答对列表
"""
# 状态定义
WAITING_INSTRUCTION = "waiting_instruction" # 等待指令
WAITING_RESPONSE = "waiting_response" # 等待回复
current_state = WAITING_INSTRUCTION
qa_res = []
qa_res: List[Union[QaPair, CutMessage]] = []
last_message = None
current_instruction = None
qa_id_counter = 0
for msg in messages:
# 检查是否为CutMessage
if isinstance(msg, CutMessage):
current_state = WAITING_INSTRUCTION
current_instruction = None
@@ -131,9 +197,20 @@ class DataProcessor:
# 状态保持不变
else: # 自己的回复 使用策略判断是否属于同一对话
if last_message and self.qa_match_strategy.is_same_conversation([last_message], msg):
qa_res.append(
{"instruction": current_instruction, "output": msg.msg, "system": self.system_prompt}
assert current_instruction is not None, (
"current_instruction should not be None when creating a QA pair"
)
qa_pair = QaPair(
id=qa_id_counter,
system=self.system_prompt,
instruction=current_instruction,
output=msg.msg,
history=[], # No history in this context yet
time=msg.CreateTime, # Use the response message time
score=0, # Default score
)
qa_res.append(qa_pair)
qa_id_counter += 1 # 增加计数器
else:
if self.c["prompt_with_history"]:
qa_res.append(
@@ -150,33 +227,60 @@ class DataProcessor:
return qa_res
def add_history_to_qa(self, qa_res: List[Dict]) -> List[Dict]:
qa_res_with_history = []
last_res = {"instruction": "", "output": "", "history": [], "system": self.system_prompt}
# TODO: need review
def add_history_to_qa(self, qa_res: List[Union[QaPair, CutMessage]]) -> List[QaPair]:
"""
Adds conversation history to QaPair objects.
for _, qa in enumerate(qa_res):
if isinstance(qa, CutMessage):
if len(last_res["history"]) == 0:
continue
else:
if len(last_res["history"]) == 1:
last_res = {
"system": self.system_prompt,
"instruction": last_res["history"][0][0],
"output": last_res["history"][0][1],
"history": [],
}
else:
last_res = {
"system": self.system_prompt,
"instruction": last_res["history"][-1][0],
"output": last_res["history"][-1][1],
"history": last_res["history"][:-1],
}
qa_res_with_history.append(last_res)
last_res = {"instruction": "", "output": "", "history": [], "system": self.system_prompt}
else:
last_res["history"].append([qa["instruction"], qa["output"]])
Args:
qa_res: A list containing QaPair and CutMessage objects.
Returns:
A list of QaPair objects with history populated.
"""
qa_res_with_history: List[QaPair] = []
current_history: List[List[str]] = []
last_timestamp: Timestamp = None # type: ignore
for item in qa_res:
if isinstance(item, CutMessage):
if current_history:
instruction = current_history[-1][0]
output = current_history[-1][1]
history = current_history[:-1]
qa_pair_with_history = QaPair(
id=-1,
system=self.system_prompt,
instruction=instruction,
output=output,
history=history,
time=last_timestamp,
score=0,
)
qa_res_with_history.append(qa_pair_with_history)
current_history = []
last_timestamp = None # type: ignore
elif isinstance(item, QaPair):
current_history.append([item.instruction, item.output])
last_timestamp = item.time
if current_history:
instruction = current_history[-1][0]
output = current_history[-1][1]
history = current_history[:-1]
# Ensure last_timestamp is not None before assignment
final_timestamp_end = last_timestamp
assert final_timestamp_end is not None, "Timestamp cannot be None for the final QaPair"
qa_pair_with_history = QaPair(
id=-1,
system=self.system_prompt,
instruction=instruction,
output=output,
history=history,
time=final_timestamp_end,
score=0,
)
qa_res_with_history.append(qa_pair_with_history)
return qa_res_with_history
@@ -311,8 +415,6 @@ class DataProcessor:
"""
df = pd.read_csv(file_path, encoding="utf-8", dtype={"msg": str})
blocked_words = json.load(open("./dataset/blocked_words.json", encoding="utf-8"))["blocked_words"]
df = df[~df["type_name"].isin(values=skip_type_list)]
# 如果type_name为文本 并且msg 包含 手机号、身份证号、邮箱、网址则删除这行
@@ -329,7 +431,7 @@ class DataProcessor:
):
df = df.drop(index=i)
continue
for blocked_word in blocked_words:
for blocked_word in self.blocked_words:
if blocked_word in msg_str:
df = df.drop(index=i)
break
@@ -346,15 +448,31 @@ class DataProcessor:
def process_text(self, chat_message: ChatMessage):
pass
def save_result(self, qa_res: List[Dict]):
# 保存结果
with open(
"./dataset/res_csv/sft/sft-my.json",
"w",
encoding="utf-8",
) as f:
json.dump(qa_res, f, ensure_ascii=False)
logger.success(f"聊天记录处理成功,共{len(qa_res)}条,保存到 {f.name}")
def save_result(self, qa_res: List[QaPair]):
"""
Saves the list of QaPair objects to a JSON file after converting them to dictionaries.
Args:
qa_res: A list of QaPair objects.
"""
processed_qa_res = []
for idx, item in enumerate(qa_res):
item_dict = {
"id": idx,
"system": item.system,
"instruction": item.instruction,
"output": item.output,
"history": item.history,
"time": item.time.isoformat() if item.time else None,
"score": item.score,
}
processed_qa_res.append(item_dict)
output_path = "./dataset/res_csv/sft/sft-my.json"
os.makedirs(os.path.dirname(output_path), exist_ok=True)
with open(output_path, "w", encoding="utf-8") as f:
json.dump(processed_qa_res, f, ensure_ascii=False, indent=4)
logger.success(f"聊天记录处理成功,共{len(qa_res)}条,保存到 {output_path}")
if __name__ == "__main__":
+8 -9
View File
@@ -2,16 +2,15 @@ from llamafactory.chat import ChatModel
from llamafactory.extras.misc import torch_gc
try:
import platform
if platform.system() != "Windows":
import readline # noqa: F401
except ImportError:
print("Install `readline` for a better experience.")
def main():
try:
import platform
if platform.system() != "Windows":
import readline # noqa: F401
except ImportError:
print("Install `readline` for a better experience.")
chat_model = ChatModel()
messages = []
print("Welcome to the CLI application, use `clear` to remove the history, use `exit` to exit the application.")
+17 -5
View File
@@ -1,8 +1,10 @@
import json
import openai
from openai import OpenAI # 导入 OpenAI 类
from tqdm import tqdm
from typing import List, Dict
from typing import List, Dict, cast # 导入 cast
from openai.types.chat import ChatCompletionMessageParam # 导入消息参数类型
from weclone.utils.config import load_config
@@ -16,18 +18,28 @@ config = {
config = type("Config", (object,), config)()
openai.api_key = """sk-test"""
openai.api_base = "http://127.0.0.1:8005/v1"
# 初始化 OpenAI 客户端
client = OpenAI(
api_key="""sk-test""",
base_url="http://127.0.0.1:8005/v1"
)
def handler_text(content: str, history: List[Dict[str, str]], config):
def handler_text(content: str, history: list, config):
messages = [{"role": "system", "content": f"{config.default_prompt}"}]
for item in history:
messages.append(item)
messages.append({"role": "user", "content": content})
history.append({"role": "user", "content": content})
try:
response = openai.ChatCompletion.create(model=config.model, messages=messages, max_tokens=50)
# 使用新的 API 调用方式
# 将 messages 转换为正确的类型
typed_messages = cast(List[ChatCompletionMessageParam], messages)
response = client.chat.completions.create(
model=config.model,
messages=typed_messages, # 传递转换后的列表
max_tokens=50
)
except openai.APIError as e:
history.pop()
return "AI接口出错,请重试\n" + str(e)
+1 -2
View File
@@ -1,10 +1,9 @@
from llamafactory.webui.interface import create_web_demo
from weclone.utils.config import load_config
config = load_config("web_demo")
def main():
config = load_config("web_demo")
demo = create_web_demo()
demo.queue()
demo.launch(server_name="0.0.0.0", share=True, inbrowser=True)
View File
+38
View File
@@ -0,0 +1,38 @@
CLEAN_PROMPT = """
# 角色
你是一个数据质量评估员。
# 任务
你的任务是评估下面提供的【回答 A】相对于【问题/上下文 Q】的**逻辑性**和**相关性**。目标是识别并帮助过滤掉那些回答与问题**明显不匹配**、**逻辑严重混乱**的数据对。请根据以下核心评估点给出一个1到5的整数分数,并将该分数与原始 `id` 一起输出。
**重要考量:**
1. **简短回答的有效性:** 请注意,诸如“好的”、“是的”、“收到”、“嗯”、“知道了”等简短的肯定、确认或应答,在合适的语境下是完全**有逻辑且相关的**。**不要仅仅因为回答简短就将其评为低分。** 只有当这类简短回答与【问题/上下文 Q】**明显不符**时,才应考虑低分。
2. **处理错别字和自我纠正:** 聊天记录中可能包含常见的打字错误(错别字)或用户先打错字随后又自行纠正的情况(例如,发送“我想去1楼”紧接着又发送“*2楼”进行更正)。在评估时,请**聚焦于用户想要表达的最终意图和信息的核心内容**,而**不应仅仅因为存在错别字或纠正过程就判定为低质量**。。
# 核心评估点 (请在心中衡量)
1. **相关性 (Relevance):** 【回答 A】是否直接回应或恰当地衔接了【问题/上下文 Q】?它是在回答问题,还是完全跑题了?只有当【回答 A】与【问题/上下文 Q】**明显矛盾**、**完全不着边际**(即使考虑上下文也无法合理化),或简短回答**明显不适用于**该【问题/上下文 Q】时,才给予低分。
2. **逻辑性 (Coherence):** 【回答 A】本身是否符合基本的逻辑?结合【问题/上下文 Q】来看,这个问答对是否构成了一个符合逻辑的交流片段?是否存在明显的矛盾、混乱的内容?只有当【回答 A】**自身逻辑混乱**、**与Q存在无法解释的矛盾**时,才给予低分。
# 评分标准 (1-5分)
* **1分 (极差):** 完全不相关;逻辑严重混乱/矛盾。
* **2分 (差):** 相关性很低;存在明显的逻辑问题或不连贯。
* **3分 (中等):** 相关性一般(可能部分跑题或回应不充分);逻辑上勉强说得通但不够流畅或有瑕疵。
* **4分 (良好):** 相关性好,回答了问题或恰当衔接;逻辑清晰。
* **5分 (优秀):** 相关性强,回应精准;逻辑严谨流畅。
# 输入数据
```json
{{
"id": "{id}",
"Q": "{Q}",
"A": "{A}"
}}
# 输出要求
请严格按照以下 JSON 格式输出,包含原始的 id 和你给出的1到5的整数评分 score,不要包含任何其他文字、解释或标签。
{{
"id": "<这里填入输入数据中的id值>",
"score": <这里填入1到5的整数评分>
}}
"""
+1 -1
View File
@@ -4,10 +4,10 @@ from llamafactory.chat import ChatModel
from llamafactory.api.app import create_app
from weclone.utils.config import load_config
config = load_config("api_service")
def main():
config = load_config("api_service")
chat_model = ChatModel(config)
app = create_app(chat_model)
print("Visit http://localhost:{}/docs for API document.".format(os.environ.get("API_PORT", 8005)))
+4
View File
@@ -1,5 +1,6 @@
import os
import sys
import json
from llamafactory.train.tuner import run_exp
from llamafactory.extras.misc import get_current_device
from weclone.utils.config import load_config
@@ -18,6 +19,9 @@ def main():
logger.error(f"错误:文件 '{sft_json_path}' 不存在,请确保数据处理步骤已正确生成该文件。")
sys.exit(1)
formatted_config = json.dumps(config, indent=4, ensure_ascii=False)
logger.info(f"微调配置:\n{formatted_config}")
run_exp(config)
+1 -1
View File
@@ -7,7 +7,7 @@ from .tools import dict_to_argv
def load_config(arg_type: str):
config_path = os.environ.get("WECLONE_CONFIG_PATH", "./settings.json")
config_path = os.environ.get("WECLONE_CONFIG_PATH", "./settings.jsonc")
logger.info(f"Loading configuration from: {config_path}") # Add logging to see which file is loaded
try:
with open(config_path, "r", encoding="utf-8") as f:
+43 -22
View File
@@ -1,5 +1,20 @@
# Copyright 2025 the LlamaFactory team.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from collections import defaultdict
import fire
from tqdm import tqdm
from weclone.utils.log import logger
@@ -9,37 +24,39 @@ from llamafactory.model import load_tokenizer
def length_cdf(
model_name_or_path: str,
dataset: str = "alpaca_en_demo",
dataset_dir: str = "data",
template: str = "default",
interval: int = 1000,
model_name_or_path: str = "./Qwen2.5-7B-Instruct",
dataset: str = "wechat-sft",
dataset_dir: str = "./dataset/res_csv/sft",
template: str = "qwen",
interval: int = 256,
):
r"""Calculate the distribution of the input lengths in the dataset.
Usage: export CUDA_VISIBLE_DEVICES=0
python length_cdf.py --model_name_or_path path_to_model --dataset alpaca_en_demo --template default
"""
logger.info("开始计算cutoff_len......")
model_args, data_args, training_args, _, _ = get_train_args(
dict(
stage="sft",
model_name_or_path=model_name_or_path,
dataset=dataset,
dataset_dir=dataset_dir,
template=template,
cutoff_len=1_000_000,
preprocessing_num_workers=16,
output_dir="dummy_dir",
overwrite_cache=True,
do_train=True,
)
{
"stage": "sft",
"model_name_or_path": model_name_or_path,
"dataset": dataset,
"dataset_dir": dataset_dir,
"template": template,
"cutoff_len": 1_000_000,
"preprocessing_num_workers": 16,
"output_dir": "dummy_dir",
"overwrite_cache": True,
"do_train": True,
}
)
tokenizer_module = load_tokenizer(model_args)
template = get_template_and_fix_tokenizer(tokenizer_module["tokenizer"], data_args) # type: ignore
trainset = get_dataset(template, model_args, data_args, training_args, "sft", **tokenizer_module)["train_dataset"] # type: ignore
total_num = len(trainset) # type: ignore
template = get_template_and_fix_tokenizer(tokenizer_module["tokenizer"], data_args) # type: ignore
trainset = get_dataset(template, model_args, data_args, training_args, "sft", **tokenizer_module)["train_dataset"] # type: ignore
total_num = len(trainset) # type: ignore
length_dict = defaultdict(int)
for sample in tqdm(trainset["input_ids"], desc="Collecting lengths"): # type: ignore
for sample in tqdm(trainset["input_ids"], desc="Collecting lengths"): # type: ignore
length_dict[len(sample) // interval * interval] += 1
length_tuples = list(length_dict.items())
@@ -49,4 +66,8 @@ def length_cdf(
for length, count in length_tuples:
count_accu += count
prob_accu += count / total_num * 100
logger.info(f"{count_accu:d} ({prob_accu:.2f}%) samples have length < {length + interval}.")
logger.success(f"{count_accu:d} ({prob_accu:.2f}%) samples have length < {length + interval}.")
if __name__ == "__main__":
fire.Fire(length_cdf)