227 lines
11 KiB
Python
227 lines
11 KiB
Python
"""BabyYarros Option C — LoRA SFT on beat→paragraph pairs, loss on the RESPONSE only.
|
||
|
||
Sibling of `r49-corpus/train_voice_lora.py`, not a mode of it: that script is the
|
||
reproducibility anchor for every raw-text arm (Brontë 0.6B/1.7B/4B, Yarros base and
|
||
instruct) and adding a branch to it would put those runs one refactor away from moving.
|
||
Everything that is not the data path is held IDENTICAL to the raw-text instruct arm --
|
||
rank 32, alpha 64, lr 1e-4, batch 1 x accum 8, seed 4919, cosine with 3% warmup, eval and
|
||
save every 25 -- so the only variable between the two arms is the shape of the data.
|
||
|
||
⚠ LOSS IS MASKED TO THE ASSISTANT TURN. The raw-text arms train on every token because
|
||
every token is Yarros. Here the system prompt and the beat are NOT Yarros -- the beat is
|
||
machine-written -- and training on them teaches the carrier to generate beats, which is
|
||
capacity spent on the half of the exchange the product supplies. Prompt tokens get label
|
||
-100 and the collator checks that something survives: an example with zero unmasked labels
|
||
contributes no gradient and would otherwise train silently as a no-op.
|
||
|
||
⚠ NO PACKING. The raw-text path packs into fixed 4096-token blocks, which is right for
|
||
continuous prose and wrong here: packing would run loss across an example boundary and
|
||
teach the model that a response is followed by another system prompt. Pairs are padded
|
||
per example instead, and the cost -- padding waste -- is trivial at ~400 tokens each.
|
||
|
||
⚠ THE HELD-OUT LOSS FROM THIS RUN IS NOT COMPARABLE TO THE RAW-TEXT ARMS' 2.5263 / 2.6114.
|
||
Different objective over different tokens. It is good for exactly one thing, picking the
|
||
best checkpoint within this run, which the 4B rung showed is not the end-of-run adapter.
|
||
The cross-arm comparison has to be behavioural (on-beat / in-band / ran-on) plus delta_cb.
|
||
"""
|
||
from __future__ import annotations
|
||
import argparse, hashlib, json, math, random, subprocess, time
|
||
from pathlib import Path
|
||
|
||
import torch
|
||
from torch.utils.data import Dataset
|
||
from transformers import (AutoModelForCausalLM, AutoTokenizer, Trainer, TrainingArguments,
|
||
TrainerCallback)
|
||
from peft import LoraConfig, get_peft_model
|
||
|
||
TARGETS = ["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj"]
|
||
|
||
SYS = ("You expand a single story beat into ONE paragraph of prose in the manner of Rebecca "
|
||
"Yarros — contemporary first-person PRESENT-tense narration, emotionally charged, sensory "
|
||
"and physical, the voice of new-adult romantasy. Render the beat itself; do not move past "
|
||
"it, do not add a new scene, do not comment. Output the paragraph only, 90–140 words.")
|
||
|
||
|
||
def user_msg(rec: dict) -> str:
|
||
if rec.get("context"):
|
||
return (f"Continuing from:\n\n{rec['context']}\n\nBeat: {rec['beat']}")
|
||
return f"Beat: {rec['beat']}"
|
||
|
||
|
||
class Pairs(Dataset):
|
||
"""Renders each pair through the chat template and masks the prompt.
|
||
|
||
The prompt length is measured by tokenising the SAME prefix the full render begins
|
||
with -- add_generation_prompt=True on the messages minus the assistant turn -- rather
|
||
than by searching for a delimiter string. A delimiter search is what breaks silently
|
||
when a template changes its spacing, and it would mask the wrong span without erroring.
|
||
"""
|
||
|
||
def __init__(self, tok, records, seq_len):
|
||
self.rows = []
|
||
dropped = 0
|
||
for r in records:
|
||
msgs = [{"role": "system", "content": SYS}, {"role": "user", "content": user_msg(r)}]
|
||
prefix = tok.apply_chat_template(msgs, tokenize=True, add_generation_prompt=True)
|
||
full = tok.apply_chat_template(
|
||
msgs + [{"role": "assistant", "content": r["response"]}],
|
||
tokenize=True, add_generation_prompt=False)
|
||
if len(full) > seq_len or len(full) <= len(prefix):
|
||
dropped += 1
|
||
continue
|
||
labels = [-100] * len(prefix) + list(full[len(prefix):])
|
||
self.rows.append((full, labels))
|
||
self.dropped = dropped
|
||
if not self.rows:
|
||
raise SystemExit("REFUSING: every pair was dropped -- template or seq_len is wrong")
|
||
|
||
def __len__(self):
|
||
return len(self.rows)
|
||
|
||
def __getitem__(self, i):
|
||
ids, labels = self.rows[i]
|
||
return {"input_ids": ids, "labels": labels}
|
||
|
||
|
||
def collate(batch, pad_id):
|
||
n = max(len(b["input_ids"]) for b in batch)
|
||
ids, labels, mask = [], [], []
|
||
for b in batch:
|
||
k = n - len(b["input_ids"])
|
||
ids.append(b["input_ids"] + [pad_id] * k)
|
||
labels.append(b["labels"] + [-100] * k)
|
||
mask.append([1] * len(b["input_ids"]) + [0] * k)
|
||
lab = torch.tensor(labels)
|
||
if int((lab != -100).sum()) == 0:
|
||
raise SystemExit("REFUSING: a batch has no unmasked labels -- it would train as a no-op")
|
||
return {"input_ids": torch.tensor(ids), "labels": lab, "attention_mask": torch.tensor(mask)}
|
||
|
||
|
||
class LossLog(TrainerCallback):
|
||
def __init__(self, path):
|
||
self.path, self.series = path, []
|
||
|
||
def on_log(self, args, state, control, logs=None, **kw):
|
||
if logs:
|
||
self.series.append({"step": state.global_step, **logs})
|
||
self.path.write_text(json.dumps(self.series, indent=2))
|
||
|
||
|
||
def main() -> int:
|
||
ap = argparse.ArgumentParser()
|
||
ap.add_argument("--pairs", required=True)
|
||
ap.add_argument("--val-pairs", required=True)
|
||
ap.add_argument("--base", required=True)
|
||
ap.add_argument("--out", required=True)
|
||
ap.add_argument("--seq-len", type=int, default=1536)
|
||
ap.add_argument("--rank", type=int, default=32)
|
||
ap.add_argument("--lr", type=float, default=1e-4)
|
||
ap.add_argument("--epochs", type=float, default=3.0)
|
||
ap.add_argument("--batch", type=int, default=1)
|
||
ap.add_argument("--accum", type=int, default=8)
|
||
ap.add_argument("--seed", type=int, default=4919)
|
||
ap.add_argument("--eval-steps", type=int, default=25)
|
||
ap.add_argument("--save-steps", type=int, default=25)
|
||
a = ap.parse_args()
|
||
|
||
torch.manual_seed(a.seed); random.seed(a.seed)
|
||
out = Path(a.out); out.mkdir(parents=True, exist_ok=True)
|
||
|
||
tok = AutoTokenizer.from_pretrained(a.base)
|
||
if tok.chat_template is None:
|
||
raise SystemExit("REFUSING: carrier has no chat template -- it is not an instruct build")
|
||
if tok.pad_token_id is None:
|
||
tok.pad_token = tok.eos_token
|
||
|
||
h = hashlib.sha256()
|
||
def load(p):
|
||
b = Path(p).read_bytes(); h.update(b)
|
||
return [json.loads(l) for l in b.decode("utf-8").splitlines() if l.strip()]
|
||
train_recs, val_recs = load(a.pairs), load(a.val_pairs)
|
||
pairs_sha = h.hexdigest()[:16]
|
||
|
||
# The val pairs must come from corpus passages the train pairs never saw. A pair set
|
||
# split off the same passages would report a held-out loss over text the adapter had
|
||
# already fit, which is the failure that looks most like success.
|
||
tr_keys = {(r["work"], r["chapter"], r["response"][:80]) for r in train_recs}
|
||
overlap = [r for r in val_recs if (r["work"], r["chapter"], r["response"][:80]) in tr_keys]
|
||
if overlap:
|
||
raise SystemExit(f"REFUSING: {len(overlap)} val pairs share a passage with train")
|
||
if any(r.get("split") != "val" for r in val_recs):
|
||
raise SystemExit("REFUSING: val pairs are not all from the corpus val split")
|
||
|
||
train_ds, val_ds = Pairs(tok, train_recs, a.seq_len), Pairs(tok, val_recs, a.seq_len)
|
||
with_ctx = sum(1 for r in train_recs if r.get("context"))
|
||
print(f"[data] {len(train_ds)} train pairs ({train_ds.dropped} dropped), "
|
||
f"{len(val_ds)} val ({val_ds.dropped} dropped), "
|
||
f"{with_ctx}/{len(train_recs)} carry context, pairs sha {pairs_sha}", flush=True)
|
||
|
||
model = AutoModelForCausalLM.from_pretrained(a.base, dtype=torch.bfloat16,
|
||
attn_implementation="sdpa").to("cuda")
|
||
model = get_peft_model(model, LoraConfig(r=a.rank, lora_alpha=2 * a.rank, lora_dropout=0.0,
|
||
bias="none", task_type="CAUSAL_LM",
|
||
target_modules=TARGETS))
|
||
model.gradient_checkpointing_enable(); model.enable_input_require_grads()
|
||
trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
|
||
total = sum(p.numel() for p in model.parameters())
|
||
|
||
resolved = {
|
||
"attn_implementation": getattr(model.config, "_attn_implementation", "?"),
|
||
"dtype": str(next(model.parameters()).dtype),
|
||
"device": torch.cuda.get_device_name(0),
|
||
"torch": torch.__version__,
|
||
"adapted_modules": sum(1 for n, _ in model.named_modules() if n.endswith("lora_A.default")),
|
||
}
|
||
try:
|
||
repo = Path(__file__).resolve().parents[2]
|
||
git = subprocess.run(["git", "-C", str(repo), "rev-parse", "--short", "HEAD"],
|
||
capture_output=True, text=True).stdout.strip()
|
||
dirty = bool(subprocess.run(["git", "-C", str(repo), "status", "--porcelain"],
|
||
capture_output=True, text=True).stdout.strip())
|
||
except Exception:
|
||
git, dirty = "?", True
|
||
|
||
steps_per_epoch = math.ceil(len(train_ds) / (a.batch * a.accum))
|
||
prov = {"run": "r49-babyyarros-pairs-pilot", "base": a.base,
|
||
"pairs": a.pairs, "val_pairs": a.val_pairs, "pairs_sha256_16": pairs_sha,
|
||
"objective": "chat-template SFT, loss on assistant turn only",
|
||
"loss_comparable_to_raw_text_arms": False,
|
||
"train_pairs": len(train_ds), "val_pairs_n": len(val_ds),
|
||
"pairs_with_context": with_ctx,
|
||
"seq_len": a.seq_len, "lora_rank": a.rank, "lora_alpha": 2 * a.rank, "targets": TARGETS,
|
||
"lr": a.lr, "epochs": a.epochs, "batch": a.batch, "grad_accum": a.accum, "seed": a.seed,
|
||
"trainable_params": trainable, "total_params": total,
|
||
"trainable_pct": round(100 * trainable / total, 3),
|
||
"steps_per_epoch": steps_per_epoch, "planned_steps": steps_per_epoch * int(a.epochs),
|
||
"resolved": resolved, "harness_commit": git, "harness_dirty_at_launch": dirty,
|
||
"launched_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"), "system_prompt": SYS}
|
||
(out / "provenance.json").write_text(json.dumps(prov, indent=2))
|
||
print("[prov] " + json.dumps({k: prov[k] for k in
|
||
("pairs_sha256_16", "train_pairs", "planned_steps", "trainable_pct",
|
||
"harness_dirty_at_launch")}), flush=True)
|
||
print("[prov] resolved: " + json.dumps(resolved), flush=True)
|
||
|
||
args = TrainingArguments(
|
||
output_dir=str(out / "checkpoints"), per_device_train_batch_size=a.batch,
|
||
gradient_accumulation_steps=a.accum, num_train_epochs=a.epochs, learning_rate=a.lr,
|
||
lr_scheduler_type="cosine",
|
||
warmup_steps=max(1, int(0.03 * steps_per_epoch * int(a.epochs))),
|
||
bf16=True, logging_steps=10,
|
||
save_strategy="steps", save_steps=a.save_steps, save_total_limit=12,
|
||
eval_strategy="steps", eval_steps=a.eval_steps,
|
||
report_to=[], seed=a.seed,
|
||
gradient_checkpointing=True, dataloader_num_workers=2,
|
||
remove_unused_columns=False,
|
||
)
|
||
trainer = Trainer(model=model, args=args, train_dataset=train_ds, eval_dataset=val_ds,
|
||
data_collator=lambda b: collate(b, tok.pad_token_id),
|
||
callbacks=[LossLog(out / "loss-series.json")])
|
||
trainer.train()
|
||
model.save_pretrained(out / "adapter")
|
||
print("[done] adapter saved; the BEST checkpoint is in checkpoints/, not necessarily this one")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|