|
|
|
|
|
|
| """
|
| 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 = ["### Solution:", "### Protocolo:"]
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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]
|
|
|
|
|
| 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:
|
|
|
| ids = tok.Encode(texto)
|
| if len(ids) >= 16:
|
| trunc = ids[:max_seq_len + 1]
|
| chunks.append((trunc, [True] * len(trunc)))
|
| continue
|
|
|
|
|
| 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))
|
|
|
|
|
| mascara = [False] * n_prefijo + [True] * (len(ids_full) - n_prefijo)
|
| chunks.append((ids_full, mascara))
|
|
|
| return chunks
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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]
|
| targets = tokens[:, 1:]
|
| loss_mask = mascara[:, 1:]
|
|
|
| logits, _, _ = modelo(input_ids)
|
| B, T, V = logits.shape
|
|
|
|
|
| logits_flat = logits.reshape(B * T, V)
|
| targets_flat = targets.reshape(B * T)
|
| mask_flat = loss_mask.reshape(B * T)
|
|
|
|
|
| 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)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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)
|
|
|
|
|
| tok = spm.SentencePieceProcessor()
|
| tok.Load(str(args.tokenizer))
|
| logger.info("Tokenizer vocab=%d", tok.GetPieceSize())
|
|
|
|
|
| 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)
|
|
|
|
|
| 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
|
|
|
|
|
| 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 = 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()
|
|
|