Phase 6.2: pin the training environment and make 10_train.py configurable
The training container from phases 3-5 no longer exists and nothing in the repo pinned its versions, so a rebuild could silently change either the checkpoint key conversion (breaking adapter naming) or the assistant-mask behaviour (training on system/user/tool tokens). requirements.train.txt pins what matters and documents the two-phase install: llmcompressor declares torch>=2.10.0 and the NGC image ships the 2.10.0a0 pre-release, which pip's resolver reads as older, so it goes in with --no-deps. Pre-flight verified against the merged bf16 checkpoint on spark: 01_inspect_modules.py prints model.layers.0.linear_attn.*, config.json is sha256-identical to the base (93a4693f...), and the index keysets match exactly (1045 tensors, 690 under model.language_model.layers.*, 0 under model.layers.*). So PEFT will name adapter #2 the same way it named #1 and ADAPTER_TO_CHECKPOINT_PREFIX in 20_merge_lora.py applies unchanged. 10_train.py: every path and hyperparameter moves to an env var, with the phase 3 values as defaults so a bare run still reproduces phase 3 exactly. Adds three guards that each cover a specific silent failure: - abort if OUTPUT_DIR already holds an adapter, unless ALLOW_OVERWRITE=1. OUTPUT_DIR was hardcoded to out/lora-adapter, which is the provenance of the model currently in production. - MAX_TOKENS aborts rather than truncates. There was no length filter at all, so one long design trajectory would blow the memory budget hours into a run; truncating would be worse, since it would silently cut assistant targets. - assert use_rslora/use_dora/bias/modules_to_save. rsLoRA scales by alpha/sqrt(r), so an adapter trained with it would merge at 2.0 where 11.3 belongs and pass every assertion in the merge script. Also adds a config banner, a token-length histogram, and a per-bucket assistant-mask ratio report. New 07_lint_penpot_code.py hard-fails on the forbidden API patterns, placeholder greys, fabricated penpot_api_info results, toy-shaped ids and per-category coverage shortfalls. Error-recovery seeds legitimately need the wrong pattern, so the exemption is derived mechanically rather than declared by hand: a payload may contain a forbidden pattern only if its tool result is a real error string from the allow-list and a later payload in the same seed does the same thing without it. Run against the 41 existing seeds it reproduces the diagnosis exactly: 110 problems, 36 unique payloads, 0% system messages, zero coverage of addGridLayout/shadows/uploadMediaUrl/layoutChild, fabricated docs and toy ids.
This commit is contained in:
+180
-23
@@ -1,20 +1,63 @@
|
||||
"""Fase 3: entrena el LoRA de Qwen3.6-35B-A3B sobre data/train.jsonl / data/eval.jsonl.
|
||||
"""Entrena un LoRA de Qwen3.6-35B-A3B sobre un par train/eval en formato JSONL.
|
||||
|
||||
Corre DENTRO del contenedor `qwen-lora-train` en spark (necesita transformers/peft/accelerate
|
||||
ya instalados ahi, y el checkpoint base en MODEL_PATH). Invocar via:
|
||||
ya instalados ahi -- ver requirements.train.txt -- y el checkpoint base en MODEL_PATH).
|
||||
|
||||
docker exec qwen-lora-train python3 /workspace/ai-projects/qwen3-6-lora/scripts/10_train.py
|
||||
|
||||
Tope de pasos para el dry-run via env var MAX_STEPS (o --max-steps N), sin tocar el resto de
|
||||
la config de TrainingArguments.
|
||||
|
||||
Masking manual (no trl.SFTTrainer): usa data/chat_template_train.jinja (con tags
|
||||
{% generation %}) para que tokenizer.apply_chat_template devuelva assistant_masks, y arma
|
||||
labels = input_ids donde assistant_masks==1, -100 en el resto (nunca entrena sobre
|
||||
system/user/tool).
|
||||
|
||||
CONFIGURACION POR ENV (Fase 6)
|
||||
------------------------------
|
||||
Todo lo que la Fase 6 necesita variar es una env var, y **todos los defaults son los valores
|
||||
exactos de la Fase 3**: una corrida pelada (`docker exec ... 10_train.py`, sin ninguna env)
|
||||
sigue reproduciendo la Fase 3 bit a bit. Eso es deliberado -- `out/lora-adapter/` es la
|
||||
procedencia del modelo que esta hoy en produccion y tiene que seguir siendo reproducible.
|
||||
|
||||
MODEL_PATH checkpoint base (def: .../Qwen--Qwen3.6-35B-A3B)
|
||||
TRAIN_FILE jsonl de entrenamiento (def: data/train.jsonl)
|
||||
EVAL_FILE jsonl de evaluacion (def: data/eval.jsonl)
|
||||
OUTPUT_DIR destino del adapter (def: out/lora-adapter)
|
||||
CHAT_TEMPLATE .jinja de training (def: data/chat_template_train.jinja)
|
||||
LEARNING_RATE (def: 1e-4) NUM_EPOCHS (def: 2)
|
||||
LORA_R (def: 32) LORA_ALPHA (def: 64) LORA_DROPOUT (def: 0.05)
|
||||
EVAL_STEPS (def: 50) SAVE_STEPS (def: 50)
|
||||
GRAD_ACCUM (def: 16)
|
||||
MAX_TOKENS tope duro de longitud (def: sin tope)
|
||||
PRESERVE_THINKING '1' para conservar el thinking de turnos previos (def: off)
|
||||
ALLOW_OVERWRITE '1' para permitir escribir sobre un OUTPUT_DIR existente (def: off)
|
||||
MAX_STEPS tope de pasos para el smoke run (o --max-steps N)
|
||||
|
||||
Invocacion de la Fase 6 (LoRA #2 de diseno en Penpot):
|
||||
|
||||
TRAIN_FILE=data/train_lora2.jsonl EVAL_FILE=data/eval_lora2.jsonl \
|
||||
OUTPUT_DIR=out/lora-adapter-penpot \
|
||||
MODEL_PATH=/workspace/ft-models/Qwen3.6-35B-A3B-mcp-bf16 \
|
||||
LEARNING_RATE=3e-5 NUM_EPOCHS=3 EVAL_STEPS=25 SAVE_STEPS=25 \
|
||||
MAX_TOKENS=3000 PRESERVE_THINKING=1 \
|
||||
python3 scripts/10_train.py
|
||||
|
||||
TRES GUARDS QUE EXISTEN POR UNA RAZON CONCRETA
|
||||
---------------------------------------------
|
||||
1. **Guard de sobrescritura.** OUTPUT_DIR estaba hardcodeado a `out/lora-adapter`. Una corrida
|
||||
de la Fase 6 con el default habria pisado el adapter de la Fase 3 -- el unico artefacto que
|
||||
hace bit-reproducible el modelo en produccion. Ahora aborta salvo ALLOW_OVERWRITE=1.
|
||||
2. **MAX_TOKENS aborta, no trunca.** Este script no tenia filtro de longitud ni truncaba nunca:
|
||||
una trayectoria de diseno de 6k tokens revienta el presupuesto de memoria a mitad de corrida,
|
||||
horas adentro. Truncar seria peor que abortar, porque cortaria targets del assistant en
|
||||
silencio y entrenaria sobre una respuesta mutilada sin que nada lo indique.
|
||||
3. **Asserts de rsLoRA/DoRA.** 20_merge_lora.py calcula `scaling = lora_alpha / r`. rsLoRA usa
|
||||
`alpha/sqrt(r)`, asi que un adapter con use_rslora=True se mergearia con una escala
|
||||
silenciosamente equivocada (2.0 donde va 11.3) **y pasaria todas las aserciones del merge**.
|
||||
Se asertan aca, en el origen, donde todavia es barato.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from collections import defaultdict
|
||||
|
||||
os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
|
||||
|
||||
@@ -27,11 +70,34 @@ from peft import LoraConfig, get_peft_model
|
||||
from transformers import AutoModelForCausalLM, AutoTokenizer, Trainer, TrainingArguments
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def _path_env(name, default_rel):
|
||||
"""Resuelve una ruta por env; las relativas cuelgan de la raiz del repo."""
|
||||
raw = os.environ.get(name)
|
||||
if not raw:
|
||||
return REPO_ROOT / default_rel
|
||||
p = Path(raw)
|
||||
return p if p.is_absolute() else REPO_ROOT / p
|
||||
|
||||
|
||||
MODEL_PATH = os.environ.get("MODEL_PATH", "/workspace/ft-models/Qwen--Qwen3.6-35B-A3B")
|
||||
TRAIN_CHAT_TEMPLATE_PATH = REPO_ROOT / "data" / "chat_template_train.jinja"
|
||||
TRAIN_FILE = REPO_ROOT / "data" / "train.jsonl"
|
||||
EVAL_FILE = REPO_ROOT / "data" / "eval.jsonl"
|
||||
OUTPUT_DIR = REPO_ROOT / "out" / "lora-adapter"
|
||||
TRAIN_CHAT_TEMPLATE_PATH = _path_env("CHAT_TEMPLATE", "data/chat_template_train.jinja")
|
||||
TRAIN_FILE = _path_env("TRAIN_FILE", "data/train.jsonl")
|
||||
EVAL_FILE = _path_env("EVAL_FILE", "data/eval.jsonl")
|
||||
OUTPUT_DIR = _path_env("OUTPUT_DIR", "out/lora-adapter")
|
||||
|
||||
LEARNING_RATE = float(os.environ.get("LEARNING_RATE", "1e-4"))
|
||||
NUM_EPOCHS = float(os.environ.get("NUM_EPOCHS", "2"))
|
||||
LORA_R = int(os.environ.get("LORA_R", "32"))
|
||||
LORA_ALPHA = int(os.environ.get("LORA_ALPHA", "64"))
|
||||
LORA_DROPOUT = float(os.environ.get("LORA_DROPOUT", "0.05"))
|
||||
EVAL_STEPS = int(os.environ.get("EVAL_STEPS", "50"))
|
||||
SAVE_STEPS = int(os.environ.get("SAVE_STEPS", "50"))
|
||||
GRAD_ACCUM = int(os.environ.get("GRAD_ACCUM", "16"))
|
||||
MAX_TOKENS = int(os.environ["MAX_TOKENS"]) if os.environ.get("MAX_TOKENS") else None
|
||||
PRESERVE_THINKING = os.environ.get("PRESERVE_THINKING", "").lower() in ("1", "true", "yes")
|
||||
ALLOW_OVERWRITE = os.environ.get("ALLOW_OVERWRITE", "").lower() in ("1", "true", "yes")
|
||||
|
||||
TARGET_MODULES = [
|
||||
"q_proj", "k_proj", "v_proj", "o_proj",
|
||||
@@ -50,17 +116,61 @@ def parse_args():
|
||||
return args
|
||||
|
||||
|
||||
def load_examples(tokenizer, path):
|
||||
import json
|
||||
def print_banner(args):
|
||||
print("=" * 78)
|
||||
print("CONFIGURACION DE ESTA CORRIDA")
|
||||
print("=" * 78)
|
||||
for label, value in [
|
||||
("MODEL_PATH", MODEL_PATH),
|
||||
("TRAIN_FILE", TRAIN_FILE),
|
||||
("EVAL_FILE", EVAL_FILE),
|
||||
("OUTPUT_DIR", OUTPUT_DIR),
|
||||
("CHAT_TEMPLATE", TRAIN_CHAT_TEMPLATE_PATH),
|
||||
("learning_rate", LEARNING_RATE),
|
||||
("num_train_epochs", NUM_EPOCHS),
|
||||
("grad_accum", GRAD_ACCUM),
|
||||
("lora r / alpha / dropout", f"{LORA_R} / {LORA_ALPHA} / {LORA_DROPOUT}"),
|
||||
("lora scaling (alpha/r)", LORA_ALPHA / LORA_R),
|
||||
("eval_steps / save_steps", f"{EVAL_STEPS} / {SAVE_STEPS}"),
|
||||
("MAX_TOKENS", MAX_TOKENS if MAX_TOKENS else "(sin tope)"),
|
||||
("PRESERVE_THINKING", PRESERVE_THINKING),
|
||||
("MAX_STEPS", args.max_steps if args.max_steps else "(corrida completa)"),
|
||||
]:
|
||||
print(f" {label:28} {value}")
|
||||
print("=" * 78)
|
||||
|
||||
|
||||
def guard_output_dir():
|
||||
"""Abortar si OUTPUT_DIR ya tiene un adapter. Ver nota 1 del docstring."""
|
||||
adapter = OUTPUT_DIR / "adapter_model.safetensors"
|
||||
if adapter.exists() and not ALLOW_OVERWRITE:
|
||||
raise SystemExit(
|
||||
f"[ABORT] {adapter} ya existe.\n"
|
||||
f" Este directorio contiene un adapter entrenado. Sobrescribirlo destruiria "
|
||||
f"la procedencia del modelo que produjo.\n"
|
||||
f" Si de verdad queres pisarlo, corre con ALLOW_OVERWRITE=1. Si lo que queres "
|
||||
f"es entrenar un adapter nuevo, pasa OUTPUT_DIR=out/<otro-nombre>."
|
||||
)
|
||||
|
||||
|
||||
def load_examples(tokenizer, path, label):
|
||||
"""Tokeniza el jsonl y arma los labels enmascarados. Aborta (no trunca, no filtra) si algun
|
||||
ejemplo supera MAX_TOKENS: ver nota 2 del docstring."""
|
||||
input_ids_list = []
|
||||
labels_list = []
|
||||
mask_by_bucket = defaultdict(lambda: {"assistant": 0, "total": 0, "n": 0})
|
||||
too_long = []
|
||||
lengths = []
|
||||
|
||||
with open(path, encoding="utf-8") as f:
|
||||
for line in f:
|
||||
for lineno, line in enumerate(f, start=1):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
example = json.loads(line)
|
||||
template_kwargs = {}
|
||||
if PRESERVE_THINKING:
|
||||
template_kwargs["preserve_thinking"] = True
|
||||
rendered = tokenizer.apply_chat_template(
|
||||
example["messages"],
|
||||
tools=example.get("tools"),
|
||||
@@ -68,14 +178,53 @@ def load_examples(tokenizer, path):
|
||||
return_assistant_tokens_mask=True,
|
||||
return_dict=True,
|
||||
add_generation_prompt=False,
|
||||
**template_kwargs,
|
||||
)
|
||||
input_ids = rendered["input_ids"]
|
||||
assistant_masks = rendered["assistant_masks"]
|
||||
if sum(assistant_masks) == 0:
|
||||
raise AssertionError(f"assistant_masks vacia para un ejemplo de {path}")
|
||||
raise AssertionError(f"assistant_masks vacia en {path.name}:{lineno}")
|
||||
|
||||
n_tokens = len(input_ids)
|
||||
lengths.append(n_tokens)
|
||||
if MAX_TOKENS and n_tokens > MAX_TOKENS:
|
||||
too_long.append((lineno, n_tokens))
|
||||
continue
|
||||
|
||||
bucket = example.get("meta", {}).get("bucket", "sin_bucket")
|
||||
stats = mask_by_bucket[bucket]
|
||||
stats["assistant"] += sum(assistant_masks)
|
||||
stats["total"] += n_tokens
|
||||
stats["n"] += 1
|
||||
|
||||
labels = [tok if mask == 1 else -100 for tok, mask in zip(input_ids, assistant_masks)]
|
||||
input_ids_list.append(input_ids)
|
||||
labels_list.append(labels)
|
||||
|
||||
if too_long:
|
||||
preview = ", ".join(f"linea {ln} ({n} tok)" for ln, n in too_long[:10])
|
||||
more = f" (y {len(too_long) - 10} mas)" if len(too_long) > 10 else ""
|
||||
raise SystemExit(
|
||||
f"[ABORT] {len(too_long)} ejemplo(s) de {path.name} superan MAX_TOKENS={MAX_TOKENS}: "
|
||||
f"{preview}{more}\n"
|
||||
f" Se aborta a proposito en vez de truncar: truncar cortaria targets del "
|
||||
f"assistant en silencio.\n"
|
||||
f" Parti esas trayectorias en dos (persistiendo ids en `storage`), o subi "
|
||||
f"MAX_TOKENS si tenes presupuesto de memoria para el pico que implica."
|
||||
)
|
||||
|
||||
lengths.sort()
|
||||
if lengths:
|
||||
def pct(p):
|
||||
return lengths[min(len(lengths) - 1, int(len(lengths) * p))]
|
||||
print(f"[INFO] {label}: {len(lengths)} ejemplos | tokens p50={pct(0.5)} "
|
||||
f"p90={pct(0.9)} p99={pct(0.99)} max={lengths[-1]}")
|
||||
|
||||
print(f"[INFO] ratio de mascara de assistant por bucket ({label}):")
|
||||
for bucket, s in sorted(mask_by_bucket.items()):
|
||||
ratio = 100.0 * s["assistant"] / s["total"] if s["total"] else 0.0
|
||||
print(f" {bucket:24} n={s['n']:5} assistant/total = {ratio:5.1f}%")
|
||||
|
||||
return Dataset.from_dict({"input_ids": input_ids_list, "labels": labels_list})
|
||||
|
||||
|
||||
@@ -104,6 +253,8 @@ class DataCollatorForCausalLMWithMasking:
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
print_banner(args)
|
||||
guard_output_dir()
|
||||
|
||||
print(f"[INFO] cargando tokenizer desde {MODEL_PATH}")
|
||||
tokenizer = AutoTokenizer.from_pretrained(MODEL_PATH)
|
||||
@@ -112,9 +263,9 @@ def main():
|
||||
tokenizer.pad_token = tokenizer.eos_token
|
||||
|
||||
print(f"[INFO] tokenizando {TRAIN_FILE}")
|
||||
train_dataset = load_examples(tokenizer, TRAIN_FILE)
|
||||
train_dataset = load_examples(tokenizer, TRAIN_FILE, "train")
|
||||
print(f"[INFO] tokenizando {EVAL_FILE}")
|
||||
eval_dataset = load_examples(tokenizer, EVAL_FILE)
|
||||
eval_dataset = load_examples(tokenizer, EVAL_FILE, "eval")
|
||||
print(f"[INFO] train={len(train_dataset)} eval={len(eval_dataset)}")
|
||||
|
||||
print(f"[INFO] cargando modelo desde {MODEL_PATH}")
|
||||
@@ -126,12 +277,18 @@ def main():
|
||||
|
||||
lora_config = LoraConfig(
|
||||
target_modules=TARGET_MODULES,
|
||||
r=32,
|
||||
lora_alpha=64,
|
||||
lora_dropout=0.05,
|
||||
r=LORA_R,
|
||||
lora_alpha=LORA_ALPHA,
|
||||
lora_dropout=LORA_DROPOUT,
|
||||
task_type="CAUSAL_LM",
|
||||
bias="none",
|
||||
)
|
||||
# Ver nota 3 del docstring: rsLoRA/DoRA romperian la aritmetica del merge en silencio.
|
||||
assert getattr(lora_config, "use_rslora", False) is False, "use_rslora tiene que quedar en False"
|
||||
assert getattr(lora_config, "use_dora", False) is False, "use_dora tiene que quedar en False"
|
||||
assert lora_config.bias == "none", "lora bias tiene que quedar en 'none'"
|
||||
assert not lora_config.modules_to_save, "modules_to_save tiene que quedar vacio"
|
||||
|
||||
model = get_peft_model(model, lora_config)
|
||||
model.print_trainable_parameters()
|
||||
|
||||
@@ -147,19 +304,19 @@ def main():
|
||||
|
||||
training_args = TrainingArguments(
|
||||
output_dir=str(OUTPUT_DIR),
|
||||
num_train_epochs=2,
|
||||
num_train_epochs=NUM_EPOCHS,
|
||||
per_device_train_batch_size=1,
|
||||
gradient_accumulation_steps=16,
|
||||
gradient_accumulation_steps=GRAD_ACCUM,
|
||||
gradient_checkpointing=True,
|
||||
bf16=True,
|
||||
optim="adamw_8bit",
|
||||
learning_rate=1e-4,
|
||||
learning_rate=LEARNING_RATE,
|
||||
lr_scheduler_type="cosine",
|
||||
warmup_ratio=0.03,
|
||||
eval_strategy="steps",
|
||||
eval_steps=50,
|
||||
eval_steps=EVAL_STEPS,
|
||||
save_strategy="steps",
|
||||
save_steps=50,
|
||||
save_steps=SAVE_STEPS,
|
||||
save_total_limit=3,
|
||||
logging_steps=5,
|
||||
max_steps=args.max_steps if args.max_steps else -1,
|
||||
|
||||
Reference in New Issue
Block a user