CBT / cbt /model.py
Neithanvarvas's picture
[UPDATE] Restructure release: safetensors weights, cbt package, chat demo, clean model card
dde517d verified
Raw History Blame Contribute Delete
17.8 kB
"""
ScaledGPT: ~18.95M Parameter Recurrent 2D-RoPE Transformer.
Optimized for 25-Hour Dual NVIDIA T4 (sm_75, FP16 AMP, DDP).
Architecture:
- d_model = 512, n_head = 8 (head_dim = 64)
- Physical layers L = 6
- Recurrent passes R = 3 (Effective depth = 18 layers at zero parameter overhead)
- Residual recurrence update: x = x + (1/sqrt(R)) * (x_res - x)
- Tied embedding and output projection weights (wte.weight == lm_head.weight)
- Decoupled 2D-RoPE (Macro place p, Micro step tau); pos_emb_type="rope_1d" gives the
standard 1D-RoPE ablation (positions 0..T-1 over the full head_dim, same parameters)
- Selective shift masking: (place_coords > 0).long() (2D only)
- O(1) SDPA causal attention
- Non-embedding params: ~18,887,680 | Total params: ~18,953,216 (~18.95M target)
"""
import math
from dataclasses import dataclass
from typing import Optional, Tuple, List, Dict, Any
import torch
import torch.nn as nn
import torch.nn.functional as F
from .tokenizer import ScaledTokenizer
@dataclass
class ScaledGPTConfig:
"""Hyperparameters for ScaledGPT (~18.95M parameters)."""
vocab_size: int = 115
block_size: int = 1024
n_layer: int = 6
n_head: int = 8
n_embd: int = 512
n_recur: int = 3
dropout: float = 0.0
bias: bool = False
rope_theta: float = 10000.0
max_coords: int = 4096
k_rand_shift: int = 32
pos_emb_type: str = "rope_2d"
def precompute_2d_rope_freqs(
head_dim: int, max_coords: int = 4096, theta_base: float = 10000.0
) -> Tuple[torch.Tensor, torch.Tensor]:
"""Precomputes rotary frequency tables (cos, sin) for decoupled 2D-RoPE."""
half_dim = head_dim // 2
assert half_dim % 2 == 0, f"head_dim // 2 must be even, got {half_dim}"
dim_indices = torch.arange(0, half_dim, 2, dtype=torch.float32)
freqs = 1.0 / (theta_base ** (dim_indices / half_dim))
coords = torch.arange(max_coords, dtype=torch.float32)
angles = torch.outer(coords, freqs)
cos = torch.cos(angles)
sin = torch.sin(angles)
return cos, sin
def apply_2d_rope(
x: torch.Tensor,
place_coords: torch.Tensor,
step_coords: torch.Tensor,
cos: torch.Tensor,
sin: torch.Tensor,
) -> torch.Tensor:
"""Applies decoupled 2D-RoPE to tensor of shape [B, n_head, T, head_dim]."""
B, H, T, D = x.shape
half_d = D // 2
max_c = cos.size(0) - 1
p_clamped = torch.clamp(place_coords, 0, max_c)
s_clamped = torch.clamp(step_coords, 0, max_c)
cos_p = cos[p_clamped].unsqueeze(1).to(dtype=x.dtype, device=x.device)
sin_p = sin[p_clamped].unsqueeze(1).to(dtype=x.dtype, device=x.device)
cos_s = cos[s_clamped].unsqueeze(1).to(dtype=x.dtype, device=x.device)
sin_s = sin[s_clamped].unsqueeze(1).to(dtype=x.dtype, device=x.device)
x_place = x[..., :half_d]
x_step = x[..., half_d:]
# Place subspace rotation
x_p_even = x_place[..., 0::2]
x_p_odd = x_place[..., 1::2]
rot_p_even = x_p_even * cos_p - x_p_odd * sin_p
rot_p_odd = x_p_even * sin_p + x_p_odd * cos_p
rot_place = torch.empty_like(x_place)
rot_place[..., 0::2] = rot_p_even
rot_place[..., 1::2] = rot_p_odd
# Step subspace rotation
x_s_even = x_step[..., 0::2]
x_s_odd = x_step[..., 1::2]
rot_s_even = x_s_even * cos_s - x_s_odd * sin_s
rot_s_odd = x_s_even * sin_s + x_s_odd * cos_s
rot_step = torch.empty_like(x_step)
rot_step[..., 0::2] = rot_s_even
rot_step[..., 1::2] = rot_s_odd
return torch.cat([rot_place, rot_step], dim=-1)
def apply_1d_rope(x: torch.Tensor, pos: torch.Tensor, cos: torch.Tensor, sin: torch.Tensor) -> torch.Tensor:
"""Applies standard 1D-RoPE over the full head_dim of [B, n_head, T, head_dim] at positions pos [B, T]."""
pos = torch.clamp(pos, 0, cos.size(0) - 1)
c = cos[pos].unsqueeze(1).to(dtype=x.dtype, device=x.device)
s = sin[pos].unsqueeze(1).to(dtype=x.dtype, device=x.device)
x_even = x[..., 0::2]
x_odd = x[..., 1::2]
out = torch.empty_like(x)
out[..., 0::2] = x_even * c - x_odd * s
out[..., 1::2] = x_even * s + x_odd * c
return out
class CausalSelfAttention(nn.Module):
"""Causal multi-head self-attention with SDPA and 2D-RoPE."""
def __init__(self, config: ScaledGPTConfig):
super().__init__()
assert config.n_embd % config.n_head == 0
self.n_head = config.n_head
self.n_embd = config.n_embd
self.head_dim = config.n_embd // config.n_head
self.dropout = config.dropout
self.rope_theta = config.rope_theta
self.pos_emb_type = config.pos_emb_type
# 1D rotates head_dim/2 pairs; 2D rotates head_dim/4 pairs per subspace
self.rope_dim = 2 * self.head_dim if self.pos_emb_type == "rope_1d" else self.head_dim
self.c_attn = nn.Linear(config.n_embd, 3 * config.n_embd, bias=config.bias)
self.c_proj = nn.Linear(config.n_embd, config.n_embd, bias=config.bias)
self.resid_dropout = nn.Dropout(config.dropout)
cos, sin = precompute_2d_rope_freqs(
self.rope_dim, max_coords=config.max_coords, theta_base=config.rope_theta
)
self.register_buffer("rope_cos", cos, persistent=False)
self.register_buffer("rope_sin", sin, persistent=False)
def rotate(self, x: torch.Tensor, place_coords: torch.Tensor, step_coords: torch.Tensor) -> torch.Tensor:
"""Applies the configured RoPE variant (step_coords is ignored for rope_1d)."""
if self.pos_emb_type == "rope_1d":
return apply_1d_rope(x, place_coords, self.rope_cos, self.rope_sin)
return apply_2d_rope(x, place_coords, step_coords, self.rope_cos, self.rope_sin)
def forward(
self,
x: torch.Tensor,
place_coords: torch.Tensor,
step_coords: torch.Tensor,
) -> torch.Tensor:
B, T, C = x.size()
qkv = self.c_attn(x)
q, k, v = qkv.chunk(3, dim=2)
k = k.view(B, T, self.n_head, self.head_dim).transpose(1, 2)
q = q.view(B, T, self.n_head, self.head_dim).transpose(1, 2)
v = v.view(B, T, self.n_head, self.head_dim).transpose(1, 2)
# Dynamic frequency expansion if coordinates exceed precomputed length
max_coord = max(int(place_coords.max().item()), int(step_coords.max().item())) + 1
if max_coord > self.rope_cos.size(0):
new_max = max(max_coord * 2, 4096)
cos, sin = precompute_2d_rope_freqs(self.rope_dim, max_coords=new_max, theta_base=self.rope_theta)
self.rope_cos = cos.to(x.device)
self.rope_sin = sin.to(x.device)
q = self.rotate(q, place_coords, step_coords)
k = self.rotate(k, place_coords, step_coords)
dropout_p = self.dropout if self.training else 0.0
y = F.scaled_dot_product_attention(
q, k, v, attn_mask=None, dropout_p=dropout_p, is_causal=True
)
y = y.transpose(1, 2).contiguous().view(B, T, C)
return self.resid_dropout(self.c_proj(y))
class MLP(nn.Module):
"""Feed-forward network with 4x expansion and GELU."""
def __init__(self, config: ScaledGPTConfig):
super().__init__()
self.c_fc = nn.Linear(config.n_embd, 4 * config.n_embd, bias=config.bias)
self.gelu = nn.GELU()
self.c_proj = nn.Linear(4 * config.n_embd, config.n_embd, bias=config.bias)
self.dropout = nn.Dropout(config.dropout)
def forward(self, x: torch.Tensor) -> torch.Tensor:
return self.dropout(self.c_proj(self.gelu(self.c_fc(x))))
class Block(nn.Module):
"""Transformer block with Pre-LN residuals."""
def __init__(self, config: ScaledGPTConfig):
super().__init__()
self.ln_1 = nn.LayerNorm(config.n_embd)
self.attn = CausalSelfAttention(config)
self.ln_2 = nn.LayerNorm(config.n_embd)
self.mlp = MLP(config)
def forward(
self,
x: torch.Tensor,
place_coords: torch.Tensor,
step_coords: torch.Tensor,
) -> torch.Tensor:
x = x + self.attn(self.ln_1(x), place_coords, step_coords)
x = x + self.mlp(self.ln_2(x))
return x
class ScaledGPT(nn.Module):
"""Scaled ~18.95M Parameter Recurrent 2D-RoPE Transformer (sm_75 Dual T4)."""
def __init__(self, config: ScaledGPTConfig, tokenizer: Optional[ScaledTokenizer] = None):
super().__init__()
assert config.pos_emb_type in ("rope_2d", "rope_1d"), f"Unsupported pos_emb_type: {config.pos_emb_type}"
self.config = config
self.tokenizer = tokenizer or ScaledTokenizer()
self.transformer = nn.ModuleDict(
dict(
wte=nn.Embedding(config.vocab_size, config.n_embd),
drop=nn.Dropout(config.dropout),
h=nn.ModuleList([Block(config) for _ in range(config.n_layer)]),
ln_f=nn.LayerNorm(config.n_embd),
)
)
self.lm_head = nn.Linear(config.n_embd, config.vocab_size, bias=False)
# Weight tying: wte.weight == lm_head.weight
self.transformer.wte.weight = self.lm_head.weight
self.apply(self._init_weights)
for pn, p in self.named_parameters():
if pn.endswith("c_proj.weight"):
torch.nn.init.normal_(p, mean=0.0, std=0.02 / math.sqrt(2 * config.n_layer))
def _init_weights(self, module: nn.Module) -> None:
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 get_num_params(self, non_embedding: bool = False) -> int:
n_params = sum(p.numel() for p in self.parameters())
if non_embedding:
n_params -= self.transformer.wte.weight.numel()
return n_params
def compute_coords(self, idx: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]:
"""Returns (place, step) coordinates: 2D mapper for rope_2d, (0..T-1, zeros) for rope_1d."""
if self.config.pos_emb_type == "rope_1d":
B, T = idx.size()
pos = torch.arange(T, dtype=torch.long, device=idx.device).unsqueeze(0).expand(B, T)
return pos, torch.zeros_like(pos)
return self.compute_2d_coords(idx)
def compute_2d_coords(self, idx: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]:
"""
Unified 2D coordinate mapper:
1. If <USER> is present, routes as Discourse 2D coordinates (segment p in [0, 4], token offset tau).
In multi-turn dialogues, handles turns gracefully.
2. Else, routes as Arithmetic 2D coordinates (column rank p up to 50+, intra-column step tau in [0, 9]).
"""
B, T = idx.size()
device = idx.device
tok = self.tokenizer
p_coords = torch.zeros((B, T), dtype=torch.long, device=device)
s_coords = torch.zeros((B, T), dtype=torch.long, device=device)
user_s_id = tok.user_s_id
plus_id = tok.plus_id
eq_id = tok.eq_id
think_s_id = tok.think_s_id
think_e_id = tok.think_e_id
lparen_id = tok.lparen_id
digit_ids = set(tok.digit_ids)
segment_tags = {
tok.user_s_id: 0,
tok.think_s_id: 1,
tok.call_s_id: 2,
tok.result_s_id: 3,
tok.answer_s_id: 4,
}
for b in range(B):
row = idx[b]
is_dialogue = (row == user_s_id).any().item()
if is_dialogue:
# Discourse routing
cur_seg = 0
cur_step = 0
for t in range(T):
token_id = row[t].item()
if token_id in segment_tags:
cur_seg = segment_tags[token_id]
cur_step = 0
p_coords[b, t] = cur_seg
s_coords[b, t] = cur_step
cur_step += 1
else:
# Arithmetic scratchpad routing (extrapolates cleanly from 1 to 50+ digits)
plus_matches = (row == plus_id).nonzero(as_tuple=True)[0]
plus_idx = plus_matches[0].item() if len(plus_matches) > 0 else -1
eq_matches = (row == eq_id).nonzero(as_tuple=True)[0]
eq_idx = -1
if plus_idx != -1:
for pos in eq_matches:
if pos.item() > plus_idx:
eq_idx = pos.item()
break
think_s_matches = (row == think_s_id).nonzero(as_tuple=True)[0]
think_s = think_s_matches[0].item() if len(think_s_matches) > 0 else -1
think_e_matches = (row == think_e_id).nonzero(as_tuple=True)[0]
think_e = think_e_matches[0].item() if len(think_e_matches) > 0 else -1
# Operand 1 (before '+')
if plus_idx > 0:
for i in range(plus_idx):
if row[i].item() in digit_ids:
p_coords[b, i] = plus_idx - 1 - i
s_coords[b, i] = 0
# Operand 2 (between '+' and '=')
if eq_idx > plus_idx > -1:
for i in range(plus_idx + 1, eq_idx):
if row[i].item() in digit_ids:
p_coords[b, i] = eq_idx - 1 - i
s_coords[b, i] = 0
# Inside THINK
col_count = 0
if think_s != -1:
end_pos = think_e if think_e != -1 else T
cur_col = -1
cur_step = 0
for i in range(think_s + 1, end_pos):
if row[i].item() == lparen_id:
cur_col += 1
cur_step = 0
if cur_col >= 0:
p_coords[b, i] = cur_col
s_coords[b, i] = min(cur_step, 9)
cur_step += 1
col_count = cur_col + 1
# After THINK (final answer)
if think_e != -1:
ans_s = -1
for pos in eq_matches:
if pos.item() > think_e:
ans_s = pos.item() + 1
break
if ans_s != -1 and ans_s < T:
ans_d_idx = 0
for i in range(ans_s, T):
if row[i].item() in digit_ids:
p_coords[b, i] = max(0, col_count - 1 - ans_d_idx)
s_coords[b, i] = 0
ans_d_idx += 1
return p_coords, s_coords
def forward(
self,
idx: torch.Tensor,
targets: Optional[torch.Tensor] = None,
place_coords: Optional[torch.Tensor] = None,
step_coords: Optional[torch.Tensor] = None,
) -> Tuple[torch.Tensor, Optional[torch.Tensor]]:
B, T = idx.size()
device = idx.device
if place_coords is None or step_coords is None:
place_coords, step_coords = self.compute_coords(idx)
# Shift invariance data augmentation during training:
# Strictly applies selective shift masking (place_coords > 0).long().
# Skipped for rope_1d: a uniform offset leaves every relative distance, hence every RoPE score, unchanged.
if self.training and self.config.k_rand_shift > 0 and self.config.pos_emb_type == "rope_2d":
shifts = torch.randint(0, self.config.k_rand_shift + 1, (B, 1), device=device)
shift_mask = (place_coords > 0).long()
place_coords = place_coords + shifts * shift_mask
x = self.transformer.drop(self.transformer.wte(idx))
# Per-block recurrence residual updates: x = x + (1/sqrt(R)) * (x_res - x)
n_recur = max(1, self.config.n_recur)
scale_factor = 1.0 / math.sqrt(n_recur) if n_recur > 1 else 1.0
for _ in range(n_recur):
for block in self.transformer.h:
x_res = block(x, place_coords, step_coords)
if n_recur > 1:
x = x + scale_factor * (x_res - x)
else:
x = x_res
x = self.transformer.ln_f(x)
logits = self.lm_head(x)
loss = None
if targets is not None:
loss = F.cross_entropy(logits.view(-1, logits.size(-1)), targets.view(-1), ignore_index=-100)
return logits, loss
@torch.no_grad()
def generate(
self,
idx: torch.Tensor,
max_new_tokens: int = 512,
temperature: float = 0.0,
stop_tokens: Optional[List[int]] = None,
) -> torch.Tensor:
"""Autoregressive token generation with greedy / multinomial sampling."""
stop_set = set(stop_tokens) if stop_tokens is not None else set()
for _ in range(max_new_tokens):
idx_cond = idx if idx.size(1) <= self.config.block_size else idx[:, -self.config.block_size:]
logits, _ = self(idx_cond)
logits = logits[:, -1, :]
if temperature == 0.0:
next_token = torch.argmax(logits, dim=-1, keepdim=True)
else:
probs = F.softmax(logits / temperature, dim=-1)
next_token = torch.multinomial(probs, num_samples=1)
idx = torch.cat((idx, next_token), dim=1)
if next_token.item() in stop_set:
break
return idx
# Aliases for Character Based Transformer (CBT)
CBT = ScaledGPT
CBTConfig = ScaledGPTConfig