brianschwabauer's picture
Full open release: source code + training script + detailed config + transparent model card. No black boxes.
059f836 verified
Raw
History Blame Contribute Delete
23.2 kB
"""
MDLM-BPE v3: Scaled model for coherent text generation.
Key changes over v2:
1. Semi-autoregressive unmasking (left-to-right in blocks)
- Fixes the root cause of incoherence: parallel prediction
- Each block has context from already-decided left tokens
2. Larger model: d_model=1024, 10 layers, 16 heads (~170M params)
3. Longer sequences: seq_len=128
4. Bigger vocab support: 16K
5. KV cache for inference speedup
6. Gradient checkpointing for memory efficiency
"""
import math
import json
import sys
import time
import random
from pathlib import Path
from typing import List, Optional, Tuple
import torch
import torch.nn as nn
import torch.nn.functional as F
import numpy as np
from tokenizers import Tokenizer
REPO = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO / "src"))
DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
CHECKPOINT_DIR = REPO / "checkpoints"
CHECKPOINT_DIR.mkdir(exist_ok=True)
TOKENIZER_PATH = REPO / "tokenizer" / "bpe_tokenizer.json"
class BPETokenizer:
def __init__(self, path=TOKENIZER_PATH):
self.tokenizer = Tokenizer.from_file(str(path))
self.pad_id = self.tokenizer.token_to_id("<pad>")
self.mask_id = self.tokenizer.token_to_id("<mask>")
self.bos_id = self.tokenizer.token_to_id("<bos>")
self.eos_id = self.tokenizer.token_to_id("<eos>")
self.unk_id = self.tokenizer.token_to_id("<unk>")
self.vocab_size = self.tokenizer.get_vocab_size()
def encode(self, text, add_special=True):
enc = self.tokenizer.encode(text)
ids = enc.ids
if add_special:
ids = [self.bos_id] + ids + [self.eos_id]
return ids
def decode(self, ids):
clean = [i for i in ids if i not in (self.pad_id, self.mask_id, self.bos_id)]
return self.tokenizer.decode(clean)
# ═══════════════════════════════════════════════════════════════════════════
# RoPE (shared with v2, tested)
# ═══════════════════════════════════════════════════════════════════════════
def precompute_rope(head_dim, max_seq_len, base=10000.0):
half = head_dim // 2
freqs = 1.0 / (base ** (torch.arange(0, half).float() / half))
positions = torch.arange(max_seq_len).float()
angles = positions[:, None] * freqs[None, :]
return torch.cos(angles), torch.sin(angles)
def apply_rope(x, cos, sin):
"""Apply RoPE, slicing cos/sin to match current sequence length."""
seq_len = x.shape[2]
cos = cos[:seq_len]
sin = sin[:seq_len]
x1 = x[..., 0::2]
x2 = x[..., 1::2]
cos = cos.unsqueeze(0).unsqueeze(0)
sin = sin.unsqueeze(0).unsqueeze(0)
rotated1 = x1 * cos - x2 * sin
rotated2 = x1 * sin + x2 * cos
out = torch.stack([rotated1, rotated2], dim=-1)
return out.flatten(-2)
# ═══════════════════════════════════════════════════════════════════════════
# Config
# ═══════════════════════════════════════════════════════════════════════════
class MDLMConfig:
def __init__(self, vocab_size=16_000, d_model=1024, n_heads=16,
n_layers=10, max_seq_len=256, d_ff=None, dropout=0.1):
self.vocab_size = vocab_size
self.d_model = d_model
self.n_heads = n_heads
self.n_layers = n_layers
self.max_seq_len = max_seq_len
self.d_ff = d_ff or d_model * 4
self.dropout = dropout
def to_dict(self):
return self.__dict__
# ═══════════════════════════════════════════════════════════════════════════
# Model
# ═════════════════════════════════════════════════════_len
class MDLMBlockV3(nn.Module):
"""Transformer block with RoPE, AdaLN, Flash Attention."""
def __init__(self, d_model, n_heads, d_ff, dropout=0.1):
super().__init__()
self.d_model = d_model
self.n_heads = n_heads
self.head_dim = d_model // n_heads
self.ln1 = nn.LayerNorm(d_model)
self.q_proj = nn.Linear(d_model, d_model, bias=False)
self.k_proj = nn.Linear(d_model, d_model, bias=False)
self.v_proj = nn.Linear(d_model, d_model, bias=False)
self.o_proj = nn.Linear(d_model, d_model)
self.attn_drop = nn.Dropout(dropout)
self.ln2 = nn.LayerNorm(d_model)
self.ff = nn.Sequential(
nn.Linear(d_model, d_ff),
nn.GELU(),
nn.Dropout(dropout),
nn.Linear(d_ff, d_model),
nn.Dropout(dropout),
)
self.ada_ln = nn.Sequential(
nn.SiLU(),
nn.Linear(d_model, 6 * d_model, bias=True),
)
nn.init.zeros_(self.ada_ln[-1].weight)
nn.init.zeros_(self.ada_ln[-1].bias)
def forward(self, x, t_emb, rope_cos, rope_sin):
batch, seq, _ = x.shape
scale_shift = self.ada_ln(t_emb)
s1, sh1, s2, sh2, s3, sh3 = scale_shift.chunk(6, dim=-1)
# Self-attention
h = self.ln1(x)
h = h * (1 + s1.unsqueeze(1)) + sh1.unsqueeze(1)
q = self.q_proj(h).view(batch, seq, self.n_heads, self.head_dim).transpose(1, 2)
k = self.k_proj(h).view(batch, seq, self.n_heads, self.head_dim).transpose(1, 2)
v = self.v_proj(h).view(batch, seq, self.n_heads, self.head_dim).transpose(1, 2)
q = apply_rope(q, rope_cos, rope_sin)
k = apply_rope(k, rope_cos, rope_sin)
attn = F.scaled_dot_product_attention(
q, k, v, dropout_p=self.attn_drop.p if self.training else 0.0,
is_causal=False,
)
attn = attn.transpose(1, 2).reshape(batch, seq, self.d_model)
x = x + self.o_proj(attn)
# FFN
h = self.ln2(x)
h = h * (1 + s2.unsqueeze(1)) + sh2.unsqueeze(1)
x = x + self.ff(h)
x = x * (1 + s3.unsqueeze(1)) + sh3.unsqueeze(1)
return x
class MDLMBPEV3(nn.Module):
"""MDLM v3 — scaled for coherent text.
d_model=1024, 10 layers, 16 heads → ~170M params
seq_len up to 256
"""
def __init__(self, config: MDLMConfig, pad_id=0, mask_id=1):
super().__init__()
self.config = config
self.pad_id = pad_id
self.mask_id = mask_id
d = config.d_model
self.token_emb = nn.Embedding(config.vocab_size, d, padding_idx=pad_id)
self.time_embed = nn.Sequential(
nn.Linear(d, d), nn.SiLU(), nn.Linear(d, d),
)
self.blocks = nn.ModuleList([
MDLMBlockV3(d, config.n_heads, config.d_ff, config.dropout)
for _ in range(config.n_layers)
])
self.ln_f = nn.LayerNorm(d)
self.output_bias = nn.Parameter(torch.zeros(config.vocab_size))
head_dim = d // config.n_heads
cos, sin = precompute_rope(head_dim, config.max_seq_len)
self.register_buffer("rope_cos", cos, persistent=False)
self.register_buffer("rope_sin", sin, persistent=False)
self._init_weights()
def _init_weights(self):
for m in self.modules():
if isinstance(m, nn.Linear):
nn.init.normal_(m.weight, mean=0, std=0.02)
if m.bias is not None:
nn.init.zeros_(m.bias)
elif isinstance(m, nn.Embedding):
nn.init.normal_(m.weight, mean=0, std=0.02)
if m.padding_idx is not None:
with torch.no_grad():
m.weight[m.padding_idx].fill_(0)
nn.init.zeros_(self.output_bias)
def _timestep_embedding(self, t):
d = self.config.d_model
half = d // 2
freqs = torch.exp(
-math.log(10000) * torch.arange(half, device=t.device).float() / half
)
args = t[:, None].float() * freqs[None, :]
emb = torch.cat([torch.sin(args), torch.cos(args)], dim=-1)
return self.time_embed(emb)
def forward(self, tokens, t, return_hidden=False):
"""Forward pass.
Args:
tokens: [batch, seq] token IDs
t: [batch] timestep in [0, 1]
return_hidden: if True, also return pre-projection hidden states
[batch, seq, d_model] for semantic analysis.
Returns:
logits [batch, seq, vocab] or (logits, hidden) tuple.
"""
batch, seq = tokens.shape
x = self.token_emb(tokens)
t_emb = self._timestep_embedding(t)
for block in self.blocks:
x = block(x, t_emb, self.rope_cos, self.rope_sin)
x = self.ln_f(x)
logits = F.linear(x, self.token_emb.weight, self.output_bias)
if return_hidden:
return logits, x
return logits
def get_embeddings(self, tokens, t=None):
"""Get contextual embeddings for tokens.
Runs forward pass but returns hidden states instead of logits.
Used by SemanticCoherenceHRM for drift detection.
Args:
tokens: [batch, seq]
t: optional timestep, defaults to 0 (fully unmasked)
Returns:
hidden: [batch, seq, d_model] contextual embeddings
"""
if t is None:
t = torch.zeros(tokens.shape[0], device=tokens.device)
_, hidden = self.forward(tokens, t, return_hidden=True)
return hidden
# ═════════════════════════════════════════ ═══════════════════════════════
# Diffusion Process
# ═════════════ ══════════════════════════════════════════════════════════════
def forward_mask_bpe(tokens, t, mask_id=1, protect_special=True):
batch, seq_len = tokens.shape
prob_mask = t[:, None].expand(batch, seq_len)
rand = torch.rand_like(tokens.float())
mask_positions = rand < prob_mask
if protect_special:
is_special = (tokens == 0) | (tokens == 2) | (tokens == 3)
mask_positions = mask_positions & (~is_special)
masked = torch.where(mask_positions, mask_id, tokens)
return masked, mask_positions
def mdlm_loss(model, tokens, mask_id=1):
batch = tokens.shape[0]
t = torch.rand(batch, device=tokens.device)
masked, mask_pos = forward_mask_bpe(tokens, t, mask_id)
with torch.amp.autocast('cuda', dtype=torch.bfloat16):
logits = model(masked, t)
mask_flat = mask_pos.reshape(-1)
if mask_flat.sum() == 0:
return torch.tensor(0.0, device=tokens.device, requires_grad=True)
logits_flat = logits.reshape(-1, logits.shape[-1])
tokens_flat = tokens.reshape(-1)
return F.cross_entropy(logits_flat[mask_flat], tokens_flat[mask_flat])
# ═══════════════════════════ ══════════════════════════════════════════════
# Semi-Autoregressive Sampling — THE KEY FIX
# ═════════════════════════════════ ══════════════════════════════════════
@torch.no_grad()
def sample_semi_ar(model, tokenizer, prompt_ids=None, seq_len=128,
n_samples=1, block_size=4, temperature=0.7,
device=DEVICE):
"""Semi-autoregressive sampling: unmask left-to-right in blocks.
Instead of predicting ALL positions simultaneously (which causes
repetition because no position knows what others chose), we:
1. Divide sequence into blocks of `block_size` tokens
2. Predict block 1 (positions 0..3) with diffusion
3. Commit those tokens, then predict block 2 (positions 4..7)
with block 1 as context
4. Continue until sequence is complete
This gives each block FULL context of everything to its left,
which is exactly what an autoregressive model does — but each
block is generated via diffusion (parallel within the block).
block_size=1 → fully autoregressive (max coherence, min speed)
block_size=4 → good balance (4 tokens see each other + left context)
block_size=seq_len → fully parallel (v2 behavior, max speed, min coherence)
"""
model.eval()
mask_id = tokenizer.mask_id
pad_id = tokenizer.pad_id
# Initialize
if prompt_ids is not None:
full = torch.full((n_samples, seq_len), mask_id, device=device)
prompt_len = min(len(prompt_ids), seq_len)
full[:, :prompt_len] = torch.tensor(prompt_ids[:prompt_len], device=device)
else:
full = torch.full((n_samples, seq_len), mask_id, device=device)
prompt_len = 0
n_steps_per_block = max(2, block_size)
# Process in left-to-right blocks
for block_start in range(prompt_len, seq_len, block_size):
block_end = min(block_start + block_size, seq_len)
# Diffusion within this block (multiple refinement steps)
for step in range(n_steps_per_block):
t_val = max(0.5 - step / (n_steps_per_block * 2), 0.01)
t = torch.full((n_samples,), t_val, device=device)
logits = model(full, t)
# Find masked positions in current block
mask_in_block = (full[:, block_start:block_end] == mask_id)
if not mask_in_block.any():
break
pos_logits = logits[:, block_start:block_end] / max(temperature, 0.01)
probs = F.softmax(pos_logits, dim=-1)
sampled = torch.multinomial(
probs.reshape(-1, probs.shape[-1]), 1
).squeeze(-1).reshape(n_samples, -1)
confidence = probs.max(dim=-1)[0]
confidence[~mask_in_block] = -1
# Unmask proportionally within block
n_masked = mask_in_block.sum(dim=1)
n_to_unmask = torch.clamp(n_masked // max(n_steps_per_block - step, 1), min=1)
for b in range(n_samples):
if n_masked[b] == 0:
continue
k = min(int(n_to_unmask[b].item()), int(n_masked[b].item()))
top_conf, top_idx = confidence[b].topk(k)
valid = top_conf > 0
if valid.any():
positions = top_idx[valid]
full[b, block_start + positions] = sampled[b, positions]
# Decode
results = []
for b in range(n_samples):
ids = full[b].cpu().tolist()
if tokenizer.eos_id in ids:
ids = ids[:ids.index(tokenizer.eos_id)]
text = tokenizer.decode(ids)
results.append(text)
return results
@torch.no_grad()
def generate_response_semi_ar(model, tokenizer, prompt, max_len=64,
block_size=4, temperature=0.6,
device=DEVICE):
"""Chatbot response with semi-AR sampling."""
model.eval()
user_tok = tokenizer.tokenizer.token_to_id("<|user|>")
asst_tok = tokenizer.tokenizer.token_to_id("<|assistant|>")
mask_id = tokenizer.mask_id
pad_id = tokenizer.pad_id
ctx_ids = tokenizer.tokenizer.encode(prompt).ids
prefix = [tokenizer.bos_id, user_tok] + ctx_ids + [asst_tok]
response_start = len(prefix)
seq_len = min(256, response_start + max_len)
seq = (prefix + [mask_id] * max_len)[:seq_len]
while len(seq) < seq_len:
seq.append(pad_id)
full = torch.tensor([seq], device=device)
n_steps = max(2, block_size)
for block_start in range(response_start, seq_len, block_size):
block_end = min(block_start + block_size, seq_len)
for step in range(n_steps):
t_val = max(0.5 - step / (n_steps * 2), 0.01)
t = torch.full((1,), t_val, device=device)
logits = model(full, t)
mask_in_block = (full[0, block_start:block_end] == mask_id)
if not mask_in_block.any():
break
idxs = mask_in_block.nonzero(as_tuple=True)[0]
pos_logits = logits[0, block_start:block_end][idxs] / max(temperature, 0.01)
probs = F.softmax(pos_logits, dim=-1)
sampled = torch.multinomial(probs, 1).squeeze(-1)
conf = probs.max(dim=-1)[0]
n_unmask = max(1, len(idxs) // (n_steps - step))
top_conf, top_idx = conf.topk(min(n_unmask, len(idxs)))
positions_in_block = idxs[top_idx]
full[0, block_start + positions_in_block] = sampled[top_idx]
resp_ids = full[0, response_start:].cpu().tolist()
if tokenizer.eos_id in resp_ids:
resp_ids = resp_ids[:resp_ids.index(tokenizer.eos_id)]
resp_ids = [i for i in resp_ids if i not in (pad_id, mask_id)]
return tokenizer.decode(resp_ids)