Spaces:
Sleeping
Sleeping
Fix API test compatibility with httpx 0.28+, fix model saving with tied weights, and update walkthrough documentation
5a72678 | """ | |
| OpenMind Transformer Model Architecture. | |
| A decoder-only transformer (GPT-style) with: | |
| - Rotary Positional Embeddings (RoPE) | |
| - RMSNorm (pre-norm architecture) | |
| - SwiGLU activation in feed-forward network | |
| - Grouped Query Attention (GQA) support | |
| - KV-Cache for efficient autoregressive inference | |
| - Hugging Face save/load compatibility | |
| """ | |
| import math | |
| import json | |
| import os | |
| from dataclasses import asdict | |
| from typing import Optional | |
| import torch | |
| import torch.nn as nn | |
| import torch.nn.functional as F | |
| try: | |
| from .config_openmind import OpenMindConfig | |
| except ImportError: | |
| import sys as _sys | |
| _sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) | |
| from config_openmind import OpenMindConfig | |
| # ─── RMSNorm ────────────────────────────────────────────────────────────────── | |
| class RMSNorm(nn.Module): | |
| """Root Mean Square Layer Normalization (Zhang & Sennrich, 2019).""" | |
| def __init__(self, dim: int, eps: float = 1e-6): | |
| super().__init__() | |
| self.eps = eps | |
| self.weight = nn.Parameter(torch.ones(dim)) | |
| def _norm(self, x: torch.Tensor) -> torch.Tensor: | |
| return x * torch.rsqrt(x.pow(2).mean(-1, keepdim=True) + self.eps) | |
| def forward(self, x: torch.Tensor) -> torch.Tensor: | |
| output = self._norm(x.float()).type_as(x) | |
| return output * self.weight | |
| # ─── Rotary Positional Embeddings ───────────────────────────────────────────── | |
| class RotaryEmbedding(nn.Module): | |
| """Rotary Positional Embeddings (Su et al., 2021).""" | |
| def __init__(self, dim: int, max_seq_len: int = 2048, theta: float = 10000.0): | |
| super().__init__() | |
| self.dim = dim | |
| self.max_seq_len = max_seq_len | |
| self.theta = theta | |
| # Precompute frequency matrix | |
| inv_freq = 1.0 / (theta ** (torch.arange(0, dim, 2).float() / dim)) | |
| self.register_buffer("inv_freq", inv_freq, persistent=False) | |
| # Precompute cos/sin cache | |
| self._build_cache(max_seq_len) | |
| def _build_cache(self, seq_len: int): | |
| t = torch.arange(seq_len, device=self.inv_freq.device, dtype=self.inv_freq.dtype) | |
| freqs = torch.outer(t, self.inv_freq) | |
| emb = torch.cat((freqs, freqs), dim=-1) | |
| self.register_buffer("cos_cached", emb.cos(), persistent=False) | |
| self.register_buffer("sin_cached", emb.sin(), persistent=False) | |
| def forward(self, x: torch.Tensor, seq_len: int, offset: int = 0): | |
| if seq_len + offset > self.max_seq_len: | |
| self._build_cache(seq_len + offset) | |
| cos = self.cos_cached[offset: offset + seq_len] | |
| sin = self.sin_cached[offset: offset + seq_len] | |
| return cos, sin | |
| def rotate_half(x: torch.Tensor) -> torch.Tensor: | |
| """Rotate half the hidden dims of the input.""" | |
| x1 = x[..., : x.shape[-1] // 2] | |
| x2 = x[..., x.shape[-1] // 2:] | |
| return torch.cat((-x2, x1), dim=-1) | |
| def apply_rotary_pos_emb( | |
| q: torch.Tensor, | |
| k: torch.Tensor, | |
| cos: torch.Tensor, | |
| sin: torch.Tensor, | |
| ) -> tuple[torch.Tensor, torch.Tensor]: | |
| """Apply rotary positional embeddings to query and key tensors.""" | |
| # cos/sin shape: (seq_len, head_dim) -> broadcast to (1, 1, seq_len, head_dim) | |
| cos = cos.unsqueeze(0).unsqueeze(0) | |
| sin = sin.unsqueeze(0).unsqueeze(0) | |
| q_embed = (q * cos) + (rotate_half(q) * sin) | |
| k_embed = (k * cos) + (rotate_half(k) * sin) | |
| return q_embed, k_embed | |
| # ─── Grouped Query Attention ────────────────────────────────────────────────── | |
| class GroupedQueryAttention(nn.Module): | |
| """ | |
| Multi-Head Attention with optional Grouped Query Attention (GQA). | |
| When n_kv_heads < n_heads, key/value heads are shared across groups, | |
| reducing memory and compute for KV cache during inference. | |
| """ | |
| def __init__(self, config: OpenMindConfig): | |
| super().__init__() | |
| self.n_heads = config.n_heads | |
| self.n_kv_heads = config.n_kv_heads | |
| self.head_dim = config.head_dim | |
| self.n_rep = self.n_heads // self.n_kv_heads # Number of query heads per KV head | |
| self.q_proj = nn.Linear(config.dim, self.n_heads * self.head_dim, bias=False) | |
| self.k_proj = nn.Linear(config.dim, self.n_kv_heads * self.head_dim, bias=False) | |
| self.v_proj = nn.Linear(config.dim, self.n_kv_heads * self.head_dim, bias=False) | |
| self.o_proj = nn.Linear(self.n_heads * self.head_dim, config.dim, bias=False) | |
| self.dropout = nn.Dropout(config.dropout) | |
| self.rotary_emb = RotaryEmbedding( | |
| self.head_dim, | |
| max_seq_len=config.max_seq_len, | |
| theta=config.rope_theta, | |
| ) | |
| def _repeat_kv(self, x: torch.Tensor) -> torch.Tensor: | |
| """Repeat KV heads to match number of query heads (for GQA).""" | |
| if self.n_rep == 1: | |
| return x | |
| bs, n_kv, seq_len, head_dim = x.shape | |
| x = x[:, :, None, :, :].expand(bs, n_kv, self.n_rep, seq_len, head_dim) | |
| return x.reshape(bs, self.n_heads, seq_len, head_dim) | |
| def forward( | |
| self, | |
| x: torch.Tensor, | |
| attention_mask: Optional[torch.Tensor] = None, | |
| position_ids: Optional[torch.Tensor] = None, | |
| past_key_value: Optional[tuple[torch.Tensor, torch.Tensor]] = None, | |
| use_cache: bool = False, | |
| ) -> tuple[torch.Tensor, Optional[tuple[torch.Tensor, torch.Tensor]]]: | |
| bsz, seq_len, _ = x.shape | |
| # Project to Q, K, V | |
| q = self.q_proj(x).view(bsz, seq_len, self.n_heads, self.head_dim).transpose(1, 2) | |
| k = self.k_proj(x).view(bsz, seq_len, self.n_kv_heads, self.head_dim).transpose(1, 2) | |
| v = self.v_proj(x).view(bsz, seq_len, self.n_kv_heads, self.head_dim).transpose(1, 2) | |
| # Determine offset for RoPE (for KV cache continuation) | |
| kv_seq_len = seq_len | |
| offset = 0 | |
| if past_key_value is not None: | |
| offset = past_key_value[0].shape[-2] | |
| kv_seq_len += offset | |
| # Apply rotary embeddings | |
| cos, sin = self.rotary_emb(q, seq_len, offset=offset) | |
| q, k = apply_rotary_pos_emb(q, k, cos, sin) | |
| # Handle KV cache | |
| if past_key_value is not None: | |
| k = torch.cat([past_key_value[0], k], dim=2) | |
| v = torch.cat([past_key_value[1], v], dim=2) | |
| new_cache = (k, v) if use_cache else None | |
| # Expand KV heads for GQA | |
| k = self._repeat_kv(k) | |
| v = self._repeat_kv(v) | |
| # Scaled dot-product attention | |
| attn_weights = torch.matmul(q, k.transpose(2, 3)) / math.sqrt(self.head_dim) | |
| # Apply causal mask | |
| if attention_mask is not None: | |
| attn_weights = attn_weights + attention_mask | |
| attn_weights = F.softmax(attn_weights, dim=-1, dtype=torch.float32).to(q.dtype) | |
| attn_weights = self.dropout(attn_weights) | |
| attn_output = torch.matmul(attn_weights, v) | |
| # Reshape and project output | |
| attn_output = attn_output.transpose(1, 2).contiguous().view(bsz, seq_len, -1) | |
| attn_output = self.o_proj(attn_output) | |
| return attn_output, new_cache | |
| # ─── SwiGLU Feed-Forward Network ────────────────────────────────────────────── | |
| class SwiGLU(nn.Module): | |
| """SwiGLU activation function (Shazeer, 2020) with gated linear unit.""" | |
| def __init__(self, config: OpenMindConfig): | |
| super().__init__() | |
| self.gate_proj = nn.Linear(config.dim, config.intermediate_dim, bias=False) | |
| self.up_proj = nn.Linear(config.dim, config.intermediate_dim, bias=False) | |
| self.down_proj = nn.Linear(config.intermediate_dim, config.dim, bias=False) | |
| self.dropout = nn.Dropout(config.dropout) | |
| def forward(self, x: torch.Tensor) -> torch.Tensor: | |
| return self.dropout(self.down_proj(F.silu(self.gate_proj(x)) * self.up_proj(x))) | |
| # ─── Transformer Block ──────────────────────────────────────────────────────── | |
| class TransformerBlock(nn.Module): | |
| """Single transformer decoder block with pre-norm architecture.""" | |
| def __init__(self, config: OpenMindConfig): | |
| super().__init__() | |
| self.attention_norm = RMSNorm(config.dim) | |
| self.attention = GroupedQueryAttention(config) | |
| self.ffn_norm = RMSNorm(config.dim) | |
| self.feed_forward = SwiGLU(config) | |
| def forward( | |
| self, | |
| x: torch.Tensor, | |
| attention_mask: Optional[torch.Tensor] = None, | |
| position_ids: Optional[torch.Tensor] = None, | |
| past_key_value: Optional[tuple[torch.Tensor, torch.Tensor]] = None, | |
| use_cache: bool = False, | |
| ) -> tuple[torch.Tensor, Optional[tuple[torch.Tensor, torch.Tensor]]]: | |
| # Pre-norm attention with residual | |
| residual = x | |
| x = self.attention_norm(x) | |
| x, cache = self.attention(x, attention_mask, position_ids, past_key_value, use_cache) | |
| x = residual + x | |
| # Pre-norm feed-forward with residual | |
| residual = x | |
| x = self.ffn_norm(x) | |
| x = self.feed_forward(x) | |
| x = residual + x | |
| return x, cache | |
| # ─── Full Model ─────────────────────────────────────────────────────────────── | |
| class OpenMindModel(nn.Module): | |
| """ | |
| OpenMind Decoder-Only Transformer Language Model. | |
| Architecture: GPT-style with RoPE, RMSNorm, SwiGLU, and optional GQA. | |
| """ | |
| def __init__(self, config: OpenMindConfig): | |
| super().__init__() | |
| self.config = config | |
| # Token embeddings | |
| self.embed_tokens = nn.Embedding(config.vocab_size, config.dim) | |
| # Transformer layers | |
| self.layers = nn.ModuleList([ | |
| TransformerBlock(config) for _ in range(config.n_layers) | |
| ]) | |
| # Final normalization | |
| self.norm = RMSNorm(config.dim) | |
| # Language model head | |
| self.lm_head = nn.Linear(config.dim, config.vocab_size, bias=False) | |
| # Tie embeddings | |
| if config.tie_embeddings: | |
| self.lm_head.weight = self.embed_tokens.weight | |
| # Initialize weights | |
| self.apply(self._init_weights) | |
| # Report parameter count | |
| n_params = sum(p.numel() for p in self.parameters()) | |
| n_params_non_embed = n_params - self.embed_tokens.weight.numel() | |
| print(f"OpenMind Model initialized:") | |
| print(f" Total parameters: {n_params:,}") | |
| print(f" Non-embedding parameters: {n_params_non_embed:,}") | |
| def _init_weights(self, module: nn.Module): | |
| """Initialize weights using GPT-2 style initialization.""" | |
| if isinstance(module, nn.Linear): | |
| torch.nn.init.normal_(module.weight, mean=0.0, std=0.02) | |
| if module.bias is not None: | |
| torch.nn.init.zeros_(module.bias) | |
| elif isinstance(module, nn.Embedding): | |
| torch.nn.init.normal_(module.weight, mean=0.0, std=0.02) | |
| def _make_causal_mask( | |
| self, | |
| seq_len: int, | |
| dtype: torch.dtype, | |
| device: torch.device, | |
| past_len: int = 0, | |
| ) -> torch.Tensor: | |
| """Create causal attention mask.""" | |
| mask = torch.full( | |
| (seq_len, seq_len + past_len), float("-inf"), dtype=dtype, device=device | |
| ) | |
| mask = torch.triu(mask, diagonal=past_len + 1) | |
| return mask.unsqueeze(0).unsqueeze(0) # (1, 1, seq_len, total_len) | |
| def forward( | |
| self, | |
| input_ids: torch.Tensor, | |
| attention_mask: Optional[torch.Tensor] = None, | |
| position_ids: Optional[torch.Tensor] = None, | |
| past_key_values: Optional[list[tuple[torch.Tensor, torch.Tensor]]] = None, | |
| use_cache: bool = False, | |
| labels: Optional[torch.Tensor] = None, | |
| ) -> dict: | |
| """ | |
| Forward pass. | |
| Args: | |
| input_ids: Token IDs, shape (batch, seq_len) | |
| attention_mask: Optional mask | |
| position_ids: Optional position indices | |
| past_key_values: Optional KV cache from previous steps | |
| use_cache: Whether to return updated KV cache | |
| labels: Optional labels for computing loss (shifted internally) | |
| Returns: | |
| Dictionary with 'logits', 'loss' (if labels provided), 'past_key_values' | |
| """ | |
| bsz, seq_len = input_ids.shape | |
| device = input_ids.device | |
| # Determine past length for KV cache | |
| past_len = 0 | |
| if past_key_values is not None and past_key_values[0] is not None: | |
| past_len = past_key_values[0][0].shape[-2] | |
| # Embed tokens | |
| h = self.embed_tokens(input_ids) | |
| # Create causal mask | |
| causal_mask = self._make_causal_mask(seq_len, h.dtype, device, past_len) | |
| # Pass through transformer layers | |
| new_caches = [] | |
| for i, layer in enumerate(self.layers): | |
| past_kv = past_key_values[i] if past_key_values is not None else None | |
| h, cache = layer(h, causal_mask, position_ids, past_kv, use_cache) | |
| new_caches.append(cache) | |
| # Final norm and LM head | |
| h = self.norm(h) | |
| logits = self.lm_head(h) | |
| # Compute loss if labels provided | |
| loss = None | |
| if labels is not None: | |
| # Shift logits and labels for next-token prediction | |
| shift_logits = logits[..., :-1, :].contiguous() | |
| shift_labels = labels[..., 1:].contiguous() | |
| loss = F.cross_entropy( | |
| shift_logits.view(-1, shift_logits.size(-1)), | |
| shift_labels.view(-1), | |
| ignore_index=-100, | |
| ) | |
| return { | |
| "logits": logits, | |
| "loss": loss, | |
| "past_key_values": new_caches if use_cache else None, | |
| } | |
| def generate( | |
| self, | |
| input_ids: torch.Tensor, | |
| max_new_tokens: int = 256, | |
| temperature: float = 0.7, | |
| top_k: int = 50, | |
| top_p: float = 0.9, | |
| eos_token_id: int = 0, | |
| do_sample: bool = True, | |
| repetition_penalty: float = 1.0, | |
| ) -> torch.Tensor: | |
| """ | |
| Autoregressive text generation with KV-cache. | |
| Args: | |
| input_ids: Starting token IDs, shape (batch, prefix_len) | |
| max_new_tokens: Maximum tokens to generate | |
| temperature: Sampling temperature (1.0 = neutral) | |
| top_k: Top-k filtering (0 = disabled) | |
| top_p: Nucleus sampling threshold (1.0 = disabled) | |
| eos_token_id: Token ID that signals end of generation | |
| do_sample: If False, use greedy decoding | |
| repetition_penalty: Repetition penalty (1.0 = disabled) | |
| Returns: | |
| Generated token IDs including the input prefix | |
| """ | |
| self.eval() | |
| past_key_values = [None] * self.config.n_layers | |
| generated = input_ids | |
| for _ in range(max_new_tokens): | |
| # Only feed the last token if we have KV cache | |
| if past_key_values[0] is not None: | |
| curr_input = generated[:, -1:] | |
| else: | |
| curr_input = generated | |
| outputs = self.forward( | |
| curr_input, | |
| past_key_values=past_key_values, | |
| use_cache=True, | |
| ) | |
| logits = outputs["logits"][:, -1, :] | |
| past_key_values = outputs["past_key_values"] | |
| # Apply repetition penalty | |
| if repetition_penalty != 1.0: | |
| for i in range(logits.shape[0]): | |
| for token_id in set(generated[i].tolist()): | |
| logit = logits[i, token_id].item() | |
| if logit < 0: | |
| logits[i, token_id] = logit * repetition_penalty | |
| else: | |
| logits[i, token_id] = logit / repetition_penalty | |
| if do_sample: | |
| # Apply temperature | |
| logits = logits / max(temperature, 1e-8) | |
| # Top-k filtering | |
| if top_k > 0: | |
| top_k_vals = torch.topk(logits, min(top_k, logits.size(-1))) | |
| indices_to_remove = logits < top_k_vals.values[..., -1, None] | |
| logits[indices_to_remove] = float("-inf") | |
| # Top-p (nucleus) filtering | |
| if top_p < 1.0: | |
| sorted_logits, sorted_indices = torch.sort(logits, descending=True) | |
| cumulative_probs = torch.cumsum( | |
| F.softmax(sorted_logits, dim=-1), dim=-1 | |
| ) | |
| sorted_indices_to_remove = cumulative_probs > top_p | |
| sorted_indices_to_remove[..., 1:] = sorted_indices_to_remove[..., :-1].clone() | |
| sorted_indices_to_remove[..., 0] = 0 | |
| indices_to_remove = sorted_indices_to_remove.scatter( | |
| 1, sorted_indices, sorted_indices_to_remove | |
| ) | |
| logits[indices_to_remove] = float("-inf") | |
| # Sample | |
| probs = F.softmax(logits, dim=-1) | |
| next_token = torch.multinomial(probs, num_samples=1) | |
| else: | |
| # Greedy | |
| next_token = torch.argmax(logits, dim=-1, keepdim=True) | |
| generated = torch.cat([generated, next_token], dim=-1) | |
| # Stop if EOS token generated (for all sequences in batch) | |
| if (next_token == eos_token_id).all(): | |
| break | |
| return generated | |
| def count_parameters(self) -> dict: | |
| """Count model parameters by component.""" | |
| counts = {} | |
| counts["embedding"] = self.embed_tokens.weight.numel() | |
| counts["attention"] = sum( | |
| p.numel() for layer in self.layers | |
| for p in layer.attention.parameters() | |
| ) | |
| counts["ffn"] = sum( | |
| p.numel() for layer in self.layers | |
| for p in layer.feed_forward.parameters() | |
| ) | |
| counts["norm"] = sum( | |
| p.numel() for layer in self.layers | |
| for p in layer.attention_norm.parameters() | |
| ) + sum( | |
| p.numel() for layer in self.layers | |
| for p in layer.ffn_norm.parameters() | |
| ) + sum(p.numel() for p in self.norm.parameters()) | |
| counts["lm_head"] = 0 if self.config.tie_embeddings else self.lm_head.weight.numel() | |
| counts["total"] = sum(p.numel() for p in self.parameters()) | |
| return counts | |
| def save_pretrained(self, output_dir: str) -> None: | |
| """Save model weights and config to directory.""" | |
| os.makedirs(output_dir, exist_ok=True) | |
| # Save config | |
| self.config.save_pretrained(output_dir) | |
| # Save model weights | |
| model_path = os.path.join(output_dir, "model.safetensors") | |
| try: | |
| from safetensors.torch import save_file | |
| save_file(self.state_dict(), model_path) | |
| except Exception: | |
| # Safetensors might fail or be missing, fall back to PyTorch bin format. | |
| # Clean up empty/partial safetensors file if created | |
| if os.path.exists(model_path): | |
| try: | |
| os.remove(model_path) | |
| except Exception: | |
| pass | |
| model_path = os.path.join(output_dir, "pytorch_model.bin") | |
| torch.save(self.state_dict(), model_path) | |
| print(f"Model saved to {output_dir}/") | |
| def from_pretrained(cls, model_dir: str, device: str = "cpu") -> "OpenMindModel": | |
| """Load model weights and config from directory.""" | |
| config = OpenMindConfig.from_pretrained(model_dir) | |
| model = cls(config) | |
| # Try safetensors first, then PyTorch format | |
| safetensors_path = os.path.join(model_dir, "model.safetensors") | |
| pytorch_path = os.path.join(model_dir, "pytorch_model.bin") | |
| pt_path = os.path.join(model_dir, "model.pt") | |
| if os.path.exists(safetensors_path): | |
| from safetensors.torch import load_file | |
| state_dict = load_file(safetensors_path) | |
| elif os.path.exists(pytorch_path): | |
| state_dict = torch.load(pytorch_path, map_location=device) | |
| elif os.path.exists(pt_path): | |
| state_dict = torch.load(pt_path, map_location=device) | |
| else: | |
| raise FileNotFoundError(f"No model weights found in {model_dir}") | |
| model.load_state_dict(state_dict) | |
| model = model.to(device) | |
| print(f"Model loaded from {model_dir}/") | |
| return model | |