Mastermind / train.py
dewdxesw's picture
Rename train (5).py to train.py
854ac5c verified
Raw
History Blame Contribute Delete
35.5 kB
"""
train.py — Train a ~50M-parameter GPT-style causal LM and export a quantized ONNX model.
Designed for a Kaggle Notebook with a T4 GPU. The resulting 4-bit ONNX file stays
well under 50 MB, so it can be uploaded as a single artifact and run client-side
in the browser via ONNX Runtime Web.
==============================================================================
Kaggle Notebook setup
==============================================================================
1. New Notebook -> Settings -> Accelerator: GPU T4 x1
2. Upload your text corpus as a Kaggle dataset, with a file named `dataset.txt`
inside it. The mount path is typically /kaggle/input/<dataset-name>/dataset.txt
3. Add this script as a Notebook utility, OR paste it into a cell.
4. Install runtime deps (only needed once per session):
!pip install -q tokenizers onnxruntime onnx
5. Run:
!python train.py --dataset /kaggle/input/<dataset-name>/dataset.txt \
--out_dir /kaggle/working
==============================================================================
Pipeline
==============================================================================
1. Train a byte-level BPE tokenizer (vocab_size=8000) on dataset.txt
2. Stream-encode dataset.txt -> uint16 binary shards (train.bin / val.bin)
3. Train a 12-layer / 512-dim transformer (~46M params) with FP16 AMP
4. Export to ONNX (opset 17, dynamic batch & sequence axes)
5. Quantize weights to 4-bit via ONNX Runtime's MatMulNBits quantizer
6. Print final model size and confirm it stays under 50 MB
==============================================================================
Outputs (in out_dir)
==============================================================================
- tokenizer.json : byte-level BPE tokenizer (for browser-side encoding)
- train.bin/val.bin : tokenized binary shards (memmap-friendly)
- model_best.pt : best-val FP32 checkpoint
- model_fp32.pt : final FP32 checkpoint
- model_fp32.onnx : unquantized ONNX (for debugging)
- model_q4.onnx : 4-bit quantized ONNX <-- the deployable artifact
Expected file size: ~25 MB for the 4-bit model.
==============================================================================
Browser deployment
==============================================================================
Use `onnxruntime-web` (npm) to load model_q4.onnx, and `@huggingface/tokenizers`
(WASM) to load tokenizer.json. Minimal sketch:
import * as ort from 'onnxruntime-web';
import { Tokenizer } from '@huggingface/tokenizers';
const session = await ort.InferenceSession.create('./model_q4.onnx');
const tokenizer = await Tokenizer.fromFile('./tokenizer.json');
function generate(prompt, maxNew = 64) {
let ids = tokenizer.encode(prompt).ids;
for (let i = 0; i < maxNew; i++) {
const ctx = ids.slice(-256); // crop to block_size
const inp = new ort.Tensor('int64', BigInt64Array.from(ctx.map(BigInt)), [1, ctx.length]);
const out = await session.run({ input_ids: inp });
const logits = out.logits.data; // [1 * ctx.length * vocab]
const next = sample(logits, ctx.length, vocabSize); // your sampler
ids = ids.concat(next);
}
return tokenizer.decode(ids, true);
}
"""
from __future__ import annotations
import argparse
import math
import os
import time
from dataclasses import asdict, dataclass
from typing import Tuple
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
# ============================================================================
# Configuration
# ============================================================================
@dataclass
class Config:
# ---- Tokenizer ----
vocab_size: int = 8000 # will be overridden by actual tokenizer size
# ---- Model architecture (sums to ~46.1M params) ----
n_layer: int = 12
n_head: int = 8
n_embd: int = 512
block_size: int = 256 # max context length
ffn_mult: int = 4 # FFN hidden = n_embd * ffn_mult = 2048
dropout: float = 0.1
bias: bool = False # GPT-2 / LLaMA convention: no biases
# ---- Training ----
batch_size: int = 32
grad_accum: int = 4 # effective batch = 32 * 4 = 128
learning_rate: float = 3e-4
weight_decay: float = 0.1
max_iters: int = 5000
warmup_iters: int = 200
eval_interval: int = 500
eval_iters: int = 50
grad_clip: float = 1.0
seed: int = 1337
# ---- Data split ----
val_split: float = 0.05
# ============================================================================
# Model (GPT-style)
# ============================================================================
class CausalSelfAttention(nn.Module):
"""Multi-head causal self-attention with fused QKV projection.
Manual attention (instead of F.scaled_dot_product_attention) so that the
ONNX export stays portable across ONNX Runtime Web builds.
"""
def __init__(self, cfg: Config):
super().__init__()
assert cfg.n_embd % cfg.n_head == 0, "n_embd must be divisible by n_head"
self.n_head = cfg.n_head
self.n_embd = cfg.n_embd
self.head_dim = cfg.n_embd // cfg.n_head
self.dropout = cfg.dropout
# Fused QKV
self.c_attn = nn.Linear(cfg.n_embd, 3 * cfg.n_embd, bias=cfg.bias)
self.c_proj = nn.Linear(cfg.n_embd, cfg.n_embd, bias=cfg.bias)
self.resid_dropout = nn.Dropout(cfg.dropout)
def forward(self, x: torch.Tensor) -> torch.Tensor:
B, T, C = x.shape
qkv = self.c_attn(x)
q, k, v = qkv.split(self.n_embd, dim=2)
# (B, T, C) -> (B, nh, T, hd)
q = q.view(B, T, self.n_head, self.head_dim).transpose(1, 2)
k = k.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)
# Manual scaled-dot-product attention with causal mask
att = (q @ k.transpose(-2, -1)) * (1.0 / math.sqrt(self.head_dim))
causal_mask = torch.tril(torch.ones(T, T, device=x.device, dtype=torch.bool))
att = att.masked_fill(~causal_mask, float("-inf"))
att = F.softmax(att, dim=-1)
att = F.dropout(att, p=self.dropout, training=self.training)
y = att @ v # (B, nh, T, hd)
y = y.transpose(1, 2).contiguous().view(B, T, C)
return self.resid_dropout(self.c_proj(y))
class MLP(nn.Module):
"""Position-wise feed-forward: Linear -> GELU -> Linear."""
def __init__(self, cfg: Config):
super().__init__()
hidden = cfg.n_embd * cfg.ffn_mult
self.c_fc = nn.Linear(cfg.n_embd, hidden, bias=cfg.bias)
self.c_proj = nn.Linear(hidden, cfg.n_embd, bias=cfg.bias)
self.dropout = nn.Dropout(cfg.dropout)
def forward(self, x: torch.Tensor) -> torch.Tensor:
x = self.c_fc(x)
x = F.gelu(x, approximate="tanh")
x = self.c_proj(x)
return self.dropout(x)
class Block(nn.Module):
"""Pre-norm transformer block (GPT-2 style)."""
def __init__(self, cfg: Config):
super().__init__()
self.ln_1 = nn.LayerNorm(cfg.n_embd, bias=cfg.bias)
self.attn = CausalSelfAttention(cfg)
self.ln_2 = nn.LayerNorm(cfg.n_embd, bias=cfg.bias)
self.mlp = MLP(cfg)
def forward(self, x: torch.Tensor) -> torch.Tensor:
x = x + self.attn(self.ln_1(x))
x = x + self.mlp (self.ln_2(x))
return x
class GPT(nn.Module):
"""GPT-style decoder-only transformer.
Untied input/output embeddings on purpose — this bumps the parameter count
from ~30M (tied) to ~46M (untied), which is what we want.
"""
def __init__(self, cfg: Config):
super().__init__()
self.cfg = cfg
self.wte = nn.Embedding(cfg.vocab_size, cfg.n_embd)
self.wpe = nn.Embedding(cfg.block_size, cfg.n_embd)
self.drop = nn.Dropout(cfg.dropout)
self.h = nn.ModuleList([Block(cfg) for _ in range(cfg.n_layer)])
self.ln_f = nn.LayerNorm(cfg.n_embd, bias=cfg.bias)
self.lm_head = nn.Linear(cfg.n_embd, cfg.vocab_size, bias=False)
# Init all weights
self.apply(self._init_weights)
# Scale residual projections by 1/sqrt(2*n_layer)
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 * cfg.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 num_parameters(self) -> int:
return sum(p.numel() for p in self.parameters())
def forward(self, idx: torch.Tensor, targets: torch.Tensor | None = None):
B, T = idx.shape
assert T <= self.cfg.block_size, f"sequence {T} > block_size {self.cfg.block_size}"
pos = torch.arange(0, T, dtype=torch.long, device=idx.device).unsqueeze(0)
x = self.drop(self.wte(idx) + self.wpe(pos))
for block in self.h:
x = block(x)
x = self.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=-1,
)
return logits, loss
class GPTForExport(nn.Module):
"""Thin wrapper that returns only logits — used for ONNX export."""
def __init__(self, gpt: GPT):
super().__init__()
self.gpt = gpt
def forward(self, input_ids: torch.Tensor) -> torch.Tensor:
logits, _ = self.gpt(input_ids)
return logits
# ============================================================================
# Tokenizer
# ============================================================================
def train_tokenizer(dataset_path: str, vocab_size: int, out_dir: str):
"""Train a byte-level BPE tokenizer using the HuggingFace `tokenizers` lib."""
from tokenizers import Tokenizer
from tokenizers.models import BPE
from tokenizers.trainers import BpeTrainer
from tokenizers.pre_tokenizers import ByteLevel
from tokenizers.decoders import ByteLevel as ByteLevelDecoder
tokenizer = Tokenizer(BPE(unk_token="<unk>"))
tokenizer.pre_tokenizer = ByteLevel(add_prefix_space=False, use_regex=True)
tokenizer.decoder = ByteLevelDecoder()
trainer = BpeTrainer(
vocab_size=vocab_size,
special_tokens=["<pad>", "<bos>", "<eos>", "<unk>"],
initial_alphabet=ByteLevel.alphabet(),
show_progress=True,
)
# `tokenizer.train` streams from disk — fine for multi-GB datasets.
tokenizer.train([dataset_path], trainer)
pad_id = tokenizer.token_to_id("<pad>")
if pad_id is not None:
tokenizer.enable_padding(pad_id=pad_id, pad_token="<pad>")
out_path = os.path.join(out_dir, "tokenizer.json")
tokenizer.save(out_path)
print(f"[tokenizer] saved -> {out_path} (vocab_size={tokenizer.get_vocab_size()})")
return tokenizer
def load_tokenizer(out_dir: str):
from tokenizers import Tokenizer
return Tokenizer.from_file(os.path.join(out_dir, "tokenizer.json"))
# ============================================================================
# Data pipeline
# ============================================================================
def encode_dataset(tokenizer, dataset_path: str, out_path: str, chunk_chars: int = 5_000_000) -> int:
"""Stream-encode dataset.txt to a binary file of uint16 token ids.
uint16 is enough for vocab_size up to 65535 — well above our 8000.
"""
written = 0
with open(dataset_path, "r", encoding="utf-8", errors="ignore") as fin, \
open(out_path, "wb") as fout:
while True:
chunk = fin.read(chunk_chars)
if not chunk:
break
enc = tokenizer.encode(chunk)
arr = np.asarray(enc.ids, dtype=np.uint16)
arr.tofile(fout)
written += arr.size
print(f"[data] encoded {written:,} tokens -> {out_path}")
return written
def load_binary(path: str) -> np.ndarray:
"""Load tokenized data as a memory-mapped numpy array (no full copy)."""
n_bytes = os.path.getsize(path)
n = n_bytes // np.dtype(np.uint16).itemsize
return np.memmap(path, dtype=np.uint16, mode="r", shape=(n,))
def split_train_val(data: np.ndarray, val_split: float) -> Tuple[np.ndarray, np.ndarray]:
n = len(data)
n_val = int(n * val_split)
return data[: n - n_val], data[n - n_val :]
def get_batch(data: np.ndarray, block_size: int, batch_size: int, device: str):
"""Sample a random batch of (x, y) pairs for next-token prediction."""
ix = torch.randint(len(data) - block_size - 1, (batch_size,))
x = torch.stack([torch.from_numpy(data[i : i + block_size].astype(np.int64)) for i in ix])
y = torch.stack([torch.from_numpy(data[i + 1 : i + 1 + block_size].astype(np.int64)) for i in ix])
if device == "cuda":
x, y = x.pin_memory().to(device, non_blocking=True), y.pin_memory().to(device, non_blocking=True)
else:
x, y = x.to(device), y.to(device)
return x, y
# ============================================================================
# Training loop
# ============================================================================
def get_lr(it: int, cfg: Config) -> float:
"""Linear warmup -> cosine decay to 10% of base LR."""
if it < cfg.warmup_iters:
return cfg.learning_rate * (it + 1) / cfg.warmup_iters
if it > cfg.max_iters:
return cfg.learning_rate * 0.1
decay_ratio = (it - cfg.warmup_iters) / max(cfg.max_iters - cfg.warmup_iters, 1)
coeff = 0.5 * (1.0 + math.cos(math.pi * decay_ratio))
return cfg.learning_rate * coeff
@torch.no_grad()
def estimate_loss(model: GPT, train_data, val_data, cfg: Config, device: str) -> dict:
out = {}
model.eval()
use_amp = device == "cuda"
for split, data in [("train", train_data), ("val", val_data)]:
losses = torch.zeros(cfg.eval_iters)
for k in range(cfg.eval_iters):
x, y = get_batch(data, cfg.block_size, cfg.batch_size, device)
with autocast_ctx(use_amp):
_, loss = model(x, y)
losses[k] = loss.item()
out[split] = losses.mean().item()
model.train()
return out
def autocast_ctx(enabled: bool):
"""Wrapper so we can call `with autocast_ctx(True):` regardless of API version.
Different PyTorch versions expose autocast in different places with different
signatures. We try each known shape in turn and catch both ImportError (module
doesn't exist) and TypeError (signature mismatch).
"""
if not enabled:
import contextlib
return contextlib.nullcontext()
# Try the new torch.amp API first (PyTorch 2.0+)
try:
from torch.amp import autocast as _ac
try:
return _ac(device_type="cuda", dtype=torch.float16)
except TypeError:
# Older 2.x where the kwarg differs
return _ac(dtype=torch.float16)
except (ImportError, TypeError):
pass
# Fall back to the legacy torch.cuda.amp API
try:
from torch.cuda.amp import autocast as _ac_legacy
return _ac_legacy(dtype=torch.float16)
except (ImportError, TypeError, AttributeError):
pass
# Last resort: no autocast at all
import contextlib
return contextlib.nullcontext()
def make_grad_scaler(enabled: bool):
"""Create a GradScaler if possible; return None if AMP is unavailable.
Same defensive pattern as autocast_ctx — try each known signature and
gracefully degrade to plain FP32 if none work.
"""
if not enabled:
return None
# PyTorch 2.0+ new API
try:
from torch.amp import GradScaler as _GS
try:
return _GS(device_type="cuda")
except TypeError:
# The class exists but doesn't accept device_type (intermediate versions)
return _GS()
except (ImportError, TypeError):
pass
# Legacy API
try:
from torch.cuda.amp import GradScaler as _GS_legacy
return _GS_legacy()
except (ImportError, TypeError, AttributeError):
pass
# No AMP available — train in plain FP32
print("[amp] GradScaler unavailable; training in plain FP32 (slower but fine).")
return None
def train_model(model: GPT, train_data, val_data, cfg: Config, device: str, out_dir: str):
# Optimizer — try `fused=True` (PyTorch 2.x), fall back if unsupported
try:
optimizer = torch.optim.AdamW(
model.parameters(),
lr=cfg.learning_rate,
betas=(0.9, 0.95),
weight_decay=cfg.weight_decay,
eps=1e-8,
fused=(device == "cuda"),
)
except TypeError:
optimizer = torch.optim.AdamW(
model.parameters(),
lr=cfg.learning_rate,
betas=(0.9, 0.95),
weight_decay=cfg.weight_decay,
eps=1e-8,
)
scaler = make_grad_scaler(device == "cuda")
best_val = float("inf")
t0 = time.time()
iter_num = 0
while iter_num < cfg.max_iters:
# LR schedule
lr = get_lr(iter_num, cfg)
for pg in optimizer.param_groups:
pg["lr"] = lr
# Gradient accumulation
optimizer.zero_grad(set_to_none=True)
accum_loss = 0.0
for _ in range(cfg.grad_accum):
x, y = get_batch(train_data, cfg.block_size, cfg.batch_size, device)
with autocast_ctx(device == "cuda"):
_, loss = model(x, y)
loss = loss / cfg.grad_accum
if scaler is not None:
scaler.scale(loss).backward()
else:
loss.backward()
accum_loss += loss.item()
if scaler is not None:
scaler.unscale_(optimizer)
torch.nn.utils.clip_grad_norm_(model.parameters(), cfg.grad_clip)
scaler.step(optimizer)
scaler.update()
else:
torch.nn.utils.clip_grad_norm_(model.parameters(), cfg.grad_clip)
optimizer.step()
# Logging
if iter_num % 100 == 0:
elapsed = time.time() - t0
total_tokens = (iter_num + 1) * cfg.batch_size * cfg.block_size * cfg.grad_accum
tps = total_tokens / max(elapsed, 1e-9)
print(f"[train] iter {iter_num:5d}/{cfg.max_iters} "
f"| loss {accum_loss:.4f} | lr {lr:.2e} "
f"| t {elapsed:6.1f}s | {tps:>10,.0f} tok/s", flush=True)
# Periodic eval
if iter_num > 0 and iter_num % cfg.eval_interval == 0:
losses = estimate_loss(model, train_data, val_data, cfg, device)
print(f" -> eval train {losses['train']:.4f} val {losses['val']:.4f}", flush=True)
if losses["val"] < best_val:
best_val = losses["val"]
torch.save(
{"model_state": model.state_dict(), "cfg": asdict(cfg),
"iter": iter_num, "val_loss": best_val},
os.path.join(out_dir, "model_best.pt"),
)
print(f" -> saved best (val={best_val:.4f})", flush=True)
iter_num += 1
# Final checkpoint
torch.save(
{"model_state": model.state_dict(), "cfg": asdict(cfg),
"iter": iter_num, "val_loss": best_val},
os.path.join(out_dir, "model_fp32.pt"),
)
print(f"[train] done. best_val={best_val:.4f}")
# ============================================================================
# ONNX export & quantization
# ============================================================================
def export_onnx(model: GPT, cfg: Config, out_dir: str) -> str:
"""Export model to ONNX with dynamic batch and sequence axes.
Export happens on CPU so the resulting graph is device-agnostic.
"""
model.eval()
wrapper = GPTForExport(model).to("cpu")
dummy = torch.zeros((1, cfg.block_size), dtype=torch.long)
out_path = os.path.join(out_dir, "model_fp32.onnx")
print(f"[onnx] exporting -> {out_path} (opset 17, dynamic axes)")
torch.onnx.export(
wrapper,
dummy,
out_path,
opset_version=17,
input_names=["input_ids"],
output_names=["logits"],
dynamic_axes={
"input_ids": {0: "batch", 1: "sequence"},
"logits": {0: "batch", 1: "sequence"},
},
do_constant_folding=True,
)
size_mb = os.path.getsize(out_path) / (1024 * 1024)
print(f"[onnx] FP32 size: {size_mb:.1f} MB")
return out_path
def quantize_onnx(onnx_path: str, bits: int, out_dir: str) -> str:
"""Quantize ONNX weights.
- bits=4: MatMulNBits quantizer (recommended; ORT 1.17+). Output uses the
`MatMulNBits` operator natively supported by ONNX Runtime Web.
- bits=8: dynamic QInt8 quantization. Smaller quality hit but ~2x larger
file (close to the 50 MB ceiling).
"""
out_path = os.path.join(out_dir, f"model_q{bits}.onnx")
if bits == 4:
from onnxruntime.quantization.matmul_nbits_quantizer import MatMulNBitsQuantizer
from onnxruntime.quantization.shape_inference import quant_pre_process
# Pre-process: shape inference + optimization (improves quantization quality)
preproc_path = onnx_path.replace(".onnx", "_preproc.onnx")
try:
quant_pre_process(
input_path=onnx_path,
output_path=preproc_path,
skip_symbolic_shape=False,
skip_onnx_shape=False,
skip_optimization=False,
)
input_for_quant = preproc_path
except Exception as e:
print(f"[quant] pre-process failed ({e}); falling back to raw model")
input_for_quant = onnx_path
print(f"[quant] 4-bit via MatMulNBits (symmetric, accuracy_level=4)")
try:
quantizer = MatMulNBitsQuantizer(
model_input=input_for_quant,
model_output=out_path,
bits=4,
use_symmetric=True,
accuracy_level=4,
)
except TypeError:
# Older ORT API without accuracy_level
quantizer = MatMulNBitsQuantizer(
model_input=input_for_quant,
model_output=out_path,
bits=4,
use_symmetric=True,
)
quantizer.process()
if os.path.exists(preproc_path):
os.remove(preproc_path)
elif bits == 8:
from onnxruntime.quantization.quantize import quantize_dynamic, QuantType
print(f"[quant] 8-bit via dynamic QInt8 (per-channel)")
quantize_dynamic(
model_input=onnx_path,
model_output=out_path,
weight_type=QuantType.QInt8,
op_types_to_quantize=["MatMul", "Gemm"],
per_channel=True,
reduce_range=False,
)
else:
raise ValueError(f"Unsupported bits={bits}. Use 4 or 8.")
size_mb = os.path.getsize(out_path) / (1024 * 1024)
print(f"[quant] {bits}-bit ONNX size: {size_mb:.2f} MB")
return out_path
# ============================================================================
# Main
# ============================================================================
def main():
parser = argparse.ArgumentParser(
description="Train a ~50M GPT and export a quantized ONNX model."
)
parser.add_argument("--dataset", default="/kaggle/input/dataset.txt",
help="Path to plain-text dataset (will also try ./dataset.txt)")
parser.add_argument("--out_dir", default="/kaggle/working",
help="Output directory for artifacts")
# Training overrides
parser.add_argument("--max_iters", type=int, default=5000)
parser.add_argument("--batch_size", type=int, default=32)
parser.add_argument("--grad_accum", type=int, default=4)
parser.add_argument("--block_size", type=int, default=256)
parser.add_argument("--vocab_size", type=int, default=8000)
parser.add_argument("--n_layer", type=int, default=12)
parser.add_argument("--n_embd", type=int, default=512)
parser.add_argument("--n_head", type=int, default=8)
parser.add_argument("--lr", type=float, default=3e-4)
parser.add_argument("--seed", type=int, default=1337)
parser.add_argument("--quant_bits", type=int, default=4, choices=[4, 8])
parser.add_argument("--skip_train", action="store_true",
help="Skip training; load model_fp32.pt and re-export/quantize")
parser.add_argument("--force_cpu", action="store_true",
help="Force CPU mode even if CUDA is available (useful for P100 or "
"other GPUs unsupported by the installed PyTorch)")
# `parse_known_args` silently ignores unrecognized CLI args. This matters
# in Kaggle/Colab, where Jupyter injects `-f /root/.local/.../kernel-*.json`
# into sys.argv — plain `parse_args()` would crash with SystemExit(2).
args, _unknown = parser.parse_known_args()
# ---- Resolve dataset path ----
# Kaggle mounts attached datasets at /kaggle/input/<dataset-slug>/ — NOT
# /kaggle/input/datasets/<user>/<slug>/. The slug is usually the dataset
# title lowercased with dashes/spaces removed. If the user's --dataset
# path doesn't exist, we:
# (a) try ./dataset.txt,
# (b) search /kaggle/input, /content, and cwd for any *.txt file,
# (c) if nothing matches, list every file under /kaggle/input so the
# user can see exactly what's mounted and copy the right path.
candidate_paths = [args.dataset, os.path.join(os.getcwd(), "dataset.txt")]
found = next((p for p in candidate_paths if p and os.path.exists(p)), None)
# Acceptable filenames when auto-discovering (case-insensitive)
ACCEPTABLE_NAMES = {"dataset.txt", "dataset1.txt", "corpus.txt", "data.txt", "train.txt"}
ACCEPTABLE_SUFFIXES = (".txt", ".md", ".csv")
if not found:
search_roots = [d for d in ("/kaggle/input", "/content", "/content/data", ".")
if os.path.isdir(d)]
for root in search_roots:
for dirpath, _dirs, files in os.walk(root):
# Prefer files with known names first
for fname in files:
if fname.lower() in ACCEPTABLE_NAMES:
found = os.path.join(dirpath, fname)
break
if found:
break
# Fall back to any .txt file
for fname in files:
if fname.lower().endswith(ACCEPTABLE_SUFFIXES):
found = os.path.join(dirpath, fname)
break
if found:
break
if found:
break
if not found:
# Build a helpful listing of everything actually mounted under /kaggle/input
listing = []
for root in ("/kaggle/input", "/content"):
if not os.path.isdir(root):
continue
listing.append(f"\n Contents of {root}:")
for dirpath, dirs, files in os.walk(root):
rel = os.path.relpath(dirpath, root)
for f in files:
full = os.path.join(dirpath, f)
try:
size_mb = os.path.getsize(full) / (1024 * 1024)
except OSError:
size_mb = 0
listing.append(f" {full} ({size_mb:.2f} MB)")
if not any(os.scandir(root)):
listing.append(f" (empty — nothing mounted under {root})")
listing_str = "\n".join(listing) if listing else " (no /kaggle/input or /content directory found)"
raise FileNotFoundError(
f"No text dataset found.\n"
f" Tried explicit path : {args.dataset}\n"
f" Tried cwd fallback : {os.path.join(os.getcwd(), 'dataset.txt')}\n"
f" Searched under : {search_roots}\n"
f" Acceptable filenames: {sorted(ACCEPTABLE_NAMES)} (or any *.txt)\n"
f"\nWhat's actually on disk:{listing_str}\n"
f"\nFix:\n"
f" Copy the full path of your dataset file from the listing above and run:\n"
f" !python train.py --dataset '/kaggle/input/<exact-path-from-listing>'\n"
f"\n If nothing is listed, you haven't attached a Kaggle Dataset yet.\n"
f" In the Kaggle sidebar (right) → 'Add Data' → search your dataset → add it."
)
args.dataset = found
print(f"[data] using dataset: {args.dataset}")
os.makedirs(args.out_dir, exist_ok=True)
cfg = Config(
vocab_size=args.vocab_size,
n_layer=args.n_layer,
n_head=args.n_head,
n_embd=args.n_embd,
block_size=args.block_size,
batch_size=args.batch_size,
grad_accum=args.grad_accum,
learning_rate=args.lr,
max_iters=args.max_iters,
seed=args.seed,
)
torch.manual_seed(cfg.seed)
np.random.seed(cfg.seed)
# ---- Device selection with GPU compatibility check ----
# Some GPUs (notably Tesla P100 = sm_60, Kepler = sm_35/37) are no longer
# supported by modern PyTorch. PyTorch will print warnings but then crash
# on the first CUDA op. We detect this proactively and either auto-fall-back
# to CPU or honor --force_cpu.
device = "cpu"
gpu_info = ""
if not args.force_cpu and torch.cuda.is_available():
try:
cap = torch.cuda.get_device_capability(0) # (major, minor) e.g. (6, 0) for P100
gpu_name = torch.cuda.get_device_name(0)
gpu_info = f" ({gpu_name}, sm_{cap[0]}{cap[1]})"
# PyTorch's compiled-in minimum is sm_70 (Volta). Anything below
# will produce wrong results or crash at runtime.
if cap[0] < 7:
print("=" * 70)
print("WARNING: GPU compatibility issue detected")
print(f" GPU: {gpu_name} (compute capability {cap[0]}.{cap[1]})")
print(f" This PyTorch build requires sm_70+ (Volta/Turing/Ampere/...).")
print(f" Running on this GPU will crash or produce garbage.")
print(f" Options:")
print(f" 1. Switch the Kaggle accelerator to 'GPU T4 x1' (recommended)")
print(f" Settings → Accelerator → GPU T4 x1")
print(f" 2. Force CPU mode (much slower but works):")
print(f" !python train.py --force_cpu --out_dir /kaggle/working")
print(f" Auto-falling back to CPU for now.")
print("=" * 70)
device = "cpu"
else:
device = "cuda"
except Exception as e:
print(f"[device] CUDA check failed ({e}); falling back to CPU.")
device = "cpu"
if args.force_cpu and torch.cuda.is_available():
print("[device] --force_cpu set; using CPU despite CUDA being available.")
print("=" * 70)
print(f"Device : {device}" + (f" ({torch.cuda.get_device_name(0)})" if device == "cuda" else gpu_info))
print(f"Config : {asdict(cfg)}")
print("=" * 70)
# 1. Tokenizer ------------------------------------------------------------
tok_path = os.path.join(args.out_dir, "tokenizer.json")
if os.path.exists(tok_path):
print("[1/6] Loading existing tokenizer...")
tokenizer = load_tokenizer(args.out_dir)
else:
print("[1/6] Training tokenizer...")
tokenizer = train_tokenizer(args.dataset, cfg.vocab_size, args.out_dir)
cfg.vocab_size = tokenizer.get_vocab_size() # actual size may differ slightly
print(f" vocab_size = {cfg.vocab_size}")
# 2. Tokenize dataset -----------------------------------------------------
train_bin = os.path.join(args.out_dir, "train.bin")
val_bin = os.path.join(args.out_dir, "val.bin")
if not (os.path.exists(train_bin) and os.path.exists(val_bin)):
print("[2/6] Tokenizing dataset...")
all_bin = os.path.join(args.out_dir, "all.bin")
encode_dataset(tokenizer, args.dataset, all_bin)
all_data = load_binary(all_bin)
train_data, val_data = split_train_val(all_data, cfg.val_split)
np.asarray(train_data).tofile(train_bin)
np.asarray(val_data).tofile(val_bin)
os.remove(all_bin)
else:
print("[2/6] Loading pre-tokenized shards...")
train_data = load_binary(train_bin)
val_data = load_binary(val_bin)
print(f" train tokens = {len(train_data):,} val tokens = {len(val_data):,}")
# 3. Build model ----------------------------------------------------------
print("[3/6] Building model...")
model = GPT(cfg).to(device)
n_params = model.num_parameters()
print(f" params = {n_params:,} (~{n_params/1e6:.2f}M)")
# 4. Train ----------------------------------------------------------------
if args.skip_train:
ckpt = os.path.join(args.out_dir, "model_fp32.pt")
if not os.path.exists(ckpt):
ckpt = os.path.join(args.out_dir, "model_best.pt")
print(f"[4/6] skip_train=True; loading {ckpt}")
model.load_state_dict(torch.load(ckpt, map_location=device)["model_state"])
else:
print("[4/6] Training...")
train_model(model, train_data, val_data, cfg, device, args.out_dir)
# 5. Export ONNX ----------------------------------------------------------
print("[5/6] Exporting ONNX...")
onnx_fp32 = export_onnx(model, cfg, args.out_dir)
# 6. Quantize -------------------------------------------------------------
print(f"[6/6] Quantizing to {args.quant_bits}-bit...")
quant_path = quantize_onnx(onnx_fp32, args.quant_bits, args.out_dir)
# ---- Final report -------------------------------------------------------
final_mb = os.path.getsize(quant_path) / (1024 * 1024)
print("=" * 70)
print(f"Final model : {quant_path}")
print(f"Final size : {final_mb:.2f} MB")
if final_mb < 50:
print(f"OK : Under 50 MB upload limit (margin {50 - final_mb:.2f} MB)")
else:
print(f"WARN : Over 50 MB by {final_mb - 50:.2f} MB")
print(" Mitigation: lower --vocab_size / --n_layer, or use --quant_bits 4")
print(f"Tokenizer : {os.path.join(args.out_dir, 'tokenizer.json')}")
print(f"Deploy with : ONNX Runtime Web (npm: onnxruntime-web)")
print("=" * 70)
if __name__ == "__main__":
main()