thefinalboss commited on
Commit
93dbfa5
·
verified ·
1 Parent(s): 86ecf8e

Upload train_pipeline.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. train_pipeline.py +546 -0
train_pipeline.py ADDED
@@ -0,0 +1,546 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ CogNet Training Pipeline
3
+ ========================
4
+ Character-level language model training on WikiText-2 (or synthetic data).
5
+
6
+ Features:
7
+ - CharTokenizer: character-level tokenizer (printable ASCII + French accents + newline/tab)
8
+ - Pre-tokenization with .pt caching for fast loading
9
+ - AdamW optimizer with cosine LR schedule + warmup
10
+ - Gradient accumulation and clipping
11
+ - Checkpoint saving (best + latest) with optimizer state
12
+ - Generation test after each evaluation
13
+ """
14
+
15
+ import argparse
16
+ import json
17
+ import math
18
+ import os
19
+ import random
20
+ import sys
21
+ import time
22
+ import urllib.request
23
+ from typing import Dict, List, Optional, Tuple
24
+
25
+ import torch
26
+ import torch.nn as nn
27
+ import torch.nn.functional as F
28
+ from torch.utils.data import Dataset, DataLoader
29
+
30
+ # Import model from same directory
31
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
32
+ from cognet_1b import CogNet1B
33
+
34
+
35
+ # ─── CharTokenizer ───────────────────────────────────────────────────────────
36
+
37
+ class CharTokenizer:
38
+ """Character-level tokenizer: printable ASCII + French accents + newline/tab."""
39
+
40
+ def __init__(self):
41
+ self.chars = sorted(set(
42
+ # Printable ASCII (32-126)
43
+ [chr(i) for i in range(32, 127)]
44
+ # Common French accented characters
45
+ + list('àâäéèêëïîôùûüÿçœæÀÂÄÉÈÊËÏÎÔÙÛÜŸÇŒÆ')
46
+ # Additional European characters
47
+ + list('ëßñ¿«»')
48
+ # Tab and newline
49
+ + ['\t', '\n']
50
+ ))
51
+ self.char_to_id = {c: i for i, c in enumerate(self.chars)}
52
+ self.id_to_char = {i: c for i, c in enumerate(self.chars)}
53
+ self.vocab_size = len(self.chars)
54
+
55
+ def encode(self, text: str) -> List[int]:
56
+ return [self.char_to_id.get(c, self.char_to_id.get(' ', 0)) for c in text]
57
+
58
+ def decode(self, ids: List[int]) -> str:
59
+ return ''.join(self.id_to_char.get(i, ' ') for i in ids)
60
+
61
+ def save(self, path: str):
62
+ with open(path, 'w', encoding='utf-8') as f:
63
+ json.dump({
64
+ 'chars': self.chars,
65
+ 'vocab_size': self.vocab_size,
66
+ }, f, ensure_ascii=False, indent=2)
67
+
68
+ @classmethod
69
+ def load(cls, path: str) -> 'CharTokenizer':
70
+ tok = cls.__new__(cls)
71
+ with open(path, 'r', encoding='utf-8') as f:
72
+ data = json.load(f)
73
+ tok.chars = data['chars']
74
+ tok.char_to_id = {c: i for i, c in enumerate(tok.chars)}
75
+ tok.id_to_char = {i: c for i, c in enumerate(tok.chars)}
76
+ tok.vocab_size = data['vocab_size']
77
+ return tok
78
+
79
+
80
+ # ─── Dataset ─────────────────────────────────────────────────────────────────
81
+
82
+ class CharDataset(Dataset):
83
+ """Fixed-length character-level dataset from pre-tokenized IDs."""
84
+
85
+ def __init__(self, ids: List[int], seq_len: int):
86
+ self.ids = ids
87
+ self.seq_len = seq_len
88
+
89
+ def __len__(self) -> int:
90
+ return max(0, len(self.ids) - self.seq_len - 1)
91
+
92
+ def __getitem__(self, idx: int) -> Tuple[torch.Tensor, torch.Tensor]:
93
+ x = torch.tensor(self.ids[idx:idx + self.seq_len], dtype=torch.long)
94
+ y = torch.tensor(self.ids[idx + 1:idx + self.seq_len + 1], dtype=torch.long)
95
+ return x, y
96
+
97
+
98
+ # ─── Data Loading ────────────────────────────────────────────────────────────
99
+
100
+ def download_wikitext2(data_dir: str) -> Optional[str]:
101
+ """Download WikiText-2 raw data. Returns data dir path or None on failure."""
102
+ url = 'https://s3.amazonaws.com/research.metamind.io/wikitext/wikitext-2-raw-v1.zip'
103
+ zip_path = os.path.join(data_dir, 'wikitext-2-raw-v1.zip')
104
+ extract_dir = os.path.join(data_dir, 'wikitext-2-raw')
105
+
106
+ if os.path.exists(extract_dir):
107
+ return extract_dir
108
+
109
+ try:
110
+ print(f"Downloading WikiText-2 from {url}...")
111
+ urllib.request.urlretrieve(url, zip_path)
112
+ import zipfile
113
+ with zipfile.ZipFile(zip_path, 'r') as zf:
114
+ zf.extractall(data_dir)
115
+ os.remove(zip_path)
116
+ print("Download complete.")
117
+ return extract_dir
118
+ except Exception as e:
119
+ print(f"Failed to download WikiText-2: {e}")
120
+ return None
121
+
122
+
123
+ def generate_synthetic_data() -> str:
124
+ """Generate synthetic English + French training data."""
125
+ random.seed(42)
126
+ english_sentences = [
127
+ "The quick brown fox jumps over the lazy dog.",
128
+ "In a world of constant change, adaptation is the key to survival.",
129
+ "The architecture of the mind remains one of science's greatest mysteries.",
130
+ "She walked through the garden, noting every flower in bloom.",
131
+ "Knowledge is not merely accumulated; it must be actively constructed.",
132
+ "The river flowed gently through the valley, reflecting the sunset.",
133
+ "Each decision we make shapes the path that lies ahead.",
134
+ "The old library contained volumes that had not been touched in decades.",
135
+ "Innovation often comes from the intersection of different fields.",
136
+ "The mountain stood silent against the horizon, ancient and immovable.",
137
+ "Patterns emerge when we observe the world with careful attention.",
138
+ "The city never sleeps; its lights burn through the darkest nights.",
139
+ "Understanding requires patience and a willingness to question assumptions.",
140
+ "The wind carried the scent of rain across the open field.",
141
+ "Memory is not a recording but a reconstruction of past experience.",
142
+ "The scientist carefully recorded every observation in her notebook.",
143
+ "Language shapes thought, and thought in turn reshapes language.",
144
+ "The forest was alive with the sound of birdsong and rustling leaves.",
145
+ "Progress is rarely linear; it spirals upward through cycles of learning.",
146
+ "The artist found beauty in the most unexpected places.",
147
+ "Every great journey begins with a single step forward.",
148
+ "The ocean stretched endlessly, its surface shimmering in the light.",
149
+ "Complexity arises from the interaction of simple components.",
150
+ "The philosopher pondered the nature of consciousness and being.",
151
+ "Stars dotted the night sky like diamonds scattered on velvet.",
152
+ "The invention of writing transformed human civilization forever.",
153
+ "Curiosity drives discovery and fuels the engine of progress.",
154
+ "The garden was a testament to years of patient cultivation.",
155
+ "Ideas flow like water, finding paths of least resistance.",
156
+ "The clock tower chimed midnight, echoing across the empty square.",
157
+ ]
158
+ french_sentences = [
159
+ "Le renard brun rapide saute par-dessus le chien paresseux.",
160
+ "Dans un monde de changement constant, l'adaptation est la clé de la survie.",
161
+ "L'architecture de l'esprit reste l'un des plus grands mystères de la science.",
162
+ "Elle marchait dans le jardin, remarquant chaque fleur en éclosion.",
163
+ "La connaissance n'est pas simplement accumulée; elle doit être construite activement.",
164
+ "La rivière coulait doucement à travers la vallée, reflétant le coucher du soleil.",
165
+ "Chaque décision que nous prenons façonne le chemin qui nous attend.",
166
+ "L'ancienne bibliothèque contenait des volumes intouchés depuis des décennies.",
167
+ "L'innovation vient souvent de l'intersection de différents domaines.",
168
+ "La montagne se tenait silencieuse contre l'horizon, ancienne et immuable.",
169
+ "Des schémas émergent quand on observe le monde avec attention.",
170
+ "La ville ne dort jamais; ses lumières brûlent les nuits les plus sombres.",
171
+ "Comprendre exige patience et volonté de remettre en question les hypothèses.",
172
+ "Le vent portait l'odeur de la pluie à travers le champ ouvert.",
173
+ "La mémoire n'est pas un enregistrement mais une reconstruction de l'expérience passée.",
174
+ "Le scientifique a soigneusement noté chaque observation dans son carnet.",
175
+ "Le langage façonne la pensée, et la pensée façonne le langage.",
176
+ "La forêt était vivante du son du chant des oiseaux et des feuilles bruissantes.",
177
+ "Le progrès est rarement linéaire; il spirale vers le haut à travers des cycles.",
178
+ "L'artiste trouvait la beauté dans les endroits les plus inattendus.",
179
+ ]
180
+
181
+ lines = []
182
+ for _ in range(500):
183
+ if random.random() < 0.6:
184
+ lines.append(random.choice(english_sentences))
185
+ else:
186
+ lines.append(random.choice(french_sentences))
187
+ if random.random() < 0.1:
188
+ lines.append('')
189
+
190
+ return '\n'.join(lines)
191
+
192
+
193
+ def load_data(data_dir: str, tokenizer: CharTokenizer, seq_len: int,
194
+ ckpt_dir: str) -> Tuple[CharDataset, CharDataset]:
195
+ """Load and tokenize data, with pre-tokenized caching."""
196
+ train_ids_path = os.path.join(ckpt_dir, 'train_ids.pt')
197
+ valid_ids_path = os.path.join(ckpt_dir, 'valid_ids.pt')
198
+
199
+ # Check for cached pre-tokenized data
200
+ if os.path.exists(train_ids_path) and os.path.exists(valid_ids_path):
201
+ print("Loading pre-tokenized data from cache...")
202
+ train_ids = torch.load(train_ids_path, weights_only=True).tolist()
203
+ valid_ids = torch.load(valid_ids_path, weights_only=True).tolist()
204
+ print(f" Train tokens: {len(train_ids):,}, Valid tokens: {len(valid_ids):,}")
205
+ return CharDataset(train_ids, seq_len), CharDataset(valid_ids, seq_len)
206
+
207
+ # Download or generate data
208
+ wikitext_dir = download_wikitext2(data_dir)
209
+
210
+ if wikitext_dir:
211
+ train_path = os.path.join(wikitext_dir, 'wiki.train.raw')
212
+ valid_path = os.path.join(wikitext_dir, 'wiki.valid.raw')
213
+
214
+ with open(train_path, 'r', encoding='utf-8') as f:
215
+ train_text = f.read()
216
+ with open(valid_path, 'r', encoding='utf-8') as f:
217
+ valid_text = f.read()
218
+ else:
219
+ print("Using synthetic data as fallback...")
220
+ train_text = generate_synthetic_data()
221
+ # Use last 20% as validation
222
+ split_idx = int(len(train_text) * 0.8)
223
+ valid_text = train_text[split_idx:]
224
+ train_text = train_text[:split_idx]
225
+
226
+ print(f"Train text length: {len(train_text):,} chars")
227
+ print(f"Valid text length: {len(valid_text):,} chars")
228
+
229
+ # Tokenize
230
+ train_ids = tokenizer.encode(train_text)
231
+ valid_ids = tokenizer.encode(valid_text)
232
+ print(f"Train tokens: {len(train_ids):,}, Valid tokens: {len(valid_ids):,}")
233
+
234
+ # Save pre-tokenized data
235
+ os.makedirs(ckpt_dir, exist_ok=True)
236
+ torch.save(torch.tensor(train_ids, dtype=torch.long), train_ids_path)
237
+ torch.save(torch.tensor(valid_ids, dtype=torch.long), valid_ids_path)
238
+ print("Saved pre-tokenized data to cache.")
239
+
240
+ return CharDataset(train_ids, seq_len), CharDataset(valid_ids, seq_len)
241
+
242
+
243
+ # ─── Learning Rate Schedule ──────────────────────────────────────────────────
244
+
245
+ def get_cosine_lr(step: int, warmup_steps: int, max_steps: int,
246
+ max_lr: float, min_lr: float) -> float:
247
+ if step < warmup_steps:
248
+ return max_lr * (step + 1) / warmup_steps
249
+ if step >= max_steps:
250
+ return min_lr
251
+ progress = (step - warmup_steps) / (max_steps - warmup_steps)
252
+ return min_lr + 0.5 * (max_lr - min_lr) * (1 + math.cos(math.pi * progress))
253
+
254
+
255
+ # ─── Training ────────────────────────────────────────────────────────────────
256
+
257
+ def train(args):
258
+ # ── Config ──
259
+ vocab_size = 136
260
+ hidden_dim = 512
261
+ num_blocks = 6
262
+ num_channels = 6
263
+ channel_dim = 128
264
+ ff_dim = 1024
265
+ routing_iters = 1
266
+ max_adaptive_steps = 2
267
+ max_seq_len = 192
268
+ working_slots = 32
269
+ episodic_slots = 64
270
+ semantic_slots = 128
271
+ key_dim = 256
272
+ dropout = 0.1
273
+
274
+ batch_size = 8
275
+ grad_accum_steps = 4
276
+ max_lr = 3e-4
277
+ min_lr = 1e-5
278
+ warmup_steps = 100
279
+ eval_interval = 50
280
+ max_steps = args.steps
281
+
282
+ data_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'data')
283
+ ckpt_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'checkpoints')
284
+ os.makedirs(ckpt_dir, exist_ok=True)
285
+ os.makedirs(data_dir, exist_ok=True)
286
+
287
+ device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
288
+ print(f"Device: {device}")
289
+
290
+ # ── Tokenizer ──
291
+ tok_path = os.path.join(ckpt_dir, 'tokenizer_v3.json')
292
+ if os.path.exists(tok_path):
293
+ print("Loading tokenizer from cache...")
294
+ tokenizer = CharTokenizer.load(tok_path)
295
+ else:
296
+ tokenizer = CharTokenizer()
297
+ tokenizer.save(tok_path)
298
+ print(f"Created tokenizer with vocab_size={tokenizer.vocab_size}")
299
+ # Override vocab_size to match tokenizer
300
+ vocab_size = tokenizer.vocab_size
301
+
302
+ # ── Data ──
303
+ train_ds, valid_ds = load_data(data_dir, tokenizer, max_seq_len, ckpt_dir)
304
+
305
+ train_loader = DataLoader(
306
+ train_ds, batch_size=batch_size, shuffle=True,
307
+ num_workers=0, pin_memory=True, drop_last=True
308
+ )
309
+ valid_loader = DataLoader(
310
+ valid_ds, batch_size=batch_size, shuffle=False,
311
+ num_workers=0, pin_memory=True, drop_last=False
312
+ )
313
+
314
+ # ── Model ──
315
+ model = CogNet1B(
316
+ vocab_size=vocab_size,
317
+ hidden_dim=hidden_dim,
318
+ num_blocks=num_blocks,
319
+ num_channels=num_channels,
320
+ channel_dim=channel_dim,
321
+ ff_dim=ff_dim,
322
+ routing_iters=routing_iters,
323
+ max_adaptive_steps=max_adaptive_steps,
324
+ max_seq_len=max_seq_len,
325
+ working_slots=working_slots,
326
+ episodic_slots=episodic_slots,
327
+ semantic_slots=semantic_slots,
328
+ key_dim=key_dim,
329
+ dropout=dropout,
330
+ ).to(device)
331
+
332
+ param_count = model.count_parameters()
333
+ print(f"Model parameters: {param_count['total']:,}")
334
+
335
+ # ── Optimizer ──
336
+ optimizer = torch.optim.AdamW(
337
+ model.parameters(), lr=max_lr, weight_decay=0.1, betas=(0.9, 0.95)
338
+ )
339
+
340
+ # ── Resume ──
341
+ start_step = 0
342
+ best_val_loss = float('inf')
343
+
344
+ latest_path = os.path.join(ckpt_dir, 'cognet_latest.pt')
345
+ best_path = os.path.join(ckpt_dir, 'cognet_best.pt')
346
+ opt_path = os.path.join(ckpt_dir, 'optimizer.pt')
347
+
348
+ # Prefer latest over best for resuming
349
+ resume_path = latest_path if os.path.exists(latest_path) else (
350
+ best_path if os.path.exists(best_path) else None
351
+ )
352
+
353
+ if resume_path:
354
+ print(f"Resuming from {resume_path}...")
355
+ ckpt = torch.load(resume_path, map_location=device, weights_only=False)
356
+ model.load_state_dict(ckpt['model_state_dict'])
357
+ start_step = ckpt.get('step', 0) + 1
358
+ best_val_loss = ckpt.get('metrics', {}).get('val_loss', float('inf'))
359
+ if isinstance(best_val_loss, dict):
360
+ best_val_loss = best_val_loss.get('val_loss', float('inf'))
361
+
362
+ if os.path.exists(opt_path):
363
+ try:
364
+ opt_ckpt = torch.load(opt_path, map_location=device, weights_only=False)
365
+ optimizer.load_state_dict(opt_ckpt['optimizer_state_dict'])
366
+ print(f" Loaded optimizer state from step {opt_ckpt.get('step', '?')}")
367
+ except Exception as e:
368
+ print(f" Could not load optimizer state: {e}")
369
+
370
+ print(f" Resumed from step {start_step}, best_val_loss={best_val_loss:.4f}")
371
+
372
+ # ── Training Loop ──
373
+ model.train()
374
+ train_iter = iter(train_loader)
375
+ running_loss = 0.0
376
+ log_interval = 10
377
+
378
+ print(f"\nTraining for {max_steps} steps (starting at step {start_step})...")
379
+ print("-" * 60)
380
+
381
+ for step in range(start_step, max_steps):
382
+ # Get batch (recreate iterator when exhausted)
383
+ try:
384
+ batch_x, batch_y = next(train_iter)
385
+ except StopIteration:
386
+ train_iter = iter(train_loader)
387
+ batch_x, batch_y = next(train_iter)
388
+
389
+ batch_x = batch_x.to(device)
390
+ batch_y = batch_y.to(device)
391
+
392
+ # Forward
393
+ result = model(batch_x)
394
+ logits = result['logits']
395
+
396
+ # Compute loss
397
+ loss = F.cross_entropy(
398
+ logits.view(-1, vocab_size),
399
+ batch_y.view(-1),
400
+ ignore_index=-1
401
+ )
402
+
403
+ # Scale for gradient accumulation
404
+ scaled_loss = loss / grad_accum_steps
405
+ scaled_loss.backward()
406
+
407
+ running_loss += loss.item()
408
+
409
+ # Optimizer step with accumulation
410
+ if (step + 1) % grad_accum_steps == 0:
411
+ # Gradient clipping
412
+ torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
413
+
414
+ # Update LR
415
+ lr = get_cosine_lr(step, warmup_steps, max_steps, max_lr, min_lr)
416
+ for param_group in optimizer.param_groups:
417
+ param_group['lr'] = lr
418
+
419
+ optimizer.step()
420
+ optimizer.zero_grad(set_to_none=True)
421
+
422
+ # Logging
423
+ if (step + 1) % log_interval == 0:
424
+ avg_loss = running_loss / log_interval
425
+ lr = get_cosine_lr(step, warmup_steps, max_steps, max_lr, min_lr)
426
+ print(f"Step {step+1:5d} | loss={avg_loss:.4f} | lr={lr:.6f}")
427
+ running_loss = 0.0
428
+
429
+ # Evaluation + checkpoint
430
+ if (step + 1) % eval_interval == 0 or step == max_steps - 1:
431
+ val_loss = evaluate(model, valid_loader, device, vocab_size)
432
+ print(f"\n Evaluation at step {step+1}: val_loss={val_loss:.4f}")
433
+
434
+ # Save latest
435
+ metrics = {
436
+ 'train_loss': running_loss / max(log_interval, 1),
437
+ 'val_loss': val_loss,
438
+ 'step': step,
439
+ }
440
+ save_checkpoint(model, optimizer, step, metrics, vocab_size,
441
+ hidden_dim, num_blocks, max_seq_len,
442
+ tokenizer.vocab_size, latest_path, opt_path)
443
+
444
+ # Save best
445
+ if val_loss < best_val_loss:
446
+ best_val_loss = val_loss
447
+ save_checkpoint(model, optimizer, step, metrics, vocab_size,
448
+ hidden_dim, num_blocks, max_seq_len,
449
+ tokenizer.vocab_size, best_path, opt_path=None)
450
+ print(f" New best model! val_loss={val_loss:.4f}")
451
+
452
+ # Generation test
453
+ test_generation(model, tokenizer, device, max_seq_len)
454
+
455
+ model.train()
456
+
457
+ print("\n" + "=" * 60)
458
+ print("Training complete!")
459
+ print(f"Best validation loss: {best_val_loss:.4f}")
460
+ print("=" * 60)
461
+
462
+
463
+ def evaluate(model: nn.Module, loader: DataLoader, device: torch.device,
464
+ vocab_size: int) -> float:
465
+ """Evaluate model on validation set."""
466
+ model.eval()
467
+ total_loss = 0.0
468
+ total_tokens = 0
469
+
470
+ with torch.no_grad():
471
+ for batch_x, batch_y in loader:
472
+ batch_x = batch_x.to(device)
473
+ batch_y = batch_y.to(device)
474
+
475
+ result = model(batch_x)
476
+ logits = result['logits']
477
+
478
+ loss = F.cross_entropy(
479
+ logits.view(-1, vocab_size),
480
+ batch_y.view(-1),
481
+ ignore_index=-1,
482
+ reduction='sum'
483
+ )
484
+ total_loss += loss.item()
485
+ total_tokens += batch_y.numel()
486
+
487
+ avg_loss = total_loss / max(total_tokens, 1)
488
+ return avg_loss
489
+
490
+
491
+ def save_checkpoint(model: nn.Module, optimizer: torch.optim.Optimizer,
492
+ step: int, metrics: Dict, vocab_size: int,
493
+ hidden_dim: int, num_blocks: int, max_seq_len: int,
494
+ tokenizer_vocab_size: int, path: str,
495
+ opt_path: Optional[str] = None):
496
+ """Save model checkpoint."""
497
+ ckpt = {
498
+ 'model_state_dict': model.state_dict(),
499
+ 'vocab_size': vocab_size,
500
+ 'hidden_dim': hidden_dim,
501
+ 'num_blocks': num_blocks,
502
+ 'max_seq_len': max_seq_len,
503
+ 'tokenizer_vocab_size': tokenizer_vocab_size,
504
+ 'metrics': metrics,
505
+ 'step': step,
506
+ }
507
+ torch.save(ckpt, path)
508
+
509
+ # Save optimizer state separately
510
+ if opt_path:
511
+ opt_ckpt = {
512
+ 'optimizer_state_dict': optimizer.state_dict(),
513
+ 'step': step,
514
+ }
515
+ torch.save(opt_ckpt, opt_path)
516
+
517
+
518
+ @torch.no_grad()
519
+ def test_generation(model: nn.Module, tokenizer: CharTokenizer,
520
+ device: torch.device, max_seq_len: int):
521
+ """Test generation after evaluation."""
522
+ model.eval()
523
+ prompts = ["The ", "In ", "Le "]
524
+
525
+ print(" ── Generation Test ──")
526
+ for prompt in prompts:
527
+ ids = tokenizer.encode(prompt)
528
+ input_ids = torch.tensor([ids], dtype=torch.long, device=device)
529
+ output_ids = model.generate(
530
+ input_ids, max_new_tokens=60, temperature=0.8, top_k=20
531
+ )
532
+ text = tokenizer.decode(output_ids[0].tolist())
533
+ # Truncate for display
534
+ display = text[:120].replace('\n', '\\n')
535
+ print(f" '{prompt}' → {display}")
536
+ print()
537
+
538
+
539
+ # ─── Main ────────────────────────────────────────────────────────────────────
540
+
541
+ if __name__ == '__main__':
542
+ parser = argparse.ArgumentParser(description='CogNet Training Pipeline')
543
+ parser.add_argument('--steps', type=int, default=100,
544
+ help='Number of training steps (default: 100)')
545
+ args = parser.parse_args()
546
+ train(args)