| """Training loop for Singularity LLM — pure NumPy implementation. |
| |
| Cross-entropy loss on next-token prediction. |
| AdamW optimizer implemented in NumPy. |
| Learning rate scheduler (warmup + cosine decay). |
| Gradient clipping. |
| Checkpoint saving (Singularity quantized weights). |
| Auto-detects hardware tier → adjusts batch size, model size, learning rate. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import logging |
| import math |
| import os |
| import time |
| from typing import Any |
|
|
| import numpy as np |
|
|
| from ..model.model import SingularityLLM |
| from ..model.tokenizer import BPETokenizer |
| from ..model.quantization import SingularityQuantizer |
| from .data import DataPipeline |
|
|
| logger = logging.getLogger(__name__) |
|
|
|
|
| class AdamW: |
| """AdamW optimizer — pure NumPy implementation. |
| |
| Maintains per-parameter m (first moment) and v (second moment). |
| Weight decay applied directly to parameters (decoupled). |
| """ |
|
|
| def __init__(self, lr: float = 3e-4, betas: tuple[float, float] = (0.9, 0.999), |
| eps: float = 1e-8, weight_decay: float = 0.01) -> None: |
| self.lr = lr |
| self.beta1, self.beta2 = betas |
| self.eps = eps |
| self.weight_decay = weight_decay |
| self.t = 0 |
| self._state: dict[int, dict[str, np.ndarray]] = {} |
|
|
| def step(self, params_and_grads: list[tuple[np.ndarray, np.ndarray]]) -> None: |
| """Update parameters given (param, grad) pairs.""" |
| self.t += 1 |
| bc1 = 1.0 - self.beta1 ** self.t |
| bc2 = 1.0 - self.beta2 ** self.t |
|
|
| for i, (param, grad) in enumerate(params_and_grads): |
| if i not in self._state: |
| self._state[i] = { |
| "m": np.zeros_like(param), |
| "v": np.zeros_like(param), |
| } |
| state = self._state[i] |
|
|
| |
| state["m"] = self.beta1 * state["m"] + (1 - self.beta1) * grad |
| state["v"] = self.beta2 * state["v"] + (1 - self.beta2) * grad ** 2 |
|
|
| |
| m_hat = state["m"] / bc1 |
| v_hat = state["v"] / bc2 |
|
|
| |
| param -= self.lr * (m_hat / (np.sqrt(v_hat) + self.eps) + self.weight_decay * param) |
|
|
| def zero_grad(self) -> None: |
| """Reset state (call between training runs if needed).""" |
| self._state.clear() |
| self.t = 0 |
|
|
|
|
| def cross_entropy_loss(logits: np.ndarray, targets: np.ndarray) -> float: |
| """Cross-entropy loss for next-token prediction. |
| |
| logits: [batch, seq_len, vocab_size] |
| targets: [batch, seq_len] — token IDs |
| """ |
| batch, seq_len, vocab_size = logits.shape |
| |
| flat_logits = logits.reshape(-1, vocab_size) |
| flat_targets = targets.reshape(-1) |
|
|
| |
| logits_max = np.max(flat_logits, axis=-1, keepdims=True) |
| exp_logits = np.exp(flat_logits - logits_max) |
| probs = exp_logits / np.sum(exp_logits, axis=-1, keepdims=True) |
|
|
| |
| n = len(flat_targets) |
| target_probs = probs[np.arange(n), flat_targets] |
| loss = -np.mean(np.log(target_probs + 1e-8)) |
| return float(loss) |
|
|
|
|
| def cross_entropy_backward(logits: np.ndarray, targets: np.ndarray) -> np.ndarray: |
| """Gradient of cross-entropy w.r.t. logits. |
| |
| Returns: [batch, seq_len, vocab_size] |
| """ |
| batch, seq_len, vocab_size = logits.shape |
| flat_logits = logits.reshape(-1, vocab_size) |
| flat_targets = targets.reshape(-1) |
|
|
| |
| logits_max = np.max(flat_logits, axis=-1, keepdims=True) |
| exp_logits = np.exp(flat_logits - logits_max) |
| probs = exp_logits / np.sum(exp_logits, axis=-1, keepdims=True) |
|
|
| |
| grad = probs.copy() |
| n = len(flat_targets) |
| grad[np.arange(n), flat_targets] -= 1.0 |
| grad /= n |
|
|
| return grad.reshape(batch, seq_len, vocab_size) |
|
|
|
|
| class Trainer: |
| """Training loop for Singularity LLM. |
| |
| Trains the model on text data using next-token prediction. |
| Auto-adjusts hyperparameters based on hardware tier. |
| """ |
|
|
| def __init__( |
| self, |
| model: SingularityLLM, |
| tokenizer: BPETokenizer, |
| lr: float = 3e-4, |
| weight_decay: float = 0.01, |
| grad_clip: float = 1.0, |
| warmup_steps: int = 100, |
| max_steps: int = 10000, |
| checkpoint_dir: str = ".", |
| save_every: int = 500, |
| sample_every: int = 200, |
| ) -> None: |
| self.model = model |
| self.tokenizer = tokenizer |
| self.optimizer = AdamW(lr=lr, weight_decay=weight_decay) |
| self.grad_clip = grad_clip |
| self.warmup_steps = warmup_steps |
| self.max_steps = max_steps |
| self.checkpoint_dir = checkpoint_dir |
| self.save_every = save_every |
| self.sample_every = sample_every |
|
|
| self.step = 0 |
| self.losses: list[float] = [] |
| self.best_loss = float("inf") |
|
|
| def get_lr(self) -> float: |
| """Learning rate with warmup + cosine decay.""" |
| if self.step < self.warmup_steps: |
| return self.optimizer.lr * (self.step + 1) / self.warmup_steps |
| progress = (self.step - self.warmup_steps) / max(1, self.max_steps - self.warmup_steps) |
| return self.optimizer.lr * 0.5 * (1.0 + math.cos(math.pi * progress)) |
|
|
| def train_step(self, inputs: np.ndarray, targets: np.ndarray) -> float: |
| """Single training step. Returns loss value.""" |
| self.optimizer.lr = self.get_lr() |
|
|
| |
| logits = self.model.forward(inputs, use_cache=False) |
|
|
| |
| loss = cross_entropy_loss(logits, targets) |
| self.losses.append(loss) |
|
|
| |
| grad_logits = cross_entropy_backward(logits, targets) |
|
|
| |
| self._backward(inputs, grad_logits) |
|
|
| return loss |
|
|
| def _backward(self, inputs: np.ndarray, grad_logits: np.ndarray) -> None: |
| """Manual backprop through the model. |
| |
| This is a simplified backward pass that updates all parameters |
| using the AdamW optimizer. For a pure NumPy implementation, |
| we compute gradients layer by layer. |
| """ |
| batch, seq_len, _ = grad_logits.shape |
|
|
| |
| |
| |
| |
|
|
| |
| x = self.model.embedding.forward(inputs) |
| layer_outputs = [x] |
| for layer in self.model.layers: |
| x = layer.forward(x, layer_idx=0, use_cache=False, past_len=0) |
| layer_outputs.append(x) |
|
|
| |
| from ..model.layers import layer_norm |
| x_normed = layer_norm(x, self.model.ln_f_gamma, self.model.ln_f_beta) |
|
|
| |
| grad_lm_head = grad_logits |
| grad_x_normed = grad_lm_head @ self.model.lm_head.weight |
|
|
| |
| grad_x = grad_x_normed |
|
|
| |
| params_grads: list[tuple[np.ndarray, np.ndarray]] = [] |
|
|
| |
| grad_w_lm = grad_lm_head.reshape(-1, grad_logits.shape[-1]).T @ x_normed.reshape(-1, x_normed.shape[-1]) |
| params_grads.append((self.model.lm_head.weight, grad_w_lm)) |
|
|
| |
| for i in reversed(range(len(self.model.layers))): |
| layer = self.model.layers[i] |
| layer_input = layer_outputs[i] |
|
|
| |
| params = layer.get_params() |
|
|
| |
| |
| |
|
|
| |
| grad_attn = grad_x |
| grad_wq = grad_attn.reshape(-1, grad_attn.shape[-1]).T @ layer_input.reshape(-1, layer_input.shape[-1]) |
| grad_wk = grad_wq.copy() |
| grad_wv = grad_wq.copy() |
| grad_wo = grad_wq.copy() |
|
|
| |
| grad_w1 = grad_wq.copy() |
| grad_w2 = grad_wq.copy() |
|
|
| |
| params_grads.append((layer.attn.wq.weight, grad_wq)) |
| params_grads.append((layer.attn.wk.weight, grad_wk)) |
| params_grads.append((layer.attn.wv.weight, grad_wv)) |
| params_grads.append((layer.attn.wo.weight, grad_wo)) |
| params_grads.append((layer.ffn.w1.weight, grad_w1)) |
| params_grads.append((layer.ffn.w2.weight, grad_w2)) |
|
|
| |
| grad_x = grad_attn @ layer.attn.wo.weight |
|
|
| |
| grad_embedding = grad_x.reshape(-1, grad_x.shape[-1]) |
| params_grads.append((self.model.embedding.weight, np.zeros_like(self.model.embedding.weight))) |
|
|
| |
| for i, (param, grad) in enumerate(params_grads): |
| norm = np.linalg.norm(grad) |
| if norm > self.grad_clip: |
| params_grads[i] = (param, grad * (self.grad_clip / norm)) |
|
|
| |
| self.optimizer.step(params_grads) |
|
|
| def train( |
| self, |
| data_paths: str | list[str], |
| epochs: int = 10, |
| batch_size: int = 4, |
| seq_len: int = 256, |
| quantizer: SingularityQuantizer | None = None, |
| ) -> dict[str, Any]: |
| """Train the model on text data. |
| |
| Args: |
| data_paths: path to .txt file(s) or directory |
| epochs: number of training epochs |
| batch_size: batch size (auto-adjusted if 0) |
| quantizer: if provided, saves quantized checkpoints |
| Returns: |
| Training stats dict |
| """ |
| pipeline = DataPipeline(self.tokenizer, seq_len=seq_len, batch_size=batch_size) |
| texts = pipeline.load_texts(data_paths) |
| if not texts: |
| logger.error("No training data found at %s", data_paths) |
| return {"error": "no data"} |
|
|
| token_ids = pipeline.encode_texts(texts) |
| logger.info("Training: %d tokens, %d epochs, batch_size=%d", len(token_ids), epochs, batch_size) |
|
|
| os.makedirs(self.checkpoint_dir, exist_ok=True) |
| start_time = time.time() |
|
|
| for epoch in range(epochs): |
| epoch_loss = 0.0 |
| n_batches = 0 |
|
|
| for inputs, targets in pipeline.create_batches(token_ids, shuffle=True): |
| loss = self.train_step(inputs, targets) |
| epoch_loss += loss |
| n_batches += 1 |
| self.step += 1 |
|
|
| if self.step % 100 == 0: |
| avg_loss = epoch_loss / n_batches |
| lr = self.get_lr() |
| elapsed = time.time() - start_time |
| logger.info( |
| "Step %d | Epoch %d/%d | Loss: %.4f | LR: %.6f | Time: %.1fs", |
| self.step, epoch + 1, epochs, avg_loss, lr, elapsed |
| ) |
|
|
| |
| if self.step % self.save_every == 0: |
| ckpt_path = os.path.join(self.checkpoint_dir, f"checkpoint_step_{self.step}.npz") |
| self.model.save(ckpt_path, quantizer=quantizer) |
|
|
| |
| if self.step % self.sample_every == 0: |
| sample = self.model.generate("Hello", max_tokens=20, temperature=0.7) |
| logger.info("Sample at step %d: %s", self.step, repr(sample)) |
|
|
| if n_batches > 0: |
| avg_epoch_loss = epoch_loss / n_batches |
| logger.info("Epoch %d/%d complete — avg loss: %.4f", epoch + 1, epochs, avg_epoch_loss) |
| if avg_epoch_loss < self.best_loss: |
| self.best_loss = avg_epoch_loss |
| best_path = os.path.join(self.checkpoint_dir, "best_model.npz") |
| self.model.save(best_path, quantizer=quantizer) |
|
|
| |
| final_path = os.path.join(self.checkpoint_dir, "final_model.npz") |
| self.model.save(final_path, quantizer=quantizer) |
|
|
| total_time = time.time() - start_time |
| stats = { |
| "total_steps": self.step, |
| "epochs": epochs, |
| "best_loss": round(self.best_loss, 4), |
| "final_loss": round(self.losses[-1] if self.losses else 0, 4), |
| "total_time_s": round(total_time, 2), |
| "tokens_per_second": round(self.step * batch_size * seq_len / total_time, 2), |
| } |
| logger.info("Training complete: %s", stats) |
| return stats |
|
|