"""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