Cortex-0 / tests /test_text_model.py
Frankenstein-Labs's picture
feat(model): Phase 2 — transformer texte from scratch
37d8122 verified
Raw History Blame Contribute Delete
7.4 kB
"""Phase 2 — tests du transformer texte from scratch.
Tout tourne sur CPU en quelques secondes avec les tailles minuscules de
`configs/model.yaml > test`.
"""
from __future__ import annotations
import pytest
import torch
from src.model.attention import MultiHeadSelfAttention, build_causal_mask
from src.model.block import TransformerBlock
from src.model.ffn import FeedForward
from src.model.rope import apply_rope, build_rope_cache, rotate_half
from src.model.text_model import CortexTextLM, TextConfig
torch.manual_seed(0)
TINY = TextConfig(
vocab_size=64, n_layer=2, n_head=2, n_embd=32, ctx_len=16, dropout=0.0, tie_embeddings=True
)
# ---------------------------------------------------------------------------
# RoPE
# ---------------------------------------------------------------------------
def test_rope_cache_shapes():
cos, sin = build_rope_cache(seq_len=8, head_dim=16)
assert cos.shape == (8, 16) and sin.shape == (8, 16)
def test_rope_cache_rejects_odd_dim():
with pytest.raises(ValueError):
build_rope_cache(seq_len=4, head_dim=15)
def test_rotate_half_is_involution_up_to_sign_layout():
x = torch.arange(8, dtype=torch.float32).view(1, 8)
y = rotate_half(x)
assert torch.equal(y, torch.tensor([[-4.0, -5, -6, -7, 0, 1, 2, 3]]))
def test_rope_preserves_norm():
"""RoPE est une rotation : la norme de chaque paire est conservée."""
q = torch.randn(1, 2, 5, 16)
k = torch.randn(1, 2, 5, 16)
cos, sin = build_rope_cache(5, 16)
q2, k2 = apply_rope(q, k, cos, sin)
assert torch.allclose(q.norm(dim=-1), q2.norm(dim=-1), atol=1e-5)
assert torch.allclose(k.norm(dim=-1), k2.norm(dim=-1), atol=1e-5)
def test_rope_position_zero_is_identity():
q = torch.randn(1, 1, 3, 8)
k = torch.randn(1, 1, 3, 8)
cos, sin = build_rope_cache(3, 8)
q2, _ = apply_rope(q, k, cos, sin)
assert torch.allclose(q[:, :, 0], q2[:, :, 0], atol=1e-6)
def test_rope_makes_attention_depend_on_relative_position():
"""Le score q·k doit dépendre de l'écart de position, pas de l'absolu."""
d = 16
cos, sin = build_rope_cache(8, d)
base = torch.randn(1, 1, 1, d)
q = base.repeat(1, 1, 8, 1)
k = base.repeat(1, 1, 8, 1)
q_rot, k_rot = apply_rope(q, k, cos, sin)
scores = (q_rot @ k_rot.transpose(-2, -1))[0, 0]
# score[i, j] ne dépend que de (i - j)
assert torch.allclose(scores[0, 1], scores[1, 2], atol=1e-5)
assert torch.allclose(scores[2, 5], scores[3, 6], atol=1e-5)
# ---------------------------------------------------------------------------
# Masque causal + attention
# ---------------------------------------------------------------------------
def test_causal_mask_shape_and_content():
mask = build_causal_mask(4)
assert mask.shape == (4, 4)
assert mask[0].tolist() == [True, False, False, False]
assert mask[3].tolist() == [True, True, True, True]
def test_attention_output_shape():
attn = MultiHeadSelfAttention(32, 2).eval()
x = torch.randn(2, 7, 32)
assert attn(x).shape == (2, 7, 32)
def test_attention_is_strictly_causal():
"""Le token t ne dépend pas des tokens > t : on modifie le futur, la sortie passée ne bouge pas."""
attn = MultiHeadSelfAttention(32, 2, dropout=0.0).eval()
x = torch.randn(1, 6, 32)
with torch.no_grad():
y1 = attn(x)
x2 = x.clone()
x2[:, 4:] += 100.0 # on ne modifie QUE le futur
y2 = attn(x2)
assert torch.allclose(y1[:, :4], y2[:, :4], atol=1e-5), "fuite d'information du futur"
assert not torch.allclose(y1[:, 4:], y2[:, 4:])
def test_attention_matches_manual_inference_for_second_token():
"""Vérifie la formule d'attention à la main sur un cas simple."""
attn = MultiHeadSelfAttention(8, 1, use_rope=False, dropout=0.0).eval()
x = torch.randn(1, 3, 8)
with torch.no_grad():
out = attn(x)
q = attn.q_proj(x)
k = attn.k_proj(x)
v = attn.v_proj(x)
scores = (q @ k.transpose(-2, -1)) / (8 ** 0.5)
scores = scores.masked_fill(~build_causal_mask(3), float("-inf"))
w = torch.softmax(scores, dim=-1)
manual = w @ v
manual = attn.out_proj(manual)
assert torch.allclose(out, manual, atol=1e-5)
# ---------------------------------------------------------------------------
# FFN + bloc
# ---------------------------------------------------------------------------
def test_ffn_shape_and_param_count():
ffn = FeedForward(32, mult=4, bias=False)
x = torch.randn(2, 5, 32)
assert ffn(x).shape == (2, 5, 32)
# 2 matrices : 32*128 + 128*32
assert sum(p.numel() for p in ffn.parameters()) == 32 * 128 + 128 * 32
def test_block_preserves_shape():
block = TransformerBlock(32, 2).eval()
x = torch.randn(2, 5, 32)
assert block(x).shape == (2, 5, 32)
def test_block_is_prenorm():
block = TransformerBlock(32, 2)
assert hasattr(block, "ln_1") and hasattr(block, "ln_2")
# ---------------------------------------------------------------------------
# Modèle complet
# ---------------------------------------------------------------------------
def test_model_forward_shapes():
model = CortexTextLM(TINY).eval()
x = torch.randint(0, 64, (2, 16))
logits, loss = model(x)
assert logits.shape == (2, 16, 64)
assert loss is None
def test_model_loss_is_scalar():
model = CortexTextLM(TINY).eval()
x = torch.randint(0, 64, (2, 16))
_, loss = model(x, targets=x)
assert loss.dim() == 0
# À l'initialisation, la perte doit être proche de log(vocab) (distribution quasi uniforme).
assert abs(loss.item() - torch.log(torch.tensor(64.0)).item()) < 1.0
def test_model_rejects_too_long_sequence():
model = CortexTextLM(TINY).eval()
x = torch.randint(0, 64, (1, 17))
with pytest.raises(ValueError):
model(x)
def test_weight_tying():
model = CortexTextLM(TINY)
assert model.lm_head.weight is model.token_embedding.weight
def test_untied_model_has_more_params():
tied = CortexTextLM(TINY)
untied = CortexTextLM(
TextConfig(**{**TINY.__dict__, "tie_embeddings": False})
)
assert untied.count_parameters() > tied.count_parameters()
def test_model_is_causal():
model = CortexTextLM(TINY).eval()
x = torch.randint(0, 64, (1, 10))
with torch.no_grad():
a, _ = model(x)
x2 = x.clone()
x2[:, 5:] = torch.randint(0, 64, (1, 5))
b, _ = model(x2)
assert torch.allclose(a[:, :5], b[:, :5], atol=1e-5), "fuite du futur dans les logits"
# ---------------------------------------------------------------------------
# Overfit 1 batch : la loss doit descendre à ~0
# ---------------------------------------------------------------------------
def test_overfit_single_batch():
torch.manual_seed(0)
model = CortexTextLM(TINY)
x = torch.randint(0, 64, (4, 16))
optimizer = torch.optim.AdamW(model.parameters(), lr=3e-3)
first_loss = None
for step in range(200):
logits, loss = model(x, targets=x)
optimizer.zero_grad()
loss.backward()
optimizer.step()
if step == 0:
first_loss = loss.item()
final_loss = loss.item()
assert first_loss is not None and first_loss > 1.0
assert final_loss < 0.05, f"n'a pas overfit : loss finale {final_loss}"
assert final_loss < first_loss / 20