ViuAI commited on
Commit
7f1c419
·
verified ·
1 Parent(s): dec50e1

Initial upload: ViuAI-1M architecture (1.18M params)

Browse files
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}")