Etapa 0: entorno, configuración validada, modelo y entrenador
Base del proyecto ENLACE: un modelo de lenguaje propio entrenado desde cero, en español, para asistencia general y familiar. El plan completo está en docs/PLAN.md. Esta etapa establece el andamiaje y lo verifica de punta a punta: - Configuración por capas (hardware × model × train × data) validada con pydantic. Ningún hiperparámetro vive en el código y una config inválida falla al arrancar, no a las tres horas de entrenamiento. - Perfiles de hardware que aíslan el salto de GPU: la RTX 2060 (Turing) no soporta bfloat16 ni FlashAttention-2, así que entrena en float16 con GradScaler y backend mem_efficient; el perfil de la 5090 ya está escrito. backends.py valida el perfil contra la GPU real antes de empezar. - Transformer decoder-only estilo Llama: RMSNorm, SwiGLU, RoPE, GQA, embeddings atados, QK-norm y z-loss. Los dos últimos son lo que mantiene estable el entrenamiento en float16. - Entrenador con schedule WSD, acumulación de gradiente, precisión mixta, checkpointing atómico y reanudación exacta. - Cargadores de datos con estado serializable: bytes para el smoke test y shards uint16 para el corpus real. 48 tests, entre ellos el crítico: reanudar desde un checkpoint reproduce los pesos de una corrida ininterrumpida, parámetro por parámetro. Verificado en CPU: 300 pasos sobre texto en español, loss 3.07 -> 1.63. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
"""La config tiene que fallar al arrancar, no a las tres horas."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from enlace.config.load import ConfigError, compose, load_config
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_composicion_por_capas():
|
||||
cfg = load_config(REPO / "configs/runs/pretrain-2060.yaml")
|
||||
assert cfg.hardware.name == "turing-2060"
|
||||
assert cfg.model.name == "tiny-50m"
|
||||
assert cfg.train.run_name == "pretrain-tiny-50m"
|
||||
assert cfg.data.source == "shards"
|
||||
|
||||
|
||||
def test_el_yaml_raiz_pisa_las_capas_incluidas():
|
||||
cfg = load_config(REPO / "configs/runs/smoke-2060.yaml")
|
||||
# El perfil de la 2060 declara micro_batch_size 8; la corrida lo sube a 64
|
||||
# porque el modelo char es diminuto.
|
||||
assert cfg.hardware.micro_batch_size == 64
|
||||
assert cfg.hardware.dtype == "float16" # esto sí viene del perfil
|
||||
|
||||
|
||||
def test_overrides_de_linea_de_comandos():
|
||||
cfg = load_config(REPO / "configs/runs/smoke-cpu.yaml", ["train.seed=99"])
|
||||
assert cfg.train.seed == 99
|
||||
|
||||
|
||||
@pytest.mark.parametrize("perfil", ["turing-2060", "blackwell-5090", "cpu"])
|
||||
def test_todos_los_perfiles_de_hardware_son_validos(perfil, tmp_path):
|
||||
"""Un perfil roto solo se descubre al migrar de placa; mejor ahora."""
|
||||
raiz = {
|
||||
"include": {
|
||||
"hardware": f"hardware/{perfil}.yaml",
|
||||
"model": "model/tiny-50m.yaml",
|
||||
"train": "train/pretrain.yaml",
|
||||
"data": "data/corpus.yaml",
|
||||
}
|
||||
}
|
||||
path = REPO / "configs" / "runs" / f"_tmp_{perfil}.yaml"
|
||||
path.write_text(yaml.safe_dump(raiz))
|
||||
try:
|
||||
cfg = load_config(path)
|
||||
assert cfg.hardware.name == perfil
|
||||
finally:
|
||||
path.unlink()
|
||||
|
||||
|
||||
def _raiz(tmp_path: Path, **parches) -> Path:
|
||||
"""Config raíz válida con parches encima, para probar validaciones."""
|
||||
base = {
|
||||
"include": {
|
||||
"hardware": "hardware/cpu.yaml",
|
||||
"model": "model/tiny-50m.yaml",
|
||||
"train": "train/pretrain.yaml",
|
||||
"data": "data/corpus.yaml",
|
||||
}
|
||||
}
|
||||
base.update(parches)
|
||||
path = REPO / "configs" / "runs" / "_tmp_test.yaml"
|
||||
path.write_text(yaml.safe_dump(base))
|
||||
return path
|
||||
|
||||
|
||||
def _espera_error(path: Path, fragmento: str):
|
||||
try:
|
||||
with pytest.raises(ConfigError) as exc:
|
||||
load_config(path)
|
||||
assert fragmento in str(exc.value)
|
||||
finally:
|
||||
path.unlink()
|
||||
|
||||
|
||||
def test_rechaza_campos_desconocidos(tmp_path):
|
||||
# Un typo tiene que ser un error ruidoso, no un default silencioso.
|
||||
_espera_error(_raiz(tmp_path, model={"n_layers": 12}), "n_layers")
|
||||
|
||||
|
||||
def test_rechaza_fp16_sin_grad_scaler(tmp_path):
|
||||
_espera_error(
|
||||
_raiz(tmp_path, hardware={"dtype": "float16", "use_grad_scaler": False}),
|
||||
"use_grad_scaler",
|
||||
)
|
||||
|
||||
|
||||
def test_rechaza_grad_scaler_sin_fp16(tmp_path):
|
||||
_espera_error(
|
||||
_raiz(tmp_path, hardware={"dtype": "float32", "use_grad_scaler": True}),
|
||||
"use_grad_scaler",
|
||||
)
|
||||
|
||||
|
||||
def test_rechaza_dmodel_no_divisible_por_heads(tmp_path):
|
||||
_espera_error(_raiz(tmp_path, model={"d_model": 500}), "no es divisible")
|
||||
|
||||
|
||||
def test_rechaza_gqa_incoherente(tmp_path):
|
||||
_espera_error(_raiz(tmp_path, model={"n_head": 8, "n_kv_head": 3}), "n_kv_head")
|
||||
|
||||
|
||||
def test_rechaza_schedule_que_no_entra(tmp_path):
|
||||
_espera_error(
|
||||
_raiz(tmp_path, train={"max_steps": 100, "schedule": {"warmup_steps": 80, "decay_steps": 80}}),
|
||||
"no quedaría fase estable",
|
||||
)
|
||||
|
||||
|
||||
def test_rechaza_data_source_sin_su_ruta(tmp_path):
|
||||
_espera_error(_raiz(tmp_path, data={"source": "chars", "text_path": None}), "text_path")
|
||||
|
||||
|
||||
def test_include_con_capa_desconocida(tmp_path):
|
||||
path = REPO / "configs" / "runs" / "_tmp_bad.yaml"
|
||||
path.write_text(yaml.safe_dump({"include": {"hardwar": "hardware/cpu.yaml"}}))
|
||||
try:
|
||||
with pytest.raises(ConfigError, match="capas desconocidas"):
|
||||
compose(path)
|
||||
finally:
|
||||
path.unlink()
|
||||
Reference in New Issue
Block a user