CogNet-40M / train_pipeline.py
thefinalboss's picture
Upload train_pipeline.py with huggingface_hub
2901eaf verified
Raw
History Blame Contribute Delete
21.8 kB
"""
CogNet Training Pipeline
========================
Character-level language model training on WikiText-2 (or synthetic data).
Features:
- CharTokenizer: character-level tokenizer (printable ASCII + French accents + newline/tab)
- Pre-tokenization with .pt caching for fast loading
- AdamW optimizer with cosine LR schedule + warmup
- Gradient accumulation and clipping
- Checkpoint saving (best + latest) with optimizer state
- Generation test after each evaluation
"""
import argparse
import json
import math
import os
import random
import sys
import time
import urllib.request
from typing import Dict, List, Optional, Tuple
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.utils.data import Dataset, DataLoader
# Import model from same directory
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from cognet_1b import CogNet1B
# ─── CharTokenizer ───────────────────────────────────────────────────────────
class CharTokenizer:
"""Character-level tokenizer: printable ASCII + French accents + newline/tab."""
def __init__(self):
self.chars = sorted(set(
# Printable ASCII (32-126)
[chr(i) for i in range(32, 127)]
# Common French accented characters
+ list('àâäéèêëïîôùûüÿçœæÀÂÄÉÈÊËÏÎÔÙÛÜŸÇŒÆ')
# Additional European characters
+ list('ëßñ¿«»')
# Tab and newline
+ ['\t', '\n']
))
self.char_to_id = {c: i for i, c in enumerate(self.chars)}
self.id_to_char = {i: c for i, c in enumerate(self.chars)}
self.vocab_size = len(self.chars)
def encode(self, text: str) -> List[int]:
return [self.char_to_id.get(c, self.char_to_id.get(' ', 0)) for c in text]
def decode(self, ids: List[int]) -> str:
return ''.join(self.id_to_char.get(i, ' ') for i in ids)
def save(self, path: str):
with open(path, 'w', encoding='utf-8') as f:
json.dump({
'chars': self.chars,
'vocab_size': self.vocab_size,
}, f, ensure_ascii=False, indent=2)
@classmethod
def load(cls, path: str) -> 'CharTokenizer':
tok = cls.__new__(cls)
with open(path, 'r', encoding='utf-8') as f:
data = json.load(f)
tok.chars = data['chars']
tok.char_to_id = {c: i for i, c in enumerate(tok.chars)}
tok.id_to_char = {i: c for i, c in enumerate(tok.chars)}
tok.vocab_size = data['vocab_size']
return tok
# ─── Dataset ─────────────────────────────────────────────────────────────────
class CharDataset(Dataset):
"""Fixed-length character-level dataset from pre-tokenized IDs."""
def __init__(self, ids: List[int], seq_len: int):
self.ids = ids
self.seq_len = seq_len
def __len__(self) -> int:
return max(0, len(self.ids) - self.seq_len - 1)
def __getitem__(self, idx: int) -> Tuple[torch.Tensor, torch.Tensor]:
x = torch.tensor(self.ids[idx:idx + self.seq_len], dtype=torch.long)
y = torch.tensor(self.ids[idx + 1:idx + self.seq_len + 1], dtype=torch.long)
return x, y
# ─── Data Loading ────────────────────────────────────────────────────────────
def download_wikitext2(data_dir: str) -> Optional[str]:
"""Download WikiText-2 raw data. Returns data dir path or None on failure."""
url = 'https://s3.amazonaws.com/research.metamind.io/wikitext/wikitext-2-raw-v1.zip'
zip_path = os.path.join(data_dir, 'wikitext-2-raw-v1.zip')
extract_dir = os.path.join(data_dir, 'wikitext-2-raw')
if os.path.exists(extract_dir):
return extract_dir
try:
print(f"Downloading WikiText-2 from {url}...")
urllib.request.urlretrieve(url, zip_path)
import zipfile
with zipfile.ZipFile(zip_path, 'r') as zf:
zf.extractall(data_dir)
os.remove(zip_path)
print("Download complete.")
return extract_dir
except Exception as e:
print(f"Failed to download WikiText-2: {e}")
return None
def generate_synthetic_data() -> str:
"""Generate synthetic English + French training data."""
random.seed(42)
english_sentences = [
"The quick brown fox jumps over the lazy dog.",
"In a world of constant change, adaptation is the key to survival.",
"The architecture of the mind remains one of science's greatest mysteries.",
"She walked through the garden, noting every flower in bloom.",
"Knowledge is not merely accumulated; it must be actively constructed.",
"The river flowed gently through the valley, reflecting the sunset.",
"Each decision we make shapes the path that lies ahead.",
"The old library contained volumes that had not been touched in decades.",
"Innovation often comes from the intersection of different fields.",
"The mountain stood silent against the horizon, ancient and immovable.",
"Patterns emerge when we observe the world with careful attention.",
"The city never sleeps; its lights burn through the darkest nights.",
"Understanding requires patience and a willingness to question assumptions.",
"The wind carried the scent of rain across the open field.",
"Memory is not a recording but a reconstruction of past experience.",
"The scientist carefully recorded every observation in her notebook.",
"Language shapes thought, and thought in turn reshapes language.",
"The forest was alive with the sound of birdsong and rustling leaves.",
"Progress is rarely linear; it spirals upward through cycles of learning.",
"The artist found beauty in the most unexpected places.",
"Every great journey begins with a single step forward.",
"The ocean stretched endlessly, its surface shimmering in the light.",
"Complexity arises from the interaction of simple components.",
"The philosopher pondered the nature of consciousness and being.",
"Stars dotted the night sky like diamonds scattered on velvet.",
"The invention of writing transformed human civilization forever.",
"Curiosity drives discovery and fuels the engine of progress.",
"The garden was a testament to years of patient cultivation.",
"Ideas flow like water, finding paths of least resistance.",
"The clock tower chimed midnight, echoing across the empty square.",
]
french_sentences = [
"Le renard brun rapide saute par-dessus le chien paresseux.",
"Dans un monde de changement constant, l'adaptation est la clé de la survie.",
"L'architecture de l'esprit reste l'un des plus grands mystères de la science.",
"Elle marchait dans le jardin, remarquant chaque fleur en éclosion.",
"La connaissance n'est pas simplement accumulée; elle doit être construite activement.",
"La rivière coulait doucement à travers la vallée, reflétant le coucher du soleil.",
"Chaque décision que nous prenons façonne le chemin qui nous attend.",
"L'ancienne bibliothèque contenait des volumes intouchés depuis des décennies.",
"L'innovation vient souvent de l'intersection de différents domaines.",
"La montagne se tenait silencieuse contre l'horizon, ancienne et immuable.",
"Des schémas émergent quand on observe le monde avec attention.",
"La ville ne dort jamais; ses lumières brûlent les nuits les plus sombres.",
"Comprendre exige patience et volonté de remettre en question les hypothèses.",
"Le vent portait l'odeur de la pluie à travers le champ ouvert.",
"La mémoire n'est pas un enregistrement mais une reconstruction de l'expérience passée.",
"Le scientifique a soigneusement noté chaque observation dans son carnet.",
"Le langage façonne la pensée, et la pensée façonne le langage.",
"La forêt était vivante du son du chant des oiseaux et des feuilles bruissantes.",
"Le progrès est rarement linéaire; il spirale vers le haut à travers des cycles.",
"L'artiste trouvait la beauté dans les endroits les plus inattendus.",
]
lines = []
for _ in range(500):
if random.random() < 0.6:
lines.append(random.choice(english_sentences))
else:
lines.append(random.choice(french_sentences))
if random.random() < 0.1:
lines.append('')
return '\n'.join(lines)
def load_data(data_dir: str, tokenizer: CharTokenizer, seq_len: int,
ckpt_dir: str) -> Tuple[CharDataset, CharDataset]:
"""Load and tokenize data, with pre-tokenized caching."""
train_ids_path = os.path.join(ckpt_dir, 'train_ids.pt')
valid_ids_path = os.path.join(ckpt_dir, 'valid_ids.pt')
# Check for cached pre-tokenized data
if os.path.exists(train_ids_path) and os.path.exists(valid_ids_path):
print("Loading pre-tokenized data from cache...")
train_ids = torch.load(train_ids_path, weights_only=True).tolist()
valid_ids = torch.load(valid_ids_path, weights_only=True).tolist()
print(f" Train tokens: {len(train_ids):,}, Valid tokens: {len(valid_ids):,}")
return CharDataset(train_ids, seq_len), CharDataset(valid_ids, seq_len)
# Download or generate data
wikitext_dir = download_wikitext2(data_dir)
if wikitext_dir:
train_path = os.path.join(wikitext_dir, 'wiki.train.raw')
valid_path = os.path.join(wikitext_dir, 'wiki.valid.raw')
with open(train_path, 'r', encoding='utf-8') as f:
train_text = f.read()
with open(valid_path, 'r', encoding='utf-8') as f:
valid_text = f.read()
else:
print("Using synthetic data as fallback...")
train_text = generate_synthetic_data()
# Use last 20% as validation
split_idx = int(len(train_text) * 0.8)
valid_text = train_text[split_idx:]
train_text = train_text[:split_idx]
print(f"Train text length: {len(train_text):,} chars")
print(f"Valid text length: {len(valid_text):,} chars")
# Tokenize
train_ids = tokenizer.encode(train_text)
valid_ids = tokenizer.encode(valid_text)
print(f"Train tokens: {len(train_ids):,}, Valid tokens: {len(valid_ids):,}")
# Save pre-tokenized data
os.makedirs(ckpt_dir, exist_ok=True)
torch.save(torch.tensor(train_ids, dtype=torch.long), train_ids_path)
torch.save(torch.tensor(valid_ids, dtype=torch.long), valid_ids_path)
print("Saved pre-tokenized data to cache.")
return CharDataset(train_ids, seq_len), CharDataset(valid_ids, seq_len)
# ─── Learning Rate Schedule ──────────────────────────────────────────────────
def get_cosine_lr(step: int, warmup_steps: int, max_steps: int,
max_lr: float, min_lr: float) -> float:
if step < warmup_steps:
return max_lr * (step + 1) / warmup_steps
if step >= max_steps:
return min_lr
progress = (step - warmup_steps) / (max_steps - warmup_steps)
return min_lr + 0.5 * (max_lr - min_lr) * (1 + math.cos(math.pi * progress))
# ─── Training ────────────────────────────────────────────────────────────────
def train(args):
# ── Config ──
vocab_size = 136
hidden_dim = 512
num_blocks = 6
num_channels = 6
channel_dim = 128
ff_dim = 1024
routing_iters = 1
max_adaptive_steps = 2
max_seq_len = 192
working_slots = 32
episodic_slots = 64
semantic_slots = 128
key_dim = 256
dropout = 0.1
batch_size = 8
grad_accum_steps = 4
max_lr = 3e-4
min_lr = 1e-5
warmup_steps = 100
eval_interval = 50
max_steps = args.steps
data_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'data')
ckpt_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'checkpoints')
os.makedirs(ckpt_dir, exist_ok=True)
os.makedirs(data_dir, exist_ok=True)
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
print(f"Device: {device}")
# ── Tokenizer ──
tok_path = os.path.join(ckpt_dir, 'tokenizer_v3.json')
if os.path.exists(tok_path):
print("Loading tokenizer from cache...")
tokenizer = CharTokenizer.load(tok_path)
else:
tokenizer = CharTokenizer()
tokenizer.save(tok_path)
print(f"Created tokenizer with vocab_size={tokenizer.vocab_size}")
# Override vocab_size to match tokenizer
vocab_size = tokenizer.vocab_size
# ── Data ──
train_ds, valid_ds = load_data(data_dir, tokenizer, max_seq_len, ckpt_dir)
train_loader = DataLoader(
train_ds, batch_size=batch_size, shuffle=True,
num_workers=0, pin_memory=True, drop_last=True
)
valid_loader = DataLoader(
valid_ds, batch_size=batch_size, shuffle=False,
num_workers=0, pin_memory=True, drop_last=False
)
# ── Model ──
model = CogNet1B(
vocab_size=vocab_size,
hidden_dim=hidden_dim,
num_blocks=num_blocks,
num_channels=num_channels,
channel_dim=channel_dim,
ff_dim=ff_dim,
routing_iters=routing_iters,
max_adaptive_steps=max_adaptive_steps,
max_seq_len=max_seq_len,
working_slots=working_slots,
episodic_slots=episodic_slots,
semantic_slots=semantic_slots,
key_dim=key_dim,
dropout=dropout,
).to(device)
param_count = model.count_parameters()
print(f"Model parameters: {param_count['total']:,}")
# ── Optimizer ──
optimizer = torch.optim.AdamW(
model.parameters(), lr=max_lr, weight_decay=0.1, betas=(0.9, 0.95)
)
# ── Resume ──
start_step = 0
best_val_loss = float('inf')
latest_path = os.path.join(ckpt_dir, 'cognet_latest.pt')
best_path = os.path.join(ckpt_dir, 'cognet_best.pt')
opt_path = os.path.join(ckpt_dir, 'optimizer.pt')
# Prefer latest over best for resuming
resume_path = latest_path if os.path.exists(latest_path) else (
best_path if os.path.exists(best_path) else None
)
if resume_path:
print(f"Resuming from {resume_path}...")
ckpt = torch.load(resume_path, map_location=device, weights_only=False)
model.load_state_dict(ckpt['model_state_dict'])
start_step = ckpt.get('step', 0) + 1
best_val_loss = ckpt.get('metrics', {}).get('val_loss', float('inf'))
if isinstance(best_val_loss, dict):
best_val_loss = best_val_loss.get('val_loss', float('inf'))
if os.path.exists(opt_path):
try:
opt_ckpt = torch.load(opt_path, map_location=device, weights_only=False)
optimizer.load_state_dict(opt_ckpt['optimizer_state_dict'])
print(f" Loaded optimizer state from step {opt_ckpt.get('step', '?')}")
except Exception as e:
print(f" Could not load optimizer state: {e}")
print(f" Resumed from step {start_step}, best_val_loss={best_val_loss:.4f}")
# ── Training Loop ──
model.train()
train_iter = iter(train_loader)
running_loss = 0.0
log_interval = 10
print(f"\nTraining for {max_steps} steps (starting at step {start_step})...")
print("-" * 60)
for step in range(start_step, max_steps):
# Get batch (recreate iterator when exhausted)
try:
batch_x, batch_y = next(train_iter)
except StopIteration:
train_iter = iter(train_loader)
batch_x, batch_y = next(train_iter)
batch_x = batch_x.to(device)
batch_y = batch_y.to(device)
# Forward
result = model(batch_x)
logits = result['logits']
# Compute loss
loss = F.cross_entropy(
logits.view(-1, vocab_size),
batch_y.view(-1),
ignore_index=-1
)
# Scale for gradient accumulation
scaled_loss = loss / grad_accum_steps
scaled_loss.backward()
running_loss += loss.item()
# Optimizer step with accumulation
if (step + 1) % grad_accum_steps == 0:
# Gradient clipping
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
# Update LR
lr = get_cosine_lr(step, warmup_steps, max_steps, max_lr, min_lr)
for param_group in optimizer.param_groups:
param_group['lr'] = lr
optimizer.step()
optimizer.zero_grad(set_to_none=True)
# Logging
if (step + 1) % log_interval == 0:
avg_loss = running_loss / log_interval
lr = get_cosine_lr(step, warmup_steps, max_steps, max_lr, min_lr)
print(f"Step {step+1:5d} | loss={avg_loss:.4f} | lr={lr:.6f}")
running_loss = 0.0
# Evaluation + checkpoint
if (step + 1) % eval_interval == 0 or step == max_steps - 1:
val_loss = evaluate(model, valid_loader, device, vocab_size)
print(f"\n Evaluation at step {step+1}: val_loss={val_loss:.4f}")
# Save latest
metrics = {
'train_loss': running_loss / max(log_interval, 1),
'val_loss': val_loss,
'step': step,
}
save_checkpoint(model, optimizer, step, metrics, vocab_size,
hidden_dim, num_blocks, max_seq_len,
tokenizer.vocab_size, latest_path, opt_path)
# Save best
if val_loss < best_val_loss:
best_val_loss = val_loss
save_checkpoint(model, optimizer, step, metrics, vocab_size,
hidden_dim, num_blocks, max_seq_len,
tokenizer.vocab_size, best_path, opt_path=None)
print(f" New best model! val_loss={val_loss:.4f}")
# Generation test
test_generation(model, tokenizer, device, max_seq_len)
model.train()
print("\n" + "=" * 60)
print("Training complete!")
print(f"Best validation loss: {best_val_loss:.4f}")
print("=" * 60)
def evaluate(model: nn.Module, loader: DataLoader, device: torch.device,
vocab_size: int) -> float:
"""Evaluate model on validation set."""
model.eval()
total_loss = 0.0
total_tokens = 0
with torch.no_grad():
for batch_x, batch_y in loader:
batch_x = batch_x.to(device)
batch_y = batch_y.to(device)
result = model(batch_x)
logits = result['logits']
loss = F.cross_entropy(
logits.view(-1, vocab_size),
batch_y.view(-1),
ignore_index=-1,
reduction='sum'
)
total_loss += loss.item()
total_tokens += batch_y.numel()
avg_loss = total_loss / max(total_tokens, 1)
return avg_loss
def save_checkpoint(model: nn.Module, optimizer: torch.optim.Optimizer,
step: int, metrics: Dict, vocab_size: int,
hidden_dim: int, num_blocks: int, max_seq_len: int,
tokenizer_vocab_size: int, path: str,
opt_path: Optional[str] = None):
"""Save model checkpoint."""
ckpt = {
'model_state_dict': model.state_dict(),
'vocab_size': vocab_size,
'hidden_dim': hidden_dim,
'num_blocks': num_blocks,
'max_seq_len': max_seq_len,
'tokenizer_vocab_size': tokenizer_vocab_size,
'metrics': metrics,
'step': step,
}
torch.save(ckpt, path)
# Save optimizer state separately
if opt_path:
opt_ckpt = {
'optimizer_state_dict': optimizer.state_dict(),
'step': step,
}
torch.save(opt_ckpt, opt_path)
@torch.no_grad()
def test_generation(model: nn.Module, tokenizer: CharTokenizer,
device: torch.device, max_seq_len: int):
"""Test generation after evaluation."""
model.eval()
prompts = ["The ", "In ", "Le "]
print(" ── Generation Test ──")
for prompt in prompts:
ids = tokenizer.encode(prompt)
input_ids = torch.tensor([ids], dtype=torch.long, device=device)
output_ids = model.generate(
input_ids, max_new_tokens=60, temperature=0.8, top_k=20
)
text = tokenizer.decode(output_ids[0].tolist())
# Truncate for display
display = text[:120].replace('\n', '\\n')
print(f" '{prompt}' → {display}")
print()
# ─── Main ────────────────────────────────────────────────────────────────────
if __name__ == '__main__':
parser = argparse.ArgumentParser(description='CogNet Training Pipeline')
parser.add_argument('--steps', type=int, default=100,
help='Number of training steps (default: 100)')
args = parser.parse_args()
train(args)