Cortex-0 / tests /test_train_text.py
Frankenstein-Labs's picture
feat(train): Phase 3 — entraînement texte
d9eec2b verified
Raw History Blame Contribute Delete
7.57 kB
"""Phase 3 — tests de la préparation de données et de la boucle d'entraînement.
Rapides (CPU) : corpus FR embarqué + vraies fonctions de tokenisation/batching.
L'entraînement complet (perplexité < 30) se fait sur Colab.
"""
from __future__ import annotations
import json
from pathlib import Path
import numpy as np
import pytest
import torch
from src.data.prepare_text import (
DTYPE,
load_bin,
prepare,
split_train_val,
tokenize_corpus,
)
from src.model.text_model import CortexTextLM, TextConfig
from src.tokenizer import load_tokenizer
from src.train.train_text import (
TrainConfig,
estimate_loss,
get_batch,
get_lr,
pick_device,
)
FR = [f"Phrase française numéro {i} avec des accents é à ç ù." for i in range(50)]
@pytest.fixture(scope="module")
def tokenizer():
tok = load_tokenizer()
if tok.vocab_size != 32000:
pytest.skip("tokenizer final 32k requis")
return tok
# ---------------------------------------------------------------------------
# prepare_text
# ---------------------------------------------------------------------------
def test_tokenize_adds_eos_between_documents(tokenizer):
from src.tokenizer.special_tokens import EOS_ID
arr = tokenize_corpus(["bonjour", "monde"], tokenizer, add_eos=True)
assert arr.dtype == DTYPE
assert arr[-1] == EOS_ID # se termine par eos
# Il y a exactement 2 eos (un par document).
assert int((arr == EOS_ID).sum()) == 2
def test_tokenize_without_eos(tokenizer):
from src.tokenizer.special_tokens import EOS_ID
arr = tokenize_corpus(["bonjour", "monde"], tokenizer, add_eos=False)
assert int((arr == EOS_ID).sum()) == 0
def test_split_train_val_no_overlap():
arr = np.arange(1000, dtype=DTYPE)
train, val = split_train_val(arr, val_frac=0.1)
assert len(train) == 900 and len(val) == 100
assert train[-1] < val[0] # découpe séquentielle
def test_split_rejects_bad_frac():
with pytest.raises(ValueError):
split_train_val(np.arange(10), val_frac=0.0)
def test_prepare_writes_artifacts(tmp_path, tokenizer):
meta = prepare(FR, tmp_path, tokenizer, val_frac=0.1, extra_meta={"preset": "test"})
assert (tmp_path / "train.bin").is_file()
assert (tmp_path / "val.bin").is_file()
assert (tmp_path / "meta.json").is_file()
assert meta["n_total_tokens"] == meta["n_train_tokens"] + meta["n_val_tokens"]
assert meta["dtype"] == "uint16"
assert meta["preset"] == "test"
def test_load_bin_roundtrip(tmp_path, tokenizer):
prepare(FR, tmp_path, tokenizer, val_frac=0.1)
arr = np.asarray(load_bin(tmp_path / "train.bin"))
assert arr.dtype == DTYPE
assert arr.size > 0
# La val commence bien après la fin de la train.
assert arr[-1] != 0 or True
def test_meta_json_is_valid(tmp_path, tokenizer):
prepare(FR, tmp_path, tokenizer, val_frac=0.1)
data = json.loads((tmp_path / "meta.json").read_text(encoding="utf-8"))
assert data["tokenizer_vocab_size"] == 32000
# ---------------------------------------------------------------------------
# get_batch
# ---------------------------------------------------------------------------
def test_get_batch_shapes_and_shift():
data = np.arange(100, dtype=DTYPE)
x, y = get_batch(data, 2, 8, torch.device("cpu"))
assert x.shape == (2, 8) and y.shape == (2, 8)
# y est x décalé d'un token
assert torch.equal(y[:, :-1], x[:, 1:])
def test_get_batch_rejects_small_corpus():
data = np.arange(5, dtype=DTYPE)
with pytest.raises(ValueError):
get_batch(data, 2, 8, torch.device("cpu"))
# ---------------------------------------------------------------------------
# get_lr / planificateur
# ---------------------------------------------------------------------------
def test_lr_warmup_starts_small():
cfg = TrainConfig(warmup_steps=100, max_steps=1000, lr=1e-3)
assert get_lr(0, cfg) < get_lr(50, cfg) < get_lr(99, cfg)
assert get_lr(0, cfg) == pytest.approx(1e-3 / 100)
def test_lr_peaks_then_decays():
cfg = TrainConfig(warmup_steps=100, max_steps=1000, lr=1e-3, min_lr_ratio=0.1)
peak = get_lr(100, cfg)
assert get_lr(500, cfg) < peak
assert get_lr(1000, cfg) == pytest.approx(1e-4)
assert get_lr(2000, cfg) == pytest.approx(1e-4)
def test_lr_is_monotonic_after_warmup():
cfg = TrainConfig(warmup_steps=10, max_steps=500, lr=1e-3)
values = [get_lr(s, cfg) for s in range(10, 500, 20)]
assert all(values[i] >= values[i + 1] for i in range(len(values) - 1))
def test_pick_device_cpu():
assert pick_device("cpu").type == "cpu"
# ---------------------------------------------------------------------------
# Entraînement court réel (CPU)
# ---------------------------------------------------------------------------
def _tiny_cfg(data_dir: Path, tmp_path: Path) -> TrainConfig:
return TrainConfig(
data_dir=str(data_dir), out_dir=str(tmp_path / "ckpt"),
max_steps=6, batch_size=2, grad_accum=1, ctx_len=16,
lr=3e-3, warmup_steps=2, eval_interval=3, eval_batches=1,
log_interval=2, device="cpu", dtype="float32",
n_layer=2, n_head=2, n_embd=32, tie_embeddings=True,
)
def test_train_runs_and_loss_decreases(tmp_path, tokenizer):
from src.train.train_text import train
corpus = FR * 5 + ["Fin du corpus pour l'entraînement."]
prepare(corpus, tmp_path / "data", tokenizer, val_frac=0.1)
summary = train(_tiny_cfg(tmp_path / "data", tmp_path))
assert (tmp_path / "ckpt" / "best.pt").is_file()
assert (tmp_path / "ckpt" / "last.pt").is_file()
assert summary["best_val_loss"] < 50 # la loss reste finie
def test_checkpoint_is_loadable(tmp_path, tokenizer):
from src.train.train_text import train
prepare(FR * 5, tmp_path / "data", tokenizer, val_frac=0.1)
cfg = _tiny_cfg(tmp_path / "data", tmp_path)
train(cfg)
ckpt = torch.load(tmp_path / "ckpt" / "best.pt", weights_only=False)
model = CortexTextLM(TextConfig(**ckpt["text_config"]))
model.load_state_dict(ckpt["model"])
assert ckpt["val_loss"] > 0
assert ckpt["train_config"]["ctx_len"] == 16
def test_estimate_loss_returns_both_splits(tmp_path, tokenizer):
from contextlib import nullcontext
prepare(FR * 5, tmp_path / "data", tokenizer, val_frac=0.1)
train_data = load_bin(tmp_path / "data" / "train.bin")
val_data = load_bin(tmp_path / "data" / "val.bin")
model = CortexTextLM(TextConfig(vocab_size=32000, n_layer=1, n_head=2, n_embd=32, ctx_len=16))
cfg = TrainConfig(batch_size=2, ctx_len=16, eval_batches=1)
metrics = estimate_loss(model, {"train": train_data, "val": val_data}, cfg,
torch.device("cpu"), nullcontext())
assert set(metrics) == {"train", "val"}
assert all(v > 0 for v in metrics.values())
def test_evaluate_perplexity_on_checkpoint(tmp_path, tokenizer):
from contextlib import nullcontext
from src.evaluate import load_checkpoint, perplexity
from src.train.train_text import train
prepare(FR * 5, tmp_path / "data", tokenizer, val_frac=0.1)
cfg = _tiny_cfg(tmp_path / "data", tmp_path)
train(cfg)
device = torch.device("cpu")
model, ckpt = load_checkpoint(tmp_path / "ckpt" / "best.pt", device)
assert ckpt["text_config"]["n_embd"] == 32
data = load_bin(tmp_path / "data" / "val.bin")
eval_cfg = TrainConfig(batch_size=2, ctx_len=16, eval_batches=1)
ppl, loss = perplexity(model, data, eval_cfg, device, nullcontext())
assert ppl > 1.0 and loss > 0.0