PAMPAr-Coder / scripts /sft_v5.py
lucas-mella's picture
feat: upload PAMPAr-Coder code and documentation
a2d6a0d verified
Raw
History Blame Contribute Delete
14.9 kB
#!/usr/bin/env python3
# SPDX-License-Identifier: BUSL-1.1
# Copyright (c) 2024-2026 Lucas Ricardo Mella Chillemi
"""
sft_v5.py — Third-pass SFT de PamparV3 sobre datos verificados en runtime.
Diferencias respecto a sft_v4.py:
- Parte de v3_sft_v4.pt (el mejor checkpoint hasta ahora: 8/16)
- Usa sft_v5.jsonl — dataset generado por generate_sft_v5.py
donde CADA ejemplo fue exec() + assert antes de incluirse
- Cubre los 8 patrones que el modelo falla (fizzbuzz, cuadrados,
invertir_dict, busqueda_binaria, merge_sort, Punto, memoize, primos)
- LR aun mas bajo: 3e-6 -> 3e-7 (no destruir el SFT v4)
- Guarda en v3_sft_v5.pt
Uso:
python -X utf8 scripts/sft_v5.py
"""
import argparse
import dataclasses
import json
import logging
import math
import random
import sys
import time
from collections import deque
from pathlib import Path
import torch
import torch.nn.functional as F
import torch.nn.utils as nn_utils
import sentencepiece as spm
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
from pampar.coder.v3 import PamparV3, ConfigV3, PRESET_V3
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%H:%M:%S",
)
logger = logging.getLogger("sft_v5")
# Marcadores válidos para la sección de respuesta
_MARCADORES = ["### Solution:", "### Protocolo:"]
# ─────────────────────────────────────────────────────────────────────────────
# Carga de datos
# ─────────────────────────────────────────────────────────────────────────────
def _cargar_targeted(ruta: Path) -> list[str]:
"""Carga targeted_sft.jsonl."""
ejemplos: list[str] = []
for linea in ruta.read_text(encoding="utf-8").splitlines():
if not linea.strip():
continue
try:
obj = json.loads(linea)
texto = obj.get("text", "")
if texto and any(m in texto for m in _MARCADORES):
ejemplos.append(texto)
except json.JSONDecodeError:
continue
logger.info("Targeted SFT: %d ejemplos cargados", len(ejemplos))
return ejemplos
# ─────────────────────────────────────────────────────────────────────────────
# Tokenizacion con mascara de loss
# ─────────────────────────────────────────────────────────────────────────────
def _tokenizar_con_mascara(
ejemplos: list[str],
tok: spm.SentencePieceProcessor,
max_seq_len: int,
) -> list[tuple[list[int], list[bool]]]:
"""
Tokeniza cada ejemplo y calcula una mascara booleana.
mask[i] = True si el token i pertenece a la seccion ### Solution: en adelante.
Solo se computa loss sobre posiciones donde mask=True.
"""
chunks: list[tuple[list[int], list[bool]]] = []
max_chars = max_seq_len * 6
for texto in ejemplos:
if len(texto) > max_chars:
texto = texto[:max_chars]
# Encontrar posicion del marcador (cualquier marcador válido)
marker_pos = -1
matched_marker = _MARCADORES[0]
for _m in _MARCADORES:
pos = texto.find(_m)
if pos >= 0 and (marker_pos < 0 or pos < marker_pos):
marker_pos = pos
matched_marker = _m
if marker_pos < 0:
# Sin marcador: entrenar sobre todo (fallback)
ids = tok.Encode(texto)
if len(ids) >= 16:
trunc = ids[:max_seq_len + 1]
chunks.append((trunc, [True] * len(trunc)))
continue
# Tokenizar el prefijo hasta el marcador para saber la longitud en tokens
prefijo = texto[:marker_pos + len(matched_marker)]
ids_prefijo = tok.Encode(prefijo)
ids_full = tok.Encode(texto)
if len(ids_full) < 16:
continue
ids_full = ids_full[:max_seq_len + 1]
n_prefijo = min(len(ids_prefijo), len(ids_full))
# mask: False para el prefijo del problema, True para la solucion
mascara = [False] * n_prefijo + [True] * (len(ids_full) - n_prefijo)
chunks.append((ids_full, mascara))
return chunks
# ─────────────────────────────────────────────────────────────────────────────
# Training helpers
# ─────────────────────────────────────────────────────────────────────────────
def _hacer_batch_mascarado(
chunks: list[tuple[list[int], list[bool]]],
indices: list[int],
device: torch.device,
max_seq_len: int,
) -> tuple[torch.Tensor, torch.Tensor]:
"""Batch con padding y mascara de loss."""
sels = [chunks[i] for i in indices]
max_len = min(max(len(ids) for ids, _ in sels), max_seq_len + 1)
padded_ids = []
padded_mask = []
for ids, mask in sels:
t = ids[:max_len]
m = mask[:max_len]
pad_len = max_len - len(t)
padded_ids.append(t + [0] * pad_len)
padded_mask.append(m + [False] * pad_len)
tokens = torch.tensor(padded_ids, dtype=torch.long, device=device)
mascara = torch.tensor(padded_mask, dtype=torch.bool, device=device)
return tokens, mascara
def _paso_mascarado(
modelo: PamparV3,
optimizer: torch.optim.Optimizer,
tokens: torch.Tensor,
mascara: torch.Tensor,
max_grad_norm: float,
) -> float:
"""
Forward + backward con loss mascarado.
Solo se entrena sobre los tokens de la solucion.
"""
modelo.train()
optimizer.zero_grad(set_to_none=True)
input_ids = tokens[:, :-1] # (B, T)
targets = tokens[:, 1:] # (B, T)
loss_mask = mascara[:, 1:] # alinear la mascara con targets
logits, _, _ = modelo(input_ids) # (B, T, V)
B, T, V = logits.shape
# Loss solo sobre posiciones de la solucion
logits_flat = logits.reshape(B * T, V)
targets_flat = targets.reshape(B * T)
mask_flat = loss_mask.reshape(B * T)
# Ignorar tokens no enmascarados poniendo target=-100
targets_masked = targets_flat.masked_fill(~mask_flat, -100)
loss = F.cross_entropy(logits_flat, targets_masked, ignore_index=-100)
if loss.isnan() or loss.isinf():
logger.warning("Loss inestable — skipping step")
return 0.0
loss.backward()
nn_utils.clip_grad_norm_(modelo.parameters(), max_grad_norm)
optimizer.step()
return float(loss.detach())
def _cosine_lr(paso: int, warmup: int, total: int, lr_max: float, lr_min: float) -> float:
if paso < warmup:
return lr_max * (paso + 1) / warmup
progreso = (paso - warmup) / max(1, total - warmup)
return lr_min + 0.5 * (lr_max - lr_min) * (1 + math.cos(math.pi * progreso))
def _guardar(ruta: Path, modelo: PamparV3, optimizer: torch.optim.Optimizer, paso: int) -> None:
ruta.parent.mkdir(parents=True, exist_ok=True)
torch.save({
"modelo": modelo.state_dict(),
"optimizer": optimizer.state_dict(),
"paso_global": paso,
"config": dataclasses.asdict(modelo.config),
"tipo": "sft_v5",
}, ruta)
logger.info("Checkpoint SFT-v5 guardado -> paso %d", paso)
# ─────────────────────────────────────────────────────────────────────────────
# CLI + main
# ─────────────────────────────────────────────────────────────────────────────
def _parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter)
p.add_argument("--checkpoint-in", type=Path,
default=ROOT / "checkpoints" / "v3_sft_v4.pt")
p.add_argument("--checkpoint-out", type=Path,
default=ROOT / "checkpoints" / "v3_sft_v5.pt")
p.add_argument("--tokenizer", type=Path,
default=ROOT / "data" / "tokenizer" / "pampar_48k.model")
p.add_argument("--targeted", type=Path,
default=ROOT / "data" / "sft_v5.jsonl")
p.add_argument("--lr", type=float, default=3e-6)
p.add_argument("--lr-min", type=float, default=3e-7)
p.add_argument("--warmup", type=int, default=30)
p.add_argument("--max-pasos", type=int, default=2000)
p.add_argument("--epochs", type=int, default=30,
help="Más epochs sobre dataset pequeño verificado")
p.add_argument("--batch-size", type=int, default=2)
p.add_argument("--seq-len", type=int, default=512)
p.add_argument("--max-grad-norm", type=float, default=1.0)
p.add_argument("--guardar-cada", type=int, default=500)
p.add_argument("--device", type=str, default="auto")
p.add_argument("--seed", type=int, default=42)
return p.parse_args()
def main() -> None:
args = _parse_args()
random.seed(args.seed)
torch.manual_seed(args.seed)
device = torch.device(
"cuda" if args.device == "auto" and torch.cuda.is_available()
else args.device if args.device != "auto" else "cpu"
)
logger.info("Device: %s", device)
if device.type == "cuda":
torch.cuda.manual_seed(args.seed)
logger.info("GPU: %s (%.1f GiB)",
torch.cuda.get_device_name(0),
torch.cuda.get_device_properties(0).total_memory / 1e9)
# Tokenizer
tok = spm.SentencePieceProcessor()
tok.Load(str(args.tokenizer))
logger.info("Tokenizer vocab=%d", tok.GetPieceSize())
# Modelo desde v3_sft.pt
if not args.checkpoint_in.exists():
logger.error("Checkpoint no encontrado: %s", args.checkpoint_in)
sys.exit(1)
payload = torch.load(args.checkpoint_in, map_location=device, weights_only=False)
config = ConfigV3(**payload["config"]) if "config" in payload else PRESET_V3
modelo = PamparV3(config).to(device)
modelo.load_state_dict(payload["modelo"])
logger.info("Cargado desde %s (tipo: %s) → fine-tune v5", args.checkpoint_in.name,
payload.get("tipo", "?"))
del payload
n_params = sum(p.numel() for p in modelo.parameters() if p.requires_grad)
logger.info("PamparV3 %.1fM params", n_params / 1e6)
# Datos
if not args.targeted.exists():
logger.error("Dataset dirigido no encontrado: %s", args.targeted)
logger.error("Primero ejecuta: python -X utf8 scripts/generate_sft_v5.py")
sys.exit(1)
ejemplos = _cargar_targeted(args.targeted)
chunks = _tokenizar_con_mascara(ejemplos, tok, args.seq_len)
logger.info("Chunks tokenizados con mascara: %d", len(chunks))
del ejemplos
# Verificar que hay suficiente texto en la zona de solucion
pct_solution = sum(sum(m) for _, m in chunks) / max(1, sum(len(ids) for ids, _ in chunks))
logger.info("Porcentaje de tokens en zona Solution: %.1f%%", pct_solution * 100)
# Optimizer con LR bajo (no destruir lo aprendido)
optimizer = torch.optim.AdamW(
modelo.parameters(),
lr=args.lr,
betas=(0.9, 0.95),
weight_decay=0.01,
eps=1e-8,
)
pasos_por_epoch = max(1, len(chunks) // args.batch_size)
total_pasos = min(args.max_pasos, args.epochs * pasos_por_epoch)
logger.info("SFT-v4: %d pasos | %d chunks | %d p/epoch | lr=%.1e->%.1e",
total_pasos, len(chunks), pasos_por_epoch, args.lr, args.lr_min)
paso = 0
t0 = time.time()
losses: deque[float] = deque(maxlen=100)
try:
for epoch in range(args.epochs):
idx = list(range(len(chunks)))
random.shuffle(idx)
logger.info("-- Epoch %d/%d --", epoch + 1, args.epochs)
for i in range(0, len(idx) - args.batch_size + 1, args.batch_size):
batch_idx = idx[i: i + args.batch_size]
tokens, mascara = _hacer_batch_mascarado(chunks, batch_idx, device, args.seq_len)
lr = _cosine_lr(paso, args.warmup, total_pasos, args.lr, args.lr_min)
for pg in optimizer.param_groups:
pg["lr"] = lr
loss = _paso_mascarado(modelo, optimizer, tokens, mascara, args.max_grad_norm)
if loss > 0:
losses.append(loss)
paso += 1
if paso % 10 == 0:
avg = sum(losses) / max(1, len(losses))
elapsed = time.time() - t0
logger.info(
"paso %5d/%d | loss=%.3f avg100=%.3f lr=%.1e ppl=%.1f (%.1f p/s)",
paso, total_pasos, loss, avg, lr,
math.exp(min(avg, 10)),
paso / elapsed,
)
if paso % args.guardar_cada == 0:
_guardar(args.checkpoint_out, modelo, optimizer, paso)
if paso >= args.max_pasos:
break
if paso >= args.max_pasos:
break
except KeyboardInterrupt:
logger.info("Interrumpido — guardando...")
_guardar(args.checkpoint_out, modelo, optimizer, paso)
elapsed = time.time() - t0
avg_final = sum(losses) / max(1, len(losses))
print(f"-- SFT-v5 Completado --")
print(f" Pasos: {paso}")
print(f" Tiempo: {int(elapsed // 3600)}h{int((elapsed % 3600) // 60):02d}m")
print(f" Loss final (avg100): {avg_final:.3f}")
print(f" PPL final: {math.exp(min(avg_final, 10)):.1f}")
print(f" Checkpoint: {args.checkpoint_out}")
print(f"\n Evaluar con:")
print(f" python -X utf8 scripts/eval_v3.py --checkpoint checkpoints/v3_sft_v5.pt")
if __name__ == "__main__":
main()