Initial upload: ViuAI-1M architecture (1.18M params)
Browse files- README.md +141 -0
- configs/viuai_1m.json +31 -0
- requirements.txt +3 -0
- scripts/chat.py +57 -0
- scripts/prepare_data.py +285 -0
- scripts/train.py +166 -0
- setup.py +20 -0
- viuai/__init__.py +14 -0
- viuai/inference/__init__.py +3 -0
- viuai/inference/chat.py +194 -0
- viuai/model/__init__.py +4 -0
- viuai/model/attention.py +154 -0
- viuai/model/config.py +100 -0
- viuai/model/layers.py +68 -0
- viuai/model/transformer.py +77 -0
- viuai/model/viuai_model.py +291 -0
- viuai/tokenizer/__init__.py +3 -0
- viuai/tokenizer/bpe_tokenizer.py +349 -0
- viuai/training/__init__.py +4 -0
- viuai/training/dataset.py +132 -0
- viuai/training/trainer.py +346 -0
README.md
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
license: apache-2.0
|
| 3 |
+
language:
|
| 4 |
+
- en
|
| 5 |
+
tags:
|
| 6 |
+
- text-generation
|
| 7 |
+
- transformer
|
| 8 |
+
- from-scratch
|
| 9 |
+
- research
|
| 10 |
+
- viuai
|
| 11 |
+
- custom-architecture
|
| 12 |
+
library_name: pytorch
|
| 13 |
+
pipeline_tag: text-generation
|
| 14 |
+
model-index:
|
| 15 |
+
- name: ViuAI-1M
|
| 16 |
+
results: []
|
| 17 |
+
---
|
| 18 |
+
|
| 19 |
+
# 🚀 ViuAI-1M
|
| 20 |
+
|
| 21 |
+
**A lightweight ~1.18M parameter chat language model built entirely from scratch.**
|
| 22 |
+
|
| 23 |
+
ViuAI-1M is a research-grade decoder-only transformer designed for learning and experimentation. Every component — from the BPE tokenizer to the attention mechanism — is custom-built with modern innovations.
|
| 24 |
+
|
| 25 |
+
## ✨ Architecture Highlights
|
| 26 |
+
|
| 27 |
+
| Component | Innovation | Benefit |
|
| 28 |
+
|---|---|---|
|
| 29 |
+
| **Attention** | Rotary Position Embeddings (RoPE) | Better length generalization |
|
| 30 |
+
| **FFN** | SwiGLU activation | More expressive than ReLU/GELU |
|
| 31 |
+
| **Normalization** | RMSNorm (Pre-Norm) | Faster & more stable training |
|
| 32 |
+
| **Embeddings** | Weight-tied input/output | Fewer parameters |
|
| 33 |
+
| **Tokenizer** | Custom BPE from scratch | Full vocabulary control |
|
| 34 |
+
|
| 35 |
+
## 📊 Model Specifications
|
| 36 |
+
|
| 37 |
+
| Spec | Value |
|
| 38 |
+
|---|---|
|
| 39 |
+
| Parameters | **1,180,800 (~1.18M)** |
|
| 40 |
+
| Hidden Size | 128 |
|
| 41 |
+
| Layers | 4 |
|
| 42 |
+
| Attention Heads | 4 |
|
| 43 |
+
| Head Dimension | 32 |
|
| 44 |
+
| FFN (SwiGLU) | 256 |
|
| 45 |
+
| Context Window | 512 tokens |
|
| 46 |
+
| Vocabulary | 4,096 (BPE) |
|
| 47 |
+
| Model Size | ~4.5 MB |
|
| 48 |
+
|
| 49 |
+
## 🏗️ Architecture
|
| 50 |
+
|
| 51 |
+
```
|
| 52 |
+
Input Token IDs
|
| 53 |
+
→ Token Embedding (4096 × 128)
|
| 54 |
+
→ Transformer Block ×4
|
| 55 |
+
├── RMSNorm → Multi-Head Attention (RoPE) → Residual
|
| 56 |
+
└── RMSNorm → SwiGLU FFN → Residual
|
| 57 |
+
→ Final RMSNorm
|
| 58 |
+
→ LM Head (weight-tied)
|
| 59 |
+
→ Output Logits
|
| 60 |
+
```
|
| 61 |
+
|
| 62 |
+
## 💬 Chat Format
|
| 63 |
+
|
| 64 |
+
```
|
| 65 |
+
<bos><|system|>System prompt<|user|>User message<|assistant|>Response<eos>
|
| 66 |
+
```
|
| 67 |
+
|
| 68 |
+
## 🚀 Quick Start
|
| 69 |
+
|
| 70 |
+
```python
|
| 71 |
+
import torch
|
| 72 |
+
from viuai.model.config import ViuAIConfig
|
| 73 |
+
from viuai.model.viuai_model import ViuAIForCausalLM
|
| 74 |
+
from viuai.tokenizer.bpe_tokenizer import ViuAITokenizer
|
| 75 |
+
|
| 76 |
+
# Load config and model
|
| 77 |
+
config = ViuAIConfig()
|
| 78 |
+
model = ViuAIForCausalLM(config)
|
| 79 |
+
print(f"Parameters: {model.count_parameters():,}")
|
| 80 |
+
|
| 81 |
+
# Train tokenizer on your data
|
| 82 |
+
tokenizer = ViuAITokenizer(vocab_size=4096)
|
| 83 |
+
tokenizer.train(["your training texts here"])
|
| 84 |
+
|
| 85 |
+
# Training
|
| 86 |
+
optimizer = torch.optim.AdamW(model.parameters(), lr=3e-4)
|
| 87 |
+
```
|
| 88 |
+
|
| 89 |
+
## 📁 Repository Structure
|
| 90 |
+
|
| 91 |
+
```
|
| 92 |
+
├── viuai/
|
| 93 |
+
│ ├── model/
|
| 94 |
+
│ │ ├── config.py # Model configuration
|
| 95 |
+
│ │ ├── attention.py # Multi-Head Attention + RoPE
|
| 96 |
+
│ │ ├── layers.py # RMSNorm + SwiGLU
|
| 97 |
+
│ │ ├── transformer.py # Transformer block
|
| 98 |
+
│ │ └── viuai_model.py # Full model + generation
|
| 99 |
+
│ ├── tokenizer/
|
| 100 |
+
│ │ └── bpe_tokenizer.py # Custom BPE tokenizer
|
| 101 |
+
│ ├── training/
|
| 102 |
+
│ │ ├── dataset.py # Dataset & data collation
|
| 103 |
+
│ │ └── trainer.py # Training loop & scheduler
|
| 104 |
+
│ └── inference/
|
| 105 |
+
│ └── chat.py # Chat inference engine
|
| 106 |
+
├── configs/
|
| 107 |
+
│ └── viuai_1m.json # Model hyperparameters
|
| 108 |
+
├── scripts/
|
| 109 |
+
│ ├── prepare_data.py # Data preparation
|
| 110 |
+
│ ├── train.py # Training script
|
| 111 |
+
│ └── chat.py # Chat interface
|
| 112 |
+
└── requirements.txt
|
| 113 |
+
```
|
| 114 |
+
|
| 115 |
+
## 📈 Training
|
| 116 |
+
|
| 117 |
+
```bash
|
| 118 |
+
# 1. Prepare data
|
| 119 |
+
python scripts/prepare_data.py
|
| 120 |
+
|
| 121 |
+
# 2. Train the model
|
| 122 |
+
python scripts/train.py --epochs 20 --batch_size 32 --lr 3e-4
|
| 123 |
+
|
| 124 |
+
# 3. Chat with the model
|
| 125 |
+
python scripts/chat.py --checkpoint output/final --tokenizer output/tokenizer
|
| 126 |
+
```
|
| 127 |
+
|
| 128 |
+
## 🔗 Related Models
|
| 129 |
+
|
| 130 |
+
- [ViuAI-500M](https://huggingface.co/ViuAI/ViuAT-500M) — 500M parameter model
|
| 131 |
+
- [Sarus-500M](https://huggingface.co/ViuAI/Sarus-500M) — 500M text generation
|
| 132 |
+
- [ViuMini-Dense-360M](https://huggingface.co/ViuAI/ViuMini-Dense-360M) — Dense 360M model
|
| 133 |
+
- [Viu-1.5B-MoE](https://huggingface.co/ViuAI/Viu-1.5B-MoE) — 1.5B Mixture of Experts
|
| 134 |
+
|
| 135 |
+
## 📄 License
|
| 136 |
+
|
| 137 |
+
Apache 2.0
|
| 138 |
+
|
| 139 |
+
---
|
| 140 |
+
|
| 141 |
+
**Built with ❤️ by [ViuAI](https://huggingface.co/ViuAI)**
|
configs/viuai_1m.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"model_name": "ViuAI-1M",
|
| 3 |
+
"version": "0.1.0",
|
| 4 |
+
"architecture": {
|
| 5 |
+
"vocab_size": 4096,
|
| 6 |
+
"hidden_size": 128,
|
| 7 |
+
"num_layers": 4,
|
| 8 |
+
"num_heads": 4,
|
| 9 |
+
"head_dim": 32,
|
| 10 |
+
"intermediate_size": 256,
|
| 11 |
+
"max_seq_len": 512,
|
| 12 |
+
"dropout": 0.1,
|
| 13 |
+
"rope_theta": 10000.0,
|
| 14 |
+
"rms_norm_eps": 1e-6,
|
| 15 |
+
"tie_word_embeddings": true
|
| 16 |
+
},
|
| 17 |
+
"special_tokens": {
|
| 18 |
+
"pad_token_id": 0,
|
| 19 |
+
"bos_token_id": 1,
|
| 20 |
+
"eos_token_id": 2
|
| 21 |
+
},
|
| 22 |
+
"training": {
|
| 23 |
+
"learning_rate": 3e-4,
|
| 24 |
+
"weight_decay": 0.01,
|
| 25 |
+
"warmup_steps": 100,
|
| 26 |
+
"max_grad_norm": 1.0,
|
| 27 |
+
"num_epochs": 10,
|
| 28 |
+
"batch_size": 32,
|
| 29 |
+
"eval_split": 0.1
|
| 30 |
+
}
|
| 31 |
+
}
|
requirements.txt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ViuAI-1M Requirements
|
| 2 |
+
torch>=2.0.0
|
| 3 |
+
numpy>=1.24.0
|
scripts/chat.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
ViuAI-1M Chat Script.
|
| 3 |
+
|
| 4 |
+
Start an interactive chat session with a trained ViuAI model.
|
| 5 |
+
|
| 6 |
+
Usage:
|
| 7 |
+
python scripts/chat.py --checkpoint output/final --tokenizer output/tokenizer
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import argparse
|
| 11 |
+
import os
|
| 12 |
+
import sys
|
| 13 |
+
|
| 14 |
+
# Add project root to path
|
| 15 |
+
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 16 |
+
sys.path.insert(0, project_root)
|
| 17 |
+
|
| 18 |
+
from viuai.inference.chat import load_model_for_chat
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def parse_args():
|
| 22 |
+
parser = argparse.ArgumentParser(description="Chat with ViuAI-1M")
|
| 23 |
+
parser.add_argument("--checkpoint", type=str, default="output/final",
|
| 24 |
+
help="Path to model checkpoint directory")
|
| 25 |
+
parser.add_argument("--tokenizer", type=str, default="output/tokenizer",
|
| 26 |
+
help="Path to tokenizer directory")
|
| 27 |
+
parser.add_argument("--device", type=str, default=None,
|
| 28 |
+
help="Device (cuda/cpu)")
|
| 29 |
+
parser.add_argument("--temperature", type=float, default=0.8,
|
| 30 |
+
help="Sampling temperature")
|
| 31 |
+
parser.add_argument("--max_tokens", type=int, default=128,
|
| 32 |
+
help="Maximum tokens to generate")
|
| 33 |
+
return parser.parse_args()
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def main():
|
| 37 |
+
args = parse_args()
|
| 38 |
+
|
| 39 |
+
checkpoint_path = os.path.join(project_root, args.checkpoint)
|
| 40 |
+
tokenizer_path = os.path.join(project_root, args.tokenizer)
|
| 41 |
+
|
| 42 |
+
print(f"\n Loading ViuAI-1M...")
|
| 43 |
+
print(f" Checkpoint: {checkpoint_path}")
|
| 44 |
+
print(f" Tokenizer: {tokenizer_path}")
|
| 45 |
+
|
| 46 |
+
engine = load_model_for_chat(
|
| 47 |
+
checkpoint_dir=checkpoint_path,
|
| 48 |
+
tokenizer_dir=tokenizer_path,
|
| 49 |
+
device=args.device,
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
# Start interactive chat
|
| 53 |
+
engine.interactive()
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
if __name__ == "__main__":
|
| 57 |
+
main()
|
scripts/prepare_data.py
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
ViuAI-1M Data Preparation Script.
|
| 3 |
+
|
| 4 |
+
Downloads and prepares a sample conversation dataset for training.
|
| 5 |
+
Creates JSONL files in the ViuAI chat format.
|
| 6 |
+
|
| 7 |
+
Usage:
|
| 8 |
+
python scripts/prepare_data.py
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import json
|
| 12 |
+
import os
|
| 13 |
+
import random
|
| 14 |
+
|
| 15 |
+
# Output paths
|
| 16 |
+
DATA_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data")
|
| 17 |
+
TRAIN_PATH = os.path.join(DATA_DIR, "train.jsonl")
|
| 18 |
+
EVAL_PATH = os.path.join(DATA_DIR, "eval.jsonl")
|
| 19 |
+
TOKENIZER_CORPUS_PATH = os.path.join(DATA_DIR, "tokenizer_corpus.txt")
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def create_sample_conversations():
|
| 23 |
+
"""Create a sample conversation dataset for initial testing.
|
| 24 |
+
|
| 25 |
+
In production, replace this with real conversation data from:
|
| 26 |
+
- OpenAssistant Conversations (https://huggingface.co/datasets/OpenAssistant/oasst1)
|
| 27 |
+
- ShareGPT (https://huggingface.co/datasets/anon8231489123/ShareGPT_Vicuna_unfiltered)
|
| 28 |
+
- Alpaca (https://github.com/tatsu-lab/stanford_alpaca)
|
| 29 |
+
- Dolly (https://huggingface.co/datasets/databricks/databricks-dolly-15k)
|
| 30 |
+
"""
|
| 31 |
+
conversations = [
|
| 32 |
+
# Greetings
|
| 33 |
+
{"messages": [
|
| 34 |
+
{"role": "user", "content": "hello"},
|
| 35 |
+
{"role": "assistant", "content": "hello! how can i help you today?"}
|
| 36 |
+
]},
|
| 37 |
+
{"messages": [
|
| 38 |
+
{"role": "user", "content": "hi there"},
|
| 39 |
+
{"role": "assistant", "content": "hi! nice to meet you. what would you like to talk about?"}
|
| 40 |
+
]},
|
| 41 |
+
{"messages": [
|
| 42 |
+
{"role": "user", "content": "hey"},
|
| 43 |
+
{"role": "assistant", "content": "hey! how are you doing today?"}
|
| 44 |
+
]},
|
| 45 |
+
{"messages": [
|
| 46 |
+
{"role": "user", "content": "good morning"},
|
| 47 |
+
{"role": "assistant", "content": "good morning! hope you have a wonderful day. how can i assist you?"}
|
| 48 |
+
]},
|
| 49 |
+
|
| 50 |
+
# General knowledge
|
| 51 |
+
{"messages": [
|
| 52 |
+
{"role": "user", "content": "what is machine learning?"},
|
| 53 |
+
{"role": "assistant", "content": "machine learning is a type of artificial intelligence where computers learn from data to make predictions or decisions without being explicitly programmed for every task."}
|
| 54 |
+
]},
|
| 55 |
+
{"messages": [
|
| 56 |
+
{"role": "user", "content": "what is python?"},
|
| 57 |
+
{"role": "assistant", "content": "python is a popular programming language known for being easy to read and write. it is widely used for web development, data science, machine learning, and automation."}
|
| 58 |
+
]},
|
| 59 |
+
{"messages": [
|
| 60 |
+
{"role": "user", "content": "explain neural networks"},
|
| 61 |
+
{"role": "assistant", "content": "neural networks are computing systems inspired by the human brain. they consist of layers of connected nodes that process information. each connection has a weight that adjusts during training to improve accuracy."}
|
| 62 |
+
]},
|
| 63 |
+
{"messages": [
|
| 64 |
+
{"role": "user", "content": "what is deep learning?"},
|
| 65 |
+
{"role": "assistant", "content": "deep learning is a subset of machine learning that uses neural networks with many layers. it excels at tasks like image recognition, natural language processing, and speech recognition."}
|
| 66 |
+
]},
|
| 67 |
+
{"messages": [
|
| 68 |
+
{"role": "user", "content": "what is artificial intelligence?"},
|
| 69 |
+
{"role": "assistant", "content": "artificial intelligence is the simulation of human intelligence by machines. it includes learning, reasoning, problem solving, perception, and language understanding."}
|
| 70 |
+
]},
|
| 71 |
+
|
| 72 |
+
# Coding help
|
| 73 |
+
{"messages": [
|
| 74 |
+
{"role": "user", "content": "how do i print hello world in python?"},
|
| 75 |
+
{"role": "assistant", "content": "you can print hello world in python by writing: print('hello world'). this will display the text on your screen."}
|
| 76 |
+
]},
|
| 77 |
+
{"messages": [
|
| 78 |
+
{"role": "user", "content": "what is a variable in programming?"},
|
| 79 |
+
{"role": "assistant", "content": "a variable is a named container that stores data in a program. you can think of it like a labeled box that holds a value. for example, in python: x = 5 creates a variable named x with the value 5."}
|
| 80 |
+
]},
|
| 81 |
+
{"messages": [
|
| 82 |
+
{"role": "user", "content": "what is a function?"},
|
| 83 |
+
{"role": "assistant", "content": "a function is a reusable block of code that performs a specific task. you define it once and can call it many times. this helps organize code and avoid repetition."}
|
| 84 |
+
]},
|
| 85 |
+
{"messages": [
|
| 86 |
+
{"role": "user", "content": "how do loops work?"},
|
| 87 |
+
{"role": "assistant", "content": "loops repeat a block of code multiple times. a for loop runs a specific number of times, while a while loop runs until a condition becomes false. they help automate repetitive tasks."}
|
| 88 |
+
]},
|
| 89 |
+
|
| 90 |
+
# Math
|
| 91 |
+
{"messages": [
|
| 92 |
+
{"role": "user", "content": "what is 2 plus 2?"},
|
| 93 |
+
{"role": "assistant", "content": "2 plus 2 equals 4."}
|
| 94 |
+
]},
|
| 95 |
+
{"messages": [
|
| 96 |
+
{"role": "user", "content": "what is the square root of 144?"},
|
| 97 |
+
{"role": "assistant", "content": "the square root of 144 is 12."}
|
| 98 |
+
]},
|
| 99 |
+
{"messages": [
|
| 100 |
+
{"role": "user", "content": "explain algebra"},
|
| 101 |
+
{"role": "assistant", "content": "algebra is a branch of mathematics that uses symbols and letters to represent numbers and quantities in formulas and equations. it helps solve problems by finding unknown values."}
|
| 102 |
+
]},
|
| 103 |
+
|
| 104 |
+
# Science
|
| 105 |
+
{"messages": [
|
| 106 |
+
{"role": "user", "content": "what is gravity?"},
|
| 107 |
+
{"role": "assistant", "content": "gravity is a force that pulls objects toward each other. on earth, it pulls everything toward the center of the planet. it is what keeps us on the ground and the moon orbiting earth."}
|
| 108 |
+
]},
|
| 109 |
+
{"messages": [
|
| 110 |
+
{"role": "user", "content": "how does the sun produce energy?"},
|
| 111 |
+
{"role": "assistant", "content": "the sun produces energy through nuclear fusion. in its core, hydrogen atoms combine to form helium, releasing enormous amounts of energy as light and heat."}
|
| 112 |
+
]},
|
| 113 |
+
{"messages": [
|
| 114 |
+
{"role": "user", "content": "what is dna?"},
|
| 115 |
+
{"role": "assistant", "content": "dna stands for deoxyribonucleic acid. it is the molecule that carries genetic instructions for the development, functioning, growth, and reproduction of all living organisms."}
|
| 116 |
+
]},
|
| 117 |
+
|
| 118 |
+
# Conversational
|
| 119 |
+
{"messages": [
|
| 120 |
+
{"role": "user", "content": "tell me a joke"},
|
| 121 |
+
{"role": "assistant", "content": "why did the programmer quit his job? because he did not get arrays! (a raise)"}
|
| 122 |
+
]},
|
| 123 |
+
{"messages": [
|
| 124 |
+
{"role": "user", "content": "what is your name?"},
|
| 125 |
+
{"role": "assistant", "content": "my name is viuai. i am a small language model built for research purposes. nice to meet you!"}
|
| 126 |
+
]},
|
| 127 |
+
{"messages": [
|
| 128 |
+
{"role": "user", "content": "who created you?"},
|
| 129 |
+
{"role": "assistant", "content": "i was created by the viuai team as a research project to explore language model architectures. i am a small model with about 1 million parameters."}
|
| 130 |
+
]},
|
| 131 |
+
{"messages": [
|
| 132 |
+
{"role": "user", "content": "can you help me?"},
|
| 133 |
+
{"role": "assistant", "content": "of course! i am here to help. please tell me what you need assistance with and i will do my best."}
|
| 134 |
+
]},
|
| 135 |
+
{"messages": [
|
| 136 |
+
{"role": "user", "content": "thank you"},
|
| 137 |
+
{"role": "assistant", "content": "you are welcome! i am glad i could help. feel free to ask me anything else."}
|
| 138 |
+
]},
|
| 139 |
+
{"messages": [
|
| 140 |
+
{"role": "user", "content": "goodbye"},
|
| 141 |
+
{"role": "assistant", "content": "goodbye! it was nice talking to you. have a great day!"}
|
| 142 |
+
]},
|
| 143 |
+
|
| 144 |
+
# Multi-turn conversations
|
| 145 |
+
{"messages": [
|
| 146 |
+
{"role": "user", "content": "i want to learn programming"},
|
| 147 |
+
{"role": "assistant", "content": "that is great! programming is a wonderful skill. what language would you like to start with? i recommend python for beginners."},
|
| 148 |
+
{"role": "user", "content": "yes python sounds good"},
|
| 149 |
+
{"role": "assistant", "content": "perfect! let us start with the basics. first, you need to install python from python.org. then you can write your first program: print('hello world'). this displays text on the screen."}
|
| 150 |
+
]},
|
| 151 |
+
{"messages": [
|
| 152 |
+
{"role": "user", "content": "explain transformers in ai"},
|
| 153 |
+
{"role": "assistant", "content": "transformers are a type of neural network architecture introduced in 2017. they use a mechanism called self-attention to process all parts of the input simultaneously, making them very efficient."},
|
| 154 |
+
{"role": "user", "content": "what is self-attention?"},
|
| 155 |
+
{"role": "assistant", "content": "self-attention allows each part of the input to look at all other parts to understand context. for example, in the sentence 'the cat sat on the mat', self-attention helps the model understand that 'cat' and 'sat' are related."}
|
| 156 |
+
]},
|
| 157 |
+
{"messages": [
|
| 158 |
+
{"role": "user", "content": "what is the weather like?"},
|
| 159 |
+
{"role": "assistant", "content": "i am sorry, i do not have access to real-time weather data. i am a text-based language model and cannot check current conditions. you can check a weather website or app for accurate information."},
|
| 160 |
+
{"role": "user", "content": "that is okay, what can you do?"},
|
| 161 |
+
{"role": "assistant", "content": "i can help with answering questions, explaining concepts, having conversations, helping with coding, math, science, and general knowledge topics. i am a small research model, so my abilities are limited compared to larger models."}
|
| 162 |
+
]},
|
| 163 |
+
|
| 164 |
+
# Technology
|
| 165 |
+
{"messages": [
|
| 166 |
+
{"role": "user", "content": "what is the internet?"},
|
| 167 |
+
{"role": "assistant", "content": "the internet is a global network of connected computers that allows people to share information, communicate, and access services. it uses protocols like tcp/ip to transmit data across the world."}
|
| 168 |
+
]},
|
| 169 |
+
{"messages": [
|
| 170 |
+
{"role": "user", "content": "how do computers work?"},
|
| 171 |
+
{"role": "assistant", "content": "computers work by processing instructions using a central processing unit (cpu). they store data in memory (ram) and on storage drives. programs tell the computer what to do step by step using binary code (ones and zeros)."}
|
| 172 |
+
]},
|
| 173 |
+
{"messages": [
|
| 174 |
+
{"role": "user", "content": "what is cloud computing?"},
|
| 175 |
+
{"role": "assistant", "content": "cloud computing is delivering computing services like servers, storage, databases, and software over the internet. instead of owning physical hardware, you rent these resources from providers like aws, google cloud, or azure."}
|
| 176 |
+
]},
|
| 177 |
+
|
| 178 |
+
# Philosophy and thinking
|
| 179 |
+
{"messages": [
|
| 180 |
+
{"role": "user", "content": "what is the meaning of life?"},
|
| 181 |
+
{"role": "assistant", "content": "the meaning of life is a deep philosophical question that has been debated for centuries. different people find meaning through relationships, work, creativity, helping others, or spiritual beliefs. what matters most is finding what gives your life purpose and fulfillment."}
|
| 182 |
+
]},
|
| 183 |
+
{"messages": [
|
| 184 |
+
{"role": "user", "content": "can machines think?"},
|
| 185 |
+
{"role": "assistant", "content": "this is a famous question posed by alan turing. current ai systems like me can process language and generate responses, but we do not truly think or feel. we pattern match based on training data. the question of machine consciousness remains open."}
|
| 186 |
+
]},
|
| 187 |
+
]
|
| 188 |
+
|
| 189 |
+
return conversations
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def augment_conversations(conversations, multiplier=5):
|
| 193 |
+
"""Create variations of conversations to increase dataset size.
|
| 194 |
+
|
| 195 |
+
Simple augmentation: slight text variations and reordering.
|
| 196 |
+
"""
|
| 197 |
+
augmented = list(conversations)
|
| 198 |
+
|
| 199 |
+
variations = {
|
| 200 |
+
"hello": ["hi", "hey", "greetings", "howdy"],
|
| 201 |
+
"thank you": ["thanks", "thank you so much", "thanks a lot", "appreciated"],
|
| 202 |
+
"goodbye": ["bye", "see you", "take care", "farewell"],
|
| 203 |
+
"what is": ["explain", "describe", "tell me about", "define"],
|
| 204 |
+
"how do": ["how can", "what is the way to", "how would"],
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
for _ in range(multiplier):
|
| 208 |
+
for conv in conversations:
|
| 209 |
+
new_messages = []
|
| 210 |
+
for msg in conv["messages"]:
|
| 211 |
+
content = msg["content"]
|
| 212 |
+
# Apply random variations
|
| 213 |
+
for original, alts in variations.items():
|
| 214 |
+
if original in content and random.random() > 0.5:
|
| 215 |
+
content = content.replace(original, random.choice(alts), 1)
|
| 216 |
+
new_messages.append({"role": msg["role"], "content": content})
|
| 217 |
+
augmented.append({"messages": new_messages})
|
| 218 |
+
|
| 219 |
+
return augmented
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
def main():
|
| 223 |
+
"""Prepare the training dataset."""
|
| 224 |
+
print("=" * 60)
|
| 225 |
+
print(" ViuAI-1M Data Preparation")
|
| 226 |
+
print("=" * 60)
|
| 227 |
+
|
| 228 |
+
os.makedirs(DATA_DIR, exist_ok=True)
|
| 229 |
+
|
| 230 |
+
# Create sample conversations
|
| 231 |
+
print("\n[1/4] Creating sample conversations...")
|
| 232 |
+
conversations = create_sample_conversations()
|
| 233 |
+
print(f" Base conversations: {len(conversations)}")
|
| 234 |
+
|
| 235 |
+
# Augment data
|
| 236 |
+
print("\n[2/4] Augmenting dataset...")
|
| 237 |
+
augmented = augment_conversations(conversations, multiplier=8)
|
| 238 |
+
random.shuffle(augmented)
|
| 239 |
+
print(f" Total conversations: {len(augmented)}")
|
| 240 |
+
|
| 241 |
+
# Split into train/eval
|
| 242 |
+
print("\n[3/4] Splitting into train/eval...")
|
| 243 |
+
split_idx = int(len(augmented) * 0.9)
|
| 244 |
+
train_data = augmented[:split_idx]
|
| 245 |
+
eval_data = augmented[split_idx:]
|
| 246 |
+
|
| 247 |
+
# Save JSONL files
|
| 248 |
+
with open(TRAIN_PATH, "w", encoding="utf-8") as f:
|
| 249 |
+
for conv in train_data:
|
| 250 |
+
f.write(json.dumps(conv, ensure_ascii=False) + "\n")
|
| 251 |
+
|
| 252 |
+
with open(EVAL_PATH, "w", encoding="utf-8") as f:
|
| 253 |
+
for conv in eval_data:
|
| 254 |
+
f.write(json.dumps(conv, ensure_ascii=False) + "\n")
|
| 255 |
+
|
| 256 |
+
print(f" Train: {len(train_data)} conversations → {TRAIN_PATH}")
|
| 257 |
+
print(f" Eval: {len(eval_data)} conversations → {EVAL_PATH}")
|
| 258 |
+
|
| 259 |
+
# Create tokenizer training corpus
|
| 260 |
+
print("\n[4/4] Creating tokenizer corpus...")
|
| 261 |
+
corpus_texts = []
|
| 262 |
+
for conv in augmented:
|
| 263 |
+
for msg in conv["messages"]:
|
| 264 |
+
corpus_texts.append(msg["content"])
|
| 265 |
+
|
| 266 |
+
with open(TOKENIZER_CORPUS_PATH, "w", encoding="utf-8") as f:
|
| 267 |
+
for text in corpus_texts:
|
| 268 |
+
f.write(text + "\n")
|
| 269 |
+
|
| 270 |
+
print(f" Corpus texts: {len(corpus_texts)} → {TOKENIZER_CORPUS_PATH}")
|
| 271 |
+
|
| 272 |
+
print(f"\n{'='*60}")
|
| 273 |
+
print(" Data preparation complete!")
|
| 274 |
+
print(f"{'='*60}")
|
| 275 |
+
print(f"\nNext steps:")
|
| 276 |
+
print(f" 1. For better results, add more conversation data to {TRAIN_PATH}")
|
| 277 |
+
print(f" 2. Recommended datasets:")
|
| 278 |
+
print(f" - OpenAssistant: huggingface.co/datasets/OpenAssistant/oasst1")
|
| 279 |
+
print(f" - Dolly: huggingface.co/datasets/databricks/databricks-dolly-15k")
|
| 280 |
+
print(f" - Alpaca: github.com/tatsu-lab/stanford_alpaca")
|
| 281 |
+
print(f" 3. Run training: python scripts/train.py")
|
| 282 |
+
|
| 283 |
+
|
| 284 |
+
if __name__ == "__main__":
|
| 285 |
+
main()
|
scripts/train.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
ViuAI-1M Training Script.
|
| 3 |
+
|
| 4 |
+
End-to-end training pipeline:
|
| 5 |
+
1. Load or prepare data
|
| 6 |
+
2. Train BPE tokenizer on the corpus
|
| 7 |
+
3. Create datasets and dataloaders
|
| 8 |
+
4. Initialize model
|
| 9 |
+
5. Train with the ViuAI trainer
|
| 10 |
+
6. Save the final model and tokenizer
|
| 11 |
+
|
| 12 |
+
Usage:
|
| 13 |
+
python scripts/train.py
|
| 14 |
+
python scripts/train.py --epochs 20 --batch_size 16 --lr 1e-4
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
import argparse
|
| 18 |
+
import os
|
| 19 |
+
import sys
|
| 20 |
+
import json
|
| 21 |
+
|
| 22 |
+
# Add project root to path
|
| 23 |
+
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 24 |
+
sys.path.insert(0, project_root)
|
| 25 |
+
|
| 26 |
+
import torch
|
| 27 |
+
from torch.utils.data import DataLoader
|
| 28 |
+
|
| 29 |
+
from viuai.model.config import ViuAIConfig
|
| 30 |
+
from viuai.model.viuai_model import ViuAIForCausalLM
|
| 31 |
+
from viuai.tokenizer.bpe_tokenizer import ViuAITokenizer
|
| 32 |
+
from viuai.training.dataset import ViuAIDataset, ChatDataCollator
|
| 33 |
+
from viuai.training.trainer import ViuAITrainer
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def parse_args():
|
| 37 |
+
parser = argparse.ArgumentParser(description="Train ViuAI-1M")
|
| 38 |
+
parser.add_argument("--config", type=str, default="configs/viuai_1m.json",
|
| 39 |
+
help="Path to model config JSON")
|
| 40 |
+
parser.add_argument("--train_data", type=str, default="data/train.jsonl",
|
| 41 |
+
help="Path to training data JSONL")
|
| 42 |
+
parser.add_argument("--eval_data", type=str, default="data/eval.jsonl",
|
| 43 |
+
help="Path to evaluation data JSONL")
|
| 44 |
+
parser.add_argument("--tokenizer_corpus", type=str, default="data/tokenizer_corpus.txt",
|
| 45 |
+
help="Path to tokenizer training corpus")
|
| 46 |
+
parser.add_argument("--output_dir", type=str, default="output",
|
| 47 |
+
help="Output directory for checkpoints and tokenizer")
|
| 48 |
+
parser.add_argument("--epochs", type=int, default=10,
|
| 49 |
+
help="Number of training epochs")
|
| 50 |
+
parser.add_argument("--batch_size", type=int, default=32,
|
| 51 |
+
help="Training batch size")
|
| 52 |
+
parser.add_argument("--lr", type=float, default=3e-4,
|
| 53 |
+
help="Learning rate")
|
| 54 |
+
parser.add_argument("--warmup_steps", type=int, default=100,
|
| 55 |
+
help="Number of warmup steps")
|
| 56 |
+
parser.add_argument("--device", type=str, default=None,
|
| 57 |
+
help="Device (cuda/cpu/mps)")
|
| 58 |
+
return parser.parse_args()
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def main():
|
| 62 |
+
args = parse_args()
|
| 63 |
+
|
| 64 |
+
print(f"\n{'='*60}")
|
| 65 |
+
print(f" ViuAI-1M Training Pipeline")
|
| 66 |
+
print(f"{'='*60}\n")
|
| 67 |
+
|
| 68 |
+
# ── Step 1: Load Config ──────────────────────────────────────
|
| 69 |
+
print("[1/6] Loading configuration...")
|
| 70 |
+
config_path = os.path.join(project_root, args.config)
|
| 71 |
+
with open(config_path, "r") as f:
|
| 72 |
+
config_data = json.load(f)
|
| 73 |
+
|
| 74 |
+
config = ViuAIConfig(**config_data["architecture"])
|
| 75 |
+
print(f" Config: {config_path}")
|
| 76 |
+
print(f" Estimated parameters: {config.num_parameters:,}")
|
| 77 |
+
|
| 78 |
+
# ── Step 2: Train Tokenizer ──────────────────────────────────
|
| 79 |
+
print(f"\n[2/6] Training BPE tokenizer...")
|
| 80 |
+
tokenizer = ViuAITokenizer(vocab_size=config.vocab_size)
|
| 81 |
+
|
| 82 |
+
corpus_path = os.path.join(project_root, args.tokenizer_corpus)
|
| 83 |
+
if os.path.exists(corpus_path):
|
| 84 |
+
with open(corpus_path, "r", encoding="utf-8") as f:
|
| 85 |
+
corpus_texts = [line.strip() for line in f if line.strip()]
|
| 86 |
+
tokenizer.train(corpus_texts, verbose=True)
|
| 87 |
+
else:
|
| 88 |
+
print(f" WARNING: Corpus not found at {corpus_path}")
|
| 89 |
+
print(f" Run 'python scripts/prepare_data.py' first!")
|
| 90 |
+
return
|
| 91 |
+
|
| 92 |
+
# Update config vocab size to match actual tokenizer
|
| 93 |
+
config.vocab_size = tokenizer.vocab_size
|
| 94 |
+
|
| 95 |
+
# Save tokenizer
|
| 96 |
+
tokenizer_dir = os.path.join(project_root, args.output_dir, "tokenizer")
|
| 97 |
+
tokenizer.save(tokenizer_dir)
|
| 98 |
+
|
| 99 |
+
# ── Step 3: Create Datasets ──────────────────────────────────
|
| 100 |
+
print(f"\n[3/6] Creating datasets...")
|
| 101 |
+
train_path = os.path.join(project_root, args.train_data)
|
| 102 |
+
eval_path = os.path.join(project_root, args.eval_data)
|
| 103 |
+
|
| 104 |
+
train_dataset = ViuAIDataset(train_path, tokenizer, max_seq_len=config.max_seq_len)
|
| 105 |
+
eval_dataset = ViuAIDataset(eval_path, tokenizer, max_seq_len=config.max_seq_len)
|
| 106 |
+
|
| 107 |
+
collator = ChatDataCollator(pad_token_id=config.pad_token_id)
|
| 108 |
+
|
| 109 |
+
train_loader = DataLoader(
|
| 110 |
+
train_dataset,
|
| 111 |
+
batch_size=args.batch_size,
|
| 112 |
+
shuffle=True,
|
| 113 |
+
collate_fn=collator,
|
| 114 |
+
num_workers=0,
|
| 115 |
+
)
|
| 116 |
+
eval_loader = DataLoader(
|
| 117 |
+
eval_dataset,
|
| 118 |
+
batch_size=args.batch_size,
|
| 119 |
+
shuffle=False,
|
| 120 |
+
collate_fn=collator,
|
| 121 |
+
num_workers=0,
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
print(f" Train batches: {len(train_loader)}")
|
| 125 |
+
print(f" Eval batches: {len(eval_loader)}")
|
| 126 |
+
|
| 127 |
+
# ── Step 4: Initialize Model ─────────────────────────────────
|
| 128 |
+
print(f"\n[4/6] Initializing ViuAI-1M model...")
|
| 129 |
+
model = ViuAIForCausalLM(config)
|
| 130 |
+
print(model.get_parameter_summary())
|
| 131 |
+
|
| 132 |
+
# ── Step 5: Train ────────────────────────────────────────────
|
| 133 |
+
print(f"\n[5/6] Starting training...")
|
| 134 |
+
checkpoint_dir = os.path.join(project_root, args.output_dir, "checkpoints")
|
| 135 |
+
|
| 136 |
+
trainer = ViuAITrainer(
|
| 137 |
+
model=model,
|
| 138 |
+
train_dataloader=train_loader,
|
| 139 |
+
eval_dataloader=eval_loader,
|
| 140 |
+
learning_rate=args.lr,
|
| 141 |
+
weight_decay=0.01,
|
| 142 |
+
warmup_steps=args.warmup_steps,
|
| 143 |
+
num_epochs=args.epochs,
|
| 144 |
+
save_dir=checkpoint_dir,
|
| 145 |
+
log_interval=10,
|
| 146 |
+
eval_interval=50,
|
| 147 |
+
device=args.device,
|
| 148 |
+
)
|
| 149 |
+
|
| 150 |
+
metrics = trainer.train()
|
| 151 |
+
|
| 152 |
+
# ── Step 6: Save Final Model ─────────────────────────────────
|
| 153 |
+
print(f"\n[6/6] Saving final model...")
|
| 154 |
+
final_dir = os.path.join(project_root, args.output_dir, "final")
|
| 155 |
+
os.makedirs(final_dir, exist_ok=True)
|
| 156 |
+
|
| 157 |
+
torch.save(model.state_dict(), os.path.join(final_dir, "model.pt"))
|
| 158 |
+
config.save(os.path.join(final_dir, "config.json"))
|
| 159 |
+
|
| 160 |
+
print(f" Model saved to: {final_dir}")
|
| 161 |
+
print(f"\n To chat with your model:")
|
| 162 |
+
print(f" python scripts/chat.py --checkpoint {final_dir} --tokenizer {tokenizer_dir}")
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
if __name__ == "__main__":
|
| 166 |
+
main()
|
setup.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from setuptools import setup, find_packages
|
| 2 |
+
|
| 3 |
+
setup(
|
| 4 |
+
name="viuai",
|
| 5 |
+
version="0.1.0",
|
| 6 |
+
description="ViuAI-1M: A lightweight chat language model built from scratch",
|
| 7 |
+
author="ViuAI Team",
|
| 8 |
+
packages=find_packages(),
|
| 9 |
+
python_requires=">=3.9",
|
| 10 |
+
install_requires=[
|
| 11 |
+
"torch>=2.0.0",
|
| 12 |
+
"numpy>=1.24.0",
|
| 13 |
+
],
|
| 14 |
+
entry_points={
|
| 15 |
+
"console_scripts": [
|
| 16 |
+
"viuai-train=scripts.train:main",
|
| 17 |
+
"viuai-chat=scripts.chat:main",
|
| 18 |
+
],
|
| 19 |
+
},
|
| 20 |
+
)
|
viuai/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
ViuAI-1M: A lightweight chat language model built from scratch.
|
| 3 |
+
|
| 4 |
+
Architecture: Decoder-only Transformer with modern innovations
|
| 5 |
+
- RMSNorm (faster than LayerNorm)
|
| 6 |
+
- Rotary Position Embeddings (RoPE)
|
| 7 |
+
- SwiGLU activation function
|
| 8 |
+
- Weight-tied embeddings
|
| 9 |
+
|
| 10 |
+
Parameters: ~1.18M
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
__version__ = "0.1.0"
|
| 14 |
+
__model_name__ = "ViuAI-1M"
|
viuai/inference/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from viuai.inference.chat import ViuAIChatEngine
|
| 2 |
+
|
| 3 |
+
__all__ = ["ViuAIChatEngine"]
|
viuai/inference/chat.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
ViuAI-1M Chat Inference Engine.
|
| 3 |
+
|
| 4 |
+
Provides a simple interactive chat interface for the trained ViuAI model.
|
| 5 |
+
Handles message formatting, tokenization, generation, and response decoding.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import torch
|
| 9 |
+
from typing import List, Dict, Optional
|
| 10 |
+
|
| 11 |
+
from viuai.model.viuai_model import ViuAIForCausalLM
|
| 12 |
+
from viuai.model.config import ViuAIConfig
|
| 13 |
+
from viuai.tokenizer.bpe_tokenizer import ViuAITokenizer
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class ViuAIChatEngine:
|
| 17 |
+
"""Chat engine for interacting with a trained ViuAI model.
|
| 18 |
+
|
| 19 |
+
Manages conversation history, formats messages using the chat template,
|
| 20 |
+
and generates responses using the model.
|
| 21 |
+
"""
|
| 22 |
+
|
| 23 |
+
def __init__(
|
| 24 |
+
self,
|
| 25 |
+
model: ViuAIForCausalLM,
|
| 26 |
+
tokenizer: ViuAITokenizer,
|
| 27 |
+
device: Optional[str] = None,
|
| 28 |
+
system_prompt: str = "You are ViuAI, a helpful and friendly AI assistant.",
|
| 29 |
+
):
|
| 30 |
+
"""Initialize the chat engine.
|
| 31 |
+
|
| 32 |
+
Args:
|
| 33 |
+
model: Trained ViuAI model.
|
| 34 |
+
tokenizer: Trained ViuAI tokenizer.
|
| 35 |
+
device: Device to run inference on.
|
| 36 |
+
system_prompt: Default system prompt for conversations.
|
| 37 |
+
"""
|
| 38 |
+
self.tokenizer = tokenizer
|
| 39 |
+
self.system_prompt = system_prompt
|
| 40 |
+
|
| 41 |
+
# Auto-detect device
|
| 42 |
+
if device:
|
| 43 |
+
self.device = torch.device(device)
|
| 44 |
+
elif torch.cuda.is_available():
|
| 45 |
+
self.device = torch.device("cuda")
|
| 46 |
+
else:
|
| 47 |
+
self.device = torch.device("cpu")
|
| 48 |
+
|
| 49 |
+
self.model = model.to(self.device)
|
| 50 |
+
self.model.eval()
|
| 51 |
+
|
| 52 |
+
# Conversation history
|
| 53 |
+
self.history: List[Dict[str, str]] = []
|
| 54 |
+
|
| 55 |
+
def chat(
|
| 56 |
+
self,
|
| 57 |
+
user_message: str,
|
| 58 |
+
max_new_tokens: int = 128,
|
| 59 |
+
temperature: float = 0.8,
|
| 60 |
+
top_k: int = 40,
|
| 61 |
+
top_p: float = 0.9,
|
| 62 |
+
) -> str:
|
| 63 |
+
"""Send a message and get a response.
|
| 64 |
+
|
| 65 |
+
Args:
|
| 66 |
+
user_message: The user's input message.
|
| 67 |
+
max_new_tokens: Maximum tokens to generate.
|
| 68 |
+
temperature: Sampling temperature.
|
| 69 |
+
top_k: Top-K sampling parameter.
|
| 70 |
+
top_p: Nucleus sampling parameter.
|
| 71 |
+
|
| 72 |
+
Returns:
|
| 73 |
+
The assistant's response string.
|
| 74 |
+
"""
|
| 75 |
+
# Add user message to history
|
| 76 |
+
self.history.append({"role": "user", "content": user_message})
|
| 77 |
+
|
| 78 |
+
# Build full conversation with system prompt
|
| 79 |
+
messages = [{"role": "system", "content": self.system_prompt}] + self.history
|
| 80 |
+
|
| 81 |
+
# Format and tokenize
|
| 82 |
+
formatted = self.tokenizer.format_chat(messages)
|
| 83 |
+
# Add the assistant turn marker so model generates a response
|
| 84 |
+
formatted += "<|assistant|>"
|
| 85 |
+
|
| 86 |
+
input_ids = self.tokenizer.encode(formatted, add_special_tokens=True)
|
| 87 |
+
# Remove the trailing EOS since we want the model to generate
|
| 88 |
+
if input_ids and input_ids[-1] == self.tokenizer.token_to_id.get("<eos>", 2):
|
| 89 |
+
input_ids = input_ids[:-1]
|
| 90 |
+
|
| 91 |
+
input_tensor = torch.tensor([input_ids], dtype=torch.long, device=self.device)
|
| 92 |
+
|
| 93 |
+
# Generate response
|
| 94 |
+
output_ids = self.model.generate(
|
| 95 |
+
input_ids=input_tensor,
|
| 96 |
+
max_new_tokens=max_new_tokens,
|
| 97 |
+
temperature=temperature,
|
| 98 |
+
top_k=top_k,
|
| 99 |
+
top_p=top_p,
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
# Decode only the newly generated tokens
|
| 103 |
+
generated_ids = output_ids[0, len(input_ids):].tolist()
|
| 104 |
+
response = self.tokenizer.decode(generated_ids, skip_special_tokens=True)
|
| 105 |
+
|
| 106 |
+
# Clean up response (stop at any special token markers)
|
| 107 |
+
for stop_token in ["<|user|>", "<|assistant|>", "<|system|>"]:
|
| 108 |
+
if stop_token in response:
|
| 109 |
+
response = response[:response.index(stop_token)]
|
| 110 |
+
|
| 111 |
+
response = response.strip()
|
| 112 |
+
|
| 113 |
+
# Add assistant response to history
|
| 114 |
+
self.history.append({"role": "assistant", "content": response})
|
| 115 |
+
|
| 116 |
+
return response
|
| 117 |
+
|
| 118 |
+
def reset(self) -> None:
|
| 119 |
+
"""Clear conversation history."""
|
| 120 |
+
self.history = []
|
| 121 |
+
|
| 122 |
+
def interactive(self) -> None:
|
| 123 |
+
"""Start an interactive chat session in the terminal.
|
| 124 |
+
|
| 125 |
+
Type 'quit' or 'exit' to end the session.
|
| 126 |
+
Type 'reset' to clear conversation history.
|
| 127 |
+
"""
|
| 128 |
+
print(f"\n{'='*60}")
|
| 129 |
+
print(f" ViuAI-1M Interactive Chat")
|
| 130 |
+
print(f"{'='*60}")
|
| 131 |
+
print(f" Model: ViuAI-1M ({self.model.count_parameters():,} params)")
|
| 132 |
+
print(f" Device: {self.device}")
|
| 133 |
+
print(f" Commands: 'quit', 'exit', 'reset'")
|
| 134 |
+
print(f"{'='*60}\n")
|
| 135 |
+
|
| 136 |
+
while True:
|
| 137 |
+
try:
|
| 138 |
+
user_input = input("You: ").strip()
|
| 139 |
+
except (EOFError, KeyboardInterrupt):
|
| 140 |
+
print("\nGoodbye!")
|
| 141 |
+
break
|
| 142 |
+
|
| 143 |
+
if not user_input:
|
| 144 |
+
continue
|
| 145 |
+
if user_input.lower() in ("quit", "exit"):
|
| 146 |
+
print("Goodbye!")
|
| 147 |
+
break
|
| 148 |
+
if user_input.lower() == "reset":
|
| 149 |
+
self.reset()
|
| 150 |
+
print("[Conversation reset]\n")
|
| 151 |
+
continue
|
| 152 |
+
|
| 153 |
+
response = self.chat(user_input)
|
| 154 |
+
print(f"ViuAI: {response}\n")
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def load_model_for_chat(
|
| 158 |
+
checkpoint_dir: str,
|
| 159 |
+
tokenizer_dir: str,
|
| 160 |
+
device: Optional[str] = None,
|
| 161 |
+
) -> ViuAIChatEngine:
|
| 162 |
+
"""Load a trained model and tokenizer for chat inference.
|
| 163 |
+
|
| 164 |
+
Args:
|
| 165 |
+
checkpoint_dir: Path to the model checkpoint directory.
|
| 166 |
+
tokenizer_dir: Path to the tokenizer directory.
|
| 167 |
+
device: Device to use for inference.
|
| 168 |
+
|
| 169 |
+
Returns:
|
| 170 |
+
Ready-to-use ViuAIChatEngine instance.
|
| 171 |
+
"""
|
| 172 |
+
import os
|
| 173 |
+
|
| 174 |
+
# Load config
|
| 175 |
+
config = ViuAIConfig.load(os.path.join(checkpoint_dir, "config.json"))
|
| 176 |
+
|
| 177 |
+
# Load tokenizer
|
| 178 |
+
tokenizer = ViuAITokenizer.load(tokenizer_dir)
|
| 179 |
+
|
| 180 |
+
# Update config vocab size to match tokenizer
|
| 181 |
+
config.vocab_size = tokenizer.vocab_size
|
| 182 |
+
|
| 183 |
+
# Load model
|
| 184 |
+
model = ViuAIForCausalLM(config)
|
| 185 |
+
model.load_state_dict(
|
| 186 |
+
torch.load(
|
| 187 |
+
os.path.join(checkpoint_dir, "model.pt"),
|
| 188 |
+
map_location="cpu",
|
| 189 |
+
)
|
| 190 |
+
)
|
| 191 |
+
|
| 192 |
+
print(f"[ViuAI] Model loaded: {model.count_parameters():,} parameters")
|
| 193 |
+
|
| 194 |
+
return ViuAIChatEngine(model=model, tokenizer=tokenizer, device=device)
|
viuai/model/__init__.py
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from viuai.model.config import ViuAIConfig
|
| 2 |
+
from viuai.model.viuai_model import ViuAIForCausalLM
|
| 3 |
+
|
| 4 |
+
__all__ = ["ViuAIConfig", "ViuAIForCausalLM"]
|
viuai/model/attention.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
ViuAI-1M Attention Mechanism.
|
| 3 |
+
|
| 4 |
+
Implements Multi-Head Self-Attention with Rotary Position Embeddings (RoPE).
|
| 5 |
+
RoPE encodes position information directly into the attention computation,
|
| 6 |
+
eliminating the need for separate positional embeddings.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import math
|
| 10 |
+
import torch
|
| 11 |
+
import torch.nn as nn
|
| 12 |
+
import torch.nn.functional as F
|
| 13 |
+
from typing import Optional, Tuple
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def precompute_rope_frequencies(
|
| 17 |
+
head_dim: int,
|
| 18 |
+
max_seq_len: int,
|
| 19 |
+
theta: float = 10000.0,
|
| 20 |
+
device: Optional[torch.device] = None,
|
| 21 |
+
) -> Tuple[torch.Tensor, torch.Tensor]:
|
| 22 |
+
"""Precompute the cosine and sine frequencies for RoPE.
|
| 23 |
+
|
| 24 |
+
Args:
|
| 25 |
+
head_dim: Dimensionality of each attention head (must be even).
|
| 26 |
+
max_seq_len: Maximum sequence length to precompute for.
|
| 27 |
+
theta: Base frequency for the rotary embeddings.
|
| 28 |
+
device: Device to place the tensors on.
|
| 29 |
+
|
| 30 |
+
Returns:
|
| 31 |
+
Tuple of (cos, sin) tensors, each of shape (max_seq_len, head_dim).
|
| 32 |
+
"""
|
| 33 |
+
assert head_dim % 2 == 0, "head_dim must be even for RoPE"
|
| 34 |
+
|
| 35 |
+
# Compute frequency bands: theta_i = 1 / (theta^(2i/d)) for i = 0..d/2-1
|
| 36 |
+
freq_indices = torch.arange(0, head_dim, 2, dtype=torch.float32, device=device)
|
| 37 |
+
frequencies = 1.0 / (theta ** (freq_indices / head_dim))
|
| 38 |
+
|
| 39 |
+
# Compute position * frequency for all positions
|
| 40 |
+
positions = torch.arange(max_seq_len, dtype=torch.float32, device=device)
|
| 41 |
+
angles = torch.outer(positions, frequencies) # (max_seq_len, head_dim/2)
|
| 42 |
+
|
| 43 |
+
# Duplicate to match full head_dim: [cos(0), cos(0), cos(1), cos(1), ...]
|
| 44 |
+
cos_cached = torch.cos(angles).repeat(1, 2) # (max_seq_len, head_dim)
|
| 45 |
+
sin_cached = torch.sin(angles).repeat(1, 2) # (max_seq_len, head_dim)
|
| 46 |
+
|
| 47 |
+
return cos_cached, sin_cached
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def apply_rope(
|
| 51 |
+
x: torch.Tensor,
|
| 52 |
+
cos: torch.Tensor,
|
| 53 |
+
sin: torch.Tensor,
|
| 54 |
+
) -> torch.Tensor:
|
| 55 |
+
"""Apply Rotary Position Embeddings to input tensor.
|
| 56 |
+
|
| 57 |
+
Uses the rotation formula:
|
| 58 |
+
x_rotated = x * cos + rotate_half(x) * sin
|
| 59 |
+
|
| 60 |
+
Args:
|
| 61 |
+
x: Input tensor of shape (batch, num_heads, seq_len, head_dim).
|
| 62 |
+
cos: Cosine frequencies of shape (seq_len, head_dim).
|
| 63 |
+
sin: Sine frequencies of shape (seq_len, head_dim).
|
| 64 |
+
|
| 65 |
+
Returns:
|
| 66 |
+
Rotated tensor of the same shape as input.
|
| 67 |
+
"""
|
| 68 |
+
seq_len = x.shape[2]
|
| 69 |
+
cos = cos[:seq_len].unsqueeze(0).unsqueeze(0) # (1, 1, seq_len, head_dim)
|
| 70 |
+
sin = sin[:seq_len].unsqueeze(0).unsqueeze(0)
|
| 71 |
+
|
| 72 |
+
# Rotate half: [-x1, x0, -x3, x2, ...] (swap pairs and negate alternating)
|
| 73 |
+
x_rotated = torch.stack([-x[..., 1::2], x[..., ::2]], dim=-1)
|
| 74 |
+
x_rotated = x_rotated.reshape(x.shape)
|
| 75 |
+
|
| 76 |
+
return (x * cos) + (x_rotated * sin)
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
class ViuAIAttention(nn.Module):
|
| 80 |
+
"""Multi-Head Self-Attention with Rotary Position Embeddings.
|
| 81 |
+
|
| 82 |
+
This implements the core attention mechanism of ViuAI-1M with:
|
| 83 |
+
- Separate Q, K, V projections (no bias for efficiency)
|
| 84 |
+
- RoPE applied to Q and K before attention computation
|
| 85 |
+
- Causal masking for autoregressive generation
|
| 86 |
+
- Dropout for regularization during training
|
| 87 |
+
"""
|
| 88 |
+
|
| 89 |
+
def __init__(self, config):
|
| 90 |
+
super().__init__()
|
| 91 |
+
self.num_heads = config.num_heads
|
| 92 |
+
self.head_dim = config.head_dim
|
| 93 |
+
self.hidden_size = config.hidden_size
|
| 94 |
+
self.dropout = config.dropout
|
| 95 |
+
|
| 96 |
+
# Linear projections without bias (standard in modern LLMs)
|
| 97 |
+
self.q_proj = nn.Linear(self.hidden_size, self.hidden_size, bias=False)
|
| 98 |
+
self.k_proj = nn.Linear(self.hidden_size, self.hidden_size, bias=False)
|
| 99 |
+
self.v_proj = nn.Linear(self.hidden_size, self.hidden_size, bias=False)
|
| 100 |
+
self.o_proj = nn.Linear(self.hidden_size, self.hidden_size, bias=False)
|
| 101 |
+
|
| 102 |
+
self.attn_dropout = nn.Dropout(self.dropout)
|
| 103 |
+
|
| 104 |
+
def forward(
|
| 105 |
+
self,
|
| 106 |
+
x: torch.Tensor,
|
| 107 |
+
rope_cos: torch.Tensor,
|
| 108 |
+
rope_sin: torch.Tensor,
|
| 109 |
+
attention_mask: Optional[torch.Tensor] = None,
|
| 110 |
+
) -> torch.Tensor:
|
| 111 |
+
"""Forward pass for multi-head attention.
|
| 112 |
+
|
| 113 |
+
Args:
|
| 114 |
+
x: Input tensor of shape (batch_size, seq_len, hidden_size).
|
| 115 |
+
rope_cos: RoPE cosine frequencies.
|
| 116 |
+
rope_sin: RoPE sine frequencies.
|
| 117 |
+
attention_mask: Optional causal mask.
|
| 118 |
+
|
| 119 |
+
Returns:
|
| 120 |
+
Output tensor of shape (batch_size, seq_len, hidden_size).
|
| 121 |
+
"""
|
| 122 |
+
batch_size, seq_len, _ = x.shape
|
| 123 |
+
|
| 124 |
+
# Project to Q, K, V and reshape for multi-head attention
|
| 125 |
+
q = self.q_proj(x).view(batch_size, seq_len, self.num_heads, self.head_dim).transpose(1, 2)
|
| 126 |
+
k = self.k_proj(x).view(batch_size, seq_len, self.num_heads, self.head_dim).transpose(1, 2)
|
| 127 |
+
v = self.v_proj(x).view(batch_size, seq_len, self.num_heads, self.head_dim).transpose(1, 2)
|
| 128 |
+
# Shape: (batch_size, num_heads, seq_len, head_dim)
|
| 129 |
+
|
| 130 |
+
# Apply Rotary Position Embeddings to Q and K
|
| 131 |
+
q = apply_rope(q, rope_cos, rope_sin)
|
| 132 |
+
k = apply_rope(k, rope_cos, rope_sin)
|
| 133 |
+
|
| 134 |
+
# Scaled dot-product attention
|
| 135 |
+
scale = math.sqrt(self.head_dim)
|
| 136 |
+
attn_weights = torch.matmul(q, k.transpose(-2, -1)) / scale
|
| 137 |
+
# Shape: (batch_size, num_heads, seq_len, seq_len)
|
| 138 |
+
|
| 139 |
+
# Apply causal mask (prevent attending to future tokens)
|
| 140 |
+
if attention_mask is not None:
|
| 141 |
+
attn_weights = attn_weights + attention_mask
|
| 142 |
+
|
| 143 |
+
attn_weights = F.softmax(attn_weights, dim=-1)
|
| 144 |
+
attn_weights = self.attn_dropout(attn_weights)
|
| 145 |
+
|
| 146 |
+
# Weighted sum of values
|
| 147 |
+
attn_output = torch.matmul(attn_weights, v)
|
| 148 |
+
# Shape: (batch_size, num_heads, seq_len, head_dim)
|
| 149 |
+
|
| 150 |
+
# Reshape back to (batch_size, seq_len, hidden_size)
|
| 151 |
+
attn_output = attn_output.transpose(1, 2).contiguous().view(batch_size, seq_len, self.hidden_size)
|
| 152 |
+
|
| 153 |
+
# Final linear projection
|
| 154 |
+
return self.o_proj(attn_output)
|
viuai/model/config.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
ViuAI-1M Model Configuration.
|
| 3 |
+
|
| 4 |
+
Defines all hyperparameters for the ViuAI transformer architecture.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import json
|
| 8 |
+
from dataclasses import dataclass, field, asdict
|
| 9 |
+
from typing import Optional
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
@dataclass
|
| 13 |
+
class ViuAIConfig:
|
| 14 |
+
"""Configuration class for the ViuAI-1M model.
|
| 15 |
+
|
| 16 |
+
This config defines a ~1.18M parameter decoder-only transformer
|
| 17 |
+
with modern architectural innovations (RoPE, SwiGLU, RMSNorm).
|
| 18 |
+
|
| 19 |
+
Attributes:
|
| 20 |
+
vocab_size: Size of the tokenizer vocabulary.
|
| 21 |
+
hidden_size: Dimensionality of the hidden representations.
|
| 22 |
+
num_layers: Number of transformer decoder blocks.
|
| 23 |
+
num_heads: Number of attention heads.
|
| 24 |
+
head_dim: Dimensionality of each attention head.
|
| 25 |
+
intermediate_size: Inner dimensionality of the SwiGLU FFN.
|
| 26 |
+
max_seq_len: Maximum sequence length (context window).
|
| 27 |
+
dropout: Dropout probability for regularization.
|
| 28 |
+
rope_theta: Base frequency for Rotary Position Embeddings.
|
| 29 |
+
rms_norm_eps: Epsilon for RMSNorm numerical stability.
|
| 30 |
+
tie_word_embeddings: Whether to tie input/output embeddings.
|
| 31 |
+
bos_token_id: Beginning-of-sequence token ID.
|
| 32 |
+
eos_token_id: End-of-sequence token ID.
|
| 33 |
+
pad_token_id: Padding token ID.
|
| 34 |
+
"""
|
| 35 |
+
|
| 36 |
+
# Architecture
|
| 37 |
+
vocab_size: int = 4096
|
| 38 |
+
hidden_size: int = 128
|
| 39 |
+
num_layers: int = 4
|
| 40 |
+
num_heads: int = 4
|
| 41 |
+
head_dim: int = 32
|
| 42 |
+
intermediate_size: int = 256
|
| 43 |
+
max_seq_len: int = 512
|
| 44 |
+
dropout: float = 0.1
|
| 45 |
+
|
| 46 |
+
# Positional encoding
|
| 47 |
+
rope_theta: float = 10000.0
|
| 48 |
+
|
| 49 |
+
# Normalization
|
| 50 |
+
rms_norm_eps: float = 1e-6
|
| 51 |
+
|
| 52 |
+
# Weight tying
|
| 53 |
+
tie_word_embeddings: bool = True
|
| 54 |
+
|
| 55 |
+
# Special tokens
|
| 56 |
+
bos_token_id: int = 1
|
| 57 |
+
eos_token_id: int = 2
|
| 58 |
+
pad_token_id: int = 0
|
| 59 |
+
|
| 60 |
+
def save(self, path: str) -> None:
|
| 61 |
+
"""Save configuration to a JSON file."""
|
| 62 |
+
with open(path, "w", encoding="utf-8") as f:
|
| 63 |
+
json.dump(asdict(self), f, indent=2)
|
| 64 |
+
|
| 65 |
+
@classmethod
|
| 66 |
+
def load(cls, path: str) -> "ViuAIConfig":
|
| 67 |
+
"""Load configuration from a JSON file."""
|
| 68 |
+
with open(path, "r", encoding="utf-8") as f:
|
| 69 |
+
data = json.load(f)
|
| 70 |
+
return cls(**data)
|
| 71 |
+
|
| 72 |
+
def __post_init__(self):
|
| 73 |
+
assert self.hidden_size == self.num_heads * self.head_dim, (
|
| 74 |
+
f"hidden_size ({self.hidden_size}) must equal "
|
| 75 |
+
f"num_heads ({self.num_heads}) * head_dim ({self.head_dim})"
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
@property
|
| 79 |
+
def num_parameters(self) -> int:
|
| 80 |
+
"""Estimate total parameter count."""
|
| 81 |
+
# Token embeddings
|
| 82 |
+
emb = self.vocab_size * self.hidden_size
|
| 83 |
+
|
| 84 |
+
# Per transformer layer
|
| 85 |
+
# Attention: Q, K, V projections + output projection (no bias)
|
| 86 |
+
attn = 4 * self.hidden_size * self.hidden_size
|
| 87 |
+
# SwiGLU FFN: gate + up + down projections (no bias)
|
| 88 |
+
ffn = 3 * self.hidden_size * self.intermediate_size
|
| 89 |
+
# RMSNorm: 2 per layer (attention + ffn), each has hidden_size params
|
| 90 |
+
norm = 2 * self.hidden_size
|
| 91 |
+
per_layer = attn + ffn + norm
|
| 92 |
+
|
| 93 |
+
# Final RMSNorm
|
| 94 |
+
final_norm = self.hidden_size
|
| 95 |
+
|
| 96 |
+
# LM head is tied with embeddings, so 0 extra
|
| 97 |
+
lm_head = 0 if self.tie_word_embeddings else self.vocab_size * self.hidden_size
|
| 98 |
+
|
| 99 |
+
total = emb + (self.num_layers * per_layer) + final_norm + lm_head
|
| 100 |
+
return total
|
viuai/model/layers.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
ViuAI-1M Custom Layers.
|
| 3 |
+
|
| 4 |
+
Implements the building blocks used in the ViuAI transformer:
|
| 5 |
+
- RMSNorm: Root Mean Square Layer Normalization (faster than LayerNorm)
|
| 6 |
+
- SwiGLU: Gated Linear Unit with Swish activation (better than ReLU/GELU)
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import torch
|
| 10 |
+
import torch.nn as nn
|
| 11 |
+
import torch.nn.functional as F
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class RMSNorm(nn.Module):
|
| 15 |
+
"""Root Mean Square Layer Normalization.
|
| 16 |
+
|
| 17 |
+
RMSNorm is computationally simpler than standard LayerNorm because it
|
| 18 |
+
does not compute the mean (no re-centering). It only normalizes by the
|
| 19 |
+
root mean square of the activations.
|
| 20 |
+
|
| 21 |
+
Formula: output = x / sqrt(mean(x^2) + eps) * weight
|
| 22 |
+
|
| 23 |
+
Reference: https://arxiv.org/abs/1910.07467
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
def __init__(self, hidden_size: int, eps: float = 1e-6):
|
| 27 |
+
super().__init__()
|
| 28 |
+
self.weight = nn.Parameter(torch.ones(hidden_size))
|
| 29 |
+
self.eps = eps
|
| 30 |
+
|
| 31 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 32 |
+
# Compute RMS
|
| 33 |
+
rms = torch.sqrt(torch.mean(x * x, dim=-1, keepdim=True) + self.eps)
|
| 34 |
+
# Normalize and scale
|
| 35 |
+
return (x / rms) * self.weight
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class SwiGLU(nn.Module):
|
| 39 |
+
"""SwiGLU Feed-Forward Network.
|
| 40 |
+
|
| 41 |
+
SwiGLU combines the Swish (SiLU) activation with a Gated Linear Unit.
|
| 42 |
+
It uses three linear projections instead of two:
|
| 43 |
+
1. Gate projection: computes the gating signal
|
| 44 |
+
2. Up projection: computes the value to be gated
|
| 45 |
+
3. Down projection: projects back to hidden_size
|
| 46 |
+
|
| 47 |
+
Formula: output = down_proj(silu(gate_proj(x)) * up_proj(x))
|
| 48 |
+
|
| 49 |
+
This is more expressive than standard FFN with ReLU/GELU and is used
|
| 50 |
+
in models like LLaMA, PaLM, and Gemini.
|
| 51 |
+
|
| 52 |
+
Reference: https://arxiv.org/abs/2002.05202
|
| 53 |
+
"""
|
| 54 |
+
|
| 55 |
+
def __init__(self, hidden_size: int, intermediate_size: int, dropout: float = 0.1):
|
| 56 |
+
super().__init__()
|
| 57 |
+
self.gate_proj = nn.Linear(hidden_size, intermediate_size, bias=False)
|
| 58 |
+
self.up_proj = nn.Linear(hidden_size, intermediate_size, bias=False)
|
| 59 |
+
self.down_proj = nn.Linear(intermediate_size, hidden_size, bias=False)
|
| 60 |
+
self.dropout = nn.Dropout(dropout)
|
| 61 |
+
|
| 62 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 63 |
+
# SwiGLU: SiLU(gate) * up, then project down
|
| 64 |
+
gate = F.silu(self.gate_proj(x))
|
| 65 |
+
up = self.up_proj(x)
|
| 66 |
+
hidden = gate * up
|
| 67 |
+
hidden = self.dropout(hidden)
|
| 68 |
+
return self.down_proj(hidden)
|
viuai/model/transformer.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
ViuAI-1M Transformer Block.
|
| 3 |
+
|
| 4 |
+
Each transformer block consists of:
|
| 5 |
+
1. RMSNorm → Multi-Head Self-Attention → Residual Connection
|
| 6 |
+
2. RMSNorm → SwiGLU FFN → Residual Connection
|
| 7 |
+
|
| 8 |
+
This follows the Pre-Norm architecture (norm before sublayer),
|
| 9 |
+
which is more stable for training than Post-Norm.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import torch
|
| 13 |
+
import torch.nn as nn
|
| 14 |
+
from typing import Optional
|
| 15 |
+
|
| 16 |
+
from viuai.model.attention import ViuAIAttention
|
| 17 |
+
from viuai.model.layers import RMSNorm, SwiGLU
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class ViuAITransformerBlock(nn.Module):
|
| 21 |
+
"""A single transformer decoder block.
|
| 22 |
+
|
| 23 |
+
Implements the Pre-Norm transformer architecture with residual connections.
|
| 24 |
+
Each block processes the input through attention and feed-forward sublayers.
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
def __init__(self, config, layer_idx: int):
|
| 28 |
+
super().__init__()
|
| 29 |
+
self.layer_idx = layer_idx
|
| 30 |
+
|
| 31 |
+
# Pre-norm for attention
|
| 32 |
+
self.attn_norm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps)
|
| 33 |
+
|
| 34 |
+
# Multi-head self-attention with RoPE
|
| 35 |
+
self.attention = ViuAIAttention(config)
|
| 36 |
+
|
| 37 |
+
# Pre-norm for feed-forward
|
| 38 |
+
self.ffn_norm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps)
|
| 39 |
+
|
| 40 |
+
# SwiGLU feed-forward network
|
| 41 |
+
self.feed_forward = SwiGLU(
|
| 42 |
+
hidden_size=config.hidden_size,
|
| 43 |
+
intermediate_size=config.intermediate_size,
|
| 44 |
+
dropout=config.dropout,
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
def forward(
|
| 48 |
+
self,
|
| 49 |
+
x: torch.Tensor,
|
| 50 |
+
rope_cos: torch.Tensor,
|
| 51 |
+
rope_sin: torch.Tensor,
|
| 52 |
+
attention_mask: Optional[torch.Tensor] = None,
|
| 53 |
+
) -> torch.Tensor:
|
| 54 |
+
"""Forward pass through one transformer block.
|
| 55 |
+
|
| 56 |
+
Args:
|
| 57 |
+
x: Input tensor of shape (batch_size, seq_len, hidden_size).
|
| 58 |
+
rope_cos: RoPE cosine frequencies.
|
| 59 |
+
rope_sin: RoPE sine frequencies.
|
| 60 |
+
attention_mask: Optional causal attention mask.
|
| 61 |
+
|
| 62 |
+
Returns:
|
| 63 |
+
Output tensor of shape (batch_size, seq_len, hidden_size).
|
| 64 |
+
"""
|
| 65 |
+
# Sublayer 1: Attention with residual
|
| 66 |
+
residual = x
|
| 67 |
+
x = self.attn_norm(x)
|
| 68 |
+
x = self.attention(x, rope_cos, rope_sin, attention_mask)
|
| 69 |
+
x = residual + x
|
| 70 |
+
|
| 71 |
+
# Sublayer 2: Feed-forward with residual
|
| 72 |
+
residual = x
|
| 73 |
+
x = self.ffn_norm(x)
|
| 74 |
+
x = self.feed_forward(x)
|
| 75 |
+
x = residual + x
|
| 76 |
+
|
| 77 |
+
return x
|
viuai/model/viuai_model.py
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
ViuAI-1M: Full Causal Language Model.
|
| 3 |
+
|
| 4 |
+
This is the main model class that assembles all components into a
|
| 5 |
+
complete decoder-only transformer for autoregressive text generation.
|
| 6 |
+
|
| 7 |
+
Architecture Overview:
|
| 8 |
+
Input IDs → Token Embedding → [Transformer Block × N] → RMSNorm → LM Head → Logits
|
| 9 |
+
|
| 10 |
+
Key Features:
|
| 11 |
+
- ~1.18M parameters (lightweight for research)
|
| 12 |
+
- RoPE (Rotary Position Embeddings) — no separate position embedding layer
|
| 13 |
+
- SwiGLU activation in FFN
|
| 14 |
+
- RMSNorm instead of LayerNorm
|
| 15 |
+
- Weight-tied input/output embeddings
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
import torch
|
| 19 |
+
import torch.nn as nn
|
| 20 |
+
import torch.nn.functional as F
|
| 21 |
+
from typing import Optional, Tuple, Dict
|
| 22 |
+
|
| 23 |
+
from viuai.model.config import ViuAIConfig
|
| 24 |
+
from viuai.model.transformer import ViuAITransformerBlock
|
| 25 |
+
from viuai.model.layers import RMSNorm
|
| 26 |
+
from viuai.model.attention import precompute_rope_frequencies
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class ViuAIModel(nn.Module):
|
| 30 |
+
"""The core ViuAI transformer model (without LM head).
|
| 31 |
+
|
| 32 |
+
This produces hidden states from input token IDs.
|
| 33 |
+
"""
|
| 34 |
+
|
| 35 |
+
def __init__(self, config: ViuAIConfig):
|
| 36 |
+
super().__init__()
|
| 37 |
+
self.config = config
|
| 38 |
+
|
| 39 |
+
# Token embeddings (no position embeddings — RoPE handles position)
|
| 40 |
+
self.token_embedding = nn.Embedding(config.vocab_size, config.hidden_size)
|
| 41 |
+
|
| 42 |
+
# Embedding dropout
|
| 43 |
+
self.embedding_dropout = nn.Dropout(config.dropout)
|
| 44 |
+
|
| 45 |
+
# Stack of transformer blocks
|
| 46 |
+
self.layers = nn.ModuleList([
|
| 47 |
+
ViuAITransformerBlock(config, layer_idx=i)
|
| 48 |
+
for i in range(config.num_layers)
|
| 49 |
+
])
|
| 50 |
+
|
| 51 |
+
# Final normalization
|
| 52 |
+
self.final_norm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps)
|
| 53 |
+
|
| 54 |
+
# Precompute RoPE frequencies (registered as buffer — not a parameter)
|
| 55 |
+
rope_cos, rope_sin = precompute_rope_frequencies(
|
| 56 |
+
head_dim=config.head_dim,
|
| 57 |
+
max_seq_len=config.max_seq_len,
|
| 58 |
+
theta=config.rope_theta,
|
| 59 |
+
)
|
| 60 |
+
self.register_buffer("rope_cos", rope_cos)
|
| 61 |
+
self.register_buffer("rope_sin", rope_sin)
|
| 62 |
+
|
| 63 |
+
def forward(
|
| 64 |
+
self,
|
| 65 |
+
input_ids: torch.Tensor,
|
| 66 |
+
attention_mask: Optional[torch.Tensor] = None,
|
| 67 |
+
) -> torch.Tensor:
|
| 68 |
+
"""Forward pass through the transformer.
|
| 69 |
+
|
| 70 |
+
Args:
|
| 71 |
+
input_ids: Token IDs of shape (batch_size, seq_len).
|
| 72 |
+
attention_mask: Optional attention mask.
|
| 73 |
+
|
| 74 |
+
Returns:
|
| 75 |
+
Hidden states of shape (batch_size, seq_len, hidden_size).
|
| 76 |
+
"""
|
| 77 |
+
batch_size, seq_len = input_ids.shape
|
| 78 |
+
|
| 79 |
+
# Token embeddings
|
| 80 |
+
hidden_states = self.token_embedding(input_ids)
|
| 81 |
+
hidden_states = self.embedding_dropout(hidden_states)
|
| 82 |
+
|
| 83 |
+
# Create causal mask if not provided
|
| 84 |
+
if attention_mask is None:
|
| 85 |
+
attention_mask = self._create_causal_mask(seq_len, hidden_states.device)
|
| 86 |
+
|
| 87 |
+
# Pass through all transformer blocks
|
| 88 |
+
for layer in self.layers:
|
| 89 |
+
hidden_states = layer(
|
| 90 |
+
hidden_states,
|
| 91 |
+
self.rope_cos,
|
| 92 |
+
self.rope_sin,
|
| 93 |
+
attention_mask,
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
# Final normalization
|
| 97 |
+
hidden_states = self.final_norm(hidden_states)
|
| 98 |
+
|
| 99 |
+
return hidden_states
|
| 100 |
+
|
| 101 |
+
@staticmethod
|
| 102 |
+
def _create_causal_mask(seq_len: int, device: torch.device) -> torch.Tensor:
|
| 103 |
+
"""Create a causal (lower-triangular) attention mask.
|
| 104 |
+
|
| 105 |
+
Future positions are masked with -inf so they get zero attention weight
|
| 106 |
+
after softmax.
|
| 107 |
+
"""
|
| 108 |
+
mask = torch.triu(
|
| 109 |
+
torch.full((seq_len, seq_len), float("-inf"), device=device),
|
| 110 |
+
diagonal=1,
|
| 111 |
+
)
|
| 112 |
+
return mask.unsqueeze(0).unsqueeze(0) # (1, 1, seq_len, seq_len)
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
class ViuAIForCausalLM(nn.Module):
|
| 116 |
+
"""ViuAI-1M model with a language modeling head for text generation.
|
| 117 |
+
|
| 118 |
+
This wraps the core transformer with a linear projection (LM head)
|
| 119 |
+
that maps hidden states to vocabulary logits for next-token prediction.
|
| 120 |
+
"""
|
| 121 |
+
|
| 122 |
+
def __init__(self, config: ViuAIConfig):
|
| 123 |
+
super().__init__()
|
| 124 |
+
self.config = config
|
| 125 |
+
self.model = ViuAIModel(config)
|
| 126 |
+
|
| 127 |
+
# LM Head: projects hidden states to vocabulary logits
|
| 128 |
+
if config.tie_word_embeddings:
|
| 129 |
+
# Share weights between token embedding and LM head
|
| 130 |
+
self.lm_head = None # Will use embedding weight directly
|
| 131 |
+
else:
|
| 132 |
+
self.lm_head = nn.Linear(config.hidden_size, config.vocab_size, bias=False)
|
| 133 |
+
|
| 134 |
+
# Initialize weights
|
| 135 |
+
self.apply(self._init_weights)
|
| 136 |
+
|
| 137 |
+
def _init_weights(self, module: nn.Module) -> None:
|
| 138 |
+
"""Initialize weights using a scaled normal distribution.
|
| 139 |
+
|
| 140 |
+
This follows the common practice in transformer models where
|
| 141 |
+
weights are initialized from N(0, 0.02) and biases to zero.
|
| 142 |
+
"""
|
| 143 |
+
if isinstance(module, nn.Linear):
|
| 144 |
+
nn.init.normal_(module.weight, mean=0.0, std=0.02)
|
| 145 |
+
if module.bias is not None:
|
| 146 |
+
nn.init.zeros_(module.bias)
|
| 147 |
+
elif isinstance(module, nn.Embedding):
|
| 148 |
+
nn.init.normal_(module.weight, mean=0.0, std=0.02)
|
| 149 |
+
|
| 150 |
+
def forward(
|
| 151 |
+
self,
|
| 152 |
+
input_ids: torch.Tensor,
|
| 153 |
+
labels: Optional[torch.Tensor] = None,
|
| 154 |
+
attention_mask: Optional[torch.Tensor] = None,
|
| 155 |
+
) -> Dict[str, torch.Tensor]:
|
| 156 |
+
"""Forward pass with optional loss computation.
|
| 157 |
+
|
| 158 |
+
Args:
|
| 159 |
+
input_ids: Token IDs of shape (batch_size, seq_len).
|
| 160 |
+
labels: Target token IDs for loss computation. Same shape as input_ids.
|
| 161 |
+
Positions with value -100 are ignored in loss.
|
| 162 |
+
attention_mask: Optional attention mask.
|
| 163 |
+
|
| 164 |
+
Returns:
|
| 165 |
+
Dictionary with 'logits' and optionally 'loss'.
|
| 166 |
+
"""
|
| 167 |
+
# Get hidden states from the transformer
|
| 168 |
+
hidden_states = self.model(input_ids, attention_mask)
|
| 169 |
+
|
| 170 |
+
# Project to vocabulary logits
|
| 171 |
+
if self.config.tie_word_embeddings:
|
| 172 |
+
logits = F.linear(hidden_states, self.model.token_embedding.weight)
|
| 173 |
+
else:
|
| 174 |
+
logits = self.lm_head(hidden_states)
|
| 175 |
+
|
| 176 |
+
output = {"logits": logits}
|
| 177 |
+
|
| 178 |
+
# Compute cross-entropy loss if labels are provided
|
| 179 |
+
if labels is not None:
|
| 180 |
+
# Shift: predict next token (logits[:-1] predicts labels[1:])
|
| 181 |
+
shift_logits = logits[:, :-1, :].contiguous()
|
| 182 |
+
shift_labels = labels[:, 1:].contiguous()
|
| 183 |
+
|
| 184 |
+
loss = F.cross_entropy(
|
| 185 |
+
shift_logits.view(-1, self.config.vocab_size),
|
| 186 |
+
shift_labels.view(-1),
|
| 187 |
+
ignore_index=-100,
|
| 188 |
+
)
|
| 189 |
+
output["loss"] = loss
|
| 190 |
+
|
| 191 |
+
return output
|
| 192 |
+
|
| 193 |
+
@torch.no_grad()
|
| 194 |
+
def generate(
|
| 195 |
+
self,
|
| 196 |
+
input_ids: torch.Tensor,
|
| 197 |
+
max_new_tokens: int = 128,
|
| 198 |
+
temperature: float = 0.8,
|
| 199 |
+
top_k: int = 40,
|
| 200 |
+
top_p: float = 0.9,
|
| 201 |
+
repetition_penalty: float = 1.1,
|
| 202 |
+
eos_token_id: Optional[int] = None,
|
| 203 |
+
) -> torch.Tensor:
|
| 204 |
+
"""Generate text autoregressively using sampling.
|
| 205 |
+
|
| 206 |
+
Args:
|
| 207 |
+
input_ids: Initial token IDs of shape (batch_size, seq_len).
|
| 208 |
+
max_new_tokens: Maximum number of new tokens to generate.
|
| 209 |
+
temperature: Sampling temperature (higher = more random).
|
| 210 |
+
top_k: Keep only top-k tokens for sampling.
|
| 211 |
+
top_p: Nucleus sampling threshold.
|
| 212 |
+
repetition_penalty: Penalty for repeating tokens.
|
| 213 |
+
eos_token_id: Token ID that signals end of generation.
|
| 214 |
+
|
| 215 |
+
Returns:
|
| 216 |
+
Generated token IDs including the input prefix.
|
| 217 |
+
"""
|
| 218 |
+
if eos_token_id is None:
|
| 219 |
+
eos_token_id = self.config.eos_token_id
|
| 220 |
+
|
| 221 |
+
self.eval()
|
| 222 |
+
generated = input_ids.clone()
|
| 223 |
+
|
| 224 |
+
for _ in range(max_new_tokens):
|
| 225 |
+
# Truncate to max_seq_len if needed
|
| 226 |
+
context = generated[:, -self.config.max_seq_len:]
|
| 227 |
+
|
| 228 |
+
# Forward pass
|
| 229 |
+
output = self.forward(context)
|
| 230 |
+
next_token_logits = output["logits"][:, -1, :] # (batch_size, vocab_size)
|
| 231 |
+
|
| 232 |
+
# Apply repetition penalty
|
| 233 |
+
if repetition_penalty != 1.0:
|
| 234 |
+
for token_id in set(generated[0].tolist()):
|
| 235 |
+
next_token_logits[0, token_id] /= repetition_penalty
|
| 236 |
+
|
| 237 |
+
# Apply temperature
|
| 238 |
+
next_token_logits = next_token_logits / temperature
|
| 239 |
+
|
| 240 |
+
# Top-K filtering
|
| 241 |
+
if top_k > 0:
|
| 242 |
+
indices_to_remove = next_token_logits < torch.topk(next_token_logits, top_k)[0][..., -1, None]
|
| 243 |
+
next_token_logits[indices_to_remove] = float("-inf")
|
| 244 |
+
|
| 245 |
+
# Top-P (nucleus) filtering
|
| 246 |
+
if top_p < 1.0:
|
| 247 |
+
sorted_logits, sorted_indices = torch.sort(next_token_logits, descending=True)
|
| 248 |
+
cumulative_probs = torch.cumsum(F.softmax(sorted_logits, dim=-1), dim=-1)
|
| 249 |
+
|
| 250 |
+
# Remove tokens with cumulative probability above threshold
|
| 251 |
+
sorted_indices_to_remove = cumulative_probs > top_p
|
| 252 |
+
sorted_indices_to_remove[..., 1:] = sorted_indices_to_remove[..., :-1].clone()
|
| 253 |
+
sorted_indices_to_remove[..., 0] = 0
|
| 254 |
+
|
| 255 |
+
indices_to_remove = sorted_indices_to_remove.scatter(
|
| 256 |
+
1, sorted_indices, sorted_indices_to_remove
|
| 257 |
+
)
|
| 258 |
+
next_token_logits[indices_to_remove] = float("-inf")
|
| 259 |
+
|
| 260 |
+
# Sample from the distribution
|
| 261 |
+
probs = F.softmax(next_token_logits, dim=-1)
|
| 262 |
+
next_token = torch.multinomial(probs, num_samples=1)
|
| 263 |
+
|
| 264 |
+
# Append generated token
|
| 265 |
+
generated = torch.cat([generated, next_token], dim=1)
|
| 266 |
+
|
| 267 |
+
# Stop if EOS token is generated
|
| 268 |
+
if next_token.item() == eos_token_id:
|
| 269 |
+
break
|
| 270 |
+
|
| 271 |
+
return generated
|
| 272 |
+
|
| 273 |
+
def count_parameters(self) -> int:
|
| 274 |
+
"""Count total trainable parameters."""
|
| 275 |
+
return sum(p.numel() for p in self.parameters() if p.requires_grad)
|
| 276 |
+
|
| 277 |
+
def get_parameter_summary(self) -> str:
|
| 278 |
+
"""Get a formatted summary of model parameters."""
|
| 279 |
+
total = self.count_parameters()
|
| 280 |
+
lines = [
|
| 281 |
+
f"{'='*50}",
|
| 282 |
+
f" ViuAI-1M Parameter Summary",
|
| 283 |
+
f"{'='*50}",
|
| 284 |
+
]
|
| 285 |
+
for name, param in self.named_parameters():
|
| 286 |
+
lines.append(f" {name:45s} {param.numel():>10,}")
|
| 287 |
+
lines.append(f"{'='*50}")
|
| 288 |
+
lines.append(f" {'Total Parameters':45s} {total:>10,}")
|
| 289 |
+
lines.append(f" {'Approx Size (MB)':45s} {total * 4 / 1024 / 1024:>10.2f}")
|
| 290 |
+
lines.append(f"{'='*50}")
|
| 291 |
+
return "\n".join(lines)
|
viuai/tokenizer/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from viuai.tokenizer.bpe_tokenizer import ViuAITokenizer
|
| 2 |
+
|
| 3 |
+
__all__ = ["ViuAITokenizer"]
|
viuai/tokenizer/bpe_tokenizer.py
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
ViuAI-1M BPE Tokenizer — Built from Scratch.
|
| 3 |
+
|
| 4 |
+
Implements Byte-Pair Encoding (BPE) tokenization:
|
| 5 |
+
1. Start with individual characters as the initial vocabulary
|
| 6 |
+
2. Iteratively merge the most frequent adjacent pair of tokens
|
| 7 |
+
3. Continue until desired vocabulary size is reached
|
| 8 |
+
|
| 9 |
+
The tokenizer also handles special tokens for chat formatting:
|
| 10 |
+
<pad> — padding token
|
| 11 |
+
<bos> — beginning of sequence
|
| 12 |
+
<eos> — end of sequence
|
| 13 |
+
<unk> — unknown token
|
| 14 |
+
<|user|> — start of user message
|
| 15 |
+
<|assistant|> — start of assistant message
|
| 16 |
+
<|system|> — start of system message
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
import json
|
| 20 |
+
import re
|
| 21 |
+
import os
|
| 22 |
+
from collections import Counter
|
| 23 |
+
from typing import List, Dict, Tuple, Optional
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
# Special tokens with reserved IDs
|
| 27 |
+
SPECIAL_TOKENS = {
|
| 28 |
+
"<pad>": 0,
|
| 29 |
+
"<bos>": 1,
|
| 30 |
+
"<eos>": 2,
|
| 31 |
+
"<unk>": 3,
|
| 32 |
+
"<|user|>": 4,
|
| 33 |
+
"<|assistant|>": 5,
|
| 34 |
+
"<|system|>": 6,
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
# Regex pattern to identify special tokens in text
|
| 38 |
+
SPECIAL_TOKEN_PATTERN = re.compile(
|
| 39 |
+
r"(<pad>|<bos>|<eos>|<unk>|<\|user\|>|<\|assistant\|>|<\|system\|>)"
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class ViuAITokenizer:
|
| 44 |
+
"""Byte-Pair Encoding tokenizer for the ViuAI-1M model.
|
| 45 |
+
|
| 46 |
+
This tokenizer is trained from scratch on the training corpus.
|
| 47 |
+
It learns subword units by iteratively merging frequent character pairs.
|
| 48 |
+
"""
|
| 49 |
+
|
| 50 |
+
def __init__(self, vocab_size: int = 4096):
|
| 51 |
+
self.target_vocab_size = vocab_size
|
| 52 |
+
|
| 53 |
+
# Initialize vocabulary with special tokens
|
| 54 |
+
self.token_to_id: Dict[str, int] = dict(SPECIAL_TOKENS)
|
| 55 |
+
self.id_to_token: Dict[int, str] = {v: k for k, v in SPECIAL_TOKENS.items()}
|
| 56 |
+
|
| 57 |
+
# BPE merge rules: list of (token_a, token_b) pairs in merge order
|
| 58 |
+
self.merges: List[Tuple[str, str]] = []
|
| 59 |
+
|
| 60 |
+
# Pre-tokenization pattern: split on whitespace boundaries and punctuation
|
| 61 |
+
self.pre_tokenize_pattern = re.compile(
|
| 62 |
+
r"""'s|'t|'re|'ve|'m|'ll|'d| ?\w+| ?\d+| ?[^\s\w]+|\s+"""
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
def train(self, texts: List[str], verbose: bool = True) -> None:
|
| 66 |
+
"""Train the BPE tokenizer on a corpus of texts.
|
| 67 |
+
|
| 68 |
+
Args:
|
| 69 |
+
texts: List of text strings to train on.
|
| 70 |
+
verbose: Whether to print progress.
|
| 71 |
+
"""
|
| 72 |
+
if verbose:
|
| 73 |
+
print(f"[ViuAI Tokenizer] Training BPE tokenizer...")
|
| 74 |
+
print(f" Target vocab size: {self.target_vocab_size}")
|
| 75 |
+
print(f" Training on {len(texts)} texts")
|
| 76 |
+
|
| 77 |
+
# Step 1: Pre-tokenize and count word frequencies
|
| 78 |
+
word_freqs = Counter()
|
| 79 |
+
for text in texts:
|
| 80 |
+
# Split text into words/chunks
|
| 81 |
+
words = self.pre_tokenize_pattern.findall(text.lower())
|
| 82 |
+
for word in words:
|
| 83 |
+
# Represent each word as a tuple of characters
|
| 84 |
+
word_freqs[tuple(word)] += 1
|
| 85 |
+
|
| 86 |
+
if verbose:
|
| 87 |
+
print(f" Unique words: {len(word_freqs)}")
|
| 88 |
+
|
| 89 |
+
# Step 2: Initialize vocabulary with all individual characters
|
| 90 |
+
char_vocab = set()
|
| 91 |
+
for word in word_freqs:
|
| 92 |
+
for char in word:
|
| 93 |
+
char_vocab.add(char)
|
| 94 |
+
|
| 95 |
+
# Add characters to vocabulary (after special tokens)
|
| 96 |
+
next_id = len(self.token_to_id)
|
| 97 |
+
for char in sorted(char_vocab):
|
| 98 |
+
if char not in self.token_to_id:
|
| 99 |
+
self.token_to_id[char] = next_id
|
| 100 |
+
self.id_to_token[next_id] = char
|
| 101 |
+
next_id += 1
|
| 102 |
+
|
| 103 |
+
if verbose:
|
| 104 |
+
print(f" Initial vocab (chars): {len(self.token_to_id)}")
|
| 105 |
+
|
| 106 |
+
# Step 3: Iteratively merge most frequent pairs
|
| 107 |
+
# word_splits tracks how each word is currently split
|
| 108 |
+
word_splits = {word: list(word) for word in word_freqs}
|
| 109 |
+
|
| 110 |
+
num_merges = self.target_vocab_size - len(self.token_to_id)
|
| 111 |
+
for merge_idx in range(num_merges):
|
| 112 |
+
# Count all adjacent pairs across the corpus
|
| 113 |
+
pair_freqs = Counter()
|
| 114 |
+
for word, freq in word_freqs.items():
|
| 115 |
+
splits = word_splits[word]
|
| 116 |
+
for i in range(len(splits) - 1):
|
| 117 |
+
pair = (splits[i], splits[i + 1])
|
| 118 |
+
pair_freqs[pair] += freq
|
| 119 |
+
|
| 120 |
+
if not pair_freqs:
|
| 121 |
+
if verbose:
|
| 122 |
+
print(f" No more pairs to merge at step {merge_idx}")
|
| 123 |
+
break
|
| 124 |
+
|
| 125 |
+
# Find the most frequent pair
|
| 126 |
+
best_pair = pair_freqs.most_common(1)[0][0]
|
| 127 |
+
best_freq = pair_freqs[best_pair]
|
| 128 |
+
|
| 129 |
+
# Create new merged token
|
| 130 |
+
new_token = best_pair[0] + best_pair[1]
|
| 131 |
+
self.token_to_id[new_token] = next_id
|
| 132 |
+
self.id_to_token[next_id] = new_token
|
| 133 |
+
self.merges.append(best_pair)
|
| 134 |
+
next_id += 1
|
| 135 |
+
|
| 136 |
+
# Apply the merge to all words
|
| 137 |
+
for word in word_splits:
|
| 138 |
+
splits = word_splits[word]
|
| 139 |
+
new_splits = []
|
| 140 |
+
i = 0
|
| 141 |
+
while i < len(splits):
|
| 142 |
+
if (
|
| 143 |
+
i < len(splits) - 1
|
| 144 |
+
and splits[i] == best_pair[0]
|
| 145 |
+
and splits[i + 1] == best_pair[1]
|
| 146 |
+
):
|
| 147 |
+
new_splits.append(new_token)
|
| 148 |
+
i += 2
|
| 149 |
+
else:
|
| 150 |
+
new_splits.append(splits[i])
|
| 151 |
+
i += 1
|
| 152 |
+
word_splits[word] = new_splits
|
| 153 |
+
|
| 154 |
+
if verbose and (merge_idx + 1) % 500 == 0:
|
| 155 |
+
print(
|
| 156 |
+
f" Merge {merge_idx + 1}/{num_merges}: "
|
| 157 |
+
f"'{best_pair[0]}' + '{best_pair[1]}' → '{new_token}' "
|
| 158 |
+
f"(freq: {best_freq})"
|
| 159 |
+
)
|
| 160 |
+
|
| 161 |
+
if verbose:
|
| 162 |
+
print(f" Final vocab size: {len(self.token_to_id)}")
|
| 163 |
+
print(f" Total merges: {len(self.merges)}")
|
| 164 |
+
print(f"[ViuAI Tokenizer] Training complete!")
|
| 165 |
+
|
| 166 |
+
def encode(self, text: str, add_special_tokens: bool = True) -> List[int]:
|
| 167 |
+
"""Encode text to token IDs.
|
| 168 |
+
|
| 169 |
+
Args:
|
| 170 |
+
text: Input text string.
|
| 171 |
+
add_special_tokens: Whether to add BOS/EOS tokens.
|
| 172 |
+
|
| 173 |
+
Returns:
|
| 174 |
+
List of token IDs.
|
| 175 |
+
"""
|
| 176 |
+
token_ids = []
|
| 177 |
+
|
| 178 |
+
if add_special_tokens:
|
| 179 |
+
token_ids.append(SPECIAL_TOKENS["<bos>"])
|
| 180 |
+
|
| 181 |
+
# Handle special tokens in the text
|
| 182 |
+
parts = SPECIAL_TOKEN_PATTERN.split(text)
|
| 183 |
+
|
| 184 |
+
for part in parts:
|
| 185 |
+
if part in SPECIAL_TOKENS:
|
| 186 |
+
token_ids.append(SPECIAL_TOKENS[part])
|
| 187 |
+
elif part:
|
| 188 |
+
# Pre-tokenize into words
|
| 189 |
+
words = self.pre_tokenize_pattern.findall(part.lower())
|
| 190 |
+
for word in words:
|
| 191 |
+
# Apply BPE merges
|
| 192 |
+
word_tokens = self._apply_bpe(word)
|
| 193 |
+
for token in word_tokens:
|
| 194 |
+
token_ids.append(
|
| 195 |
+
self.token_to_id.get(token, SPECIAL_TOKENS["<unk>"])
|
| 196 |
+
)
|
| 197 |
+
|
| 198 |
+
if add_special_tokens:
|
| 199 |
+
token_ids.append(SPECIAL_TOKENS["<eos>"])
|
| 200 |
+
|
| 201 |
+
return token_ids
|
| 202 |
+
|
| 203 |
+
def decode(self, token_ids: List[int], skip_special_tokens: bool = True) -> str:
|
| 204 |
+
"""Decode token IDs back to text.
|
| 205 |
+
|
| 206 |
+
Args:
|
| 207 |
+
token_ids: List of token IDs.
|
| 208 |
+
skip_special_tokens: Whether to skip special tokens in output.
|
| 209 |
+
|
| 210 |
+
Returns:
|
| 211 |
+
Decoded text string.
|
| 212 |
+
"""
|
| 213 |
+
tokens = []
|
| 214 |
+
for tid in token_ids:
|
| 215 |
+
token = self.id_to_token.get(tid, "<unk>")
|
| 216 |
+
if skip_special_tokens and token in SPECIAL_TOKENS:
|
| 217 |
+
continue
|
| 218 |
+
tokens.append(token)
|
| 219 |
+
return "".join(tokens)
|
| 220 |
+
|
| 221 |
+
def _apply_bpe(self, word: str) -> List[str]:
|
| 222 |
+
"""Apply learned BPE merges to a word.
|
| 223 |
+
|
| 224 |
+
Args:
|
| 225 |
+
word: Input word string.
|
| 226 |
+
|
| 227 |
+
Returns:
|
| 228 |
+
List of BPE tokens.
|
| 229 |
+
"""
|
| 230 |
+
# Start with individual characters
|
| 231 |
+
tokens = list(word)
|
| 232 |
+
|
| 233 |
+
# Apply merges in order
|
| 234 |
+
for merge_a, merge_b in self.merges:
|
| 235 |
+
new_tokens = []
|
| 236 |
+
i = 0
|
| 237 |
+
while i < len(tokens):
|
| 238 |
+
if (
|
| 239 |
+
i < len(tokens) - 1
|
| 240 |
+
and tokens[i] == merge_a
|
| 241 |
+
and tokens[i + 1] == merge_b
|
| 242 |
+
):
|
| 243 |
+
new_tokens.append(merge_a + merge_b)
|
| 244 |
+
i += 2
|
| 245 |
+
else:
|
| 246 |
+
new_tokens.append(tokens[i])
|
| 247 |
+
i += 1
|
| 248 |
+
tokens = new_tokens
|
| 249 |
+
|
| 250 |
+
return tokens
|
| 251 |
+
|
| 252 |
+
def format_chat(
|
| 253 |
+
self,
|
| 254 |
+
messages: List[Dict[str, str]],
|
| 255 |
+
) -> str:
|
| 256 |
+
"""Format a list of chat messages into the ViuAI chat template.
|
| 257 |
+
|
| 258 |
+
Chat format:
|
| 259 |
+
<bos><|system|>System message<|user|>User message<|assistant|>Response<eos>
|
| 260 |
+
|
| 261 |
+
Args:
|
| 262 |
+
messages: List of dicts with 'role' and 'content' keys.
|
| 263 |
+
Roles: 'system', 'user', 'assistant'.
|
| 264 |
+
|
| 265 |
+
Returns:
|
| 266 |
+
Formatted chat string.
|
| 267 |
+
"""
|
| 268 |
+
formatted = ""
|
| 269 |
+
for msg in messages:
|
| 270 |
+
role = msg["role"]
|
| 271 |
+
content = msg["content"]
|
| 272 |
+
if role == "system":
|
| 273 |
+
formatted += f"<|system|>{content}"
|
| 274 |
+
elif role == "user":
|
| 275 |
+
formatted += f"<|user|>{content}"
|
| 276 |
+
elif role == "assistant":
|
| 277 |
+
formatted += f"<|assistant|>{content}"
|
| 278 |
+
return formatted
|
| 279 |
+
|
| 280 |
+
def save(self, directory: str) -> None:
|
| 281 |
+
"""Save tokenizer to a directory.
|
| 282 |
+
|
| 283 |
+
Saves:
|
| 284 |
+
- vocab.json: token-to-ID mapping
|
| 285 |
+
- merges.txt: BPE merge rules
|
| 286 |
+
"""
|
| 287 |
+
os.makedirs(directory, exist_ok=True)
|
| 288 |
+
|
| 289 |
+
# Save vocabulary
|
| 290 |
+
vocab_path = os.path.join(directory, "vocab.json")
|
| 291 |
+
with open(vocab_path, "w", encoding="utf-8") as f:
|
| 292 |
+
json.dump(self.token_to_id, f, ensure_ascii=False, indent=2)
|
| 293 |
+
|
| 294 |
+
# Save merges
|
| 295 |
+
merges_path = os.path.join(directory, "merges.txt")
|
| 296 |
+
with open(merges_path, "w", encoding="utf-8") as f:
|
| 297 |
+
f.write("# ViuAI-1M BPE Merges\n")
|
| 298 |
+
f.write(f"# Total merges: {len(self.merges)}\n")
|
| 299 |
+
for a, b in self.merges:
|
| 300 |
+
f.write(f"{a} {b}\n")
|
| 301 |
+
|
| 302 |
+
print(f"[ViuAI Tokenizer] Saved to {directory}")
|
| 303 |
+
|
| 304 |
+
@classmethod
|
| 305 |
+
def load(cls, directory: str) -> "ViuAITokenizer":
|
| 306 |
+
"""Load tokenizer from a directory.
|
| 307 |
+
|
| 308 |
+
Args:
|
| 309 |
+
directory: Path containing vocab.json and merges.txt.
|
| 310 |
+
|
| 311 |
+
Returns:
|
| 312 |
+
Loaded ViuAITokenizer instance.
|
| 313 |
+
"""
|
| 314 |
+
tokenizer = cls()
|
| 315 |
+
|
| 316 |
+
# Load vocabulary
|
| 317 |
+
vocab_path = os.path.join(directory, "vocab.json")
|
| 318 |
+
with open(vocab_path, "r", encoding="utf-8") as f:
|
| 319 |
+
tokenizer.token_to_id = json.load(f)
|
| 320 |
+
# Convert string keys back to int values
|
| 321 |
+
tokenizer.token_to_id = {
|
| 322 |
+
k: int(v) for k, v in tokenizer.token_to_id.items()
|
| 323 |
+
}
|
| 324 |
+
tokenizer.id_to_token = {v: k for k, v in tokenizer.token_to_id.items()}
|
| 325 |
+
|
| 326 |
+
# Load merges
|
| 327 |
+
merges_path = os.path.join(directory, "merges.txt")
|
| 328 |
+
tokenizer.merges = []
|
| 329 |
+
with open(merges_path, "r", encoding="utf-8") as f:
|
| 330 |
+
for line in f:
|
| 331 |
+
line = line.strip()
|
| 332 |
+
if line and not line.startswith("#"):
|
| 333 |
+
parts = line.split(" ", 1)
|
| 334 |
+
if len(parts) == 2:
|
| 335 |
+
tokenizer.merges.append((parts[0], parts[1]))
|
| 336 |
+
|
| 337 |
+
tokenizer.target_vocab_size = len(tokenizer.token_to_id)
|
| 338 |
+
print(f"[ViuAI Tokenizer] Loaded from {directory}")
|
| 339 |
+
print(f" Vocab size: {len(tokenizer.token_to_id)}")
|
| 340 |
+
print(f" Merges: {len(tokenizer.merges)}")
|
| 341 |
+
|
| 342 |
+
return tokenizer
|
| 343 |
+
|
| 344 |
+
@property
|
| 345 |
+
def vocab_size(self) -> int:
|
| 346 |
+
return len(self.token_to_id)
|
| 347 |
+
|
| 348 |
+
def __len__(self) -> int:
|
| 349 |
+
return self.vocab_size
|
viuai/training/__init__.py
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from viuai.training.dataset import ViuAIDataset, ChatDataCollator
|
| 2 |
+
from viuai.training.trainer import ViuAITrainer
|
| 3 |
+
|
| 4 |
+
__all__ = ["ViuAIDataset", "ChatDataCollator", "ViuAITrainer"]
|
viuai/training/dataset.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
ViuAI-1M Dataset and Data Collation.
|
| 3 |
+
|
| 4 |
+
Handles loading conversation data, tokenizing it using the ViuAI chat template,
|
| 5 |
+
and creating PyTorch DataLoader-compatible datasets.
|
| 6 |
+
|
| 7 |
+
Supported data format (JSONL):
|
| 8 |
+
{"messages": [{"role": "user", "content": "..."}, {"role": "assistant", "content": "..."}]}
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import json
|
| 12 |
+
import os
|
| 13 |
+
import torch
|
| 14 |
+
from torch.utils.data import Dataset
|
| 15 |
+
from typing import List, Dict, Optional
|
| 16 |
+
|
| 17 |
+
from viuai.tokenizer.bpe_tokenizer import ViuAITokenizer
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class ViuAIDataset(Dataset):
|
| 21 |
+
"""Dataset for chat-formatted conversation data.
|
| 22 |
+
|
| 23 |
+
Loads conversations from JSONL files and tokenizes them
|
| 24 |
+
using the ViuAI chat template.
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
def __init__(
|
| 28 |
+
self,
|
| 29 |
+
data_path: str,
|
| 30 |
+
tokenizer: ViuAITokenizer,
|
| 31 |
+
max_seq_len: int = 512,
|
| 32 |
+
):
|
| 33 |
+
"""Initialize the dataset.
|
| 34 |
+
|
| 35 |
+
Args:
|
| 36 |
+
data_path: Path to a JSONL file with chat conversations.
|
| 37 |
+
tokenizer: Trained ViuAI tokenizer.
|
| 38 |
+
max_seq_len: Maximum sequence length (truncate longer sequences).
|
| 39 |
+
"""
|
| 40 |
+
self.tokenizer = tokenizer
|
| 41 |
+
self.max_seq_len = max_seq_len
|
| 42 |
+
self.examples: List[Dict] = []
|
| 43 |
+
|
| 44 |
+
# Load conversations from JSONL
|
| 45 |
+
if os.path.exists(data_path):
|
| 46 |
+
with open(data_path, "r", encoding="utf-8") as f:
|
| 47 |
+
for line in f:
|
| 48 |
+
line = line.strip()
|
| 49 |
+
if line:
|
| 50 |
+
try:
|
| 51 |
+
self.examples.append(json.loads(line))
|
| 52 |
+
except json.JSONDecodeError:
|
| 53 |
+
continue
|
| 54 |
+
|
| 55 |
+
print(f"[ViuAI Dataset] Loaded {len(self.examples)} conversations from {data_path}")
|
| 56 |
+
|
| 57 |
+
def __len__(self) -> int:
|
| 58 |
+
return len(self.examples)
|
| 59 |
+
|
| 60 |
+
def __getitem__(self, idx: int) -> Dict[str, torch.Tensor]:
|
| 61 |
+
"""Get a tokenized training example.
|
| 62 |
+
|
| 63 |
+
Returns:
|
| 64 |
+
Dictionary with 'input_ids' and 'labels' tensors.
|
| 65 |
+
Labels are the same as input_ids (shifted internally by the model).
|
| 66 |
+
"""
|
| 67 |
+
example = self.examples[idx]
|
| 68 |
+
messages = example.get("messages", [])
|
| 69 |
+
|
| 70 |
+
# Format using chat template
|
| 71 |
+
formatted = self.tokenizer.format_chat(messages)
|
| 72 |
+
|
| 73 |
+
# Tokenize
|
| 74 |
+
token_ids = self.tokenizer.encode(formatted, add_special_tokens=True)
|
| 75 |
+
|
| 76 |
+
# Truncate to max length
|
| 77 |
+
if len(token_ids) > self.max_seq_len:
|
| 78 |
+
token_ids = token_ids[: self.max_seq_len]
|
| 79 |
+
|
| 80 |
+
input_ids = torch.tensor(token_ids, dtype=torch.long)
|
| 81 |
+
labels = input_ids.clone()
|
| 82 |
+
|
| 83 |
+
return {"input_ids": input_ids, "labels": labels}
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
class ChatDataCollator:
|
| 87 |
+
"""Collates variable-length sequences into a padded batch.
|
| 88 |
+
|
| 89 |
+
Pads sequences to the longest sequence in the batch and
|
| 90 |
+
creates attention-appropriate labels (pad positions = -100).
|
| 91 |
+
"""
|
| 92 |
+
|
| 93 |
+
def __init__(self, pad_token_id: int = 0):
|
| 94 |
+
self.pad_token_id = pad_token_id
|
| 95 |
+
|
| 96 |
+
def __call__(self, batch: List[Dict[str, torch.Tensor]]) -> Dict[str, torch.Tensor]:
|
| 97 |
+
"""Collate a list of examples into a batch.
|
| 98 |
+
|
| 99 |
+
Args:
|
| 100 |
+
batch: List of dicts, each with 'input_ids' and 'labels'.
|
| 101 |
+
|
| 102 |
+
Returns:
|
| 103 |
+
Padded batch dict with 'input_ids' and 'labels'.
|
| 104 |
+
"""
|
| 105 |
+
# Find max length in this batch
|
| 106 |
+
max_len = max(example["input_ids"].size(0) for example in batch)
|
| 107 |
+
|
| 108 |
+
input_ids_list = []
|
| 109 |
+
labels_list = []
|
| 110 |
+
|
| 111 |
+
for example in batch:
|
| 112 |
+
seq_len = example["input_ids"].size(0)
|
| 113 |
+
padding_len = max_len - seq_len
|
| 114 |
+
|
| 115 |
+
# Pad input_ids with pad_token_id
|
| 116 |
+
padded_input = torch.cat([
|
| 117 |
+
example["input_ids"],
|
| 118 |
+
torch.full((padding_len,), self.pad_token_id, dtype=torch.long),
|
| 119 |
+
])
|
| 120 |
+
input_ids_list.append(padded_input)
|
| 121 |
+
|
| 122 |
+
# Pad labels with -100 (ignored in loss computation)
|
| 123 |
+
padded_labels = torch.cat([
|
| 124 |
+
example["labels"],
|
| 125 |
+
torch.full((padding_len,), -100, dtype=torch.long),
|
| 126 |
+
])
|
| 127 |
+
labels_list.append(padded_labels)
|
| 128 |
+
|
| 129 |
+
return {
|
| 130 |
+
"input_ids": torch.stack(input_ids_list),
|
| 131 |
+
"labels": torch.stack(labels_list),
|
| 132 |
+
}
|
viuai/training/trainer.py
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
ViuAI-1M Training Loop.
|
| 3 |
+
|
| 4 |
+
Implements a custom training loop with:
|
| 5 |
+
- AdamW optimizer with weight decay
|
| 6 |
+
- Cosine learning rate scheduler with warmup
|
| 7 |
+
- Gradient clipping
|
| 8 |
+
- Training metrics logging
|
| 9 |
+
- Checkpoint saving and loading
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import os
|
| 13 |
+
import math
|
| 14 |
+
import time
|
| 15 |
+
import json
|
| 16 |
+
import torch
|
| 17 |
+
import torch.nn as nn
|
| 18 |
+
from torch.utils.data import DataLoader
|
| 19 |
+
from typing import Optional, Dict
|
| 20 |
+
|
| 21 |
+
from viuai.model.viuai_model import ViuAIForCausalLM
|
| 22 |
+
from viuai.model.config import ViuAIConfig
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class ViuAITrainer:
|
| 26 |
+
"""Custom trainer for the ViuAI-1M model.
|
| 27 |
+
|
| 28 |
+
Provides a complete training pipeline with learning rate scheduling,
|
| 29 |
+
gradient clipping, checkpointing, and metrics logging.
|
| 30 |
+
"""
|
| 31 |
+
|
| 32 |
+
def __init__(
|
| 33 |
+
self,
|
| 34 |
+
model: ViuAIForCausalLM,
|
| 35 |
+
train_dataloader: DataLoader,
|
| 36 |
+
eval_dataloader: Optional[DataLoader] = None,
|
| 37 |
+
learning_rate: float = 3e-4,
|
| 38 |
+
weight_decay: float = 0.01,
|
| 39 |
+
warmup_steps: int = 100,
|
| 40 |
+
max_grad_norm: float = 1.0,
|
| 41 |
+
num_epochs: int = 10,
|
| 42 |
+
save_dir: str = "checkpoints",
|
| 43 |
+
log_interval: int = 10,
|
| 44 |
+
eval_interval: int = 100,
|
| 45 |
+
device: Optional[str] = None,
|
| 46 |
+
):
|
| 47 |
+
self.model = model
|
| 48 |
+
self.train_dataloader = train_dataloader
|
| 49 |
+
self.eval_dataloader = eval_dataloader
|
| 50 |
+
self.learning_rate = learning_rate
|
| 51 |
+
self.weight_decay = weight_decay
|
| 52 |
+
self.warmup_steps = warmup_steps
|
| 53 |
+
self.max_grad_norm = max_grad_norm
|
| 54 |
+
self.num_epochs = num_epochs
|
| 55 |
+
self.save_dir = save_dir
|
| 56 |
+
self.log_interval = log_interval
|
| 57 |
+
self.eval_interval = eval_interval
|
| 58 |
+
|
| 59 |
+
# Auto-detect device
|
| 60 |
+
if device:
|
| 61 |
+
self.device = torch.device(device)
|
| 62 |
+
elif torch.cuda.is_available():
|
| 63 |
+
self.device = torch.device("cuda")
|
| 64 |
+
else:
|
| 65 |
+
self.device = torch.device("cpu")
|
| 66 |
+
|
| 67 |
+
print(f"[ViuAI Trainer] Using device: {self.device}")
|
| 68 |
+
|
| 69 |
+
# Move model to device
|
| 70 |
+
self.model = self.model.to(self.device)
|
| 71 |
+
|
| 72 |
+
# Setup optimizer (AdamW with weight decay)
|
| 73 |
+
self.optimizer = self._create_optimizer()
|
| 74 |
+
|
| 75 |
+
# Training state
|
| 76 |
+
self.global_step = 0
|
| 77 |
+
self.best_eval_loss = float("inf")
|
| 78 |
+
self.training_log: list = []
|
| 79 |
+
|
| 80 |
+
def _create_optimizer(self) -> torch.optim.Optimizer:
|
| 81 |
+
"""Create AdamW optimizer with separate weight decay groups.
|
| 82 |
+
|
| 83 |
+
Weight decay is NOT applied to bias and normalization parameters.
|
| 84 |
+
"""
|
| 85 |
+
decay_params = []
|
| 86 |
+
no_decay_params = []
|
| 87 |
+
|
| 88 |
+
for name, param in self.model.named_parameters():
|
| 89 |
+
if not param.requires_grad:
|
| 90 |
+
continue
|
| 91 |
+
if "norm" in name or "bias" in name:
|
| 92 |
+
no_decay_params.append(param)
|
| 93 |
+
else:
|
| 94 |
+
decay_params.append(param)
|
| 95 |
+
|
| 96 |
+
param_groups = [
|
| 97 |
+
{"params": decay_params, "weight_decay": self.weight_decay},
|
| 98 |
+
{"params": no_decay_params, "weight_decay": 0.0},
|
| 99 |
+
]
|
| 100 |
+
|
| 101 |
+
return torch.optim.AdamW(
|
| 102 |
+
param_groups,
|
| 103 |
+
lr=self.learning_rate,
|
| 104 |
+
betas=(0.9, 0.95),
|
| 105 |
+
eps=1e-8,
|
| 106 |
+
)
|
| 107 |
+
|
| 108 |
+
def _get_lr(self, step: int, total_steps: int) -> float:
|
| 109 |
+
"""Compute learning rate with linear warmup and cosine decay.
|
| 110 |
+
|
| 111 |
+
Args:
|
| 112 |
+
step: Current training step.
|
| 113 |
+
total_steps: Total number of training steps.
|
| 114 |
+
|
| 115 |
+
Returns:
|
| 116 |
+
Current learning rate.
|
| 117 |
+
"""
|
| 118 |
+
if step < self.warmup_steps:
|
| 119 |
+
# Linear warmup
|
| 120 |
+
return self.learning_rate * (step / max(1, self.warmup_steps))
|
| 121 |
+
else:
|
| 122 |
+
# Cosine decay
|
| 123 |
+
progress = (step - self.warmup_steps) / max(1, total_steps - self.warmup_steps)
|
| 124 |
+
return self.learning_rate * 0.5 * (1.0 + math.cos(math.pi * progress))
|
| 125 |
+
|
| 126 |
+
def _update_lr(self, step: int, total_steps: int) -> float:
|
| 127 |
+
"""Update the learning rate for the current step."""
|
| 128 |
+
lr = self._get_lr(step, total_steps)
|
| 129 |
+
for param_group in self.optimizer.param_groups:
|
| 130 |
+
param_group["lr"] = lr
|
| 131 |
+
return lr
|
| 132 |
+
|
| 133 |
+
def train(self) -> Dict:
|
| 134 |
+
"""Run the full training loop.
|
| 135 |
+
|
| 136 |
+
Returns:
|
| 137 |
+
Dictionary with training metrics.
|
| 138 |
+
"""
|
| 139 |
+
total_steps = len(self.train_dataloader) * self.num_epochs
|
| 140 |
+
os.makedirs(self.save_dir, exist_ok=True)
|
| 141 |
+
|
| 142 |
+
print(f"\n{'='*60}")
|
| 143 |
+
print(f" ViuAI-1M Training")
|
| 144 |
+
print(f"{'='*60}")
|
| 145 |
+
print(f" Parameters: {self.model.count_parameters():,}")
|
| 146 |
+
print(f" Epochs: {self.num_epochs}")
|
| 147 |
+
print(f" Total steps: {total_steps}")
|
| 148 |
+
print(f" Learning rate: {self.learning_rate}")
|
| 149 |
+
print(f" Weight decay: {self.weight_decay}")
|
| 150 |
+
print(f" Warmup steps: {self.warmup_steps}")
|
| 151 |
+
print(f" Device: {self.device}")
|
| 152 |
+
print(f"{'='*60}\n")
|
| 153 |
+
|
| 154 |
+
self.model.train()
|
| 155 |
+
start_time = time.time()
|
| 156 |
+
|
| 157 |
+
for epoch in range(self.num_epochs):
|
| 158 |
+
epoch_loss = 0.0
|
| 159 |
+
epoch_steps = 0
|
| 160 |
+
|
| 161 |
+
for batch in self.train_dataloader:
|
| 162 |
+
# Move batch to device
|
| 163 |
+
input_ids = batch["input_ids"].to(self.device)
|
| 164 |
+
labels = batch["labels"].to(self.device)
|
| 165 |
+
|
| 166 |
+
# Update learning rate
|
| 167 |
+
current_lr = self._update_lr(self.global_step, total_steps)
|
| 168 |
+
|
| 169 |
+
# Forward pass
|
| 170 |
+
output = self.model(input_ids=input_ids, labels=labels)
|
| 171 |
+
loss = output["loss"]
|
| 172 |
+
|
| 173 |
+
# Backward pass
|
| 174 |
+
self.optimizer.zero_grad()
|
| 175 |
+
loss.backward()
|
| 176 |
+
|
| 177 |
+
# Gradient clipping
|
| 178 |
+
grad_norm = torch.nn.utils.clip_grad_norm_(
|
| 179 |
+
self.model.parameters(), self.max_grad_norm
|
| 180 |
+
)
|
| 181 |
+
|
| 182 |
+
# Update weights
|
| 183 |
+
self.optimizer.step()
|
| 184 |
+
|
| 185 |
+
# Track metrics
|
| 186 |
+
epoch_loss += loss.item()
|
| 187 |
+
epoch_steps += 1
|
| 188 |
+
self.global_step += 1
|
| 189 |
+
|
| 190 |
+
# Log progress
|
| 191 |
+
if self.global_step % self.log_interval == 0:
|
| 192 |
+
avg_loss = epoch_loss / epoch_steps
|
| 193 |
+
elapsed = time.time() - start_time
|
| 194 |
+
tokens_per_sec = (
|
| 195 |
+
self.global_step * input_ids.shape[0] * input_ids.shape[1]
|
| 196 |
+
) / elapsed
|
| 197 |
+
|
| 198 |
+
log_entry = {
|
| 199 |
+
"step": self.global_step,
|
| 200 |
+
"epoch": epoch + 1,
|
| 201 |
+
"loss": loss.item(),
|
| 202 |
+
"avg_loss": avg_loss,
|
| 203 |
+
"lr": current_lr,
|
| 204 |
+
"grad_norm": grad_norm.item(),
|
| 205 |
+
"tokens_per_sec": tokens_per_sec,
|
| 206 |
+
}
|
| 207 |
+
self.training_log.append(log_entry)
|
| 208 |
+
|
| 209 |
+
print(
|
| 210 |
+
f" Step {self.global_step:>6d}/{total_steps} | "
|
| 211 |
+
f"Epoch {epoch+1}/{self.num_epochs} | "
|
| 212 |
+
f"Loss: {loss.item():.4f} | "
|
| 213 |
+
f"Avg Loss: {avg_loss:.4f} | "
|
| 214 |
+
f"LR: {current_lr:.2e} | "
|
| 215 |
+
f"Grad Norm: {grad_norm:.3f} | "
|
| 216 |
+
f"Tok/s: {tokens_per_sec:.0f}"
|
| 217 |
+
)
|
| 218 |
+
|
| 219 |
+
# Evaluation
|
| 220 |
+
if self.eval_dataloader and self.global_step % self.eval_interval == 0:
|
| 221 |
+
eval_loss = self.evaluate()
|
| 222 |
+
if eval_loss < self.best_eval_loss:
|
| 223 |
+
self.best_eval_loss = eval_loss
|
| 224 |
+
self.save_checkpoint("best")
|
| 225 |
+
print(f" ★ New best eval loss: {eval_loss:.4f}")
|
| 226 |
+
self.model.train()
|
| 227 |
+
|
| 228 |
+
# End of epoch
|
| 229 |
+
avg_epoch_loss = epoch_loss / max(1, epoch_steps)
|
| 230 |
+
print(f"\n [Epoch {epoch+1}/{self.num_epochs}] Average Loss: {avg_epoch_loss:.4f}\n")
|
| 231 |
+
|
| 232 |
+
# Save checkpoint at end of each epoch
|
| 233 |
+
self.save_checkpoint(f"epoch_{epoch+1}")
|
| 234 |
+
|
| 235 |
+
# Training complete
|
| 236 |
+
total_time = time.time() - start_time
|
| 237 |
+
print(f"\n{'='*60}")
|
| 238 |
+
print(f" Training Complete!")
|
| 239 |
+
print(f" Total time: {total_time:.1f}s")
|
| 240 |
+
print(f" Best eval loss: {self.best_eval_loss:.4f}")
|
| 241 |
+
print(f"{'='*60}\n")
|
| 242 |
+
|
| 243 |
+
# Save final checkpoint and training log
|
| 244 |
+
self.save_checkpoint("final")
|
| 245 |
+
self._save_training_log()
|
| 246 |
+
|
| 247 |
+
return {
|
| 248 |
+
"total_steps": self.global_step,
|
| 249 |
+
"total_time": total_time,
|
| 250 |
+
"best_eval_loss": self.best_eval_loss,
|
| 251 |
+
"final_loss": avg_epoch_loss,
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
@torch.no_grad()
|
| 255 |
+
def evaluate(self) -> float:
|
| 256 |
+
"""Run evaluation on the eval dataset.
|
| 257 |
+
|
| 258 |
+
Returns:
|
| 259 |
+
Average evaluation loss.
|
| 260 |
+
"""
|
| 261 |
+
self.model.eval()
|
| 262 |
+
total_loss = 0.0
|
| 263 |
+
total_steps = 0
|
| 264 |
+
|
| 265 |
+
for batch in self.eval_dataloader:
|
| 266 |
+
input_ids = batch["input_ids"].to(self.device)
|
| 267 |
+
labels = batch["labels"].to(self.device)
|
| 268 |
+
|
| 269 |
+
output = self.model(input_ids=input_ids, labels=labels)
|
| 270 |
+
total_loss += output["loss"].item()
|
| 271 |
+
total_steps += 1
|
| 272 |
+
|
| 273 |
+
avg_loss = total_loss / max(1, total_steps)
|
| 274 |
+
print(f" [Eval] Loss: {avg_loss:.4f}")
|
| 275 |
+
return avg_loss
|
| 276 |
+
|
| 277 |
+
def save_checkpoint(self, name: str) -> None:
|
| 278 |
+
"""Save model checkpoint.
|
| 279 |
+
|
| 280 |
+
Args:
|
| 281 |
+
name: Checkpoint name (e.g., 'best', 'epoch_1', 'final').
|
| 282 |
+
"""
|
| 283 |
+
checkpoint_dir = os.path.join(self.save_dir, name)
|
| 284 |
+
os.makedirs(checkpoint_dir, exist_ok=True)
|
| 285 |
+
|
| 286 |
+
# Save model weights
|
| 287 |
+
torch.save(
|
| 288 |
+
self.model.state_dict(),
|
| 289 |
+
os.path.join(checkpoint_dir, "model.pt"),
|
| 290 |
+
)
|
| 291 |
+
|
| 292 |
+
# Save optimizer state
|
| 293 |
+
torch.save(
|
| 294 |
+
self.optimizer.state_dict(),
|
| 295 |
+
os.path.join(checkpoint_dir, "optimizer.pt"),
|
| 296 |
+
)
|
| 297 |
+
|
| 298 |
+
# Save training state
|
| 299 |
+
state = {
|
| 300 |
+
"global_step": self.global_step,
|
| 301 |
+
"best_eval_loss": self.best_eval_loss,
|
| 302 |
+
}
|
| 303 |
+
with open(os.path.join(checkpoint_dir, "training_state.json"), "w") as f:
|
| 304 |
+
json.dump(state, f, indent=2)
|
| 305 |
+
|
| 306 |
+
# Save config
|
| 307 |
+
self.model.config.save(os.path.join(checkpoint_dir, "config.json"))
|
| 308 |
+
|
| 309 |
+
print(f" [Checkpoint] Saved: {checkpoint_dir}")
|
| 310 |
+
|
| 311 |
+
def load_checkpoint(self, name: str) -> None:
|
| 312 |
+
"""Load model checkpoint.
|
| 313 |
+
|
| 314 |
+
Args:
|
| 315 |
+
name: Checkpoint name to load.
|
| 316 |
+
"""
|
| 317 |
+
checkpoint_dir = os.path.join(self.save_dir, name)
|
| 318 |
+
|
| 319 |
+
# Load model weights
|
| 320 |
+
self.model.load_state_dict(
|
| 321 |
+
torch.load(os.path.join(checkpoint_dir, "model.pt"), map_location=self.device)
|
| 322 |
+
)
|
| 323 |
+
|
| 324 |
+
# Load optimizer state
|
| 325 |
+
opt_path = os.path.join(checkpoint_dir, "optimizer.pt")
|
| 326 |
+
if os.path.exists(opt_path):
|
| 327 |
+
self.optimizer.load_state_dict(
|
| 328 |
+
torch.load(opt_path, map_location=self.device)
|
| 329 |
+
)
|
| 330 |
+
|
| 331 |
+
# Load training state
|
| 332 |
+
state_path = os.path.join(checkpoint_dir, "training_state.json")
|
| 333 |
+
if os.path.exists(state_path):
|
| 334 |
+
with open(state_path, "r") as f:
|
| 335 |
+
state = json.load(f)
|
| 336 |
+
self.global_step = state.get("global_step", 0)
|
| 337 |
+
self.best_eval_loss = state.get("best_eval_loss", float("inf"))
|
| 338 |
+
|
| 339 |
+
print(f" [Checkpoint] Loaded: {checkpoint_dir}")
|
| 340 |
+
|
| 341 |
+
def _save_training_log(self) -> None:
|
| 342 |
+
"""Save training metrics log to JSON."""
|
| 343 |
+
log_path = os.path.join(self.save_dir, "training_log.json")
|
| 344 |
+
with open(log_path, "w") as f:
|
| 345 |
+
json.dump(self.training_log, f, indent=2)
|
| 346 |
+
print(f" [Log] Saved training log to {log_path}")
|