Compare commits
56
Commits
v1.3.7
...
b4587c5d08
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4587c5d08 | ||
|
|
88ec63121d | ||
|
|
01d2da2893 | ||
|
|
25d4ea3f91 | ||
|
|
39985840c7 | ||
|
|
b1adc40cfb | ||
|
|
7348bac6ab | ||
|
|
8ab7564d02 | ||
|
|
d096b6e29e | ||
|
|
d88a41f8f1 | ||
|
|
376e9eba80 | ||
|
|
a62c2e11a2 | ||
|
|
a4e5a8c81c | ||
|
|
3e234c46f6 | ||
|
|
7a04b1f8ce | ||
|
|
a30e3d5114 | ||
|
|
1818d06576 | ||
|
|
4e8d1ee24e | ||
|
|
fec376b0dd | ||
|
|
a2512f8a5a | ||
|
|
457e16ea3c | ||
|
|
daf627a6de | ||
|
|
445378667f | ||
|
|
6ae1828449 | ||
|
|
e7b18b7c03 | ||
|
|
9e31d4ef2b | ||
|
|
52aa4d01d5 | ||
|
|
986be957ec | ||
|
|
cf9c60841b | ||
|
|
31bc7f5c2a | ||
|
|
3057741de9 | ||
|
|
acd1103bd0 | ||
|
|
dc7d2cfbca | ||
|
|
b36a78c612 | ||
|
|
985d940db6 | ||
|
|
5e73ca20aa | ||
|
|
438dc10391 | ||
|
|
615ba5d8ef | ||
|
|
02a7cb9fa0 | ||
|
|
9fe2121743 | ||
|
|
0422d6d38e | ||
|
|
9b416c1bbb | ||
|
|
d6899100ac | ||
|
|
0deee48602 | ||
|
|
746a1475b2 | ||
|
|
01ce1fb9e3 | ||
|
|
14f83cbdac | ||
|
|
dbe5891201 | ||
|
|
2a65c3314c | ||
|
|
1c2ff05a6d | ||
|
|
31ae2deeba | ||
|
|
69207e2c57 | ||
|
|
138c5bcc08 | ||
|
|
a923e0a23a | ||
|
|
f521a30b22 | ||
|
|
d4451f6afb |
+1
-1
@@ -5,7 +5,7 @@ Thank you for your interest in contributing! This document provides step-by-step
|
|||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/your-username/AstrAI.git
|
git clone https://github.com/ViperEkura/AstrAI.git
|
||||||
cd AstrAI
|
cd AstrAI
|
||||||
pip install -e ".[dev]" # install with dev dependencies (pytest, ruff)
|
pip install -e ".[dev]" # install with dev dependencies (pytest, ruff)
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -9,9 +9,9 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="https://img.shields.io/badge/python-3.12+-blue.svg" alt="python">
|
<img src="https://img.shields.io/badge/python-3.12+-blue.svg" alt="python">
|
||||||
<img src="https://img.shields.io/badge/license-GPL--3.0-blue.svg" alt="license">
|
<img src="https://img.shields.io/badge/license-GPL--3.0-blue.svg" alt="license">
|
||||||
<img src="https://img.shields.io/github/v/release/ViperEkura/AstrAI?color=76bad9" alt="release">
|
<img src="https://img.shields.io/github/v/release/ViperEkura/AstrAI?label=Release&color=76bad9" alt="release">
|
||||||
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.github.com%2Frepos%2FViperEkura%2FAstrAI&query=%24.stargazers_count&label=stars&suffix=%20stars&color=76bad9" alt="stars">
|
<img src="https://img.shields.io/github/stars/ViperEkura/AstrAI?style=flat&label=Stars&color=76bad9" alt="stars">
|
||||||
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.github.com%2Frepos%2FViperEkura%2FAstrAI&query=%24.forks_count&label=forks&suffix=%20forks&color=76bad9" alt="forks">
|
<img src="https://img.shields.io/github/forks/ViperEkura/AstrAI?style=flat&label=Forks&color=76bad9" alt="forks">
|
||||||
</div>
|
</div>
|
||||||
<br>
|
<br>
|
||||||
|
|
||||||
@@ -28,7 +28,8 @@
|
|||||||
## 📖 Table of Contents
|
## 📖 Table of Contents
|
||||||
|
|
||||||
- [Features](#features)
|
- [Features](#features)
|
||||||
- [Quick Start](#quick-start)
|
- [Getting Started](#getting-started)
|
||||||
|
- [Demo](#demo)
|
||||||
- [Documentation](#documentation)
|
- [Documentation](#documentation)
|
||||||
- [Contributing](#contributing)
|
- [Contributing](#contributing)
|
||||||
- [Community](#community)
|
- [Community](#community)
|
||||||
@@ -49,39 +50,50 @@
|
|||||||
- 🤗 **HuggingFace-Style API**: AutoModel/AutoTokenizer APIs inspired by HuggingFace for easy model and tokenizer loading.
|
- 🤗 **HuggingFace-Style API**: AutoModel/AutoTokenizer APIs inspired by HuggingFace for easy model and tokenizer loading.
|
||||||
- 🔌 **Dual API Compatibility**: Supports both OpenAI and Anthropic chat completion APIs out of the box.
|
- 🔌 **Dual API Compatibility**: Supports both OpenAI and Anthropic chat completion APIs out of the box.
|
||||||
|
|
||||||
### Quick Start
|
### Getting Started
|
||||||
|
|
||||||
#### Installation
|
End-to-end walkthrough in 5 steps:
|
||||||
|
|
||||||
|
**1. Install**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/ViperEkura/AstrAI.git
|
git clone https://github.com/ViperEkura/AstrAI.git
|
||||||
cd AstrAI
|
cd AstrAI
|
||||||
pip install -e .
|
pip install -e .
|
||||||
|
# pip install -e ".[dev]" # optional: dev dependencies (pytest, ruff)
|
||||||
```
|
```
|
||||||
|
|
||||||
For development dependencies:
|
**2. Download model**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install -e ".[dev]"
|
python scripts/demo/download.py # downloads 1B checkpoint to params/
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Download Pre-trained Model
|
**3. Preprocess data**
|
||||||
|
|
||||||
Download pre-trained model weights (1B bilingual checkpoint) to `params/`:
|
Create `pretrain.json` (preprocessing config for `seq` strategy):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"input": {"sections": [{"field": "text", "action": "train"}]},
|
||||||
|
"preprocessing": {"max_seq_len": 2048},
|
||||||
|
"output": {"storage_format": "bin"}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python scripts/demo/download.py
|
python scripts/tools/preprocess.py data/*.jsonl -o output/ -c pretrain.json
|
||||||
```
|
```
|
||||||
|
|
||||||
Or download manually from [HuggingFace](https://huggingface.co/ViperEk/KHAOSZ) into `params/`.
|
**4. Train**
|
||||||
|
|
||||||
#### Train a Model
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export CUDA_VISIBLE_DEVICES=0,1,2,3
|
export CUDA_VISIBLE_DEVICES=0,1,2,3
|
||||||
|
|
||||||
nohup python scripts/tools/train.py \
|
nohup python scripts/tools/train.py \
|
||||||
--nprocs=4 \
|
--nprocs=4 \
|
||||||
|
--parallel_mode=ddp \
|
||||||
--train_type=seq \
|
--train_type=seq \
|
||||||
--data_root_path=/path/to/dataset \
|
--data_root_path=/path/to/dataset \
|
||||||
--param_path=/path/to/model \
|
--param_path=/path/to/model \
|
||||||
@@ -101,15 +113,54 @@ nohup python scripts/tools/train.py \
|
|||||||
> out.log 2> err.log &
|
> out.log 2> err.log &
|
||||||
```
|
```
|
||||||
|
|
||||||
Full reference at [Parameter Guide](assets/docs/params.md).
|
**5. Serve & query**
|
||||||
|
|
||||||
#### Generate Text
|
```bash
|
||||||
|
# Terminal 1: start server
|
||||||
|
python scripts/tools/server.py --param_path ./params --device cuda
|
||||||
|
|
||||||
|
# Terminal 2: query
|
||||||
|
curl http://localhost:8000/v1/chat/completions \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"messages":[{"role":"user","content":"Hello"}],"max_tokens":512}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Demo
|
||||||
|
|
||||||
|
Check out the demos in the `scripts/demo/` folder:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Download model weights (required before running demos)
|
||||||
|
python scripts/demo/download.py # model → params/
|
||||||
|
|
||||||
|
# Interactive streaming chat (multi-turn, maintains history)
|
||||||
|
python scripts/demo/stream_chat.py
|
||||||
|
# Type your message after >>, type !exit to quit
|
||||||
|
|
||||||
|
# Batch generation (5 hardcoded prompts, non-streaming)
|
||||||
|
python scripts/demo/generate_batch.py
|
||||||
|
|
||||||
|
# Single-prompt autoregressive streaming
|
||||||
|
python scripts/demo/generate_ar.py
|
||||||
|
```
|
||||||
|
|
||||||
|
All generation demos use `temperature=0.8`, `top_p=0.95`, `top_k=50`, `max_tokens=2048` by default and require `params/` to contain model weights (run `download.py` first).
|
||||||
|
|
||||||
|
Watch a video walkthrough on [bilibili](https://www.bilibili.com/video/BV1fuLB6yEj6).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
See [Documentation](#documentation) for full references beyond the examples above.
|
||||||
|
|
||||||
|
#### Text Generation
|
||||||
|
|
||||||
|
Batch generation from a JSONL file:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python scripts/tools/generate.py \
|
python scripts/tools/generate.py \
|
||||||
--param_path /path/to/model \
|
--param_path ./params \
|
||||||
--input_json_file /path/to/input.json \
|
--input_json_file input.jsonl \
|
||||||
--output_json_file /path/to/output.json
|
--output_json_file output.jsonl
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Docker
|
#### Docker
|
||||||
@@ -123,9 +174,6 @@ docker build -t astrai:latest .
|
|||||||
# Run with GPU support
|
# Run with GPU support
|
||||||
docker run --gpus all -it astrai:latest
|
docker run --gpus all -it astrai:latest
|
||||||
|
|
||||||
# Run with specific GPUs
|
|
||||||
docker run --gpus '"device=0,1"' -it astrai:latest
|
|
||||||
|
|
||||||
# Run inference server
|
# Run inference server
|
||||||
docker run --gpus all -p 8000:8000 astrai:latest \
|
docker run --gpus all -p 8000:8000 astrai:latest \
|
||||||
python -m scripts.tools.server --port 8000 --device cuda
|
python -m scripts.tools.server --port 8000 --device cuda
|
||||||
@@ -142,88 +190,42 @@ docker compose --profile cpu up -d
|
|||||||
|
|
||||||
> **Note**: `--gpus all` is required for CUDA support. Without it, `torch.cuda.is_available()` will return `False`.
|
> **Note**: `--gpus all` is required for CUDA support. Without it, `torch.cuda.is_available()` will return `False`.
|
||||||
|
|
||||||
#### Start HTTP Server
|
#### HTTP API Examples
|
||||||
|
|
||||||
Start the inference server with OpenAI and Anthropic-compatible HTTP API:
|
Additional request examples beyond the [Getting Started](#getting-started) flow:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python -m scripts.tools.server --port 8000 --device cuda
|
|
||||||
```
|
|
||||||
|
|
||||||
Make requests:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# OpenAI-compatible
|
|
||||||
curl -X POST http://localhost:8000/v1/chat/completions \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{
|
|
||||||
"messages": [{"role": "user", "content": "Hello"}],
|
|
||||||
"max_tokens": 512
|
|
||||||
}'
|
|
||||||
|
|
||||||
# OpenAI-compatible streaming
|
# OpenAI-compatible streaming
|
||||||
curl -X POST http://localhost:8000/v1/chat/completions \
|
curl -X POST http://localhost:8000/v1/chat/completions \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{
|
-d '{"messages":[{"role":"user","content":"Tell a story"}],"stream":true,"max_tokens":500}'
|
||||||
"messages": [{"role": "user", "content": "Tell a story"}],
|
|
||||||
"stream": true,
|
|
||||||
"max_tokens": 500
|
|
||||||
}'
|
|
||||||
|
|
||||||
# Anthropic-compatible
|
# Anthropic-compatible
|
||||||
curl -X POST http://localhost:8000/v1/messages \
|
curl -X POST http://localhost:8000/v1/messages \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{
|
-d '{"model":"astrai","system":"You are a helpful assistant.","messages":[{"role":"user","content":"Hello"}],"max_tokens":512}'
|
||||||
"model": "astrai",
|
|
||||||
"system": "You are a helpful assistant.",
|
|
||||||
"messages": [{"role": "user", "content": "Hello"}],
|
|
||||||
"max_tokens": 512
|
|
||||||
}'
|
|
||||||
|
|
||||||
# Anthropic-compatible streaming with stop sequences
|
# Anthropic-compatible streaming with stop sequences
|
||||||
curl -X POST http://localhost:8000/v1/messages \
|
curl -X POST http://localhost:8000/v1/messages \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{
|
-d '{"model":"astrai","messages":[{"role":"user","content":"Write a story"}],"max_tokens":500,"stream":true,"stop_sequences":["The end"]}'
|
||||||
"model": "astrai",
|
|
||||||
"messages": [{"role": "user", "content": "Write a story"}],
|
|
||||||
"max_tokens": 500,
|
|
||||||
"stream": true,
|
|
||||||
"stop_sequences": ["The end"]
|
|
||||||
}'
|
|
||||||
|
|
||||||
# Health check
|
# Health check
|
||||||
curl http://localhost:8000/health
|
curl http://localhost:8000/health
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Demo
|
See [Inference Guide](assets/docs/inference.md) for SSE streaming format, error codes, and stats endpoint.
|
||||||
|
|
||||||
Check out the demos in the `scripts/demo/` folder:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Download pre‑processed data (required before running demos)
|
|
||||||
python scripts/demo/download.py
|
|
||||||
|
|
||||||
# Interactive streaming chat
|
|
||||||
python scripts/demo/stream_chat.py
|
|
||||||
|
|
||||||
# Batch generation
|
|
||||||
python scripts/demo/generate_batch.py
|
|
||||||
|
|
||||||
# Auto‑regressive generation
|
|
||||||
python scripts/demo/generate_ar.py
|
|
||||||
```
|
|
||||||
|
|
||||||
Watch a video walkthrough on [bilibili](https://www.bilibili.com/video/BV1fuLB6yEj6).
|
|
||||||
|
|
||||||
### Documentation
|
### Documentation
|
||||||
|
|
||||||
| Document | Description |
|
| Document | Description |
|
||||||
|----------|-------------|
|
|----------|-------------|
|
||||||
| [Parameter Guide](./assets/docs/params.md) | Training & inference parameters |
|
| [CLI Reference](./assets/docs/params.md) | Parameters for all CLI tools (train, server, generate, preprocess) |
|
||||||
| [Architecture](./assets/docs/architecture.md) | System architecture, class diagram & design patterns |
|
| [Architecture](./assets/docs/architecture.md) | System architecture, class diagram & design patterns |
|
||||||
| [Training](./assets/docs/training.md) | Training loop, strategies & formulas |
|
| [Training](./assets/docs/training.md) | Training loop, strategies & formulas |
|
||||||
| [Inference](./assets/docs/inference.md) | KVCache, continuous batching, sampling & HTTP API |
|
| [Inference](./assets/docs/inference.md) | KVCache, continuous batching, sampling & HTTP API |
|
||||||
| [Data Flow](./assets/docs/dataflow.md) | Data pipeline, storage backends & dataset architecture |
|
| [Data Flow](./assets/docs/dataflow.md) | Data pipeline, storage backends & dataset architecture |
|
||||||
|
| [Preprocessing](./assets/docs/preprocessing.md) | Declarative JSON-driven data preprocessing |
|
||||||
|
|
||||||
### Contributing
|
### Contributing
|
||||||
|
|
||||||
|
|||||||
+77
-75
@@ -15,9 +15,9 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="https://img.shields.io/badge/python-3.12+-blue.svg" alt="python">
|
<img src="https://img.shields.io/badge/python-3.12+-blue.svg" alt="python">
|
||||||
<img src="https://img.shields.io/badge/license-GPL--3.0-blue.svg" alt="license">
|
<img src="https://img.shields.io/badge/license-GPL--3.0-blue.svg" alt="license">
|
||||||
<img src="https://img.shields.io/github/v/release/ViperEkura/AstrAI?color=76bad9" alt="release">
|
<img src="https://img.shields.io/github/v/release/ViperEkura/AstrAI?label=Release&color=76bad9" alt="release">
|
||||||
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.github.com%2Frepos%2FViperEkura%2FAstrAI&query=%24.stargazers_count&label=stars&suffix=%20stars&color=76bad9" alt="stars">
|
<img src="https://img.shields.io/github/stars/ViperEkura/AstrAI?style=flat&label=Stars&color=76bad9" alt="stars">
|
||||||
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.github.com%2Frepos%2FViperEkura%2FAstrAI&query=%24.forks_count&label=forks&suffix=%20forks&color=76bad9" alt="forks">
|
<img src="https://img.shields.io/github/forks/ViperEkura/AstrAI?style=flat&label=Forks&color=76bad9" alt="forks">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<br>
|
<br>
|
||||||
@@ -34,7 +34,8 @@
|
|||||||
## 📖 目录
|
## 📖 目录
|
||||||
|
|
||||||
- [特性](#特性)
|
- [特性](#特性)
|
||||||
- [快速开始](#快速开始)
|
- [快速上手](#快速上手)
|
||||||
|
- [演示](#演示)
|
||||||
- [文档](#文档)
|
- [文档](#文档)
|
||||||
- [贡献](#贡献)
|
- [贡献](#贡献)
|
||||||
- [社区](#社区)
|
- [社区](#社区)
|
||||||
@@ -55,39 +56,50 @@
|
|||||||
- 🤗 **HuggingFace 风格 API**: 类 HuggingFace 的 AutoModel/AutoTokenizer 接口,方便加载模型和分词器。
|
- 🤗 **HuggingFace 风格 API**: 类 HuggingFace 的 AutoModel/AutoTokenizer 接口,方便加载模型和分词器。
|
||||||
- 🔌 **双 API 兼容**: 同时支持 OpenAI 和 Anthropic 聊天补全 API,开箱即用。
|
- 🔌 **双 API 兼容**: 同时支持 OpenAI 和 Anthropic 聊天补全 API,开箱即用。
|
||||||
|
|
||||||
### 快速开始
|
### 快速上手
|
||||||
|
|
||||||
#### 安装
|
端到端演示,只需 5 步:
|
||||||
|
|
||||||
|
**1. 安装**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/ViperEkura/AstrAI.git
|
git clone https://github.com/ViperEkura/AstrAI.git
|
||||||
cd AstrAI
|
cd AstrAI
|
||||||
pip install -e .
|
pip install -e .
|
||||||
|
# pip install -e ".[dev]" # 可选:开发依赖(pytest, ruff)
|
||||||
```
|
```
|
||||||
|
|
||||||
安装开发依赖:
|
**2. 下载模型**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install -e ".[dev]"
|
python scripts/demo/download.py # 下载 1B 检查点到 params/
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 下载预训练模型
|
**3. 预处理数据**
|
||||||
|
|
||||||
下载预训练模型权重(1B 双语检查点)到 `params/` 目录:
|
创建 `pretrain.json`(`seq` 策略的预处理配置):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"input": {"sections": [{"field": "text", "action": "train"}]},
|
||||||
|
"preprocessing": {"max_seq_len": 2048},
|
||||||
|
"output": {"storage_format": "bin"}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python scripts/demo/download.py
|
python scripts/tools/preprocess.py data/*.jsonl -o output/ -c pretrain.json
|
||||||
```
|
```
|
||||||
|
|
||||||
或从 [HuggingFace](https://huggingface.co/ViperEk/KHAOSZ) 手动下载放入 `params/`。
|
**4. 训练**
|
||||||
|
|
||||||
#### 训练模型
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export CUDA_VISIBLE_DEVICES=0,1,2,3
|
export CUDA_VISIBLE_DEVICES=0,1,2,3
|
||||||
|
|
||||||
nohup python scripts/tools/train.py \
|
nohup python scripts/tools/train.py \
|
||||||
--nprocs=4 \
|
--nprocs=4 \
|
||||||
|
--parallel_mode=ddp \
|
||||||
--train_type=seq \
|
--train_type=seq \
|
||||||
--data_root_path=/path/to/dataset \
|
--data_root_path=/path/to/dataset \
|
||||||
--param_path=/path/to/model \
|
--param_path=/path/to/model \
|
||||||
@@ -107,15 +119,54 @@ nohup python scripts/tools/train.py \
|
|||||||
> out.log 2> err.log &
|
> out.log 2> err.log &
|
||||||
```
|
```
|
||||||
|
|
||||||
完整参数列表见[参数说明](./params.md)。
|
**5. 启动服务并调用**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 终端 1:启动服务
|
||||||
|
python scripts/tools/server.py --param_path ./params --device cuda
|
||||||
|
|
||||||
|
# 终端 2:发起请求
|
||||||
|
curl http://localhost:8000/v1/chat/completions \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"messages":[{"role":"user","content":"你好"}],"max_tokens":512}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 演示
|
||||||
|
|
||||||
|
查看 `scripts/demo/` 文件夹中的演示:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 下载模型权重(运行演示前必需)
|
||||||
|
python scripts/demo/download.py # model → params/
|
||||||
|
|
||||||
|
# 交互式流式聊天(多轮对话,保持历史记录)
|
||||||
|
python scripts/demo/stream_chat.py
|
||||||
|
# 在 >> 后输入消息,输入 !exit 退出
|
||||||
|
|
||||||
|
# 批量生成(5 条硬编码提示词,非流式)
|
||||||
|
python scripts/demo/generate_batch.py
|
||||||
|
|
||||||
|
# 单条提示词自回归流式生成
|
||||||
|
python scripts/demo/generate_ar.py
|
||||||
|
```
|
||||||
|
|
||||||
|
所有生成演示默认使用 `temperature=0.8`、`top_p=0.95`、`top_k=50`、`max_tokens=2048`,需要 `params/` 目录包含模型权重(请先运行 `download.py`)。
|
||||||
|
|
||||||
|
观看 [bilibili](https://www.bilibili.com/video/BV1fuLB6yEj6) 上的视频演示。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
更多选项请参考[文档](#文档)。
|
||||||
|
|
||||||
#### 文本生成
|
#### 文本生成
|
||||||
|
|
||||||
|
从 JSONL 文件批量生成:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python scripts/tools/generate.py \
|
python scripts/tools/generate.py \
|
||||||
--param_path /path/to/model \
|
--param_path ./params \
|
||||||
--input_json_file /path/to/input.json \
|
--input_json_file input.jsonl \
|
||||||
--output_json_file /path/to/output.json
|
--output_json_file output.jsonl
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Docker
|
#### Docker
|
||||||
@@ -129,9 +180,6 @@ docker build -t astrai:latest .
|
|||||||
# 启用 GPU 运行
|
# 启用 GPU 运行
|
||||||
docker run --gpus all -it astrai:latest
|
docker run --gpus all -it astrai:latest
|
||||||
|
|
||||||
# 指定特定 GPU
|
|
||||||
docker run --gpus '"device=0,1"' -it astrai:latest
|
|
||||||
|
|
||||||
# 运行推理服务
|
# 运行推理服务
|
||||||
docker run --gpus all -p 8000:8000 astrai:latest \
|
docker run --gpus all -p 8000:8000 astrai:latest \
|
||||||
python -m scripts.tools.server --port 8000 --device cuda
|
python -m scripts.tools.server --port 8000 --device cuda
|
||||||
@@ -148,88 +196,42 @@ docker compose --profile cpu up -d
|
|||||||
|
|
||||||
> **注意**: 必须使用 `--gpus all` 才能启用 CUDA 支持,否则 `torch.cuda.is_available()` 将返回 `False`。
|
> **注意**: 必须使用 `--gpus all` 才能启用 CUDA 支持,否则 `torch.cuda.is_available()` 将返回 `False`。
|
||||||
|
|
||||||
#### 启动 HTTP 服务
|
#### HTTP API 示例
|
||||||
|
|
||||||
启动推理服务器,支持 OpenAI 和 Anthropic 兼容的 HTTP API:
|
除[快速上手](#快速上手)流程外,更多请求示例:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python -m scripts.tools.server --port 8000 --device cuda
|
|
||||||
```
|
|
||||||
|
|
||||||
发起请求:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# OpenAI 兼容
|
|
||||||
curl -X POST http://localhost:8000/v1/chat/completions \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{
|
|
||||||
"messages": [{"role": "user", "content": "你好"}],
|
|
||||||
"max_tokens": 512
|
|
||||||
}'
|
|
||||||
|
|
||||||
# OpenAI 兼容流式
|
# OpenAI 兼容流式
|
||||||
curl -X POST http://localhost:8000/v1/chat/completions \
|
curl -X POST http://localhost:8000/v1/chat/completions \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{
|
-d '{"messages":[{"role":"user","content":"讲个故事"}],"stream":true,"max_tokens":500}'
|
||||||
"messages": [{"role": "user", "content": "讲个故事"}],
|
|
||||||
"stream": true,
|
|
||||||
"max_tokens": 500
|
|
||||||
}'
|
|
||||||
|
|
||||||
# Anthropic 兼容
|
# Anthropic 兼容
|
||||||
curl -X POST http://localhost:8000/v1/messages \
|
curl -X POST http://localhost:8000/v1/messages \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{
|
-d '{"model":"astrai","system":"你是一个乐于助人的助手。","messages":[{"role":"user","content":"你好"}],"max_tokens":512}'
|
||||||
"model": "astrai",
|
|
||||||
"system": "你是一个乐于助人的助手。",
|
|
||||||
"messages": [{"role": "user", "content": "你好"}],
|
|
||||||
"max_tokens": 512
|
|
||||||
}'
|
|
||||||
|
|
||||||
# Anthropic 兼容流式并设置停止序列
|
# Anthropic 兼容流式并设置停止序列
|
||||||
curl -X POST http://localhost:8000/v1/messages \
|
curl -X POST http://localhost:8000/v1/messages \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{
|
-d '{"model":"astrai","messages":[{"role":"user","content":"写个故事"}],"max_tokens":500,"stream":true,"stop_sequences":["结束"]}'
|
||||||
"model": "astrai",
|
|
||||||
"messages": [{"role": "user", "content": "写个故事"}],
|
|
||||||
"max_tokens": 500,
|
|
||||||
"stream": true,
|
|
||||||
"stop_sequences": ["结束"]
|
|
||||||
}'
|
|
||||||
|
|
||||||
# 健康检查
|
# 健康检查
|
||||||
curl http://localhost:8000/health
|
curl http://localhost:8000/health
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 演示
|
SSE 流式格式、错误码和统计端点详见[推理文档](./inference.md)。
|
||||||
|
|
||||||
查看 `scripts/demo/` 文件夹中的演示:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 下载预处理数据(运行演示前必需)
|
|
||||||
python scripts/demo/download.py
|
|
||||||
|
|
||||||
# 交互式流式聊天
|
|
||||||
python scripts/demo/stream_chat.py
|
|
||||||
|
|
||||||
# 批量生成
|
|
||||||
python scripts/demo/generate_batch.py
|
|
||||||
|
|
||||||
# 自回归生成
|
|
||||||
python scripts/demo/generate_ar.py
|
|
||||||
```
|
|
||||||
|
|
||||||
观看 [bilibili](https://www.bilibili.com/video/BV1fuLB6yEj6) 上的视频演示。
|
|
||||||
|
|
||||||
### 文档
|
### 文档
|
||||||
|
|
||||||
| 文档 | 说明 |
|
| 文档 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| [参数说明](./params.md) | 训练与推理参数配置 |
|
| [CLI 参考](./params.md) | 所有 CLI 工具参数(训练、服务、生成、预处理) |
|
||||||
| [架构文档](./architecture.md) | 系统架构、类图与设计模式 |
|
| [架构文档](./architecture.md) | 系统架构、类图与设计模式 |
|
||||||
| [训练文档](./training.md) | 训练循环、策略与公式 |
|
| [训练文档](./training.md) | 训练循环、策略与公式 |
|
||||||
| [推理文档](./inference.md) | KVCache、连续批处理、采样与 HTTP API |
|
| [推理文档](./inference.md) | KVCache、连续批处理、采样与 HTTP API |
|
||||||
| [数据流程](./dataflow.md) | 数据管道、存储后端与数据集架构 |
|
| [数据流程](./dataflow.md) | 数据管道、存储后端与数据集架构 |
|
||||||
|
| [数据预处理](./preprocessing.md) | 声明式 JSON 驱动数据预处理 |
|
||||||
|
|
||||||
### 贡献
|
### 贡献
|
||||||
|
|
||||||
|
|||||||
+199
-79
@@ -1,5 +1,12 @@
|
|||||||
# AstrAI Architecture
|
# AstrAI Architecture
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
- [Class Diagram](#class-diagram) — Full Mermaid class diagram across 10+ namespaces
|
||||||
|
- [Module Overview](#module-overview) — Component inventory per module
|
||||||
|
- [Design Patterns](#design-patterns) — 13 documented patterns with classes
|
||||||
|
- [Core Relationships](#core-relationships) — 11 key inter-component relationships
|
||||||
|
|
||||||
## Class Diagram
|
## Class Diagram
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
@@ -8,6 +15,8 @@ classDiagram
|
|||||||
class BaseConfig {
|
class BaseConfig {
|
||||||
+to_dict() Dict
|
+to_dict() Dict
|
||||||
+from_dict(d) Self
|
+from_dict(d) Self
|
||||||
|
+from_file(path) Self
|
||||||
|
+to_file(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
class BaseModelConfig {
|
class BaseModelConfig {
|
||||||
@@ -17,53 +26,86 @@ classDiagram
|
|||||||
}
|
}
|
||||||
|
|
||||||
class AutoRegressiveLMConfig {
|
class AutoRegressiveLMConfig {
|
||||||
+int vocab_size
|
+Optional[int] vocab_size
|
||||||
+int dim
|
+Optional[int] dim
|
||||||
+int n_layers
|
+Optional[int] n_layers
|
||||||
+float norm_eps
|
+Optional[float] norm_eps
|
||||||
+int dim_ffn
|
+Optional[int] dim_ffn
|
||||||
+Optional[bool] tie_weight
|
+Optional[bool] tie_weight
|
||||||
+Optional[dict] rope_scaling
|
+Optional[dict] rope_scaling
|
||||||
+int max_len
|
+Optional[int] max_len
|
||||||
+float rope_theta
|
+Optional[float] rope_theta
|
||||||
+str attn_type
|
+str attn_type
|
||||||
+int n_heads
|
+Optional[int] n_heads
|
||||||
+int n_kv_heads
|
+Optional[int] n_kv_heads
|
||||||
+bool use_qk_norm
|
+Optional[bool] use_qk_norm
|
||||||
+bool use_gated_attention
|
+Optional[bool] use_gated_attention
|
||||||
+Optional[int] kv_lora_rank
|
+Optional[int] kv_lora_rank
|
||||||
+Optional[int] qk_nope_head_dim
|
+Optional[int] qk_nope_head_dim
|
||||||
+Optional[int] qk_rope_head_dim
|
+Optional[int] qk_rope_head_dim
|
||||||
+str ffn_type
|
+str ffn_type
|
||||||
+int n_routed_experts
|
+Optional[int] n_routed_experts
|
||||||
+int n_shared_experts
|
+Optional[int] n_shared_experts
|
||||||
+int n_activated_experts
|
+Optional[int] n_activated_experts
|
||||||
+Optional[str] topk_method
|
+Optional[str] topk_method
|
||||||
}
|
}
|
||||||
|
|
||||||
class EncoderConfig {
|
class EncoderConfig {
|
||||||
+int vocab_size
|
+Optional[int] vocab_size
|
||||||
+int dim
|
+Optional[int] dim
|
||||||
+int n_layers
|
+Optional[int] n_layers
|
||||||
+float norm_eps
|
+Optional[float] norm_eps
|
||||||
+int dim_ffn
|
+Optional[int] dim_ffn
|
||||||
+int max_len
|
+Optional[int] max_len
|
||||||
+float rope_theta
|
+Optional[float] rope_theta
|
||||||
+int n_heads
|
+Optional[int] n_heads
|
||||||
+int n_kv_heads
|
+Optional[int] n_kv_heads
|
||||||
+bool use_qk_norm
|
+Optional[bool] use_qk_norm
|
||||||
+bool use_gated_attention
|
+Optional[bool] use_gated_attention
|
||||||
+Optional[dict] rope_scaling
|
+Optional[dict] rope_scaling
|
||||||
+Optional[str] pooling_type
|
+Optional[str] pooling_type
|
||||||
+Optional[bool] normalize_embeddings
|
+Optional[bool] normalize_embeddings
|
||||||
}
|
}
|
||||||
|
|
||||||
class ConfigFactory {
|
class ConfigFactory {
|
||||||
+Registry _registry
|
+Dict _entries
|
||||||
+register(name) decorator
|
+register(name) decorator
|
||||||
+load(raw) BaseConfig
|
+load(raw) BaseConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class InputConfig {
|
||||||
|
+Optional[List[Dict]] sections
|
||||||
|
+Optional[Dict[str, Dict]] sources
|
||||||
|
}
|
||||||
|
|
||||||
|
class ProcessingConfig {
|
||||||
|
+int max_seq_len
|
||||||
|
+int min_chars
|
||||||
|
+int max_chars
|
||||||
|
+Optional[int] max_items
|
||||||
|
+str packing_strategy
|
||||||
|
+int max_packed_len
|
||||||
|
+str truncation_mode
|
||||||
|
}
|
||||||
|
|
||||||
|
class OutputConfig {
|
||||||
|
+Optional[str] domain_key
|
||||||
|
+str storage_format
|
||||||
|
+int max_tokens_per_shard
|
||||||
|
+Dict[str, str] dtype
|
||||||
|
+str position_ids_mode
|
||||||
|
}
|
||||||
|
|
||||||
|
class PipelineConfig {
|
||||||
|
+int version
|
||||||
|
+InputConfig input
|
||||||
|
+dict mask
|
||||||
|
+str mask_default
|
||||||
|
+ProcessingConfig preprocessing
|
||||||
|
+OutputConfig output
|
||||||
|
+from_dict(d) Self
|
||||||
|
}
|
||||||
|
|
||||||
class TrainConfig {
|
class TrainConfig {
|
||||||
+Callable[[], nn.Module] model_fn
|
+Callable[[], nn.Module] model_fn
|
||||||
+str strategy
|
+str strategy
|
||||||
@@ -156,13 +198,13 @@ classDiagram
|
|||||||
}
|
}
|
||||||
|
|
||||||
class StoreFactory {
|
class StoreFactory {
|
||||||
+Registry _registry
|
+Dict _entries
|
||||||
+register(name) decorator
|
+register(name) decorator
|
||||||
+create(storage_type) Store
|
+create(storage_type) Store
|
||||||
}
|
}
|
||||||
|
|
||||||
class DatasetFactory {
|
class DatasetFactory {
|
||||||
+Registry _registry
|
+Dict _entries
|
||||||
+register(name) decorator
|
+register(name) decorator
|
||||||
+create(train_type, window_size, stride) BaseDataset
|
+create(train_type, window_size, stride) BaseDataset
|
||||||
+load(train_type, load_path, window_size, stride, storage_type) BaseDataset
|
+load(train_type, load_path, window_size, stride, storage_type) BaseDataset
|
||||||
@@ -185,7 +227,7 @@ classDiagram
|
|||||||
namespace model {
|
namespace model {
|
||||||
class AutoModel {
|
class AutoModel {
|
||||||
+BaseModelConfig config
|
+BaseModelConfig config
|
||||||
+Registry _registry
|
+Dict _entries
|
||||||
+register(name) decorator
|
+register(name) decorator
|
||||||
+get_component_class(name) Type
|
+get_component_class(name) Type
|
||||||
+from_pretrained(path, disable_random_init, strict) nn.Module
|
+from_pretrained(path, disable_random_init, strict) nn.Module
|
||||||
@@ -312,10 +354,38 @@ classDiagram
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
namespace preprocessing {
|
||||||
|
class BaseMaskBuilder {
|
||||||
|
<<abstract>>
|
||||||
|
+build(item, config, tokenizer) Optional[dict]
|
||||||
|
}
|
||||||
|
|
||||||
|
class SectionedMaskBuilder {
|
||||||
|
+SectionRenderer renderer
|
||||||
|
+build(item, config, tokenizer) Optional[dict]
|
||||||
|
+_build_single(item, config, tokenizer) Optional[dict]
|
||||||
|
+_build_multi(item, sources_spec, config, tokenizer) Optional[dict]
|
||||||
|
}
|
||||||
|
|
||||||
|
class Pipeline {
|
||||||
|
+PipelineConfig config
|
||||||
|
+List[str] paths
|
||||||
|
+str output_dir
|
||||||
|
+str tokenizer_path
|
||||||
|
+BaseMaskBuilder mask_builder
|
||||||
|
+PackingStrategy _packer
|
||||||
|
+PositionIdStrategy _position_id
|
||||||
|
+StoreWriter _writer
|
||||||
|
+transform(item) Optional[dict]
|
||||||
|
+run()
|
||||||
|
+_flush(domains, shard_idx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
namespace tokenize {
|
namespace tokenize {
|
||||||
class AutoTokenizer {
|
class AutoTokenizer {
|
||||||
+vocab_size int
|
+vocab_size int
|
||||||
+encode(tokens, out_ids, is_pretokenized, add_special_tokens) List[int]
|
+encode(tokens, out_ids, is_pretokenized, add_special_tokens) List
|
||||||
+decode(tokens, skip_special_tokens) str
|
+decode(tokens, skip_special_tokens) str
|
||||||
+__getattr__(name) Any (bos_id, eos_id, pad_id, stop_ids)
|
+__getattr__(name) Any (bos_id, eos_id, pad_id, stop_ids)
|
||||||
+apply_chat_template(messages, system_prompt, tokenize, add_generation_prompt) Union[str, List[int]]
|
+apply_chat_template(messages, system_prompt, tokenize, add_generation_prompt) Union[str, List[int]]
|
||||||
@@ -333,27 +403,26 @@ classDiagram
|
|||||||
}
|
}
|
||||||
|
|
||||||
namespace factory {
|
namespace factory {
|
||||||
class Registry {
|
|
||||||
+Dict _entries
|
|
||||||
+register(name, component_cls, category, priority)
|
|
||||||
+get(name) Type
|
|
||||||
+list_names() List[str]
|
|
||||||
}
|
|
||||||
|
|
||||||
class BaseFactory {
|
class BaseFactory {
|
||||||
+Registry _registry
|
+Dict _entries
|
||||||
+register(name, category, priority) decorator
|
+register(name) decorator
|
||||||
+create(name, *args, **kwargs) T
|
+create(name, *args, **kwargs) T
|
||||||
+list_registered() list
|
+list_registered() list
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class MaskBuilderFactory {
|
||||||
|
+Dict _entries
|
||||||
|
+register(name) decorator
|
||||||
|
+create(name, *args, **kwargs) BaseMaskBuilder
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
namespace trainer {
|
namespace trainer {
|
||||||
class Trainer {
|
class Trainer {
|
||||||
+TrainConfig train_config
|
+TrainConfig train_config
|
||||||
+List[TrainCallback] callbacks
|
+List[TrainCallback] callbacks
|
||||||
+train(checkpoint)
|
+train(resume_dir)
|
||||||
+_get_default_callbacks() List[TrainCallback]
|
-_get_default_callbacks() List[TrainCallback]
|
||||||
}
|
}
|
||||||
|
|
||||||
class TrainContext {
|
class TrainContext {
|
||||||
@@ -383,13 +452,17 @@ classDiagram
|
|||||||
}
|
}
|
||||||
|
|
||||||
class BaseStrategy {
|
class BaseStrategy {
|
||||||
+Union[Callable, nn.Module] model
|
+Callable model
|
||||||
|
+Optional[BaseExecutor] executor
|
||||||
|
+Optional[Callable] model_fn
|
||||||
|
+dict extra_kwargs
|
||||||
+str device
|
+str device
|
||||||
|
+__call__(batch) Tensor
|
||||||
+compute_loss(batch) Tensor
|
+compute_loss(batch) Tensor
|
||||||
}
|
}
|
||||||
|
|
||||||
class StrategyFactory {
|
class StrategyFactory {
|
||||||
+Registry _registry
|
+Dict _entries
|
||||||
+register(name) decorator
|
+register(name) decorator
|
||||||
+create(train_type, model, device, **kwargs) BaseStrategy
|
+create(train_type, model, device, **kwargs) BaseStrategy
|
||||||
}
|
}
|
||||||
@@ -425,17 +498,20 @@ classDiagram
|
|||||||
class BaseScheduler {
|
class BaseScheduler {
|
||||||
+get_lr() List[float]
|
+get_lr() List[float]
|
||||||
+step()
|
+step()
|
||||||
|
+state_dict() dict
|
||||||
|
+load_state_dict(d)
|
||||||
}
|
}
|
||||||
|
|
||||||
class SchedulerFactory {
|
class SchedulerFactory {
|
||||||
+Registry _registry
|
+Dict _entries
|
||||||
+register(name) decorator
|
+register(name) decorator
|
||||||
+create(optimizer, schedule_type, **kwargs) BaseScheduler
|
+create(name, *args, **kwargs) BaseScheduler
|
||||||
}
|
}
|
||||||
|
|
||||||
class CosineScheduler {
|
class CosineScheduler {
|
||||||
+int warmup_steps
|
+int warmup_steps
|
||||||
+int lr_decay_steps
|
+int lr_decay_steps
|
||||||
|
+int total_steps
|
||||||
+float min_rate
|
+float min_rate
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -446,6 +522,13 @@ classDiagram
|
|||||||
+int t_mult
|
+int t_mult
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class WSDScheduler {
|
||||||
|
+int warmup_steps
|
||||||
|
+int stable_steps
|
||||||
|
+int decay_steps
|
||||||
|
+float min_rate
|
||||||
|
}
|
||||||
|
|
||||||
class TrainCallback {
|
class TrainCallback {
|
||||||
<<protocol>>
|
<<protocol>>
|
||||||
+on_train_begin(context)
|
+on_train_begin(context)
|
||||||
@@ -474,11 +557,11 @@ classDiagram
|
|||||||
+int interval
|
+int interval
|
||||||
+bool weight_only
|
+bool weight_only
|
||||||
+Callable save_extra_fn
|
+Callable save_extra_fn
|
||||||
+_save_checkpoint(context)
|
-_save_checkpoint(context)
|
||||||
+on_batch_end(context)
|
+on_batch_end(context)
|
||||||
+on_train_end(context)
|
+on_train_end(context)
|
||||||
+on_error(context)
|
+on_error(context)
|
||||||
+save_extra(context)$
|
+save_extra(context) dict$
|
||||||
}
|
}
|
||||||
|
|
||||||
class ProgressBarCallback {
|
class ProgressBarCallback {
|
||||||
@@ -491,7 +574,7 @@ classDiagram
|
|||||||
}
|
}
|
||||||
|
|
||||||
class MetricLoggerCallback {
|
class MetricLoggerCallback {
|
||||||
+str log_dir
|
+Path log_dir
|
||||||
+int save_interval
|
+int save_interval
|
||||||
+int log_interval
|
+int log_interval
|
||||||
+List[str] metrics
|
+List[str] metrics
|
||||||
@@ -501,12 +584,12 @@ classDiagram
|
|||||||
}
|
}
|
||||||
|
|
||||||
class ValidationCallback {
|
class ValidationCallback {
|
||||||
+_run_validation(context)
|
-_run_validation(context)
|
||||||
+on_optimizer_step(context)
|
+on_optimizer_step(context)
|
||||||
}
|
}
|
||||||
|
|
||||||
class CallbackFactory {
|
class CallbackFactory {
|
||||||
+Registry _registry
|
+Dict _entries
|
||||||
+register(name) decorator
|
+register(name) decorator
|
||||||
+create(name, **kwargs) TrainCallback
|
+create(name, **kwargs) TrainCallback
|
||||||
}
|
}
|
||||||
@@ -517,7 +600,7 @@ classDiagram
|
|||||||
+float weight_decay
|
+float weight_decay
|
||||||
+bool nesterov
|
+bool nesterov
|
||||||
+int ns_steps
|
+int ns_steps
|
||||||
+float adamw_lr
|
+Optional[float] adamw_lr
|
||||||
+tuple adamw_betas
|
+tuple adamw_betas
|
||||||
+float adamw_eps
|
+float adamw_eps
|
||||||
+float adamw_wd
|
+float adamw_wd
|
||||||
@@ -634,7 +717,7 @@ classDiagram
|
|||||||
class Task {
|
class Task {
|
||||||
+str task_id
|
+str task_id
|
||||||
+List prompt_ids
|
+List prompt_ids
|
||||||
+int max_tokens
|
+Optional[int] max_tokens
|
||||||
+float temperature
|
+float temperature
|
||||||
+float top_p
|
+float top_p
|
||||||
+int top_k
|
+int top_k
|
||||||
@@ -643,8 +726,8 @@ classDiagram
|
|||||||
+int input_tokens
|
+int input_tokens
|
||||||
+int output_tokens
|
+int output_tokens
|
||||||
+float arrival_time
|
+float arrival_time
|
||||||
+float finish_time
|
+Optional[float] finish_time
|
||||||
+Callable stream_callback
|
+Optional[Callable] stream_callback
|
||||||
+int next_pos
|
+int next_pos
|
||||||
+is_finished(stop_ids) bool
|
+is_finished(stop_ids) bool
|
||||||
}
|
}
|
||||||
@@ -671,6 +754,11 @@ classDiagram
|
|||||||
+activate(task)
|
+activate(task)
|
||||||
+return_to_waiting(tasks)
|
+return_to_waiting(tasks)
|
||||||
+get_active_tasks() List[Task]
|
+get_active_tasks() List[Task]
|
||||||
|
+has_work() bool
|
||||||
|
+wait_for_tasks(timeout)
|
||||||
|
+get_waiting_tasks() List[Task]
|
||||||
|
+clear_queues()
|
||||||
|
+wake()
|
||||||
+get_stats() Dict
|
+get_stats() Dict
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -787,12 +875,13 @@ classDiagram
|
|||||||
+request
|
+request
|
||||||
+engine
|
+engine
|
||||||
+builder: ResponseBuilder
|
+builder: ResponseBuilder
|
||||||
+handle() Union[StreamingResponse, Dict]
|
+async handle() Union[StreamingResponse, Dict]
|
||||||
-_handle_stream(agen, ctx, stops) StreamingResponse
|
-_handle_stream(agen, ctx, stop_sequences) StreamingResponse
|
||||||
-_handle_non_stream(agen, ctx, stops) Dict
|
-async _handle_non_stream(agen, ctx, stop_sequences) Dict
|
||||||
}
|
}
|
||||||
|
|
||||||
class StopChecker {
|
class StopChecker {
|
||||||
|
+__init__(sequences)
|
||||||
+check(text) Optional[str]
|
+check(text) Optional[str]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -804,9 +893,15 @@ classDiagram
|
|||||||
+int completion_tokens
|
+int completion_tokens
|
||||||
}
|
}
|
||||||
|
|
||||||
class app {
|
class StopInfo {
|
||||||
<<singleton>>
|
+Optional[str] matched
|
||||||
+FastAPI app
|
+str body
|
||||||
|
+str yielded
|
||||||
|
}
|
||||||
|
|
||||||
|
class get_app {
|
||||||
|
<<module>>
|
||||||
|
+get_app() FastAPI
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -829,14 +924,14 @@ classDiagram
|
|||||||
}
|
}
|
||||||
|
|
||||||
namespace parallel {
|
namespace parallel {
|
||||||
class Functions {
|
class setup {
|
||||||
<<module>>
|
<<module>>
|
||||||
+spawn_parallel_fn(func, world_size, backend, master_addr, master_port, device_type, start_method, **kwargs)
|
+spawn_parallel_fn(func, world_size, backend, master_addr, master_port, device_type, start_method, **kwargs)
|
||||||
+setup_parallel(rank, world_size, backend, master_addr, master_port, device_type)
|
+setup_parallel(rank, world_size, backend, master_addr, master_port, device_type) contextmanager
|
||||||
+get_current_device() str
|
+get_current_device() str
|
||||||
+get_world_size() int
|
+get_world_size() int
|
||||||
+get_rank() int
|
+get_rank() int
|
||||||
+only_on_rank(rank, sync) decorator
|
+only_on_rank(rank, sync=False) decorator
|
||||||
}
|
}
|
||||||
|
|
||||||
class GradientState {
|
class GradientState {
|
||||||
@@ -847,6 +942,7 @@ classDiagram
|
|||||||
class AccumOptimizer {
|
class AccumOptimizer {
|
||||||
+Optimizer optimizer
|
+Optimizer optimizer
|
||||||
+GradientState gradient_state
|
+GradientState gradient_state
|
||||||
|
+param_groups (property)
|
||||||
+step(closure)
|
+step(closure)
|
||||||
+zero_grad()
|
+zero_grad()
|
||||||
+state_dict() dict
|
+state_dict() dict
|
||||||
@@ -867,7 +963,7 @@ classDiagram
|
|||||||
+prepare(model, optimizer, dataloader, scheduler) tuple
|
+prepare(model, optimizer, dataloader, scheduler) tuple
|
||||||
+accumulate(model) context manager
|
+accumulate(model) context manager
|
||||||
+backward(loss)
|
+backward(loss)
|
||||||
+unwrap_model(model) nn.Module
|
+unwrap_model(model) dict
|
||||||
+sync_gradients (property) bool
|
+sync_gradients (property) bool
|
||||||
+grad_accum_steps (property) int
|
+grad_accum_steps (property) int
|
||||||
}
|
}
|
||||||
@@ -876,18 +972,18 @@ classDiagram
|
|||||||
}
|
}
|
||||||
|
|
||||||
class DDPExecutor {
|
class DDPExecutor {
|
||||||
+_prepare_model(model) nn.Module
|
-_prepare_model(model) nn.Module
|
||||||
+_no_sync(model) context manager
|
-_no_sync(model) context manager
|
||||||
+unwrap_model(model) nn.Module
|
+unwrap_model(model) dict
|
||||||
}
|
}
|
||||||
|
|
||||||
class FSDPExecutor {
|
class FSDPExecutor {
|
||||||
+_prepare_model(model) nn.Module
|
-_prepare_model(model) nn.Module
|
||||||
+unwrap_model(model) nn.Module
|
+unwrap_model(model) dict
|
||||||
}
|
}
|
||||||
|
|
||||||
class ExecutorFactory {
|
class ExecutorFactory {
|
||||||
+Registry _registry
|
+Dict _entries
|
||||||
+register(name) decorator
|
+register(name) decorator
|
||||||
+create(parallel_mode, **kwargs) BaseExecutor
|
+create(parallel_mode, **kwargs) BaseExecutor
|
||||||
}
|
}
|
||||||
@@ -899,11 +995,25 @@ classDiagram
|
|||||||
}
|
}
|
||||||
|
|
||||||
class ColumnParallelLinear {
|
class ColumnParallelLinear {
|
||||||
|
+int in_features
|
||||||
|
+int out_features
|
||||||
|
+int out_features_per_rank
|
||||||
|
+bool gather_results
|
||||||
|
+Parameter weight
|
||||||
|
+Optional[Parameter] bias
|
||||||
+forward(x) Tensor
|
+forward(x) Tensor
|
||||||
|
+load_state_dict(state_dict)
|
||||||
}
|
}
|
||||||
|
|
||||||
class RowParallelLinear {
|
class RowParallelLinear {
|
||||||
|
+int in_features
|
||||||
|
+int out_features
|
||||||
|
+int in_features_per_rank
|
||||||
|
+bool reduce_results
|
||||||
|
+Parameter weight
|
||||||
|
+Optional[Parameter] bias
|
||||||
+forward(x) Tensor
|
+forward(x) Tensor
|
||||||
|
+load_state_dict(state_dict)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -916,6 +1026,7 @@ classDiagram
|
|||||||
BaseStrategy <|-- GRPOStrategy
|
BaseStrategy <|-- GRPOStrategy
|
||||||
BaseScheduler <|-- CosineScheduler
|
BaseScheduler <|-- CosineScheduler
|
||||||
BaseScheduler <|-- SGDRScheduler
|
BaseScheduler <|-- SGDRScheduler
|
||||||
|
BaseScheduler <|-- WSDScheduler
|
||||||
TrainCallback <|-- GradientClippingCallback
|
TrainCallback <|-- GradientClippingCallback
|
||||||
TrainCallback <|-- GradientCheckpointingCallback
|
TrainCallback <|-- GradientCheckpointingCallback
|
||||||
TrainCallback <|-- CheckpointCallback
|
TrainCallback <|-- CheckpointCallback
|
||||||
@@ -931,13 +1042,16 @@ classDiagram
|
|||||||
BaseSamplingStrategy <|-- TemperatureStrategy
|
BaseSamplingStrategy <|-- TemperatureStrategy
|
||||||
BaseSamplingStrategy <|-- TopKStrategy
|
BaseSamplingStrategy <|-- TopKStrategy
|
||||||
BaseSamplingStrategy <|-- TopPStrategy
|
BaseSamplingStrategy <|-- TopPStrategy
|
||||||
BaseSamplingStrategy <|-- SamplingPipeline
|
|
||||||
ParallelModel <|-- RowParallelLinear
|
ParallelModel <|-- RowParallelLinear
|
||||||
ParallelModel <|-- ColumnParallelLinear
|
ParallelModel <|-- ColumnParallelLinear
|
||||||
AutoModel <|-- AutoRegressiveLM
|
AutoModel <|-- AutoRegressiveLM
|
||||||
AutoModel <|-- EmbeddingEncoder
|
AutoModel <|-- EmbeddingEncoder
|
||||||
BaseConfig <|-- BaseModelConfig
|
BaseConfig <|-- BaseModelConfig
|
||||||
BaseConfig <|-- TrainConfig
|
BaseConfig <|-- TrainConfig
|
||||||
|
BaseConfig <|-- InputConfig
|
||||||
|
BaseConfig <|-- ProcessingConfig
|
||||||
|
BaseConfig <|-- OutputConfig
|
||||||
|
BaseConfig <|-- PipelineConfig
|
||||||
BaseModelConfig <|-- AutoRegressiveLMConfig
|
BaseModelConfig <|-- AutoRegressiveLMConfig
|
||||||
BaseModelConfig <|-- EncoderConfig
|
BaseModelConfig <|-- EncoderConfig
|
||||||
BaseFactory <|-- AutoModel
|
BaseFactory <|-- AutoModel
|
||||||
@@ -950,11 +1064,13 @@ classDiagram
|
|||||||
BaseFactory <|-- StoreFactory
|
BaseFactory <|-- StoreFactory
|
||||||
BaseFactory <|-- ExecutorFactory
|
BaseFactory <|-- ExecutorFactory
|
||||||
BaseFactory <|-- ConfigFactory
|
BaseFactory <|-- ConfigFactory
|
||||||
|
BaseFactory <|-- MaskBuilderFactory
|
||||||
BaseExecutor <|-- NoneExecutor
|
BaseExecutor <|-- NoneExecutor
|
||||||
BaseExecutor <|-- DDPExecutor
|
BaseExecutor <|-- DDPExecutor
|
||||||
BaseExecutor <|-- FSDPExecutor
|
BaseExecutor <|-- FSDPExecutor
|
||||||
ResponseBuilder <|-- OpenAIResponseBuilder
|
ResponseBuilder <|-- OpenAIResponseBuilder
|
||||||
ResponseBuilder <|-- AnthropicResponseBuilder
|
ResponseBuilder <|-- AnthropicResponseBuilder
|
||||||
|
BaseMaskBuilder <|-- SectionedMaskBuilder
|
||||||
|
|
||||||
%% --- Composition (strong ownership, part destroyed with whole) ---
|
%% --- Composition (strong ownership, part destroyed with whole) ---
|
||||||
KVCache *-- PagePool
|
KVCache *-- PagePool
|
||||||
@@ -973,7 +1089,6 @@ classDiagram
|
|||||||
DecoderBlock *-- RMSNorm
|
DecoderBlock *-- RMSNorm
|
||||||
ChatCompletionRequest *-- ChatMessage
|
ChatCompletionRequest *-- ChatMessage
|
||||||
MessagesRequest *-- AnthropicMessage
|
MessagesRequest *-- AnthropicMessage
|
||||||
BaseFactory *-- Registry
|
|
||||||
BaseExecutor *-- GradientState
|
BaseExecutor *-- GradientState
|
||||||
AccumOptimizer o-- GradientState
|
AccumOptimizer o-- GradientState
|
||||||
AccumScheduler o-- GradientState
|
AccumScheduler o-- GradientState
|
||||||
@@ -991,9 +1106,13 @@ classDiagram
|
|||||||
KvcacheView o-- Storage
|
KvcacheView o-- Storage
|
||||||
SamplingPipeline o-- BaseSamplingStrategy
|
SamplingPipeline o-- BaseSamplingStrategy
|
||||||
BaseDataset o-- Store
|
BaseDataset o-- Store
|
||||||
|
Pipeline o-- PipelineConfig
|
||||||
|
Pipeline o-- BaseMaskBuilder
|
||||||
|
|
||||||
%% --- Dependency (uses temporarily) ---
|
%% --- Dependency (uses temporarily) ---
|
||||||
TrainConfig ..> BaseStrategy : selects
|
TrainConfig ..> BaseStrategy : selects
|
||||||
|
PipelineConfig ..> MaskBuilderFactory : selects
|
||||||
|
MaskBuilderFactory ..> BaseMaskBuilder : creates
|
||||||
StrategyFactory ..> BaseStrategy : creates
|
StrategyFactory ..> BaseStrategy : creates
|
||||||
SchedulerFactory ..> BaseScheduler : creates
|
SchedulerFactory ..> BaseScheduler : creates
|
||||||
DatasetFactory ..> BaseDataset : creates
|
DatasetFactory ..> BaseDataset : creates
|
||||||
@@ -1046,12 +1165,13 @@ classDiagram
|
|||||||
|
|
||||||
| Module | Components | Description |
|
| Module | Components | Description |
|
||||||
|--------|------------|-------------|
|
|--------|------------|-------------|
|
||||||
| **astrai.config** | BaseConfig, BaseModelConfig, AutoRegressiveLMConfig, EncoderConfig, ConfigFactory, TrainConfig | Configuration management (to_dict/from_dict, to_file/from_file) |
|
| **astrai.config** | BaseConfig, BaseModelConfig, AutoRegressiveLMConfig, EncoderConfig, ConfigFactory, TrainConfig, PipelineConfig, InputConfig, ProcessingConfig, OutputConfig | Configuration management (to_dict/from_dict, to_file/from_file) |
|
||||||
|
| **astrai.preprocessing** | BaseMaskBuilder, MaskBuilderFactory, SectionedMaskBuilder, Pipeline, filter_by_length, PackingStrategy, PackingStrategyFactory, PositionIdStrategy, PositionIdStrategyFactory, StoreWriter, StoreWriterFactory | Declarative JSON-driven data preprocessing |
|
||||||
| **astrai.dataset** | BaseDataset–GRPODataset, Store–MmapStore, StoreFactory, ResumableDistributedSampler, DatasetFactory | Dataset loading and management |
|
| **astrai.dataset** | BaseDataset–GRPODataset, Store–MmapStore, StoreFactory, ResumableDistributedSampler, DatasetFactory | Dataset loading and management |
|
||||||
| **astrai.serialization** | Checkpoint | Model serialization |
|
| **astrai.serialization** | Checkpoint | Model serialization |
|
||||||
| **astrai.model** | AutoModel, AutoRegressiveLM, EmbeddingEncoder, DecoderBlock, GQA, MLA, MLP, DeepSeekMoE, AttnFactory, FFNFactory, RMSNorm, Linear, RotaryEmbedding, Embedding | Neural network model |
|
| **astrai.model** | AutoModel, AutoRegressiveLM, EmbeddingEncoder, DecoderBlock, GQA, MLA, MLP, DeepSeekMoE, AttnFactory, FFNFactory, RMSNorm, Linear, RotaryEmbedding, Embedding | Neural network model |
|
||||||
| **astrai.tokenize** | AutoTokenizer, ChatTemplate | Tokenizer and chat template |
|
| **astrai.tokenize** | AutoTokenizer, ChatTemplate | Tokenizer and chat template |
|
||||||
| **astrai.trainer** | Trainer, TrainContext, TrainContextBuilder, BaseStrategy–GRPOStrategy, StrategyFactory, BaseScheduler–SGDRScheduler, SchedulerFactory, TrainCallback(Protocol)–ValidationCallback, CallbackFactory, Muon | Training workflow |
|
| **astrai.trainer** | Trainer, TrainContext, TrainContextBuilder, BaseStrategy–GRPOStrategy, StrategyFactory, BaseScheduler–WSDScheduler, SchedulerFactory, TrainCallback(Protocol)–ValidationCallback, CallbackFactory, Muon | Training workflow |
|
||||||
| **astrai.inference** | InferenceEngine, InferenceScheduler, Executor, KVCache–KvcacheView, Allocator–Storage, Task, TaskManager, TaskStatus, GenerationRequest, GenerateResult, BaseSamplingStrategy–SamplingPipeline, ProtocolHandler, ResponseBuilder, OpenAIResponseBuilder, AnthropicResponseBuilder, StopChecker, GenContext, ChatMessage–MessagesRequest, app | Inference service |
|
| **astrai.inference** | InferenceEngine, InferenceScheduler, Executor, KVCache–KvcacheView, Allocator–Storage, Task, TaskManager, TaskStatus, GenerationRequest, GenerateResult, BaseSamplingStrategy–SamplingPipeline, ProtocolHandler, ResponseBuilder, OpenAIResponseBuilder, AnthropicResponseBuilder, StopChecker, GenContext, ChatMessage–MessagesRequest, app | Inference service |
|
||||||
| **astrai.parallel** | spawn_parallel_fn, setup_parallel, get_rank/get_world_size/get_current_device, only_on_rank, BaseExecutor, ExecutorFactory, NoneExecutor, DDPExecutor, FSDPExecutor, GradientState, AccumOptimizer, AccumScheduler, ParallelModel, RowParallelLinear, ColumnParallelLinear | Distributed parallel & gradient accumulation |
|
| **astrai.parallel** | spawn_parallel_fn, setup_parallel, get_rank/get_world_size/get_current_device, only_on_rank, BaseExecutor, ExecutorFactory, NoneExecutor, DDPExecutor, FSDPExecutor, GradientState, AccumOptimizer, AccumScheduler, ParallelModel, RowParallelLinear, ColumnParallelLinear | Distributed parallel & gradient accumulation |
|
||||||
| **astrai.factory** | Registry, BaseFactory[T] | Component registration |
|
| **astrai.factory** | Registry, BaseFactory[T] | Component registration |
|
||||||
@@ -1062,7 +1182,7 @@ classDiagram
|
|||||||
| Pattern | Classes | Purpose |
|
| Pattern | Classes | Purpose |
|
||||||
|---------|---------|---------|
|
|---------|---------|---------|
|
||||||
| **Factory** | `AttnFactory`, `FFNFactory`, `StrategyFactory`, `DatasetFactory`, `SchedulerFactory`, `CallbackFactory`, `StoreFactory`, `ConfigFactory`, `ExecutorFactory` | Decorator-based component creation |
|
| **Factory** | `AttnFactory`, `FFNFactory`, `StrategyFactory`, `DatasetFactory`, `SchedulerFactory`, `CallbackFactory`, `StoreFactory`, `ConfigFactory`, `ExecutorFactory` | Decorator-based component creation |
|
||||||
| **Registry** | `BaseFactory`, `Registry` | Component registration with category/priority |
|
| **Registry** | `BaseFactory` | Component registration |
|
||||||
| **Strategy** | `SEQStrategy`, `SFTStrategy`, `DPOStrategy`, `GRPOStrategy` | Training strategy switching |
|
| **Strategy** | `SEQStrategy`, `SFTStrategy`, `DPOStrategy`, `GRPOStrategy` | Training strategy switching |
|
||||||
| **Strategy (Sampling)** | `TemperatureStrategy`, `TopKStrategy`, `TopPStrategy`, `SamplingPipeline` | Composable logit transformations |
|
| **Strategy (Sampling)** | `TemperatureStrategy`, `TopKStrategy`, `TopPStrategy`, `SamplingPipeline` | Composable logit transformations |
|
||||||
| **Strategy (API)** | `ResponseBuilder`, `OpenAIResponseBuilder`, `AnthropicResponseBuilder` | HTTP API handler with format hooks |
|
| **Strategy (API)** | `ResponseBuilder`, `OpenAIResponseBuilder`, `AnthropicResponseBuilder` | HTTP API handler with format hooks |
|
||||||
@@ -1070,14 +1190,14 @@ classDiagram
|
|||||||
| **Observer** | `TrainCallback`, callback implementations | Training process monitoring |
|
| **Observer** | `TrainCallback`, callback implementations | Training process monitoring |
|
||||||
| **Context** | `TrainContext` | Unified training state bag |
|
| **Context** | `TrainContext` | Unified training state bag |
|
||||||
| **Object Pool** | `Allocator`, `PagePool` | Page-based KV cache with LRU eviction |
|
| **Object Pool** | `Allocator`, `PagePool` | Page-based KV cache with LRU eviction |
|
||||||
| **Executor** | `BaseExecutor`, `NoneExecutor`, `DDPExecutor` | Gradient accumulation & model distribution |
|
| **Executor** | `BaseExecutor`, `NoneExecutor`, `DDPExecutor`, `FSDPExecutor` | Gradient accumulation & model distribution |
|
||||||
| **Storage** | `Store`, `H5Store`, `MmapStore` | Format-agnostic data access with multi-segment support |
|
| **Storage** | `Store`, `H5Store`, `MmapStore` | Format-agnostic data access with multi-segment support |
|
||||||
| **Producer-Consumer** | `InferenceScheduler`, `Task`, queues | Continuous batching |
|
| **Producer-Consumer** | `InferenceScheduler`, `Task`, queues | Continuous batching |
|
||||||
| **AutoModel Registry** | `AutoModel`, `AutoRegressiveLM`, `EmbeddingEncoder` | Model-type dynamic loading |
|
| **AutoModel Registry** | `AutoModel`, `AutoRegressiveLM`, `EmbeddingEncoder` | Model-type dynamic loading |
|
||||||
|
|
||||||
## Core Relationships
|
## Core Relationships
|
||||||
|
|
||||||
1. **Config → Training**: `TrainConfig` holds model, dataset, optimizer_fn, scheduler_fn, `parallel_mode`, `executor_kwargs`
|
1. **Config → Training**: `TrainConfig` holds `model_fn`, `dataset`, `optimizer_fn`, `scheduler_fn`, `parallel_mode`, `executor_kwargs`
|
||||||
2. **Training Flow**: `Trainer` → `TrainContextBuilder` → `TrainContext`, uses `BaseStrategy` for loss, `BaseExecutor` for gradient accumulation + model distribution
|
2. **Training Flow**: `Trainer` → `TrainContextBuilder` → `TrainContext`, uses `BaseStrategy` for loss, `BaseExecutor` for gradient accumulation + model distribution
|
||||||
3. **Strategy Selection**: `StrategyFactory` creates strategy by `train_type`
|
3. **Strategy Selection**: `StrategyFactory` creates strategy by `train_type`
|
||||||
4. **Executor Selection**: `ExecutorFactory.create(cfg.parallel_mode, grad_accum_steps=cfg.grad_accum_steps, **cfg.executor_kwargs)` → `NoneExecutor` / `DDPExecutor` / `FSDPExecutor`
|
4. **Executor Selection**: `ExecutorFactory.create(cfg.parallel_mode, grad_accum_steps=cfg.grad_accum_steps, **cfg.executor_kwargs)` → `NoneExecutor` / `DDPExecutor` / `FSDPExecutor`
|
||||||
@@ -1085,8 +1205,8 @@ classDiagram
|
|||||||
6. **Distributed**: `spawn_parallel_fn` + `setup_parallel` for multi-process DDP
|
6. **Distributed**: `spawn_parallel_fn` + `setup_parallel` for multi-process DDP
|
||||||
7. **Dataset Loading**: `DatasetFactory` creates datasets, `Store` (H5Store/MmapStore) loads data with explicit `_length` and multi-segment `_data`
|
7. **Dataset Loading**: `DatasetFactory` creates datasets, `Store` (H5Store/MmapStore) loads data with explicit `_length` and multi-segment `_data`
|
||||||
8. **Checkpoint**: `Checkpoint` saves/loads safetensors + metadata (rank-0 only), extra state saved as `{key}.pt`
|
8. **Checkpoint**: `Checkpoint` saves/loads safetensors + metadata (rank-0 only), extra state saved as `{key}.pt`
|
||||||
9. **Scheduler**: `SchedulerFactory` creates `CosineScheduler`/`SGDRScheduler`
|
9. **Scheduler**: `SchedulerFactory` creates `CosineScheduler`/`SGDRScheduler`/`WSDScheduler`
|
||||||
10. **AutoModel**: `from_pretrained()` loads `config.json` + `model.safetensors`, `_disable_random_init` replaces `nn.init.*` with no-ops
|
10. **AutoModel**: `from_pretrained()` loads `config.json` + `model.safetensors`, `_disable_random_init` replaces `nn.init.*` with no-ops
|
||||||
11. **Protocols**: `OptimizerProtocol` / `SchedulerProtocol` — structural subtyping for `AccumOptimizer` / `AccumScheduler` wrappers
|
11. **Protocols**: `OptimizerProtocol` / `SchedulerProtocol` — structural subtyping for `AccumOptimizer` / `AccumScheduler` wrappers
|
||||||
|
|
||||||
> Document Update Time: 2026-05-28
|
> Document Update Time: 2026-05-30
|
||||||
|
|||||||
+61
-9
@@ -1,17 +1,58 @@
|
|||||||
# Data Flow
|
# Data Flow
|
||||||
|
|
||||||
This document describes the data pipeline: from raw text to model input tensors.
|
This document describes the data pipeline: from raw text to model input tensors. For creating preprocessing configs, see [Preprocessing Guide](preprocessing.md).
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
- [Overview](#overview)
|
||||||
|
- [Data Preparation](#data-preparation) — tokenization, format detection, backends
|
||||||
|
- [Data Keys by Training Type](#data-keys-by-training-type)
|
||||||
|
- [Dataset Architecture](#dataset-architecture)
|
||||||
|
- [Sampler](#sampler)
|
||||||
|
- [DataLoader](#dataloader)
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
```
|
```
|
||||||
Raw Text → AutoTokenizer → Token IDs → .h5/.bin → Dataset → Sampler → DataLoader → Training/Inference
|
JSONL Lines → Pipeline (mask builder) → Tokenized Tensors
|
||||||
|
↓
|
||||||
|
.h5 or .bin storage
|
||||||
|
↓
|
||||||
|
Store.load()
|
||||||
|
↓
|
||||||
|
Store.fetch(begin, end, keys)
|
||||||
|
↓
|
||||||
|
BaseDataset.__getitem__(idx)
|
||||||
|
↓
|
||||||
|
Sampler → DataLoader → Training / Inference
|
||||||
```
|
```
|
||||||
|
|
||||||
## Data Preparation
|
## Data Preparation
|
||||||
|
|
||||||
Raw text is tokenized via `AutoTokenizer.encode()` and saved as HDF5 (`.h5`) or binary (`.bin` + `meta.json`) files with keyed tensor groups.
|
Raw text is tokenized via `AutoTokenizer.encode()` and saved as HDF5 (`.h5`) or binary (`.bin` + `meta.json`) files with keyed tensor groups.
|
||||||
|
|
||||||
|
### Tokenization
|
||||||
|
|
||||||
|
The `Pipeline` reads JSONL lines, applies the mask builder (see [Preprocessing](preprocessing.md)), and produces flat token sequences:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Per JSONL line: messages → chat template → token IDs + loss mask
|
||||||
|
tokens = tokenizer.encode(rendered_text) # List[int]
|
||||||
|
loss_mask = [0, 0, 0, 1, 1, 1, 1, 1, 1] # 0=masked, 1=train
|
||||||
|
# Stored as flat tensors, packed with other lines by packing strategy
|
||||||
|
```
|
||||||
|
|
||||||
|
The output `meta.json` records the storage format, key names, dtype, total token count, and tensor shapes for each shard.
|
||||||
|
|
||||||
|
### Format Detection
|
||||||
|
|
||||||
|
`detect_format(load_path)` inspects the directory:
|
||||||
|
|
||||||
|
- If `*.h5` files exist → `"h5"` (HDF5 backend)
|
||||||
|
- If `*.bin` + `meta.json` files exist → `"bin"` (memory-mapped backend)
|
||||||
|
|
||||||
|
### Store Backends
|
||||||
|
|
||||||
Storage format is auto-detected by `detect_format()`; backends are dispatched via registry:
|
Storage format is auto-detected by `detect_format()`; backends are dispatched via registry:
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -19,28 +60,39 @@ StoreFactory.create("h5") → H5Store
|
|||||||
StoreFactory.create("bin") → MmapStore
|
StoreFactory.create("bin") → MmapStore
|
||||||
```
|
```
|
||||||
|
|
||||||
H5 backend supports shared memory via `.share_memory_()`. Bin (mmap) uses OS page-cache sharing natively.
|
**H5Store**: Reads HDF5 files, supports `share_memory_()` for multi-process DataLoader workers (copies tensors to shared memory).
|
||||||
|
|
||||||
|
**MmapStore**: Memory-maps `.bin` files. OS page cache sharing is native — no explicit `share_memory_()` needed. Uses `torch.from_numpy(np.memmap(...))`.
|
||||||
|
|
||||||
|
Both backends normalise tensors into `Store._data[Dict[str, List[Tensor]]]` + `Store._cum[Dict[str, List[int]]]` (cumulative lengths for bisect-based indexing).
|
||||||
|
|
||||||
## Data Keys by Training Type
|
## Data Keys by Training Type
|
||||||
|
|
||||||
| Type | Storage Keys |
|
| Type | Storage Keys |
|
||||||
|------|-------------|
|
|------|-------------|
|
||||||
| `seq` | `sequence` (→ input_ids, target_ids via offset-by-1) |
|
| `seq` | `sequence` (→ input_ids, target_ids via offset-by-1) |
|
||||||
| `sft` | `sequence`, `loss_mask` |
|
| `sft` | `sequence`, `loss_mask`, `position_ids` |
|
||||||
| `dpo` | `chosen`, `rejected`, `chosen_mask`, `rejected_mask` |
|
| `dpo` | `chosen`, `rejected`, `chosen_mask`, `rejected_mask` |
|
||||||
| `grpo` | `prompts`, `responses`, `masks`, `rewards` |
|
| `grpo` | `prompts`, `responses`, `masks`, `rewards` |
|
||||||
|
|
||||||
## Dataset Architecture
|
## Dataset Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
DatasetFactory.load(train_type, load_path, window_size, stride, storage_type)
|
DatasetFactory.load(train_type, load_path, window_size, stride=None, storage_type=None)
|
||||||
→ StoreFactory.create(detect_format(path))
|
→ BaseDataset.load(load_path, storage_type=None)
|
||||||
|
→ detect_format(load_path)
|
||||||
|
→ StoreFactory.create(storage_type)
|
||||||
|
→ Store.load(load_path)
|
||||||
|
→ H5Store._normalize() / MmapStore._normalize()
|
||||||
→ Store._data[Dict[str, List[Tensor]]] + _cum[Dict[str, List[int]]]
|
→ Store._data[Dict[str, List[Tensor]]] + _cum[Dict[str, List[int]]]
|
||||||
→ BaseDataset.__getitem__(idx)
|
→ BaseDataset.__getitem__(idx)
|
||||||
→ sliding window [begin, end) via get_index(idx)
|
→ get_index(idx) → [begin, end)
|
||||||
|
→ Store.fetch(begin, end, keys) → Tensor / Dict[str, Tensor]
|
||||||
```
|
```
|
||||||
|
|
||||||
`window_size` = max input length, `stride` = step between consecutive samples (defaults to `window_size`).
|
`window_size` = max input length, `stride` = step between consecutive samples (defaults to `window_size`, optional). `storage_type` defaults to `None` (auto-detect via `detect_format`).
|
||||||
|
|
||||||
|
`Store.fetch(begin, end, keys)` accepts a single key (`str`) returning a `Tensor`, or a list of keys returning `Dict[str, Tensor]`. Internally uses `bisect` across multi-segment tensors. Raises `RuntimeError("Store not loaded")` if called before `load()`.
|
||||||
|
|
||||||
## Sampler
|
## Sampler
|
||||||
|
|
||||||
@@ -54,4 +106,4 @@ DatasetFactory.load(train_type, load_path, window_size, stride, storage_type)
|
|||||||
|
|
||||||
Standard PyTorch `DataLoader` with configurable `batch_size`, `num_workers`, `pin_memory`, `prefetch_factor`. Sampler produces indices; dataloader fetches tensor batches via `__getitem__`.
|
Standard PyTorch `DataLoader` with configurable `batch_size`, `num_workers`, `pin_memory`, `prefetch_factor`. Sampler produces indices; dataloader fetches tensor batches via `__getitem__`.
|
||||||
|
|
||||||
> Document Update Time: 2026-05-28
|
> Document Update Time: 2026-06-19
|
||||||
|
|||||||
+108
-7
@@ -1,5 +1,16 @@
|
|||||||
# Inference
|
# Inference
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
- [KV Cache](#kv-cache)
|
||||||
|
- [KVCache System](#kvcache-system)
|
||||||
|
- [Continuous Batching](#continuous-batching)
|
||||||
|
- [Sampling](#sampling-strategy-pattern)
|
||||||
|
- [Protocol Handlers](#protocol-handlers-strategy-pattern)
|
||||||
|
- [Engine & GenerateResult](#engine--generateresult)
|
||||||
|
- [HTTP API](#http-api) — endpoints, SSE, errors, stats
|
||||||
|
- [Engine API](#engine-api)
|
||||||
|
|
||||||
## KV Cache
|
## KV Cache
|
||||||
|
|
||||||
At decode time, only the last query token matters. All previous K/V are cached to avoid recomputation:
|
At decode time, only the last query token matters. All previous K/V are cached to avoid recomputation:
|
||||||
@@ -12,7 +23,7 @@ RoPE is applied **before** KV cache write, not after — otherwise position enco
|
|||||||
|
|
||||||
## KVCache System
|
## KVCache System
|
||||||
|
|
||||||
Six classes working together:
|
Six classes (plus two helpers) working together:
|
||||||
|
|
||||||
```
|
```
|
||||||
KVCache (facade)
|
KVCache (facade)
|
||||||
@@ -43,7 +54,8 @@ KVCache (facade)
|
|||||||
BaseSamplingStrategy (ABC)
|
BaseSamplingStrategy (ABC)
|
||||||
├── TemperatureStrategy
|
├── TemperatureStrategy
|
||||||
├── TopKStrategy
|
├── TopKStrategy
|
||||||
└── TopPStrategy
|
├── TopPStrategy
|
||||||
|
└── SamplingPipeline
|
||||||
```
|
```
|
||||||
|
|
||||||
`SamplingPipeline` composes them: Temperature → Top-K → Top-P → softmax → multinomial.
|
`SamplingPipeline` composes them: Temperature → Top-K → Top-P → softmax → multinomial.
|
||||||
@@ -73,7 +85,9 @@ Adding a protocol = one builder file, no handler subclassing needed.
|
|||||||
InferenceEngine
|
InferenceEngine
|
||||||
├── generate(prompt, stream, ...) → str | List[str] | Generator
|
├── generate(prompt, stream, ...) → str | List[str] | Generator
|
||||||
├── generate_with_request(req) → same
|
├── generate_with_request(req) → same
|
||||||
└── generate_async(prompt, ...) → AsyncGenerator
|
├── generate_async(prompt, ...) → AsyncGenerator
|
||||||
|
├── get_stats() → Dict
|
||||||
|
└── shutdown()
|
||||||
```
|
```
|
||||||
|
|
||||||
`GenerateResult` uses `Condition` for non-streaming (`wait_completion()`) and `Event` for streaming (`wait()`). Stream callback is `cb(token)`.
|
`GenerateResult` uses `Condition` for non-streaming (`wait_completion()`) and `Event` for streaming (`wait()`). Stream callback is `cb(token)`.
|
||||||
@@ -124,12 +138,98 @@ Supports `stop_sequences` and streaming via `event: content_block_delta`.
|
|||||||
| Param | Type | Default | Description |
|
| Param | Type | Default | Description |
|
||||||
|-------|------|---------|-------------|
|
|-------|------|---------|-------------|
|
||||||
| `messages` | List[dict] | required | Chat messages (role, content) |
|
| `messages` | List[dict] | required | Chat messages (role, content) |
|
||||||
| `temperature` | float | 1.0 | Sampling temperature (>= 0.0) |
|
|
||||||
| `top_p` | float | 1.0 | Nucleus threshold |
|
|
||||||
| `top_k` | int | 50 | Top-k count |
|
| `top_k` | int | 50 | Top-k count |
|
||||||
|
| `top_p` | float | 1.0 | Nucleus threshold |
|
||||||
|
| `temperature` | float | 1.0 | Sampling temperature (> 0.0) |
|
||||||
| `max_tokens` | Optional[int] | None | Max generation length |
|
| `max_tokens` | Optional[int] | None | Max generation length |
|
||||||
| `stream` | bool | False | Stream output |
|
| `stream` | bool | False | Stream output |
|
||||||
|
|
||||||
|
### SSE Streaming Format
|
||||||
|
|
||||||
|
**OpenAI** (`/v1/chat/completions`, `stream=true`):
|
||||||
|
|
||||||
|
```
|
||||||
|
data: {"id":"chatcmpl-...","object":"chat.completion.chunk","created":...,"model":"astrai",
|
||||||
|
"choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-...","object":"chat.completion.chunk",...,
|
||||||
|
"choices":[{"index":0,"delta":{"content":"Hello"},"finish_reason":null}]}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-...","object":"chat.completion.chunk",...,
|
||||||
|
"choices":[{"index":0,"delta":{},"finish_reason":"stop"}],
|
||||||
|
"usage":{"prompt_tokens":5,"completion_tokens":1,"total_tokens":6}}
|
||||||
|
|
||||||
|
data: [DONE]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Anthropic** (`/v1/messages`, `stream=true`):
|
||||||
|
|
||||||
|
```
|
||||||
|
event: message_start
|
||||||
|
data: {"type":"message_start","message":{"id":"msg_...","model":"astrai","role":"assistant",
|
||||||
|
"content":[],"stop_reason":null,...}}
|
||||||
|
|
||||||
|
event: content_block_start
|
||||||
|
data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}
|
||||||
|
|
||||||
|
event: content_block_delta
|
||||||
|
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}
|
||||||
|
|
||||||
|
event: content_block_stop
|
||||||
|
data: {"type":"content_block_stop","index":0}
|
||||||
|
|
||||||
|
event: message_delta
|
||||||
|
data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{...}}
|
||||||
|
|
||||||
|
event: message_stop
|
||||||
|
data: {"type":"message_stop"}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error Responses
|
||||||
|
|
||||||
|
All endpoints use standard HTTP status codes:
|
||||||
|
|
||||||
|
| Status | Meaning |
|
||||||
|
|--------|---------|
|
||||||
|
| 200 | Success |
|
||||||
|
| 400 | Invalid request (bad JSON, missing fields, validation error) |
|
||||||
|
| 405 | Method not allowed |
|
||||||
|
| 422 | Unprocessable entity (Pydantic validation) |
|
||||||
|
| 500 | Internal server error (model crash, OOM, scheduler failure) |
|
||||||
|
| 503 | Service unavailable (model not loaded, engine not ready) |
|
||||||
|
|
||||||
|
Error response body:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": {
|
||||||
|
"message": "Invalid request: max_tokens must be > 0",
|
||||||
|
"type": "invalid_request_error",
|
||||||
|
"code": 400
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stats Endpoint
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /stats
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"active_requests": 3,
|
||||||
|
"waiting_requests": 2,
|
||||||
|
"total_requests": 128,
|
||||||
|
"cache_usage": 0.45,
|
||||||
|
"tokens_generated": 10240
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`cache_usage` is the fraction of KV cache pages currently in use (0.0–1.0).
|
||||||
|
|
||||||
## Engine API
|
## Engine API
|
||||||
|
|
||||||
```python
|
```python
|
||||||
@@ -142,7 +242,8 @@ engine.generate("Hello", stream=True) # -> Generator[str]
|
|||||||
engine.generate(["A", "B"], stream=True) # -> Generator[Tuple[int, str]]
|
engine.generate(["A", "B"], stream=True) # -> Generator[Tuple[int, str]]
|
||||||
|
|
||||||
# Async
|
# Async
|
||||||
await engine.generate_async("Hello", ...) # -> AsyncGenerator[str]
|
async for token in engine.generate_async("Hello", ...): # -> AsyncGenerator[str]
|
||||||
|
print(token)
|
||||||
```
|
```
|
||||||
|
|
||||||
> Document Update Time: 2026-05-28
|
> Document Update Time: 2026-06-19
|
||||||
|
|||||||
+96
-3
@@ -1,4 +1,11 @@
|
|||||||
# Parameter Documentation
|
# CLI Parameter Reference
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
- [Training Parameters](#training-parameters)
|
||||||
|
- [Inference Server](#inference-server-serverpy)
|
||||||
|
- [Generate](#generate-generatepy)
|
||||||
|
- [Preprocess](#preprocess-preprocesspy)
|
||||||
|
|
||||||
## Training Parameters
|
## Training Parameters
|
||||||
|
|
||||||
@@ -48,6 +55,27 @@
|
|||||||
| `--start_epoch` | Resume from epoch (0 = from scratch) | 0 |
|
| `--start_epoch` | Resume from epoch (0 = from scratch) | 0 |
|
||||||
| `--start_batch` | Resume from batch iteration | 0 |
|
| `--start_batch` | Resume from batch iteration | 0 |
|
||||||
|
|
||||||
|
### Validation
|
||||||
|
|
||||||
|
| Parameter | Description | Default |
|
||||||
|
|-----------|-------------|---------|
|
||||||
|
| `--val_split` | Ratio to split from training dataset for validation (e.g. 0.05) | None |
|
||||||
|
| `--val_step` | Number of optimizer steps between validation runs | 1000 |
|
||||||
|
|
||||||
|
### Logging
|
||||||
|
|
||||||
|
| Parameter | Description | Default |
|
||||||
|
|-----------|-------------|---------|
|
||||||
|
| `--log_dir` | Directory for metric logs | checkpoint/logs |
|
||||||
|
| `--log_interval` | Number of batch iterations between metric logs | 100 |
|
||||||
|
| `--metrics` | Metrics to log (e.g. --metrics loss lr val_loss) | ["loss", "lr"] |
|
||||||
|
|
||||||
|
### Gradient Checkpointing
|
||||||
|
|
||||||
|
| Parameter | Description | Default |
|
||||||
|
|-----------|-------------|---------|
|
||||||
|
| `--gradient_checkpointing` | Enable activation checkpointing for DecoderBlock modules | False |
|
||||||
|
|
||||||
### Distributed Training
|
### Distributed Training
|
||||||
|
|
||||||
| Parameter | Description | Default |
|
| Parameter | Description | Default |
|
||||||
@@ -56,17 +84,21 @@
|
|||||||
| `--parallel_mode` | Parallel strategy (`none`, `ddp`, or `fsdp`) | none |
|
| `--parallel_mode` | Parallel strategy (`none`, `ddp`, or `fsdp`) | none |
|
||||||
| `--device_type` | Device type | cuda |
|
| `--device_type` | Device type | cuda |
|
||||||
| `--start_method` | Multiprocessing start method (`spawn`, `fork`, `forkserver`) | spawn |
|
| `--start_method` | Multiprocessing start method (`spawn`, `fork`, `forkserver`) | spawn |
|
||||||
|
| `--backend` | Distributed training backend | nccl |
|
||||||
|
| `--master_addr` | Master node address | localhost |
|
||||||
|
| `--master_port` | Master node port | 29500 |
|
||||||
|
|
||||||
### Strategy-specific
|
### Strategy-specific
|
||||||
|
|
||||||
| Parameter | Description | Default | Used by |
|
| Parameter | Description | Default | Used by |
|
||||||
|-----------|-------------|---------|---------|
|
|-----------|-------------|---------|---------|
|
||||||
| `--dpo_beta` | DPO beta value | 0.1 | `dpo` |
|
| `--dpo_beta` | DPO beta value | 0.1 | `dpo` |
|
||||||
| `--label_smoothing` | Label smoothing for cross-entropy loss | 0.05 | `seq`, `sft` |
|
| `--label_smoothing` | Label smoothing for cross-entropy loss | 0.0 | `seq`, `sft` |
|
||||||
| `--group_size` | GRPO group size | 4 | `grpo` |
|
| `--group_size` | GRPO group size | 4 | `grpo` |
|
||||||
| `--grpo_clip_eps` | GRPO clipping epsilon | 0.2 | `grpo` |
|
| `--grpo_clip_eps` | GRPO clipping epsilon | 0.2 | `grpo` |
|
||||||
| `--grpo_kl_coef` | GRPO KL penalty coefficient | 0.01 | `grpo` |
|
| `--grpo_kl_coef` | GRPO KL penalty coefficient | 0.01 | `grpo` |
|
||||||
| `--grpo_sync_interval` | GRPO ref_model sync interval (steps) | 200 | `grpo` |
|
| `--grpo_sync_interval` | GRPO ref_model sync interval (steps) | 200 | `grpo` |
|
||||||
|
| `--neftune_alpha` | NEFTune noise alpha (0=disabled, typical: 5.0) | 0.0 | `sft` |
|
||||||
|
|
||||||
### Usage Example
|
### Usage Example
|
||||||
|
|
||||||
@@ -75,6 +107,7 @@ export CUDA_VISIBLE_DEVICES=0,1,2,3
|
|||||||
|
|
||||||
nohup python scripts/tools/train.py \
|
nohup python scripts/tools/train.py \
|
||||||
--nprocs=4 \
|
--nprocs=4 \
|
||||||
|
--parallel_mode=ddp \
|
||||||
--train_type=seq \
|
--train_type=seq \
|
||||||
--data_root_path=/path/to/dataset \
|
--data_root_path=/path/to/dataset \
|
||||||
--param_path=/path/to/model \
|
--param_path=/path/to/model \
|
||||||
@@ -96,4 +129,64 @@ nohup python scripts/tools/train.py \
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
> Document Update Time: 2026-05-24
|
## Inference Server (`server.py`)
|
||||||
|
|
||||||
|
| Parameter | Type | Default | Description |
|
||||||
|
|-----------|------|---------|-------------|
|
||||||
|
| `--host` | str | `0.0.0.0` | Host address |
|
||||||
|
| `--port` | int | `8000` | Port number |
|
||||||
|
| `--param_path` | path | `project_root/params` | Path to model parameters |
|
||||||
|
| `--device` | str | `cuda` | Device to load model on |
|
||||||
|
| `--dtype` | str | `bfloat16` | Model weights dtype (`bfloat16`, `float16`, `float32`) |
|
||||||
|
| `--max_batch_size` | int | `16` | Maximum batch size for continuous batching |
|
||||||
|
| `--reload` | flag | `False` | Enable auto-reload for development |
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
```bash
|
||||||
|
python scripts/tools/server.py --param_path ./params --device cuda --dtype bfloat16
|
||||||
|
```
|
||||||
|
|
||||||
|
See [Inference Guide](inference.md) for HTTP API documentation.
|
||||||
|
|
||||||
|
## Generate (`generate.py`)
|
||||||
|
|
||||||
|
| Parameter | Type | Default | Description |
|
||||||
|
|-----------|------|---------|-------------|
|
||||||
|
| `--param_path` | str | required | Path to the model directory |
|
||||||
|
| `--input_json_file` | str | required | Path to the input JSONL file |
|
||||||
|
| `--output_json_file` | str | required | Path to the output JSONL file |
|
||||||
|
| `--question_key` | str | `question` | Key for the question in input JSON |
|
||||||
|
| `--response_key` | str | `response` | Key for the response in output JSON |
|
||||||
|
| `--temperature` | float | `0.60` | Sampling temperature |
|
||||||
|
| `--top_k` | int | `30` | Top-k filtering |
|
||||||
|
| `--top_p` | float | `0.95` | Nucleus sampling threshold |
|
||||||
|
| `--batch_size` | int | `1` | Batch size for generation |
|
||||||
|
| `--max_tokens` | int | model config `max_len` | Maximum tokens to generate |
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
```bash
|
||||||
|
python scripts/tools/generate.py \
|
||||||
|
--param_path ./params \
|
||||||
|
--input_json_file input.jsonl \
|
||||||
|
--output_json_file output.jsonl
|
||||||
|
```
|
||||||
|
|
||||||
|
## Preprocess (`preprocess.py`)
|
||||||
|
|
||||||
|
| Parameter | Type | Default | Description |
|
||||||
|
|-----------|------|---------|-------------|
|
||||||
|
| `input_files` | path(s) | required | Input JSONL file(s), supports glob (`data/*.jsonl`) |
|
||||||
|
| `--output_dir`, `-o` | path | required | Output directory for processed data |
|
||||||
|
| `--config`, `-c` | path | required | Preprocessing pipeline config (JSON) |
|
||||||
|
| `--num_workers` | int | `4` | Number of parallel workers |
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
```bash
|
||||||
|
python scripts/tools/preprocess.py data/*.jsonl -o output/ -c sft.json
|
||||||
|
```
|
||||||
|
|
||||||
|
See [Preprocessing Guide](preprocessing.md) for config file format and examples.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> Document Update Time: 2026-06-19
|
||||||
@@ -0,0 +1,361 @@
|
|||||||
|
# Preprocessing Pipeline
|
||||||
|
|
||||||
|
Declarative JSON-driven data preprocessing. One `SectionedMaskBuilder` handles all formats via `input.sections` (single-output) or `input.sources` (multi-output).
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
- [Philosophy](#philosophy)
|
||||||
|
- [Config Structure](#config-structure)
|
||||||
|
- [Quick Start](#quick-start) — SFT Chat, SFT Instruction, Pretrain, DPO, GRPO examples
|
||||||
|
- [Configuration Reference](#configuration-reference) — all fields
|
||||||
|
- [Mask Algorithm](#mask-algorithm)
|
||||||
|
- [Output Layout](#output-layout)
|
||||||
|
- [CLI](#cli)
|
||||||
|
- [Python API](#python-api)
|
||||||
|
|
||||||
|
## Philosophy
|
||||||
|
|
||||||
|
| Component | Responsibility |
|
||||||
|
|-----------|---------------|
|
||||||
|
| `tokenizer_config.json` (`chat_template`) | Formatting -- how roles become tokens |
|
||||||
|
| `pipeline.json` (`mask`) | Masking -- which roles participate in training |
|
||||||
|
|
||||||
|
A single config file captures the entire pipeline, reusable and version-controllable.
|
||||||
|
|
||||||
|
## Config Structure
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"input": {}, // sections (single) or sources (multi)
|
||||||
|
"mask": {}, // role → "train" | "mask"
|
||||||
|
"mask_default": "mask",
|
||||||
|
"preprocessing": {},
|
||||||
|
"output": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Section Fields
|
||||||
|
|
||||||
|
| Field | Type | Default | Description |
|
||||||
|
|-------|------|---------|-------------|
|
||||||
|
| `field` | str | -- | JSONL key to read |
|
||||||
|
| `action` | str | -- | `"train"` / `"mask"` / `"$role"` |
|
||||||
|
| `template` | bool | `false` | Apply `chat_template` per message |
|
||||||
|
| `add_special_tokens` | bool | `true` for first non-template section | Add special tokens during encode |
|
||||||
|
|
||||||
|
### Source Fields (multi-output mode)
|
||||||
|
|
||||||
|
| Field | Type | Default | Description |
|
||||||
|
|-------|------|---------|-------------|
|
||||||
|
| `sections` | list[dict] | -- | Same as single-output section list |
|
||||||
|
| `list_field` | bool | `false` | JSONL field holds a list; tokenise each element |
|
||||||
|
| `mask_key` | str | `"{key}_mask"` | Explicit output key for loss mask |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### SFT Chat
|
||||||
|
|
||||||
|
Input JSONL:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"messages": [{"role": "system", "content": "You are helpful."}, {"role": "user", "content": "Hi"}, {"role": "assistant", "content": "Hello!"}]}
|
||||||
|
```
|
||||||
|
|
||||||
|
Config:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"input": {
|
||||||
|
"sections": [
|
||||||
|
{"field": "messages", "action": "$role", "template": true}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"mask": {
|
||||||
|
"system": "mask",
|
||||||
|
"user": "mask",
|
||||||
|
"assistant": "train"
|
||||||
|
},
|
||||||
|
"mask_default": "mask",
|
||||||
|
"preprocessing": {
|
||||||
|
"max_seq_len": 2048
|
||||||
|
},
|
||||||
|
"output": {
|
||||||
|
"storage_format": "bin",
|
||||||
|
"dtype": {"loss_mask": "bool"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Output keys: `sequence` (int32), `loss_mask` (bool)
|
||||||
|
|
||||||
|
### SFT Instruction
|
||||||
|
|
||||||
|
Input JSONL:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"prompt": "Translate to French: Hello", "response": "Bonjour"}
|
||||||
|
```
|
||||||
|
|
||||||
|
Config:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"input": {
|
||||||
|
"sections": [
|
||||||
|
{"field": "prompt", "action": "mask", "add_special_tokens": true},
|
||||||
|
{"field": "response", "action": "train"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"mask_default": "mask",
|
||||||
|
"preprocessing": {
|
||||||
|
"max_seq_len": 2048
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Output keys: `sequence`, `loss_mask`
|
||||||
|
|
||||||
|
### Pretrain
|
||||||
|
|
||||||
|
Input JSONL:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"text": "Artificial Intelligence is a field of computer science..."}
|
||||||
|
```
|
||||||
|
|
||||||
|
Config:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"input": {
|
||||||
|
"sections": [
|
||||||
|
{"field": "text", "action": "train"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"preprocessing": {
|
||||||
|
"max_seq_len": 8192,
|
||||||
|
"min_chars": 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Output keys: `sequence` (no `loss_mask` — all tokens trained)
|
||||||
|
|
||||||
|
### DPO
|
||||||
|
|
||||||
|
Input JSONL:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"chosen": [{"role": "user", "content": "What is 2+2?"}, {"role": "assistant", "content": "4"}], "rejected": [{"role": "user", "content": "What is 2+2?"}, {"role": "assistant", "content": "5"}]}
|
||||||
|
```
|
||||||
|
|
||||||
|
Config:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"input": {
|
||||||
|
"sources": {
|
||||||
|
"chosen": {
|
||||||
|
"sections": [
|
||||||
|
{"field": "chosen", "action": "$role", "template": true}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"rejected": {
|
||||||
|
"sections": [
|
||||||
|
{"field": "rejected", "action": "$role", "template": true}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mask": {
|
||||||
|
"user": "mask",
|
||||||
|
"assistant": "train"
|
||||||
|
},
|
||||||
|
"mask_default": "mask"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Output keys: `chosen`, `chosen_mask`, `rejected`, `rejected_mask`
|
||||||
|
|
||||||
|
### GRPO
|
||||||
|
|
||||||
|
Input JSONL:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"prompt": [{"role": "user", "content": "What is 2+2?"}], "responses": ["4", "Five", "Four"], "rewards": [1.0, 0.3, 0.8]}
|
||||||
|
```
|
||||||
|
|
||||||
|
Config:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"input": {
|
||||||
|
"sources": {
|
||||||
|
"prompts": {
|
||||||
|
"sections": [
|
||||||
|
{"field": "prompt", "action": "mask", "template": true}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"sections": [
|
||||||
|
{"field": "responses", "action": "train"}
|
||||||
|
],
|
||||||
|
"list_field": true,
|
||||||
|
"mask_key": "masks"
|
||||||
|
},
|
||||||
|
"rewards": {
|
||||||
|
"sections": [
|
||||||
|
{"field": "rewards", "action": "value"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mask": {
|
||||||
|
"user": "mask",
|
||||||
|
"assistant": "train"
|
||||||
|
},
|
||||||
|
"mask_default": "mask"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Output keys: `prompts`, `responses`, `masks`, `rewards` (float32)
|
||||||
|
|
||||||
|
- `action: "value"` — extract raw values from JSONL without tokenisation
|
||||||
|
- `list_field: true` — tokenise each list element independently, then concatenate
|
||||||
|
- `mask_key: "masks"` — rename the auto-generated mask key (default: `responses_mask`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration Reference
|
||||||
|
|
||||||
|
### `input`
|
||||||
|
|
||||||
|
| Field | Type | Default | Description |
|
||||||
|
|-------|------|---------|-------------|
|
||||||
|
| `sections` | list[dict] or null | `null` | Section specs for single-output mode |
|
||||||
|
| `sources` | dict[str, dict] or null | `null` | Source specs for multi-output mode (DPO/GRPO) |
|
||||||
|
|
||||||
|
When `sources` is set, `sections` is ignored.
|
||||||
|
|
||||||
|
### `mask`
|
||||||
|
|
||||||
|
| Field | Type | Default | Description |
|
||||||
|
|-------|------|---------|-------------|
|
||||||
|
| `mask` | dict | `{}` | `{role: "train" \| "mask"}` |
|
||||||
|
| `mask_default` | str | `"mask"` | Default action for unlisted roles |
|
||||||
|
|
||||||
|
### `preprocessing`
|
||||||
|
|
||||||
|
| Field | Type | Default | Description |
|
||||||
|
|-------|------|---------|-------------|
|
||||||
|
| `max_seq_len` | int | `2048` | Truncate sequences to this length |
|
||||||
|
| `min_chars` | int | `50` | Skip text-mode items shorter than this |
|
||||||
|
| `max_chars` | int | `2000000` | Skip text-mode items longer than this |
|
||||||
|
| `max_items` | int or null | `null` | Stop after N documents |
|
||||||
|
| `packing_strategy` | str | `"simple"` | Packing strategy: `"simple"`, `"bfd"`, `"bfd_split"` |
|
||||||
|
| `max_packed_len` | int | `8192` | Maximum length of a packed bin |
|
||||||
|
| `truncation_mode` | str | `"keep_start"` | How to truncate sequences: `"keep_start"` or `"keep_end"` |
|
||||||
|
|
||||||
|
### `output`
|
||||||
|
|
||||||
|
| Field | Type | Default | Description |
|
||||||
|
|-------|------|---------|-------------|
|
||||||
|
| `domain_key` | str or null | `null` | JSONL key for domain grouping |
|
||||||
|
| `storage_format` | str | `"bin"` | `"bin"` (mmap) or `"h5"` |
|
||||||
|
| `max_tokens_per_shard` | int | `100000000` | Flush threshold in cumulative tokens |
|
||||||
|
| `dtype` | dict[str, str] | `{}` | Per-key tensor dtype override (e.g. `{"loss_mask": "bool"}`) |
|
||||||
|
| `position_ids_mode` | str | `"none"` | How to compute position_ids: `"none"`, `"doc_reset"`, `"continuous"` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mask Algorithm
|
||||||
|
|
||||||
|
### Template mode (`template: true`)
|
||||||
|
|
||||||
|
For each message in the field's array:
|
||||||
|
|
||||||
|
1. Prepend BOS token (masked)
|
||||||
|
2. Render through `chat_template` for that single message
|
||||||
|
3. Encode rendered text
|
||||||
|
4. Apply mask rule for the message's role
|
||||||
|
|
||||||
|
### Non-template mode
|
||||||
|
|
||||||
|
Encode the field value as text. Mask value is 1 (train) or 0 (mask) per the section's `action`.
|
||||||
|
|
||||||
|
### Text config detection
|
||||||
|
|
||||||
|
When no section uses `template` and all sections have `action: "train"`, the builder skips mask generation entirely — all tokens are trained.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Output Layout
|
||||||
|
|
||||||
|
### Single-Shard (`bin`)
|
||||||
|
|
||||||
|
```
|
||||||
|
output/
|
||||||
|
__default__/
|
||||||
|
meta.json
|
||||||
|
sequence.bin
|
||||||
|
loss_mask.bin
|
||||||
|
wiki/
|
||||||
|
meta.json
|
||||||
|
sequence.bin
|
||||||
|
loss_mask.bin
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multi-Shard (`bin`)
|
||||||
|
|
||||||
|
When `max_tokens_per_shard` is exceeded:
|
||||||
|
|
||||||
|
```
|
||||||
|
output/
|
||||||
|
__default__/
|
||||||
|
shard_0000/
|
||||||
|
meta.json
|
||||||
|
sequence.bin
|
||||||
|
loss_mask.bin
|
||||||
|
shard_0001/
|
||||||
|
meta.json
|
||||||
|
sequence.bin
|
||||||
|
loss_mask.bin
|
||||||
|
```
|
||||||
|
|
||||||
|
`MmapStore` discovers all shards under the domain directory via `rglob("meta.json")`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CLI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# SFT
|
||||||
|
python scripts/tools/preprocess.py data/sft/*.jsonl -o output/sft/ -c configs/sft_chat.json
|
||||||
|
|
||||||
|
# DPO
|
||||||
|
python scripts/tools/preprocess.py data/dpo/*.jsonl -o output/dpo/ -c configs/dpo.json --tokenizer_path params
|
||||||
|
|
||||||
|
# GRPO
|
||||||
|
python scripts/tools/preprocess.py data/grpo/*.jsonl -o output/grpo/ -c configs/grpo.json
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Python API
|
||||||
|
|
||||||
|
```python
|
||||||
|
from astrai.preprocessing.pipeline import Pipeline
|
||||||
|
from astrai.config.preprocess_config import PipelineConfig
|
||||||
|
|
||||||
|
config = PipelineConfig.from_json("sft.json")
|
||||||
|
Pipeline(
|
||||||
|
config,
|
||||||
|
["data_part1.jsonl", "data_part2.jsonl"],
|
||||||
|
output_dir="output/",
|
||||||
|
tokenizer_path="params",
|
||||||
|
).run()
|
||||||
|
```
|
||||||
|
|
||||||
|
> Document Update Time: 2026-06-03
|
||||||
+31
-43
@@ -1,37 +1,17 @@
|
|||||||
# Training
|
# Training
|
||||||
|
|
||||||
## Model Architecture
|
## Contents
|
||||||
|
|
||||||
The model uses a decoder-only Transformer with **GQA** (Grouped Query Attention) and optional **MLA** (Multi-head Latent Attention). 1.0 billion parameters, Chinese–English bilingual.
|
- [Autoregression](#autoregression)
|
||||||
|
- [Causal Mask](#causal-mask)
|
||||||
```mermaid
|
- [Rotary Position Embedding (RoPE)](#rotary-position-embedding-rope)
|
||||||
flowchart TB
|
- [Training Loop](#training-loop)
|
||||||
subgraph Layers["Transformer Layers"]
|
- [Strategies](#strategies) — SEQ, SFT, DPO, GRPO
|
||||||
direction TB
|
- [LR Schedulers](#lr-schedulers)
|
||||||
A[Input Embedding] --> B[Transformer Block\nLayer 1]
|
- [Gradient Checkpointing](#gradient-checkpointing)
|
||||||
B --> C[Transformer Block\nLayer ...]
|
- [Checkpoint](#checkpoint)
|
||||||
C --> D[Transformer Block\nLayer ...]
|
- [TrainContextBuilder](#traincontextbuilder-builder-pattern)
|
||||||
D --> E[RMSNorm]
|
- [Training CLI](#training-cli)
|
||||||
E --> F[Linear]
|
|
||||||
F --> G[SoftMax]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph TransformerBlock["Transformer Block"]
|
|
||||||
direction TB
|
|
||||||
H[x] --> I[RMSNorm]
|
|
||||||
I --> J[Linear → Q/K/V]
|
|
||||||
J --> K[Q]; J --> L[K]; J --> M[V]
|
|
||||||
K --> N[RoPE]; L --> O[RoPE]
|
|
||||||
N --> P["Q @ K^T / sqrt(d)"]; O --> P
|
|
||||||
P --> Q[Masked SoftMax]; Q --> R[S @ V]; M --> R
|
|
||||||
R --> S[Linear]; S --> T[+]; H --> T
|
|
||||||
T --> U[RMSNorm]
|
|
||||||
U --> V["Linear (gate)"]; U --> W["Linear (up)"]
|
|
||||||
V --> X[SiLU]; X --> Y[×]; W --> Y
|
|
||||||
Y --> Z["Linear (down)"]; Z --> AA[+]; T --> AA
|
|
||||||
AA --> BB[x']
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
### Autoregression
|
### Autoregression
|
||||||
|
|
||||||
@@ -69,14 +49,16 @@ Two-level loop: **epoch** → **batch**. Optimizer step fires every `grad_accum_
|
|||||||
|
|
||||||
```
|
```
|
||||||
on_train_begin
|
on_train_begin
|
||||||
|
model.train()
|
||||||
on_epoch_begin
|
on_epoch_begin
|
||||||
for batch in dataloader:
|
for batch in dataloader:
|
||||||
on_batch_begin
|
on_batch_begin
|
||||||
with executor.accumulate(model):
|
with executor.accumulate(model):
|
||||||
loss = strategy(batch)
|
loss = strategy.compute_loss(batch)
|
||||||
|
context.loss = loss.item()
|
||||||
stand_loss = loss / executor.grad_accum_steps
|
stand_loss = loss / executor.grad_accum_steps
|
||||||
executor.backward(stand_loss)
|
executor.backward(stand_loss)
|
||||||
iteration += 1
|
context.iteration += 1
|
||||||
on_batch_end
|
on_batch_end
|
||||||
|
|
||||||
if executor.sync_gradients:
|
if executor.sync_gradients:
|
||||||
@@ -94,9 +76,13 @@ on_train_end
|
|||||||
| Hook | Fires | Default callback |
|
| Hook | Fires | Default callback |
|
||||||
|------|-------|-----------------|
|
|------|-------|-----------------|
|
||||||
| `on_train_begin` | Before training starts | `GradientCheckpointingCallback` |
|
| `on_train_begin` | Before training starts | `GradientCheckpointingCallback` |
|
||||||
|
| `on_epoch_begin` | Start of each epoch | `ProgressBarCallback` |
|
||||||
|
| `on_batch_begin` | Every batch | — |
|
||||||
| `on_optimizer_step` | Every accumulation window | `GradientClippingCallback`, `ValidationCallback` |
|
| `on_optimizer_step` | Every accumulation window | `GradientClippingCallback`, `ValidationCallback` |
|
||||||
| `on_batch_end` | Every batch | `CheckpointCallback`, `MetricLoggerCallback`, `ProgressBarCallback` |
|
| `on_batch_end` | Every batch | `CheckpointCallback`, `MetricLoggerCallback`, `ProgressBarCallback` |
|
||||||
| `on_train_end` | Training ends | `CheckpointCallback`, `MetricLoggerCallback` (final save) |
|
| `on_epoch_end` | End of each epoch | `ProgressBarCallback` |
|
||||||
|
| `on_error` | On exception during training | `CheckpointCallback`, `MetricLoggerCallback` |
|
||||||
|
| `on_train_end` | Training ends (always via finally) | `CheckpointCallback`, `MetricLoggerCallback`, `GradientCheckpointingCallback` |
|
||||||
|
|
||||||
Default callbacks (in order): `gradient_checkpointing` (activation checkpointing, optional), `checkpoint` (safetensors, rank-0), `metric_logger` (JSONL, rank-0), `progress_bar` (tqdm), `gradient_clipping`, `validation` (periodic validation on val_dataset).
|
Default callbacks (in order): `gradient_checkpointing` (activation checkpointing, optional), `checkpoint` (safetensors, rank-0), `metric_logger` (JSONL, rank-0), `progress_bar` (tqdm), `gradient_clipping`, `validation` (periodic validation on val_dataset).
|
||||||
|
|
||||||
@@ -110,7 +96,7 @@ $$
|
|||||||
L_{\text{PT}} = -\sum_{t=1}^{T} \log P(x_t \mid x_{\lt t}; \theta)
|
L_{\text{PT}} = -\sum_{t=1}^{T} \log P(x_t \mid x_{\lt t}; \theta)
|
||||||
$$
|
$$
|
||||||
|
|
||||||
Keys: `input_ids`, `target_ids`
|
Keys: `input_ids`, `target_ids`. Optional: `label_smoothing`.
|
||||||
|
|
||||||
### SFT (Supervised Fine-Tuning)
|
### SFT (Supervised Fine-Tuning)
|
||||||
|
|
||||||
@@ -120,7 +106,7 @@ $$
|
|||||||
L_{\text{SFT}} = -\sum_{t=P+1}^{P+L} \log P(s_t \mid s_{\lt t}; \theta)
|
L_{\text{SFT}} = -\sum_{t=P+1}^{P+L} \log P(s_t \mid s_{\lt t}; \theta)
|
||||||
$$
|
$$
|
||||||
|
|
||||||
Keys: `input_ids`, `target_ids`, `loss_mask`
|
Keys: `input_ids`, `target_ids`, `loss_mask`. Optional: `label_smoothing`.
|
||||||
|
|
||||||
### DPO (Direct Preference Optimization)
|
### DPO (Direct Preference Optimization)
|
||||||
|
|
||||||
@@ -130,7 +116,7 @@ $$
|
|||||||
L_{\text{DPO}} = -\mathbb{E}\left[\log\sigma\left(\beta\log\frac{\pi_\theta(y_w\mid x)}{\pi_{\text{ref}}(y_w\mid x)} - \beta\log\frac{\pi_\theta(y_l\mid x)}{\pi_{\text{ref}}(y_l\mid x)}\right)\right]
|
L_{\text{DPO}} = -\mathbb{E}\left[\log\sigma\left(\beta\log\frac{\pi_\theta(y_w\mid x)}{\pi_{\text{ref}}(y_w\mid x)} - \beta\log\frac{\pi_\theta(y_l\mid x)}{\pi_{\text{ref}}(y_l\mid x)}\right)\right]
|
||||||
$$
|
$$
|
||||||
|
|
||||||
Parameters: `beta=0.1`. Keys: `chosen`, `rejected`, `chosen_mask`, `rejected_mask`.
|
Parameters: `beta=0.1`, `reduction="mean"`. Keys: `chosen`, `rejected`, `chosen_mask`, `rejected_mask`.
|
||||||
|
|
||||||
### GRPO (Group Relative Policy Optimization)
|
### GRPO (Group Relative Policy Optimization)
|
||||||
|
|
||||||
@@ -144,7 +130,7 @@ $$
|
|||||||
L_{\text{GRPO}} = -\mathbb{E}\left[\min\left(\frac{\pi_\theta}{\pi_{\text{ref}}}A,\; \text{clip}\left(\frac{\pi_\theta}{\pi_{\text{ref}}}, 1-\epsilon, 1+\epsilon\right)A\right)\right] + \lambda \cdot \mathbb{E}\left[(\log\pi_\theta - \log\pi_{\text{ref}})^2\right]
|
L_{\text{GRPO}} = -\mathbb{E}\left[\min\left(\frac{\pi_\theta}{\pi_{\text{ref}}}A,\; \text{clip}\left(\frac{\pi_\theta}{\pi_{\text{ref}}}, 1-\epsilon, 1+\epsilon\right)A\right)\right] + \lambda \cdot \mathbb{E}\left[(\log\pi_\theta - \log\pi_{\text{ref}})^2\right]
|
||||||
$$
|
$$
|
||||||
|
|
||||||
Parameters: `group_size=4`, `clip_eps=0.2`, `kl_coef=0.01`, `sync_interval=200`.
|
Parameters: `group_size=4`, `clip_eps=0.2`, `kl_coef=0.01`, `sync_interval=200`, `reduction="mean"`.
|
||||||
|
|
||||||
Keys: `prompts`, `responses`, `masks`, `rewards`.
|
Keys: `prompts`, `responses`, `masks`, `rewards`.
|
||||||
|
|
||||||
@@ -154,8 +140,9 @@ Keys: `prompts`, `responses`, `masks`, `rewards`.
|
|||||||
|------|-------|-------------|
|
|------|-------|-------------|
|
||||||
| Cosine | `CosineScheduler` | Linear warmup → cosine decay to `min_rate` |
|
| Cosine | `CosineScheduler` | Linear warmup → cosine decay to `min_rate` |
|
||||||
| SGDR | `SGDRScheduler` | Cosine annealing with warm restarts (`t_mult=2`) |
|
| SGDR | `SGDRScheduler` | Cosine annealing with warm restarts (`t_mult=2`) |
|
||||||
|
| WSD | `WSDScheduler` | Warmup-Stable-Decay with sqrt cooldown |
|
||||||
|
|
||||||
Created by `SchedulerFactory.create(optimizer, schedule_type, **kwargs)`.
|
Created by `SchedulerFactory.create(schedule_type, optimizer, **kwargs)`. Valid types: `"cosine"`, `"sgdr"`, `"wsd"`. Omit to use no scheduler.
|
||||||
|
|
||||||
## Gradient Checkpointing
|
## Gradient Checkpointing
|
||||||
|
|
||||||
@@ -172,8 +159,8 @@ Callback wraps each `DecoderBlock.forward` with `torch.utils.checkpoint.checkpoi
|
|||||||
|
|
||||||
```
|
```
|
||||||
Checkpoint(state_dict, epoch, iteration, extra, meta, config)
|
Checkpoint(state_dict, epoch, iteration, extra, meta, config)
|
||||||
├── save(save_dir) rank-0 only: meta.json (epoch/iteration/timestamp) + config.json (model config) + state_dict.safetensors + optional {key}.pt (optimizer.pt, scheduler.pt)
|
├── save(save_dir) rank-0 only: meta.json (epoch/iteration/timestamp) + config.json (model config) + model.safetensors + optional {key}.pt (optimizer.pt, scheduler.pt)
|
||||||
└── load(save_dir) broadcasts metadata from rank-0
|
└── load(save_dir, broadcast=False) loads from local disk; set broadcast=True to broadcast metadata from rank-0
|
||||||
```
|
```
|
||||||
|
|
||||||
Optimizer/scheduler state persisted by default via `Checkpoint.extra`.
|
Optimizer/scheduler state persisted by default via `Checkpoint.extra`.
|
||||||
@@ -194,7 +181,7 @@ context = (
|
|||||||
- Creates executor via `ExecutorFactory.create(cfg.parallel_mode, grad_accum_steps=cfg.grad_accum_steps, **cfg.executor_kwargs)`
|
- Creates executor via `ExecutorFactory.create(cfg.parallel_mode, grad_accum_steps=cfg.grad_accum_steps, **cfg.executor_kwargs)`
|
||||||
- Calls `executor.prepare(model, optimizer, dataloader, scheduler)` for model distribution (e.g. DDP) + gradient accumulation wrappers
|
- Calls `executor.prepare(model, optimizer, dataloader, scheduler)` for model distribution (e.g. DDP) + gradient accumulation wrappers
|
||||||
- Creates `ResumableDistributedSampler` for shuffle+resume
|
- Creates `ResumableDistributedSampler` for shuffle+resume
|
||||||
- Builds strategy via `StrategyFactory.create(train_type, ...)`
|
- Builds strategy via `StrategyFactory.create(train_type, model, device, **kwargs)`
|
||||||
|
|
||||||
## Training CLI
|
## Training CLI
|
||||||
|
|
||||||
@@ -203,6 +190,7 @@ export CUDA_VISIBLE_DEVICES=0,1,2,3
|
|||||||
|
|
||||||
nohup python scripts/tools/train.py \
|
nohup python scripts/tools/train.py \
|
||||||
--nprocs=4 \
|
--nprocs=4 \
|
||||||
|
--parallel_mode=ddp \
|
||||||
--train_type=seq \
|
--train_type=seq \
|
||||||
--data_root_path=/path/to/dataset \
|
--data_root_path=/path/to/dataset \
|
||||||
--param_path=/path/to/model \
|
--param_path=/path/to/model \
|
||||||
@@ -224,4 +212,4 @@ nohup python scripts/tools/train.py \
|
|||||||
|
|
||||||
Full parameter reference at [params.md](params.md).
|
Full parameter reference at [params.md](params.md).
|
||||||
|
|
||||||
> Document Update Time: 2026-05-28
|
> Document Update Time: 2026-05-30
|
||||||
|
|||||||
+78
-12
@@ -3,32 +3,98 @@ __author__ = "ViperEkura"
|
|||||||
|
|
||||||
from astrai.config import (
|
from astrai.config import (
|
||||||
AutoRegressiveLMConfig,
|
AutoRegressiveLMConfig,
|
||||||
|
BaseModelConfig,
|
||||||
|
ConfigFactory,
|
||||||
EncoderConfig,
|
EncoderConfig,
|
||||||
|
PipelineConfig,
|
||||||
TrainConfig,
|
TrainConfig,
|
||||||
)
|
)
|
||||||
from astrai.dataset import DatasetFactory
|
from astrai.dataset import (
|
||||||
|
BaseDataset,
|
||||||
|
DatasetFactory,
|
||||||
|
ResumableDistributedSampler,
|
||||||
|
Store,
|
||||||
|
StoreFactory,
|
||||||
|
)
|
||||||
from astrai.factory import BaseFactory
|
from astrai.factory import BaseFactory
|
||||||
from astrai.inference import (
|
from astrai.inference import (
|
||||||
GenerationRequest,
|
GenerationRequest,
|
||||||
InferenceEngine,
|
InferenceEngine,
|
||||||
|
ProtocolHandler,
|
||||||
|
SamplingPipeline,
|
||||||
|
get_app,
|
||||||
|
run_server,
|
||||||
|
sample,
|
||||||
|
)
|
||||||
|
from astrai.model import (
|
||||||
|
AutoModel,
|
||||||
|
AutoRegressiveLM,
|
||||||
|
EmbeddingEncoder,
|
||||||
|
LoRAConfig,
|
||||||
|
inject_lora,
|
||||||
|
)
|
||||||
|
from astrai.parallel import (
|
||||||
|
ExecutorFactory,
|
||||||
|
get_rank,
|
||||||
|
get_world_size,
|
||||||
|
only_on_rank,
|
||||||
|
spawn_parallel_fn,
|
||||||
|
)
|
||||||
|
from astrai.preprocessing import Pipeline, filter_by_length
|
||||||
|
from astrai.serialization import Checkpoint
|
||||||
|
from astrai.tokenize import AutoTokenizer, ChatTemplate
|
||||||
|
from astrai.trainer import (
|
||||||
|
BaseScheduler,
|
||||||
|
BaseStrategy,
|
||||||
|
CallbackFactory,
|
||||||
|
Muon,
|
||||||
|
SchedulerFactory,
|
||||||
|
StrategyFactory,
|
||||||
|
TrainCallback,
|
||||||
|
Trainer,
|
||||||
)
|
)
|
||||||
from astrai.model import AutoModel, AutoRegressiveLM
|
|
||||||
from astrai.tokenize import AutoTokenizer
|
|
||||||
from astrai.trainer import CallbackFactory, SchedulerFactory, StrategyFactory, Trainer
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"AutoRegressiveLM",
|
"AutoRegressiveLM",
|
||||||
"AutoRegressiveLMConfig",
|
"AutoRegressiveLMConfig",
|
||||||
"EncoderConfig",
|
"AutoModel",
|
||||||
"TrainConfig",
|
|
||||||
"DatasetFactory",
|
|
||||||
"AutoTokenizer",
|
"AutoTokenizer",
|
||||||
|
"BaseDataset",
|
||||||
|
"BaseFactory",
|
||||||
|
"BaseModelConfig",
|
||||||
|
"BaseScheduler",
|
||||||
|
"BaseStrategy",
|
||||||
|
"CallbackFactory",
|
||||||
|
"ChatTemplate",
|
||||||
|
"Checkpoint",
|
||||||
|
"ConfigFactory",
|
||||||
|
"DatasetFactory",
|
||||||
|
"EmbeddingEncoder",
|
||||||
|
"EncoderConfig",
|
||||||
|
"ExecutorFactory",
|
||||||
"GenerationRequest",
|
"GenerationRequest",
|
||||||
"InferenceEngine",
|
"InferenceEngine",
|
||||||
"Trainer",
|
"LoRAConfig",
|
||||||
"CallbackFactory",
|
"Muon",
|
||||||
"StrategyFactory",
|
"Pipeline",
|
||||||
|
"PipelineConfig",
|
||||||
|
"ProtocolHandler",
|
||||||
|
"ResumableDistributedSampler",
|
||||||
|
"SamplingPipeline",
|
||||||
"SchedulerFactory",
|
"SchedulerFactory",
|
||||||
"BaseFactory",
|
"Store",
|
||||||
"AutoModel",
|
"StoreFactory",
|
||||||
|
"StrategyFactory",
|
||||||
|
"TrainCallback",
|
||||||
|
"TrainConfig",
|
||||||
|
"Trainer",
|
||||||
|
"filter_by_length",
|
||||||
|
"get_app",
|
||||||
|
"get_rank",
|
||||||
|
"get_world_size",
|
||||||
|
"inject_lora",
|
||||||
|
"only_on_rank",
|
||||||
|
"run_server",
|
||||||
|
"sample",
|
||||||
|
"spawn_parallel_fn",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -4,13 +4,22 @@ from astrai.config.model_config import (
|
|||||||
ConfigFactory,
|
ConfigFactory,
|
||||||
EncoderConfig,
|
EncoderConfig,
|
||||||
)
|
)
|
||||||
|
from astrai.config.preprocess_config import (
|
||||||
|
InputConfig,
|
||||||
|
OutputConfig,
|
||||||
|
PipelineConfig,
|
||||||
|
ProcessingConfig,
|
||||||
|
)
|
||||||
from astrai.config.train_config import TrainConfig
|
from astrai.config.train_config import TrainConfig
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# Model configuration
|
|
||||||
"BaseModelConfig",
|
"BaseModelConfig",
|
||||||
"AutoRegressiveLMConfig",
|
"AutoRegressiveLMConfig",
|
||||||
"EncoderConfig",
|
"EncoderConfig",
|
||||||
"ConfigFactory",
|
"ConfigFactory",
|
||||||
"TrainConfig",
|
"TrainConfig",
|
||||||
|
"InputConfig",
|
||||||
|
"OutputConfig",
|
||||||
|
"PipelineConfig",
|
||||||
|
"ProcessingConfig",
|
||||||
]
|
]
|
||||||
|
|||||||
+13
-1
@@ -1,6 +1,7 @@
|
|||||||
import json
|
import json
|
||||||
from dataclasses import MISSING, dataclass, fields
|
from dataclasses import MISSING, dataclass, fields
|
||||||
from typing import Any, Dict, Optional, Self, get_type_hints
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, Optional, Self, Union, get_type_hints
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -83,4 +84,15 @@ class BaseConfig:
|
|||||||
return value
|
return value
|
||||||
if isinstance(value, target_type):
|
if isinstance(value, target_type):
|
||||||
return value
|
return value
|
||||||
|
if isinstance(value, dict) and issubclass(target_type, BaseConfig):
|
||||||
|
return target_type.from_dict(value)
|
||||||
raise TypeError
|
raise TypeError
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_file(cls, path: Union[str, Path]) -> Self:
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
return cls.from_dict(json.load(f))
|
||||||
|
|
||||||
|
def to_file(self, path: Union[str, Path]):
|
||||||
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(self.to_dict(), f, indent=2, ensure_ascii=False)
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import json
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, Dict, Optional, Self
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
from astrai.config.base import BaseConfig
|
from astrai.config.base import BaseConfig
|
||||||
from astrai.factory import BaseFactory
|
from astrai.factory import BaseFactory
|
||||||
@@ -21,18 +20,7 @@ class BaseModelConfig(BaseConfig):
|
|||||||
"""Base config with ``model_type`` dispatch and file I/O."""
|
"""Base config with ``model_type`` dispatch and file I/O."""
|
||||||
|
|
||||||
model_type: Optional[str] = None
|
model_type: Optional[str] = None
|
||||||
|
neftune_alpha: float = 0.0
|
||||||
@classmethod
|
|
||||||
def from_file(cls, config_path: str) -> Self:
|
|
||||||
with open(config_path, "r") as f:
|
|
||||||
raw: Dict[str, Any] = json.load(f)
|
|
||||||
return cls.from_dict(raw)
|
|
||||||
|
|
||||||
def to_file(self, config_path: str):
|
|
||||||
d = self.to_dict()
|
|
||||||
config_dict = {k: v for k, v in d.items() if v is not None}
|
|
||||||
with open(config_path, "w") as f:
|
|
||||||
json.dump(config_dict, f, indent=4)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -83,10 +71,12 @@ class EncoderConfig(BaseModelConfig):
|
|||||||
rope_theta: Optional[float] = None
|
rope_theta: Optional[float] = None
|
||||||
rope_scaling: Optional[dict] = None
|
rope_scaling: Optional[dict] = None
|
||||||
|
|
||||||
|
attn_type: str = "gqa"
|
||||||
n_heads: Optional[int] = None
|
n_heads: Optional[int] = None
|
||||||
n_kv_heads: Optional[int] = None
|
n_kv_heads: Optional[int] = None
|
||||||
use_qk_norm: Optional[bool] = None
|
use_qk_norm: Optional[bool] = None
|
||||||
use_gated_attention: Optional[bool] = None
|
use_gated_attention: Optional[bool] = None
|
||||||
|
|
||||||
|
ffn_type: str = "mlp"
|
||||||
pooling_type: Optional[str] = None
|
pooling_type: Optional[str] = None
|
||||||
normalize_embeddings: Optional[bool] = None
|
normalize_embeddings: Optional[bool] = None
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"""Pipeline configuration for JSONL preprocessing.
|
||||||
|
|
||||||
|
Supports single-sequence (SFT/pretrain) and multi-output (DPO/GRPO)
|
||||||
|
modes, both driven declaratively through ``input.sections`` or
|
||||||
|
``input.sources``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
|
from astrai.config.base import BaseConfig
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class InputConfig(BaseConfig):
|
||||||
|
"""Declarative input mapping.
|
||||||
|
|
||||||
|
Single-output mode (backward-compatible)::
|
||||||
|
|
||||||
|
{"input": {"sections": [{"field": "messages", ...}]}}
|
||||||
|
|
||||||
|
Multi-output mode (DPO / GRPO)::
|
||||||
|
|
||||||
|
{"input": {"sources": {
|
||||||
|
"chosen": {"sections": [{"field": "chosen", ...}]},
|
||||||
|
"rejected": {"sections": [{"field": "rejected", ...}]},
|
||||||
|
}}}
|
||||||
|
"""
|
||||||
|
|
||||||
|
sections: Optional[List[Dict]] = None
|
||||||
|
sources: Optional[Dict[str, Dict]] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ProcessingConfig(BaseConfig):
|
||||||
|
"""Processing configuration.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
max_seq_len : int
|
||||||
|
Maximum sequence length (default: 2048).
|
||||||
|
min_chars : int
|
||||||
|
Minimum number of characters to keep (default: 50).
|
||||||
|
max_chars : int
|
||||||
|
Maximum number of characters to keep (default: 2_000_000).
|
||||||
|
max_items : Optional[int]
|
||||||
|
Maximum number of items to process (default: None, unlimited).
|
||||||
|
packing_strategy : str
|
||||||
|
How to pack sequences into a contiguous stream.
|
||||||
|
|
||||||
|
- ``"simple"``: sequential concatenation (default, backward compatible).
|
||||||
|
- ``"bfd"``: best-fit decreasing bin packing, minimises wasted tokens.
|
||||||
|
- ``"bfd_split"``: BFD with over-length sequences split into chunks.
|
||||||
|
max_packed_len : int
|
||||||
|
Maximum length of a packed bin. Sequences longer than this are
|
||||||
|
truncated or split depending on ``packing_strategy`` (default: 8192).
|
||||||
|
truncation_mode : str
|
||||||
|
How to truncate sequences longer than ``max_packed_len``.
|
||||||
|
|
||||||
|
- ``"keep_start"``: keep the first ``max_packed_len`` tokens (default).
|
||||||
|
- ``"keep_end"``: keep the last ``max_packed_len`` tokens.
|
||||||
|
"""
|
||||||
|
|
||||||
|
max_seq_len: int = 2048
|
||||||
|
min_chars: int = 50
|
||||||
|
max_chars: int = 2_000_000
|
||||||
|
max_items: Optional[int] = None
|
||||||
|
packing_strategy: str = "simple"
|
||||||
|
max_packed_len: int = 8192
|
||||||
|
truncation_mode: str = "keep_start"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class OutputConfig(BaseConfig):
|
||||||
|
"""Output configuration.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
domain_key : Optional[str]
|
||||||
|
Domain key for the output store (default: None).
|
||||||
|
storage_format : str
|
||||||
|
Storage format, one of ``"bin"``, ``"jsonl"`` (default: ``"bin"``).
|
||||||
|
max_tokens_per_shard : int
|
||||||
|
Maximum tokens per shard before splitting (default: 100_000_000).
|
||||||
|
dtype : Dict[str, str]
|
||||||
|
Per-key dtype overrides, e.g. ``{"input_ids": "int32"}`` (default: {}).
|
||||||
|
position_ids_mode : Optional[str]
|
||||||
|
How to compute position_ids in packed sequences.
|
||||||
|
|
||||||
|
- ``"none"``: do not generate (default).
|
||||||
|
- ``"doc_reset"``: reset to 0 at each document boundary.
|
||||||
|
- ``"continuous"``: sequential 0, 1, 2, ... (pretrain, single doc).
|
||||||
|
"""
|
||||||
|
|
||||||
|
domain_key: Optional[str] = None
|
||||||
|
storage_format: str = "bin"
|
||||||
|
max_tokens_per_shard: int = 100_000_000
|
||||||
|
dtype: Dict[str, str] = field(default_factory=dict)
|
||||||
|
position_ids_mode: str = "none"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PipelineConfig(BaseConfig):
|
||||||
|
version: int = 1
|
||||||
|
input: InputConfig = field(default_factory=InputConfig)
|
||||||
|
mask: Dict[str, str] = field(default_factory=dict)
|
||||||
|
mask_default: str = "mask"
|
||||||
|
preprocessing: ProcessingConfig = field(default_factory=ProcessingConfig)
|
||||||
|
output: OutputConfig = field(default_factory=OutputConfig)
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
from dataclasses import dataclass, field, fields
|
from dataclasses import dataclass, field, fields
|
||||||
from typing import Callable, List, Optional
|
from typing import Any, Callable, Dict, List, Optional
|
||||||
|
|
||||||
import torch.nn as nn
|
import torch.nn as nn
|
||||||
from torch.optim import Optimizer
|
from torch.optim import Optimizer
|
||||||
@@ -40,7 +40,7 @@ class TrainConfig(BaseConfig):
|
|||||||
max_grad_norm: float = field(
|
max_grad_norm: float = field(
|
||||||
default=1.0, metadata={"help": "Maximum gradient norm."}
|
default=1.0, metadata={"help": "Maximum gradient norm."}
|
||||||
)
|
)
|
||||||
gradient_checkpointing_modules: list = field(
|
gradient_checkpointing_modules: List[str] = field(
|
||||||
default_factory=list,
|
default_factory=list,
|
||||||
metadata={"help": "Module types to enable activation checkpointing for."},
|
metadata={"help": "Module types to enable activation checkpointing for."},
|
||||||
)
|
)
|
||||||
@@ -118,16 +118,26 @@ class TrainConfig(BaseConfig):
|
|||||||
val_dataset: Optional[Dataset] = field(
|
val_dataset: Optional[Dataset] = field(
|
||||||
default=None, metadata={"help": "Dataset for validation."}
|
default=None, metadata={"help": "Dataset for validation."}
|
||||||
)
|
)
|
||||||
|
val_split: Optional[float] = field(
|
||||||
|
default=None,
|
||||||
|
metadata={
|
||||||
|
"help": "Ratio to split from training dataset for validation (e.g. 0.05). Ignored if val_dataset is set."
|
||||||
|
},
|
||||||
|
)
|
||||||
val_step: int = field(
|
val_step: int = field(
|
||||||
default=1000,
|
default=1000,
|
||||||
metadata={"help": "Number of optimizer steps between validation runs."},
|
metadata={"help": "Number of optimizer steps between validation runs."},
|
||||||
)
|
)
|
||||||
|
neftune_alpha: float = field(
|
||||||
|
default=0.0,
|
||||||
|
metadata={"help": "NEFTune noise alpha (0=disabled, typical: 5.0)."},
|
||||||
|
)
|
||||||
|
|
||||||
executor_kwargs: dict = field(
|
executor_kwargs: Dict[str, Any] = field(
|
||||||
default_factory=dict,
|
default_factory=dict,
|
||||||
metadata={"help": "Extra kwargs passed to ExecutorFactory.create()."},
|
metadata={"help": "Extra kwargs passed to ExecutorFactory.create()."},
|
||||||
)
|
)
|
||||||
extra_kwargs: dict = field(
|
extra_kwargs: Dict[str, Any] = field(
|
||||||
default_factory=dict, metadata={"help": "Other arguments."}
|
default_factory=dict, metadata={"help": "Other arguments."}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+11
-46
@@ -136,26 +136,6 @@ class DatasetFactory(BaseFactory["BaseDataset"]):
|
|||||||
dataset = DatasetFactory.create("custom", window_size, stride)
|
dataset = DatasetFactory.create("custom", window_size, stride)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _validate_component(cls, dataset_cls: type):
|
|
||||||
"""Validate that the dataset class inherits from BaseDataset."""
|
|
||||||
if not issubclass(dataset_cls, BaseDataset):
|
|
||||||
raise TypeError(f"{dataset_cls.__name__} must inherit from BaseDataset")
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def create(cls, train_type: str, window_size: int, stride: int) -> "BaseDataset":
|
|
||||||
"""Create a dataset instance.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
train_type: Type of training ("seq", "sft", "dpo", "grpo")
|
|
||||||
window_size: Window size for data sampling
|
|
||||||
stride: Stride between consecutive samples
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dataset instance
|
|
||||||
"""
|
|
||||||
return super().create(train_type, window_size, stride)
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def load(
|
def load(
|
||||||
cls,
|
cls,
|
||||||
@@ -185,19 +165,11 @@ class DatasetFactory(BaseFactory["BaseDataset"]):
|
|||||||
|
|
||||||
return dataset
|
return dataset
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def available_types(cls) -> list:
|
|
||||||
"""Return list of registered dataset type names."""
|
|
||||||
return cls.list_registered()
|
|
||||||
|
|
||||||
|
|
||||||
@DatasetFactory.register("seq")
|
@DatasetFactory.register("seq")
|
||||||
class SEQDataset(BaseDataset):
|
class SEQDataset(BaseDataset):
|
||||||
"""Dataset for sequential next-token prediction training."""
|
"""Dataset for sequential next-token prediction training."""
|
||||||
|
|
||||||
def __init__(self, window_size: int, stride: int):
|
|
||||||
super().__init__(window_size, stride)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def required_keys(self) -> List[str]:
|
def required_keys(self) -> List[str]:
|
||||||
return ["sequence"]
|
return ["sequence"]
|
||||||
@@ -218,12 +190,9 @@ class SEQDataset(BaseDataset):
|
|||||||
class SFTDataset(BaseDataset):
|
class SFTDataset(BaseDataset):
|
||||||
"""Dataset for supervised fine-tuning with loss masking."""
|
"""Dataset for supervised fine-tuning with loss masking."""
|
||||||
|
|
||||||
def __init__(self, window_size: int, stride: int):
|
|
||||||
super().__init__(window_size, stride)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def required_keys(self) -> List[str]:
|
def required_keys(self) -> List[str]:
|
||||||
return ["sequence", "loss_mask"]
|
return ["sequence", "loss_mask", "position_ids"]
|
||||||
|
|
||||||
def _fetch_data(self, begin_idx: int, end_idx: int, key: str) -> Tensor:
|
def _fetch_data(self, begin_idx: int, end_idx: int, key: str) -> Tensor:
|
||||||
return self.storage.fetch(begin_idx, end_idx, key)
|
return self.storage.fetch(begin_idx, end_idx, key)
|
||||||
@@ -231,24 +200,23 @@ class SFTDataset(BaseDataset):
|
|||||||
def __getitem__(self, index):
|
def __getitem__(self, index):
|
||||||
begin_idx, end_idx = self.get_index(index)
|
begin_idx, end_idx = self.get_index(index)
|
||||||
|
|
||||||
x = self._fetch_data(begin_idx, end_idx, "sequence").to(dtype=torch.long)
|
x = self._fetch_data(begin_idx, end_idx, "sequence")
|
||||||
y = self._fetch_data(begin_idx + 1, end_idx + 1, "sequence").to(
|
y = self._fetch_data(begin_idx + 1, end_idx + 1, "sequence")
|
||||||
dtype=torch.long
|
position_ids = self._fetch_data(begin_idx, end_idx, "position_ids")
|
||||||
)
|
loss_mask = self._fetch_data(begin_idx + 1, end_idx + 1, "loss_mask")
|
||||||
loss_mask = self._fetch_data(begin_idx + 1, end_idx + 1, "loss_mask").to(
|
|
||||||
dtype=torch.bool
|
|
||||||
)
|
|
||||||
|
|
||||||
return {"input_ids": x, "target_ids": y, "loss_mask": loss_mask}
|
return {
|
||||||
|
"input_ids": x.to(dtype=torch.long),
|
||||||
|
"target_ids": y.to(dtype=torch.long),
|
||||||
|
"position_ids": position_ids.to(dtype=torch.long),
|
||||||
|
"loss_mask": loss_mask.to(dtype=torch.bool),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@DatasetFactory.register("dpo")
|
@DatasetFactory.register("dpo")
|
||||||
class DPODataset(BaseDataset):
|
class DPODataset(BaseDataset):
|
||||||
"""Dataset for Direct Preference Optimization training."""
|
"""Dataset for Direct Preference Optimization training."""
|
||||||
|
|
||||||
def __init__(self, window_size: int, stride: int):
|
|
||||||
super().__init__(window_size, stride)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def required_keys(self) -> List[str]:
|
def required_keys(self) -> List[str]:
|
||||||
return ["chosen", "rejected", "chosen_mask", "rejected_mask"]
|
return ["chosen", "rejected", "chosen_mask", "rejected_mask"]
|
||||||
@@ -280,9 +248,6 @@ class DPODataset(BaseDataset):
|
|||||||
class GRPODataset(BaseDataset):
|
class GRPODataset(BaseDataset):
|
||||||
"""Dataset for Group Relative Policy Optimization training."""
|
"""Dataset for Group Relative Policy Optimization training."""
|
||||||
|
|
||||||
def __init__(self, window_size: int, stride: int):
|
|
||||||
super().__init__(window_size, stride)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def required_keys(self) -> List[str]:
|
def required_keys(self) -> List[str]:
|
||||||
return ["prompts", "responses", "masks", "rewards"]
|
return ["prompts", "responses", "masks", "rewards"]
|
||||||
|
|||||||
+26
-10
@@ -18,6 +18,7 @@ Key properties:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import bisect
|
import bisect
|
||||||
|
import glob
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
@@ -113,11 +114,19 @@ def detect_format(load_path: str) -> str:
|
|||||||
return "h5"
|
return "h5"
|
||||||
raise ValueError(f"Unsupported file format: {suffix}")
|
raise ValueError(f"Unsupported file format: {suffix}")
|
||||||
|
|
||||||
h5_files = list(root.rglob("*.h5")) + list(root.rglob("*.hdf5"))
|
h5_files = [
|
||||||
|
Path(p)
|
||||||
|
for pattern in ("*.h5", "*.hdf5")
|
||||||
|
for p in glob.glob(str(root / "**" / pattern), recursive=True)
|
||||||
|
]
|
||||||
if h5_files:
|
if h5_files:
|
||||||
return "h5"
|
return "h5"
|
||||||
bin_files = list(root.rglob("*.bin"))
|
bin_files = [Path(p) for p in glob.glob(str(root / "**" / "*.bin"), recursive=True)]
|
||||||
if bin_files and (root / "meta.json").exists():
|
if bin_files:
|
||||||
|
has_meta = (root / "meta.json").exists() or len(
|
||||||
|
[Path(p) for p in glob.glob(str(root / "**" / "meta.json"), recursive=True)]
|
||||||
|
) > 0
|
||||||
|
if has_meta:
|
||||||
return "bin"
|
return "bin"
|
||||||
raise FileNotFoundError(f"No supported data files found at {load_path}")
|
raise FileNotFoundError(f"No supported data files found at {load_path}")
|
||||||
|
|
||||||
@@ -213,11 +222,6 @@ class StoreFactory(BaseFactory["Store"]):
|
|||||||
...
|
...
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _validate_component(cls, store_cls: type):
|
|
||||||
if not issubclass(store_cls, Store):
|
|
||||||
raise TypeError(f"{store_cls.__name__} must inherit from Store")
|
|
||||||
|
|
||||||
|
|
||||||
@StoreFactory.register("h5")
|
@StoreFactory.register("h5")
|
||||||
class H5Store(Store):
|
class H5Store(Store):
|
||||||
@@ -244,7 +248,19 @@ class MmapStore(Store):
|
|||||||
|
|
||||||
def load(self, path: str):
|
def load(self, path: str):
|
||||||
self._mmap_refs = []
|
self._mmap_refs = []
|
||||||
raw = load_bin(path)
|
root = Path(path)
|
||||||
self._normalize(raw)
|
all_raw: Dict[str, List[Tensor]] = {}
|
||||||
|
meta_paths = [
|
||||||
|
Path(p) for p in glob.glob(str(root / "**" / "meta.json"), recursive=True)
|
||||||
|
]
|
||||||
|
for meta_path in meta_paths:
|
||||||
|
raw = load_bin(str(meta_path.parent))
|
||||||
|
for key, tensors in raw.items():
|
||||||
|
if key not in all_raw:
|
||||||
|
all_raw[key] = []
|
||||||
|
all_raw[key].extend(tensors)
|
||||||
|
if not meta_paths:
|
||||||
|
raise FileNotFoundError(f"No meta.json found under {path}")
|
||||||
|
self._normalize(all_raw)
|
||||||
for tensors in self._data.values():
|
for tensors in self._data.values():
|
||||||
self._mmap_refs.extend(tensors)
|
self._mmap_refs.extend(tensors)
|
||||||
|
|||||||
+76
-158
@@ -1,149 +1,103 @@
|
|||||||
"""Base factory class for extensible component registration."""
|
"""Base factory with decorator-based registration and kwarg-filtered instantiation."""
|
||||||
|
|
||||||
import inspect
|
import inspect
|
||||||
|
import sys
|
||||||
from abc import ABC
|
from abc import ABC
|
||||||
from typing import Callable, Dict, Generic, List, Optional, Tuple, Type, TypeVar
|
from typing import (
|
||||||
|
Callable,
|
||||||
|
Dict,
|
||||||
|
ForwardRef,
|
||||||
|
Generic,
|
||||||
|
List,
|
||||||
|
Optional,
|
||||||
|
Type,
|
||||||
|
TypeVar,
|
||||||
|
Union,
|
||||||
|
)
|
||||||
|
from typing import get_args as _get_args
|
||||||
|
from typing import get_origin as _get_origin
|
||||||
|
|
||||||
T = TypeVar("T")
|
T = TypeVar("T")
|
||||||
|
|
||||||
|
|
||||||
class Registry:
|
def _resolve_type(
|
||||||
"""Flexible registry for component classes with category and priority support.
|
arg: Union[Type, str, ForwardRef], factory_cls: type
|
||||||
|
) -> Optional[Type]:
|
||||||
|
"""Resolve a generic type-arg (str forward-ref, ForwardRef, or class)."""
|
||||||
|
if not isinstance(arg, (str, ForwardRef)):
|
||||||
|
return arg
|
||||||
|
|
||||||
This registry stores component classes with optional metadata (category, priority).
|
name = arg if isinstance(arg, str) else arg.__forward_arg__
|
||||||
It provides methods for registration, retrieval, and listing with filtering.
|
if name == factory_cls.__name__:
|
||||||
"""
|
return factory_cls
|
||||||
|
|
||||||
def __init__(self):
|
mod = sys.modules.get(factory_cls.__module__)
|
||||||
self._entries = {} # name -> (component_cls, category, priority)
|
if mod is None:
|
||||||
|
return None
|
||||||
|
ns = vars(mod)
|
||||||
|
|
||||||
def register(
|
if isinstance(arg, ForwardRef):
|
||||||
self,
|
return arg._evaluate(ns, None, frozenset(), recursive_guard=frozenset())
|
||||||
name: str,
|
|
||||||
component_cls: Type,
|
|
||||||
category: Optional[str] = None,
|
|
||||||
priority: int = 0,
|
|
||||||
):
|
|
||||||
"""Register a component class with optional category and priority."""
|
|
||||||
if name in self._entries:
|
|
||||||
raise ValueError(f"Component '{name}' is already registered")
|
|
||||||
self._entries[name] = (component_cls, category, priority)
|
|
||||||
|
|
||||||
def get(self, name: str) -> Type:
|
return ns.get(name)
|
||||||
"""Get component class by name."""
|
|
||||||
if name not in self._entries:
|
|
||||||
raise KeyError(f"Component '{name}' not found in registry")
|
|
||||||
return self._entries[name][0]
|
|
||||||
|
|
||||||
def get_with_metadata(self, name: str) -> Tuple[Type, Optional[str], int]:
|
|
||||||
"""Get component class with its metadata."""
|
|
||||||
entry = self._entries.get(name)
|
|
||||||
if entry is None:
|
|
||||||
raise KeyError(f"Component '{name}' not found in registry")
|
|
||||||
return entry
|
|
||||||
|
|
||||||
def contains(self, name: str) -> bool:
|
|
||||||
"""Check if a name is registered."""
|
|
||||||
return name in self._entries
|
|
||||||
|
|
||||||
def list_names(self) -> List[str]:
|
|
||||||
"""Return list of registered component names."""
|
|
||||||
return sorted(self._entries.keys())
|
|
||||||
|
|
||||||
def list_by_category(self, category: str) -> List[str]:
|
|
||||||
"""Return names of components belonging to a specific category."""
|
|
||||||
return sorted(
|
|
||||||
name for name, (_, cat, _) in self._entries.items() if cat == category
|
|
||||||
)
|
|
||||||
|
|
||||||
def list_by_priority(self, reverse: bool = False) -> List[str]:
|
|
||||||
"""Return names sorted by priority (default ascending)."""
|
|
||||||
return sorted(
|
|
||||||
self._entries.keys(),
|
|
||||||
key=lambda name: self._entries[name][2],
|
|
||||||
reverse=reverse,
|
|
||||||
)
|
|
||||||
|
|
||||||
def entries(self) -> Dict[str, Tuple[Type, Optional[str], int]]:
|
|
||||||
"""Return raw entries dictionary."""
|
|
||||||
return self._entries.copy()
|
|
||||||
|
|
||||||
|
|
||||||
class BaseFactory(ABC, Generic[T]):
|
class BaseFactory(ABC, Generic[T]):
|
||||||
"""Generic factory class for component registration and creation.
|
"""Generic factory with decorator-based component registration.
|
||||||
|
|
||||||
This base class provides a decorator-based registration pattern
|
class MyFactory(BaseFactory[MyBase]):
|
||||||
for creating extensible component factories.
|
|
||||||
|
|
||||||
Example usage:
|
|
||||||
class MyFactory(BaseFactory[MyBaseClass]):
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@MyFactory.register("custom")
|
@MyFactory.register("custom")
|
||||||
class CustomComponent(MyBaseClass):
|
class CustomComponent(MyBase):
|
||||||
...
|
...
|
||||||
|
|
||||||
component = MyFactory.create("custom", *args, **kwargs)
|
obj = MyFactory.create("custom", *args, **kwargs)
|
||||||
|
|
||||||
|
``create()`` filters kwargs to match the component's ``__init__``
|
||||||
|
signature so components don't need ``**kwargs`` just to absorb
|
||||||
|
unrelated parameters.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_registry: Registry
|
_entries: Dict[str, Type[T]]
|
||||||
|
|
||||||
def __init_subclass__(cls, **kwargs):
|
def __init_subclass__(cls, **kwargs):
|
||||||
super().__init_subclass__(**kwargs)
|
super().__init_subclass__(**kwargs)
|
||||||
cls._registry = Registry()
|
for orig_base in getattr(cls, "__orig_bases__", ()):
|
||||||
|
if _get_origin(orig_base) is BaseFactory:
|
||||||
|
(arg,) = _get_args(orig_base)
|
||||||
|
cls._entries = {}
|
||||||
|
cls._component_base = _resolve_type(arg, cls)
|
||||||
|
return
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def register(
|
def register(cls, name: str) -> Callable[[Type[T]], Type[T]]:
|
||||||
cls, name: str, category: Optional[str] = None, priority: int = 0
|
"""Decorator to register a component class.
|
||||||
) -> Callable[[Type[T]], Type[T]]:
|
|
||||||
"""Decorator to register a component class with optional category and priority.
|
|
||||||
|
|
||||||
Args:
|
Validates that the decorated class inherits from the generic
|
||||||
name: Registration name for the component
|
type parameter ``T`` declared on the factory.
|
||||||
category: Optional category for grouping components
|
|
||||||
priority: Priority for ordering (default 0)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Decorator function that registers the component class
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
TypeError: If the decorated class doesn't inherit from the base type
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def decorator(component_cls: Type[T]) -> Type[T]:
|
def decorator(component_cls: Type[T]) -> Type[T]:
|
||||||
cls._validate_component(component_cls)
|
cls._validate_component(component_cls)
|
||||||
cls._registry.register(
|
if name in cls._entries:
|
||||||
name, component_cls, category=category, priority=priority
|
raise ValueError(f"Component '{name}' is already registered")
|
||||||
)
|
cls._entries[name] = component_cls
|
||||||
return component_cls
|
return component_cls
|
||||||
|
|
||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create(cls, name: str, *args, **kwargs) -> T:
|
def create(cls, name: str, *args, **kwargs) -> T:
|
||||||
"""Create a component instance by name.
|
"""Create a component instance by name, filtering kwargs to match
|
||||||
|
the component's ``__init__`` signature.
|
||||||
Filters kwargs to match the component's __init__ signature,
|
|
||||||
so components don't need to declare **kwargs just to absorb
|
|
||||||
parameters meant for other components.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
name: Registered name of the component
|
|
||||||
*args: Positional arguments passed to component constructor
|
|
||||||
**kwargs: Keyword arguments passed to component constructor
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Component instance
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: If the component name is not registered
|
|
||||||
"""
|
"""
|
||||||
if not cls._registry.contains(name):
|
entry = cls._entries.get(name)
|
||||||
|
if entry is None:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Unknown component: '{name}'. "
|
f"Unknown component: '{name}'. Supported types: {sorted(cls._entries)}"
|
||||||
f"Supported types: {sorted(cls._registry.list_names())}"
|
|
||||||
)
|
)
|
||||||
component_cls = cls._registry.get(name)
|
component_cls = entry
|
||||||
sig = inspect.signature(component_cls.__init__)
|
sig = inspect.signature(component_cls.__init__)
|
||||||
has_var_kwargs = any(
|
has_var_kwargs = any(
|
||||||
p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()
|
p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()
|
||||||
@@ -159,68 +113,32 @@ class BaseFactory(ABC, Generic[T]):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _validate_component(cls, component_cls: Type[T]):
|
def _validate_component(cls, component_cls: Type[T]):
|
||||||
"""Validate that the component class is valid for this factory.
|
"""Validate the decorated class inherits from the factory's base type.
|
||||||
|
|
||||||
Override this method in subclasses to add custom validation.
|
Override for custom validation beyond ``issubclass``.
|
||||||
|
|
||||||
Args:
|
|
||||||
component_cls: Component class to validate
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
TypeError: If the component class is invalid
|
|
||||||
"""
|
"""
|
||||||
pass
|
base = cls._component_base
|
||||||
|
if base is not None and not issubclass(component_cls, base):
|
||||||
|
raise TypeError(
|
||||||
|
f"{component_cls.__name__} must inherit from {base.__name__}"
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_component_class(cls, name: str) -> Type[T]:
|
def get_component_class(cls, name: str) -> Type[T]:
|
||||||
"""Get the registered component class by name without instantiating it.
|
"""Get the registered component class without instantiating it."""
|
||||||
|
entry = cls._entries.get(name)
|
||||||
Args:
|
if entry is None:
|
||||||
name: Registered name of the component
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The component class itself
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: If the component name is not registered
|
|
||||||
"""
|
|
||||||
if not cls._registry.contains(name):
|
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Unknown component: '{name}'. "
|
f"Unknown component: '{name}'. Supported types: {sorted(cls._entries)}"
|
||||||
f"Supported types: {sorted(cls._registry.list_names())}"
|
|
||||||
)
|
)
|
||||||
return cls._registry.get(name)
|
return entry
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def list_registered(cls) -> list:
|
def list_registered(cls) -> List[str]:
|
||||||
"""List all registered component names.
|
"""List all registered component names."""
|
||||||
|
return sorted(cls._entries)
|
||||||
Returns:
|
|
||||||
List of registered component names
|
|
||||||
"""
|
|
||||||
return cls._registry.list_names()
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def is_registered(cls, name: str) -> bool:
|
def is_registered(cls, name: str) -> bool:
|
||||||
"""Check if a component name is registered.
|
"""Check if a component name is registered."""
|
||||||
|
return name in cls._entries
|
||||||
Args:
|
|
||||||
name: Component name to check
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if registered, False otherwise
|
|
||||||
"""
|
|
||||||
return cls._registry.contains(name)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def list_by_category(cls, category: str) -> List[str]:
|
|
||||||
"""List registered component names in a category."""
|
|
||||||
return cls._registry.list_by_category(category)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def list_by_priority(cls, reverse: bool = False) -> List[str]:
|
|
||||||
"""List registered component names sorted by priority."""
|
|
||||||
return cls._registry.list_by_priority(reverse)
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["Registry", "BaseFactory"]
|
|
||||||
|
|||||||
@@ -11,13 +11,18 @@ Layers:
|
|||||||
|
|
||||||
from astrai.inference.api import (
|
from astrai.inference.api import (
|
||||||
AnthropicMessage,
|
AnthropicMessage,
|
||||||
|
BaseToolParser,
|
||||||
ChatCompletionRequest,
|
ChatCompletionRequest,
|
||||||
ChatMessage,
|
ChatMessage,
|
||||||
|
FunctionDef,
|
||||||
GenContext,
|
GenContext,
|
||||||
MessagesRequest,
|
MessagesRequest,
|
||||||
ProtocolHandler,
|
ProtocolHandler,
|
||||||
|
SimpleJsonToolParser,
|
||||||
StopChecker,
|
StopChecker,
|
||||||
app,
|
ToolDef,
|
||||||
|
ToolParserFactory,
|
||||||
|
get_app,
|
||||||
run_server,
|
run_server,
|
||||||
)
|
)
|
||||||
from astrai.inference.api.anthropic import AnthropicResponseBuilder
|
from astrai.inference.api.anthropic import AnthropicResponseBuilder
|
||||||
@@ -74,12 +79,17 @@ __all__ = [
|
|||||||
"ProtocolHandler",
|
"ProtocolHandler",
|
||||||
"StopChecker",
|
"StopChecker",
|
||||||
"GenContext",
|
"GenContext",
|
||||||
|
"BaseToolParser",
|
||||||
|
"SimpleJsonToolParser",
|
||||||
|
"ToolParserFactory",
|
||||||
"OpenAIResponseBuilder",
|
"OpenAIResponseBuilder",
|
||||||
"AnthropicResponseBuilder",
|
"AnthropicResponseBuilder",
|
||||||
"ChatMessage",
|
"ChatMessage",
|
||||||
"ChatCompletionRequest",
|
"ChatCompletionRequest",
|
||||||
|
"FunctionDef",
|
||||||
|
"ToolDef",
|
||||||
"AnthropicMessage",
|
"AnthropicMessage",
|
||||||
"MessagesRequest",
|
"MessagesRequest",
|
||||||
"app",
|
"get_app",
|
||||||
"run_server",
|
"run_server",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,23 +1,39 @@
|
|||||||
"""Inference API: protocol handler, stop checker, and FastAPI server."""
|
"""Inference API: protocol handler, stop checker, tool parsers, and FastAPI server.
|
||||||
|
|
||||||
|
``app`` is no longer a module-level global. Use :func:`get_app` to access the
|
||||||
|
lazy singleton FastAPI instance.
|
||||||
|
"""
|
||||||
|
|
||||||
from astrai.inference.api.protocol import GenContext, ProtocolHandler, StopChecker
|
from astrai.inference.api.protocol import GenContext, ProtocolHandler, StopChecker
|
||||||
from astrai.inference.api.server import (
|
from astrai.inference.api.server import (
|
||||||
AnthropicMessage,
|
AnthropicMessage,
|
||||||
ChatCompletionRequest,
|
ChatCompletionRequest,
|
||||||
ChatMessage,
|
ChatMessage,
|
||||||
|
FunctionDef,
|
||||||
MessagesRequest,
|
MessagesRequest,
|
||||||
app,
|
ToolDef,
|
||||||
|
get_app,
|
||||||
run_server,
|
run_server,
|
||||||
)
|
)
|
||||||
|
from astrai.inference.api.tool_parser import (
|
||||||
|
BaseToolParser,
|
||||||
|
SimpleJsonToolParser,
|
||||||
|
ToolParserFactory,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"ProtocolHandler",
|
"ProtocolHandler",
|
||||||
"StopChecker",
|
"StopChecker",
|
||||||
"GenContext",
|
"GenContext",
|
||||||
|
"BaseToolParser",
|
||||||
|
"SimpleJsonToolParser",
|
||||||
|
"ToolParserFactory",
|
||||||
"AnthropicMessage",
|
"AnthropicMessage",
|
||||||
"ChatCompletionRequest",
|
"ChatCompletionRequest",
|
||||||
"ChatMessage",
|
"ChatMessage",
|
||||||
|
"FunctionDef",
|
||||||
|
"ToolDef",
|
||||||
"MessagesRequest",
|
"MessagesRequest",
|
||||||
"app",
|
"get_app",
|
||||||
"run_server",
|
"run_server",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Anthropic message completion response builder."""
|
"""Anthropic message completion response builder."""
|
||||||
|
|
||||||
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Any, Dict, List, Tuple, Union
|
from typing import Any, Dict, List, Tuple, Union
|
||||||
|
|
||||||
@@ -39,9 +40,8 @@ class AnthropicResponseBuilder(ResponseBuilder):
|
|||||||
prompt = engine.tokenizer.apply_chat_template(messages, tokenize=False)
|
prompt = engine.tokenizer.apply_chat_template(messages, tokenize=False)
|
||||||
ctx = GenContext(
|
ctx = GenContext(
|
||||||
resp_id=f"msg_{uuid.uuid4().hex[:24]}",
|
resp_id=f"msg_{uuid.uuid4().hex[:24]}",
|
||||||
created=0,
|
created=int(time.time()),
|
||||||
model=request.model,
|
model=request.model,
|
||||||
prompt_tokens=0,
|
|
||||||
)
|
)
|
||||||
stop_sequences = getattr(request, "stop_sequences", None) or []
|
stop_sequences = getattr(request, "stop_sequences", None) or []
|
||||||
return prompt, ctx, stop_sequences
|
return prompt, ctx, stop_sequences
|
||||||
@@ -72,8 +72,9 @@ class AnthropicResponseBuilder(ResponseBuilder):
|
|||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
def format_chunk(self, token: str) -> str:
|
def format_chunk(self, token: str, **kwargs) -> List[str]:
|
||||||
return sse_event(
|
return [
|
||||||
|
sse_event(
|
||||||
{
|
{
|
||||||
"type": "content_block_delta",
|
"type": "content_block_delta",
|
||||||
"index": 0,
|
"index": 0,
|
||||||
@@ -81,6 +82,7 @@ class AnthropicResponseBuilder(ResponseBuilder):
|
|||||||
},
|
},
|
||||||
event="content_block_delta",
|
event="content_block_delta",
|
||||||
)
|
)
|
||||||
|
]
|
||||||
|
|
||||||
def format_stream_end(self, ctx: GenContext, stop: StopInfo) -> List[str]:
|
def format_stream_end(self, ctx: GenContext, stop: StopInfo) -> List[str]:
|
||||||
events: List[str] = []
|
events: List[str] = []
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
"""OpenAI chat completion response builder."""
|
"""OpenAI chat completion response builder."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Any, Dict, List, Tuple
|
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
@@ -11,24 +13,79 @@ from astrai.inference.api.protocol import (
|
|||||||
StopInfo,
|
StopInfo,
|
||||||
sse_event,
|
sse_event,
|
||||||
)
|
)
|
||||||
|
from astrai.inference.api.tool_parser import BaseToolParser, ToolParserFactory
|
||||||
from astrai.inference.engine import InferenceEngine
|
from astrai.inference.engine import InferenceEngine
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_UNSUPPORTED_PARAMS = (
|
||||||
|
"n",
|
||||||
|
"presence_penalty",
|
||||||
|
"frequency_penalty",
|
||||||
|
"logit_bias",
|
||||||
|
"user",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_tool_choice(
|
||||||
|
request: BaseModel,
|
||||||
|
) -> Union[str, Dict[str, Any]]:
|
||||||
|
tc = getattr(request, "tool_choice", None)
|
||||||
|
if tc is None:
|
||||||
|
return "auto"
|
||||||
|
if isinstance(tc, str):
|
||||||
|
return tc
|
||||||
|
if isinstance(tc, dict):
|
||||||
|
return tc
|
||||||
|
return "auto"
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_tools(request: BaseModel) -> Optional[List[Dict[str, Any]]]:
|
||||||
|
raw = getattr(request, "tools", None)
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
if isinstance(raw, list):
|
||||||
|
return [t.model_dump() if hasattr(t, "model_dump") else t for t in raw]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class OpenAIResponseBuilder(ResponseBuilder):
|
class OpenAIResponseBuilder(ResponseBuilder):
|
||||||
def prepare(
|
def prepare(
|
||||||
self, request: BaseModel, engine: InferenceEngine
|
self, request: BaseModel, engine: InferenceEngine
|
||||||
) -> Tuple[str, GenContext, List[str]]:
|
) -> Tuple[str, GenContext, List[str]]:
|
||||||
messages = [{"role": m.role, "content": m.content} for m in request.messages]
|
messages = [{"role": m.role, "content": m.content} for m in request.messages]
|
||||||
prompt = engine.tokenizer.apply_chat_template(messages, tokenize=False)
|
tools = _resolve_tools(request)
|
||||||
|
prompt = engine.tokenizer.apply_chat_template(
|
||||||
|
messages, tokenize=False, tools=tools or []
|
||||||
|
)
|
||||||
|
|
||||||
self._resp_id = f"chatcmpl-{uuid.uuid4().hex[:12]}"
|
self._resp_id = f"chatcmpl-{uuid.uuid4().hex[:12]}"
|
||||||
self._model = request.model
|
self._model = request.model
|
||||||
|
|
||||||
|
for param in _UNSUPPORTED_PARAMS:
|
||||||
|
value = getattr(request, param, None)
|
||||||
|
fields = getattr(type(request), "model_fields", {})
|
||||||
|
default = fields[param].default if param in fields else None
|
||||||
|
if value is not None and value != default:
|
||||||
|
logger.warning(
|
||||||
|
"ChatCompletionRequest param '%s'=%r is not supported"
|
||||||
|
" and will be ignored",
|
||||||
|
param,
|
||||||
|
value,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._parser: Optional[BaseToolParser] = None
|
||||||
|
if tools:
|
||||||
|
tool_choice = _resolve_tool_choice(request)
|
||||||
|
self._parser = ToolParserFactory.create(
|
||||||
|
"simple_json", tools=tools, tool_choice=tool_choice
|
||||||
|
)
|
||||||
|
self._content_started = False
|
||||||
|
|
||||||
ctx = GenContext(
|
ctx = GenContext(
|
||||||
resp_id=self._resp_id,
|
resp_id=self._resp_id,
|
||||||
created=0,
|
created=int(time.time()),
|
||||||
model=self._model,
|
model=self._model,
|
||||||
prompt_tokens=0,
|
|
||||||
)
|
)
|
||||||
stop = request.stop
|
stop = request.stop
|
||||||
stop_sequences = (
|
stop_sequences = (
|
||||||
@@ -55,7 +112,82 @@ class OpenAIResponseBuilder(ResponseBuilder):
|
|||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|
||||||
def format_chunk(self, token: str) -> str:
|
def format_chunk(self, token: str, **kwargs) -> List[str]:
|
||||||
|
body = kwargs.get("body", "")
|
||||||
|
if self._parser is not None:
|
||||||
|
return self._format_tool_chunk(body, **kwargs)
|
||||||
|
|
||||||
|
return [
|
||||||
|
sse_event(
|
||||||
|
{
|
||||||
|
"id": self._resp_id,
|
||||||
|
"object": "chat.completion.chunk",
|
||||||
|
"created": 0,
|
||||||
|
"model": self._model,
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"delta": {"content": token},
|
||||||
|
"finish_reason": None,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
def _format_tool_chunk(self, body: str, **kwargs) -> List[str]:
|
||||||
|
deltas = self._parser.feed(
|
||||||
|
body,
|
||||||
|
current_token_ids=kwargs.get("current_token_ids"),
|
||||||
|
delta_token_ids=kwargs.get("delta_token_ids"),
|
||||||
|
)
|
||||||
|
events: List[str] = []
|
||||||
|
for d in deltas:
|
||||||
|
if "content" in d:
|
||||||
|
if not self._content_started:
|
||||||
|
events.append(self._role_chunk())
|
||||||
|
self._content_started = True
|
||||||
|
events.append(
|
||||||
|
sse_event(
|
||||||
|
{
|
||||||
|
"id": self._resp_id,
|
||||||
|
"object": "chat.completion.chunk",
|
||||||
|
"created": 0,
|
||||||
|
"model": self._model,
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"delta": {"content": d["content"]},
|
||||||
|
"finish_reason": None,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif "tool_calls" in d:
|
||||||
|
if not self._content_started:
|
||||||
|
events.append(self._role_chunk())
|
||||||
|
self._content_started = True
|
||||||
|
events.append(
|
||||||
|
sse_event(
|
||||||
|
{
|
||||||
|
"id": self._resp_id,
|
||||||
|
"object": "chat.completion.chunk",
|
||||||
|
"created": 0,
|
||||||
|
"model": self._model,
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"delta": {"tool_calls": d["tool_calls"]},
|
||||||
|
"finish_reason": None,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return events
|
||||||
|
|
||||||
|
def _role_chunk(self) -> str:
|
||||||
return sse_event(
|
return sse_event(
|
||||||
{
|
{
|
||||||
"id": self._resp_id,
|
"id": self._resp_id,
|
||||||
@@ -63,12 +195,19 @@ class OpenAIResponseBuilder(ResponseBuilder):
|
|||||||
"created": 0,
|
"created": 0,
|
||||||
"model": self._model,
|
"model": self._model,
|
||||||
"choices": [
|
"choices": [
|
||||||
{"index": 0, "delta": {"content": token}, "finish_reason": None}
|
{
|
||||||
|
"index": 0,
|
||||||
|
"delta": {"role": "assistant"},
|
||||||
|
"finish_reason": None,
|
||||||
|
}
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
def format_stream_end(self, ctx: GenContext, stop: StopInfo) -> List[str]:
|
def format_stream_end(self, ctx: GenContext, stop: StopInfo) -> List[str]:
|
||||||
|
finish_reason = "stop"
|
||||||
|
if self._parser is not None and self._parser.has_tool_calls:
|
||||||
|
finish_reason = "tool_calls"
|
||||||
return [
|
return [
|
||||||
sse_event(
|
sse_event(
|
||||||
{
|
{
|
||||||
@@ -76,7 +215,9 @@ class OpenAIResponseBuilder(ResponseBuilder):
|
|||||||
"object": "chat.completion.chunk",
|
"object": "chat.completion.chunk",
|
||||||
"created": ctx.created,
|
"created": ctx.created,
|
||||||
"model": self._model,
|
"model": self._model,
|
||||||
"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
|
"choices": [
|
||||||
|
{"index": 0, "delta": {}, "finish_reason": finish_reason}
|
||||||
|
],
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
sse_event(
|
sse_event(
|
||||||
@@ -91,6 +232,32 @@ class OpenAIResponseBuilder(ResponseBuilder):
|
|||||||
def format_response(
|
def format_response(
|
||||||
self, ctx: GenContext, content: str, stop: StopInfo
|
self, ctx: GenContext, content: str, stop: StopInfo
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
|
if self._parser is not None:
|
||||||
|
parsed = self._parser.parse_complete(content)
|
||||||
|
if parsed and parsed.get("tool_calls"):
|
||||||
|
return {
|
||||||
|
"id": self._resp_id,
|
||||||
|
"object": "chat.completion",
|
||||||
|
"created": ctx.created,
|
||||||
|
"model": self._model,
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"message": {
|
||||||
|
"role": "assistant",
|
||||||
|
"content": parsed.get("content"),
|
||||||
|
"tool_calls": parsed["tool_calls"],
|
||||||
|
},
|
||||||
|
"finish_reason": "tool_calls",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"usage": {
|
||||||
|
"prompt_tokens": ctx.prompt_tokens,
|
||||||
|
"completion_tokens": ctx.completion_tokens,
|
||||||
|
"total_tokens": ctx.prompt_tokens + ctx.completion_tokens,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": self._resp_id,
|
"id": self._resp_id,
|
||||||
"object": "chat.completion",
|
"object": "chat.completion",
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ class GenContext:
|
|||||||
resp_id: str
|
resp_id: str
|
||||||
created: int
|
created: int
|
||||||
model: str
|
model: str
|
||||||
prompt_tokens: int
|
prompt_tokens: int = 0
|
||||||
completion_tokens: int = 0
|
completion_tokens: int = 0
|
||||||
|
|
||||||
|
|
||||||
@@ -64,7 +64,7 @@ class StopChecker:
|
|||||||
class ResponseBuilder(ABC):
|
class ResponseBuilder(ABC):
|
||||||
"""Interface for protocol-specific response formatting.
|
"""Interface for protocol-specific response formatting.
|
||||||
|
|
||||||
A new protocol requires one concrete builder implementing 6 methods.
|
A new protocol requires one concrete builder implementing 5 methods.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
@@ -78,8 +78,15 @@ class ResponseBuilder(ABC):
|
|||||||
"""SSE events that open the stream."""
|
"""SSE events that open the stream."""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def format_chunk(self, token: str) -> str:
|
def format_chunk(self, token: str, **kwargs) -> List[str]:
|
||||||
"""SSE event for a single generated token."""
|
"""SSE events for a single generated token.
|
||||||
|
|
||||||
|
``body`` (the full accumulated text so far) is always provided
|
||||||
|
as a keyword argument. Additional keyword arguments such as
|
||||||
|
``current_token_ids`` and ``delta_token_ids`` may be included
|
||||||
|
for tool parsers that need token-level information.
|
||||||
|
Returns a list of SSE event strings (may be empty).
|
||||||
|
"""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def format_stream_end(self, ctx: GenContext, stop: StopInfo) -> List[str]:
|
def format_stream_end(self, ctx: GenContext, stop: StopInfo) -> List[str]:
|
||||||
@@ -137,15 +144,25 @@ class ProtocolHandler:
|
|||||||
body = ""
|
body = ""
|
||||||
yielded = ""
|
yielded = ""
|
||||||
matched = None
|
matched = None
|
||||||
|
token_ids: List[int] = []
|
||||||
async for token in agen:
|
async for token in agen:
|
||||||
ctx.completion_tokens += 1
|
|
||||||
body += token
|
body += token
|
||||||
|
|
||||||
|
new_ids = self.engine.tokenizer.encode(token)
|
||||||
|
token_ids.extend(new_ids)
|
||||||
|
|
||||||
matched = checker.check(body)
|
matched = checker.check(body)
|
||||||
if matched:
|
if matched:
|
||||||
break
|
break
|
||||||
|
|
||||||
yield self.builder.format_chunk(token)
|
ctx.completion_tokens += 1
|
||||||
|
for event in self.builder.format_chunk(
|
||||||
|
token,
|
||||||
|
body=body,
|
||||||
|
current_token_ids=token_ids,
|
||||||
|
delta_token_ids=new_ids,
|
||||||
|
):
|
||||||
|
yield event
|
||||||
yielded += token
|
yielded += token
|
||||||
|
|
||||||
stop = StopInfo(matched=matched, body=body, yielded=yielded)
|
stop = StopInfo(matched=matched, body=body, yielded=yielded)
|
||||||
@@ -168,7 +185,6 @@ class ProtocolHandler:
|
|||||||
matched = None
|
matched = None
|
||||||
|
|
||||||
async for token in agen:
|
async for token in agen:
|
||||||
ctx.completion_tokens += 1
|
|
||||||
chunks.append(token)
|
chunks.append(token)
|
||||||
body += token
|
body += token
|
||||||
|
|
||||||
@@ -176,6 +192,8 @@ class ProtocolHandler:
|
|||||||
if matched:
|
if matched:
|
||||||
break
|
break
|
||||||
|
|
||||||
|
ctx.completion_tokens += 1
|
||||||
|
|
||||||
content = "".join(chunks)
|
content = "".join(chunks)
|
||||||
stop = StopInfo(matched=matched, body=body)
|
stop = StopInfo(matched=matched, body=body)
|
||||||
return self.builder.format_response(ctx, content, stop)
|
return self.builder.format_response(ctx, content, stop)
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ OpenAI / Anthropic-compatible chat completion server backed by continuous-batchi
|
|||||||
|
|
||||||
Protocol-specific formatting is delegated to ``astrai.inference.protocol``.
|
Protocol-specific formatting is delegated to ``astrai.inference.protocol``.
|
||||||
This module owns the FastAPI app, request/response schemas, and dependency wiring.
|
This module owns the FastAPI app, request/response schemas, and dependency wiring.
|
||||||
|
|
||||||
|
``app`` is lazily constructed — importing this module does NOT create a FastAPI instance.
|
||||||
|
Use :func:`get_app` to access the singleton.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -12,7 +15,7 @@ from typing import Any, Dict, List, Optional, Union
|
|||||||
|
|
||||||
import torch
|
import torch
|
||||||
import uvicorn
|
import uvicorn
|
||||||
from fastapi import FastAPI, HTTPException
|
from fastapi import APIRouter, FastAPI, HTTPException
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from astrai.inference.api.anthropic import AnthropicResponseBuilder
|
from astrai.inference.api.anthropic import AnthropicResponseBuilder
|
||||||
@@ -24,12 +27,25 @@ from astrai.tokenize import AutoTokenizer
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_project_root = Path(__file__).parent.parent.parent
|
_app_instance: Optional[FastAPI] = None
|
||||||
|
|
||||||
|
|
||||||
class ChatMessage(BaseModel):
|
class ChatMessage(BaseModel):
|
||||||
role: str
|
role: str
|
||||||
content: str
|
content: Optional[str] = None
|
||||||
|
tool_calls: Optional[List[Dict[str, Any]]] = None
|
||||||
|
tool_call_id: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class FunctionDef(BaseModel):
|
||||||
|
name: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
parameters: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ToolDef(BaseModel):
|
||||||
|
type: str = "function"
|
||||||
|
function: FunctionDef
|
||||||
|
|
||||||
|
|
||||||
class ChatCompletionRequest(BaseModel):
|
class ChatCompletionRequest(BaseModel):
|
||||||
@@ -48,6 +64,8 @@ class ChatCompletionRequest(BaseModel):
|
|||||||
frequency_penalty: Optional[float] = Field(default=0.0, ge=-2.0, le=2.0)
|
frequency_penalty: Optional[float] = Field(default=0.0, ge=-2.0, le=2.0)
|
||||||
logit_bias: Optional[Dict[int, float]] = None
|
logit_bias: Optional[Dict[int, float]] = None
|
||||||
user: Optional[str] = None
|
user: Optional[str] = None
|
||||||
|
tools: Optional[List[ToolDef]] = None
|
||||||
|
tool_choice: Optional[Union[str, Dict[str, Any]]] = "auto"
|
||||||
|
|
||||||
|
|
||||||
class AnthropicMessage(BaseModel):
|
class AnthropicMessage(BaseModel):
|
||||||
@@ -84,17 +102,15 @@ async def lifespan(app: FastAPI):
|
|||||||
logger.info("Inference engine shutdown complete")
|
logger.info("Inference engine shutdown complete")
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="AstrAI Inference Server", version="0.2.0", lifespan=lifespan)
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
def _create_engine(
|
def _create_engine(
|
||||||
param_path: Optional[Path] = None,
|
param_path: Path,
|
||||||
device: str = "cuda",
|
device: str = "cuda",
|
||||||
dtype: torch.dtype = torch.bfloat16,
|
dtype: torch.dtype = torch.bfloat16,
|
||||||
max_batch_size: int = 16,
|
max_batch_size: int = 16,
|
||||||
) -> InferenceEngine:
|
) -> InferenceEngine:
|
||||||
if param_path is None:
|
|
||||||
param_path = _project_root / "params"
|
|
||||||
if not param_path.exists():
|
if not param_path.exists():
|
||||||
raise FileNotFoundError(f"Parameter directory not found: {param_path}")
|
raise FileNotFoundError(f"Parameter directory not found: {param_path}")
|
||||||
|
|
||||||
@@ -112,34 +128,50 @@ def _create_engine(
|
|||||||
return engine
|
return engine
|
||||||
|
|
||||||
|
|
||||||
|
def get_app() -> FastAPI:
|
||||||
|
"""Return the singleton FastAPI instance (lazily created on first call)."""
|
||||||
|
global _app_instance
|
||||||
|
if _app_instance is None:
|
||||||
|
_app_instance = FastAPI(
|
||||||
|
title="AstrAI Inference Server",
|
||||||
|
version="0.2.0",
|
||||||
|
lifespan=lifespan,
|
||||||
|
)
|
||||||
|
_app_instance.include_router(router)
|
||||||
|
_app_instance.state.server_config = {}
|
||||||
|
_app_instance.state.engine = None
|
||||||
|
return _app_instance
|
||||||
|
|
||||||
|
|
||||||
def _get_engine() -> InferenceEngine:
|
def _get_engine() -> InferenceEngine:
|
||||||
engine = app.state.engine
|
engine = get_app().state.engine
|
||||||
if engine is None:
|
if engine is None:
|
||||||
raise HTTPException(status_code=503, detail="Engine not initialized")
|
raise HTTPException(status_code=503, detail="Engine not initialized")
|
||||||
return engine
|
return engine
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@router.get("/health")
|
||||||
async def health():
|
async def health():
|
||||||
|
app = get_app()
|
||||||
return {
|
return {
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"model_loaded": app.state.engine is not None,
|
"model_loaded": app.state.engine is not None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/stats")
|
@router.get("/stats")
|
||||||
async def get_stats():
|
async def get_stats():
|
||||||
return _get_engine().get_stats()
|
return _get_engine().get_stats()
|
||||||
|
|
||||||
|
|
||||||
@app.post("/v1/chat/completions")
|
@router.post("/v1/chat/completions")
|
||||||
async def chat_completion(request: ChatCompletionRequest):
|
async def chat_completion(request: ChatCompletionRequest):
|
||||||
engine = _get_engine()
|
engine = _get_engine()
|
||||||
handler = ProtocolHandler(request, engine, OpenAIResponseBuilder())
|
handler = ProtocolHandler(request, engine, OpenAIResponseBuilder())
|
||||||
return await handler.handle()
|
return await handler.handle()
|
||||||
|
|
||||||
|
|
||||||
@app.post("/v1/messages")
|
@router.post("/v1/messages")
|
||||||
async def create_message(request: MessagesRequest):
|
async def create_message(request: MessagesRequest):
|
||||||
engine = _get_engine()
|
engine = _get_engine()
|
||||||
handler = ProtocolHandler(request, engine, AnthropicResponseBuilder())
|
handler = ProtocolHandler(request, engine, AnthropicResponseBuilder())
|
||||||
@@ -147,14 +179,15 @@ async def create_message(request: MessagesRequest):
|
|||||||
|
|
||||||
|
|
||||||
def run_server(
|
def run_server(
|
||||||
|
param_path: Path,
|
||||||
host: str = "0.0.0.0",
|
host: str = "0.0.0.0",
|
||||||
port: int = 8000,
|
port: int = 8000,
|
||||||
reload: bool = False,
|
reload: bool = False,
|
||||||
device: str = "cuda",
|
device: str = "cuda",
|
||||||
dtype: torch.dtype = torch.bfloat16,
|
dtype: torch.dtype = torch.bfloat16,
|
||||||
param_path: Optional[Path] = None,
|
|
||||||
max_batch_size: int = 16,
|
max_batch_size: int = 16,
|
||||||
):
|
):
|
||||||
|
app = get_app()
|
||||||
app.state.server_config = {
|
app.state.server_config = {
|
||||||
"device": device,
|
"device": device,
|
||||||
"dtype": dtype,
|
"dtype": dtype,
|
||||||
|
|||||||
@@ -0,0 +1,325 @@
|
|||||||
|
"""Tool call parsers for extracting structured tool calls from model output.
|
||||||
|
|
||||||
|
Patterned after vLLM's ToolParser abstraction. Each parser knows how to
|
||||||
|
detect and incrementally extract tool calls from raw generated text.
|
||||||
|
|
||||||
|
Subclasses may optionally consume ``token_ids`` for token-level parsing
|
||||||
|
(e.g. Harmony / VLM-style parsers).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import uuid
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
|
from astrai.factory import BaseFactory
|
||||||
|
|
||||||
|
|
||||||
|
class BaseToolParser(ABC):
|
||||||
|
"""Abstract tool call parser — one instance per request.
|
||||||
|
|
||||||
|
Maintains streaming state internally so that each call to :meth:`feed`
|
||||||
|
can diff against previously emitted content.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
tools : list of dict, optional
|
||||||
|
Tool definitions from the request.
|
||||||
|
tool_choice : str
|
||||||
|
``"auto"`` / ``"required"`` / ``"none"`` or a named tool choice
|
||||||
|
dict.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, tools: Optional[List[Dict]] = None, tool_choice: str = "auto"):
|
||||||
|
self.tools = tools or []
|
||||||
|
self.tool_choice = tool_choice
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def feed(
|
||||||
|
self,
|
||||||
|
body: str,
|
||||||
|
current_token_ids: Optional[List[int]] = None,
|
||||||
|
delta_token_ids: Optional[List[int]] = None,
|
||||||
|
) -> List[Dict]:
|
||||||
|
"""Feed the *full* accumulated text each step.
|
||||||
|
|
||||||
|
Returns a list of delta dicts to emit. Each delta is one of:
|
||||||
|
|
||||||
|
- ``{"content": "text"}`` — plain text delta
|
||||||
|
- ``{"tool_calls": [...]}`` — tool-call delta (OpenAI format)
|
||||||
|
|
||||||
|
Returns an empty list when nothing new should be emitted.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
body : str
|
||||||
|
The complete accumulated generated text so far.
|
||||||
|
current_token_ids : list of int, optional
|
||||||
|
All token IDs decoded into *body* (cumulative).
|
||||||
|
delta_token_ids : list of int, optional
|
||||||
|
Only the token IDs for this chunk.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def parse_complete(self, body: str) -> Optional[Dict]:
|
||||||
|
"""Parse the *complete* generated text after generation ends.
|
||||||
|
|
||||||
|
Returns ``None`` when no tool calls were found, otherwise a dict
|
||||||
|
with ``content`` (str or None) and ``tool_calls`` (list of dicts).
|
||||||
|
"""
|
||||||
|
|
||||||
|
@property
|
||||||
|
@abstractmethod
|
||||||
|
def has_tool_calls(self) -> bool:
|
||||||
|
"""True if the parser detected at least one tool call in the stream."""
|
||||||
|
|
||||||
|
|
||||||
|
class ToolParserFactory(BaseFactory["BaseToolParser"]):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
_TOOL_CALL_HEAD_RE = re.compile(r'\{\s*"name"\s*:')
|
||||||
|
|
||||||
|
|
||||||
|
def _scan_json(text: str, start: int = 0):
|
||||||
|
"""Scan for a complete JSON object starting at *start*.
|
||||||
|
|
||||||
|
Returns ``(end, complete)`` where *end* is one-past the closing
|
||||||
|
brace (or ``len(text)`` if unclosed), and *complete* is a bool.
|
||||||
|
"""
|
||||||
|
depth = 0
|
||||||
|
in_string = False
|
||||||
|
escape = False
|
||||||
|
for i in range(start, len(text)):
|
||||||
|
c = text[i]
|
||||||
|
if escape:
|
||||||
|
escape = False
|
||||||
|
continue
|
||||||
|
if c == "\\":
|
||||||
|
escape = True
|
||||||
|
continue
|
||||||
|
if c == '"':
|
||||||
|
in_string = not in_string
|
||||||
|
continue
|
||||||
|
if in_string:
|
||||||
|
continue
|
||||||
|
if c == "{":
|
||||||
|
depth += 1
|
||||||
|
elif c == "}":
|
||||||
|
depth -= 1
|
||||||
|
if depth == 0:
|
||||||
|
return i + 1, True
|
||||||
|
return len(text), False
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_tool_call_json(json_str: str, complete: bool):
|
||||||
|
"""Extract *name* and *arguments* from a tool-call JSON string.
|
||||||
|
|
||||||
|
Returns ``(name, args, valid)``.
|
||||||
|
"""
|
||||||
|
name_match = re.search(r'"name"\s*:\s*"([^"]*)"', json_str)
|
||||||
|
if not name_match:
|
||||||
|
return None, "", False
|
||||||
|
name = name_match.group(1)
|
||||||
|
|
||||||
|
args_match = re.search(r'"arguments"\s*:\s*(.*)', json_str, re.DOTALL)
|
||||||
|
if not args_match:
|
||||||
|
return name, "", True
|
||||||
|
|
||||||
|
raw = args_match.group(1).rstrip()
|
||||||
|
if complete and raw.endswith("}"):
|
||||||
|
raw = raw[:-1].rstrip()
|
||||||
|
if raw.startswith("{"):
|
||||||
|
inner = raw[1:].rstrip()
|
||||||
|
if inner.endswith("}"):
|
||||||
|
inner = inner[:-1].rstrip()
|
||||||
|
raw = inner
|
||||||
|
return name, raw, True
|
||||||
|
|
||||||
|
|
||||||
|
def _find_tool_calls(text: str, start_pos: int = 0):
|
||||||
|
"""Find all complete ``{...}`` tool-call objects in *text*.
|
||||||
|
|
||||||
|
Returns a list of dicts with keys *start*, *end*, *name*, *args*,
|
||||||
|
*complete*.
|
||||||
|
"""
|
||||||
|
results = []
|
||||||
|
pos = start_pos
|
||||||
|
|
||||||
|
while True:
|
||||||
|
brace = text.find("{", pos)
|
||||||
|
if brace == -1:
|
||||||
|
break
|
||||||
|
|
||||||
|
end, complete = _scan_json(text, brace)
|
||||||
|
if not complete:
|
||||||
|
break
|
||||||
|
|
||||||
|
json_str = text[brace:end]
|
||||||
|
if not _TOOL_CALL_HEAD_RE.search(json_str):
|
||||||
|
pos = end
|
||||||
|
continue
|
||||||
|
|
||||||
|
name, args, valid = _parse_tool_call_json(json_str, complete=True)
|
||||||
|
if not valid or name is None:
|
||||||
|
pos = end
|
||||||
|
continue
|
||||||
|
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"start": brace,
|
||||||
|
"end": end,
|
||||||
|
"name": name,
|
||||||
|
"args": args,
|
||||||
|
"complete": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
pos = end
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def _find_partial_tool_call(text: str, start_pos: int = 0):
|
||||||
|
"""Find one incomplete (still-generating) tool-call JSON object."""
|
||||||
|
brace = text.find("{", start_pos)
|
||||||
|
if brace == -1:
|
||||||
|
return None
|
||||||
|
|
||||||
|
json_str = text[brace:]
|
||||||
|
if not _TOOL_CALL_HEAD_RE.search(json_str):
|
||||||
|
return None
|
||||||
|
|
||||||
|
name, args, valid = _parse_tool_call_json(json_str, complete=False)
|
||||||
|
if not valid or name is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"start": brace,
|
||||||
|
"name": name,
|
||||||
|
"args": args,
|
||||||
|
"complete": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ToolParserFactory.register("simple_json")
|
||||||
|
class SimpleJsonToolParser(BaseToolParser):
|
||||||
|
"""Parser for models that output tool calls as plain JSON objects.
|
||||||
|
|
||||||
|
Detects ``{"name": "<func>", "arguments": {...}}`` anywhere in the
|
||||||
|
generated text. Handles single and (non-overlapping) multiple tool
|
||||||
|
calls. Text preceding the first tool call is emitted as plain
|
||||||
|
``content`` deltas.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, tools=None, tool_choice="auto"):
|
||||||
|
super().__init__(tools, tool_choice)
|
||||||
|
self._emitted_content_len = 0
|
||||||
|
self._tc_state: List[Dict] = []
|
||||||
|
self._has_tool_calls = False
|
||||||
|
|
||||||
|
# -------------------------------------------------------------- feed
|
||||||
|
|
||||||
|
def feed(
|
||||||
|
self,
|
||||||
|
body: str,
|
||||||
|
current_token_ids: Optional[List[int]] = None,
|
||||||
|
delta_token_ids: Optional[List[int]] = None,
|
||||||
|
) -> List[Dict]:
|
||||||
|
deltas: List[Dict] = []
|
||||||
|
|
||||||
|
completed = _find_tool_calls(body)
|
||||||
|
|
||||||
|
if not completed:
|
||||||
|
partial = _find_partial_tool_call(body)
|
||||||
|
if not partial:
|
||||||
|
return self._emit_plain_content(body, deltas)
|
||||||
|
all_tcs = [partial]
|
||||||
|
else:
|
||||||
|
all_tcs = completed
|
||||||
|
partial = _find_partial_tool_call(body, completed[-1]["end"])
|
||||||
|
if partial:
|
||||||
|
all_tcs = completed + [partial]
|
||||||
|
|
||||||
|
first_start = all_tcs[0]["start"]
|
||||||
|
if first_start > self._emitted_content_len:
|
||||||
|
content = body[self._emitted_content_len : first_start]
|
||||||
|
self._emitted_content_len = first_start
|
||||||
|
if content:
|
||||||
|
deltas.append({"content": content})
|
||||||
|
|
||||||
|
for i, tc in enumerate(all_tcs):
|
||||||
|
if i >= len(self._tc_state):
|
||||||
|
self._tc_state.append(
|
||||||
|
{
|
||||||
|
"id": f"call_{uuid.uuid4().hex[:12]}",
|
||||||
|
"name_emitted": False,
|
||||||
|
"args_emitted_len": 0,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self._has_tool_calls = True
|
||||||
|
st = self._tc_state[i]
|
||||||
|
|
||||||
|
if not st["name_emitted"]:
|
||||||
|
st["name_emitted"] = True
|
||||||
|
deltas.append(
|
||||||
|
{
|
||||||
|
"tool_calls": [
|
||||||
|
{
|
||||||
|
"index": i,
|
||||||
|
"id": st["id"],
|
||||||
|
"type": "function",
|
||||||
|
"function": {"name": tc["name"], "arguments": ""},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
new_args = tc["args"]
|
||||||
|
if len(new_args) > st["args_emitted_len"]:
|
||||||
|
diff = new_args[st["args_emitted_len"] :]
|
||||||
|
st["args_emitted_len"] = len(new_args)
|
||||||
|
deltas.append(
|
||||||
|
{
|
||||||
|
"tool_calls": [
|
||||||
|
{
|
||||||
|
"index": i,
|
||||||
|
"function": {"arguments": diff},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return deltas
|
||||||
|
|
||||||
|
def _emit_plain_content(self, body: str, deltas: List[Dict]) -> List[Dict]:
|
||||||
|
new_content = body[self._emitted_content_len :]
|
||||||
|
if new_content:
|
||||||
|
self._emitted_content_len = len(body)
|
||||||
|
deltas.append({"content": new_content})
|
||||||
|
return deltas
|
||||||
|
|
||||||
|
# -------------------------------------------------------- complete
|
||||||
|
|
||||||
|
def parse_complete(self, body: str) -> Optional[Dict]:
|
||||||
|
completed = _find_tool_calls(body)
|
||||||
|
if not completed:
|
||||||
|
return None
|
||||||
|
|
||||||
|
content = body[: completed[0]["start"]].strip() or None
|
||||||
|
tool_calls = []
|
||||||
|
for i, tc in enumerate(completed):
|
||||||
|
tool_calls.append(
|
||||||
|
{
|
||||||
|
"id": f"call_{uuid.uuid4().hex[:12]}",
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": tc["name"],
|
||||||
|
"arguments": tc["args"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {"content": content, "tool_calls": tool_calls}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_tool_calls(self) -> bool:
|
||||||
|
return self._has_tool_calls
|
||||||
@@ -70,7 +70,8 @@ class InferenceScheduler:
|
|||||||
dtype=self.dtype,
|
dtype=self.dtype,
|
||||||
)
|
)
|
||||||
|
|
||||||
self._running = False
|
self._stop_event = threading.Event()
|
||||||
|
self._loop_thread: Optional[threading.Thread] = None
|
||||||
|
|
||||||
def add_task(self, prompt: str, **kwargs) -> str:
|
def add_task(self, prompt: str, **kwargs) -> str:
|
||||||
return self._task_mgr.add_task(prompt, **kwargs)
|
return self._task_mgr.add_task(prompt, **kwargs)
|
||||||
@@ -85,7 +86,7 @@ class InferenceScheduler:
|
|||||||
def _run_generation_loop(self):
|
def _run_generation_loop(self):
|
||||||
stop_ids = self._task_mgr.tokenizer.stop_ids
|
stop_ids = self._task_mgr.tokenizer.stop_ids
|
||||||
try:
|
try:
|
||||||
while self._running:
|
while not self._stop_event.is_set():
|
||||||
finished = self._task_mgr.remove_finished_tasks(stop_ids)
|
finished = self._task_mgr.remove_finished_tasks(stop_ids)
|
||||||
for task in finished:
|
for task in finished:
|
||||||
self._page_cache.task_free(task.task_id)
|
self._page_cache.task_free(task.task_id)
|
||||||
@@ -175,6 +176,7 @@ class InferenceScheduler:
|
|||||||
t.stream_callback(STOP)
|
t.stream_callback(STOP)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
self._stop_event.set()
|
||||||
logger.error(f"Scheduler loop crashed: {e}", exc_info=True)
|
logger.error(f"Scheduler loop crashed: {e}", exc_info=True)
|
||||||
for task in self._task_mgr.get_active_tasks():
|
for task in self._task_mgr.get_active_tasks():
|
||||||
if task.stream_callback:
|
if task.stream_callback:
|
||||||
@@ -184,22 +186,28 @@ class InferenceScheduler:
|
|||||||
if task.stream_callback:
|
if task.stream_callback:
|
||||||
task.stream_callback(STOP)
|
task.stream_callback(STOP)
|
||||||
self._task_mgr.clear_queues()
|
self._task_mgr.clear_queues()
|
||||||
raise
|
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
if not self._running:
|
if self._loop_thread is not None and self._loop_thread.is_alive():
|
||||||
self._running = True
|
return
|
||||||
|
self._stop_event.clear()
|
||||||
t = threading.Thread(target=self._run_generation_loop, daemon=True)
|
t = threading.Thread(target=self._run_generation_loop, daemon=True)
|
||||||
t.start()
|
t.start()
|
||||||
self._loop_thread = t
|
self._loop_thread = t
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
self._running = False
|
self._stop_event.set()
|
||||||
self._task_mgr.wake()
|
self._task_mgr.wake()
|
||||||
if hasattr(self, "_loop_thread"):
|
if self._loop_thread is not None:
|
||||||
self._loop_thread.join(timeout=2.0)
|
self._loop_thread.join(timeout=2.0)
|
||||||
|
self._loop_thread = None
|
||||||
for task in self._task_mgr.get_active_tasks():
|
for task in self._task_mgr.get_active_tasks():
|
||||||
|
if task.stream_callback:
|
||||||
|
task.stream_callback(STOP)
|
||||||
self._page_cache.task_free(task.task_id)
|
self._page_cache.task_free(task.task_id)
|
||||||
|
for task in self._task_mgr.get_waiting_tasks():
|
||||||
|
if task.stream_callback:
|
||||||
|
task.stream_callback(STOP)
|
||||||
self._task_mgr.clear_queues()
|
self._task_mgr.clear_queues()
|
||||||
if torch.cuda.is_available():
|
if torch.cuda.is_available():
|
||||||
torch.cuda.empty_cache()
|
torch.cuda.empty_cache()
|
||||||
|
|||||||
@@ -186,6 +186,9 @@ class TaskManager:
|
|||||||
return bool(self.active_tasks or self.waiting_queue)
|
return bool(self.active_tasks or self.waiting_queue)
|
||||||
|
|
||||||
def wait_for_tasks(self, timeout: float = 1.0):
|
def wait_for_tasks(self, timeout: float = 1.0):
|
||||||
|
with self._lock:
|
||||||
|
if self.waiting_queue or self.active_tasks:
|
||||||
|
return
|
||||||
self._task_event.clear()
|
self._task_event.clear()
|
||||||
self._task_event.wait(timeout=timeout)
|
self._task_event.wait(timeout=timeout)
|
||||||
|
|
||||||
|
|||||||
@@ -79,8 +79,8 @@ class GenerationRequest:
|
|||||||
raise ValueError("top_k must be a non-negative integer")
|
raise ValueError("top_k must be a non-negative integer")
|
||||||
if not (0.0 <= top_p <= 1.0):
|
if not (0.0 <= top_p <= 1.0):
|
||||||
raise ValueError("top_p must be a float between 0.0 and 1.0")
|
raise ValueError("top_p must be a float between 0.0 and 1.0")
|
||||||
if not (isinstance(temperature, (int, float)) and temperature >= 0):
|
if not (isinstance(temperature, (int, float)) and temperature > 0):
|
||||||
raise ValueError("temperature must be a non-negative number")
|
raise ValueError("temperature must be a positive number")
|
||||||
|
|
||||||
self.messages = messages
|
self.messages = messages
|
||||||
self.top_k = top_k
|
self.top_k = top_k
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ class BaseSamplingStrategy(ABC):
|
|||||||
Returns:
|
Returns:
|
||||||
Transformed logits tensor.
|
Transformed logits tensor.
|
||||||
"""
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
class TemperatureStrategy(BaseSamplingStrategy):
|
class TemperatureStrategy(BaseSamplingStrategy):
|
||||||
@@ -41,13 +42,15 @@ class TemperatureStrategy(BaseSamplingStrategy):
|
|||||||
def __init__(self, temperature: Union[float, Tensor] = 1.0):
|
def __init__(self, temperature: Union[float, Tensor] = 1.0):
|
||||||
self.temperature = temperature
|
self.temperature = temperature
|
||||||
|
|
||||||
def apply(self, logits, filter_value=-float("inf")):
|
def apply(self, logits: Tensor, filter_value: float = -float("inf")) -> Tensor:
|
||||||
t = self.temperature
|
t = self.temperature
|
||||||
if isinstance(t, Tensor):
|
if isinstance(t, Tensor):
|
||||||
|
t = t.to(logits.device, non_blocking=True).view(-1, 1)
|
||||||
|
t = torch.clamp(t, min=1e-8)
|
||||||
if (t != 1.0).any():
|
if (t != 1.0).any():
|
||||||
logits = logits / t.to(logits.device, non_blocking=True).view(-1, 1)
|
|
||||||
elif t != 1.0:
|
|
||||||
logits = logits / t
|
logits = logits / t
|
||||||
|
elif t != 1.0:
|
||||||
|
logits = logits / max(t, 1e-8)
|
||||||
return logits
|
return logits
|
||||||
|
|
||||||
|
|
||||||
@@ -61,7 +64,7 @@ class TopKStrategy(BaseSamplingStrategy):
|
|||||||
def __init__(self, top_k: Union[int, Tensor] = 0):
|
def __init__(self, top_k: Union[int, Tensor] = 0):
|
||||||
self.top_k = top_k
|
self.top_k = top_k
|
||||||
|
|
||||||
def apply(self, logits, filter_value=-float("inf")):
|
def apply(self, logits: Tensor, filter_value: float = -float("inf")) -> Tensor:
|
||||||
tk = self.top_k
|
tk = self.top_k
|
||||||
if isinstance(tk, Tensor):
|
if isinstance(tk, Tensor):
|
||||||
tk = tk.to(logits.device, non_blocking=True).long().clamp(min=0)
|
tk = tk.to(logits.device, non_blocking=True).long().clamp(min=0)
|
||||||
@@ -98,7 +101,9 @@ class TopPStrategy(BaseSamplingStrategy):
|
|||||||
def __init__(self, top_p: Union[float, Tensor] = 1.0):
|
def __init__(self, top_p: Union[float, Tensor] = 1.0):
|
||||||
self.top_p = top_p
|
self.top_p = top_p
|
||||||
|
|
||||||
def _apply(self, logits, top_p, filter_value):
|
def _apply(
|
||||||
|
self, logits: Tensor, top_p: Union[float, Tensor], filter_value: float
|
||||||
|
) -> Tensor:
|
||||||
sorted_logits, sorted_indices = torch.sort(logits, descending=True, dim=-1)
|
sorted_logits, sorted_indices = torch.sort(logits, descending=True, dim=-1)
|
||||||
cum_probs = torch.cumsum(torch.softmax(sorted_logits, dim=-1), dim=-1)
|
cum_probs = torch.cumsum(torch.softmax(sorted_logits, dim=-1), dim=-1)
|
||||||
remove = cum_probs > top_p
|
remove = cum_probs > top_p
|
||||||
@@ -109,7 +114,7 @@ class TopPStrategy(BaseSamplingStrategy):
|
|||||||
logits[mask] = filter_value
|
logits[mask] = filter_value
|
||||||
return logits
|
return logits
|
||||||
|
|
||||||
def apply(self, logits, filter_value=-float("inf")):
|
def apply(self, logits: Tensor, filter_value: float = -float("inf")) -> Tensor:
|
||||||
tp = self.top_p
|
tp = self.top_p
|
||||||
if isinstance(tp, Tensor):
|
if isinstance(tp, Tensor):
|
||||||
tp = tp.to(logits.device, non_blocking=True)
|
tp = tp.to(logits.device, non_blocking=True)
|
||||||
@@ -140,7 +145,7 @@ class SamplingPipeline(BaseSamplingStrategy):
|
|||||||
def __init__(self, strategies: List[BaseSamplingStrategy]):
|
def __init__(self, strategies: List[BaseSamplingStrategy]):
|
||||||
self.strategies = strategies
|
self.strategies = strategies
|
||||||
|
|
||||||
def apply(self, logits, filter_value=-float("inf")):
|
def apply(self, logits: Tensor, filter_value: float = -float("inf")) -> Tensor:
|
||||||
for strategy in self.strategies:
|
for strategy in self.strategies:
|
||||||
logits = strategy.apply(logits, filter_value)
|
logits = strategy.apply(logits, filter_value)
|
||||||
return logits
|
return logits
|
||||||
|
|||||||
@@ -24,9 +24,7 @@ def repeat_kv(x: Tensor, n_rep: int) -> Tensor:
|
|||||||
|
|
||||||
|
|
||||||
class AttnFactory(BaseFactory[nn.Module]):
|
class AttnFactory(BaseFactory[nn.Module]):
|
||||||
@classmethod
|
pass
|
||||||
def create(cls, attn_type: str, **kwargs) -> nn.Module:
|
|
||||||
return super().create(attn_type, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
@AttnFactory.register("gqa")
|
@AttnFactory.register("gqa")
|
||||||
@@ -40,6 +38,7 @@ class GQA(nn.Module):
|
|||||||
norm_eps: float,
|
norm_eps: float,
|
||||||
use_gated_attention: bool,
|
use_gated_attention: bool,
|
||||||
layer_id: int,
|
layer_id: int,
|
||||||
|
n_layers: int = 1,
|
||||||
):
|
):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
assert dim % n_heads == 0
|
assert dim % n_heads == 0
|
||||||
@@ -57,7 +56,7 @@ class GQA(nn.Module):
|
|||||||
self.q_proj = Linear(dim, n_heads * self.head_dim)
|
self.q_proj = Linear(dim, n_heads * self.head_dim)
|
||||||
self.k_proj = Linear(dim, n_kv_heads * self.head_dim)
|
self.k_proj = Linear(dim, n_kv_heads * self.head_dim)
|
||||||
self.v_proj = Linear(dim, n_kv_heads * self.head_dim)
|
self.v_proj = Linear(dim, n_kv_heads * self.head_dim)
|
||||||
self.o_proj = Linear(dim, dim)
|
self.o_proj = Linear(dim, dim, init_std=0.02 / (2 * n_layers) ** 0.5)
|
||||||
|
|
||||||
if self.use_qk_norm:
|
if self.use_qk_norm:
|
||||||
self.q_norm = RMSNorm(self.head_dim, norm_eps)
|
self.q_norm = RMSNorm(self.head_dim, norm_eps)
|
||||||
@@ -123,6 +122,7 @@ class MLA(nn.Module):
|
|||||||
use_qk_norm: bool,
|
use_qk_norm: bool,
|
||||||
use_gated_attention: bool,
|
use_gated_attention: bool,
|
||||||
layer_id: int,
|
layer_id: int,
|
||||||
|
n_layers: int = 1,
|
||||||
):
|
):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.dim = dim
|
self.dim = dim
|
||||||
@@ -150,7 +150,9 @@ class MLA(nn.Module):
|
|||||||
n_kv_heads * (2 * self.head_dim),
|
n_kv_heads * (2 * self.head_dim),
|
||||||
)
|
)
|
||||||
|
|
||||||
self.o_proj = Linear(dim, dim, bias=False)
|
self.o_proj = Linear(
|
||||||
|
dim, dim, bias=False, init_std=0.02 / (2 * n_layers) ** 0.5
|
||||||
|
)
|
||||||
|
|
||||||
if use_gated_attention:
|
if use_gated_attention:
|
||||||
self.gate = Linear(dim, dim, bias=False)
|
self.gate = Linear(dim, dim, bias=False)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from dataclasses import asdict
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import torch.nn as nn
|
import torch.nn as nn
|
||||||
@@ -10,35 +11,14 @@ from astrai.model.components.norm import RMSNorm
|
|||||||
|
|
||||||
|
|
||||||
class DecoderBlock(nn.Module):
|
class DecoderBlock(nn.Module):
|
||||||
def __init__(
|
def __init__(self, config, layer_id: int):
|
||||||
self,
|
|
||||||
dim: int,
|
|
||||||
n_heads: int,
|
|
||||||
dim_ffn: int,
|
|
||||||
n_kv_heads: int,
|
|
||||||
norm_eps: float,
|
|
||||||
use_qk_norm: bool,
|
|
||||||
use_gated_attention: bool,
|
|
||||||
layer_id: int,
|
|
||||||
attn_type: str = "gqa",
|
|
||||||
ffn_type: str = "mlp",
|
|
||||||
**kwargs,
|
|
||||||
):
|
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.attention = AttnFactory.create(
|
cfg = asdict(config)
|
||||||
attn_type,
|
cfg["down_init_std"] = 0.02 / (2 * config.n_layers) ** 0.5
|
||||||
dim=dim,
|
self.attention = AttnFactory.create(config.attn_type, **cfg, layer_id=layer_id)
|
||||||
n_heads=n_heads,
|
self.input_norm = RMSNorm(config.dim, config.norm_eps)
|
||||||
n_kv_heads=n_kv_heads,
|
self.post_attention_norm = RMSNorm(config.dim, config.norm_eps)
|
||||||
use_qk_norm=use_qk_norm,
|
self.mlp = FFNFactory.create(config.ffn_type, **cfg)
|
||||||
norm_eps=norm_eps,
|
|
||||||
use_gated_attention=use_gated_attention,
|
|
||||||
layer_id=layer_id,
|
|
||||||
**kwargs,
|
|
||||||
)
|
|
||||||
self.input_norm = RMSNorm(dim, norm_eps)
|
|
||||||
self.post_attention_norm = RMSNorm(dim, norm_eps)
|
|
||||||
self.mlp = FFNFactory.create(ffn_type, dim, dim_ffn, **kwargs)
|
|
||||||
|
|
||||||
def forward(
|
def forward(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import math
|
||||||
|
|
||||||
import torch
|
import torch
|
||||||
import torch.nn as nn
|
import torch.nn as nn
|
||||||
import torch.nn.functional as F
|
import torch.nn.functional as F
|
||||||
@@ -5,12 +7,20 @@ from torch import Tensor
|
|||||||
|
|
||||||
|
|
||||||
class Embedding(nn.Module):
|
class Embedding(nn.Module):
|
||||||
def __init__(self, vocab_size: int, embedding_dim: int):
|
def __init__(self, vocab_size: int, embedding_dim: int, neftune_alpha: float = 0.0):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.weight = nn.Parameter(torch.empty((vocab_size, embedding_dim)))
|
self.weight = nn.Parameter(torch.empty((vocab_size, embedding_dim)))
|
||||||
|
self.neftune_noise_alpha = neftune_alpha
|
||||||
|
|
||||||
|
def set_neftune_alpha(self, alpha: float):
|
||||||
|
self.neftune_noise_alpha = alpha
|
||||||
|
|
||||||
def reset_parameters(self):
|
def reset_parameters(self):
|
||||||
nn.init.normal_(self.weight, mean=0.0, std=0.02)
|
nn.init.normal_(self.weight, mean=0.0, std=0.02)
|
||||||
|
|
||||||
def forward(self, x: Tensor) -> Tensor:
|
def forward(self, x: Tensor) -> Tensor:
|
||||||
return F.embedding(x, self.weight)
|
out = F.embedding(x, self.weight)
|
||||||
|
if self.training and self.neftune_noise_alpha > 0.0:
|
||||||
|
eps = self.neftune_noise_alpha / math.sqrt(out.size(1))
|
||||||
|
out = out + eps * torch.randn_like(out)
|
||||||
|
return out
|
||||||
|
|||||||
@@ -5,13 +5,16 @@ from torch import Tensor
|
|||||||
|
|
||||||
|
|
||||||
class Linear(nn.Module):
|
class Linear(nn.Module):
|
||||||
def __init__(self, in_dim: int, out_dim: int, bias: bool = False):
|
def __init__(
|
||||||
|
self, in_dim: int, out_dim: int, bias: bool = False, init_std: float = 0.02
|
||||||
|
):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.weight = nn.Parameter(torch.empty((out_dim, in_dim)))
|
self.weight = nn.Parameter(torch.empty((out_dim, in_dim)))
|
||||||
self.bias = nn.Parameter(torch.zeros(out_dim)) if bias else None
|
self.bias = nn.Parameter(torch.zeros(out_dim)) if bias else None
|
||||||
|
self.init_std = init_std
|
||||||
|
|
||||||
def reset_parameters(self):
|
def reset_parameters(self):
|
||||||
nn.init.kaiming_uniform_(self.weight, a=5**0.5)
|
nn.init.normal_(self.weight, mean=0.0, std=self.init_std)
|
||||||
if self.bias is not None:
|
if self.bias is not None:
|
||||||
fan_in, _ = nn.init._calculate_fan_in_and_fan_out(self.weight)
|
fan_in, _ = nn.init._calculate_fan_in_and_fan_out(self.weight)
|
||||||
bound = 1 / (fan_in**0.5)
|
bound = 1 / (fan_in**0.5)
|
||||||
|
|||||||
@@ -8,18 +8,16 @@ from astrai.model.components.linear import Linear
|
|||||||
|
|
||||||
|
|
||||||
class FFNFactory(BaseFactory[nn.Module]):
|
class FFNFactory(BaseFactory[nn.Module]):
|
||||||
@classmethod
|
pass
|
||||||
def create(cls, ffn_type: str, dim: int, dim_ffn: int, **kwargs) -> nn.Module:
|
|
||||||
return super().create(ffn_type, dim, dim_ffn, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
@FFNFactory.register("mlp")
|
@FFNFactory.register("mlp")
|
||||||
class MLP(nn.Module):
|
class MLP(nn.Module):
|
||||||
def __init__(self, dim: int, dim_ffn: int):
|
def __init__(self, dim: int, dim_ffn: int, down_init_std: float = 0.02):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.up = Linear(dim, dim_ffn)
|
self.up = Linear(dim, dim_ffn)
|
||||||
self.gate = Linear(dim, dim_ffn)
|
self.gate = Linear(dim, dim_ffn)
|
||||||
self.down = Linear(dim_ffn, dim)
|
self.down = Linear(dim_ffn, dim, init_std=down_init_std)
|
||||||
|
|
||||||
def forward(self, x: Tensor) -> Tensor:
|
def forward(self, x: Tensor) -> Tensor:
|
||||||
gated = self.up(x) * F.silu(self.gate(x))
|
gated = self.up(x) * F.silu(self.gate(x))
|
||||||
@@ -37,6 +35,7 @@ class DeepSeekMoE(nn.Module):
|
|||||||
n_shared_experts: int = 1,
|
n_shared_experts: int = 1,
|
||||||
n_activated_experts: int = 2,
|
n_activated_experts: int = 2,
|
||||||
topk_method: str = "greedy",
|
topk_method: str = "greedy",
|
||||||
|
n_layers: int = 1,
|
||||||
):
|
):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.dim = dim
|
self.dim = dim
|
||||||
@@ -46,12 +45,20 @@ class DeepSeekMoE(nn.Module):
|
|||||||
self.topk_method = topk_method
|
self.topk_method = topk_method
|
||||||
|
|
||||||
self.router = Linear(dim, n_routed_experts, bias=False)
|
self.router = Linear(dim, n_routed_experts, bias=False)
|
||||||
|
moe_scale = 1 / max(n_shared_experts, 1) + 1 / n_activated_experts
|
||||||
|
down_init_std = 0.02 / (2 * n_layers * moe_scale) ** 0.5
|
||||||
|
|
||||||
self.shared_experts = nn.ModuleList(
|
self.shared_experts = nn.ModuleList(
|
||||||
[MLP(dim, dim_ffn) for _ in range(n_shared_experts)]
|
[
|
||||||
|
MLP(dim, dim_ffn, down_init_std=down_init_std)
|
||||||
|
for _ in range(n_shared_experts)
|
||||||
|
]
|
||||||
)
|
)
|
||||||
self.routed_experts = nn.ModuleList(
|
self.routed_experts = nn.ModuleList(
|
||||||
[MLP(dim, dim_ffn) for _ in range(n_routed_experts)]
|
[
|
||||||
|
MLP(dim, dim_ffn, down_init_std=down_init_std)
|
||||||
|
for _ in range(n_routed_experts)
|
||||||
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
def forward(self, x: Tensor) -> Tensor:
|
def forward(self, x: Tensor) -> Tensor:
|
||||||
|
|||||||
+4
-14
@@ -23,22 +23,12 @@ class EmbeddingEncoder(AutoModel):
|
|||||||
self.rotary_embedding = RotaryEmbedding(
|
self.rotary_embedding = RotaryEmbedding(
|
||||||
rope_dim, config.max_len, rope_base, rope_scaling=config.rope_scaling
|
rope_dim, config.max_len, rope_base, rope_scaling=config.rope_scaling
|
||||||
)
|
)
|
||||||
self.embed_tokens = Embedding(config.vocab_size, config.dim)
|
self.embed_tokens = Embedding(
|
||||||
|
config.vocab_size, config.dim, neftune_alpha=config.neftune_alpha
|
||||||
|
)
|
||||||
|
|
||||||
self.layers = nn.ModuleList(
|
self.layers = nn.ModuleList(
|
||||||
[
|
[DecoderBlock(config, layer_id) for layer_id in range(config.n_layers)]
|
||||||
DecoderBlock(
|
|
||||||
config.dim,
|
|
||||||
config.n_heads,
|
|
||||||
config.dim_ffn,
|
|
||||||
config.n_kv_heads,
|
|
||||||
config.norm_eps,
|
|
||||||
config.use_qk_norm,
|
|
||||||
config.use_gated_attention,
|
|
||||||
layer_id,
|
|
||||||
)
|
|
||||||
for layer_id in range(config.n_layers)
|
|
||||||
]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
self.norm = RMSNorm(config.dim, config.norm_eps)
|
self.norm = RMSNorm(config.dim, config.norm_eps)
|
||||||
|
|||||||
+10
-32
@@ -26,24 +26,21 @@ def process_attention_mask(
|
|||||||
return input_mask
|
return input_mask
|
||||||
|
|
||||||
device = input_tensor.device
|
device = input_tensor.device
|
||||||
dtype = input_tensor.dtype
|
B = input_tensor.size(0)
|
||||||
B, S = input_tensor.size()[:2]
|
|
||||||
T = position_ids.max().item() + 1
|
T = position_ids.max().item() + 1
|
||||||
|
|
||||||
if input_mask is None:
|
if input_mask is None:
|
||||||
if position_ids.min().item() == 0 and is_causal:
|
if position_ids.min().item() == 0 and is_causal:
|
||||||
return None
|
return None
|
||||||
pad = torch.ones(B, T, dtype=torch.bool, device=device)
|
attend = torch.ones(B, 1, T, dtype=torch.bool, device=device)
|
||||||
else:
|
else:
|
||||||
pad = input_mask[:, :T].to(device=device, dtype=torch.bool)
|
attend = input_mask[:, :T].to(device=device, dtype=torch.bool).unsqueeze(1)
|
||||||
|
|
||||||
attend = pad.view(B, 1, T).expand(B, S, T).clone()
|
|
||||||
if is_causal:
|
if is_causal:
|
||||||
attend &= position_ids.unsqueeze(-1) >= torch.arange(T, device=device)
|
causal = position_ids.unsqueeze(-1) >= torch.arange(T, device=device)
|
||||||
|
attend = attend & causal
|
||||||
|
|
||||||
return torch.full(
|
return attend.unsqueeze(1)
|
||||||
(B, 1, S, T), -torch.finfo(dtype).max / 2, dtype=dtype, device=device
|
|
||||||
).masked_fill_(attend.unsqueeze(1), 0.0)
|
|
||||||
|
|
||||||
|
|
||||||
@AutoModel.register("autoregressive_lm")
|
@AutoModel.register("autoregressive_lm")
|
||||||
@@ -62,31 +59,12 @@ class AutoRegressiveLM(AutoModel):
|
|||||||
self.rotary_embedding = RotaryEmbedding(
|
self.rotary_embedding = RotaryEmbedding(
|
||||||
rope_dim, config.max_len, rope_base, rope_scaling=config.rope_scaling
|
rope_dim, config.max_len, rope_base, rope_scaling=config.rope_scaling
|
||||||
)
|
)
|
||||||
self.embed_tokens = Embedding(config.vocab_size, config.dim)
|
self.embed_tokens = Embedding(
|
||||||
|
config.vocab_size, config.dim, neftune_alpha=config.neftune_alpha
|
||||||
|
)
|
||||||
|
|
||||||
self.layers = nn.ModuleList(
|
self.layers = nn.ModuleList(
|
||||||
[
|
[DecoderBlock(config, layer_id) for layer_id in range(config.n_layers)]
|
||||||
DecoderBlock(
|
|
||||||
config.dim,
|
|
||||||
config.n_heads,
|
|
||||||
config.dim_ffn,
|
|
||||||
config.n_kv_heads,
|
|
||||||
config.norm_eps,
|
|
||||||
config.use_qk_norm,
|
|
||||||
config.use_gated_attention,
|
|
||||||
layer_id,
|
|
||||||
attn_type=config.attn_type,
|
|
||||||
ffn_type=config.ffn_type,
|
|
||||||
n_routed_experts=config.n_routed_experts,
|
|
||||||
n_shared_experts=config.n_shared_experts,
|
|
||||||
n_activated_experts=config.n_activated_experts,
|
|
||||||
topk_method=config.topk_method,
|
|
||||||
kv_lora_rank=config.kv_lora_rank,
|
|
||||||
qk_nope_head_dim=config.qk_nope_head_dim,
|
|
||||||
qk_rope_head_dim=config.qk_rope_head_dim,
|
|
||||||
)
|
|
||||||
for layer_id in range(config.n_layers)
|
|
||||||
]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
self.norm = RMSNorm(config.dim, config.norm_eps)
|
self.norm = RMSNorm(config.dim, config.norm_eps)
|
||||||
|
|||||||
+19
-14
@@ -2,11 +2,13 @@
|
|||||||
|
|
||||||
import contextlib
|
import contextlib
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from typing import Optional, Tuple
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
import torch
|
import torch
|
||||||
import torch.nn as nn
|
import torch.nn as nn
|
||||||
|
from torch.distributed.fsdp import FullStateDictConfig, StateDictType
|
||||||
from torch.distributed.fsdp import FullyShardedDataParallel as FSDP
|
from torch.distributed.fsdp import FullyShardedDataParallel as FSDP
|
||||||
from torch.nn.parallel import DistributedDataParallel as DDP
|
from torch.nn.parallel import DistributedDataParallel as DDP
|
||||||
from torch.optim import Optimizer
|
from torch.optim import Optimizer
|
||||||
@@ -115,8 +117,8 @@ class BaseExecutor:
|
|||||||
def backward(self, loss: torch.Tensor):
|
def backward(self, loss: torch.Tensor):
|
||||||
loss.backward()
|
loss.backward()
|
||||||
|
|
||||||
def unwrap_model(self, model: nn.Module) -> nn.Module:
|
def unwrap_model(self, model: nn.Module):
|
||||||
return model
|
return model.state_dict()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def use_distributed(self) -> bool:
|
def use_distributed(self) -> bool:
|
||||||
@@ -180,7 +182,7 @@ class DDPExecutor(BaseExecutor):
|
|||||||
if not self.use_distributed:
|
if not self.use_distributed:
|
||||||
logger.warning("DDP backend selected but world_size=1, model not wrapped")
|
logger.warning("DDP backend selected but world_size=1, model not wrapped")
|
||||||
return model
|
return model
|
||||||
local_rank = get_rank()
|
local_rank = int(os.environ.get("LOCAL_RANK", get_rank()))
|
||||||
model = DDP(
|
model = DDP(
|
||||||
model,
|
model,
|
||||||
device_ids=[local_rank],
|
device_ids=[local_rank],
|
||||||
@@ -195,10 +197,10 @@ class DDPExecutor(BaseExecutor):
|
|||||||
return model.no_sync()
|
return model.no_sync()
|
||||||
return contextlib.nullcontext()
|
return contextlib.nullcontext()
|
||||||
|
|
||||||
def unwrap_model(self, model: nn.Module) -> nn.Module:
|
def unwrap_model(self, model: nn.Module):
|
||||||
if isinstance(model, DDP):
|
if isinstance(model, DDP):
|
||||||
return model.module
|
return model.module.state_dict()
|
||||||
return model
|
return model.state_dict()
|
||||||
|
|
||||||
|
|
||||||
@ExecutorFactory.register("fsdp")
|
@ExecutorFactory.register("fsdp")
|
||||||
@@ -217,7 +219,6 @@ class FSDPExecutor(BaseExecutor):
|
|||||||
sync_module_states: bool = False,
|
sync_module_states: bool = False,
|
||||||
forward_prefetch: bool = False,
|
forward_prefetch: bool = False,
|
||||||
limit_all_gathers: bool = True,
|
limit_all_gathers: bool = True,
|
||||||
use_orig_params: bool = False,
|
|
||||||
ignored_states=None,
|
ignored_states=None,
|
||||||
device_mesh=None,
|
device_mesh=None,
|
||||||
):
|
):
|
||||||
@@ -236,7 +237,7 @@ class FSDPExecutor(BaseExecutor):
|
|||||||
sync_module_states=sync_module_states,
|
sync_module_states=sync_module_states,
|
||||||
forward_prefetch=forward_prefetch,
|
forward_prefetch=forward_prefetch,
|
||||||
limit_all_gathers=limit_all_gathers,
|
limit_all_gathers=limit_all_gathers,
|
||||||
use_orig_params=use_orig_params,
|
use_orig_params=True,
|
||||||
ignored_states=ignored_states,
|
ignored_states=ignored_states,
|
||||||
device_mesh=device_mesh,
|
device_mesh=device_mesh,
|
||||||
).items()
|
).items()
|
||||||
@@ -259,9 +260,13 @@ class FSDPExecutor(BaseExecutor):
|
|||||||
return model.no_sync()
|
return model.no_sync()
|
||||||
return contextlib.nullcontext()
|
return contextlib.nullcontext()
|
||||||
|
|
||||||
def unwrap_model(self, model: nn.Module) -> nn.Module:
|
def unwrap_model(self, model: nn.Module):
|
||||||
if self._original_model is not None:
|
if isinstance(model, FSDP) and self.use_distributed:
|
||||||
return self._original_model
|
with FSDP.state_dict_type(
|
||||||
if isinstance(model, FSDP):
|
model,
|
||||||
return model._fsdp_wrapped_module
|
StateDictType.FULL_STATE_DICT,
|
||||||
return model
|
FullStateDictConfig(offload_to_cpu=True, rank0_only=False),
|
||||||
|
):
|
||||||
|
return model.state_dict()
|
||||||
|
|
||||||
|
return model.state_dict()
|
||||||
|
|||||||
+108
-42
@@ -1,4 +1,5 @@
|
|||||||
import os
|
import os
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
@@ -30,6 +31,7 @@ def get_rank() -> int:
|
|||||||
def setup_parallel(
|
def setup_parallel(
|
||||||
rank: int,
|
rank: int,
|
||||||
world_size: int,
|
world_size: int,
|
||||||
|
local_rank: int,
|
||||||
backend: str = "nccl",
|
backend: str = "nccl",
|
||||||
master_addr: str = "localhost",
|
master_addr: str = "localhost",
|
||||||
master_port: str = "29500",
|
master_port: str = "29500",
|
||||||
@@ -41,14 +43,18 @@ def setup_parallel(
|
|||||||
return
|
return
|
||||||
|
|
||||||
if world_size <= 1:
|
if world_size <= 1:
|
||||||
|
device_id = torch.device(device_type, local_rank)
|
||||||
|
os.environ["LOCAL_RANK"] = str(local_rank)
|
||||||
|
os.environ["WORLD_SIZE"] = "1"
|
||||||
|
os.environ["LOCAL_DEVICE"] = str(device_id)
|
||||||
yield None
|
yield None
|
||||||
return
|
return
|
||||||
|
|
||||||
device_id = torch.device(device_type, rank)
|
device_id = torch.device(device_type, local_rank)
|
||||||
|
|
||||||
os.environ["MASTER_ADDR"] = master_addr
|
os.environ["MASTER_ADDR"] = master_addr
|
||||||
os.environ["MASTER_PORT"] = master_port
|
os.environ["MASTER_PORT"] = master_port
|
||||||
os.environ["LOCAL_RANK"] = str(rank)
|
os.environ["LOCAL_RANK"] = str(local_rank)
|
||||||
os.environ["WORLD_SIZE"] = str(world_size)
|
os.environ["WORLD_SIZE"] = str(world_size)
|
||||||
os.environ["LOCAL_DEVICE"] = str(device_id)
|
os.environ["LOCAL_DEVICE"] = str(device_id)
|
||||||
|
|
||||||
@@ -90,7 +96,7 @@ def only_on_rank(rank, sync=False):
|
|||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
def wrapper_spawn_func(
|
def _run_single_rank(
|
||||||
rank: int,
|
rank: int,
|
||||||
world_size: int,
|
world_size: int,
|
||||||
backend: str,
|
backend: str,
|
||||||
@@ -100,10 +106,10 @@ def wrapper_spawn_func(
|
|||||||
func: Callable,
|
func: Callable,
|
||||||
kwargs: dict,
|
kwargs: dict,
|
||||||
):
|
):
|
||||||
try:
|
|
||||||
with setup_parallel(
|
with setup_parallel(
|
||||||
rank=rank,
|
rank=rank,
|
||||||
world_size=world_size,
|
world_size=world_size,
|
||||||
|
local_rank=rank,
|
||||||
backend=backend,
|
backend=backend,
|
||||||
master_addr=master_addr,
|
master_addr=master_addr,
|
||||||
master_port=master_port,
|
master_port=master_port,
|
||||||
@@ -111,11 +117,99 @@ def wrapper_spawn_func(
|
|||||||
):
|
):
|
||||||
func(**kwargs)
|
func(**kwargs)
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error in rank {rank}: {e}")
|
class LaunchStrategy(ABC):
|
||||||
|
"""Strategy for launching a function in a distributed context."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
world_size: int,
|
||||||
|
backend: str,
|
||||||
|
master_addr: str,
|
||||||
|
master_port: str,
|
||||||
|
device_type: str,
|
||||||
|
start_method: str,
|
||||||
|
):
|
||||||
|
self.world_size = world_size
|
||||||
|
self.backend = backend
|
||||||
|
self.master_addr = master_addr
|
||||||
|
self.master_port = master_port
|
||||||
|
self.device_type = device_type
|
||||||
|
self.start_method = start_method
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def launch(self, func: Callable, **kwargs):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
class TorchrunStrategy(LaunchStrategy):
|
||||||
|
"""External orchestrator (torchrun, SLURM, K8s) — env vars pre-set."""
|
||||||
|
|
||||||
|
def launch(self, func: Callable, **kwargs):
|
||||||
|
rank = int(os.environ["RANK"])
|
||||||
|
world_size = int(os.environ["WORLD_SIZE"])
|
||||||
|
local_rank = int(os.environ.get("LOCAL_RANK", rank))
|
||||||
|
with setup_parallel(
|
||||||
|
rank=rank,
|
||||||
|
world_size=world_size,
|
||||||
|
local_rank=local_rank,
|
||||||
|
backend=self.backend,
|
||||||
|
master_addr=os.environ.get("MASTER_ADDR", self.master_addr),
|
||||||
|
master_port=os.environ.get("MASTER_PORT", self.master_port),
|
||||||
|
device_type=self.device_type,
|
||||||
|
):
|
||||||
|
func(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class LocalStrategy(LaunchStrategy):
|
||||||
|
"""Local launcher — single-process or mp.start_processes."""
|
||||||
|
|
||||||
|
def launch(self, func: Callable, **kwargs):
|
||||||
|
args = (
|
||||||
|
self.world_size,
|
||||||
|
self.backend,
|
||||||
|
self.master_addr,
|
||||||
|
self.master_port,
|
||||||
|
self.device_type,
|
||||||
|
func,
|
||||||
|
kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.world_size == 1:
|
||||||
|
_run_single_rank(0, *args)
|
||||||
|
return
|
||||||
|
|
||||||
|
ctx = mp.start_processes(
|
||||||
|
_run_single_rank,
|
||||||
|
args=args,
|
||||||
|
nprocs=self.world_size,
|
||||||
|
start_method=self.start_method,
|
||||||
|
join=False,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
while not ctx.join():
|
||||||
|
pass
|
||||||
|
except BaseException:
|
||||||
|
for p in ctx.processes:
|
||||||
|
p.terminate()
|
||||||
|
ctx.join()
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_launcher() -> str:
|
||||||
|
"""Detect the distributed launcher from environment.
|
||||||
|
|
||||||
|
Returns one of: "torchelastic", "torchrun", "external", "local".
|
||||||
|
"""
|
||||||
|
if dist.is_torchelastic_launched():
|
||||||
|
return "torchelastic"
|
||||||
|
if "LOCAL_WORLD_SIZE" in os.environ:
|
||||||
|
return "torchrun"
|
||||||
|
if "RANK" in os.environ and "WORLD_SIZE" in os.environ:
|
||||||
|
return "external"
|
||||||
|
return "local"
|
||||||
|
|
||||||
|
|
||||||
def spawn_parallel_fn(
|
def spawn_parallel_fn(
|
||||||
func: Callable,
|
func: Callable,
|
||||||
world_size: int,
|
world_size: int,
|
||||||
@@ -126,41 +220,13 @@ def spawn_parallel_fn(
|
|||||||
start_method: str = "spawn",
|
start_method: str = "spawn",
|
||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
# clear environment variables
|
launcher = _detect_launcher()
|
||||||
for key in [
|
if launcher in ("torchelastic", "torchrun", "external"):
|
||||||
"MASTER_ADDR",
|
strategy = TorchrunStrategy(
|
||||||
"MASTER_PORT",
|
world_size, backend, master_addr, master_port, device_type, start_method
|
||||||
"RANK",
|
|
||||||
"WORLD_SIZE",
|
|
||||||
"LOCAL_RANK",
|
|
||||||
"LOCAL_DEVICE",
|
|
||||||
]:
|
|
||||||
if key in os.environ:
|
|
||||||
del os.environ[key]
|
|
||||||
|
|
||||||
if world_size == 1:
|
|
||||||
device_id = torch.device(device_type, 0)
|
|
||||||
os.environ["LOCAL_RANK"] = "0"
|
|
||||||
os.environ["WORLD_SIZE"] = "1"
|
|
||||||
os.environ["LOCAL_DEVICE"] = str(device_id)
|
|
||||||
|
|
||||||
func(**kwargs)
|
|
||||||
return
|
|
||||||
|
|
||||||
wrapper_spawn_func_args = (
|
|
||||||
world_size,
|
|
||||||
backend,
|
|
||||||
master_addr,
|
|
||||||
master_port,
|
|
||||||
device_type,
|
|
||||||
func,
|
|
||||||
kwargs,
|
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
mp.start_processes(
|
strategy = LocalStrategy(
|
||||||
wrapper_spawn_func,
|
world_size, backend, master_addr, master_port, device_type, start_method
|
||||||
args=wrapper_spawn_func_args,
|
|
||||||
nprocs=world_size,
|
|
||||||
start_method=start_method,
|
|
||||||
join=True,
|
|
||||||
)
|
)
|
||||||
|
strategy.launch(func, **kwargs)
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
from astrai.preprocessing.builder import (
|
||||||
|
BaseMaskBuilder,
|
||||||
|
MaskBuilderFactory,
|
||||||
|
SectionedMaskBuilder,
|
||||||
|
)
|
||||||
|
from astrai.preprocessing.packing import (
|
||||||
|
PackingStrategy,
|
||||||
|
PackingStrategyFactory,
|
||||||
|
)
|
||||||
|
from astrai.preprocessing.pipeline import Pipeline, filter_by_length
|
||||||
|
from astrai.preprocessing.position_id import (
|
||||||
|
PositionIdStrategy,
|
||||||
|
PositionIdStrategyFactory,
|
||||||
|
)
|
||||||
|
from astrai.preprocessing.writer import (
|
||||||
|
StoreWriter,
|
||||||
|
StoreWriterFactory,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"BaseMaskBuilder",
|
||||||
|
"MaskBuilderFactory",
|
||||||
|
"PackingStrategy",
|
||||||
|
"PackingStrategyFactory",
|
||||||
|
"Pipeline",
|
||||||
|
"PositionIdStrategy",
|
||||||
|
"PositionIdStrategyFactory",
|
||||||
|
"SectionedMaskBuilder",
|
||||||
|
"StoreWriter",
|
||||||
|
"StoreWriterFactory",
|
||||||
|
"filter_by_length",
|
||||||
|
]
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
"""Mask building for preprocessing pipeline.
|
||||||
|
|
||||||
|
:class:`SectionRenderer` converts section specs into token ids and loss
|
||||||
|
masks (template / text / value extraction). :class:`SectionedMaskBuilder`
|
||||||
|
orchestrates single-output / multi-output (DPO / GRPO) assembly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from astrai.factory import BaseFactory
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_domain(item: dict, domain_key: Optional[str]) -> str:
|
||||||
|
if not domain_key:
|
||||||
|
return "__default__"
|
||||||
|
val = item.get(domain_key, "__default__")
|
||||||
|
return val if isinstance(val, str) else "__default__"
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_action(action: str, role: str, config) -> str:
|
||||||
|
if action == "$role":
|
||||||
|
return config.mask.get(role, config.mask_default)
|
||||||
|
return action
|
||||||
|
|
||||||
|
|
||||||
|
class SectionRenderer:
|
||||||
|
"""Render section specs into ``(ids, loss_mask)`` tuples."""
|
||||||
|
|
||||||
|
def process_sections(
|
||||||
|
self,
|
||||||
|
item: dict,
|
||||||
|
sections: list,
|
||||||
|
config,
|
||||||
|
tokenizer,
|
||||||
|
*,
|
||||||
|
is_top_level: bool = False,
|
||||||
|
):
|
||||||
|
all_ids: list[int] = []
|
||||||
|
loss_mask: list[int] = []
|
||||||
|
|
||||||
|
has_template = any(s.get("template") for s in sections)
|
||||||
|
is_text_config = not has_template and all(
|
||||||
|
s["action"] == "train" for s in sections
|
||||||
|
)
|
||||||
|
|
||||||
|
if is_top_level and has_template and tokenizer.bos_token_id is not None:
|
||||||
|
all_ids.append(tokenizer.bos_token_id)
|
||||||
|
loss_mask.append(0)
|
||||||
|
|
||||||
|
first_section = True
|
||||||
|
for sec in sections:
|
||||||
|
field = sec["field"]
|
||||||
|
action = sec["action"]
|
||||||
|
use_template = sec.get("template", False)
|
||||||
|
add_special = sec.get(
|
||||||
|
"add_special_tokens", not use_template and first_section
|
||||||
|
)
|
||||||
|
|
||||||
|
if use_template:
|
||||||
|
success = self._append_template(
|
||||||
|
item, field, action, tokenizer, config, all_ids, loss_mask
|
||||||
|
)
|
||||||
|
if not success:
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
success = self._append_text(
|
||||||
|
item,
|
||||||
|
field,
|
||||||
|
action,
|
||||||
|
tokenizer,
|
||||||
|
add_special,
|
||||||
|
is_text_config,
|
||||||
|
config,
|
||||||
|
all_ids,
|
||||||
|
loss_mask,
|
||||||
|
)
|
||||||
|
if not success:
|
||||||
|
continue
|
||||||
|
|
||||||
|
first_section = False
|
||||||
|
|
||||||
|
max_len = config.preprocessing.max_seq_len
|
||||||
|
all_ids = all_ids[:max_len]
|
||||||
|
loss_mask = loss_mask[: len(all_ids)]
|
||||||
|
|
||||||
|
if not all_ids:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
if is_top_level and has_template and len(all_ids) <= 1:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
return all_ids, loss_mask
|
||||||
|
|
||||||
|
def process_list_field(self, item: dict, sections: list, config, tokenizer):
|
||||||
|
all_ids: list[int] = []
|
||||||
|
loss_mask: list[int] = []
|
||||||
|
|
||||||
|
for sec in sections:
|
||||||
|
field = sec["field"]
|
||||||
|
action = sec["action"]
|
||||||
|
use_template = sec.get("template", False)
|
||||||
|
|
||||||
|
values = item.get(field)
|
||||||
|
if not isinstance(values, list):
|
||||||
|
continue
|
||||||
|
|
||||||
|
for val in values:
|
||||||
|
if use_template:
|
||||||
|
if isinstance(val, list):
|
||||||
|
wrapper = {field: val}
|
||||||
|
self._append_template(
|
||||||
|
wrapper,
|
||||||
|
field,
|
||||||
|
action,
|
||||||
|
tokenizer,
|
||||||
|
config,
|
||||||
|
all_ids,
|
||||||
|
loss_mask,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
wrapper = {field: str(val)}
|
||||||
|
self._append_text(
|
||||||
|
wrapper,
|
||||||
|
field,
|
||||||
|
action,
|
||||||
|
tokenizer,
|
||||||
|
False,
|
||||||
|
False,
|
||||||
|
config,
|
||||||
|
all_ids,
|
||||||
|
loss_mask,
|
||||||
|
)
|
||||||
|
|
||||||
|
max_len = config.preprocessing.max_seq_len
|
||||||
|
all_ids = all_ids[:max_len]
|
||||||
|
loss_mask = loss_mask[: len(all_ids)]
|
||||||
|
|
||||||
|
if not all_ids:
|
||||||
|
return None, None
|
||||||
|
return all_ids, loss_mask
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def is_value_section(sections: list) -> bool:
|
||||||
|
return len(sections) == 1 and sections[0].get("action") == "value"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def extract_raw_value(item: dict, sections: list):
|
||||||
|
sec = sections[0]
|
||||||
|
field = sec["field"]
|
||||||
|
raw = item.get(field)
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
if isinstance(raw, list):
|
||||||
|
return [float(v) for v in raw]
|
||||||
|
return [float(raw)]
|
||||||
|
|
||||||
|
def _append_template(
|
||||||
|
self, item, field, action, tokenizer, config, all_ids, loss_mask
|
||||||
|
):
|
||||||
|
messages = item.get(field)
|
||||||
|
if not isinstance(messages, list) or not messages:
|
||||||
|
return False
|
||||||
|
for msg in messages:
|
||||||
|
role = msg.get("role", "")
|
||||||
|
act = _resolve_action(action, role, config)
|
||||||
|
rendered = tokenizer.apply_chat_template(
|
||||||
|
[msg], tokenize=False, add_generation_prompt=False
|
||||||
|
)
|
||||||
|
ids = tokenizer.encode(rendered, add_special_tokens=False)
|
||||||
|
all_ids.extend(ids)
|
||||||
|
val = 1 if act == "train" else 0
|
||||||
|
loss_mask.extend([val] * len(ids))
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _append_text(
|
||||||
|
self,
|
||||||
|
item,
|
||||||
|
field,
|
||||||
|
action,
|
||||||
|
tokenizer,
|
||||||
|
add_special,
|
||||||
|
is_text_config,
|
||||||
|
config,
|
||||||
|
all_ids,
|
||||||
|
loss_mask,
|
||||||
|
):
|
||||||
|
text = str(item.get(field, ""))
|
||||||
|
if not text.strip():
|
||||||
|
return False
|
||||||
|
if is_text_config:
|
||||||
|
pp = config.preprocessing
|
||||||
|
if pp.min_chars > 0 and len(text) < pp.min_chars:
|
||||||
|
return False
|
||||||
|
if len(text) > pp.max_chars:
|
||||||
|
return False
|
||||||
|
ids = tokenizer.encode(text, add_special_tokens=add_special)
|
||||||
|
all_ids.extend(ids)
|
||||||
|
val = 1 if action == "train" else 0
|
||||||
|
loss_mask.extend([val] * len(ids))
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class BaseMaskBuilder(ABC):
|
||||||
|
"""Convert a JSONL item into token ids and optional loss_mask."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def build(self, item: dict, config, tokenizer) -> Optional[dict]: ...
|
||||||
|
|
||||||
|
|
||||||
|
class MaskBuilderFactory(BaseFactory["BaseMaskBuilder"]):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@MaskBuilderFactory.register("sectioned")
|
||||||
|
class SectionedMaskBuilder(BaseMaskBuilder):
|
||||||
|
"""Config-driven builder supporting single and multi-output modes.
|
||||||
|
|
||||||
|
Single-output::
|
||||||
|
|
||||||
|
{"input": {"sections": [
|
||||||
|
{"field": "messages", "action": "$role", "template": true}
|
||||||
|
]}}
|
||||||
|
→ {"sequence": [...], "loss_mask": [...], "domain": "..."}
|
||||||
|
|
||||||
|
Multi-output (DPO / GRPO)::
|
||||||
|
|
||||||
|
{"input": {"sources": {
|
||||||
|
"chosen": {"sections": [{"field": "chosen", "action": "$role", "template": true}]},
|
||||||
|
"rejected": {"sections": [{"field": "rejected", "action": "$role", "template": true}]},
|
||||||
|
}}}
|
||||||
|
→ {"chosen": [...], "chosen_mask": [...], "rejected": [...], "rejected_mask": [...], "domain": "..."}
|
||||||
|
|
||||||
|
Output spec fields::
|
||||||
|
|
||||||
|
sections – list of section specs (same format as single-output)
|
||||||
|
list_field – True when JSONL field holds a list (GRPO responses)
|
||||||
|
mask_key – explicit loss-mask output key (default: ``"{output_key}_mask"``)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.renderer = SectionRenderer()
|
||||||
|
|
||||||
|
def build(self, item: dict, config, tokenizer) -> Optional[dict]:
|
||||||
|
sources_spec = getattr(config.input, "sources", None)
|
||||||
|
if sources_spec:
|
||||||
|
return self._build_multi(item, sources_spec, config, tokenizer)
|
||||||
|
return self._build_single(item, config, tokenizer)
|
||||||
|
|
||||||
|
def _build_single(self, item: dict, config, tokenizer) -> Optional[dict]:
|
||||||
|
sections = config.input.sections
|
||||||
|
if not sections:
|
||||||
|
return None
|
||||||
|
|
||||||
|
ids, mask = self.renderer.process_sections(
|
||||||
|
item, sections, config, tokenizer, is_top_level=True
|
||||||
|
)
|
||||||
|
if ids is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
result: dict = {
|
||||||
|
"sequence": ids,
|
||||||
|
"domain": _extract_domain(item, config.output.domain_key),
|
||||||
|
}
|
||||||
|
if not all(m == 1 for m in mask):
|
||||||
|
result["loss_mask"] = mask
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _build_multi(
|
||||||
|
self, item: dict, sources_spec: dict, config, tokenizer
|
||||||
|
) -> Optional[dict]:
|
||||||
|
result: dict = {}
|
||||||
|
any_output = False
|
||||||
|
|
||||||
|
for output_key, spec in sources_spec.items():
|
||||||
|
sections = spec.get("sections", [])
|
||||||
|
if not sections:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if self.renderer.is_value_section(sections):
|
||||||
|
ids = self.renderer.extract_raw_value(item, sections)
|
||||||
|
if ids is None:
|
||||||
|
continue
|
||||||
|
result[output_key] = ids
|
||||||
|
any_output = True
|
||||||
|
continue
|
||||||
|
|
||||||
|
list_field = spec.get("list_field", False)
|
||||||
|
mask_key = spec.get("mask_key", f"{output_key}_mask")
|
||||||
|
|
||||||
|
if list_field:
|
||||||
|
ids, mask = self.renderer.process_list_field(
|
||||||
|
item, sections, config, tokenizer
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
ids, mask = self.renderer.process_sections(
|
||||||
|
item, sections, config, tokenizer, is_top_level=True
|
||||||
|
)
|
||||||
|
|
||||||
|
if ids is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
result[output_key] = ids
|
||||||
|
if not all(m == 1 for m in mask):
|
||||||
|
result[mask_key] = mask
|
||||||
|
elif "mask_key" in spec:
|
||||||
|
result[mask_key] = mask
|
||||||
|
|
||||||
|
any_output = True
|
||||||
|
|
||||||
|
if not any_output:
|
||||||
|
return None
|
||||||
|
|
||||||
|
result["domain"] = _extract_domain(item, config.output.domain_key)
|
||||||
|
return result
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
"""Sequence packing strategies for shard-level reordering and truncation.
|
||||||
|
|
||||||
|
Each strategy receives the accumulated ``{key: [list of token lists]}``
|
||||||
|
dict for a shard and returns a reordered / truncated version. The
|
||||||
|
pipeline later flattens the result into contiguous tensors.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Dict, List
|
||||||
|
|
||||||
|
from astrai.factory import BaseFactory
|
||||||
|
|
||||||
|
|
||||||
|
def _truncate(seq: List[int], max_len: int, mode: str) -> List[int]:
|
||||||
|
if len(seq) <= max_len:
|
||||||
|
return seq
|
||||||
|
if mode == "keep_end":
|
||||||
|
return seq[-max_len:]
|
||||||
|
return seq[:max_len]
|
||||||
|
|
||||||
|
|
||||||
|
class PackingStrategy(ABC):
|
||||||
|
"""Reorder and truncate sequences within a shard."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def apply(
|
||||||
|
self,
|
||||||
|
keys: Dict[str, List[List[int]]],
|
||||||
|
max_packed_len: int,
|
||||||
|
truncation_mode: str,
|
||||||
|
) -> Dict[str, List[List[int]]]:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
class PackingStrategyFactory(BaseFactory["PackingStrategy"]):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@PackingStrategyFactory.register("simple")
|
||||||
|
class SimplePacking(PackingStrategy):
|
||||||
|
def apply(
|
||||||
|
self,
|
||||||
|
keys: Dict[str, List[List[int]]],
|
||||||
|
max_packed_len: int,
|
||||||
|
truncation_mode: str,
|
||||||
|
) -> Dict[str, List[List[int]]]:
|
||||||
|
return {
|
||||||
|
k: [_truncate(v, max_packed_len, truncation_mode) for v in vals]
|
||||||
|
for k, vals in keys.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@PackingStrategyFactory.register("bfd")
|
||||||
|
class BFDPacking(PackingStrategy):
|
||||||
|
"""Best-Fit Decreasing bin packing.
|
||||||
|
|
||||||
|
Assigns sequences to bins using a best-fit heuristic (sorted by
|
||||||
|
decreasing length) and concatenates sequences within each bin into
|
||||||
|
a single packed sequence. Packed sequences are truncated to
|
||||||
|
*max_packed_len* so that each packed bin fits within one context
|
||||||
|
window during training.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def apply(
|
||||||
|
self,
|
||||||
|
keys: Dict[str, List[List[int]]],
|
||||||
|
max_packed_len: int,
|
||||||
|
truncation_mode: str,
|
||||||
|
) -> Dict[str, List[List[int]]]:
|
||||||
|
sequences = keys.get("sequence", [])
|
||||||
|
if not sequences:
|
||||||
|
return keys
|
||||||
|
bins = self._plan(sequences, max_packed_len, truncation_mode)
|
||||||
|
|
||||||
|
packed: Dict[str, List[List[int]]] = {}
|
||||||
|
for k, vals in keys.items():
|
||||||
|
packed[k] = [
|
||||||
|
_truncate(
|
||||||
|
self._concat_bin(vals, bin_indices),
|
||||||
|
max_packed_len,
|
||||||
|
truncation_mode,
|
||||||
|
)
|
||||||
|
for bin_indices in bins
|
||||||
|
]
|
||||||
|
return packed
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _concat_bin(vals: List[List[int]], indices: List[int]) -> List[int]:
|
||||||
|
result: List[int] = []
|
||||||
|
for i in indices:
|
||||||
|
result.extend(vals[i])
|
||||||
|
return result
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _plan(
|
||||||
|
sequences: List[List[int]], max_packed_len: int, truncation_mode: str
|
||||||
|
) -> List[List[int]]:
|
||||||
|
n = len(sequences)
|
||||||
|
order = sorted(range(n), key=lambda i: len(sequences[i]), reverse=True)
|
||||||
|
bins: List[List[int]] = []
|
||||||
|
bin_lengths: List[int] = []
|
||||||
|
|
||||||
|
for orig_idx in order:
|
||||||
|
seq_len = len(
|
||||||
|
_truncate(sequences[orig_idx], max_packed_len, truncation_mode)
|
||||||
|
)
|
||||||
|
best_bin = None
|
||||||
|
best_remain = max_packed_len + 1
|
||||||
|
for i, bl in enumerate(bin_lengths):
|
||||||
|
remain = max_packed_len - bl
|
||||||
|
if seq_len <= remain < best_remain:
|
||||||
|
best_remain = remain
|
||||||
|
best_bin = i
|
||||||
|
if best_bin is not None:
|
||||||
|
bins[best_bin].append(orig_idx)
|
||||||
|
bin_lengths[best_bin] += seq_len
|
||||||
|
else:
|
||||||
|
bins.append([orig_idx])
|
||||||
|
bin_lengths.append(seq_len)
|
||||||
|
|
||||||
|
return bins
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
"""Config-driven JSONL preprocessing pipeline.
|
||||||
|
|
||||||
|
Composes a :class:`BaseMaskBuilder` (selected by ``input.type``) with
|
||||||
|
sharding and flush to ``.h5`` / ``.bin`` storage. Packing, position-id
|
||||||
|
generation and storage writing are each delegated to pluggable strategies,
|
||||||
|
dispatched by configuration keys.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from collections import defaultdict
|
||||||
|
from itertools import chain
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
|
import torch
|
||||||
|
import tqdm
|
||||||
|
|
||||||
|
from astrai.config.preprocess_config import PipelineConfig
|
||||||
|
from astrai.preprocessing.builder import MaskBuilderFactory
|
||||||
|
from astrai.preprocessing.packing import PackingStrategyFactory
|
||||||
|
from astrai.preprocessing.position_id import PositionIdStrategyFactory
|
||||||
|
from astrai.preprocessing.writer import StoreWriterFactory
|
||||||
|
from astrai.tokenize import AutoTokenizer
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_STR_TO_DTYPE: dict[str, torch.dtype] = {
|
||||||
|
"bool": torch.bool,
|
||||||
|
"uint8": torch.uint8,
|
||||||
|
"int8": torch.int8,
|
||||||
|
"int16": torch.int16,
|
||||||
|
"int32": torch.int32,
|
||||||
|
"int64": torch.int64,
|
||||||
|
"float16": torch.float16,
|
||||||
|
"float32": torch.float32,
|
||||||
|
"float64": torch.float64,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def filter_by_length(text: str, min_len: int = 50, max_len: int = 2_000_000) -> bool:
|
||||||
|
return min_len <= len(text) <= max_len
|
||||||
|
|
||||||
|
|
||||||
|
class Pipeline:
|
||||||
|
"""Tokenization pipeline driven by a declarative :class:`PipelineConfig`.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
config = PipelineConfig.from_file("sft_pipeline.json")
|
||||||
|
Pipeline(config, ["data.jsonl"], output_dir="out", tokenizer_path="params").run()
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
config: PipelineConfig,
|
||||||
|
input_paths: list[str],
|
||||||
|
output_dir: str,
|
||||||
|
tokenizer_path: str,
|
||||||
|
):
|
||||||
|
os.makedirs(output_dir, exist_ok=True)
|
||||||
|
self.config = config
|
||||||
|
self.paths = input_paths
|
||||||
|
self.output_dir = output_dir
|
||||||
|
self.tokenizer_path = tokenizer_path
|
||||||
|
|
||||||
|
self.mask_builder = MaskBuilderFactory.create("sectioned")
|
||||||
|
self._packer = PackingStrategyFactory.create(
|
||||||
|
config.preprocessing.packing_strategy
|
||||||
|
)
|
||||||
|
self._position_id = PositionIdStrategyFactory.create(
|
||||||
|
config.output.position_ids_mode
|
||||||
|
)
|
||||||
|
self._writer = StoreWriterFactory.create(config.output.storage_format)
|
||||||
|
|
||||||
|
def transform(self, item: dict) -> Optional[dict]:
|
||||||
|
return self.mask_builder.build(item, self.config, self._tokenizer)
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
self._tokenizer = AutoTokenizer.from_pretrained(self.tokenizer_path)
|
||||||
|
domains: dict = defaultdict(lambda: defaultdict(list))
|
||||||
|
total_tokens = 0
|
||||||
|
shard_idx: dict[str, int] = defaultdict(int)
|
||||||
|
count = 0
|
||||||
|
|
||||||
|
pp = self.config.preprocessing
|
||||||
|
|
||||||
|
for item in tqdm.tqdm(
|
||||||
|
self._iter_items(), desc="Tokenizing", unit="docs", mininterval=0.5
|
||||||
|
):
|
||||||
|
if pp.max_items and count >= pp.max_items:
|
||||||
|
break
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = self.transform(item)
|
||||||
|
except Exception:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to process item #%d, skipping", count + 1, exc_info=True
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if result is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
domain = result.pop("domain", "__default__")
|
||||||
|
|
||||||
|
is_multi = bool(getattr(self.config.input, "sources", None))
|
||||||
|
if is_multi:
|
||||||
|
ids = self._primary_ids(result)
|
||||||
|
else:
|
||||||
|
ids = result.pop("sequence")
|
||||||
|
result["sequence"] = ids
|
||||||
|
|
||||||
|
if not ids:
|
||||||
|
continue
|
||||||
|
|
||||||
|
bucket = domains[domain]
|
||||||
|
self._align_bucket(bucket, result, ids)
|
||||||
|
for key, val in result.items():
|
||||||
|
bucket[key].append(val)
|
||||||
|
|
||||||
|
count += 1
|
||||||
|
total_tokens += len(ids)
|
||||||
|
|
||||||
|
if total_tokens >= self.config.output.max_tokens_per_shard:
|
||||||
|
self._flush(domains, shard_idx)
|
||||||
|
domains.clear()
|
||||||
|
total_tokens = 0
|
||||||
|
|
||||||
|
if total_tokens > 0:
|
||||||
|
self._flush(domains, shard_idx)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _primary_ids(result: dict) -> list:
|
||||||
|
"""Return the first list-valued entry in *result* as the primary id
|
||||||
|
sequence for token counting."""
|
||||||
|
for val in result.values():
|
||||||
|
if isinstance(val, list) and val and isinstance(val[0], int):
|
||||||
|
return val
|
||||||
|
return []
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _align_bucket(bucket: dict, result: dict, ids: list):
|
||||||
|
"""Pad previously-accumulated keys that are missing from *result*."""
|
||||||
|
for key in list(bucket.keys()):
|
||||||
|
if key in result:
|
||||||
|
continue
|
||||||
|
bucket[key].append([1] * len(ids))
|
||||||
|
|
||||||
|
def _iter_items(self):
|
||||||
|
for path in self.paths:
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
for line in f:
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
yield json.loads(line)
|
||||||
|
|
||||||
|
def _flush(self, domains, shard_idx):
|
||||||
|
for domain, keys in domains.items():
|
||||||
|
idx = shard_idx[domain]
|
||||||
|
|
||||||
|
pp = self.config.preprocessing
|
||||||
|
keys = self._packer.apply(dict(keys), pp.max_packed_len, pp.truncation_mode)
|
||||||
|
|
||||||
|
tensors: Dict[str, List[torch.Tensor]] = {}
|
||||||
|
for key, ids_list in keys.items():
|
||||||
|
dt = _STR_TO_DTYPE.get(
|
||||||
|
self.config.output.dtype.get(key, "int32"), torch.int32
|
||||||
|
)
|
||||||
|
tensors[key] = [
|
||||||
|
torch.tensor(list(chain.from_iterable(ids_list)), dtype=dt)
|
||||||
|
]
|
||||||
|
|
||||||
|
pos_ids = self._position_id.generate(keys.get("sequence", []))
|
||||||
|
if pos_ids:
|
||||||
|
tensors["position_ids"] = [torch.tensor(pos_ids, dtype=torch.int32)]
|
||||||
|
|
||||||
|
self._writer.save(self.output_dir, domain, idx, tensors)
|
||||||
|
shard_idx[domain] = idx + 1
|
||||||
|
|
||||||
|
first_key = "sequence" if "sequence" in tensors else next(iter(tensors))
|
||||||
|
tqdm.tqdm.write(
|
||||||
|
f" saved {domain}/shard_{idx:04d} "
|
||||||
|
f"({tensors[first_key][0].numel():,} tokens)"
|
||||||
|
)
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""Position-id generation strategies for packed sequences.
|
||||||
|
|
||||||
|
Each strategy takes the list of per-document token sequences after packing
|
||||||
|
and returns a flat list of position ids (same total length as all
|
||||||
|
sequences combined). The pipeline wraps the result into a tensor and
|
||||||
|
attaches it as ``position_ids``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from astrai.factory import BaseFactory
|
||||||
|
|
||||||
|
|
||||||
|
class PositionIdStrategy(ABC):
|
||||||
|
"""Generate ``position_ids`` for packed sequences."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def generate(self, sequences: List[List[int]]) -> List[int]:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
class PositionIdStrategyFactory(BaseFactory["PositionIdStrategy"]):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@PositionIdStrategyFactory.register("none")
|
||||||
|
class NoPositionId(PositionIdStrategy):
|
||||||
|
def generate(self, sequences: List[List[int]]) -> List[int]:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
@PositionIdStrategyFactory.register("doc_reset")
|
||||||
|
class DocResetPositionId(PositionIdStrategy):
|
||||||
|
def generate(self, sequences: List[List[int]]) -> List[int]:
|
||||||
|
pos_ids = []
|
||||||
|
for seq in sequences:
|
||||||
|
pos_ids.extend(range(len(seq)))
|
||||||
|
return pos_ids
|
||||||
|
|
||||||
|
|
||||||
|
@PositionIdStrategyFactory.register("continuous")
|
||||||
|
class ContinuousPositionId(PositionIdStrategy):
|
||||||
|
def generate(self, sequences: List[List[int]]) -> List[int]:
|
||||||
|
total = sum(len(seq) for seq in sequences)
|
||||||
|
return list(range(total))
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"""Storage writer strategies for pipeline output.
|
||||||
|
|
||||||
|
The :class:`StoreWriter` abstraction decouples the pipeline from the
|
||||||
|
concrete storage format (bin / h5). The pipeline builds a ``{key:
|
||||||
|
List[Tensor]}`` dict and delegates the write to the writer selected
|
||||||
|
by ``output.storage_format``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Dict, List
|
||||||
|
|
||||||
|
import torch
|
||||||
|
|
||||||
|
from astrai.dataset.storage import save_bin, save_h5
|
||||||
|
from astrai.factory import BaseFactory
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class StoreWriter(ABC):
|
||||||
|
"""Write pre-tokenized tensors to disk in a format-specific way."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def save(
|
||||||
|
self,
|
||||||
|
output_dir: str,
|
||||||
|
domain: str,
|
||||||
|
shard_idx: int,
|
||||||
|
tensors: Dict[str, List[torch.Tensor]],
|
||||||
|
) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
class StoreWriterFactory(BaseFactory["StoreWriter"]):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@StoreWriterFactory.register("bin")
|
||||||
|
class BinWriter(StoreWriter):
|
||||||
|
def save(self, output_dir, domain, shard_idx, tensors):
|
||||||
|
shard_path = os.path.join(output_dir, domain, f"shard_{shard_idx:04d}")
|
||||||
|
try:
|
||||||
|
save_bin(shard_path, tensors)
|
||||||
|
except Exception:
|
||||||
|
if os.path.exists(shard_path):
|
||||||
|
shutil.rmtree(shard_path, ignore_errors=True)
|
||||||
|
logger.error(
|
||||||
|
"Failed to write shard %s/%s_%04d, cleaned up partial output",
|
||||||
|
domain,
|
||||||
|
"shard",
|
||||||
|
shard_idx,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
@StoreWriterFactory.register("h5")
|
||||||
|
class H5Writer(StoreWriter):
|
||||||
|
def save(self, output_dir, domain, shard_idx, tensors):
|
||||||
|
chunk_dir = os.path.join(output_dir, domain)
|
||||||
|
file_path = os.path.join(chunk_dir, f"data_{shard_idx:04d}.h5")
|
||||||
|
try:
|
||||||
|
save_h5(chunk_dir, f"data_{shard_idx:04d}", tensors)
|
||||||
|
except Exception:
|
||||||
|
if os.path.exists(file_path):
|
||||||
|
os.remove(file_path)
|
||||||
|
logger.error(
|
||||||
|
"Failed to write shard %s/data_%04d.h5, cleaned up partial output",
|
||||||
|
domain,
|
||||||
|
shard_idx,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
raise
|
||||||
+20
-1
@@ -3,7 +3,7 @@ import json
|
|||||||
import time
|
import time
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, Union
|
from typing import Any, Dict, Optional, Union
|
||||||
|
|
||||||
import safetensors.torch as st
|
import safetensors.torch as st
|
||||||
import torch
|
import torch
|
||||||
@@ -180,3 +180,22 @@ class Checkpoint:
|
|||||||
extra=extra,
|
extra=extra,
|
||||||
config=config,
|
config=config,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def load_any(cls, save_dir: str, broadcast: bool = False) -> Optional["Checkpoint"]:
|
||||||
|
save_path = Path(save_dir)
|
||||||
|
meta_path = save_path / _META_FILE
|
||||||
|
weights_path = save_path / _WEIGHTS_FILE
|
||||||
|
|
||||||
|
if meta_path.exists():
|
||||||
|
return cls.load(save_dir, broadcast=broadcast)
|
||||||
|
|
||||||
|
if weights_path.exists():
|
||||||
|
state_dict = load_state_dict(weights_path, broadcast=broadcast)
|
||||||
|
config = {}
|
||||||
|
config_path = save_path / _CONFIG_FILE
|
||||||
|
if config_path.exists():
|
||||||
|
config = load_json(config_path, broadcast)
|
||||||
|
return cls(state_dict=state_dict, config=config)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
from dataclasses import dataclass
|
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from jinja2 import Template
|
from jinja2 import Template
|
||||||
|
|
||||||
# Message type for chat messages
|
|
||||||
type MessageType = Dict[str, Any]
|
type MessageType = Dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ChatTemplate:
|
class ChatTemplate:
|
||||||
"""A chat template with Jinja2 rendering support.
|
"""A chat template with Jinja2 rendering support.
|
||||||
|
|
||||||
@@ -15,23 +12,24 @@ class ChatTemplate:
|
|||||||
name: Unique identifier for the template.
|
name: Unique identifier for the template.
|
||||||
template_str: Jinja2 template string.
|
template_str: Jinja2 template string.
|
||||||
description: Optional description.
|
description: Optional description.
|
||||||
default_variables: Optional dictionary of default variable values
|
default_variables: Optional dictionary of default variable values.
|
||||||
that will be passed to the template if not overridden during rendering.
|
|
||||||
special_tokens: Optional dictionary mapping token names to their string values.
|
special_tokens: Optional dictionary mapping token names to their string values.
|
||||||
These tokens are automatically added to the template variables.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name: str
|
def __init__(
|
||||||
template_str: str
|
self,
|
||||||
description: str = ""
|
name: str = "",
|
||||||
default_variables: Dict[str, Any] = None
|
template_str: str = "",
|
||||||
special_tokens: Dict[str, str] = None
|
description: str = "",
|
||||||
|
default_variables: Optional[Dict[str, Any]] = None,
|
||||||
def __post_init__(self):
|
special_tokens: Optional[Dict[str, str]] = None,
|
||||||
if self.default_variables is None:
|
):
|
||||||
self.default_variables = {}
|
self.name = name
|
||||||
if self.special_tokens is None:
|
self.template_str = template_str
|
||||||
self.special_tokens = {}
|
self.description = description
|
||||||
|
self.default_variables = default_variables or {}
|
||||||
|
self.special_tokens = special_tokens or {}
|
||||||
|
self._compiled: Template = Template(template_str)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_string(
|
def from_string(
|
||||||
@@ -43,7 +41,7 @@ class ChatTemplate:
|
|||||||
) -> "ChatTemplate":
|
) -> "ChatTemplate":
|
||||||
"""Create a ChatTemplate instance directly from a template string."""
|
"""Create a ChatTemplate instance directly from a template string."""
|
||||||
return cls(
|
return cls(
|
||||||
name="", # empty name for ad‑hoc templates
|
name="",
|
||||||
template_str=template_str,
|
template_str=template_str,
|
||||||
description=description,
|
description=description,
|
||||||
default_variables=default_variables,
|
default_variables=default_variables,
|
||||||
@@ -73,5 +71,4 @@ class ChatTemplate:
|
|||||||
if system_prompt is not None:
|
if system_prompt is not None:
|
||||||
variables["system_prompt"] = system_prompt
|
variables["system_prompt"] = system_prompt
|
||||||
|
|
||||||
jinja_template = Template(self.template_str)
|
return self._compiled.render(**variables)
|
||||||
return jinja_template.render(**variables)
|
|
||||||
|
|||||||
+64
-29
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import math
|
import math
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from typing import Any, Dict, List, Type
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
from torch.optim.lr_scheduler import LRScheduler
|
from torch.optim.lr_scheduler import LRScheduler
|
||||||
|
|
||||||
@@ -31,7 +31,6 @@ class SchedulerFactory(BaseFactory["BaseScheduler"]):
|
|||||||
"""Factory class for creating learning rate schedulers.
|
"""Factory class for creating learning rate schedulers.
|
||||||
|
|
||||||
Supports decorator-based registration for extensible scheduler types.
|
Supports decorator-based registration for extensible scheduler types.
|
||||||
Also supports creation from ScheduleConfig objects.
|
|
||||||
|
|
||||||
Example usage:
|
Example usage:
|
||||||
@SchedulerFactory.register("custom")
|
@SchedulerFactory.register("custom")
|
||||||
@@ -41,33 +40,6 @@ class SchedulerFactory(BaseFactory["BaseScheduler"]):
|
|||||||
scheduler = SchedulerFactory.create("custom", optimizer, **kwargs)
|
scheduler = SchedulerFactory.create("custom", optimizer, **kwargs)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _validate_component(cls, scheduler_cls: Type[BaseScheduler]):
|
|
||||||
"""Validate that the scheduler class inherits from BaseScheduler."""
|
|
||||||
if not issubclass(scheduler_cls, BaseScheduler):
|
|
||||||
raise TypeError(f"{scheduler_cls.__name__} must inherit from BaseScheduler")
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def create(
|
|
||||||
cls, optimizer, schedule_type: str = "none", **kwargs
|
|
||||||
) -> "BaseScheduler":
|
|
||||||
"""Create a scheduler instance by type name.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
optimizer: PyTorch optimizer
|
|
||||||
schedule_type: Type of scheduler ("cosine", "sgdr")
|
|
||||||
**kwargs: Arguments passed to the scheduler constructor
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Scheduler instance
|
|
||||||
"""
|
|
||||||
return super().create(schedule_type, optimizer, **kwargs)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def available_types(cls) -> list:
|
|
||||||
"""Return list of registered scheduler type names."""
|
|
||||||
return cls.list_registered()
|
|
||||||
|
|
||||||
|
|
||||||
# ----------- Scheduler implementations -----------
|
# ----------- Scheduler implementations -----------
|
||||||
|
|
||||||
@@ -192,3 +164,66 @@ class SGDRScheduler(BaseScheduler):
|
|||||||
self.min_rate = state_dict.pop("min_rate")
|
self.min_rate = state_dict.pop("min_rate")
|
||||||
self.t_mult = state_dict.pop("t_mult")
|
self.t_mult = state_dict.pop("t_mult")
|
||||||
super().load_state_dict(state_dict)
|
super().load_state_dict(state_dict)
|
||||||
|
|
||||||
|
|
||||||
|
@SchedulerFactory.register("wsd")
|
||||||
|
class WSDScheduler(BaseScheduler):
|
||||||
|
"""WSD (Warmup-Stable-Decay) scheduler with sqrt cooldown.
|
||||||
|
|
||||||
|
warmup_steps: linear warmup from min_rate to 1.0
|
||||||
|
stable_steps: constant at base_lr
|
||||||
|
decay_steps: sqrt decay from base_lr to min_rate
|
||||||
|
min_rate: minimum lr as fraction of base_lr (default 0.0)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
optimizer,
|
||||||
|
warmup_steps: int,
|
||||||
|
stable_steps: int,
|
||||||
|
decay_steps: int,
|
||||||
|
min_rate: float = 0.0,
|
||||||
|
last_epoch: int = -1,
|
||||||
|
):
|
||||||
|
self.warmup_steps = warmup_steps
|
||||||
|
self.stable_steps = stable_steps
|
||||||
|
self.decay_steps = decay_steps
|
||||||
|
self.min_rate = min_rate
|
||||||
|
self.total_steps = warmup_steps + stable_steps + decay_steps
|
||||||
|
super().__init__(optimizer, last_epoch)
|
||||||
|
|
||||||
|
def get_lr(self) -> List[float]:
|
||||||
|
if self.last_epoch < self.warmup_steps:
|
||||||
|
factor = self.last_epoch / max(self.warmup_steps, 1)
|
||||||
|
return [base_lr * factor for base_lr in self.base_lrs]
|
||||||
|
|
||||||
|
offset = self.last_epoch - self.warmup_steps
|
||||||
|
|
||||||
|
if offset < self.stable_steps:
|
||||||
|
return list(self.base_lrs)
|
||||||
|
|
||||||
|
decay_ratio = (offset - self.stable_steps) / max(self.decay_steps, 1)
|
||||||
|
decay_ratio = min(decay_ratio, 1.0)
|
||||||
|
factor = (1.0 - self.min_rate) * (1.0 - decay_ratio) ** 2 + self.min_rate
|
||||||
|
return [base_lr * factor for base_lr in self.base_lrs]
|
||||||
|
|
||||||
|
def state_dict(self):
|
||||||
|
state = super().state_dict()
|
||||||
|
state.update(
|
||||||
|
{
|
||||||
|
"warmup_steps": self.warmup_steps,
|
||||||
|
"stable_steps": self.stable_steps,
|
||||||
|
"decay_steps": self.decay_steps,
|
||||||
|
"min_rate": self.min_rate,
|
||||||
|
"total_steps": self.total_steps,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return state
|
||||||
|
|
||||||
|
def load_state_dict(self, state_dict):
|
||||||
|
self.warmup_steps = state_dict.pop("warmup_steps")
|
||||||
|
self.stable_steps = state_dict.pop("stable_steps")
|
||||||
|
self.decay_steps = state_dict.pop("decay_steps")
|
||||||
|
self.min_rate = state_dict.pop("min_rate")
|
||||||
|
self.total_steps = state_dict.pop("total_steps")
|
||||||
|
super().load_state_dict(state_dict)
|
||||||
|
|||||||
+58
-57
@@ -1,41 +1,28 @@
|
|||||||
"""Training strategy implementations with factory pattern."""
|
"""Training strategy implementations with factory pattern."""
|
||||||
|
|
||||||
import copy
|
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from typing import Any, Callable, Dict, Union
|
from typing import Callable, Dict, Union
|
||||||
|
|
||||||
import torch
|
import torch
|
||||||
import torch.nn as nn
|
import torch.nn as nn
|
||||||
import torch.nn.functional as F
|
import torch.nn.functional as F
|
||||||
from torch import Tensor
|
from torch import Tensor
|
||||||
from torch.distributed.fsdp import FullyShardedDataParallel as FSDP
|
|
||||||
from torch.nn.parallel import DistributedDataParallel as DDP
|
|
||||||
|
|
||||||
from astrai.factory import BaseFactory
|
from astrai.factory import BaseFactory
|
||||||
|
|
||||||
|
|
||||||
def unwrap_model(model: nn.Module) -> nn.Module:
|
def create_ref_model(
|
||||||
if isinstance(model, DDP):
|
model_fn: Callable[[], nn.Module], state_dict: Dict[str, Tensor]
|
||||||
return model.module
|
) -> nn.Module:
|
||||||
if isinstance(model, FSDP):
|
"""Create a frozen reference model from model_fn + full state dict."""
|
||||||
return model._fsdp_wrapped_module
|
ref_model = model_fn()
|
||||||
return model
|
ref_model.load_state_dict(state_dict)
|
||||||
|
|
||||||
|
|
||||||
def create_ref_model(model: nn.Module) -> nn.Module:
|
|
||||||
"""Create a reference model for DPO/GRPO training.
|
|
||||||
|
|
||||||
Handles DDP-wrapped models safely by unwrapping first,
|
|
||||||
then creating a deep copy with frozen gradients.
|
|
||||||
"""
|
|
||||||
original_model = unwrap_model(model)
|
|
||||||
ref_model = copy.deepcopy(original_model)
|
|
||||||
ref_model.requires_grad_(False)
|
ref_model.requires_grad_(False)
|
||||||
ref_model.eval()
|
ref_model.eval()
|
||||||
return ref_model
|
return ref_model
|
||||||
|
|
||||||
|
|
||||||
def move_to_device(batch: Dict[str, Tensor], device: str) -> Any:
|
def move_to_device(batch: Dict[str, Tensor], device: str) -> Dict[str, Tensor]:
|
||||||
"""Move batch tensors to specified device with non-blocking transfer."""
|
"""Move batch tensors to specified device with non-blocking transfer."""
|
||||||
return {key: value.to(device, non_blocking=True) for key, value in batch.items()}
|
return {key: value.to(device, non_blocking=True) for key, value in batch.items()}
|
||||||
|
|
||||||
@@ -45,7 +32,7 @@ def get_logprobs(
|
|||||||
input_ids: Tensor,
|
input_ids: Tensor,
|
||||||
mask: Tensor,
|
mask: Tensor,
|
||||||
reduction: str,
|
reduction: str,
|
||||||
):
|
) -> Tensor:
|
||||||
"""Compute token-wise log probabilities from model outputs.
|
"""Compute token-wise log probabilities from model outputs.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -83,14 +70,35 @@ def get_logprobs(
|
|||||||
return token_logprobs * shifted_mask
|
return token_logprobs * shifted_mask
|
||||||
|
|
||||||
|
|
||||||
|
def make_doc_boundary_mask(position_ids: Tensor) -> Tensor:
|
||||||
|
S = position_ids.size(1)
|
||||||
|
device = position_ids.device
|
||||||
|
boundaries = position_ids[:, 1:] <= position_ids[:, :-1]
|
||||||
|
doc_ids = torch.cat(
|
||||||
|
[
|
||||||
|
torch.zeros(position_ids.size(0), 1, dtype=torch.long, device=device),
|
||||||
|
boundaries.long().cumsum(dim=1),
|
||||||
|
],
|
||||||
|
dim=1,
|
||||||
|
)
|
||||||
|
same_doc = doc_ids.unsqueeze(-1) == doc_ids.unsqueeze(-2)
|
||||||
|
causal = torch.tril(torch.ones(S, S, dtype=torch.bool, device=device))
|
||||||
|
return (same_doc & causal).unsqueeze(1)
|
||||||
|
|
||||||
|
|
||||||
class BaseStrategy(ABC):
|
class BaseStrategy(ABC):
|
||||||
"""Abstract base class for training strategies."""
|
"""Abstract base class for training strategies."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self, model: Union[Callable[..., Dict[str, Tensor]]], device: str, **kwargs
|
self,
|
||||||
|
model: Union[nn.Module, Callable[..., Dict[str, Tensor]]],
|
||||||
|
device: str,
|
||||||
|
**kwargs,
|
||||||
):
|
):
|
||||||
self.model = model
|
self.model = model
|
||||||
self.device = device
|
self.device = device
|
||||||
|
self.executor = kwargs.pop("executor", None)
|
||||||
|
self.model_fn = kwargs.pop("model_fn", None)
|
||||||
self.extra_kwargs = kwargs
|
self.extra_kwargs = kwargs
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
@@ -124,32 +132,6 @@ class StrategyFactory(BaseFactory["BaseStrategy"]):
|
|||||||
strategy = StrategyFactory.create("custom", model, device)
|
strategy = StrategyFactory.create("custom", model, device)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _validate_component(cls, strategy_cls: type):
|
|
||||||
"""Validate that the strategy class inherits from BaseStrategy."""
|
|
||||||
if not issubclass(strategy_cls, BaseStrategy):
|
|
||||||
raise TypeError(f"{strategy_cls.__name__} must inherit from BaseStrategy")
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def create(cls, train_type: str, model, device: str, **kwargs) -> "BaseStrategy":
|
|
||||||
"""Create a strategy instance based on training type.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
train_type: Type of training ("seq", "sft", "dpo", "grpo")
|
|
||||||
model: Model instance for the strategy
|
|
||||||
device: Device to run the strategy on
|
|
||||||
**kwargs: Additional arguments passed to strategy constructor
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Strategy instance
|
|
||||||
"""
|
|
||||||
return super().create(train_type, model, device, **kwargs)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def available_strategies(cls) -> list:
|
|
||||||
"""Return list of registered strategy names."""
|
|
||||||
return cls.list_registered()
|
|
||||||
|
|
||||||
|
|
||||||
# ============== Strategy Classes ==============
|
# ============== Strategy Classes ==============
|
||||||
# All strategies are registered at class definition time using the decorator
|
# All strategies are registered at class definition time using the decorator
|
||||||
@@ -162,7 +144,13 @@ class SEQStrategy(BaseStrategy):
|
|||||||
Computes cross-entropy loss for next token prediction.
|
Computes cross-entropy loss for next token prediction.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, model, device, label_smoothing: float = 0.0, **kwargs):
|
def __init__(
|
||||||
|
self,
|
||||||
|
model: Union[nn.Module, Callable[..., Dict[str, Tensor]]],
|
||||||
|
device: str,
|
||||||
|
label_smoothing: float = 0.0,
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
super().__init__(model, device, **kwargs)
|
super().__init__(model, device, **kwargs)
|
||||||
self.label_smoothing = label_smoothing
|
self.label_smoothing = label_smoothing
|
||||||
|
|
||||||
@@ -187,21 +175,31 @@ class SFTStrategy(BaseStrategy):
|
|||||||
Applies cross-entropy loss only to tokens where loss_mask is True.
|
Applies cross-entropy loss only to tokens where loss_mask is True.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, model, device, label_smoothing: float = 0.0, **kwargs):
|
def __init__(
|
||||||
|
self,
|
||||||
|
model: Union[nn.Module, Callable[..., Dict[str, Tensor]]],
|
||||||
|
device: str,
|
||||||
|
label_smoothing: float = 0.0,
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
super().__init__(model, device, **kwargs)
|
super().__init__(model, device, **kwargs)
|
||||||
self.label_smoothing = label_smoothing
|
self.label_smoothing = label_smoothing
|
||||||
|
|
||||||
def compute_loss(self, batch: Dict[str, Tensor]) -> Tensor:
|
def compute_loss(self, batch: Dict[str, Tensor]) -> Tensor:
|
||||||
batch = move_to_device(batch, self.device)
|
batch = move_to_device(batch, self.device)
|
||||||
input_ids, target_ids, loss_mask = (
|
input_ids, target_ids, position_ids, loss_mask = (
|
||||||
batch["input_ids"],
|
batch["input_ids"],
|
||||||
batch["target_ids"],
|
batch["target_ids"],
|
||||||
|
batch["position_ids"],
|
||||||
batch["loss_mask"],
|
batch["loss_mask"],
|
||||||
)
|
)
|
||||||
|
|
||||||
ignore_index = -100
|
ignore_index = -100
|
||||||
logits = self.model(input_ids=input_ids)["logits"]
|
input_mask = make_doc_boundary_mask(position_ids)
|
||||||
target_ids = target_ids.masked_fill(loss_mask == 0, ignore_index)
|
target_ids = target_ids.masked_fill(loss_mask == 0, ignore_index)
|
||||||
|
logits = self.model(
|
||||||
|
input_ids=input_ids, position_ids=position_ids, input_mask=input_mask
|
||||||
|
)["logits"]
|
||||||
|
|
||||||
loss = F.cross_entropy(
|
loss = F.cross_entropy(
|
||||||
input=logits.flatten(0, 1).float(),
|
input=logits.flatten(0, 1).float(),
|
||||||
@@ -230,7 +228,9 @@ class DPOStrategy(BaseStrategy):
|
|||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
super().__init__(model, device, **kwargs)
|
super().__init__(model, device, **kwargs)
|
||||||
self.ref_model = create_ref_model(model)
|
self.ref_model = create_ref_model(
|
||||||
|
self.model_fn, self.executor.unwrap_model(model)
|
||||||
|
).to(device=self.device)
|
||||||
self.beta = beta
|
self.beta = beta
|
||||||
self.reduction = reduction
|
self.reduction = reduction
|
||||||
|
|
||||||
@@ -284,7 +284,9 @@ class GRPOStrategy(BaseStrategy):
|
|||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
super().__init__(model, device, **kwargs)
|
super().__init__(model, device, **kwargs)
|
||||||
self.ref_model = create_ref_model(model)
|
self.ref_model = create_ref_model(
|
||||||
|
self.model_fn, self.executor.unwrap_model(model)
|
||||||
|
).to(device=self.device)
|
||||||
self.clip_eps = clip_eps
|
self.clip_eps = clip_eps
|
||||||
self.kl_coef = kl_coef
|
self.kl_coef = kl_coef
|
||||||
self.group_size = group_size
|
self.group_size = group_size
|
||||||
@@ -294,8 +296,7 @@ class GRPOStrategy(BaseStrategy):
|
|||||||
|
|
||||||
def sync_ref_model(self):
|
def sync_ref_model(self):
|
||||||
"""Copy current model weights to ref model."""
|
"""Copy current model weights to ref model."""
|
||||||
ref_state = self.model.state_dict()
|
self.ref_model.load_state_dict(self.executor.unwrap_model(self.model))
|
||||||
self.ref_model.load_state_dict(ref_state)
|
|
||||||
|
|
||||||
def compute_loss(self, batch: Dict[str, Tensor]) -> Tensor:
|
def compute_loss(self, batch: Dict[str, Tensor]) -> Tensor:
|
||||||
self._step += 1
|
self._step += 1
|
||||||
|
|||||||
@@ -146,8 +146,7 @@ class CheckpointCallback(TrainCallback):
|
|||||||
self.last_ckpt_iter = 0
|
self.last_ckpt_iter = 0
|
||||||
|
|
||||||
def _save_checkpoint(self, context: TrainContext):
|
def _save_checkpoint(self, context: TrainContext):
|
||||||
unwrapped = context.executor.unwrap_model(context.model)
|
state_dict = context.executor.unwrap_model(context.model)
|
||||||
state_dict = unwrapped.state_dict()
|
|
||||||
self.last_ckpt_iter = context.iteration
|
self.last_ckpt_iter = context.iteration
|
||||||
|
|
||||||
if get_rank() == 0:
|
if get_rank() == 0:
|
||||||
@@ -155,11 +154,13 @@ class CheckpointCallback(TrainCallback):
|
|||||||
self.save_dir, f"epoch_{context.epoch}_iter_{context.iteration}"
|
self.save_dir, f"epoch_{context.epoch}_iter_{context.iteration}"
|
||||||
)
|
)
|
||||||
extra = self.save_extra_fn(context)
|
extra = self.save_extra_fn(context)
|
||||||
|
meta = context.config.to_dict()
|
||||||
context.checkpoint = Checkpoint(
|
context.checkpoint = Checkpoint(
|
||||||
state_dict=state_dict,
|
state_dict=state_dict,
|
||||||
epoch=context.epoch,
|
epoch=context.epoch,
|
||||||
iteration=context.iteration,
|
iteration=context.iteration,
|
||||||
extra=extra,
|
extra=extra,
|
||||||
|
meta=meta,
|
||||||
config=context.model_config,
|
config=context.model_config,
|
||||||
)
|
)
|
||||||
context.checkpoint.save(save_path)
|
context.checkpoint.save(save_path)
|
||||||
@@ -214,7 +215,7 @@ class ProgressBarCallback(TrainCallback):
|
|||||||
"loss": f"{context.loss:.4f}",
|
"loss": f"{context.loss:.4f}",
|
||||||
"lr": f"{context.optimizer.param_groups[-1]['lr']:.2e}",
|
"lr": f"{context.optimizer.param_groups[-1]['lr']:.2e}",
|
||||||
}
|
}
|
||||||
if context.val_loss > 0:
|
if context.val_loss is not None:
|
||||||
postfix["val_loss"] = f"{context.val_loss:.4f}"
|
postfix["val_loss"] = f"{context.val_loss:.4f}"
|
||||||
self.progress_bar.set_postfix(postfix)
|
self.progress_bar.set_postfix(postfix)
|
||||||
self.progress_bar.update(1)
|
self.progress_bar.update(1)
|
||||||
@@ -236,6 +237,7 @@ class MetricLoggerCallback(TrainCallback):
|
|||||||
metrics: List[str] = None,
|
metrics: List[str] = None,
|
||||||
):
|
):
|
||||||
self.last_log_iter = 0
|
self.last_log_iter = 0
|
||||||
|
self._last_val_loss = None
|
||||||
self.save_interval = save_interval
|
self.save_interval = save_interval
|
||||||
self.log_interval = log_interval
|
self.log_interval = log_interval
|
||||||
self.metrics = metrics or ["loss", "lr"]
|
self.metrics = metrics or ["loss", "lr"]
|
||||||
@@ -257,41 +259,54 @@ class MetricLoggerCallback(TrainCallback):
|
|||||||
"grad_nan_num": ctx_get_grad_nan_num,
|
"grad_nan_num": ctx_get_grad_nan_num,
|
||||||
}
|
}
|
||||||
|
|
||||||
def _get_log_data(self, context: TrainContext):
|
def _metrics(self, context: TrainContext, names):
|
||||||
return {
|
return {
|
||||||
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
m: self._metric_funcs[m](context)
|
||||||
"epoch": context.epoch,
|
for m in names
|
||||||
"iter": context.iteration,
|
if self._metric_funcs[m](context) is not None
|
||||||
**{m: self._metric_funcs[m](context) for m in self.metrics},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@only_on_rank(0)
|
@only_on_rank(0)
|
||||||
def _add_log(self, log_data):
|
def _append(self, event_type: str, context: TrainContext, **extra):
|
||||||
self.log_cache.append(log_data)
|
entry = {
|
||||||
|
"type": event_type,
|
||||||
|
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
||||||
|
"epoch": context.epoch,
|
||||||
|
"iter": context.iteration,
|
||||||
|
**extra,
|
||||||
|
}
|
||||||
|
self.log_cache.append(entry)
|
||||||
|
|
||||||
@only_on_rank(0)
|
@only_on_rank(0)
|
||||||
def _save_log(self, epoch, iter):
|
def _flush(self, epoch, iter):
|
||||||
log_file = self.log_dir / f"epoch_{epoch}_iter_{iter}_metric.jsonl"
|
log_file = self.log_dir / f"epoch_{epoch}_iter_{iter}_metric.jsonl"
|
||||||
|
log_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
with open(log_file, "w") as f:
|
with open(log_file, "w") as f:
|
||||||
for log in self.log_cache:
|
for log in self.log_cache:
|
||||||
f.write(json.dumps(log) + "\n")
|
f.write(json.dumps(log) + "\n")
|
||||||
|
|
||||||
def on_batch_end(self, context):
|
def on_batch_end(self, context):
|
||||||
if context.iteration % self.log_interval == 0:
|
if context.iteration % self.log_interval == 0:
|
||||||
log_data = self._get_log_data(context)
|
step_metrics = [m for m in self.metrics if m != "val_loss"]
|
||||||
self._add_log(log_data)
|
self._append("step", context, **self._metrics(context, step_metrics))
|
||||||
|
|
||||||
if context.iteration - self.last_log_iter >= self.save_interval:
|
if context.iteration - self.last_log_iter >= self.save_interval:
|
||||||
self._save_log(context.epoch, context.iteration)
|
self._flush(context.epoch, context.iteration)
|
||||||
self.last_log_iter = context.iteration
|
self.last_log_iter = context.iteration
|
||||||
|
|
||||||
|
def on_optimizer_step(self, context):
|
||||||
|
if context.val_loss is not None and context.val_loss != self._last_val_loss:
|
||||||
|
self._append("validation", context, val_loss=context.val_loss)
|
||||||
|
self._last_val_loss = context.val_loss
|
||||||
|
|
||||||
|
def on_epoch_end(self, context):
|
||||||
|
self._append("epoch", context)
|
||||||
|
|
||||||
def on_train_end(self, context):
|
def on_train_end(self, context):
|
||||||
if context.iteration != self.last_log_iter:
|
if context.iteration != self.last_log_iter:
|
||||||
self._save_log(context.epoch, context.iteration)
|
self._flush(context.epoch, context.iteration)
|
||||||
|
|
||||||
def on_error(self, context):
|
def on_error(self, context):
|
||||||
self._save_log(context.epoch, context.iteration)
|
self._flush(context.epoch, context.iteration)
|
||||||
|
|
||||||
|
|
||||||
@CallbackFactory.register("validation")
|
@CallbackFactory.register("validation")
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, Self
|
from typing import Any, Dict, Optional, Self
|
||||||
|
|
||||||
|
import torch
|
||||||
import torch.nn as nn
|
import torch.nn as nn
|
||||||
from torch.utils.data import DataLoader
|
from torch.utils.data import DataLoader, random_split
|
||||||
|
|
||||||
from astrai.config.train_config import TrainConfig
|
from astrai.config.train_config import TrainConfig
|
||||||
from astrai.dataset import ResumableDistributedSampler
|
from astrai.dataset import ResumableDistributedSampler
|
||||||
@@ -11,7 +12,7 @@ from astrai.model.components.lora import inject_lora
|
|||||||
from astrai.parallel.executor import BaseExecutor, ExecutorFactory
|
from astrai.parallel.executor import BaseExecutor, ExecutorFactory
|
||||||
from astrai.parallel.setup import get_current_device, get_rank, get_world_size
|
from astrai.parallel.setup import get_current_device, get_rank, get_world_size
|
||||||
from astrai.protocols import OptimizerProtocol, SchedulerProtocol
|
from astrai.protocols import OptimizerProtocol, SchedulerProtocol
|
||||||
from astrai.serialization import Checkpoint, load_json, load_model_weights
|
from astrai.serialization import Checkpoint, load_json
|
||||||
from astrai.trainer.strategy import BaseStrategy, StrategyFactory
|
from astrai.trainer.strategy import BaseStrategy, StrategyFactory
|
||||||
|
|
||||||
|
|
||||||
@@ -30,12 +31,12 @@ class TrainContext:
|
|||||||
epoch: int = field(default=0)
|
epoch: int = field(default=0)
|
||||||
iteration: int = field(default=0)
|
iteration: int = field(default=0)
|
||||||
loss: float = field(default=0.0)
|
loss: float = field(default=0.0)
|
||||||
val_dataloader: DataLoader = field(default=None)
|
val_dataloader: Optional[DataLoader] = field(default=None)
|
||||||
val_loss: float = field(default=0.0)
|
val_loss: Optional[float] = field(default=None)
|
||||||
|
|
||||||
world_size: int = field(default=1)
|
world_size: int = field(default=1)
|
||||||
rank: int = field(default=0)
|
rank: int = field(default=0)
|
||||||
kwargs: dict = field(default_factory=dict)
|
kwargs: Dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
class TrainContextBuilder:
|
class TrainContextBuilder:
|
||||||
@@ -81,20 +82,14 @@ class TrainContextBuilder:
|
|||||||
executor=executor,
|
executor=executor,
|
||||||
)
|
)
|
||||||
|
|
||||||
if self._resume_dir is not None:
|
if self._resume_dir:
|
||||||
resume_path = Path(self._resume_dir)
|
checkpoint = Checkpoint.load_any(self._resume_dir)
|
||||||
if (resume_path / "meta.json").exists():
|
if checkpoint is not None:
|
||||||
checkpoint = Checkpoint.load(self._resume_dir)
|
model.load_state_dict(checkpoint.state_dict, strict=False)
|
||||||
state_dict = checkpoint.state_dict
|
|
||||||
if checkpoint.config:
|
if checkpoint.config:
|
||||||
context.model_config = checkpoint.config
|
context.model_config = checkpoint.config
|
||||||
else:
|
context.epoch = checkpoint.epoch or cfg.start_epoch
|
||||||
checkpoint = None
|
context.iteration = checkpoint.iteration or cfg.start_batch
|
||||||
state_dict = load_model_weights(self._resume_dir)
|
|
||||||
model.load_state_dict(state_dict, strict=False)
|
|
||||||
if checkpoint is not None:
|
|
||||||
context.epoch = cfg.start_epoch
|
|
||||||
context.iteration = cfg.start_batch
|
|
||||||
context.checkpoint = checkpoint
|
context.checkpoint = checkpoint
|
||||||
|
|
||||||
if cfg.lora is not None:
|
if cfg.lora is not None:
|
||||||
@@ -108,15 +103,27 @@ class TrainContextBuilder:
|
|||||||
context.optimizer = cfg.optimizer_fn(model)
|
context.optimizer = cfg.optimizer_fn(model)
|
||||||
context.scheduler = cfg.scheduler_fn(context.optimizer)
|
context.scheduler = cfg.scheduler_fn(context.optimizer)
|
||||||
|
|
||||||
|
train_dataset = cfg.dataset
|
||||||
|
val_dataset = cfg.val_dataset
|
||||||
|
|
||||||
|
if val_dataset is None and cfg.val_split is not None:
|
||||||
|
n_total = len(cfg.dataset)
|
||||||
|
n_val = max(1, int(n_total * cfg.val_split))
|
||||||
|
n_train = n_total - n_val
|
||||||
|
generator = torch.Generator().manual_seed(cfg.random_seed)
|
||||||
|
train_dataset, val_dataset = random_split(
|
||||||
|
cfg.dataset, [n_train, n_val], generator=generator
|
||||||
|
)
|
||||||
|
|
||||||
sampler_offset = context.iteration * cfg.batch_per_device
|
sampler_offset = context.iteration * cfg.batch_per_device
|
||||||
sampler = ResumableDistributedSampler(
|
sampler = ResumableDistributedSampler(
|
||||||
data_source=cfg.dataset,
|
data_source=train_dataset,
|
||||||
start_epoch=context.epoch,
|
start_epoch=context.epoch,
|
||||||
start_iter=sampler_offset,
|
start_iter=sampler_offset,
|
||||||
seed=cfg.random_seed,
|
seed=cfg.random_seed,
|
||||||
)
|
)
|
||||||
context.dataloader = DataLoader(
|
context.dataloader = DataLoader(
|
||||||
cfg.dataset,
|
train_dataset,
|
||||||
batch_size=cfg.batch_per_device,
|
batch_size=cfg.batch_per_device,
|
||||||
sampler=sampler,
|
sampler=sampler,
|
||||||
num_workers=cfg.num_workers,
|
num_workers=cfg.num_workers,
|
||||||
@@ -124,16 +131,16 @@ class TrainContextBuilder:
|
|||||||
prefetch_factor=cfg.prefetch_factor,
|
prefetch_factor=cfg.prefetch_factor,
|
||||||
)
|
)
|
||||||
|
|
||||||
if cfg.val_dataset is not None:
|
if val_dataset is not None:
|
||||||
val_sampler = ResumableDistributedSampler(
|
val_sampler = ResumableDistributedSampler(
|
||||||
data_source=cfg.val_dataset,
|
data_source=val_dataset,
|
||||||
start_epoch=0,
|
start_epoch=0,
|
||||||
start_iter=0,
|
start_iter=0,
|
||||||
seed=cfg.random_seed,
|
seed=cfg.random_seed,
|
||||||
shuffle=False,
|
shuffle=False,
|
||||||
)
|
)
|
||||||
context.val_dataloader = DataLoader(
|
context.val_dataloader = DataLoader(
|
||||||
cfg.val_dataset,
|
val_dataset,
|
||||||
batch_size=cfg.batch_per_device,
|
batch_size=cfg.batch_per_device,
|
||||||
sampler=val_sampler,
|
sampler=val_sampler,
|
||||||
num_workers=cfg.num_workers,
|
num_workers=cfg.num_workers,
|
||||||
@@ -159,9 +166,11 @@ class TrainContextBuilder:
|
|||||||
obj.load_state_dict(extra[name])
|
obj.load_state_dict(extra[name])
|
||||||
|
|
||||||
context.strategy = StrategyFactory.create(
|
context.strategy = StrategyFactory.create(
|
||||||
|
cfg.strategy,
|
||||||
model=context.model,
|
model=context.model,
|
||||||
train_type=cfg.strategy,
|
|
||||||
device=device,
|
device=device,
|
||||||
|
executor=executor,
|
||||||
|
model_fn=cfg.model_fn,
|
||||||
**cfg.extra_kwargs,
|
**cfg.extra_kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ class Trainer:
|
|||||||
cfg.ckpt_dir,
|
cfg.ckpt_dir,
|
||||||
cfg.ckpt_interval,
|
cfg.ckpt_interval,
|
||||||
),
|
),
|
||||||
|
CallbackFactory.create("validation"),
|
||||||
CallbackFactory.create(
|
CallbackFactory.create(
|
||||||
"metric_logger",
|
"metric_logger",
|
||||||
log_dir=cfg.log_dir,
|
log_dir=cfg.log_dir,
|
||||||
@@ -43,7 +44,6 @@ class Trainer:
|
|||||||
),
|
),
|
||||||
CallbackFactory.create("progress_bar", cfg.n_epoch),
|
CallbackFactory.create("progress_bar", cfg.n_epoch),
|
||||||
CallbackFactory.create("gradient_clipping", cfg.max_grad_norm),
|
CallbackFactory.create("gradient_clipping", cfg.max_grad_norm),
|
||||||
CallbackFactory.create("validation"),
|
|
||||||
]
|
]
|
||||||
return callbacks
|
return callbacks
|
||||||
|
|
||||||
@@ -68,9 +68,8 @@ class Trainer:
|
|||||||
self._call_callbacks("on_epoch_begin", context)
|
self._call_callbacks("on_epoch_begin", context)
|
||||||
|
|
||||||
for batch in context.dataloader:
|
for batch in context.dataloader:
|
||||||
self._call_callbacks("on_batch_begin", context)
|
|
||||||
|
|
||||||
with executor.accumulate(context.model):
|
with executor.accumulate(context.model):
|
||||||
|
self._call_callbacks("on_batch_begin", context)
|
||||||
loss = context.strategy(batch)
|
loss = context.strategy(batch)
|
||||||
context.loss = loss.item()
|
context.loss = loss.item()
|
||||||
stand_loss = loss / executor.grad_accum_steps
|
stand_loss = loss / executor.grad_accum_steps
|
||||||
|
|||||||
@@ -0,0 +1,334 @@
|
|||||||
|
"""HumanEval code generation benchmark.
|
||||||
|
|
||||||
|
Generates n completions per problem, extracts function bodies, executes
|
||||||
|
against hidden tests, and computes pass@k.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
python scripts/tools/evaluate_humaneval.py --param_path ./params \
|
||||||
|
--data_path HumanEval.jsonl.gz --output results.json \
|
||||||
|
--num_samples 200 --temperature 0.8 --max_tokens 512
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from math import prod
|
||||||
|
from multiprocessing import Process, Queue
|
||||||
|
from typing import Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import torch
|
||||||
|
import tqdm
|
||||||
|
|
||||||
|
from astrai.inference import InferenceEngine
|
||||||
|
from astrai.model import AutoModel
|
||||||
|
from astrai.tokenize import AutoTokenizer
|
||||||
|
|
||||||
|
HUMANEVAL_URL = (
|
||||||
|
"https://github.com/openai/human-eval/raw/master/data/HumanEval.jsonl.gz"
|
||||||
|
)
|
||||||
|
|
||||||
|
_STOP_SEQUENCES = [
|
||||||
|
"\nclass ",
|
||||||
|
"\ndef ",
|
||||||
|
"\n# ",
|
||||||
|
"\nif __name__",
|
||||||
|
"\nprint(",
|
||||||
|
"\n\n\n",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _download_humaneval(data_path: str):
|
||||||
|
if os.path.exists(data_path):
|
||||||
|
return
|
||||||
|
import gzip
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
os.makedirs(os.path.dirname(data_path) or ".", exist_ok=True)
|
||||||
|
print(f"Downloading HumanEval from {HUMANEVAL_URL} ...")
|
||||||
|
tmp = data_path + ".tmp"
|
||||||
|
urllib.request.urlretrieve(HUMANEVAL_URL, tmp)
|
||||||
|
with gzip.open(tmp, "rb") as f_in:
|
||||||
|
with open(data_path, "wb") as f_out:
|
||||||
|
f_out.write(f_in.read())
|
||||||
|
os.remove(tmp)
|
||||||
|
print(f" saved to {data_path}")
|
||||||
|
|
||||||
|
|
||||||
|
def _load_problems(data_path: str) -> List[dict]:
|
||||||
|
problems = []
|
||||||
|
with open(data_path, "r", encoding="utf-8") as f:
|
||||||
|
for line in f:
|
||||||
|
line = line.strip()
|
||||||
|
if line:
|
||||||
|
problems.append(json.loads(line))
|
||||||
|
return problems
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_function_body(code: str, entry_point: str) -> Optional[str]:
|
||||||
|
"""Extract the function body from a completion."""
|
||||||
|
pattern = rf"def\s+{re.escape(entry_point)}\b[^:]*:"
|
||||||
|
match = re.search(pattern, code)
|
||||||
|
if not match:
|
||||||
|
# Use the full code as-is if we can't find the function
|
||||||
|
return code
|
||||||
|
|
||||||
|
body_start = match.end()
|
||||||
|
lines = code[body_start:].split("\n")
|
||||||
|
body_lines = []
|
||||||
|
started = False
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
stripped = line.rstrip()
|
||||||
|
if not stripped and not started:
|
||||||
|
continue
|
||||||
|
if not stripped and started:
|
||||||
|
body_lines.append("")
|
||||||
|
continue
|
||||||
|
if not started:
|
||||||
|
started = True
|
||||||
|
if stripped.lstrip() == stripped and started:
|
||||||
|
break
|
||||||
|
body_lines.append(stripped)
|
||||||
|
|
||||||
|
body = "\n".join(body_lines)
|
||||||
|
if not body.strip():
|
||||||
|
return None
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
def _trim_stop_sequences(text: str) -> str:
|
||||||
|
for stop in _STOP_SEQUENCES:
|
||||||
|
idx = text.find(stop)
|
||||||
|
if idx != -1:
|
||||||
|
text = text[:idx]
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def _execute_code(problem: dict, completion: str, timeout: float = 3.0) -> bool:
|
||||||
|
"""Run the completion against hidden tests in a subprocess."""
|
||||||
|
|
||||||
|
def _worker(queue, full_code):
|
||||||
|
try:
|
||||||
|
namespace = {}
|
||||||
|
exec(full_code, namespace)
|
||||||
|
check = namespace.get("check")
|
||||||
|
if check is None:
|
||||||
|
queue.put(False)
|
||||||
|
return
|
||||||
|
check(namespace.get(problem["entry_point"]))
|
||||||
|
queue.put(True)
|
||||||
|
except Exception:
|
||||||
|
queue.put(False)
|
||||||
|
|
||||||
|
full_code = problem["prompt"] + completion + "\n" + problem["test"]
|
||||||
|
|
||||||
|
queue: Queue = Queue()
|
||||||
|
proc = Process(target=_worker, args=(queue, full_code))
|
||||||
|
proc.start()
|
||||||
|
proc.join(timeout)
|
||||||
|
|
||||||
|
if proc.is_alive():
|
||||||
|
proc.terminate()
|
||||||
|
proc.join()
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
return queue.get_nowait()
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _pass_at_k(n: int, c: int, k: int) -> float:
|
||||||
|
"""Unbiased estimator of pass@k."""
|
||||||
|
if n - c < k:
|
||||||
|
return 1.0
|
||||||
|
return 1.0 - float(prod(1.0 - k / np.arange(n - c + 1, n + 1)))
|
||||||
|
|
||||||
|
|
||||||
|
def _deduplicate(completions: List[str]) -> List[str]:
|
||||||
|
seen = set()
|
||||||
|
unique = []
|
||||||
|
for c in completions:
|
||||||
|
if c not in seen:
|
||||||
|
seen.add(c)
|
||||||
|
unique.append(c)
|
||||||
|
return unique
|
||||||
|
|
||||||
|
|
||||||
|
def _generate(
|
||||||
|
engine: InferenceEngine,
|
||||||
|
prompt: str,
|
||||||
|
num_samples: int,
|
||||||
|
max_tokens: int,
|
||||||
|
temperature: float,
|
||||||
|
top_p: float,
|
||||||
|
top_k: int,
|
||||||
|
batch_size: int,
|
||||||
|
) -> List[str]:
|
||||||
|
batches = [prompt] * min(batch_size, num_samples)
|
||||||
|
completions = []
|
||||||
|
remaining = num_samples
|
||||||
|
|
||||||
|
while remaining > 0:
|
||||||
|
current = min(batch_size, remaining)
|
||||||
|
batch_prompts = batches[:current]
|
||||||
|
outputs = engine.generate(
|
||||||
|
prompt=batch_prompts,
|
||||||
|
stream=False,
|
||||||
|
max_tokens=max_tokens,
|
||||||
|
temperature=temperature,
|
||||||
|
top_p=top_p,
|
||||||
|
top_k=top_k,
|
||||||
|
)
|
||||||
|
if isinstance(outputs, str):
|
||||||
|
outputs = [outputs]
|
||||||
|
completions.extend(outputs)
|
||||||
|
remaining -= current
|
||||||
|
|
||||||
|
return _deduplicate(completions)
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate(
|
||||||
|
engine: InferenceEngine,
|
||||||
|
problems: List[dict],
|
||||||
|
num_samples: int,
|
||||||
|
max_tokens: int,
|
||||||
|
temperature: float,
|
||||||
|
top_p: float,
|
||||||
|
top_k: int,
|
||||||
|
batch_size: int,
|
||||||
|
k_values: Tuple[int, ...] = (1, 10, 100),
|
||||||
|
) -> Dict:
|
||||||
|
results = {}
|
||||||
|
all_pass_at_k = {k: [] for k in k_values}
|
||||||
|
|
||||||
|
for problem in tqdm.tqdm(problems, desc="HumanEval", unit="problem"):
|
||||||
|
task_id = problem["task_id"]
|
||||||
|
prompt = problem["prompt"]
|
||||||
|
entry_point = problem["entry_point"]
|
||||||
|
|
||||||
|
raw_completions = _generate(
|
||||||
|
engine,
|
||||||
|
prompt,
|
||||||
|
num_samples,
|
||||||
|
max_tokens,
|
||||||
|
temperature,
|
||||||
|
top_p,
|
||||||
|
top_k,
|
||||||
|
batch_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
completions = []
|
||||||
|
for raw in raw_completions:
|
||||||
|
trimmed = _trim_stop_sequences(raw)
|
||||||
|
body = _extract_function_body(trimmed, entry_point)
|
||||||
|
if body:
|
||||||
|
completions.append(body)
|
||||||
|
|
||||||
|
passed = 0
|
||||||
|
for comp in completions:
|
||||||
|
if _execute_code(problem, comp):
|
||||||
|
passed += 1
|
||||||
|
|
||||||
|
n = len(completions)
|
||||||
|
c = passed
|
||||||
|
result = {"task_id": task_id, "n": n, "passed": c}
|
||||||
|
for k in k_values:
|
||||||
|
result[f"pass@{k}"] = round(_pass_at_k(n, c, k), 4)
|
||||||
|
all_pass_at_k[k].append(_pass_at_k(n, c, k))
|
||||||
|
results[task_id] = result
|
||||||
|
|
||||||
|
summary = {}
|
||||||
|
for k in k_values:
|
||||||
|
vals = all_pass_at_k[k]
|
||||||
|
summary[f"pass@{k}"] = round(float(np.mean(vals)), 4)
|
||||||
|
results["_summary"] = summary
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="HumanEval benchmark")
|
||||||
|
parser.add_argument(
|
||||||
|
"--param_path", type=str, default="./params", help="Model directory"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--data_path",
|
||||||
|
type=str,
|
||||||
|
default="./humaneval/HumanEval.jsonl",
|
||||||
|
help="HumanEval JSONL file (auto-download if missing)",
|
||||||
|
)
|
||||||
|
parser.add_argument("--output", type=str, default=None, help="Output JSON path")
|
||||||
|
parser.add_argument(
|
||||||
|
"--num_samples",
|
||||||
|
type=int,
|
||||||
|
default=200,
|
||||||
|
help="Completions per problem",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--max_tokens", type=int, default=512, help="Max generation tokens"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--temperature", type=float, default=0.8, help="Sampling temperature"
|
||||||
|
)
|
||||||
|
parser.add_argument("--top_p", type=float, default=0.95, help="Top-p sampling")
|
||||||
|
parser.add_argument("--top_k", type=int, default=50, help="Top-k sampling")
|
||||||
|
parser.add_argument(
|
||||||
|
"--batch_size", type=int, default=1, help="Inference batch size"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--problems",
|
||||||
|
type=int,
|
||||||
|
nargs="+",
|
||||||
|
default=None,
|
||||||
|
help="Specific problem indices (0-based)",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
_download_humaneval(args.data_path)
|
||||||
|
problems = _load_problems(args.data_path)
|
||||||
|
if args.problems:
|
||||||
|
problems = [problems[i] for i in args.problems if i < len(problems)]
|
||||||
|
|
||||||
|
model = AutoModel.from_pretrained(args.param_path)
|
||||||
|
tokenizer = AutoTokenizer.from_pretrained(args.param_path)
|
||||||
|
model.to(device="cuda", dtype=torch.bfloat16)
|
||||||
|
|
||||||
|
engine = InferenceEngine(
|
||||||
|
model=model,
|
||||||
|
tokenizer=tokenizer,
|
||||||
|
max_batch_size=args.batch_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
results = evaluate(
|
||||||
|
engine=engine,
|
||||||
|
problems=problems,
|
||||||
|
num_samples=args.num_samples,
|
||||||
|
max_tokens=args.max_tokens,
|
||||||
|
temperature=args.temperature,
|
||||||
|
top_p=args.top_p,
|
||||||
|
top_k=args.top_k,
|
||||||
|
batch_size=args.batch_size,
|
||||||
|
k_values=(1, 10, 100),
|
||||||
|
)
|
||||||
|
|
||||||
|
summary = results.pop("_summary")
|
||||||
|
print(f"\n{'=' * 60}")
|
||||||
|
for k, v in summary.items():
|
||||||
|
print(f" {k}: {v:.2%}")
|
||||||
|
print(f"{'=' * 60}")
|
||||||
|
|
||||||
|
if args.output:
|
||||||
|
results["_summary"] = summary
|
||||||
|
with open(args.output, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(results, f, indent=2, ensure_ascii=False)
|
||||||
|
print(f"Results saved to {args.output}")
|
||||||
|
|
||||||
|
engine.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
"""IFD (Instruction Following Difficulty) data quality scoring.
|
||||||
|
|
||||||
|
Computes IFD scores for instruction-response pairs to guide data selection.
|
||||||
|
IFD = conditional_NLL / unconditional_NLL, where:
|
||||||
|
|
||||||
|
- conditional_NLL: average CE loss on response tokens given instruction context
|
||||||
|
- unconditional_NLL: average CE loss on response tokens alone
|
||||||
|
|
||||||
|
Higher IFD (close to 1) = instruction provides less help = harder sample.
|
||||||
|
Lower IFD (close to 0) = instruction provides strong guidance = easy sample.
|
||||||
|
IFD > 1 = instruction misleads the model = likely low-quality data.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
python scripts/eval/ifd.py --param_path ./params \
|
||||||
|
--input data.jsonl --output data_with_ifd.jsonl \
|
||||||
|
--instr_key instruction --resp_key response
|
||||||
|
|
||||||
|
Disable chat template::
|
||||||
|
|
||||||
|
python scripts/eval/ifd.py --param_path ./params \
|
||||||
|
--input data.jsonl --output data_with_ifd.jsonl \
|
||||||
|
--instr_key instruction --resp_key response \
|
||||||
|
--no_chat_template
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
|
||||||
|
import torch
|
||||||
|
import torch.nn.functional as F
|
||||||
|
import tqdm
|
||||||
|
|
||||||
|
from astrai.model import AutoModel
|
||||||
|
from astrai.tokenize import AutoTokenizer
|
||||||
|
|
||||||
|
|
||||||
|
def compute_ifd(
|
||||||
|
model,
|
||||||
|
tokenizer,
|
||||||
|
instruction: str,
|
||||||
|
response: str,
|
||||||
|
device: str,
|
||||||
|
max_len: int = 2048,
|
||||||
|
use_chat_template: bool = False,
|
||||||
|
) -> dict:
|
||||||
|
if use_chat_template:
|
||||||
|
return _compute_ifd_with_template(
|
||||||
|
model, tokenizer, instruction, response, device, max_len
|
||||||
|
)
|
||||||
|
return _compute_ifd_raw(model, tokenizer, instruction, response, device, max_len)
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_ifd_raw(model, tokenizer, instruction, response, device, max_len) -> dict:
|
||||||
|
instr_ids = tokenizer.encode(instruction)
|
||||||
|
resp_ids = tokenizer.encode(response)
|
||||||
|
|
||||||
|
if not resp_ids:
|
||||||
|
return {
|
||||||
|
"L_cond": None,
|
||||||
|
"L_uncond": None,
|
||||||
|
"ifd": None,
|
||||||
|
"error": "empty response",
|
||||||
|
}
|
||||||
|
|
||||||
|
qa_len = len(instr_ids) + len(resp_ids)
|
||||||
|
if qa_len > max_len:
|
||||||
|
overflow = qa_len - max_len
|
||||||
|
instr_ids = instr_ids[overflow:]
|
||||||
|
|
||||||
|
instr_len = len(instr_ids)
|
||||||
|
resp_len = len(resp_ids)
|
||||||
|
|
||||||
|
qa_ids = instr_ids + resp_ids
|
||||||
|
qa_tensor = torch.tensor([qa_ids], device=device, dtype=torch.long)
|
||||||
|
|
||||||
|
with torch.inference_mode():
|
||||||
|
logits_qa = model(qa_tensor)["logits"][0]
|
||||||
|
|
||||||
|
resp_logits = logits_qa[instr_len - 1 : -1]
|
||||||
|
resp_targets = torch.tensor(resp_ids, device=device, dtype=torch.long)
|
||||||
|
L_cond = F.cross_entropy(resp_logits, resp_targets, reduction="mean").item()
|
||||||
|
|
||||||
|
resp_tensor = torch.tensor([resp_ids], device=device, dtype=torch.long)
|
||||||
|
|
||||||
|
with torch.inference_mode():
|
||||||
|
logits_resp = model(resp_tensor)["logits"][0]
|
||||||
|
|
||||||
|
unp_logits = logits_resp[:-1]
|
||||||
|
unp_targets = resp_tensor[0, 1:]
|
||||||
|
L_uncond = F.cross_entropy(unp_logits, unp_targets, reduction="mean").item()
|
||||||
|
|
||||||
|
ifd = L_cond / L_uncond if L_uncond > 0 else None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"L_cond": round(L_cond, 6),
|
||||||
|
"L_uncond": round(L_uncond, 6),
|
||||||
|
"ifd": round(ifd, 6) if ifd is not None else None,
|
||||||
|
"instr_len": instr_len,
|
||||||
|
"resp_len": resp_len,
|
||||||
|
"error": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_ifd_with_template(
|
||||||
|
model, tokenizer, instruction, response, device, max_len
|
||||||
|
) -> dict:
|
||||||
|
instr_prefix = tokenizer.apply_chat_template(
|
||||||
|
[{"role": "user", "content": instruction}],
|
||||||
|
tokenize=False,
|
||||||
|
add_generation_prompt=True,
|
||||||
|
)
|
||||||
|
full_text = tokenizer.apply_chat_template(
|
||||||
|
[
|
||||||
|
{"role": "user", "content": instruction},
|
||||||
|
{"role": "assistant", "content": response},
|
||||||
|
],
|
||||||
|
tokenize=False,
|
||||||
|
add_generation_prompt=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
full_ids = tokenizer.encode(full_text)
|
||||||
|
prefix_ids = tokenizer.encode(instr_prefix)
|
||||||
|
resp_ids = tokenizer.encode(response)
|
||||||
|
|
||||||
|
if not resp_ids:
|
||||||
|
return {
|
||||||
|
"L_cond": None,
|
||||||
|
"L_uncond": None,
|
||||||
|
"ifd": None,
|
||||||
|
"error": "empty response",
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(full_ids) > max_len:
|
||||||
|
overflow = len(full_ids) - max_len
|
||||||
|
full_ids = full_ids[overflow:]
|
||||||
|
prefix_len = len(prefix_ids) - overflow
|
||||||
|
prefix_len = max(0, prefix_len)
|
||||||
|
else:
|
||||||
|
prefix_len = len(prefix_ids)
|
||||||
|
|
||||||
|
cond_tensor = torch.tensor([full_ids], device=device, dtype=torch.long)
|
||||||
|
|
||||||
|
with torch.inference_mode():
|
||||||
|
logits_qa = model(cond_tensor)["logits"][0]
|
||||||
|
|
||||||
|
resp_start = prefix_len - 1
|
||||||
|
resp_end = len(full_ids) - 1
|
||||||
|
if resp_end <= resp_start:
|
||||||
|
return {
|
||||||
|
"L_cond": None,
|
||||||
|
"L_uncond": None,
|
||||||
|
"ifd": None,
|
||||||
|
"error": "response truncated entirely",
|
||||||
|
}
|
||||||
|
|
||||||
|
resp_logits = logits_qa[resp_start:resp_end]
|
||||||
|
resp_targets = torch.tensor(full_ids[prefix_len:], device=device, dtype=torch.long)
|
||||||
|
L_cond = F.cross_entropy(resp_logits, resp_targets, reduction="mean").item()
|
||||||
|
|
||||||
|
resp_tensor = torch.tensor([resp_ids], device=device, dtype=torch.long)
|
||||||
|
|
||||||
|
with torch.inference_mode():
|
||||||
|
logits_resp = model(resp_tensor)["logits"][0]
|
||||||
|
|
||||||
|
unp_logits = logits_resp[:-1]
|
||||||
|
unp_targets = resp_tensor[0, 1:]
|
||||||
|
L_uncond = F.cross_entropy(unp_logits, unp_targets, reduction="mean").item()
|
||||||
|
|
||||||
|
ifd = L_cond / L_uncond if L_uncond > 0 else None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"L_cond": round(L_cond, 6),
|
||||||
|
"L_uncond": round(L_uncond, 6),
|
||||||
|
"ifd": round(ifd, 6) if ifd is not None else None,
|
||||||
|
"instr_len": prefix_len,
|
||||||
|
"resp_len": len(resp_ids),
|
||||||
|
"error": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def process_file(
|
||||||
|
param_path: str,
|
||||||
|
input_file: str,
|
||||||
|
output_file: str,
|
||||||
|
instr_key: str,
|
||||||
|
resp_key: str,
|
||||||
|
max_len: int,
|
||||||
|
use_chat_template: bool = False,
|
||||||
|
):
|
||||||
|
device = "cuda" if torch.cuda.is_available() else "cpu"
|
||||||
|
dtype = torch.bfloat16 if device == "cuda" else torch.float32
|
||||||
|
|
||||||
|
model = AutoModel.from_pretrained(param_path)
|
||||||
|
tokenizer = AutoTokenizer.from_pretrained(param_path)
|
||||||
|
model.to(device=device, dtype=dtype)
|
||||||
|
model.eval()
|
||||||
|
|
||||||
|
if use_chat_template and tokenizer._chat_template is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"--use_chat_template specified but tokenizer has no chat template. "
|
||||||
|
"Add a chat_template to tokenizer_config.json or omit the flag."
|
||||||
|
)
|
||||||
|
|
||||||
|
with open(input_file, "r", encoding="utf-8") as f:
|
||||||
|
data = [json.loads(line) for line in f if line.strip()]
|
||||||
|
|
||||||
|
results = []
|
||||||
|
ifd_values = []
|
||||||
|
|
||||||
|
with torch.inference_mode():
|
||||||
|
for item in tqdm.tqdm(data, desc="Computing IFD", unit="sample"):
|
||||||
|
instruction = item[instr_key]
|
||||||
|
response = item[resp_key]
|
||||||
|
scores = compute_ifd(
|
||||||
|
model,
|
||||||
|
tokenizer,
|
||||||
|
instruction,
|
||||||
|
response,
|
||||||
|
device,
|
||||||
|
max_len,
|
||||||
|
use_chat_template=use_chat_template,
|
||||||
|
)
|
||||||
|
ifd_values.append(scores["ifd"])
|
||||||
|
results.append({**item, "ifd": scores["ifd"], "ifd_detail": scores})
|
||||||
|
|
||||||
|
with open(output_file, "w", encoding="utf-8") as f:
|
||||||
|
for item in results:
|
||||||
|
f.write(json.dumps(item, ensure_ascii=False) + "\n")
|
||||||
|
|
||||||
|
valid_ifd = [v for v in ifd_values if v is not None]
|
||||||
|
if valid_ifd:
|
||||||
|
import statistics
|
||||||
|
|
||||||
|
print(f"\n{'=' * 50}")
|
||||||
|
print(f" Samples: {len(data)}")
|
||||||
|
print(f" Valid IFD: {len(valid_ifd)}")
|
||||||
|
print(f" Mean IFD: {statistics.mean(valid_ifd):.4f}")
|
||||||
|
print(f" Median IFD: {statistics.median(valid_ifd):.4f}")
|
||||||
|
print(f" Stdev IFD: {statistics.stdev(valid_ifd):.4f}")
|
||||||
|
print(f" Min IFD: {min(valid_ifd):.4f}")
|
||||||
|
print(f" Max IFD: {max(valid_ifd):.4f}")
|
||||||
|
print(f"{'=' * 50}")
|
||||||
|
|
||||||
|
print(f"Results saved to {output_file}")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Compute IFD scores for instruction-response data"
|
||||||
|
)
|
||||||
|
parser.add_argument("--param_path", type=str, required=True, help="Model directory")
|
||||||
|
parser.add_argument("--input", type=str, required=True, help="Input JSONL file")
|
||||||
|
parser.add_argument("--output", type=str, required=True, help="Output JSONL file")
|
||||||
|
parser.add_argument(
|
||||||
|
"--instr_key",
|
||||||
|
type=str,
|
||||||
|
default="instruction",
|
||||||
|
help="Key for instruction field",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--resp_key",
|
||||||
|
type=str,
|
||||||
|
default="response",
|
||||||
|
help="Key for response field",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--max_len",
|
||||||
|
type=int,
|
||||||
|
default=2048,
|
||||||
|
help="Max token length (instruction truncated to fit)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--no_chat_template",
|
||||||
|
action="store_true",
|
||||||
|
default=False,
|
||||||
|
help="Disable chat template, use raw text concatenation",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
process_file(
|
||||||
|
args.param_path,
|
||||||
|
args.input,
|
||||||
|
args.output,
|
||||||
|
args.instr_key,
|
||||||
|
args.resp_key,
|
||||||
|
args.max_len,
|
||||||
|
use_chat_template=not args.no_chat_template,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,609 @@
|
|||||||
|
"""IFEval instruction-following evaluation benchmark.
|
||||||
|
|
||||||
|
Evaluates model responses against regex-based constraint verifiers.
|
||||||
|
Supports all IFEval constraint types except language detection.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
python scripts/tools/evaluate_ifeval.py --param_path ./params \
|
||||||
|
--data_path ifeval.jsonl --output results.json \
|
||||||
|
--temperature 0.1 --max_tokens 512
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import urllib.request
|
||||||
|
from typing import Callable, Dict, List, Optional
|
||||||
|
|
||||||
|
import torch
|
||||||
|
import tqdm
|
||||||
|
|
||||||
|
from astrai.inference import InferenceEngine
|
||||||
|
from astrai.model import AutoModel
|
||||||
|
from astrai.tokenize import AutoTokenizer
|
||||||
|
|
||||||
|
IFEVAL_URL = (
|
||||||
|
"https://raw.githubusercontent.com/google-research/"
|
||||||
|
"google-research/master/instruction_following_eval/data/input_data.jsonl"
|
||||||
|
)
|
||||||
|
|
||||||
|
CONSTRAINT_VERIFIERS: Dict[str, Callable[[str, dict], bool]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def register(instruction_id: str):
|
||||||
|
def decorator(fn):
|
||||||
|
CONSTRAINT_VERIFIERS[instruction_id] = fn
|
||||||
|
return fn
|
||||||
|
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
@register("keywords:existence")
|
||||||
|
def check_keyword_existence(response: str, kwargs: dict) -> bool:
|
||||||
|
for kw in kwargs["keywords"]:
|
||||||
|
if not re.search(re.escape(kw), response, re.IGNORECASE):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
@register("keywords:frequency")
|
||||||
|
def check_keyword_frequency(response: str, kwargs: dict) -> bool:
|
||||||
|
keyword = kwargs["keyword"]
|
||||||
|
frequency = kwargs.get("frequency", 1)
|
||||||
|
relation = kwargs.get("relation", "at least")
|
||||||
|
count = len(re.findall(re.escape(keyword), response, re.IGNORECASE))
|
||||||
|
if relation == "less than":
|
||||||
|
return count < frequency
|
||||||
|
return count >= frequency
|
||||||
|
|
||||||
|
|
||||||
|
@register("keywords:forbidden_words")
|
||||||
|
def check_forbidden_words(response: str, kwargs: dict) -> bool:
|
||||||
|
for word in kwargs["forbidden_words"]:
|
||||||
|
if re.search(r"\b" + re.escape(word) + r"\b", response, re.IGNORECASE):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
@register("keywords:letter_frequency")
|
||||||
|
def check_letter_frequency(response: str, kwargs: dict) -> bool:
|
||||||
|
letter = kwargs["letter"].lower()
|
||||||
|
frequency = kwargs.get("let_frequency", 1)
|
||||||
|
relation = kwargs.get("let_relation", "at least")
|
||||||
|
count = response.lower().count(letter)
|
||||||
|
if relation == "less than":
|
||||||
|
return count < frequency
|
||||||
|
return count >= frequency
|
||||||
|
|
||||||
|
|
||||||
|
@register("detectable_content:number_placeholders")
|
||||||
|
def check_placeholders(response: str, kwargs: dict) -> bool:
|
||||||
|
num = kwargs.get("num_placeholders", 1)
|
||||||
|
placeholders = re.findall(r"\[.*?\]", response)
|
||||||
|
return len(placeholders) >= num
|
||||||
|
|
||||||
|
|
||||||
|
@register("detectable_content:postscript")
|
||||||
|
def check_postscript(response: str, kwargs: dict) -> bool:
|
||||||
|
marker = kwargs.get("postscript_marker", "P.S.")
|
||||||
|
response_lower = response.lower()
|
||||||
|
if marker == "P.P.S":
|
||||||
|
return bool(re.search(r"p\.\s?p\.\s?s", response_lower))
|
||||||
|
elif marker == "P.S.":
|
||||||
|
return bool(re.search(r"p\.\s?s\.", response_lower))
|
||||||
|
else:
|
||||||
|
return bool(re.search(re.escape(marker.lower()), response_lower))
|
||||||
|
|
||||||
|
|
||||||
|
@register("detectable_format:number_bullet_lists")
|
||||||
|
def check_bullet_lists(response: str, kwargs: dict) -> bool:
|
||||||
|
num = kwargs.get("num_bullets", 1)
|
||||||
|
bullets = re.findall(r"^\s*\*[^\*].*$", response, re.MULTILINE)
|
||||||
|
dashes = re.findall(r"^\s*-.*$", response, re.MULTILINE)
|
||||||
|
return len(bullets) + len(dashes) == num
|
||||||
|
|
||||||
|
|
||||||
|
@register("detectable_format:number_highlighted_sections")
|
||||||
|
def check_highlighted_sections(response: str, kwargs: dict) -> bool:
|
||||||
|
num = kwargs.get("num_highlights", 1)
|
||||||
|
highlights = re.findall(r"\*[^\n\*]+\*", response)
|
||||||
|
count = 0
|
||||||
|
for h in highlights:
|
||||||
|
if h.strip("*").strip():
|
||||||
|
count += 1
|
||||||
|
return count >= num
|
||||||
|
|
||||||
|
|
||||||
|
@register("detectable_format:multiple_sections")
|
||||||
|
def check_multiple_sections(response: str, kwargs: dict) -> bool:
|
||||||
|
splitter = kwargs.get("section_spliter", "Section")
|
||||||
|
num = kwargs.get("num_sections", 1)
|
||||||
|
pattern = r"\s?" + re.escape(splitter) + r"\s?\d+\s?"
|
||||||
|
sections = re.split(pattern, response)
|
||||||
|
return len(sections) - 1 >= num
|
||||||
|
|
||||||
|
|
||||||
|
@register("detectable_format:title")
|
||||||
|
def check_title(response: str, kwargs: dict) -> bool:
|
||||||
|
titles = re.findall(r"<<[^>\n]+>>", response)
|
||||||
|
for title in titles:
|
||||||
|
if title.strip("<>").strip():
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@register("detectable_format:json_format")
|
||||||
|
def check_json_format(response: str, kwargs: dict) -> bool:
|
||||||
|
value = response.strip()
|
||||||
|
for prefix in ("```json", "```Json", "```JSON", "```"):
|
||||||
|
if value.lower().startswith(prefix.lower()):
|
||||||
|
value = value[len(prefix) :].strip()
|
||||||
|
if value.endswith("```"):
|
||||||
|
value = value[:-3].strip()
|
||||||
|
try:
|
||||||
|
json.loads(value)
|
||||||
|
return True
|
||||||
|
except (ValueError, json.JSONDecodeError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@register("detectable_format:general_punctuation")
|
||||||
|
def check_general_punctuation(response: str, kwargs: dict) -> bool:
|
||||||
|
punctuation_blacklist = kwargs.get("punctuation_blacklist", [])
|
||||||
|
for punct in punctuation_blacklist:
|
||||||
|
if punct in response:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
@register("detectable_format:number_highlighted_words")
|
||||||
|
def check_highlighted_words(response: str, kwargs: dict) -> bool:
|
||||||
|
num = kwargs.get("num_highlights", 1)
|
||||||
|
highlights = re.findall(r"\*[^\s\*][^\*]*[^\s\*]\*", response)
|
||||||
|
return len(highlights) >= num
|
||||||
|
|
||||||
|
|
||||||
|
@register("startend:end_checker")
|
||||||
|
def check_end_checker(response: str, kwargs: dict) -> bool:
|
||||||
|
end_phrase = kwargs["end_phrase"]
|
||||||
|
return (
|
||||||
|
response.strip()
|
||||||
|
.rstrip('"')
|
||||||
|
.rstrip()
|
||||||
|
.lower()
|
||||||
|
.endswith(end_phrase.strip().lower())
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@register("startend:quotation")
|
||||||
|
def check_quotation(response: str, kwargs: dict) -> bool:
|
||||||
|
value = response.strip()
|
||||||
|
return value.startswith('"') and value.endswith('"')
|
||||||
|
|
||||||
|
|
||||||
|
@register("startend:start_checker")
|
||||||
|
def check_start_checker(response: str, kwargs: dict) -> bool:
|
||||||
|
starter = kwargs["starter"]
|
||||||
|
return bool(re.search(r"^\s*" + re.escape(starter), response, re.MULTILINE))
|
||||||
|
|
||||||
|
|
||||||
|
@register("change_case:english_capital")
|
||||||
|
def check_english_capital(response: str, kwargs: dict) -> bool:
|
||||||
|
return response.isupper()
|
||||||
|
|
||||||
|
|
||||||
|
@register("change_case:english_lowercase")
|
||||||
|
def check_english_lowercase(response: str, kwargs: dict) -> bool:
|
||||||
|
return response.islower()
|
||||||
|
|
||||||
|
|
||||||
|
@register("change_case:capital_word_frequency")
|
||||||
|
def check_capital_word_frequency(response: str, kwargs: dict) -> bool:
|
||||||
|
frequency = kwargs.get("capital_frequency", 1)
|
||||||
|
relation = kwargs.get("capital_relation", "at least")
|
||||||
|
capital_words = re.findall(r"\b[A-Z]{2,}\b", response)
|
||||||
|
count = len(capital_words)
|
||||||
|
if relation == "less than":
|
||||||
|
return count < frequency
|
||||||
|
return count >= frequency
|
||||||
|
|
||||||
|
|
||||||
|
@register("punctuation:no_comma")
|
||||||
|
def check_no_comma(response: str, kwargs: dict) -> bool:
|
||||||
|
return "," not in response
|
||||||
|
|
||||||
|
|
||||||
|
def count_words(text: str) -> int:
|
||||||
|
return len(re.findall(r"\b\w+\b", text))
|
||||||
|
|
||||||
|
|
||||||
|
def count_sentences(text: str) -> int:
|
||||||
|
text = text.strip()
|
||||||
|
if not text:
|
||||||
|
return 0
|
||||||
|
sentences = re.split(r"(?<=[.!?])\s+", text)
|
||||||
|
return len([s for s in sentences if s.strip()])
|
||||||
|
|
||||||
|
|
||||||
|
@register("length_constraints:number_words")
|
||||||
|
def check_number_words(response: str, kwargs: dict) -> bool:
|
||||||
|
num = kwargs.get("num_words", 100)
|
||||||
|
relation = kwargs.get("relation", "at least")
|
||||||
|
cnt = count_words(response)
|
||||||
|
if relation == "less than":
|
||||||
|
return cnt < num
|
||||||
|
return cnt >= num
|
||||||
|
|
||||||
|
|
||||||
|
@register("length_constraints:number_sentences")
|
||||||
|
def check_number_sentences(response: str, kwargs: dict) -> bool:
|
||||||
|
num = kwargs.get("num_sentences", 5)
|
||||||
|
relation = kwargs.get("relation", "at least")
|
||||||
|
cnt = count_sentences(response)
|
||||||
|
if relation == "less than":
|
||||||
|
return cnt < num
|
||||||
|
return cnt >= num
|
||||||
|
|
||||||
|
|
||||||
|
@register("length_constraints:number_paragraphs")
|
||||||
|
def check_number_paragraphs(response: str, kwargs: dict) -> bool:
|
||||||
|
num = kwargs.get("num_paragraphs", 1)
|
||||||
|
if "***" in response:
|
||||||
|
paragraphs = re.split(r"\s?\*\*\*\s?", response)
|
||||||
|
else:
|
||||||
|
paragraphs = re.split(r"\n\n+", response)
|
||||||
|
actual = len([p for p in paragraphs if p.strip()])
|
||||||
|
return actual == num
|
||||||
|
|
||||||
|
|
||||||
|
@register("length_constraints:nth_paragraph_first_word")
|
||||||
|
def check_nth_paragraph_first_word(response: str, kwargs: dict) -> bool:
|
||||||
|
num_paragraphs = kwargs.get("num_paragraphs", 1)
|
||||||
|
nth = kwargs.get("nth_paragraph", 1)
|
||||||
|
first_word = kwargs.get("first_word", "").lower()
|
||||||
|
|
||||||
|
paragraphs = re.split(r"\n\n+", response)
|
||||||
|
paragraphs = [p.strip() for p in paragraphs if p.strip()]
|
||||||
|
|
||||||
|
if len(paragraphs) != num_paragraphs:
|
||||||
|
return False
|
||||||
|
if nth > len(paragraphs):
|
||||||
|
return False
|
||||||
|
|
||||||
|
target = paragraphs[nth - 1]
|
||||||
|
words = target.split()
|
||||||
|
if not words:
|
||||||
|
return False
|
||||||
|
|
||||||
|
word = words[0].strip().lstrip("'\"").rstrip(".,!?:;\"'")
|
||||||
|
return word.lower() == first_word
|
||||||
|
|
||||||
|
|
||||||
|
@register("length_constraints:nth_word_checker")
|
||||||
|
def check_nth_word(response: str, kwargs: dict) -> bool:
|
||||||
|
nth = kwargs.get("nth_word", 1)
|
||||||
|
target = kwargs.get("target_word", "").lower()
|
||||||
|
words = re.findall(r"\b\w+\b", response)
|
||||||
|
if nth > len(words):
|
||||||
|
return False
|
||||||
|
return words[nth - 1].lower() == target
|
||||||
|
|
||||||
|
|
||||||
|
@register("combination:repeat_prompt")
|
||||||
|
def check_repeat_prompt(response: str, kwargs: dict) -> bool:
|
||||||
|
prompt = kwargs["prompt_to_repeat"]
|
||||||
|
return response.strip().lower().startswith(prompt.strip().lower())
|
||||||
|
|
||||||
|
|
||||||
|
@register("combination:two_responses")
|
||||||
|
def check_two_responses(response: str, kwargs: dict) -> bool:
|
||||||
|
parts = response.split("******")
|
||||||
|
valid = [p for p in parts if p.strip()]
|
||||||
|
if len(valid) != 2:
|
||||||
|
return False
|
||||||
|
return valid[0].strip() != valid[1].strip()
|
||||||
|
|
||||||
|
|
||||||
|
def download_ifeval(data_path: str):
|
||||||
|
if os.path.exists(data_path):
|
||||||
|
return
|
||||||
|
os.makedirs(os.path.dirname(data_path) or ".", exist_ok=True)
|
||||||
|
print(f"Downloading IFEval from {IFEVAL_URL} ...")
|
||||||
|
tmp = data_path + ".tmp"
|
||||||
|
urllib.request.urlretrieve(IFEVAL_URL, tmp)
|
||||||
|
with open(tmp, "rb") as f_in:
|
||||||
|
content = f_in.read()
|
||||||
|
with open(data_path, "wb") as f_out:
|
||||||
|
f_out.write(content)
|
||||||
|
os.remove(tmp)
|
||||||
|
print(f" saved to {data_path}")
|
||||||
|
|
||||||
|
|
||||||
|
def load_problems(data_path: str) -> List[dict]:
|
||||||
|
problems = []
|
||||||
|
with open(data_path, "r", encoding="utf-8") as f:
|
||||||
|
for line in f:
|
||||||
|
line = line.strip()
|
||||||
|
if line:
|
||||||
|
problems.append(json.loads(line))
|
||||||
|
return problems
|
||||||
|
|
||||||
|
|
||||||
|
def verify_response(response: str, instruction_id: str, kwargs: dict) -> Optional[bool]:
|
||||||
|
verifier = CONSTRAINT_VERIFIERS.get(instruction_id)
|
||||||
|
if verifier is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return verifier(response, kwargs)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def generate_one(
|
||||||
|
engine: InferenceEngine,
|
||||||
|
tokenizer: AutoTokenizer,
|
||||||
|
prompt: str,
|
||||||
|
max_tokens: int,
|
||||||
|
temperature: float,
|
||||||
|
top_p: float,
|
||||||
|
top_k: int,
|
||||||
|
) -> str:
|
||||||
|
formatted = tokenizer.apply_chat_template(
|
||||||
|
[{"role": "user", "content": prompt}],
|
||||||
|
tokenize=False,
|
||||||
|
add_generation_prompt=True,
|
||||||
|
)
|
||||||
|
output = engine.generate(
|
||||||
|
prompt=formatted,
|
||||||
|
stream=False,
|
||||||
|
max_tokens=max_tokens,
|
||||||
|
temperature=temperature,
|
||||||
|
top_p=top_p,
|
||||||
|
top_k=top_k,
|
||||||
|
)
|
||||||
|
if isinstance(output, list):
|
||||||
|
return output[0]
|
||||||
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate(
|
||||||
|
engine: InferenceEngine,
|
||||||
|
tokenizer: AutoTokenizer,
|
||||||
|
problems: List[dict],
|
||||||
|
max_tokens: int,
|
||||||
|
temperature: float,
|
||||||
|
top_p: float,
|
||||||
|
top_k: int,
|
||||||
|
num_samples: int = 1,
|
||||||
|
) -> Dict:
|
||||||
|
results = {}
|
||||||
|
constraint_stats: Dict[str, Dict[str, int]] = {}
|
||||||
|
total_constraints = 0
|
||||||
|
total_passed = 0
|
||||||
|
|
||||||
|
for problem in tqdm.tqdm(problems, desc="IFEval", unit="problem"):
|
||||||
|
key = problem["key"]
|
||||||
|
prompt = problem["prompt"]
|
||||||
|
instruction_ids = problem["instruction_id_list"]
|
||||||
|
kwargs_list = problem["kwargs"]
|
||||||
|
|
||||||
|
samples = []
|
||||||
|
for _ in range(num_samples):
|
||||||
|
response = generate_one(
|
||||||
|
engine, tokenizer, prompt, max_tokens, temperature, top_p, top_k
|
||||||
|
)
|
||||||
|
samples.append(response)
|
||||||
|
|
||||||
|
constraint_results = []
|
||||||
|
passed = 0
|
||||||
|
verified = 0
|
||||||
|
|
||||||
|
for idx, instruction_id in enumerate(instruction_ids):
|
||||||
|
kwargs = kwargs_list[idx] if idx < len(kwargs_list) else {}
|
||||||
|
best_pass = False
|
||||||
|
for response in samples:
|
||||||
|
result = verify_response(response, instruction_id, kwargs)
|
||||||
|
if result is None:
|
||||||
|
continue
|
||||||
|
if result:
|
||||||
|
best_pass = True
|
||||||
|
break
|
||||||
|
|
||||||
|
verifier_exists = instruction_id in CONSTRAINT_VERIFIERS
|
||||||
|
if verifier_exists:
|
||||||
|
verified += 1
|
||||||
|
if best_pass:
|
||||||
|
passed += 1
|
||||||
|
|
||||||
|
constraint_results.append(
|
||||||
|
{
|
||||||
|
"instruction_id": instruction_id,
|
||||||
|
"passed": best_pass,
|
||||||
|
"supported": verifier_exists,
|
||||||
|
"kwargs": kwargs,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if verifier_exists:
|
||||||
|
if instruction_id not in constraint_stats:
|
||||||
|
constraint_stats[instruction_id] = {
|
||||||
|
"total": 0,
|
||||||
|
"passed": 0,
|
||||||
|
}
|
||||||
|
constraint_stats[instruction_id]["total"] += 1
|
||||||
|
if best_pass:
|
||||||
|
constraint_stats[instruction_id]["passed"] += 1
|
||||||
|
|
||||||
|
total_constraints += verified
|
||||||
|
total_passed += passed
|
||||||
|
|
||||||
|
accuracy = passed / verified if verified > 0 else None
|
||||||
|
results[str(key)] = {
|
||||||
|
"key": key,
|
||||||
|
"prompt": prompt,
|
||||||
|
"response": samples[0],
|
||||||
|
"num_samples": num_samples,
|
||||||
|
"num_constraints": len(instruction_ids),
|
||||||
|
"num_verified": verified,
|
||||||
|
"num_passed": passed,
|
||||||
|
"accuracy": round(accuracy, 4) if accuracy is not None else None,
|
||||||
|
"constraints": constraint_results,
|
||||||
|
}
|
||||||
|
|
||||||
|
overall_accuracy = (
|
||||||
|
round(total_passed / total_constraints, 4) if total_constraints > 0 else 0.0
|
||||||
|
)
|
||||||
|
|
||||||
|
type_summary = {}
|
||||||
|
for inst_id, stats in sorted(constraint_stats.items()):
|
||||||
|
type_summary[inst_id] = {
|
||||||
|
"total": stats["total"],
|
||||||
|
"passed": stats["passed"],
|
||||||
|
"accuracy": round(stats["passed"] / stats["total"], 4)
|
||||||
|
if stats["total"] > 0
|
||||||
|
else 0.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
unsupported_count = sum(
|
||||||
|
1
|
||||||
|
for p in problems
|
||||||
|
for iid in p["instruction_id_list"]
|
||||||
|
if iid not in CONSTRAINT_VERIFIERS
|
||||||
|
)
|
||||||
|
|
||||||
|
results["_summary"] = {
|
||||||
|
"total_problems": len(problems),
|
||||||
|
"total_constraints": total_constraints,
|
||||||
|
"total_passed": total_passed,
|
||||||
|
"overall_accuracy": overall_accuracy,
|
||||||
|
"unsupported_constraints": unsupported_count,
|
||||||
|
"supported_types": sorted(CONSTRAINT_VERIFIERS.keys()),
|
||||||
|
"per_type_accuracy": type_summary,
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="IFEval benchmark")
|
||||||
|
parser.add_argument(
|
||||||
|
"--param_path", type=str, default="./params", help="Model directory"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--data_path",
|
||||||
|
type=str,
|
||||||
|
default="./ifeval/input_data.jsonl",
|
||||||
|
help="IFEval JSONL file (auto-download if missing)",
|
||||||
|
)
|
||||||
|
parser.add_argument("--output", type=str, default=None, help="Output JSON path")
|
||||||
|
parser.add_argument(
|
||||||
|
"--max_tokens", type=int, default=512, help="Max generation tokens"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--temperature",
|
||||||
|
type=float,
|
||||||
|
default=0.1,
|
||||||
|
help="Sampling temperature",
|
||||||
|
)
|
||||||
|
parser.add_argument("--top_p", type=float, default=0.95, help="Top-p sampling")
|
||||||
|
parser.add_argument("--top_k", type=int, default=50, help="Top-k sampling")
|
||||||
|
parser.add_argument(
|
||||||
|
"--num_samples",
|
||||||
|
type=int,
|
||||||
|
default=1,
|
||||||
|
help="Number of samples per problem (best-of-n scoring)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--batch_size", type=int, default=1, help="Inference batch size"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--limit",
|
||||||
|
type=int,
|
||||||
|
default=None,
|
||||||
|
help="Limit to first N problems (for quick testing)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--dump_responses",
|
||||||
|
type=str,
|
||||||
|
default=None,
|
||||||
|
help="Path to dump raw model responses (JSONL)",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
download_ifeval(args.data_path)
|
||||||
|
problems = load_problems(args.data_path)
|
||||||
|
if args.limit:
|
||||||
|
problems = problems[: args.limit]
|
||||||
|
|
||||||
|
print(f"Loaded {len(problems)} problems")
|
||||||
|
print(f"Supported constraint types: {len(CONSTRAINT_VERIFIERS)}")
|
||||||
|
|
||||||
|
model = AutoModel.from_pretrained(args.param_path)
|
||||||
|
tokenizer = AutoTokenizer.from_pretrained(args.param_path)
|
||||||
|
model.to(device="cuda", dtype=torch.bfloat16)
|
||||||
|
model.eval()
|
||||||
|
|
||||||
|
engine = InferenceEngine(
|
||||||
|
model=model,
|
||||||
|
tokenizer=tokenizer,
|
||||||
|
max_batch_size=args.batch_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
results = evaluate(
|
||||||
|
engine=engine,
|
||||||
|
tokenizer=tokenizer,
|
||||||
|
problems=problems,
|
||||||
|
max_tokens=args.max_tokens,
|
||||||
|
temperature=args.temperature,
|
||||||
|
top_p=args.top_p,
|
||||||
|
top_k=args.top_k,
|
||||||
|
num_samples=args.num_samples,
|
||||||
|
)
|
||||||
|
|
||||||
|
summary = results.pop("_summary")
|
||||||
|
print(f"\n{'=' * 60}")
|
||||||
|
print(f" Problems: {summary['total_problems']}")
|
||||||
|
print(f" Constraints: {summary['total_constraints']}")
|
||||||
|
print(f" Passed: {summary['total_passed']}")
|
||||||
|
print(f" Accuracy: {summary['overall_accuracy']:.2%}")
|
||||||
|
print(f" Unsupported: {summary['unsupported_constraints']}")
|
||||||
|
print(f"{'=' * 60}")
|
||||||
|
|
||||||
|
print("\nPer-type accuracy:")
|
||||||
|
for inst_id, stats in sorted(summary["per_type_accuracy"].items()):
|
||||||
|
print(
|
||||||
|
f" {inst_id:50s} {stats['accuracy']:.2%} "
|
||||||
|
f"({stats['passed']}/{stats['total']})"
|
||||||
|
)
|
||||||
|
|
||||||
|
if args.output:
|
||||||
|
results["_summary"] = summary
|
||||||
|
with open(args.output, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(results, f, indent=2, ensure_ascii=False)
|
||||||
|
print(f"\nResults saved to {args.output}")
|
||||||
|
|
||||||
|
if args.dump_responses:
|
||||||
|
with open(args.dump_responses, "w", encoding="utf-8") as f:
|
||||||
|
for k, v in results.items():
|
||||||
|
if k.startswith("_"):
|
||||||
|
continue
|
||||||
|
f.write(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"key": v["key"],
|
||||||
|
"prompt": v["prompt"],
|
||||||
|
"response": v["response"],
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
+ "\n"
|
||||||
|
)
|
||||||
|
print(f"Responses dumped to {args.dump_responses}")
|
||||||
|
|
||||||
|
engine.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -5,9 +5,9 @@ import csv
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import urllib.request
|
import tarfile
|
||||||
import zipfile
|
|
||||||
|
|
||||||
|
import requests
|
||||||
import torch
|
import torch
|
||||||
import torch.nn.functional as F
|
import torch.nn.functional as F
|
||||||
import tqdm
|
import tqdm
|
||||||
@@ -15,7 +15,7 @@ import tqdm
|
|||||||
from astrai.model import AutoModel
|
from astrai.model import AutoModel
|
||||||
from astrai.tokenize import AutoTokenizer
|
from astrai.tokenize import AutoTokenizer
|
||||||
|
|
||||||
MMLU_URL = "https://github.com/hendrycks/test/archive/refs/heads/master.zip"
|
MMLU_URL = "https://people.eecs.berkeley.edu/~hendrycks/data.tar"
|
||||||
MMLU_SUBJECTS = [
|
MMLU_SUBJECTS = [
|
||||||
"abstract_algebra",
|
"abstract_algebra",
|
||||||
"anatomy",
|
"anatomy",
|
||||||
@@ -78,23 +78,37 @@ MMLU_SUBJECTS = [
|
|||||||
|
|
||||||
|
|
||||||
def _download_and_extract(url: str, data_dir: str):
|
def _download_and_extract(url: str, data_dir: str):
|
||||||
zip_path = os.path.join(data_dir, "mmlu.zip")
|
tar_path = os.path.join(data_dir, "data.tar")
|
||||||
os.makedirs(data_dir, exist_ok=True)
|
os.makedirs(data_dir, exist_ok=True)
|
||||||
print(f"Downloading MMLU data from {url}...")
|
print(f"Downloading MMLU data from {url}...")
|
||||||
urllib.request.urlretrieve(url, zip_path)
|
resp = requests.get(url, stream=True, timeout=300)
|
||||||
|
resp.raise_for_status()
|
||||||
|
total = int(resp.headers.get("content-length", 0))
|
||||||
|
with tqdm.tqdm(total=total, unit="B", unit_scale=True, desc=" Download") as bar:
|
||||||
|
with open(tar_path, "wb") as f:
|
||||||
|
for chunk in resp.iter_content(chunk_size=8192):
|
||||||
|
f.write(chunk)
|
||||||
|
bar.update(len(chunk))
|
||||||
print("Extracting...")
|
print("Extracting...")
|
||||||
with zipfile.ZipFile(zip_path, "r") as zf:
|
with tarfile.open(tar_path, "r") as tf:
|
||||||
zf.extractall(data_dir)
|
tf.extractall(data_dir)
|
||||||
os.remove(zip_path)
|
os.remove(tar_path)
|
||||||
|
|
||||||
|
|
||||||
def download_mmlu(data_dir: str):
|
def download_mmlu(data_dir: str):
|
||||||
_download_and_extract(MMLU_URL, data_dir)
|
_download_and_extract(MMLU_URL, data_dir)
|
||||||
src = os.path.join(data_dir, "test-master", "data")
|
src = os.path.join(data_dir, "data")
|
||||||
if os.path.exists(src):
|
if os.path.exists(src):
|
||||||
for item in os.listdir(src):
|
for item in os.listdir(src):
|
||||||
os.rename(os.path.join(src, item), os.path.join(data_dir, item))
|
src_item = os.path.join(src, item)
|
||||||
shutil.rmtree(os.path.join(data_dir, "test-master"))
|
dst_item = os.path.join(data_dir, item)
|
||||||
|
if os.path.exists(dst_item):
|
||||||
|
if os.path.isdir(dst_item):
|
||||||
|
shutil.rmtree(dst_item)
|
||||||
|
else:
|
||||||
|
os.remove(dst_item)
|
||||||
|
os.rename(src_item, dst_item)
|
||||||
|
os.rmdir(src)
|
||||||
print(f"MMLU data saved to {data_dir}")
|
print(f"MMLU data saved to {data_dir}")
|
||||||
|
|
||||||
|
|
||||||
@@ -143,10 +157,32 @@ def build_prompt(
|
|||||||
return prompt
|
return prompt
|
||||||
|
|
||||||
|
|
||||||
|
def apply_chat(
|
||||||
|
tokenizer, raw_prompt: str, n_shot: int, dev_data: list[dict] | None
|
||||||
|
) -> str:
|
||||||
|
"""Wrap raw MMLU prompt in the model's chat template format.
|
||||||
|
|
||||||
|
For few-shot, prepend example Q&A pairs as a second user/assistant exchange.
|
||||||
|
"""
|
||||||
|
messages = []
|
||||||
|
if n_shot > 0 and dev_data:
|
||||||
|
for item in dev_data[:n_shot]:
|
||||||
|
q = f"Question: {item['question']}\n"
|
||||||
|
for k in ("A", "B", "C", "D"):
|
||||||
|
q += f"{k}. {item[k]}\n"
|
||||||
|
q += "Answer:"
|
||||||
|
messages.append({"role": "user", "content": q})
|
||||||
|
messages.append({"role": "assistant", "content": item["answer"]})
|
||||||
|
messages.append({"role": "user", "content": raw_prompt})
|
||||||
|
return tokenizer.apply_chat_template(
|
||||||
|
messages, tokenize=False, add_generation_prompt=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def choice_logprob(
|
def choice_logprob(
|
||||||
model, tokenizer, context_ids: list[int], choice_letter: str, device: str
|
model, tokenizer, context_ids: list[int], choice_letter: str, device: str
|
||||||
) -> float:
|
) -> float:
|
||||||
choice_text = f" {choice_letter}"
|
choice_text = choice_letter
|
||||||
choice_ids = tokenizer.encode(choice_text, add_special_tokens=False)
|
choice_ids = tokenizer.encode(choice_text, add_special_tokens=False)
|
||||||
input_ids = context_ids + choice_ids
|
input_ids = context_ids + choice_ids
|
||||||
max_len = model.config.max_len
|
max_len = model.config.max_len
|
||||||
@@ -182,8 +218,11 @@ def evaluate_subject(
|
|||||||
correct = 0
|
correct = 0
|
||||||
total = 0
|
total = 0
|
||||||
for item in tqdm.tqdm(test_data, desc=f"{subject:40s}", leave=False):
|
for item in tqdm.tqdm(test_data, desc=f"{subject:40s}", leave=False):
|
||||||
prompt = build_prompt(item["question"], item, subject, n_shot, dev_data or [])
|
raw_prompt = build_prompt(
|
||||||
context_ids = tokenizer.encode(prompt)
|
item["question"], item, subject, n_shot, dev_data or []
|
||||||
|
)
|
||||||
|
context = apply_chat(tokenizer, raw_prompt, n_shot, dev_data or [])
|
||||||
|
context_ids = tokenizer.encode(context)
|
||||||
scores = {
|
scores = {
|
||||||
c: choice_logprob(model, tokenizer, context_ids, c, device)
|
c: choice_logprob(model, tokenizer, context_ids, c, device)
|
||||||
for c in ("A", "B", "C", "D")
|
for c in ("A", "B", "C", "D")
|
||||||
@@ -233,6 +272,7 @@ def main():
|
|||||||
device = args.device
|
device = args.device
|
||||||
dtype = getattr(torch, args.dtype)
|
dtype = getattr(torch, args.dtype)
|
||||||
model.to(device=device, dtype=dtype)
|
model.to(device=device, dtype=dtype)
|
||||||
|
model.eval()
|
||||||
|
|
||||||
subjects = args.subjects or MMLU_SUBJECTS
|
subjects = args.subjects or MMLU_SUBJECTS
|
||||||
results = {}
|
results = {}
|
||||||
@@ -86,7 +86,7 @@ def process_file(
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
parser = argparse.ArgumentParser(description="Run perplexity with a Khaosz model.")
|
parser = argparse.ArgumentParser(description="Perplexity evaluation on JSONL text.")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--param_path", type=str, required=True, help="Path to the model directory."
|
"--param_path", type=str, required=True, help="Path to the model directory."
|
||||||
)
|
)
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
import torch
|
import torch
|
||||||
|
|
||||||
@@ -17,7 +18,7 @@ def processor(
|
|||||||
top_p: float,
|
top_p: float,
|
||||||
question_key: str,
|
question_key: str,
|
||||||
response_key: str,
|
response_key: str,
|
||||||
max_tokens: int,
|
max_tokens: Optional[int],
|
||||||
batch_size: int,
|
batch_size: int,
|
||||||
):
|
):
|
||||||
# Load model and tokenizer
|
# Load model and tokenizer
|
||||||
@@ -72,7 +73,7 @@ def processor(
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
parser = argparse.ArgumentParser(description="Run generate with a Khaosz model.")
|
parser = argparse.ArgumentParser(description="Batch generation from JSONL file.")
|
||||||
|
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--param_path", type=str, required=True, help="Path to the model directory."
|
"--param_path", type=str, required=True, help="Path to the model directory."
|
||||||
@@ -93,36 +94,42 @@ if __name__ == "__main__":
|
|||||||
"--question_key",
|
"--question_key",
|
||||||
type=str,
|
type=str,
|
||||||
default="question",
|
default="question",
|
||||||
help="Key for the question in the input JSON.",
|
help="Key for the question in the input JSON (default: question).",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--response_key",
|
"--response_key",
|
||||||
type=str,
|
type=str,
|
||||||
default="response",
|
default="response",
|
||||||
help="Key for the response in the output JSON.",
|
help="Key for the response in the output JSON (default: response).",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--temperature",
|
"--temperature",
|
||||||
type=float,
|
type=float,
|
||||||
default=0.60,
|
default=0.60,
|
||||||
help="Temperature for generating responses.",
|
help="Temperature for generating responses (default: 0.60).",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--top_k", type=int, default=30, help="Top-k value for generating responses."
|
"--top_k",
|
||||||
|
type=int,
|
||||||
|
default=30,
|
||||||
|
help="Top-k value for generating responses (default: 30).",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--top_p",
|
"--top_p",
|
||||||
type=float,
|
type=float,
|
||||||
default=0.95,
|
default=0.95,
|
||||||
help="Top-p value for generating responses.",
|
help="Top-p value for generating responses (default: 0.95).",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--batch_size", type=int, default=1, help="Batch size for generating responses."
|
"--batch_size",
|
||||||
|
type=int,
|
||||||
|
default=1,
|
||||||
|
help="Batch size for generating responses (default: 1).",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--max_tokens",
|
"--max_tokens",
|
||||||
type=int,
|
type=int,
|
||||||
default=2048,
|
default=None,
|
||||||
help="Maximum tokens to generate (default: model config max_len).",
|
help="Maximum tokens to generate (default: model config max_len).",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""CLI: JSONL → tokenized .h5/.bin via config-driven Pipeline."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
from astrai.config.preprocess_config import PipelineConfig
|
||||||
|
from astrai.preprocessing.pipeline import Pipeline
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Raw JSONL → tokenized .h5/.bin via config-driven Pipeline"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"inputs", nargs="+", metavar="JSONL", help="One or more JSONL files"
|
||||||
|
)
|
||||||
|
parser.add_argument("--output_dir", "-o", required=True, help="Output directory")
|
||||||
|
parser.add_argument(
|
||||||
|
"--config", "-c", required=True, help="Path to pipeline config JSON"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--tokenizer_path",
|
||||||
|
default="params",
|
||||||
|
help="Path to tokenizer directory (default: params)",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
config = PipelineConfig.from_file(args.config)
|
||||||
|
|
||||||
|
Pipeline(
|
||||||
|
config=config,
|
||||||
|
input_paths=args.inputs,
|
||||||
|
output_dir=args.output_dir,
|
||||||
|
tokenizer_path=args.tokenizer_path,
|
||||||
|
).run()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+152
-7
@@ -8,6 +8,7 @@ import torch.optim as optim
|
|||||||
from astrai.config import AutoRegressiveLMConfig, TrainConfig
|
from astrai.config import AutoRegressiveLMConfig, TrainConfig
|
||||||
from astrai.dataset import DatasetFactory
|
from astrai.dataset import DatasetFactory
|
||||||
from astrai.model import AutoRegressiveLM
|
from astrai.model import AutoRegressiveLM
|
||||||
|
from astrai.model.components.decoder_block import DecoderBlock
|
||||||
from astrai.trainer import SchedulerFactory, Trainer
|
from astrai.trainer import SchedulerFactory, Trainer
|
||||||
|
|
||||||
|
|
||||||
@@ -112,9 +113,15 @@ def parse_args() -> argparse.Namespace:
|
|||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--label_smoothing",
|
"--label_smoothing",
|
||||||
type=float,
|
type=float,
|
||||||
default=0.05,
|
default=0.0,
|
||||||
help="cross_entropy function label smoothing parameter",
|
help="cross_entropy function label smoothing parameter",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--gradient_checkpointing",
|
||||||
|
action=argparse.BooleanOptionalAction,
|
||||||
|
default=False,
|
||||||
|
help="Enable activation checkpointing for DecoderBlock modules.",
|
||||||
|
)
|
||||||
|
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--ckpt_interval",
|
"--ckpt_interval",
|
||||||
@@ -128,6 +135,36 @@ def parse_args() -> argparse.Namespace:
|
|||||||
default="checkpoint",
|
default="checkpoint",
|
||||||
help="Directory to save checkpoints.",
|
help="Directory to save checkpoints.",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--val_split",
|
||||||
|
type=float,
|
||||||
|
default=None,
|
||||||
|
help="Ratio to split from training dataset for validation (e.g. 0.05).",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--val_step",
|
||||||
|
type=int,
|
||||||
|
default=1000,
|
||||||
|
help="Number of optimizer steps between validation runs.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--metrics",
|
||||||
|
nargs="*",
|
||||||
|
default=["loss", "lr"],
|
||||||
|
help="Metrics to log (e.g. --metrics loss lr val_loss). Default: loss lr.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--log_dir",
|
||||||
|
type=str,
|
||||||
|
default="checkpoint/logs",
|
||||||
|
help="Directory for metric logs.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--log_interval",
|
||||||
|
type=int,
|
||||||
|
default=100,
|
||||||
|
help="Number of batch iterations between metric logs.",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--grpo_sync_interval",
|
"--grpo_sync_interval",
|
||||||
type=int,
|
type=int,
|
||||||
@@ -141,6 +178,24 @@ def parse_args() -> argparse.Namespace:
|
|||||||
"--start_batch", type=int, default=0, help="Start batch for training."
|
"--start_batch", type=int, default=0, help="Start batch for training."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--master_addr",
|
||||||
|
type=str,
|
||||||
|
default="localhost",
|
||||||
|
help="Master node address for distributed training.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--master_port",
|
||||||
|
type=str,
|
||||||
|
default="29500",
|
||||||
|
help="Master node port for distributed training.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--backend",
|
||||||
|
type=str,
|
||||||
|
default="nccl",
|
||||||
|
help="Distributed training backend.",
|
||||||
|
)
|
||||||
parser.add_argument("--nprocs", type=int, default=1, help="Number of GPUs to use.")
|
parser.add_argument("--nprocs", type=int, default=1, help="Number of GPUs to use.")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--parallel_mode",
|
"--parallel_mode",
|
||||||
@@ -159,6 +214,50 @@ def parse_args() -> argparse.Namespace:
|
|||||||
choices=["spawn", "fork", "forkserver"],
|
choices=["spawn", "fork", "forkserver"],
|
||||||
help="Multiprocessing start method.",
|
help="Multiprocessing start method.",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--neftune_alpha",
|
||||||
|
type=float,
|
||||||
|
default=0.0,
|
||||||
|
help="NEFTune noise alpha (0=disabled, typical: 5.0).",
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--schedule_type",
|
||||||
|
type=str,
|
||||||
|
default="cosine",
|
||||||
|
choices=["cosine", "sgdr", "wsd"],
|
||||||
|
help="Learning rate scheduler type.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--min_rate",
|
||||||
|
type=float,
|
||||||
|
default=None,
|
||||||
|
help="Minimum LR as fraction of base LR. Uses scheduler default if not set (cosine/sgdr: 0.05, wsd: 0.0).",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--cycle_length",
|
||||||
|
type=int,
|
||||||
|
default=None,
|
||||||
|
help="SGDR first cycle length in steps. Defaults to total_steps - warmup_steps.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--t_mult",
|
||||||
|
type=int,
|
||||||
|
default=2,
|
||||||
|
help="SGDR cycle length multiplier per restart.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--stable_steps",
|
||||||
|
type=int,
|
||||||
|
default=None,
|
||||||
|
help="WSD stable plateau steps. Required when --schedule_type wsd.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--decay_steps",
|
||||||
|
type=int,
|
||||||
|
default=None,
|
||||||
|
help="WSD decay steps. Defaults to total_steps - warmup_steps - stable_steps.",
|
||||||
|
)
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
@@ -176,7 +275,8 @@ def create_optimizer(model, **kwargs) -> optim.Optimizer:
|
|||||||
def create_scheduler(
|
def create_scheduler(
|
||||||
optimizer: optim.Optimizer, **kwargs
|
optimizer: optim.Optimizer, **kwargs
|
||||||
) -> optim.lr_scheduler.LRScheduler:
|
) -> optim.lr_scheduler.LRScheduler:
|
||||||
return SchedulerFactory.create(optimizer, **kwargs)
|
schedule_type = kwargs.pop("schedule_type")
|
||||||
|
return SchedulerFactory.create(schedule_type, optimizer, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
def compute_total_steps(
|
def compute_total_steps(
|
||||||
@@ -209,6 +309,11 @@ def train(
|
|||||||
warmup_ratio: float,
|
warmup_ratio: float,
|
||||||
ckpt_interval: int,
|
ckpt_interval: int,
|
||||||
ckpt_dir: str,
|
ckpt_dir: str,
|
||||||
|
val_split: float,
|
||||||
|
val_step: int,
|
||||||
|
metrics: list[str],
|
||||||
|
log_dir: str,
|
||||||
|
log_interval: int,
|
||||||
dpo_beta: float,
|
dpo_beta: float,
|
||||||
grpo_clip_eps: float,
|
grpo_clip_eps: float,
|
||||||
grpo_kl_coef: float,
|
grpo_kl_coef: float,
|
||||||
@@ -222,12 +327,23 @@ def train(
|
|||||||
random_seed: int,
|
random_seed: int,
|
||||||
num_workers: int,
|
num_workers: int,
|
||||||
pin_memory: bool,
|
pin_memory: bool,
|
||||||
|
gradient_checkpointing: bool,
|
||||||
window_size: int,
|
window_size: int,
|
||||||
stride: int,
|
stride: int,
|
||||||
nprocs: int,
|
nprocs: int,
|
||||||
parallel_mode: str,
|
parallel_mode: str,
|
||||||
device_type: str,
|
device_type: str,
|
||||||
|
backend: str,
|
||||||
|
master_addr: str,
|
||||||
|
master_port: str,
|
||||||
start_method: str,
|
start_method: str,
|
||||||
|
neftune_alpha: float,
|
||||||
|
schedule_type: str,
|
||||||
|
min_rate: float,
|
||||||
|
cycle_length: int,
|
||||||
|
t_mult: int,
|
||||||
|
stable_steps: int,
|
||||||
|
decay_steps: int,
|
||||||
):
|
):
|
||||||
assert train_type in ["seq", "sft", "dpo", "grpo"]
|
assert train_type in ["seq", "sft", "dpo", "grpo"]
|
||||||
assert os.path.exists(param_path)
|
assert os.path.exists(param_path)
|
||||||
@@ -237,6 +353,7 @@ def train(
|
|||||||
# Load config
|
# Load config
|
||||||
config_path = os.path.join(param_path, "config.json")
|
config_path = os.path.join(param_path, "config.json")
|
||||||
config = AutoRegressiveLMConfig.from_file(config_path)
|
config = AutoRegressiveLMConfig.from_file(config_path)
|
||||||
|
config.neftune_alpha = neftune_alpha
|
||||||
|
|
||||||
if window_size is None:
|
if window_size is None:
|
||||||
window_size = config.max_len
|
window_size = config.max_len
|
||||||
@@ -276,16 +393,34 @@ def train(
|
|||||||
len(dataset), n_epoch, batch_per_device, nprocs, grad_accum_steps
|
len(dataset), n_epoch, batch_per_device, nprocs, grad_accum_steps
|
||||||
)
|
)
|
||||||
warmup_steps = int(warmup_ratio * total_steps)
|
warmup_steps = int(warmup_ratio * total_steps)
|
||||||
|
warmup_steps = min(warmup_steps, total_steps)
|
||||||
|
|
||||||
|
scheduler_kwargs = {"warmup_steps": warmup_steps}
|
||||||
|
|
||||||
|
if schedule_type == "cosine":
|
||||||
|
scheduler_kwargs["lr_decay_steps"] = total_steps - warmup_steps
|
||||||
|
elif schedule_type == "sgdr":
|
||||||
|
scheduler_kwargs["cycle_length"] = cycle_length or (total_steps - warmup_steps)
|
||||||
|
scheduler_kwargs["t_mult"] = t_mult
|
||||||
|
elif schedule_type == "wsd":
|
||||||
|
remaining = total_steps - warmup_steps
|
||||||
|
stable_steps_ = stable_steps or max(1, int(remaining * 0.8))
|
||||||
|
scheduler_kwargs["stable_steps"] = stable_steps_
|
||||||
|
scheduler_kwargs["decay_steps"] = max(
|
||||||
|
1, decay_steps or (remaining - stable_steps_)
|
||||||
|
)
|
||||||
|
|
||||||
|
if min_rate is not None:
|
||||||
|
scheduler_kwargs["min_rate"] = min_rate
|
||||||
|
|
||||||
scheduler_fn = partial(
|
scheduler_fn = partial(
|
||||||
create_scheduler,
|
create_scheduler,
|
||||||
**{
|
schedule_type=schedule_type,
|
||||||
"schedule_type": "cosine",
|
**scheduler_kwargs,
|
||||||
"warmup_steps": min(warmup_steps, total_steps),
|
|
||||||
"lr_decay_steps": total_steps - min(warmup_steps, total_steps),
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
grad_ckpt_modules = [DecoderBlock] if gradient_checkpointing else []
|
||||||
|
|
||||||
train_config = TrainConfig(
|
train_config = TrainConfig(
|
||||||
model_fn=model_fn,
|
model_fn=model_fn,
|
||||||
strategy=train_type,
|
strategy=train_type,
|
||||||
@@ -304,11 +439,21 @@ def train(
|
|||||||
num_workers=num_workers,
|
num_workers=num_workers,
|
||||||
pin_memory=pin_memory,
|
pin_memory=pin_memory,
|
||||||
nprocs=nprocs,
|
nprocs=nprocs,
|
||||||
|
backend=backend,
|
||||||
|
master_addr=master_addr,
|
||||||
|
master_port=master_port,
|
||||||
parallel_mode=parallel_mode,
|
parallel_mode=parallel_mode,
|
||||||
device_type=device_type,
|
device_type=device_type,
|
||||||
start_method=start_method,
|
start_method=start_method,
|
||||||
|
val_split=val_split,
|
||||||
|
val_step=val_step,
|
||||||
|
metrics=metrics,
|
||||||
|
log_dir=log_dir,
|
||||||
|
log_interval=log_interval,
|
||||||
|
gradient_checkpointing_modules=grad_ckpt_modules,
|
||||||
executor_kwargs=executor_kwargs,
|
executor_kwargs=executor_kwargs,
|
||||||
extra_kwargs=strategy_kwargs,
|
extra_kwargs=strategy_kwargs,
|
||||||
|
neftune_alpha=neftune_alpha,
|
||||||
)
|
)
|
||||||
|
|
||||||
trainer = Trainer(train_config)
|
trainer = Trainer(train_config)
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from tokenizers import Tokenizer, models, pre_tokenizers, trainers
|
||||||
|
|
||||||
|
from astrai.config.preprocess_config import (
|
||||||
|
InputConfig,
|
||||||
|
PipelineConfig,
|
||||||
|
ProcessingConfig,
|
||||||
|
)
|
||||||
|
from astrai.preprocessing.builder import SectionedMaskBuilder
|
||||||
|
from astrai.tokenize import AutoTokenizer
|
||||||
|
|
||||||
|
_SPECIAL_TOKENS_CONFIG = {
|
||||||
|
"bos_token": "<|begin_of_sentence|>",
|
||||||
|
"eos_token": "<|end_of_sentence|>",
|
||||||
|
"pad_token": "<|_pad_|>",
|
||||||
|
"unk_token": "<|_unk_|>",
|
||||||
|
"im_start": "<|im_start|>",
|
||||||
|
"im_end": "<|im_end|>",
|
||||||
|
}
|
||||||
|
|
||||||
|
_SPECIAL_TOKENS = list(_SPECIAL_TOKENS_CONFIG.values())
|
||||||
|
|
||||||
|
_CHAT_TEMPLATE = (
|
||||||
|
"{% for message in messages %}"
|
||||||
|
"{% if message['role'] == 'system' %}"
|
||||||
|
"<|im_start|>system\n{{ message['content'] }}<|im_end|>\n"
|
||||||
|
"{% elif message['role'] == 'user' %}"
|
||||||
|
"<|im_start|>user\n{{ message['content'] }}<|im_end|>\n"
|
||||||
|
"{% elif message['role'] == 'assistant' %}"
|
||||||
|
"<|im_start|>assistant\n{{ message['content'] }}<|im_end|>\n"
|
||||||
|
"{% endif %}"
|
||||||
|
"{% endfor %}"
|
||||||
|
"{% if add_generation_prompt %}<|im_start|>assistant\n{% endif %}"
|
||||||
|
)
|
||||||
|
|
||||||
|
_CHAT_SECTIONS = [{"field": "messages", "action": "$role", "template": True}]
|
||||||
|
|
||||||
|
_INSTRUCTION_SECTIONS = [
|
||||||
|
{"field": "prompt", "action": "mask", "add_special_tokens": True},
|
||||||
|
{"field": "response", "action": "train"},
|
||||||
|
]
|
||||||
|
|
||||||
|
_TEXT_SECTIONS = [{"field": "text", "action": "train"}]
|
||||||
|
|
||||||
|
_GRPO_RESPONSE_SECTIONS = [{"field": "responses", "action": "train"}]
|
||||||
|
|
||||||
|
|
||||||
|
def _build_chat_tokenizer():
|
||||||
|
tok = Tokenizer(models.BPE())
|
||||||
|
tok.pre_tokenizer = pre_tokenizers.ByteLevel(add_prefix_space=False)
|
||||||
|
tr = trainers.BpeTrainer(
|
||||||
|
vocab_size=512,
|
||||||
|
min_frequency=1,
|
||||||
|
special_tokens=_SPECIAL_TOKENS,
|
||||||
|
)
|
||||||
|
train_data = [
|
||||||
|
"hello world",
|
||||||
|
"Hi there!",
|
||||||
|
"You are helpful.",
|
||||||
|
"What is 2+2?",
|
||||||
|
"Tell me a story about dragons and knights.",
|
||||||
|
"Sure, here is a tale.",
|
||||||
|
"Translate to French: Hello",
|
||||||
|
"Bonjour",
|
||||||
|
"Artificial Intelligence is a field of computer science.",
|
||||||
|
"system",
|
||||||
|
"user",
|
||||||
|
"assistant",
|
||||||
|
"<|im_start|>",
|
||||||
|
"<|im_end|>",
|
||||||
|
*[chr(i) for i in range(32, 127)],
|
||||||
|
]
|
||||||
|
tok.train_from_iterator(train_data, tr)
|
||||||
|
|
||||||
|
auto_tok = AutoTokenizer()
|
||||||
|
auto_tok._tokenizer = tok
|
||||||
|
auto_tok._special_token_map = {
|
||||||
|
"bos_token": "<|begin_of_sentence|>",
|
||||||
|
"eos_token": "<|end_of_sentence|>",
|
||||||
|
"pad_token": "<|_pad_|>",
|
||||||
|
"unk_token": "<|_unk_|>",
|
||||||
|
}
|
||||||
|
auto_tok.set_chat_template(_CHAT_TEMPLATE)
|
||||||
|
return auto_tok
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def chat_tokenizer():
|
||||||
|
return _build_chat_tokenizer()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def temp_dir():
|
||||||
|
d = tempfile.mkdtemp()
|
||||||
|
yield d
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
shutil.rmtree(d, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
def make_chat_config():
|
||||||
|
return PipelineConfig(
|
||||||
|
input=InputConfig(sections=_CHAT_SECTIONS),
|
||||||
|
mask={"system": "mask", "user": "mask", "assistant": "train"},
|
||||||
|
mask_default="mask",
|
||||||
|
preprocessing=ProcessingConfig(max_seq_len=2048),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def make_instruction_config():
|
||||||
|
return PipelineConfig(
|
||||||
|
input=InputConfig(sections=_INSTRUCTION_SECTIONS),
|
||||||
|
mask={"prompt": "mask", "response": "train"},
|
||||||
|
mask_default="mask",
|
||||||
|
preprocessing=ProcessingConfig(max_seq_len=2048),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def make_text_config():
|
||||||
|
return PipelineConfig(
|
||||||
|
input=InputConfig(sections=_TEXT_SECTIONS),
|
||||||
|
preprocessing=ProcessingConfig(
|
||||||
|
max_seq_len=2048, min_chars=1, max_chars=2_000_000
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def make_dpo_chat_config():
|
||||||
|
return PipelineConfig(
|
||||||
|
input=InputConfig(
|
||||||
|
sources={
|
||||||
|
"chosen": {
|
||||||
|
"sections": [
|
||||||
|
{"field": "chosen", "action": "$role", "template": True}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"rejected": {
|
||||||
|
"sections": [
|
||||||
|
{"field": "rejected", "action": "$role", "template": True}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
mask={"user": "mask", "assistant": "train"},
|
||||||
|
mask_default="mask",
|
||||||
|
preprocessing=ProcessingConfig(max_seq_len=2048),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def make_grpo_config():
|
||||||
|
return PipelineConfig(
|
||||||
|
input=InputConfig(
|
||||||
|
sources={
|
||||||
|
"prompts": {
|
||||||
|
"sections": [
|
||||||
|
{"field": "prompt", "action": "mask", "template": True}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"sections": _GRPO_RESPONSE_SECTIONS,
|
||||||
|
"list_field": True,
|
||||||
|
"mask_key": "masks",
|
||||||
|
},
|
||||||
|
"rewards": {
|
||||||
|
"sections": [{"field": "rewards", "action": "value"}],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
mask={"user": "mask", "assistant": "train"},
|
||||||
|
mask_default="mask",
|
||||||
|
preprocessing=ProcessingConfig(max_seq_len=2048),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def make_grpo_no_template_config():
|
||||||
|
return PipelineConfig(
|
||||||
|
input=InputConfig(
|
||||||
|
sources={
|
||||||
|
"prompts": {
|
||||||
|
"sections": [
|
||||||
|
{
|
||||||
|
"field": "prompt",
|
||||||
|
"action": "mask",
|
||||||
|
"add_special_tokens": True,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"sections": _GRPO_RESPONSE_SECTIONS,
|
||||||
|
"list_field": True,
|
||||||
|
"mask_key": "masks",
|
||||||
|
},
|
||||||
|
"rewards": {
|
||||||
|
"sections": [{"field": "rewards", "action": "value"}],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
mask={"user": "mask", "assistant": "train"},
|
||||||
|
mask_default="mask",
|
||||||
|
preprocessing=ProcessingConfig(max_seq_len=2048),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def builder():
|
||||||
|
return SectionedMaskBuilder()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def tokenizer_dir(temp_dir, test_tokenizer):
|
||||||
|
d = os.path.join(temp_dir, "tok")
|
||||||
|
os.makedirs(d, exist_ok=True)
|
||||||
|
test_tokenizer._tokenizer.save(os.path.join(d, "tokenizer.json"))
|
||||||
|
with open(os.path.join(d, "tokenizer_config.json"), "w") as f:
|
||||||
|
json.dump(
|
||||||
|
{"special_tokens": {"pad_token": "<|_pad_|>", "unk_token": "<|_unk_|>"}}, f
|
||||||
|
)
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def chat_tokenizer_dir(temp_dir, chat_tokenizer):
|
||||||
|
d = os.path.join(temp_dir, "tok")
|
||||||
|
os.makedirs(d, exist_ok=True)
|
||||||
|
chat_tokenizer._tokenizer.save(os.path.join(d, "tokenizer.json"))
|
||||||
|
with open(os.path.join(d, "tokenizer_config.json"), "w") as f:
|
||||||
|
json.dump(
|
||||||
|
{"special_tokens": _SPECIAL_TOKENS_CONFIG, "chat_template": _CHAT_TEMPLATE},
|
||||||
|
f,
|
||||||
|
)
|
||||||
|
return d
|
||||||
+56
-127
@@ -1,4 +1,3 @@
|
|||||||
import json
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
@@ -8,7 +7,6 @@ import torch
|
|||||||
from astrai.dataset.dataset import DatasetFactory, SEQDataset
|
from astrai.dataset.dataset import DatasetFactory, SEQDataset
|
||||||
from astrai.dataset.storage import (
|
from astrai.dataset.storage import (
|
||||||
H5Store,
|
H5Store,
|
||||||
MmapStore,
|
|
||||||
StoreFactory,
|
StoreFactory,
|
||||||
detect_format,
|
detect_format,
|
||||||
load_bin,
|
load_bin,
|
||||||
@@ -17,28 +15,34 @@ from astrai.dataset.storage import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _rand_seq(length, vocab=1000):
|
||||||
|
return torch.randint(0, vocab, (length,), dtype=torch.int64)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_seq_dataset(
|
||||||
|
test_dir, name="data", seq_length=200, train_type="seq", data=None, **load_kwargs
|
||||||
|
):
|
||||||
|
if data is None:
|
||||||
|
data = {"sequence": [_rand_seq(seq_length)]}
|
||||||
|
save_h5(test_dir, name, data)
|
||||||
|
return DatasetFactory.load(
|
||||||
|
train_type,
|
||||||
|
test_dir,
|
||||||
|
window_size=load_kwargs.pop("window_size", 64),
|
||||||
|
**load_kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_dataset_loader_random_paths(base_test_env):
|
def test_dataset_loader_random_paths(base_test_env):
|
||||||
"""Test dataset loader with multiple random paths"""
|
"""Test dataset loader with multiple random paths"""
|
||||||
test_dir = base_test_env["test_dir"]
|
test_dir = base_test_env["test_dir"]
|
||||||
|
|
||||||
# Create multiple mmap dataset directories with random data
|
|
||||||
num_files = np.random.randint(2, 5)
|
num_files = np.random.randint(2, 5)
|
||||||
|
|
||||||
for i in range(num_files):
|
for i in range(num_files):
|
||||||
seq_length = np.random.randint(200, 400)
|
seq_length = np.random.randint(200, 400)
|
||||||
dummy_data = {
|
dummy_data = {"sequence": [_rand_seq(seq_length) for _ in range(10)]}
|
||||||
"sequence": [
|
loaded_dataset = _make_seq_dataset(
|
||||||
torch.randint(0, 1000, (seq_length,), dtype=torch.int64)
|
test_dir, f"data_{i}", seq_length, data=dummy_data
|
||||||
for _ in range(10)
|
|
||||||
],
|
|
||||||
}
|
|
||||||
save_h5(test_dir, f"data_{i}", dummy_data)
|
|
||||||
|
|
||||||
# Test loading with multiple paths
|
|
||||||
loaded_dataset = DatasetFactory.load(
|
|
||||||
train_type="seq",
|
|
||||||
load_path=test_dir,
|
|
||||||
window_size=64,
|
|
||||||
)
|
)
|
||||||
assert loaded_dataset is not None
|
assert loaded_dataset is not None
|
||||||
assert len(loaded_dataset) > 0
|
assert len(loaded_dataset) > 0
|
||||||
@@ -56,23 +60,15 @@ def test_dpo_strategy_with_random_data(base_test_env):
|
|||||||
"""Test DPO strategy with randomized preference data"""
|
"""Test DPO strategy with randomized preference data"""
|
||||||
test_dir = base_test_env["test_dir"]
|
test_dir = base_test_env["test_dir"]
|
||||||
|
|
||||||
# Create DPO-style data with memory mapping format
|
|
||||||
seq_length = np.random.randint(100, 200)
|
seq_length = np.random.randint(100, 200)
|
||||||
|
|
||||||
dummy_data = {
|
dummy_data = {
|
||||||
"chosen": [torch.randint(0, 1000, (seq_length,), dtype=torch.int64)],
|
"chosen": [_rand_seq(seq_length)],
|
||||||
"rejected": [torch.randint(0, 1000, (seq_length,), dtype=torch.int64)],
|
"rejected": [_rand_seq(seq_length)],
|
||||||
"chosen_mask": [torch.ones(seq_length, dtype=torch.bool)],
|
"chosen_mask": [torch.ones(seq_length, dtype=torch.bool)],
|
||||||
"rejected_mask": [torch.ones(seq_length, dtype=torch.bool)],
|
"rejected_mask": [torch.ones(seq_length, dtype=torch.bool)],
|
||||||
}
|
}
|
||||||
|
dpo_dataset = _make_seq_dataset(
|
||||||
save_h5(test_dir, "dpo_data", dummy_data)
|
test_dir, "dpo_data", seq_length, train_type="dpo", data=dummy_data
|
||||||
|
|
||||||
# Load DPO dataset
|
|
||||||
dpo_dataset = DatasetFactory.load(
|
|
||||||
train_type="dpo",
|
|
||||||
load_path=test_dir,
|
|
||||||
window_size=64,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert dpo_dataset is not None
|
assert dpo_dataset is not None
|
||||||
@@ -94,21 +90,14 @@ def test_sft_dataset_with_random_data(base_test_env):
|
|||||||
"""Test SFT dataset with random data"""
|
"""Test SFT dataset with random data"""
|
||||||
test_dir = base_test_env["test_dir"]
|
test_dir = base_test_env["test_dir"]
|
||||||
|
|
||||||
# Create SFT-style data with memory mapping format
|
|
||||||
seq_length = np.random.randint(100, 200)
|
seq_length = np.random.randint(100, 200)
|
||||||
|
|
||||||
dummy_data = {
|
dummy_data = {
|
||||||
"sequence": [torch.randint(0, 1000, (seq_length,), dtype=torch.int64)],
|
"sequence": [_rand_seq(seq_length)],
|
||||||
"loss_mask": [torch.ones(seq_length, dtype=torch.bool)],
|
"loss_mask": [torch.ones(seq_length, dtype=torch.bool)],
|
||||||
|
"position_ids": [torch.arange(seq_length, dtype=torch.int32)],
|
||||||
}
|
}
|
||||||
|
sft_dataset = _make_seq_dataset(
|
||||||
save_h5(test_dir, "sft_data", dummy_data)
|
test_dir, "sft_data", seq_length, train_type="sft", data=dummy_data
|
||||||
|
|
||||||
# Load SFT dataset
|
|
||||||
sft_dataset = DatasetFactory.load(
|
|
||||||
train_type="sft",
|
|
||||||
load_path=test_dir,
|
|
||||||
window_size=64,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert sft_dataset is not None
|
assert sft_dataset is not None
|
||||||
@@ -129,25 +118,11 @@ def test_dataset_with_custom_stride(base_test_env):
|
|||||||
"""Test dataset with custom stride parameter"""
|
"""Test dataset with custom stride parameter"""
|
||||||
test_dir = base_test_env["test_dir"]
|
test_dir = base_test_env["test_dir"]
|
||||||
|
|
||||||
# Create test data
|
|
||||||
seq_length = 200
|
|
||||||
dummy_data = {
|
|
||||||
"sequence": [torch.randint(0, 1000, (seq_length,), dtype=torch.int64)],
|
|
||||||
}
|
|
||||||
|
|
||||||
save_h5(test_dir, "stride_test_data", dummy_data)
|
|
||||||
|
|
||||||
# Test with custom stride
|
|
||||||
custom_stride = 32
|
custom_stride = 32
|
||||||
dataset = DatasetFactory.load(
|
dataset = _make_seq_dataset(test_dir, "stride_test_data", stride=custom_stride)
|
||||||
train_type="seq", load_path=test_dir, window_size=64, stride=custom_stride
|
|
||||||
)
|
|
||||||
|
|
||||||
assert dataset is not None
|
assert dataset is not None
|
||||||
assert len(dataset) > 0
|
assert len(dataset) > 0
|
||||||
|
|
||||||
# With stride 32 and window 64 on 200 length data, we should get more samples
|
|
||||||
# than with default stride (which equals window size)
|
|
||||||
default_stride_dataset = DatasetFactory.load(
|
default_stride_dataset = DatasetFactory.load(
|
||||||
train_type="seq",
|
train_type="seq",
|
||||||
load_path=test_dir,
|
load_path=test_dir,
|
||||||
@@ -158,25 +133,11 @@ def test_dataset_with_custom_stride(base_test_env):
|
|||||||
|
|
||||||
|
|
||||||
def test_dataset_count_property(base_test_env):
|
def test_dataset_count_property(base_test_env):
|
||||||
"""Test the count property returns correct raw token count"""
|
|
||||||
test_dir = base_test_env["test_dir"]
|
test_dir = base_test_env["test_dir"]
|
||||||
|
dataset = _make_seq_dataset(test_dir, "count_test_data")
|
||||||
seq_length = 200
|
assert dataset.count == 200
|
||||||
dummy_data = {
|
assert dataset.count > len(dataset)
|
||||||
"sequence": [torch.randint(0, 1000, (seq_length,), dtype=torch.int64)],
|
assert len(dataset) == (200 - 1 - 64) // 64 + 1
|
||||||
}
|
|
||||||
|
|
||||||
save_h5(test_dir, "count_test_data", dummy_data)
|
|
||||||
|
|
||||||
dataset = DatasetFactory.load(
|
|
||||||
train_type="seq",
|
|
||||||
load_path=test_dir,
|
|
||||||
window_size=64,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert dataset.count == seq_length
|
|
||||||
assert dataset.count > len(dataset) # raw tokens > windows
|
|
||||||
assert len(dataset) == (seq_length - 1 - 64) // 64 + 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_empty_dataset_count():
|
def test_empty_dataset_count():
|
||||||
@@ -187,17 +148,10 @@ def test_empty_dataset_count():
|
|||||||
|
|
||||||
|
|
||||||
def test_dataset_too_short_for_window(base_test_env):
|
def test_dataset_too_short_for_window(base_test_env):
|
||||||
"""Dataset shorter than window_size returns __len__ == 0"""
|
|
||||||
test_dir = base_test_env["test_dir"]
|
test_dir = base_test_env["test_dir"]
|
||||||
seq_length = 30
|
dataset = _make_seq_dataset(test_dir, "short", seq_length=30)
|
||||||
save_h5(
|
|
||||||
test_dir,
|
|
||||||
"short",
|
|
||||||
{"sequence": [torch.randint(0, 1000, (seq_length,), dtype=torch.int64)]},
|
|
||||||
)
|
|
||||||
dataset = DatasetFactory.load("seq", test_dir, window_size=64)
|
|
||||||
assert len(dataset) == 0
|
assert len(dataset) == 0
|
||||||
assert dataset.count == seq_length
|
assert dataset.count == 30
|
||||||
|
|
||||||
|
|
||||||
def test_unloaded_dataset_getitem_raises():
|
def test_unloaded_dataset_getitem_raises():
|
||||||
@@ -221,12 +175,8 @@ def test_store_unloaded_len():
|
|||||||
|
|
||||||
|
|
||||||
def test_store_fetch_begin_equals_end(base_test_env):
|
def test_store_fetch_begin_equals_end(base_test_env):
|
||||||
"""Store.fetch with begin == end returns empty tensor"""
|
|
||||||
test_dir = base_test_env["test_dir"]
|
test_dir = base_test_env["test_dir"]
|
||||||
dummy = {"sequence": [torch.randint(0, 1000, (100,), dtype=torch.int64)]}
|
dataset = _make_seq_dataset(test_dir, "empty_fetch", seq_length=100, window_size=32)
|
||||||
save_h5(test_dir, "empty_fetch", dummy)
|
|
||||||
|
|
||||||
dataset = DatasetFactory.load("seq", test_dir, window_size=32)
|
|
||||||
result = dataset.storage.fetch(10, 10, "sequence")
|
result = dataset.storage.fetch(10, 10, "sequence")
|
||||||
assert result.numel() == 0
|
assert result.numel() == 0
|
||||||
|
|
||||||
@@ -300,12 +250,8 @@ def test_save_load_bin_roundtrip(base_test_env):
|
|||||||
|
|
||||||
|
|
||||||
def test_mmap_store_load_and_fetch(base_test_env):
|
def test_mmap_store_load_and_fetch(base_test_env):
|
||||||
"""MmapStore loads bin data and fetches correctly"""
|
|
||||||
test_dir = base_test_env["test_dir"]
|
test_dir = base_test_env["test_dir"]
|
||||||
|
data = {"sequence": [_rand_seq(200)]}
|
||||||
data = {
|
|
||||||
"sequence": [torch.randint(0, 1000, (200,), dtype=torch.int64)],
|
|
||||||
}
|
|
||||||
save_bin(test_dir, data)
|
save_bin(test_dir, data)
|
||||||
|
|
||||||
store = StoreFactory.create("bin")
|
store = StoreFactory.create("bin")
|
||||||
@@ -318,14 +264,9 @@ def test_mmap_store_load_and_fetch(base_test_env):
|
|||||||
|
|
||||||
|
|
||||||
def test_mmap_dataset_load(base_test_env):
|
def test_mmap_dataset_load(base_test_env):
|
||||||
"""DatasetFactory.load auto-detects bin format"""
|
|
||||||
test_dir = base_test_env["test_dir"]
|
test_dir = base_test_env["test_dir"]
|
||||||
|
data = {"sequence": [_rand_seq(200)]}
|
||||||
data = {
|
|
||||||
"sequence": [torch.randint(0, 1000, (200,), dtype=torch.int64)],
|
|
||||||
}
|
|
||||||
save_bin(test_dir, data)
|
save_bin(test_dir, data)
|
||||||
|
|
||||||
dataset = DatasetFactory.load("seq", test_dir, window_size=64)
|
dataset = DatasetFactory.load("seq", test_dir, window_size=64)
|
||||||
assert len(dataset) > 0
|
assert len(dataset) > 0
|
||||||
assert dataset.count == 200
|
assert dataset.count == 200
|
||||||
@@ -349,19 +290,16 @@ def test_normalize_mixed_empty_key():
|
|||||||
|
|
||||||
|
|
||||||
def test_grpo_dataset_dtype(base_test_env):
|
def test_grpo_dataset_dtype(base_test_env):
|
||||||
"""GRPODataset returns correct dtypes"""
|
|
||||||
test_dir = base_test_env["test_dir"]
|
test_dir = base_test_env["test_dir"]
|
||||||
|
dummy_data = {
|
||||||
seq_len = 100
|
"prompts": [torch.randint(0, 100, (100,), dtype=torch.int32)],
|
||||||
data = {
|
"responses": [torch.randint(0, 100, (100,), dtype=torch.int32)],
|
||||||
"prompts": [torch.randint(0, 100, (seq_len,), dtype=torch.int32)],
|
"masks": [torch.ones(100, dtype=torch.int32)],
|
||||||
"responses": [torch.randint(0, 100, (seq_len,), dtype=torch.int32)],
|
"rewards": [torch.ones(100, dtype=torch.float32)],
|
||||||
"masks": [torch.ones(seq_len, dtype=torch.int32)],
|
|
||||||
"rewards": [torch.ones(seq_len, dtype=torch.float32)],
|
|
||||||
}
|
}
|
||||||
save_h5(test_dir, "grpo_dtype", data)
|
dataset = _make_seq_dataset(
|
||||||
|
test_dir, "grpo_dtype", train_type="grpo", data=dummy_data, window_size=32
|
||||||
dataset = DatasetFactory.load("grpo", test_dir, window_size=32)
|
)
|
||||||
item = dataset[0]
|
item = dataset[0]
|
||||||
|
|
||||||
assert item["prompts"].dtype == torch.long
|
assert item["prompts"].dtype == torch.long
|
||||||
@@ -371,18 +309,16 @@ def test_grpo_dataset_dtype(base_test_env):
|
|||||||
|
|
||||||
|
|
||||||
def test_grpo_dataset_load(base_test_env):
|
def test_grpo_dataset_load(base_test_env):
|
||||||
"""GRPODataset loads and returns correct keys"""
|
|
||||||
test_dir = base_test_env["test_dir"]
|
test_dir = base_test_env["test_dir"]
|
||||||
seq_len = 200
|
dummy_data = {
|
||||||
data = {
|
"prompts": [_rand_seq(200)],
|
||||||
"prompts": [torch.randint(0, 1000, (seq_len,), dtype=torch.int64)],
|
"responses": [_rand_seq(200)],
|
||||||
"responses": [torch.randint(0, 1000, (seq_len,), dtype=torch.int64)],
|
"masks": [torch.ones(200, dtype=torch.int64)],
|
||||||
"masks": [torch.ones(seq_len, dtype=torch.int64)],
|
"rewards": [torch.rand(200, dtype=torch.float32)],
|
||||||
"rewards": [torch.rand(seq_len, dtype=torch.float32)],
|
|
||||||
}
|
}
|
||||||
save_h5(test_dir, "grpo_test", data)
|
dataset = _make_seq_dataset(
|
||||||
|
test_dir, "grpo_test", train_type="grpo", data=dummy_data
|
||||||
dataset = DatasetFactory.load("grpo", test_dir, window_size=64)
|
)
|
||||||
assert len(dataset) > 0
|
assert len(dataset) > 0
|
||||||
item = dataset[0]
|
item = dataset[0]
|
||||||
assert "prompts" in item
|
assert "prompts" in item
|
||||||
@@ -401,7 +337,6 @@ def test_detect_format_bin_dir(base_test_env):
|
|||||||
|
|
||||||
|
|
||||||
def test_store_fetch_multi_key(base_test_env):
|
def test_store_fetch_multi_key(base_test_env):
|
||||||
"""Store.fetch with List[str] returns Dict[str, Tensor]"""
|
|
||||||
test_dir = base_test_env["test_dir"]
|
test_dir = base_test_env["test_dir"]
|
||||||
save_h5(
|
save_h5(
|
||||||
test_dir,
|
test_dir,
|
||||||
@@ -411,7 +346,6 @@ def test_store_fetch_multi_key(base_test_env):
|
|||||||
"loss_mask": [torch.ones(100, dtype=torch.int64)],
|
"loss_mask": [torch.ones(100, dtype=torch.int64)],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
store = StoreFactory.create("h5")
|
store = StoreFactory.create("h5")
|
||||||
store.load(test_dir)
|
store.load(test_dir)
|
||||||
result = store.fetch(10, 20, ["sequence", "loss_mask"])
|
result = store.fetch(10, 20, ["sequence", "loss_mask"])
|
||||||
@@ -421,10 +355,8 @@ def test_store_fetch_multi_key(base_test_env):
|
|||||||
|
|
||||||
|
|
||||||
def test_store_fetch_out_of_bounds(base_test_env):
|
def test_store_fetch_out_of_bounds(base_test_env):
|
||||||
"""Store.fetch raises ValueError for out-of-bounds indices"""
|
|
||||||
test_dir = base_test_env["test_dir"]
|
test_dir = base_test_env["test_dir"]
|
||||||
save_h5(test_dir, "bounds", {"sequence": [torch.randint(0, 100, (50,))]})
|
save_h5(test_dir, "bounds", {"sequence": [torch.randint(0, 100, (50,))]})
|
||||||
|
|
||||||
store = StoreFactory.create("h5")
|
store = StoreFactory.create("h5")
|
||||||
store.load(test_dir)
|
store.load(test_dir)
|
||||||
with pytest.raises(ValueError, match="out of bounds"):
|
with pytest.raises(ValueError, match="out of bounds"):
|
||||||
@@ -436,10 +368,7 @@ def test_store_fetch_out_of_bounds(base_test_env):
|
|||||||
|
|
||||||
|
|
||||||
def test_dataset_load_explicit_storage_type(base_test_env):
|
def test_dataset_load_explicit_storage_type(base_test_env):
|
||||||
"""DatasetFactory.load with explicit storage_type bypasses auto-detect"""
|
|
||||||
test_dir = base_test_env["test_dir"]
|
test_dir = base_test_env["test_dir"]
|
||||||
save_h5(test_dir, "explicit", {"sequence": [torch.randint(0, 100, (200,))]})
|
dataset = _make_seq_dataset(test_dir, "explicit", storage_type="h5")
|
||||||
|
|
||||||
dataset = DatasetFactory.load("seq", test_dir, window_size=64, storage_type="h5")
|
|
||||||
assert len(dataset) > 0
|
assert len(dataset) > 0
|
||||||
assert dataset.count == 200
|
assert dataset.count == 200
|
||||||
|
|||||||
@@ -0,0 +1,369 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from astrai.config.preprocess_config import (
|
||||||
|
InputConfig,
|
||||||
|
OutputConfig,
|
||||||
|
PipelineConfig,
|
||||||
|
ProcessingConfig,
|
||||||
|
)
|
||||||
|
from astrai.preprocessing.builder import (
|
||||||
|
MaskBuilderFactory,
|
||||||
|
SectionedMaskBuilder,
|
||||||
|
)
|
||||||
|
from tests.data.conftest import (
|
||||||
|
_CHAT_SECTIONS,
|
||||||
|
_INSTRUCTION_SECTIONS,
|
||||||
|
_TEXT_SECTIONS,
|
||||||
|
make_chat_config,
|
||||||
|
make_dpo_chat_config,
|
||||||
|
make_grpo_config,
|
||||||
|
make_instruction_config,
|
||||||
|
make_text_config,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_chat_simple(chat_tokenizer, builder):
|
||||||
|
config = make_chat_config()
|
||||||
|
item = {
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": "You are helpful."},
|
||||||
|
{"role": "user", "content": "Hello."},
|
||||||
|
{"role": "assistant", "content": "Hi there!"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
result = builder.build(item, config, chat_tokenizer)
|
||||||
|
assert result is not None
|
||||||
|
assert "sequence" in result
|
||||||
|
assert "loss_mask" in result
|
||||||
|
assert len(result["sequence"]) == len(result["loss_mask"])
|
||||||
|
|
||||||
|
ids = chat_tokenizer.decode(result["sequence"], skip_special_tokens=False)
|
||||||
|
assert "system" in ids.lower() or "<|im_start|>system" in ids
|
||||||
|
assert "assistant" in ids.lower() or "<|im_start|>assistant" in ids
|
||||||
|
|
||||||
|
total = len(result["sequence"])
|
||||||
|
trained = sum(result["loss_mask"])
|
||||||
|
assert trained > 0
|
||||||
|
assert trained < total
|
||||||
|
|
||||||
|
|
||||||
|
def test_chat_mask_only_assistant(chat_tokenizer, builder):
|
||||||
|
config = make_chat_config()
|
||||||
|
item = {
|
||||||
|
"messages": [
|
||||||
|
{"role": "user", "content": "What is 2+2?"},
|
||||||
|
{"role": "assistant", "content": "4"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
result = builder.build(item, config, chat_tokenizer)
|
||||||
|
mask = result["loss_mask"]
|
||||||
|
ids = result["sequence"]
|
||||||
|
assert len(ids) == len(mask)
|
||||||
|
|
||||||
|
trained = [i for i, m in enumerate(mask) if m == 1]
|
||||||
|
masked = [i for i, m in enumerate(mask) if m == 0]
|
||||||
|
assert len(trained) > 0
|
||||||
|
assert len(masked) > 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"mask_rules,mask_default,expect_nonzero",
|
||||||
|
[
|
||||||
|
({"system": "mask", "user": "mask", "assistant": "mask"}, "mask", False),
|
||||||
|
({}, "train", True),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_chat_uniform_masking(
|
||||||
|
mask_rules, mask_default, expect_nonzero, chat_tokenizer, builder
|
||||||
|
):
|
||||||
|
config = PipelineConfig(
|
||||||
|
input=InputConfig(sections=_CHAT_SECTIONS),
|
||||||
|
mask=mask_rules,
|
||||||
|
mask_default=mask_default,
|
||||||
|
preprocessing=ProcessingConfig(max_seq_len=2048),
|
||||||
|
)
|
||||||
|
item = {
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": "You are helpful."},
|
||||||
|
{"role": "assistant", "content": "Hi there!"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
result = builder.build(item, config, chat_tokenizer)
|
||||||
|
masked_count = sum(result["loss_mask"])
|
||||||
|
if expect_nonzero:
|
||||||
|
assert masked_count > 0
|
||||||
|
else:
|
||||||
|
assert masked_count == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_chat_empty_messages(chat_tokenizer, builder):
|
||||||
|
config = make_chat_config()
|
||||||
|
assert builder.build({"messages": []}, config, chat_tokenizer) is None
|
||||||
|
assert builder.build({}, config, chat_tokenizer) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_chat_domain_extraction(chat_tokenizer, builder):
|
||||||
|
config = PipelineConfig(
|
||||||
|
input=InputConfig(sections=_CHAT_SECTIONS),
|
||||||
|
mask={"assistant": "train"},
|
||||||
|
mask_default="mask",
|
||||||
|
preprocessing=ProcessingConfig(max_seq_len=2048),
|
||||||
|
output=OutputConfig(domain_key="source"),
|
||||||
|
)
|
||||||
|
item = {
|
||||||
|
"messages": [
|
||||||
|
{"role": "user", "content": "Hi"},
|
||||||
|
{"role": "assistant", "content": "Hello"},
|
||||||
|
],
|
||||||
|
"source": "wiki",
|
||||||
|
}
|
||||||
|
result = builder.build(item, config, chat_tokenizer)
|
||||||
|
assert result["domain"] == "wiki"
|
||||||
|
|
||||||
|
|
||||||
|
def test_chat_truncation(chat_tokenizer, builder):
|
||||||
|
config = PipelineConfig(
|
||||||
|
input=InputConfig(sections=_CHAT_SECTIONS),
|
||||||
|
mask={"assistant": "train"},
|
||||||
|
mask_default="mask",
|
||||||
|
preprocessing=ProcessingConfig(max_seq_len=10),
|
||||||
|
)
|
||||||
|
item = {
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "Tell me a very long story about dragons and knights and magic.",
|
||||||
|
},
|
||||||
|
{"role": "assistant", "content": "Sure! Here is a tale..."},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
result = builder.build(item, config, chat_tokenizer)
|
||||||
|
assert len(result["sequence"]) <= 10
|
||||||
|
assert len(result["loss_mask"]) == len(result["sequence"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_instruction_basic(test_tokenizer, builder):
|
||||||
|
config = make_instruction_config()
|
||||||
|
item = {"prompt": "Translate to French: Hello", "response": "Bonjour"}
|
||||||
|
result = builder.build(item, config, test_tokenizer)
|
||||||
|
assert result is not None
|
||||||
|
assert len(result["sequence"]) == len(result["loss_mask"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_instruction_prompt_masked(test_tokenizer, builder):
|
||||||
|
config = make_instruction_config()
|
||||||
|
item = {"prompt": "hello", "response": "world"}
|
||||||
|
result = builder.build(item, config, test_tokenizer)
|
||||||
|
mask = result["loss_mask"]
|
||||||
|
ids = result["sequence"]
|
||||||
|
|
||||||
|
prompt_ids = test_tokenizer.encode("hello", add_special_tokens=True)
|
||||||
|
p_len = min(len(prompt_ids), len(ids))
|
||||||
|
assert all(m == 0 for m in mask[:p_len])
|
||||||
|
if p_len < len(ids):
|
||||||
|
assert all(m == 1 for m in mask[p_len:])
|
||||||
|
|
||||||
|
|
||||||
|
def test_instruction_train_on_prompt(test_tokenizer, builder):
|
||||||
|
config = PipelineConfig(
|
||||||
|
input=InputConfig(
|
||||||
|
sections=[
|
||||||
|
{"field": "prompt", "action": "train", "add_special_tokens": True},
|
||||||
|
{"field": "response", "action": "mask"},
|
||||||
|
]
|
||||||
|
),
|
||||||
|
preprocessing=ProcessingConfig(max_seq_len=2048),
|
||||||
|
)
|
||||||
|
item = {"prompt": "hello", "response": "world"}
|
||||||
|
result = builder.build(item, config, test_tokenizer)
|
||||||
|
mask = result["loss_mask"]
|
||||||
|
ids = result["sequence"]
|
||||||
|
|
||||||
|
prompt_ids = test_tokenizer.encode("hello", add_special_tokens=True)
|
||||||
|
p_len = min(len(prompt_ids), len(ids))
|
||||||
|
assert all(m == 1 for m in mask[:p_len])
|
||||||
|
|
||||||
|
|
||||||
|
def test_text_basic(test_tokenizer, builder):
|
||||||
|
config = make_text_config()
|
||||||
|
item = {"text": "Hello world. This is a test document."}
|
||||||
|
result = builder.build(item, config, test_tokenizer)
|
||||||
|
assert result is not None
|
||||||
|
assert "sequence" in result
|
||||||
|
assert len(result["sequence"]) > 0
|
||||||
|
assert "loss_mask" not in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_text_empty(test_tokenizer, builder):
|
||||||
|
config = make_text_config()
|
||||||
|
assert builder.build({"text": ""}, config, test_tokenizer) is None
|
||||||
|
assert builder.build({"text": " "}, config, test_tokenizer) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_text_too_short(test_tokenizer, builder):
|
||||||
|
config = PipelineConfig(
|
||||||
|
input=InputConfig(sections=_TEXT_SECTIONS),
|
||||||
|
preprocessing=ProcessingConfig(min_chars=100),
|
||||||
|
)
|
||||||
|
assert builder.build({"text": "short"}, config, test_tokenizer) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_text_truncation(test_tokenizer, builder):
|
||||||
|
config = PipelineConfig(
|
||||||
|
input=InputConfig(sections=_TEXT_SECTIONS),
|
||||||
|
preprocessing=ProcessingConfig(max_seq_len=3, min_chars=1),
|
||||||
|
)
|
||||||
|
item = {"text": "This is a very long text that should be truncated"}
|
||||||
|
result = builder.build(item, config, test_tokenizer)
|
||||||
|
assert len(result["sequence"]) <= 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_sectioned_chat(chat_tokenizer, builder):
|
||||||
|
config = PipelineConfig(
|
||||||
|
input=InputConfig(sections=_CHAT_SECTIONS),
|
||||||
|
mask={"system": "mask", "user": "mask", "assistant": "train"},
|
||||||
|
mask_default="mask",
|
||||||
|
preprocessing=ProcessingConfig(max_seq_len=2048),
|
||||||
|
)
|
||||||
|
item = {
|
||||||
|
"messages": [
|
||||||
|
{"role": "user", "content": "What is 2+2?"},
|
||||||
|
{"role": "assistant", "content": "4"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
result = builder.build(item, config, chat_tokenizer)
|
||||||
|
assert result is not None
|
||||||
|
assert len(result["sequence"]) == len(result["loss_mask"])
|
||||||
|
assert sum(result["loss_mask"]) > 0
|
||||||
|
assert 0 in result["loss_mask"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_sectioned_instruction(test_tokenizer, builder):
|
||||||
|
config = PipelineConfig(
|
||||||
|
input=InputConfig(sections=_INSTRUCTION_SECTIONS),
|
||||||
|
preprocessing=ProcessingConfig(max_seq_len=2048, min_chars=0),
|
||||||
|
)
|
||||||
|
item = {"prompt": "Q: Why?", "response": "A: Because."}
|
||||||
|
result = builder.build(item, config, test_tokenizer)
|
||||||
|
assert result is not None
|
||||||
|
mask = result["loss_mask"]
|
||||||
|
assert mask[0] == 0
|
||||||
|
assert mask[-1] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_sectioned_text(test_tokenizer, builder):
|
||||||
|
config = PipelineConfig(
|
||||||
|
input=InputConfig(sections=_TEXT_SECTIONS),
|
||||||
|
preprocessing=ProcessingConfig(max_seq_len=2048, min_chars=1),
|
||||||
|
)
|
||||||
|
item = {"text": "Hello world, this is a test."}
|
||||||
|
result = builder.build(item, config, test_tokenizer)
|
||||||
|
assert result is not None
|
||||||
|
assert "loss_mask" not in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_sectioned_text_too_short(test_tokenizer, builder):
|
||||||
|
config = PipelineConfig(
|
||||||
|
input=InputConfig(sections=_TEXT_SECTIONS),
|
||||||
|
preprocessing=ProcessingConfig(max_seq_len=2048, min_chars=100),
|
||||||
|
)
|
||||||
|
assert builder.build({"text": "short"}, config, test_tokenizer) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_factory_registered():
|
||||||
|
names = MaskBuilderFactory.list_registered()
|
||||||
|
assert "sectioned" in names
|
||||||
|
|
||||||
|
|
||||||
|
def test_factory_create():
|
||||||
|
builder_obj = MaskBuilderFactory.create("sectioned")
|
||||||
|
assert isinstance(builder_obj, SectionedMaskBuilder)
|
||||||
|
|
||||||
|
|
||||||
|
def test_dpo_chat_basic(chat_tokenizer, builder):
|
||||||
|
config = make_dpo_chat_config()
|
||||||
|
item = {
|
||||||
|
"chosen": [
|
||||||
|
{"role": "user", "content": "What is 2+2?"},
|
||||||
|
{"role": "assistant", "content": "4"},
|
||||||
|
],
|
||||||
|
"rejected": [
|
||||||
|
{"role": "user", "content": "What is 2+2?"},
|
||||||
|
{"role": "assistant", "content": "5"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
result = builder.build(item, config, chat_tokenizer)
|
||||||
|
assert result is not None
|
||||||
|
assert "chosen" in result
|
||||||
|
assert "rejected" in result
|
||||||
|
assert "chosen_mask" in result
|
||||||
|
assert "rejected_mask" in result
|
||||||
|
assert len(result["chosen"]) == len(result["chosen_mask"])
|
||||||
|
assert len(result["rejected"]) == len(result["rejected_mask"])
|
||||||
|
assert sum(result["chosen_mask"]) > 0
|
||||||
|
assert sum(result["rejected_mask"]) > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_dpo_chosen_only_trained(chat_tokenizer, builder):
|
||||||
|
config = make_dpo_chat_config()
|
||||||
|
item = {
|
||||||
|
"chosen": [
|
||||||
|
{"role": "user", "content": "Hi"},
|
||||||
|
{"role": "assistant", "content": "Hello"},
|
||||||
|
],
|
||||||
|
"rejected": [
|
||||||
|
{"role": "user", "content": "Hi"},
|
||||||
|
{"role": "assistant", "content": "Go away"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
result = builder.build(item, config, chat_tokenizer)
|
||||||
|
assert 0 in result["chosen_mask"]
|
||||||
|
assert 1 in result["chosen_mask"]
|
||||||
|
assert 0 in result["rejected_mask"]
|
||||||
|
assert 1 in result["rejected_mask"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_dpo_missing_field_is_none(chat_tokenizer, builder):
|
||||||
|
config = make_dpo_chat_config()
|
||||||
|
assert builder.build({"chosen": [], "rejected": []}, config, chat_tokenizer) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_grpo_basic(chat_tokenizer, builder):
|
||||||
|
config = make_grpo_config()
|
||||||
|
item = {
|
||||||
|
"prompt": [{"role": "user", "content": "What is 2+2?"}],
|
||||||
|
"responses": ["4", "The answer is four", "Four", "2+2=4"],
|
||||||
|
"rewards": [1.0, 0.5, 0.8, 0.2],
|
||||||
|
}
|
||||||
|
result = builder.build(item, config, chat_tokenizer)
|
||||||
|
assert result is not None
|
||||||
|
assert "prompts" in result
|
||||||
|
assert "responses" in result
|
||||||
|
assert "masks" in result
|
||||||
|
assert "rewards" in result
|
||||||
|
assert len(result["responses"]) == len(result["masks"])
|
||||||
|
assert result["rewards"] == [1.0, 0.5, 0.8, 0.2]
|
||||||
|
|
||||||
|
|
||||||
|
def test_grpo_response_tokens_all_trained(chat_tokenizer, builder):
|
||||||
|
config = make_grpo_config()
|
||||||
|
item = {
|
||||||
|
"prompt": [{"role": "user", "content": "Q"}],
|
||||||
|
"responses": ["A", "B"],
|
||||||
|
"rewards": [0.8, 0.2],
|
||||||
|
}
|
||||||
|
result = builder.build(item, config, chat_tokenizer)
|
||||||
|
masks = result["masks"]
|
||||||
|
assert all(m == 1 for m in masks)
|
||||||
|
assert len(masks) == len(result["responses"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_grpo_single_reward(chat_tokenizer, builder):
|
||||||
|
config = make_grpo_config()
|
||||||
|
item = {
|
||||||
|
"prompt": [{"role": "user", "content": "Q"}],
|
||||||
|
"responses": ["A"],
|
||||||
|
"rewards": 0.9,
|
||||||
|
}
|
||||||
|
result = builder.build(item, config, chat_tokenizer)
|
||||||
|
assert result["rewards"] == [0.9]
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
from astrai.config.preprocess_config import (
|
||||||
|
InputConfig,
|
||||||
|
PipelineConfig,
|
||||||
|
)
|
||||||
|
from tests.data.conftest import (
|
||||||
|
_INSTRUCTION_SECTIONS,
|
||||||
|
_TEXT_SECTIONS,
|
||||||
|
make_dpo_chat_config,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_values():
|
||||||
|
config = PipelineConfig()
|
||||||
|
assert config.version == 1
|
||||||
|
assert config.mask == {}
|
||||||
|
assert config.mask_default == "mask"
|
||||||
|
assert config.preprocessing.max_seq_len == 2048
|
||||||
|
assert config.output.storage_format == "bin"
|
||||||
|
assert config.input.sections is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_from_dict_flat():
|
||||||
|
data = {
|
||||||
|
"version": 1,
|
||||||
|
"input": {
|
||||||
|
"sections": [{"field": "messages", "action": "$role", "template": True}]
|
||||||
|
},
|
||||||
|
"mask": {"system": "mask", "assistant": "train"},
|
||||||
|
"mask_default": "mask",
|
||||||
|
"preprocessing": {"max_seq_len": 1024},
|
||||||
|
"output": {"storage_format": "h5"},
|
||||||
|
}
|
||||||
|
config = PipelineConfig.from_dict(data)
|
||||||
|
assert config.input.sections == [
|
||||||
|
{"field": "messages", "action": "$role", "template": True}
|
||||||
|
]
|
||||||
|
assert config.mask == {"system": "mask", "assistant": "train"}
|
||||||
|
assert config.preprocessing.max_seq_len == 1024
|
||||||
|
assert config.output.storage_format == "h5"
|
||||||
|
|
||||||
|
|
||||||
|
def test_to_dict_roundtrip():
|
||||||
|
config = PipelineConfig(
|
||||||
|
input=InputConfig(sections=_INSTRUCTION_SECTIONS),
|
||||||
|
mask={"prompt": "mask", "response": "train"},
|
||||||
|
mask_default="mask",
|
||||||
|
)
|
||||||
|
d = config.to_dict()
|
||||||
|
config2 = PipelineConfig.from_dict(d)
|
||||||
|
assert config2.input.sections == _INSTRUCTION_SECTIONS
|
||||||
|
assert config2.mask == {"prompt": "mask", "response": "train"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_to_file_from_file(temp_dir):
|
||||||
|
config = PipelineConfig(
|
||||||
|
input=InputConfig(sections=_TEXT_SECTIONS),
|
||||||
|
mask={"text": "train"},
|
||||||
|
mask_default="mask",
|
||||||
|
)
|
||||||
|
path = os.path.join(temp_dir, "config.json")
|
||||||
|
config.to_file(path)
|
||||||
|
loaded = PipelineConfig.from_file(path)
|
||||||
|
assert loaded.input.sections == _TEXT_SECTIONS
|
||||||
|
assert loaded.mask == {"text": "train"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_dpo_config_roundtrip(temp_dir):
|
||||||
|
config = make_dpo_chat_config()
|
||||||
|
path = os.path.join(temp_dir, "config.json")
|
||||||
|
config.to_file(path)
|
||||||
|
loaded = PipelineConfig.from_file(path)
|
||||||
|
assert loaded.input.sources is not None
|
||||||
|
assert "chosen" in loaded.input.sources
|
||||||
|
assert "rejected" in loaded.input.sources
|
||||||
|
assert loaded.input.sections is None
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
from astrai.config.preprocess_config import (
|
||||||
|
InputConfig,
|
||||||
|
OutputConfig,
|
||||||
|
PipelineConfig,
|
||||||
|
ProcessingConfig,
|
||||||
|
)
|
||||||
|
from astrai.preprocessing.pipeline import Pipeline, filter_by_length
|
||||||
|
from tests.data.conftest import (
|
||||||
|
_CHAT_SECTIONS,
|
||||||
|
_INSTRUCTION_SECTIONS,
|
||||||
|
_TEXT_SECTIONS,
|
||||||
|
make_dpo_chat_config,
|
||||||
|
make_grpo_no_template_config,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_by_length():
|
||||||
|
assert filter_by_length("hello world", min_len=5)
|
||||||
|
assert not filter_by_length("hi", min_len=5)
|
||||||
|
assert not filter_by_length("x" * 100, max_len=50)
|
||||||
|
assert filter_by_length("just right", min_len=5, max_len=20)
|
||||||
|
|
||||||
|
|
||||||
|
def test_full_chat_pipeline(temp_dir, chat_tokenizer_dir):
|
||||||
|
jsonl_path = os.path.join(temp_dir, "chat.jsonl")
|
||||||
|
with open(jsonl_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": "You are helpful."},
|
||||||
|
{"role": "user", "content": "Hi."},
|
||||||
|
{"role": "assistant", "content": "Hello!"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
+ "\n"
|
||||||
|
)
|
||||||
|
f.write(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"messages": [
|
||||||
|
{"role": "user", "content": "What is 2+2?"},
|
||||||
|
{"role": "assistant", "content": "4"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
+ "\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
config = PipelineConfig(
|
||||||
|
input=InputConfig(sections=_CHAT_SECTIONS),
|
||||||
|
mask={"system": "mask", "user": "mask", "assistant": "train"},
|
||||||
|
mask_default="mask",
|
||||||
|
preprocessing=ProcessingConfig(max_seq_len=2048),
|
||||||
|
output=OutputConfig(storage_format="bin", domain_key=None),
|
||||||
|
)
|
||||||
|
|
||||||
|
out_dir = os.path.join(temp_dir, "output")
|
||||||
|
Pipeline(
|
||||||
|
config=config,
|
||||||
|
input_paths=[jsonl_path],
|
||||||
|
output_dir=out_dir,
|
||||||
|
tokenizer_path=chat_tokenizer_dir,
|
||||||
|
).run()
|
||||||
|
|
||||||
|
meta_path = os.path.join(out_dir, "__default__", "shard_0000", "meta.json")
|
||||||
|
assert os.path.exists(meta_path)
|
||||||
|
with open(meta_path, "r") as f:
|
||||||
|
meta = json.load(f)
|
||||||
|
assert "sequence" in meta
|
||||||
|
assert "loss_mask" in meta
|
||||||
|
assert meta["sequence"]["dtype"] == "int32"
|
||||||
|
assert meta["loss_mask"]["dtype"] == "int32"
|
||||||
|
|
||||||
|
|
||||||
|
def test_full_text_pipeline(temp_dir, tokenizer_dir):
|
||||||
|
jsonl_path = os.path.join(temp_dir, "text.jsonl")
|
||||||
|
with open(jsonl_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"text": "Hello world this is a test document with enough characters to pass the minimum length filter."
|
||||||
|
}
|
||||||
|
)
|
||||||
|
+ "\n"
|
||||||
|
)
|
||||||
|
f.write(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"text": "Another document for testing purposes with sufficient length to be processed."
|
||||||
|
}
|
||||||
|
)
|
||||||
|
+ "\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
config = PipelineConfig(
|
||||||
|
input=InputConfig(sections=_TEXT_SECTIONS),
|
||||||
|
preprocessing=ProcessingConfig(max_seq_len=2048, min_chars=10),
|
||||||
|
output=OutputConfig(storage_format="bin"),
|
||||||
|
)
|
||||||
|
|
||||||
|
out_dir = os.path.join(temp_dir, "output")
|
||||||
|
Pipeline(
|
||||||
|
config=config,
|
||||||
|
input_paths=[jsonl_path],
|
||||||
|
output_dir=out_dir,
|
||||||
|
tokenizer_path=tokenizer_dir,
|
||||||
|
).run()
|
||||||
|
|
||||||
|
meta_path = os.path.join(out_dir, "__default__", "shard_0000", "meta.json")
|
||||||
|
assert os.path.exists(meta_path)
|
||||||
|
with open(meta_path, "r") as f:
|
||||||
|
meta = json.load(f)
|
||||||
|
assert "sequence" in meta
|
||||||
|
assert "loss_mask" not in meta
|
||||||
|
|
||||||
|
|
||||||
|
def test_full_instruction_pipeline(temp_dir, tokenizer_dir):
|
||||||
|
jsonl_path = os.path.join(temp_dir, "instruct.jsonl")
|
||||||
|
with open(jsonl_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"prompt": "Tell me a joke",
|
||||||
|
"response": "Why did the chicken cross the road?",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
+ "\n"
|
||||||
|
)
|
||||||
|
f.write(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"prompt": "What is AI?",
|
||||||
|
"response": "Artificial Intelligence is a field of computer science.",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
+ "\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
config = PipelineConfig(
|
||||||
|
input=InputConfig(sections=_INSTRUCTION_SECTIONS),
|
||||||
|
mask={"prompt": "mask", "response": "train"},
|
||||||
|
mask_default="mask",
|
||||||
|
preprocessing=ProcessingConfig(max_seq_len=2048),
|
||||||
|
output=OutputConfig(storage_format="bin"),
|
||||||
|
)
|
||||||
|
|
||||||
|
out_dir = os.path.join(temp_dir, "output")
|
||||||
|
Pipeline(
|
||||||
|
config=config,
|
||||||
|
input_paths=[jsonl_path],
|
||||||
|
output_dir=out_dir,
|
||||||
|
tokenizer_path=tokenizer_dir,
|
||||||
|
).run()
|
||||||
|
|
||||||
|
meta_path = os.path.join(out_dir, "__default__", "shard_0000", "meta.json")
|
||||||
|
assert os.path.exists(meta_path)
|
||||||
|
with open(meta_path, "r") as f:
|
||||||
|
meta = json.load(f)
|
||||||
|
assert "sequence" in meta
|
||||||
|
assert "loss_mask" in meta
|
||||||
|
|
||||||
|
|
||||||
|
def test_dtype_override(temp_dir, tokenizer_dir):
|
||||||
|
jsonl_path = os.path.join(temp_dir, "data.jsonl")
|
||||||
|
with open(jsonl_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(json.dumps({"prompt": "Q", "response": "A"}) + "\n")
|
||||||
|
|
||||||
|
config = PipelineConfig(
|
||||||
|
input=InputConfig(sections=_INSTRUCTION_SECTIONS),
|
||||||
|
mask={"prompt": "mask", "response": "train"},
|
||||||
|
mask_default="mask",
|
||||||
|
preprocessing=ProcessingConfig(max_seq_len=2048),
|
||||||
|
output=OutputConfig(storage_format="bin", dtype={"loss_mask": "bool"}),
|
||||||
|
)
|
||||||
|
|
||||||
|
out_dir = os.path.join(temp_dir, "output")
|
||||||
|
Pipeline(
|
||||||
|
config=config,
|
||||||
|
input_paths=[jsonl_path],
|
||||||
|
output_dir=out_dir,
|
||||||
|
tokenizer_path=tokenizer_dir,
|
||||||
|
).run()
|
||||||
|
|
||||||
|
meta_path = os.path.join(out_dir, "__default__", "shard_0000", "meta.json")
|
||||||
|
with open(meta_path, "r") as f:
|
||||||
|
meta = json.load(f)
|
||||||
|
assert meta["sequence"]["dtype"] == "int32"
|
||||||
|
assert meta["loss_mask"]["dtype"] == "bool"
|
||||||
|
|
||||||
|
|
||||||
|
def test_dpo_pipeline(temp_dir, chat_tokenizer_dir):
|
||||||
|
jsonl_path = os.path.join(temp_dir, "dpo.jsonl")
|
||||||
|
with open(jsonl_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"chosen": [
|
||||||
|
{"role": "user", "content": "Hi."},
|
||||||
|
{"role": "assistant", "content": "Hello!"},
|
||||||
|
],
|
||||||
|
"rejected": [
|
||||||
|
{"role": "user", "content": "Hi."},
|
||||||
|
{"role": "assistant", "content": "Go away."},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
+ "\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
out_dir = os.path.join(temp_dir, "output")
|
||||||
|
Pipeline(
|
||||||
|
config=make_dpo_chat_config(),
|
||||||
|
input_paths=[jsonl_path],
|
||||||
|
output_dir=out_dir,
|
||||||
|
tokenizer_path=chat_tokenizer_dir,
|
||||||
|
).run()
|
||||||
|
|
||||||
|
meta_path = os.path.join(out_dir, "__default__", "shard_0000", "meta.json")
|
||||||
|
assert os.path.exists(meta_path)
|
||||||
|
with open(meta_path, "r") as f:
|
||||||
|
meta = json.load(f)
|
||||||
|
assert "chosen" in meta
|
||||||
|
assert "rejected" in meta
|
||||||
|
assert "chosen_mask" in meta
|
||||||
|
assert "rejected_mask" in meta
|
||||||
|
assert "sequence" not in meta
|
||||||
|
|
||||||
|
|
||||||
|
def test_grpo_pipeline(temp_dir, tokenizer_dir):
|
||||||
|
jsonl_path = os.path.join(temp_dir, "grpo.jsonl")
|
||||||
|
with open(jsonl_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"prompt": "Question?",
|
||||||
|
"responses": ["Answer A", "Answer B"],
|
||||||
|
"rewards": [0.8, 0.3],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
+ "\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
out_dir = os.path.join(temp_dir, "output")
|
||||||
|
Pipeline(
|
||||||
|
config=make_grpo_no_template_config(),
|
||||||
|
input_paths=[jsonl_path],
|
||||||
|
output_dir=out_dir,
|
||||||
|
tokenizer_path=tokenizer_dir,
|
||||||
|
).run()
|
||||||
|
|
||||||
|
meta_path = os.path.join(out_dir, "__default__", "shard_0000", "meta.json")
|
||||||
|
assert os.path.exists(meta_path)
|
||||||
|
with open(meta_path, "r") as f:
|
||||||
|
meta = json.load(f)
|
||||||
|
assert "prompts" in meta
|
||||||
|
assert "responses" in meta
|
||||||
|
assert "masks" in meta
|
||||||
|
assert "rewards" in meta
|
||||||
|
assert "sequence" not in meta
|
||||||
@@ -5,21 +5,22 @@ from unittest.mock import MagicMock
|
|||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from astrai.inference import app
|
from astrai.inference import get_app
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def client():
|
def client():
|
||||||
"""Provide a test client for the FastAPI app."""
|
"""Provide a test client for the FastAPI app."""
|
||||||
app.state.server_config = {
|
_app = get_app()
|
||||||
|
_app.state.server_config = {
|
||||||
"device": "cpu",
|
"device": "cpu",
|
||||||
"dtype": "bfloat16",
|
"dtype": "bfloat16",
|
||||||
"param_path": None,
|
"param_path": None,
|
||||||
"max_batch_size": 1,
|
"max_batch_size": 1,
|
||||||
"_test": True,
|
"_test": True,
|
||||||
}
|
}
|
||||||
app.state.engine = None
|
_app.state.engine = None
|
||||||
return TestClient(app)
|
return TestClient(_app)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -49,5 +50,5 @@ def mock_engine():
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def loaded_model(client, mock_engine):
|
def loaded_model(client, mock_engine):
|
||||||
"""Simulate that the engine is loaded."""
|
"""Simulate that the engine is loaded."""
|
||||||
app.state.engine = mock_engine
|
get_app().state.engine = mock_engine
|
||||||
return mock_engine
|
return mock_engine
|
||||||
|
|||||||
@@ -121,8 +121,8 @@ class TestOpenAIResponseBuilder:
|
|||||||
assert p["choices"][0]["finish_reason"] is None
|
assert p["choices"][0]["finish_reason"] is None
|
||||||
|
|
||||||
def test_format_chunk(self, builder):
|
def test_format_chunk(self, builder):
|
||||||
event = builder.format_chunk("hello")
|
events = builder.format_chunk("hello", body="hello")
|
||||||
payload = json.loads(event.split("data: ", 1)[1])
|
payload = json.loads(events[0].split("data: ", 1)[1])
|
||||||
assert payload["choices"][0]["delta"]["content"] == "hello"
|
assert payload["choices"][0]["delta"]["content"] == "hello"
|
||||||
assert payload["choices"][0]["finish_reason"] is None
|
assert payload["choices"][0]["finish_reason"] is None
|
||||||
|
|
||||||
@@ -192,8 +192,8 @@ class TestAnthropicResponseBuilder:
|
|||||||
assert payloads[1]["type"] == "content_block_start"
|
assert payloads[1]["type"] == "content_block_start"
|
||||||
|
|
||||||
def test_format_chunk(self, builder):
|
def test_format_chunk(self, builder):
|
||||||
event = builder.format_chunk("tok")
|
events = builder.format_chunk("tok", body="tok")
|
||||||
payload = json.loads(event.split("data: ", 1)[1])
|
payload = json.loads(events[0].split("data: ", 1)[1])
|
||||||
assert payload["type"] == "content_block_delta"
|
assert payload["type"] == "content_block_delta"
|
||||||
assert payload["delta"]["text"] == "tok"
|
assert payload["delta"]["text"] == "tok"
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,12 @@
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from astrai.inference import app
|
from astrai.inference import get_app
|
||||||
|
|
||||||
|
|
||||||
def test_health_no_model(client):
|
def test_health_no_model(client):
|
||||||
"""GET /health should return 200 even when engine not loaded."""
|
"""GET /health should return 200 even when engine not loaded."""
|
||||||
app.state.engine = None
|
get_app().state.engine = None
|
||||||
response = client.get("/health")
|
response = client.get("/health")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = response.json()
|
data = response.json()
|
||||||
@@ -30,7 +30,7 @@ def test_chat_completions_non_stream(client, loaded_model):
|
|||||||
async def async_gen():
|
async def async_gen():
|
||||||
yield "Assistant reply"
|
yield "Assistant reply"
|
||||||
|
|
||||||
app.state.engine = loaded_model
|
get_app().state.engine = loaded_model
|
||||||
loaded_model.generate_async.return_value = async_gen()
|
loaded_model.generate_async.return_value = async_gen()
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/v1/chat/completions",
|
"/v1/chat/completions",
|
||||||
@@ -56,7 +56,7 @@ def test_chat_completions_stream(client, loaded_model):
|
|||||||
yield "cumulative1"
|
yield "cumulative1"
|
||||||
yield "cumulative2"
|
yield "cumulative2"
|
||||||
|
|
||||||
app.state.engine = loaded_model
|
get_app().state.engine = loaded_model
|
||||||
loaded_model.generate_async.return_value = async_gen()
|
loaded_model.generate_async.return_value = async_gen()
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/v1/chat/completions",
|
"/v1/chat/completions",
|
||||||
@@ -83,7 +83,7 @@ def test_messages_non_stream(client, loaded_model):
|
|||||||
async def async_gen():
|
async def async_gen():
|
||||||
yield "Assistant reply"
|
yield "Assistant reply"
|
||||||
|
|
||||||
app.state.engine = loaded_model
|
get_app().state.engine = loaded_model
|
||||||
loaded_model.generate_async.return_value = async_gen()
|
loaded_model.generate_async.return_value = async_gen()
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/v1/messages",
|
"/v1/messages",
|
||||||
@@ -111,7 +111,7 @@ def test_messages_stream(client, loaded_model):
|
|||||||
yield "cumulative1"
|
yield "cumulative1"
|
||||||
yield "cumulative2"
|
yield "cumulative2"
|
||||||
|
|
||||||
app.state.engine = loaded_model
|
get_app().state.engine = loaded_model
|
||||||
loaded_model.generate_async.return_value = async_gen()
|
loaded_model.generate_async.return_value = async_gen()
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/v1/messages",
|
"/v1/messages",
|
||||||
@@ -141,7 +141,7 @@ def test_messages_with_system(client, loaded_model):
|
|||||||
async def async_gen():
|
async def async_gen():
|
||||||
yield "Reply"
|
yield "Reply"
|
||||||
|
|
||||||
app.state.engine = loaded_model
|
get_app().state.engine = loaded_model
|
||||||
loaded_model.generate_async.return_value = async_gen()
|
loaded_model.generate_async.return_value = async_gen()
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/v1/messages",
|
"/v1/messages",
|
||||||
@@ -165,7 +165,7 @@ def test_chat_completions_stop_sequence(client, loaded_model):
|
|||||||
yield "X"
|
yield "X"
|
||||||
yield "world"
|
yield "world"
|
||||||
|
|
||||||
app.state.engine = loaded_model
|
get_app().state.engine = loaded_model
|
||||||
loaded_model.generate_async.return_value = async_gen()
|
loaded_model.generate_async.return_value = async_gen()
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/v1/chat/completions",
|
"/v1/chat/completions",
|
||||||
@@ -191,7 +191,7 @@ def test_chat_completions_stop_sequence_stream(client, loaded_model):
|
|||||||
yield "X"
|
yield "X"
|
||||||
yield "world"
|
yield "world"
|
||||||
|
|
||||||
app.state.engine = loaded_model
|
get_app().state.engine = loaded_model
|
||||||
loaded_model.generate_async.return_value = async_gen()
|
loaded_model.generate_async.return_value = async_gen()
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/v1/chat/completions",
|
"/v1/chat/completions",
|
||||||
|
|||||||
@@ -0,0 +1,608 @@
|
|||||||
|
"""Unit tests for tool call parsers."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from astrai.inference.api.tool_parser import (
|
||||||
|
_TOOL_CALL_HEAD_RE,
|
||||||
|
BaseToolParser,
|
||||||
|
SimpleJsonToolParser,
|
||||||
|
ToolParserFactory,
|
||||||
|
_find_partial_tool_call,
|
||||||
|
_find_tool_calls,
|
||||||
|
_scan_json,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"text,expected_complete,check_end_eq_len",
|
||||||
|
[
|
||||||
|
('{"key": "value"}', True, True),
|
||||||
|
('{"outer": {"inner": 1}}', True, True),
|
||||||
|
('{"key": "value"', False, False),
|
||||||
|
('{"outer": {"inner": 1}', False, False),
|
||||||
|
('{"key": "a{b}c"} extra', True, False),
|
||||||
|
(r'{"key": "a\"b"}', True, False),
|
||||||
|
('{"a": {"b": {"c": {"d": {"e": 5}}}}}', True, True),
|
||||||
|
('{"items": [{"x": 1}, {"x": 2}]}', True, True),
|
||||||
|
('{"fn": "function() { return 1; }"}', True, False),
|
||||||
|
('{"key": "\u5317\u4eac"}', True, False),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_scan_json(text, expected_complete, check_end_eq_len):
|
||||||
|
end, complete = _scan_json(text, 0)
|
||||||
|
assert complete is expected_complete
|
||||||
|
if check_end_eq_len:
|
||||||
|
assert end == len(text)
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_single_tool_call():
|
||||||
|
text = '{"name": "get_weather", "arguments": {"city": "Beijing"}}'
|
||||||
|
results = _find_tool_calls(text)
|
||||||
|
assert len(results) == 1
|
||||||
|
assert results[0]["name"] == "get_weather"
|
||||||
|
assert '"city"' in results[0]["args"]
|
||||||
|
assert results[0]["complete"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_text_before_tool_call():
|
||||||
|
text = 'Some text {"name": "func", "arguments": {}}'
|
||||||
|
results = _find_tool_calls(text)
|
||||||
|
assert len(results) == 1
|
||||||
|
assert results[0]["start"] > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_multiple_tool_calls():
|
||||||
|
text = '{"name": "f1", "arguments": {"a": 1}}{"name": "f2", "arguments": {"b": 2}}'
|
||||||
|
results = _find_tool_calls(text)
|
||||||
|
assert len(results) == 2
|
||||||
|
assert results[0]["name"] == "f1"
|
||||||
|
assert results[1]["name"] == "f2"
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_no_tool_call():
|
||||||
|
results = _find_tool_calls("Hello, how are you?")
|
||||||
|
assert len(results) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_non_tool_json_skipped():
|
||||||
|
results = _find_tool_calls('{"not_a_tool": true}')
|
||||||
|
assert len(results) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_no_arguments_field():
|
||||||
|
results = _find_tool_calls('{"name": "simple_func"}')
|
||||||
|
assert len(results) == 1
|
||||||
|
assert results[0]["name"] == "simple_func"
|
||||||
|
assert results[0]["args"] == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_deeply_nested_arguments():
|
||||||
|
text = '{"name": "deep", "arguments": {"a": {"b": {"c": {"d": 4}}}}}'
|
||||||
|
results = _find_tool_calls(text)
|
||||||
|
assert len(results) == 1
|
||||||
|
assert results[0]["name"] == "deep"
|
||||||
|
assert '"d": 4' in results[0]["args"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_arguments_with_boolean_and_null():
|
||||||
|
text = '{"name": "flags", "arguments": {"active": true, "count": 0, "nick": null}}'
|
||||||
|
results = _find_tool_calls(text)
|
||||||
|
assert len(results) == 1
|
||||||
|
assert results[0]["name"] == "flags"
|
||||||
|
assert "true" in results[0]["args"]
|
||||||
|
assert "null" in results[0]["args"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_arguments_with_array():
|
||||||
|
text = '{"name": "add_items", "arguments": {"items": [1, 2, 3], "name": "list"}}'
|
||||||
|
results = _find_tool_calls(text)
|
||||||
|
assert len(results) == 1
|
||||||
|
assert results[0]["name"] == "add_items"
|
||||||
|
assert "[1, 2, 3]" in results[0]["args"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_arguments_with_nested_array_of_objects():
|
||||||
|
text = '{"name": "batch", "arguments": {"rows": [{"id": 1, "val": "a"}, {"id": 2, "val": "b"}]}}'
|
||||||
|
results = _find_tool_calls(text)
|
||||||
|
assert len(results) == 1
|
||||||
|
assert '"rows"' in results[0]["args"]
|
||||||
|
assert '"id": 1' in results[0]["args"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_arguments_as_string_not_object():
|
||||||
|
text = '{"name": "echo", "arguments": "just a string"}'
|
||||||
|
results = _find_tool_calls(text)
|
||||||
|
assert len(results) == 1
|
||||||
|
assert results[0]["name"] == "echo"
|
||||||
|
assert "just a string" in results[0]["args"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_arguments_with_unicode():
|
||||||
|
text = (
|
||||||
|
'{"name": "translate", "arguments": {"text": "\u4f60\u597d\uff0c\u4e16\u754c"}}'
|
||||||
|
)
|
||||||
|
results = _find_tool_calls(text)
|
||||||
|
assert len(results) == 1
|
||||||
|
assert results[0]["name"] == "translate"
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_arguments_with_escaped_quotes():
|
||||||
|
text = '{"name": "format", "arguments": {"template": "he said \\"hello\\""}}'
|
||||||
|
results = _find_tool_calls(text)
|
||||||
|
assert len(results) == 1
|
||||||
|
assert 'he said \\"hello\\"' in results[0]["args"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_arguments_with_braces_in_string():
|
||||||
|
text = '{"name": "eval", "arguments": {"code": "function(x) { return x + 1; }"}}'
|
||||||
|
results = _find_tool_calls(text)
|
||||||
|
assert len(results) == 1
|
||||||
|
assert results[0]["name"] == "eval"
|
||||||
|
assert "function(x) { return x + 1; }" in results[0]["args"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_many_properties():
|
||||||
|
args = ",".join(f'"{chr(97 + i % 26)}" : {i}' for i in range(20))
|
||||||
|
text = '{"name": "many", "arguments": {' + args + "}}"
|
||||||
|
results = _find_tool_calls(text)
|
||||||
|
assert len(results) == 1
|
||||||
|
assert results[0]["name"] == "many"
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_empty_arguments():
|
||||||
|
results = _find_tool_calls('{"name": "ping", "arguments": {}}')
|
||||||
|
assert len(results) == 1
|
||||||
|
assert results[0]["name"] == "ping"
|
||||||
|
assert results[0]["args"] == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_extracts_correct_arg_start_position():
|
||||||
|
text = '{"name": "f", "arguments": {"x": 1}}'
|
||||||
|
results = _find_tool_calls(text)
|
||||||
|
assert len(results) == 1
|
||||||
|
json_str = text[results[0]["start"] : results[0]["end"]]
|
||||||
|
assert json_str == text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"text,expected_name,expected_complete",
|
||||||
|
[
|
||||||
|
('{"name": "func", "arguments": {"city"', "func", False),
|
||||||
|
('{"name": "func", "arguments": {"city": "BJ"}}', "func", None),
|
||||||
|
("plain text", None, None),
|
||||||
|
('{"nam', None, None),
|
||||||
|
('{"name": "deep", "arguments": {"a": {"b": {"c": ', "deep", None),
|
||||||
|
('{"name": "batch", "arguments": {"items": [1, 2, ', "batch", None),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_find_partial_tool_call(text, expected_name, expected_complete):
|
||||||
|
result = _find_partial_tool_call(text)
|
||||||
|
if expected_name is None:
|
||||||
|
assert result is None
|
||||||
|
else:
|
||||||
|
assert result is not None
|
||||||
|
assert result["name"] == expected_name
|
||||||
|
if expected_complete is not None:
|
||||||
|
assert result["complete"] is expected_complete
|
||||||
|
|
||||||
|
|
||||||
|
def test_feed_plain_text():
|
||||||
|
parser = SimpleJsonToolParser()
|
||||||
|
deltas = parser.feed("Hello")
|
||||||
|
assert len(deltas) == 1
|
||||||
|
assert deltas[0]["content"] == "Hello"
|
||||||
|
|
||||||
|
|
||||||
|
def test_feed_incremental_text():
|
||||||
|
parser = SimpleJsonToolParser()
|
||||||
|
assert parser.feed("He") == [{"content": "He"}]
|
||||||
|
assert parser.feed("Hello") == [{"content": "llo"}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_feed_tool_call_name_delta():
|
||||||
|
parser = SimpleJsonToolParser()
|
||||||
|
text = '{"name": "get_weather", "arguments": {"city": "Beijing"}}'
|
||||||
|
deltas = parser.feed(text)
|
||||||
|
tc_deltas = [d for d in deltas if "tool_calls" in d]
|
||||||
|
assert len(tc_deltas) >= 1
|
||||||
|
name_delta = tc_deltas[0]["tool_calls"][0]
|
||||||
|
assert name_delta["function"]["name"] == "get_weather"
|
||||||
|
assert name_delta["type"] == "function"
|
||||||
|
assert "id" in name_delta
|
||||||
|
|
||||||
|
|
||||||
|
def test_feed_tool_call_args_streaming():
|
||||||
|
parser = SimpleJsonToolParser()
|
||||||
|
d1 = parser.feed('{"name": "f", "arguments": {"x":')
|
||||||
|
d2 = parser.feed('{"name": "f", "arguments": {"x": "1"}}')
|
||||||
|
args_deltas = [
|
||||||
|
d
|
||||||
|
for batch in (d1, d2)
|
||||||
|
for d in batch
|
||||||
|
if "tool_calls" in d
|
||||||
|
and "function" in d["tool_calls"][0]
|
||||||
|
and "arguments" in d["tool_calls"][0]["function"]
|
||||||
|
]
|
||||||
|
assert len(args_deltas) >= 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_feed_text_before_tool_call():
|
||||||
|
parser = SimpleJsonToolParser()
|
||||||
|
text = 'Let me check. {"name": "func", "arguments": {"a": 1}}'
|
||||||
|
deltas = parser.feed(text)
|
||||||
|
content_deltas = [d for d in deltas if "content" in d]
|
||||||
|
assert any("Let me check" in d.get("content", "") for d in content_deltas)
|
||||||
|
|
||||||
|
|
||||||
|
def test_has_tool_calls_false_by_default():
|
||||||
|
assert SimpleJsonToolParser().has_tool_calls is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_has_tool_calls_true_after_detection():
|
||||||
|
parser = SimpleJsonToolParser()
|
||||||
|
parser.feed('{"name": "f", "arguments": {}}')
|
||||||
|
assert parser.has_tool_calls is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_feed_no_content_when_no_new_text():
|
||||||
|
parser = SimpleJsonToolParser()
|
||||||
|
parser.feed("Hello")
|
||||||
|
assert parser.feed("Hello") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_feed_multiple_tool_calls():
|
||||||
|
parser = SimpleJsonToolParser()
|
||||||
|
text = '{"name": "f1", "arguments": {"a": 1}}{"name": "f2", "arguments": {"b": 2}}'
|
||||||
|
deltas = parser.feed(text)
|
||||||
|
tc_deltas = [d for d in deltas if "tool_calls" in d]
|
||||||
|
names = set()
|
||||||
|
for batch in tc_deltas:
|
||||||
|
for tc in batch["tool_calls"]:
|
||||||
|
if "function" in tc and "name" in tc["function"]:
|
||||||
|
names.add(tc["function"]["name"])
|
||||||
|
assert "f1" in names
|
||||||
|
assert "f2" in names
|
||||||
|
|
||||||
|
|
||||||
|
def test_feed_with_tools_constructor():
|
||||||
|
tools = [{"type": "function", "function": {"name": "get_weather"}}]
|
||||||
|
parser = SimpleJsonToolParser(tools=tools, tool_choice="auto")
|
||||||
|
deltas = parser.feed('{"name": "get_weather", "arguments": {"city": "BJ"}}')
|
||||||
|
assert len(deltas) > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_feed_content_after_tool_call_is_not_emitted():
|
||||||
|
parser = SimpleJsonToolParser()
|
||||||
|
parser.feed('{"name": "f", "arguments": {}} trailing text')
|
||||||
|
assert parser.has_tool_calls
|
||||||
|
|
||||||
|
|
||||||
|
def _simulate_streaming(parser, text):
|
||||||
|
all_delta_names = []
|
||||||
|
all_args_chunks = []
|
||||||
|
for i in range(1, len(text) + 1):
|
||||||
|
deltas = parser.feed(text[:i])
|
||||||
|
for d in deltas:
|
||||||
|
if "tool_calls" in d:
|
||||||
|
for tc in d["tool_calls"]:
|
||||||
|
fn = tc.get("function", {})
|
||||||
|
if "name" in fn:
|
||||||
|
all_delta_names.append(fn["name"])
|
||||||
|
if "arguments" in fn and fn["arguments"]:
|
||||||
|
all_args_chunks.append(fn["arguments"])
|
||||||
|
return all_delta_names, all_args_chunks
|
||||||
|
|
||||||
|
|
||||||
|
def test_streaming_token_by_token_full_build():
|
||||||
|
parser = SimpleJsonToolParser()
|
||||||
|
text = '{"name": "get_weather", "arguments": {"city": "Beijing"}}'
|
||||||
|
names, args_chunks = _simulate_streaming(parser, text)
|
||||||
|
assert "get_weather" in names
|
||||||
|
joined_args = "".join(args_chunks)
|
||||||
|
assert '"city"' in joined_args
|
||||||
|
assert "Beijing" in joined_args
|
||||||
|
|
||||||
|
|
||||||
|
def test_streaming_token_by_token_text_then_tool():
|
||||||
|
parser = SimpleJsonToolParser()
|
||||||
|
parts = [
|
||||||
|
"I'll ",
|
||||||
|
"check ",
|
||||||
|
"that. ",
|
||||||
|
'{"',
|
||||||
|
'name": "search", ',
|
||||||
|
'"arguments": {"q": "hello"}}',
|
||||||
|
]
|
||||||
|
body = ""
|
||||||
|
content_chunks = []
|
||||||
|
tool_names = []
|
||||||
|
for part in parts:
|
||||||
|
body += part
|
||||||
|
deltas = parser.feed(body)
|
||||||
|
for d in deltas:
|
||||||
|
if "content" in d:
|
||||||
|
content_chunks.append(d["content"])
|
||||||
|
if "tool_calls" in d:
|
||||||
|
for tc in d["tool_calls"]:
|
||||||
|
fn = tc.get("function", {})
|
||||||
|
if "name" in fn:
|
||||||
|
tool_names.append(fn["name"])
|
||||||
|
full_content = "".join(content_chunks)
|
||||||
|
assert "I'll check that." in full_content
|
||||||
|
assert "search" in tool_names
|
||||||
|
|
||||||
|
|
||||||
|
def test_streaming_multiple_tool_calls_incremental():
|
||||||
|
parser = SimpleJsonToolParser()
|
||||||
|
text = '{"name": "f1", "arguments": {"a": 1}}{"name": "f2", "arguments": {"b": 2}}'
|
||||||
|
names, _ = _simulate_streaming(parser, text)
|
||||||
|
assert names[0] == "f1"
|
||||||
|
assert "f2" in names
|
||||||
|
|
||||||
|
|
||||||
|
def test_streaming_deeply_nested_args():
|
||||||
|
parser = SimpleJsonToolParser()
|
||||||
|
text = '{"name": "deep", "arguments": {"a": {"b": {"c": 42}}}}'
|
||||||
|
_, args_chunks = _simulate_streaming(parser, text)
|
||||||
|
joined = "".join(args_chunks)
|
||||||
|
assert '"c": 42' in joined
|
||||||
|
|
||||||
|
|
||||||
|
def test_streaming_args_with_unicode():
|
||||||
|
parser = SimpleJsonToolParser()
|
||||||
|
text = (
|
||||||
|
'{"name": "translate", "arguments": {"text": "\u4f60\u597d\uff0c\u4e16\u754c"}}'
|
||||||
|
)
|
||||||
|
_, args_chunks = _simulate_streaming(parser, text)
|
||||||
|
joined = "".join(args_chunks)
|
||||||
|
assert "\u4f60\u597d" in joined
|
||||||
|
|
||||||
|
|
||||||
|
def test_streaming_args_with_array():
|
||||||
|
parser = SimpleJsonToolParser()
|
||||||
|
text = '{"name": "add", "arguments": {"items": [1, 2, 3]}}'
|
||||||
|
_, args_chunks = _simulate_streaming(parser, text)
|
||||||
|
joined = "".join(args_chunks)
|
||||||
|
assert "[1, 2, 3]" in joined
|
||||||
|
|
||||||
|
|
||||||
|
def test_streaming_empty_arguments():
|
||||||
|
parser = SimpleJsonToolParser()
|
||||||
|
text = '{"name": "ping", "arguments": {}}'
|
||||||
|
deltas = parser.feed(text)
|
||||||
|
tc_deltas = [d for d in deltas if "tool_calls" in d]
|
||||||
|
assert len(tc_deltas) >= 1
|
||||||
|
name_delta = tc_deltas[0]["tool_calls"][0]
|
||||||
|
assert name_delta["function"]["name"] == "ping"
|
||||||
|
assert "arguments" in name_delta["function"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_streaming_args_diff_only_emits_new_bytes():
|
||||||
|
parser = SimpleJsonToolParser()
|
||||||
|
step1 = parser.feed('{"name": "f", "arguments": {"city": "Bei')
|
||||||
|
step2 = parser.feed('{"name": "f", "arguments": {"city": "Beijing"}}')
|
||||||
|
all_args = []
|
||||||
|
for step in (step1, step2):
|
||||||
|
for d in step:
|
||||||
|
if "tool_calls" in d:
|
||||||
|
for tc in d["tool_calls"]:
|
||||||
|
fn = tc.get("function", {})
|
||||||
|
if "arguments" in fn and fn["arguments"]:
|
||||||
|
all_args.append(fn["arguments"])
|
||||||
|
joined = "".join(all_args)
|
||||||
|
assert "city" in joined
|
||||||
|
assert "Beijing" in joined
|
||||||
|
assert joined.startswith('"city":')
|
||||||
|
assert all_args[0] != all_args[1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_streaming_distinct_tool_call_ids():
|
||||||
|
parser = SimpleJsonToolParser()
|
||||||
|
text = '{"name": "f1", "arguments": {"a": 1}}{"name": "f2", "arguments": {"b": 2}}'
|
||||||
|
all_ids = []
|
||||||
|
for i in range(1, len(text) + 1):
|
||||||
|
deltas = parser.feed(text[:i])
|
||||||
|
for d in deltas:
|
||||||
|
if "tool_calls" in d:
|
||||||
|
for tc in d["tool_calls"]:
|
||||||
|
if "id" in tc:
|
||||||
|
all_ids.append(tc["id"])
|
||||||
|
unique = list(dict.fromkeys(all_ids))
|
||||||
|
assert len(unique) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_complete_basic():
|
||||||
|
parser = SimpleJsonToolParser()
|
||||||
|
body = '{"name": "get_weather", "arguments": {"city": "Beijing"}}'
|
||||||
|
result = parser.parse_complete(body)
|
||||||
|
assert result is not None
|
||||||
|
assert result["tool_calls"][0]["function"]["name"] == "get_weather"
|
||||||
|
assert "Beijing" in result["tool_calls"][0]["function"]["arguments"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_complete_no_tool_call():
|
||||||
|
assert SimpleJsonToolParser().parse_complete("Hello world") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_complete_with_content():
|
||||||
|
parser = SimpleJsonToolParser()
|
||||||
|
result = parser.parse_complete('Prefix text. {"name": "f", "arguments": {}}')
|
||||||
|
assert result is not None
|
||||||
|
assert result["content"] == "Prefix text."
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_complete_multiple_tool_calls():
|
||||||
|
parser = SimpleJsonToolParser()
|
||||||
|
body = '{"name": "get_weather", "arguments": {"city": "Beijing"}}{"name": "get_time", "arguments": {"tz": "Asia/Shanghai"}}'
|
||||||
|
result = parser.parse_complete(body)
|
||||||
|
assert result is not None
|
||||||
|
assert len(result["tool_calls"]) == 2
|
||||||
|
assert result["tool_calls"][0]["function"]["name"] == "get_weather"
|
||||||
|
assert result["tool_calls"][1]["function"]["name"] == "get_time"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_complete_complex_real_world():
|
||||||
|
parser = SimpleJsonToolParser()
|
||||||
|
body = (
|
||||||
|
'{"name": "send_email", "arguments": {'
|
||||||
|
'"to": ["a@b.com", "c@d.com"], "cc": null, '
|
||||||
|
'"subject": "Hello World", "body": "This is a test email.", '
|
||||||
|
'"priority": 1, "attachments": false}}'
|
||||||
|
)
|
||||||
|
result = parser.parse_complete(body)
|
||||||
|
assert result is not None
|
||||||
|
tc = result["tool_calls"][0]
|
||||||
|
assert tc["function"]["name"] == "send_email"
|
||||||
|
args = tc["function"]["arguments"]
|
||||||
|
assert '"to"' in args
|
||||||
|
assert "a@b.com" in args
|
||||||
|
assert "null" in args
|
||||||
|
assert "false" in args
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_complete_content_with_multiple_tool_calls():
|
||||||
|
parser = SimpleJsonToolParser()
|
||||||
|
body = 'I will do two things. {"name": "f1", "arguments": {"a": 1}}{"name": "f2", "arguments": {"b": 2}}'
|
||||||
|
result = parser.parse_complete(body)
|
||||||
|
assert result is not None
|
||||||
|
assert result["content"] == "I will do two things."
|
||||||
|
assert len(result["tool_calls"]) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_complete_no_arguments_field():
|
||||||
|
parser = SimpleJsonToolParser()
|
||||||
|
result = parser.parse_complete('{"name": "ping"}')
|
||||||
|
assert result is not None
|
||||||
|
assert result["tool_calls"][0]["function"]["name"] == "ping"
|
||||||
|
assert result["tool_calls"][0]["function"]["arguments"] == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_complete_content_is_none_when_pure_tool_call():
|
||||||
|
parser = SimpleJsonToolParser()
|
||||||
|
result = parser.parse_complete('{"name": "f", "arguments": {"x": 1}}')
|
||||||
|
assert result is not None
|
||||||
|
assert result["content"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_complete_tool_calls_have_ids():
|
||||||
|
parser = SimpleJsonToolParser()
|
||||||
|
result = parser.parse_complete(
|
||||||
|
'{"name": "f1", "arguments": {}}{"name": "f2", "arguments": {}}'
|
||||||
|
)
|
||||||
|
assert result is not None
|
||||||
|
ids = [tc["id"] for tc in result["tool_calls"]]
|
||||||
|
assert len(ids) == 2
|
||||||
|
assert all(isinstance(i, str) and i.startswith("call_") for i in ids)
|
||||||
|
assert ids[0] != ids[1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_feed_then_parse_complete_same_instance():
|
||||||
|
parser = SimpleJsonToolParser()
|
||||||
|
parser.feed('{"name": "get_weather", "arguments": {"city": "Beijing"}}')
|
||||||
|
result = parser.parse_complete(
|
||||||
|
'{"name": "get_weather", "arguments": {"city": "Beijing"}}'
|
||||||
|
)
|
||||||
|
assert result is not None
|
||||||
|
assert result["tool_calls"][0]["function"]["name"] == "get_weather"
|
||||||
|
assert parser.has_tool_calls
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"text,matches",
|
||||||
|
[
|
||||||
|
('{"name": "f"}', True),
|
||||||
|
('{ "name" : "f"}', True),
|
||||||
|
('{"other": 1}', False),
|
||||||
|
('prefix {"name": "f", "args": {}}', True),
|
||||||
|
('{"name": "f"}', True), # match at start
|
||||||
|
(' {"name": "f"}', True),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_pattern_regex(text, matches):
|
||||||
|
result = _TOOL_CALL_HEAD_RE.search(text)
|
||||||
|
if matches:
|
||||||
|
assert result is not None
|
||||||
|
else:
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_pattern_name_at_start():
|
||||||
|
assert _TOOL_CALL_HEAD_RE.match('{"name": "f"}')
|
||||||
|
|
||||||
|
|
||||||
|
def test_factory_register_and_create():
|
||||||
|
parser = ToolParserFactory.create("simple_json")
|
||||||
|
assert isinstance(parser, BaseToolParser)
|
||||||
|
assert isinstance(parser, SimpleJsonToolParser)
|
||||||
|
|
||||||
|
|
||||||
|
def test_factory_create_passes_tools():
|
||||||
|
parser = ToolParserFactory.create(
|
||||||
|
"simple_json", tools=[{"type": "function"}], tool_choice="required"
|
||||||
|
)
|
||||||
|
assert parser.tool_choice == "required"
|
||||||
|
|
||||||
|
|
||||||
|
def test_factory_list_registered():
|
||||||
|
assert "simple_json" in ToolParserFactory.list_registered()
|
||||||
|
|
||||||
|
|
||||||
|
def test_factory_create_with_no_extra_kwargs():
|
||||||
|
assert isinstance(ToolParserFactory.create("simple_json"), BaseToolParser)
|
||||||
|
|
||||||
|
|
||||||
|
def test_factory_create_with_tools_only():
|
||||||
|
tools = [
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {"name": "test", "parameters": {"type": "object"}},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
parser = ToolParserFactory.create("simple_json", tools=tools)
|
||||||
|
assert parser.tools == tools
|
||||||
|
assert parser.tool_choice == "auto"
|
||||||
|
|
||||||
|
|
||||||
|
def test_feed_accepts_token_ids_and_ignores_them():
|
||||||
|
parser = SimpleJsonToolParser()
|
||||||
|
text = '{"name": "get_weather", "arguments": {"city": "Beijing"}}'
|
||||||
|
deltas_with = parser.feed(text, current_token_ids=[123, 456], delta_token_ids=[456])
|
||||||
|
assert len(deltas_with) > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_feed_token_ids_do_not_affect_parsing():
|
||||||
|
parser_no_ids = SimpleJsonToolParser()
|
||||||
|
parser_with_ids = SimpleJsonToolParser()
|
||||||
|
text = '{"name": "get_weather", "arguments": {"city": "Beijing"}}'
|
||||||
|
result_no = parser_no_ids.feed(text)
|
||||||
|
result_with = parser_with_ids.feed(
|
||||||
|
text, current_token_ids=[1, 2, 3], delta_token_ids=[3]
|
||||||
|
)
|
||||||
|
assert len(result_no) == len(result_with)
|
||||||
|
assert (
|
||||||
|
result_no[0]["tool_calls"][0]["function"]["name"]
|
||||||
|
== result_with[0]["tool_calls"][0]["function"]["name"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parser_uses_token_ids_for_detection():
|
||||||
|
class TokenIdParser(BaseToolParser):
|
||||||
|
def __init__(self, tools=None, tool_choice="auto"):
|
||||||
|
super().__init__(tools, tool_choice)
|
||||||
|
self._detections = 0
|
||||||
|
|
||||||
|
def feed(self, body, current_token_ids=None, delta_token_ids=None):
|
||||||
|
if current_token_ids and 999 in current_token_ids:
|
||||||
|
self._detections += 1
|
||||||
|
return []
|
||||||
|
|
||||||
|
def parse_complete(self, body):
|
||||||
|
return None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_tool_calls(self):
|
||||||
|
return self._detections > 0
|
||||||
|
|
||||||
|
parser = TokenIdParser()
|
||||||
|
parser.feed("hello", current_token_ids=[1, 999, 3])
|
||||||
|
assert parser.has_tool_calls
|
||||||
@@ -1,6 +1,13 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import safetensors.torch as st
|
||||||
import torch
|
import torch
|
||||||
|
|
||||||
from astrai.config.model_config import EncoderConfig
|
from astrai.config.model_config import EncoderConfig
|
||||||
|
from astrai.model.automodel import AutoModel
|
||||||
from astrai.model.encoder import EmbeddingEncoder
|
from astrai.model.encoder import EmbeddingEncoder
|
||||||
|
|
||||||
TINY_CONFIG = dict(
|
TINY_CONFIG = dict(
|
||||||
@@ -14,92 +21,56 @@ TINY_CONFIG = dict(
|
|||||||
norm_eps=1e-5,
|
norm_eps=1e-5,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_device = "cuda" if torch.cuda.is_available() else "cpu"
|
||||||
|
|
||||||
def test_encoder_forward_mean():
|
|
||||||
config = EncoderConfig(**TINY_CONFIG)
|
def _make_model(**kwargs):
|
||||||
device = "cuda" if torch.cuda.is_available() else "cpu"
|
config = EncoderConfig(**{**TINY_CONFIG, **kwargs})
|
||||||
model = EmbeddingEncoder(config).to(device=device)
|
return EmbeddingEncoder(config).to(device=_device)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("pooling_type", ["mean", "cls", "last"])
|
||||||
|
def test_encoder_forward_pooling(pooling_type):
|
||||||
|
model = _make_model(pooling_type=pooling_type)
|
||||||
model.eval()
|
model.eval()
|
||||||
|
|
||||||
batch_size, seq_len = 2, 8
|
batch_size, seq_len = 2, 8
|
||||||
input_ids = torch.randint(
|
input_ids = torch.randint(
|
||||||
0, config.vocab_size, (batch_size, seq_len), device=device
|
0, TINY_CONFIG["vocab_size"], (batch_size, seq_len), device=_device
|
||||||
)
|
)
|
||||||
|
|
||||||
with torch.no_grad():
|
with torch.no_grad():
|
||||||
output = model(input_ids)
|
output = model(input_ids)
|
||||||
|
|
||||||
assert output.shape == (batch_size, config.dim)
|
assert output.shape == (batch_size, TINY_CONFIG["dim"])
|
||||||
assert not torch.isnan(output).any()
|
|
||||||
|
|
||||||
|
|
||||||
def test_encoder_forward_cls():
|
|
||||||
config = EncoderConfig(**{**TINY_CONFIG, "pooling_type": "cls"})
|
|
||||||
device = "cuda" if torch.cuda.is_available() else "cpu"
|
|
||||||
model = EmbeddingEncoder(config).to(device=device)
|
|
||||||
model.eval()
|
|
||||||
|
|
||||||
batch_size, seq_len = 2, 8
|
|
||||||
input_ids = torch.randint(
|
|
||||||
0, config.vocab_size, (batch_size, seq_len), device=device
|
|
||||||
)
|
|
||||||
|
|
||||||
with torch.no_grad():
|
|
||||||
output = model(input_ids)
|
|
||||||
|
|
||||||
assert output.shape == (batch_size, config.dim)
|
|
||||||
assert not torch.isnan(output).any()
|
|
||||||
|
|
||||||
|
|
||||||
def test_encoder_forward_last():
|
|
||||||
config = EncoderConfig(**{**TINY_CONFIG, "pooling_type": "last"})
|
|
||||||
device = "cuda" if torch.cuda.is_available() else "cpu"
|
|
||||||
model = EmbeddingEncoder(config).to(device=device)
|
|
||||||
model.eval()
|
|
||||||
|
|
||||||
batch_size, seq_len = 2, 8
|
|
||||||
input_ids = torch.randint(
|
|
||||||
0, config.vocab_size, (batch_size, seq_len), device=device
|
|
||||||
)
|
|
||||||
|
|
||||||
with torch.no_grad():
|
|
||||||
output = model(input_ids)
|
|
||||||
|
|
||||||
assert output.shape == (batch_size, config.dim)
|
|
||||||
assert not torch.isnan(output).any()
|
assert not torch.isnan(output).any()
|
||||||
|
|
||||||
|
|
||||||
def test_encoder_forward_with_padding():
|
def test_encoder_forward_with_padding():
|
||||||
config = EncoderConfig(**TINY_CONFIG)
|
model = _make_model()
|
||||||
device = "cuda" if torch.cuda.is_available() else "cpu"
|
|
||||||
model = EmbeddingEncoder(config).to(device=device)
|
|
||||||
model.eval()
|
model.eval()
|
||||||
|
|
||||||
batch_size, seq_len = 2, 8
|
batch_size, seq_len = 2, 8
|
||||||
input_ids = torch.randint(
|
input_ids = torch.randint(
|
||||||
0, config.vocab_size, (batch_size, seq_len), device=device
|
0, TINY_CONFIG["vocab_size"], (batch_size, seq_len), device=_device
|
||||||
)
|
)
|
||||||
input_mask = torch.ones(batch_size, seq_len, dtype=torch.bool, device=device)
|
input_mask = torch.ones(batch_size, seq_len, dtype=torch.bool, device=_device)
|
||||||
input_mask[:, 4:] = False
|
input_mask[:, 4:] = False
|
||||||
|
|
||||||
with torch.no_grad():
|
with torch.no_grad():
|
||||||
output = model(input_ids, input_mask=input_mask)
|
output = model(input_ids, input_mask=input_mask)
|
||||||
|
|
||||||
assert output.shape == (batch_size, config.dim)
|
assert output.shape == (batch_size, TINY_CONFIG["dim"])
|
||||||
assert not torch.isnan(output).any()
|
assert not torch.isnan(output).any()
|
||||||
|
|
||||||
|
|
||||||
def test_encoder_normalize():
|
def test_encoder_normalize():
|
||||||
config = EncoderConfig(
|
model = _make_model(pooling_type="mean", normalize_embeddings=True)
|
||||||
**{**TINY_CONFIG, "pooling_type": "mean", "normalize_embeddings": True}
|
|
||||||
)
|
|
||||||
device = "cuda" if torch.cuda.is_available() else "cpu"
|
|
||||||
model = EmbeddingEncoder(config).to(device=device)
|
|
||||||
model.eval()
|
model.eval()
|
||||||
|
|
||||||
batch_size, seq_len = 2, 8
|
batch_size, seq_len = 2, 8
|
||||||
input_ids = torch.randint(
|
input_ids = torch.randint(
|
||||||
0, config.vocab_size, (batch_size, seq_len), device=device
|
0, TINY_CONFIG["vocab_size"], (batch_size, seq_len), device=_device
|
||||||
)
|
)
|
||||||
|
|
||||||
with torch.no_grad():
|
with torch.no_grad():
|
||||||
@@ -110,24 +81,19 @@ def test_encoder_normalize():
|
|||||||
|
|
||||||
|
|
||||||
def test_encoder_register():
|
def test_encoder_register():
|
||||||
from astrai.model.automodel import AutoModel
|
|
||||||
|
|
||||||
assert AutoModel.is_registered("embedding")
|
assert AutoModel.is_registered("embedding")
|
||||||
cls = AutoModel.get_component_class("embedding")
|
cls = AutoModel.get_component_class("embedding")
|
||||||
assert cls is EmbeddingEncoder
|
assert cls is EmbeddingEncoder
|
||||||
|
|
||||||
|
|
||||||
def test_encoder_from_transformer_checkpoint():
|
def test_encoder_from_transformer_checkpoint():
|
||||||
config = EncoderConfig(**TINY_CONFIG)
|
model = _make_model()
|
||||||
device = "cuda" if torch.cuda.is_available() else "cpu"
|
|
||||||
model = EmbeddingEncoder(config).to(device=device)
|
|
||||||
|
|
||||||
state_dict = model.state_dict()
|
state_dict = model.state_dict()
|
||||||
state_dict["lm_head.weight"] = torch.randn(
|
state_dict["lm_head.weight"] = torch.randn(
|
||||||
config.vocab_size, config.dim, device=device
|
TINY_CONFIG["vocab_size"], TINY_CONFIG["dim"], device=_device
|
||||||
)
|
)
|
||||||
|
|
||||||
new_model = EmbeddingEncoder(config).to(device=device)
|
new_model = _make_model()
|
||||||
new_model.load_state_dict(state_dict, strict=True)
|
new_model.load_state_dict(state_dict, strict=True)
|
||||||
|
|
||||||
for key in model.state_dict():
|
for key in model.state_dict():
|
||||||
@@ -135,12 +101,6 @@ def test_encoder_from_transformer_checkpoint():
|
|||||||
|
|
||||||
|
|
||||||
def test_encoder_save_load():
|
def test_encoder_save_load():
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import tempfile
|
|
||||||
|
|
||||||
import safetensors.torch as st
|
|
||||||
|
|
||||||
test_dir = tempfile.mkdtemp(prefix="encoder_test_")
|
test_dir = tempfile.mkdtemp(prefix="encoder_test_")
|
||||||
config_path = os.path.join(test_dir, "config.json")
|
config_path = os.path.join(test_dir, "config.json")
|
||||||
weights_path = os.path.join(test_dir, "model.safetensors")
|
weights_path = os.path.join(test_dir, "model.safetensors")
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ def create_train_config(
|
|||||||
|
|
||||||
def scheduler_fn(optim):
|
def scheduler_fn(optim):
|
||||||
return SchedulerFactory.create(
|
return SchedulerFactory.create(
|
||||||
optim, "cosine", warmup_steps=10, lr_decay_steps=10, min_rate=0.05
|
"cosine", optim, warmup_steps=10, lr_decay_steps=10, min_rate=0.05
|
||||||
)
|
)
|
||||||
|
|
||||||
return TrainConfig(
|
return TrainConfig(
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ def test_gradient_checkpointing_trainer_integration(base_test_env, random_datase
|
|||||||
|
|
||||||
def scheduler_fn(optim):
|
def scheduler_fn(optim):
|
||||||
return SchedulerFactory.create(
|
return SchedulerFactory.create(
|
||||||
optim, "cosine", warmup_steps=10, lr_decay_steps=10, min_rate=0.05
|
"cosine", optim, warmup_steps=10, lr_decay_steps=10, min_rate=0.05
|
||||||
)
|
)
|
||||||
|
|
||||||
train_config = TrainConfig(
|
train_config = TrainConfig(
|
||||||
@@ -136,7 +136,7 @@ def test_callback_integration(base_test_env, random_dataset):
|
|||||||
|
|
||||||
def scheduler_fn(optim):
|
def scheduler_fn(optim):
|
||||||
return SchedulerFactory.create(
|
return SchedulerFactory.create(
|
||||||
optim, "cosine", warmup_steps=10, lr_decay_steps=10, min_rate=0.05
|
"cosine", optim, warmup_steps=10, lr_decay_steps=10, min_rate=0.05
|
||||||
)
|
)
|
||||||
|
|
||||||
train_config = TrainConfig(
|
train_config = TrainConfig(
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ def test_early_stopping_simulation(base_test_env, early_stopping_dataset):
|
|||||||
|
|
||||||
def scheduler_fn(optim):
|
def scheduler_fn(optim):
|
||||||
return SchedulerFactory.create(
|
return SchedulerFactory.create(
|
||||||
optim, "cosine", warmup_steps=10, lr_decay_steps=10, min_rate=0.05
|
"cosine", optim, warmup_steps=10, lr_decay_steps=10, min_rate=0.05
|
||||||
)
|
)
|
||||||
|
|
||||||
train_config = TrainConfig(
|
train_config = TrainConfig(
|
||||||
|
|||||||
@@ -36,8 +36,8 @@ def test_schedule_factory_random_configs():
|
|||||||
min_rate = params["min_rate"]
|
min_rate = params["min_rate"]
|
||||||
lr_decay_steps = total_steps - warmup_steps
|
lr_decay_steps = total_steps - warmup_steps
|
||||||
scheduler = SchedulerFactory.create(
|
scheduler = SchedulerFactory.create(
|
||||||
optimizer,
|
|
||||||
schedule_type,
|
schedule_type,
|
||||||
|
optimizer,
|
||||||
warmup_steps=warmup_steps,
|
warmup_steps=warmup_steps,
|
||||||
lr_decay_steps=lr_decay_steps,
|
lr_decay_steps=lr_decay_steps,
|
||||||
min_rate=min_rate,
|
min_rate=min_rate,
|
||||||
@@ -52,8 +52,8 @@ def test_schedule_factory_random_configs():
|
|||||||
t_mult = params["t_mult"]
|
t_mult = params["t_mult"]
|
||||||
min_rate = params["min_rate"]
|
min_rate = params["min_rate"]
|
||||||
scheduler = SchedulerFactory.create(
|
scheduler = SchedulerFactory.create(
|
||||||
optimizer,
|
|
||||||
schedule_type,
|
schedule_type,
|
||||||
|
optimizer,
|
||||||
warmup_steps=warmup_steps,
|
warmup_steps=warmup_steps,
|
||||||
cycle_length=cycle_length,
|
cycle_length=cycle_length,
|
||||||
t_mult=t_mult,
|
t_mult=t_mult,
|
||||||
@@ -103,8 +103,8 @@ def test_schedule_factory_edge_cases():
|
|||||||
min_rate = params["min_rate"]
|
min_rate = params["min_rate"]
|
||||||
lr_decay_steps = total_steps - warmup_steps
|
lr_decay_steps = total_steps - warmup_steps
|
||||||
scheduler = SchedulerFactory.create(
|
scheduler = SchedulerFactory.create(
|
||||||
optimizer,
|
|
||||||
"cosine",
|
"cosine",
|
||||||
|
optimizer,
|
||||||
warmup_steps=warmup_steps,
|
warmup_steps=warmup_steps,
|
||||||
lr_decay_steps=lr_decay_steps,
|
lr_decay_steps=lr_decay_steps,
|
||||||
min_rate=min_rate,
|
min_rate=min_rate,
|
||||||
@@ -129,8 +129,8 @@ def test_schedule_factory_state_persistence():
|
|||||||
min_rate = 0.1
|
min_rate = 0.1
|
||||||
lr_decay_steps = total_steps - warmup_steps
|
lr_decay_steps = total_steps - warmup_steps
|
||||||
scheduler = SchedulerFactory.create(
|
scheduler = SchedulerFactory.create(
|
||||||
optimizer,
|
|
||||||
"cosine",
|
"cosine",
|
||||||
|
optimizer,
|
||||||
warmup_steps=warmup_steps,
|
warmup_steps=warmup_steps,
|
||||||
lr_decay_steps=lr_decay_steps,
|
lr_decay_steps=lr_decay_steps,
|
||||||
min_rate=min_rate,
|
min_rate=min_rate,
|
||||||
@@ -146,8 +146,8 @@ def test_schedule_factory_state_persistence():
|
|||||||
|
|
||||||
# Create new scheduler with same parameters
|
# Create new scheduler with same parameters
|
||||||
new_scheduler = SchedulerFactory.create(
|
new_scheduler = SchedulerFactory.create(
|
||||||
optimizer,
|
|
||||||
"cosine",
|
"cosine",
|
||||||
|
optimizer,
|
||||||
warmup_steps=warmup_steps,
|
warmup_steps=warmup_steps,
|
||||||
lr_decay_steps=lr_decay_steps,
|
lr_decay_steps=lr_decay_steps,
|
||||||
min_rate=min_rate,
|
min_rate=min_rate,
|
||||||
|
|||||||
Reference in New Issue
Block a user