mirror of
https://github.com/ooyinet/WeClone.git
synced 2026-09-01 15:44:35 +08:00
@@ -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
@@ -156,5 +156,9 @@ data/test
|
||||
Spark-TTS-0.5B/
|
||||
uv.lock
|
||||
output*
|
||||
*.out
|
||||
|
||||
Qwen*/
|
||||
Qwen*/
|
||||
settings.jsonc
|
||||
settings.json
|
||||
dataset/blocked_words.json
|
||||
|
||||
@@ -5,26 +5,29 @@
|
||||
|
||||
[](https://github.com/xming521/WeClone/stargazers)
|
||||
[](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>
|
||||
[](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="Featured|HelloGitHub" 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 中新增服务提供商,类型选择OpenAI,API 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) 或者更方便的 [](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>
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"blocked_words": [
|
||||
"例如 姓名",
|
||||
"例如 地址",
|
||||
"//....."
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
{
|
||||
"questions": [
|
||||
[
|
||||
"你多大了?"
|
||||
],
|
||||
[
|
||||
"你有什么爱好吗?"
|
||||
],
|
||||
[
|
||||
"你的理想是什么?",
|
||||
"你觉得你离你的理想还有多远?"
|
||||
],
|
||||
[
|
||||
"你最近在忙什么?",
|
||||
"工作/学习顺利吗?",
|
||||
"有什么有趣的事情发生吗?"
|
||||
],
|
||||
[
|
||||
"你喜欢看什么类型的电影?",
|
||||
"最近看过什么好看的电影吗?",
|
||||
"你最喜欢的电影是什么?"
|
||||
],
|
||||
[
|
||||
"你平时喜欢听什么音乐?",
|
||||
"有推荐的歌手或乐队吗?",
|
||||
"最近有喜欢的歌曲吗?"
|
||||
],
|
||||
[
|
||||
"你喜欢旅游吗?",
|
||||
"去过哪些地方?",
|
||||
"最喜欢的旅游地是哪里?"
|
||||
],
|
||||
[
|
||||
"你喜欢读书吗?",
|
||||
"最近在读什么书?",
|
||||
"最喜欢的书是哪本?"
|
||||
],
|
||||
[
|
||||
"你平时喜欢运动吗?",
|
||||
"喜欢做哪些运动?",
|
||||
"有固定去锻炼吗?"
|
||||
],
|
||||
[
|
||||
"周末一般都做些什么?",
|
||||
"有没有什么特别的计划?",
|
||||
"周末喜欢宅在家还是出去玩?"
|
||||
],
|
||||
[
|
||||
"你喜欢宠物吗?",
|
||||
"有养宠物吗?",
|
||||
"最喜欢什么动物?"
|
||||
],
|
||||
[
|
||||
"你喜欢吃什么类型的食物?",
|
||||
"有推荐的餐厅吗?",
|
||||
"最喜欢的菜是什么?"
|
||||
],
|
||||
[
|
||||
"你喜欢什么样的天气?",
|
||||
"最喜欢的季节是哪一个?",
|
||||
"你觉得今天的天气怎么样?"
|
||||
],
|
||||
[
|
||||
"你有看电视剧的习惯吗?",
|
||||
"最近在追哪部剧?",
|
||||
"最喜欢的电视剧是哪部?"
|
||||
],
|
||||
[
|
||||
"你喜欢玩游戏吗?",
|
||||
"最近在玩什么游戏?",
|
||||
"有推荐的好玩的游戏吗?"
|
||||
],
|
||||
[
|
||||
"你会做饭吗?",
|
||||
"平时喜欢做哪些菜?",
|
||||
"有没有特别拿手的菜?"
|
||||
],
|
||||
[
|
||||
"你喜欢购物吗?",
|
||||
"最近买了什么新东西?",
|
||||
"有推荐的购物网站或店铺吗?"
|
||||
],
|
||||
[
|
||||
"你平时怎么放松自己?",
|
||||
"有特别的解压方式吗?",
|
||||
"最喜欢的放松活动是什么?"
|
||||
],
|
||||
[
|
||||
"你喜欢和朋友出去玩吗?",
|
||||
"平时会和朋友去哪玩?",
|
||||
"最近有没有和朋友聚会的计划?"
|
||||
],
|
||||
[
|
||||
"你喜欢喝咖啡还是茶?",
|
||||
"有没有特别喜欢的咖啡馆或茶馆?",
|
||||
"最喜欢的饮品是什么?"
|
||||
],
|
||||
[
|
||||
"你有兄弟姐妹吗?",
|
||||
"和他们关系怎么样?",
|
||||
"经常联系吗?"
|
||||
],
|
||||
[
|
||||
"你喜欢读什么类型的杂志?",
|
||||
"最近有看什么有趣的文章吗?",
|
||||
"有订阅的杂志吗?"
|
||||
],
|
||||
[
|
||||
"你喜欢看体育比赛吗?",
|
||||
"最喜欢的运动项目是什么?",
|
||||
"有没有特别支持的球队或运动员?"
|
||||
],
|
||||
[
|
||||
"你会说其他语言吗?",
|
||||
"最想学的语言是什么?",
|
||||
"学习语言有什么技巧吗?"
|
||||
],
|
||||
[
|
||||
"你对科技产品感兴趣吗?",
|
||||
"最近有没有关注什么新科技?",
|
||||
"最喜欢的电子产品是什么?"
|
||||
],
|
||||
[
|
||||
"你喜欢喝什么样的饮料?",
|
||||
"有没有自己调饮料的习惯?",
|
||||
"最喜欢的饮品品牌是什么?"
|
||||
],
|
||||
[
|
||||
"你平时用社交媒体吗?",
|
||||
"常用哪些平台?",
|
||||
"在社交媒体上做什么?"
|
||||
],
|
||||
[
|
||||
"你对艺术感兴趣吗?",
|
||||
"最喜欢的艺术家是谁?",
|
||||
"有去过哪些艺术展览?"
|
||||
],
|
||||
[
|
||||
"你喜欢DIY吗?",
|
||||
"平时做些什么手工?",
|
||||
"有没有完成的作品可以分享?"
|
||||
],
|
||||
[
|
||||
"你喜欢种植植物吗?",
|
||||
"有养什么植物?",
|
||||
"最喜欢的植物是什么?"
|
||||
],
|
||||
[
|
||||
"你喜欢拍照吗?",
|
||||
"喜欢拍什么样的照片?",
|
||||
"有没有用什么特别的摄影设备?"
|
||||
],
|
||||
[
|
||||
"你喜欢听播客吗?",
|
||||
"常听哪些主题的播客?",
|
||||
"有没有推荐的播客?"
|
||||
],
|
||||
[
|
||||
"你对历史感兴趣吗?",
|
||||
"最喜欢哪个历史时期?",
|
||||
"有没有特别喜欢的历史人物?"
|
||||
],
|
||||
[
|
||||
"你喜欢画画吗?",
|
||||
"平时画什么类型的画?",
|
||||
"有参加过画展吗?"
|
||||
],
|
||||
[
|
||||
"你喜欢写作吗?",
|
||||
"平时写什么类型的文章?",
|
||||
"有没有发表过作品?"
|
||||
],
|
||||
[
|
||||
"你喜欢钓鱼吗?",
|
||||
"平时去哪里钓鱼?",
|
||||
"有没有钓到过什么大鱼?"
|
||||
],
|
||||
[
|
||||
"你喜欢露营吗?",
|
||||
"平时会去哪里露营?",
|
||||
"有没有什么难忘的露营经历?"
|
||||
],
|
||||
[
|
||||
"你喜欢摄影吗?",
|
||||
"最喜欢拍什么题材?",
|
||||
"有没有特别喜欢的摄影师?"
|
||||
],
|
||||
[
|
||||
"你喜欢喝酒吗?",
|
||||
"喜欢什么类型的酒?",
|
||||
"有没有推荐的酒吧或品牌?"
|
||||
],
|
||||
[
|
||||
"你喜欢滑雪吗?",
|
||||
"平时去哪里滑雪?",
|
||||
"有没有什么滑雪技巧分享?"
|
||||
],
|
||||
[
|
||||
"你喜欢海边还是山里?",
|
||||
"最喜欢去哪个地方度假?",
|
||||
"有没有什么特别推荐的景点?"
|
||||
],
|
||||
[
|
||||
"你喜欢参加音乐节吗?",
|
||||
"参加过哪些音乐节?",
|
||||
"最喜欢的音乐节是哪一个?"
|
||||
],
|
||||
[
|
||||
"你喜欢跑步吗?",
|
||||
"平时跑多长距离?",
|
||||
"有没有参加过马拉松?"
|
||||
],
|
||||
[
|
||||
"你喜欢参加聚会吗?",
|
||||
"平时和朋友聚会做什么?",
|
||||
"有没有什么有趣的聚会游戏?"
|
||||
],
|
||||
[
|
||||
"你喜欢收集东西吗?",
|
||||
"收集什么类型的物品?",
|
||||
"有没有什么特别的收藏?"
|
||||
]
|
||||
]
|
||||
}
|
||||
+76
-150
@@ -8,224 +8,150 @@
|
||||
"可以请我吃吗"
|
||||
],
|
||||
[
|
||||
"你多大了?"
|
||||
"干嘛呢?",
|
||||
"等会准备干什么去"
|
||||
],
|
||||
[
|
||||
"你有什么爱好吗?"
|
||||
"在忙什么呢?",
|
||||
"今天有什么特别的安排吗?",
|
||||
"感觉怎么样?"
|
||||
],
|
||||
[
|
||||
"你的理想是什么?",
|
||||
"你觉得你离你的理想还有多远?"
|
||||
"最近有什么新鲜事发生吗?",
|
||||
"有没有什么有趣的故事可以分享?"
|
||||
],
|
||||
[
|
||||
"你最近在忙什么?",
|
||||
"工作/学习顺利吗?",
|
||||
"有什么有趣的事情发生吗?"
|
||||
"周末过得怎么样?",
|
||||
"做了什么好玩的?"
|
||||
],
|
||||
[
|
||||
"你喜欢看什么类型的电影?",
|
||||
"最近看过什么好看的电影吗?",
|
||||
"你最喜欢的电影是什么?"
|
||||
"最近看了什么好看的电影或电视剧吗?",
|
||||
"有什么推荐的吗?",
|
||||
"大概讲了什么内容呀?"
|
||||
],
|
||||
[
|
||||
"你平时喜欢听什么音乐?",
|
||||
"有推荐的歌手或乐队吗?",
|
||||
"最近有喜欢的歌曲吗?"
|
||||
"今天天气怎么样?",
|
||||
"你那里呢?"
|
||||
],
|
||||
[
|
||||
"你喜欢旅游吗?",
|
||||
"去过哪些地方?",
|
||||
"最喜欢的旅游地是哪里?"
|
||||
"最近工作/学习顺利吗?",
|
||||
"有没有遇到什么挑战?"
|
||||
],
|
||||
[
|
||||
"你喜欢读书吗?",
|
||||
"最近在读什么书?",
|
||||
"最喜欢的书是哪本?"
|
||||
"嗨,这会儿在忙啥呢?",
|
||||
"今天有什么特别的安排不?",
|
||||
"一切都还顺利吧?"
|
||||
],
|
||||
[
|
||||
"你平时喜欢运动吗?",
|
||||
"喜欢做哪些运动?",
|
||||
"有固定去锻炼吗?"
|
||||
"你那边现在天气咋样啊?",
|
||||
"是大晴天还是有点阴沉沉的?",
|
||||
"冷不冷,或者热不热呀?"
|
||||
],
|
||||
[
|
||||
"周末一般都做些什么?",
|
||||
"有没有什么特别的计划?",
|
||||
"周末喜欢宅在家还是出去玩?"
|
||||
"到饭点儿了没呀?",
|
||||
"今天打算犒劳一下自己,吃点啥好吃的?",
|
||||
"有没有啥特别想吃的,或者想去哪家馆子尝尝鲜?"
|
||||
],
|
||||
[
|
||||
"你喜欢宠物吗?",
|
||||
"有养宠物吗?",
|
||||
"最喜欢什么动物?"
|
||||
"最近网上有啥好玩儿的新闻或者梗吗?",
|
||||
"刷到啥有意思的视频或者段子没?分享一下呗!"
|
||||
],
|
||||
[
|
||||
"你喜欢吃什么类型的食物?",
|
||||
"有推荐的餐厅吗?",
|
||||
"最喜欢的菜是什么?"
|
||||
"待会儿有啥打算呀?",
|
||||
"今天剩下的时间准备怎么过呢?"
|
||||
],
|
||||
[
|
||||
"你喜欢什么样的天气?",
|
||||
"最喜欢的季节是哪一个?",
|
||||
"你觉得今天的天气怎么样?"
|
||||
"今天有没有碰到啥让你眼前一亮的小事儿?",
|
||||
"随便聊聊呗,有啥轻松点的话题不?"
|
||||
],
|
||||
[
|
||||
"你有看电视剧的习惯吗?",
|
||||
"最近在追哪部剧?",
|
||||
"最喜欢的电视剧是哪部?"
|
||||
"今天有啥新发现或者小感悟没?",
|
||||
"感觉今天过得快不快?节奏怎么样?"
|
||||
],
|
||||
[
|
||||
"你喜欢玩游戏吗?",
|
||||
"最近在玩什么游戏?",
|
||||
"有推荐的好玩的游戏吗?"
|
||||
"你现在周围环境咋样,吵不吵?",
|
||||
"今天出门溜达了没,外面人多不多呀?",
|
||||
"瞅瞅窗外,有啥特别的景儿不?"
|
||||
],
|
||||
[
|
||||
"你会做饭吗?",
|
||||
"平时喜欢做哪些菜?",
|
||||
"有没有特别拿手的菜?"
|
||||
"吃饭了没啊?",
|
||||
"吃的啥呀?合胃口不?"
|
||||
],
|
||||
[
|
||||
"你喜欢购物吗?",
|
||||
"最近买了什么新东西?",
|
||||
"有推荐的购物网站或店铺吗?"
|
||||
"今天怎么样啊?累不累?",
|
||||
"有啥事儿不?"
|
||||
],
|
||||
[
|
||||
"你平时怎么放松自己?",
|
||||
"有特别的解压方式吗?",
|
||||
"最喜欢的放松活动是什么?"
|
||||
"最近身体还好吧?",
|
||||
"没什么不舒服的地方吧?"
|
||||
],
|
||||
[
|
||||
"你喜欢和朋友出去玩吗?",
|
||||
"平时会和朋友去哪玩?",
|
||||
"最近有没有和朋友聚会的计划?"
|
||||
"今天忙不忙啊?",
|
||||
"都干啥了呀?"
|
||||
],
|
||||
[
|
||||
"你喜欢喝咖啡还是茶?",
|
||||
"有没有特别喜欢的咖啡馆或茶馆?",
|
||||
"最喜欢的饮品是什么?"
|
||||
"家里都挺好的吧?",
|
||||
"有啥需要帮忙的不?"
|
||||
],
|
||||
[
|
||||
"你有兄弟姐妹吗?",
|
||||
"和他们关系怎么样?",
|
||||
"经常联系吗?"
|
||||
"今天出门了没?",
|
||||
"外面冷不冷/热不热啊?多穿点/注意防暑。"
|
||||
],
|
||||
[
|
||||
"你喜欢读什么类型的杂志?",
|
||||
"最近有看什么有趣的文章吗?",
|
||||
"有订阅的杂志吗?"
|
||||
"最近有啥开心的事儿不?说来听听!",
|
||||
"或者有啥烦心事儿,跟我说说?"
|
||||
],
|
||||
[
|
||||
"你喜欢看体育比赛吗?",
|
||||
"最喜欢的运动项目是什么?",
|
||||
"有没有特别支持的球队或运动员?"
|
||||
"晚上早点休息啊,别熬太晚。",
|
||||
"睡得好不好啊最近?"
|
||||
],
|
||||
[
|
||||
"你会说其他语言吗?",
|
||||
"最想学的语言是什么?",
|
||||
"学习语言有什么技巧吗?"
|
||||
"缺啥东西不?跟我说。",
|
||||
"钱够不够花呀?"
|
||||
],
|
||||
[
|
||||
"你对科技产品感兴趣吗?",
|
||||
"最近有没有关注什么新科技?",
|
||||
"最喜欢的电子产品是什么?"
|
||||
"今天看到啥有意思的了没?",
|
||||
"或者有啥想跟我分享的?"
|
||||
],
|
||||
[
|
||||
"你喜欢喝什么样的饮料?",
|
||||
"有没有自己调饮料的习惯?",
|
||||
"最喜欢的饮品品牌是什么?"
|
||||
"周末有啥安排啊?",
|
||||
"要不要一起吃个饭/出去转转?"
|
||||
],
|
||||
[
|
||||
"你平时用社交媒体吗?",
|
||||
"常用哪些平台?",
|
||||
"在社交媒体上做什么?"
|
||||
"最近常联系的那些朋友都还好不?",
|
||||
"有空多聚聚。"
|
||||
],
|
||||
[
|
||||
"你对艺术感兴趣吗?",
|
||||
"最喜欢的艺术家是谁?",
|
||||
"有去过哪些艺术展览?"
|
||||
"工作/学习上还顺利吧?",
|
||||
"别太给自己压力啊。"
|
||||
],
|
||||
[
|
||||
"你喜欢DIY吗?",
|
||||
"平时做些什么手工?",
|
||||
"有没有完成的作品可以分享?"
|
||||
"今天做了啥好吃的呀?",
|
||||
"下次也给我尝尝呗!"
|
||||
],
|
||||
[
|
||||
"你喜欢种植植物吗?",
|
||||
"有养什么植物?",
|
||||
"最喜欢的植物是什么?"
|
||||
"有啥新闻没有啊最近?",
|
||||
"跟我讲讲。"
|
||||
],
|
||||
[
|
||||
"你喜欢拍照吗?",
|
||||
"喜欢拍什么样的照片?",
|
||||
"有没有用什么特别的摄影设备?"
|
||||
"那谁谁谁最近怎么样了?",
|
||||
"好久没听到他/她消息了。"
|
||||
],
|
||||
[
|
||||
"你喜欢听播客吗?",
|
||||
"常听哪些主题的播客?",
|
||||
"有没有推荐的播客?"
|
||||
"今天心情好不好呀?",
|
||||
"看你气色不错/有点疲惫。"
|
||||
],
|
||||
[
|
||||
"你对历史感兴趣吗?",
|
||||
"最喜欢哪个历史时期?",
|
||||
"有没有特别喜欢的历史人物?"
|
||||
"有啥想吃的没?下次给你做/带。",
|
||||
"或者想去哪儿玩,我陪你。"
|
||||
],
|
||||
[
|
||||
"你喜欢画画吗?",
|
||||
"平时画什么类型的画?",
|
||||
"有参加过画展吗?"
|
||||
"最近有没有看啥电视剧/电影啊?",
|
||||
"有啥好看的推荐给我呗。"
|
||||
],
|
||||
[
|
||||
"你喜欢写作吗?",
|
||||
"平时写什么类型的文章?",
|
||||
"有没有发表过作品?"
|
||||
],
|
||||
[
|
||||
"你喜欢钓鱼吗?",
|
||||
"平时去哪里钓鱼?",
|
||||
"有没有钓到过什么大鱼?"
|
||||
],
|
||||
[
|
||||
"你喜欢露营吗?",
|
||||
"平时会去哪里露营?",
|
||||
"有没有什么难忘的露营经历?"
|
||||
],
|
||||
[
|
||||
"你喜欢摄影吗?",
|
||||
"最喜欢拍什么题材?",
|
||||
"有没有特别喜欢的摄影师?"
|
||||
],
|
||||
[
|
||||
"你喜欢喝酒吗?",
|
||||
"喜欢什么类型的酒?",
|
||||
"有没有推荐的酒吧或品牌?"
|
||||
],
|
||||
[
|
||||
"你喜欢滑雪吗?",
|
||||
"平时去哪里滑雪?",
|
||||
"有没有什么滑雪技巧分享?"
|
||||
],
|
||||
[
|
||||
"你喜欢海边还是山里?",
|
||||
"最喜欢去哪个地方度假?",
|
||||
"有没有什么特别推荐的景点?"
|
||||
],
|
||||
[
|
||||
"你喜欢参加音乐节吗?",
|
||||
"参加过哪些音乐节?",
|
||||
"最喜欢的音乐节是哪一个?"
|
||||
],
|
||||
[
|
||||
"你喜欢跑步吗?",
|
||||
"平时跑多长距离?",
|
||||
"有没有参加过马拉松?"
|
||||
],
|
||||
[
|
||||
"你喜欢参加聚会吗?",
|
||||
"平时和朋友聚会做什么?",
|
||||
"有没有什么有趣的聚会游戏?"
|
||||
],
|
||||
[
|
||||
"你喜欢收集东西吗?",
|
||||
"收集什么类型的物品?",
|
||||
"有没有什么特别的收藏?"
|
||||
"没事儿就早点回家/休息。",
|
||||
"注意安全啊。"
|
||||
]
|
||||
]
|
||||
}
|
||||
+29
-12
@@ -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]
|
||||
|
||||
@@ -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_args的output_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打分阈值,1分最差,5分最好,低于此分数的数据不会用于训练
|
||||
}
|
||||
}
|
||||
},
|
||||
"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_args的output_dir
|
||||
"template": "qwen",
|
||||
"default_system": "请你扮演一名人类,不要说自己是人工智能",
|
||||
"finetuning_type": "lora",
|
||||
"trust_remote_code": true
|
||||
}
|
||||
}
|
||||
@@ -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调试脚本运行结束。")
|
||||
@@ -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 步骤执行失败。"""
|
||||
|
||||
@@ -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])
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
]
|
||||
@@ -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
@@ -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__":
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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的整数评分>
|
||||
}}
|
||||
"""
|
||||
@@ -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)))
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user