Spaces:
Running
Running
| #!/usr/bin/env python3 | |
| """ | |
| train.py — Fine-tune a causal LM into the ghost diary. | |
| Default target: full fine-tune of Qwen/Qwen2.5-3B on a single GPU | |
| (A100/H100/4090-class; bf16 + gradient checkpointing). A LoRA fallback is one | |
| flag away (--lora) if VRAM is tight. | |
| Everything is driven by a YAML config (see configs/) and any key can be | |
| overridden on the CLI: python scripts/train.py --config configs/train_qwen3b.yaml \ | |
| --override learning_rate=1e-5 num_train_epochs=3 | |
| The script is deliberately LOUD: it prints the full resolved hyperparameter | |
| set at start, again at save time, and stamps every post-training test | |
| generation with both the training hparams and the generation hparams used. | |
| """ | |
| import subprocess | |
| import sys | |
| print("[init] Checking for required training libraries...") | |
| try: | |
| import torch | |
| import yaml | |
| import datasets | |
| import transformers | |
| import peft | |
| import accelerate | |
| except ImportError: | |
| print("[init] Missing libraries detected. Installing via pip now...") | |
| subprocess.check_call([ | |
| sys.executable, "-m", "pip", "install", | |
| "torch", "transformers", "datasets", "pyyaml", "peft", "accelerate" | |
| ]) | |
| print("[init] Libraries installed successfully!\n") | |
| import argparse | |
| import json | |
| import math | |
| import os | |
| import random | |
| import time | |
| from dataclasses import asdict | |
| from pathlib import Path | |
| import torch | |
| import yaml | |
| from datasets import Dataset | |
| from transformers import (AutoModelForCausalLM, AutoTokenizer, | |
| DataCollatorForLanguageModeling, Trainer, | |
| TrainingArguments, set_seed) | |
| BANNER = "=" * 78 | |
| # ------------------------------------------------------------ config | |
| DEFAULTS = dict( | |
| model_name="Qwen/Qwen2.5-3B", | |
| dataset_dir="data/dataset", | |
| output_dir="outputs/ghost-qwen3b-base", | |
| seed=42, | |
| max_seq_len=2048, | |
| # optimization | |
| learning_rate=2e-5, | |
| embed_lr_multiplier=10.0, | |
| num_train_epochs=5, | |
| per_device_train_batch_size=4, | |
| gradient_accumulation_steps=4, | |
| warmup_ratio=0.03, | |
| weight_decay=0.01, | |
| lr_scheduler_type="cosine", | |
| max_grad_norm=1.0, | |
| bf16=True, | |
| gradient_checkpointing=True, | |
| # eval/logging | |
| logging_steps=10, | |
| eval_steps=50, | |
| save_total_limit=2, | |
| # lora fallback | |
| lora=False, | |
| lora_r=32, | |
| lora_alpha=64, | |
| lora_dropout=0.05, | |
| lora_target_modules=["q_proj", "k_proj", "v_proj", "o_proj", | |
| "gate_proj", "up_proj", "down_proj"], | |
| # post-train test inference | |
| run_test_inference=True, | |
| test_max_new_tokens=200, | |
| test_temperature=0.92, | |
| test_top_p=0.95, | |
| test_repetition_penalty=1.05, | |
| save=False, | |
| ) | |
| def load_config() -> dict: | |
| ap = argparse.ArgumentParser() | |
| ap.add_argument("--config", type=str, default=None) | |
| ap.add_argument("--override", nargs="*", default=[], | |
| help="key=value pairs overriding config") | |
| ap.add_argument("--lora", action="store_true", help="shortcut for lora=true") | |
| ap.add_argument("--save", action="store_true", help="Save the model weights at the end") | |
| args = ap.parse_args() | |
| cfg = dict(DEFAULTS) | |
| # Auto-detect OPBDH environment variables | |
| if "OPBDH_MODEL_ID" in os.environ: | |
| cfg["model_name"] = os.environ["OPBDH_MODEL_ID"] | |
| if "OPBDH_RESULTS_DIR" in os.environ: | |
| cfg["output_dir"] = os.path.join(os.environ["OPBDH_RESULTS_DIR"], "ghost-qwen3b") | |
| if args.save: | |
| cfg["save"] = True | |
| if args.config: | |
| cfg.update(yaml.safe_load(Path(args.config).read_text()) or {}) | |
| for kv in args.override: | |
| k, v = kv.split("=", 1) | |
| try: | |
| v = yaml.safe_load(v) # parses numbers/bools/lists | |
| except Exception: | |
| pass | |
| cfg[k] = v | |
| if args.lora: | |
| cfg["lora"] = True | |
| return cfg | |
| def shout(title: str, kv: dict): | |
| print(f"\n{BANNER}\n>>> {title}\n{BANNER}") | |
| for k, v in kv.items(): | |
| print(f" {k:32s} = {v}") | |
| print(BANNER, flush=True) | |
| # ------------------------------------------------------------ data | |
| def load_and_pack(cfg, tokenizer): | |
| """Tokenize each entry-document, append EOS, pack into max_seq_len blocks.""" | |
| def docs(split): | |
| path = Path(cfg["dataset_dir"]) / f"{split}.jsonl" | |
| return [json.loads(l)["text"] for l in path.open()] | |
| def pack(texts): | |
| ids = [] | |
| for t in texts: | |
| ids.extend(tokenizer(t, add_special_tokens=False)["input_ids"]) | |
| ids.append(tokenizer.eos_token_id) | |
| L = cfg["max_seq_len"] | |
| n_blocks = len(ids) // L | |
| blocks = [ids[i * L:(i + 1) * L] for i in range(n_blocks)] | |
| return Dataset.from_dict({"input_ids": blocks, | |
| "labels": [b[:] for b in blocks]}) | |
| train, val = pack(docs("train")), pack(docs("val")) | |
| print(f"[data] packed: train={len(train)} blocks, val={len(val)} blocks " | |
| f"of {cfg['max_seq_len']} tokens " | |
| f"(~{len(train) * cfg['max_seq_len']:,} train tokens/epoch)") | |
| return train, val | |
| # ------------------------------------------------------------ model | |
| def build_model(cfg, tokenizer, special_tokens): | |
| dtype = torch.bfloat16 if cfg["bf16"] and torch.cuda.is_available() else torch.float32 | |
| model = AutoModelForCausalLM.from_pretrained( | |
| cfg["model_name"], dtype=dtype, | |
| attn_implementation="sdpa", | |
| ) | |
| # Resize for the ghost tokens. NOTE: Qwen pads its embedding matrix beyond | |
| # the vocab (151936 rows vs 151665 tokens), so new token ids may land in | |
| # pre-existing padded rows — we therefore mean-init the ghost rows | |
| # explicitly by id rather than relying on matrix growth. | |
| old_n = model.get_input_embeddings().weight.shape[0] | |
| if len(tokenizer) > old_n: | |
| model.resize_token_embeddings(len(tokenizer)) | |
| ghost_ids = tokenizer.convert_tokens_to_ids(special_tokens) | |
| with torch.no_grad(): | |
| emb = model.get_input_embeddings().weight | |
| vocab_mean = emb[:old_n].mean(dim=0) | |
| for tid in ghost_ids: | |
| emb[tid] = vocab_mean + torch.randn_like(vocab_mean) * 0.02 | |
| out = model.get_output_embeddings() | |
| if out is not None and out.weight.data_ptr() != emb.data_ptr(): | |
| for tid in ghost_ids: | |
| out.weight[tid] = emb[tid] | |
| print(f"[model] embedding rows={emb.shape[0]}; mean-initialized " | |
| f"{len(ghost_ids)} ghost token rows (ids {ghost_ids})") | |
| if cfg["lora"]: | |
| from peft import LoraConfig, get_peft_model | |
| lcfg = LoraConfig(r=cfg["lora_r"], lora_alpha=cfg["lora_alpha"], | |
| lora_dropout=cfg["lora_dropout"], | |
| target_modules=cfg["lora_target_modules"], | |
| modules_to_save=["embed_tokens", "lm_head"], | |
| task_type="CAUSAL_LM") | |
| model = get_peft_model(model, lcfg) | |
| model.print_trainable_parameters() | |
| return model | |
| # ------------------------------------------------------------ test inference | |
| TEST_PROMPTS = [ | |
| # --- CHUNK 1: Wonderful / Statement --- | |
| ("Wonderful / Blended", "<|entry|>\n7 June.\nToday was a wonderful day. "), | |
| ("Wonderful / Pepys", "<|entry|><|pepys|>\n7 June.\nToday was a wonderful day. "), | |
| ("Wonderful / Van Gogh", "<|entry|><|vangogh|>\n7 June.\nToday was a wonderful day. "), | |
| ("Wonderful / Mansfield", "<|entry|><|mansfield|>\n7 June.\nToday was a wonderful day. "), | |
| ("Wonderful / MacLane", "<|entry|><|maclane|>\n7 June.\nToday was a wonderful day. "), | |
| # --- CHUNK 2: Interesting / Statement --- | |
| ("Interesting / Blended", "<|entry|>\n14 October.\nToday was quite interesting. "), | |
| ("Interesting / Pepys", "<|entry|><|pepys|>\n14 October.\nToday was quite interesting. "), | |
| ("Interesting / Van Gogh", "<|entry|><|vangogh|>\n14 October.\nToday was quite interesting. "), | |
| ("Interesting / Mansfield", "<|entry|><|mansfield|>\n14 October.\nToday was quite interesting. "), | |
| ("Interesting / MacLane", "<|entry|><|maclane|>\n14 October.\nToday was quite interesting. "), | |
| # --- CHUNK 3: Disaster / Statement --- | |
| ("Disaster / Blended", "<|entry|>\n14 October.\nToday was an absolute disaster. "), | |
| ("Disaster / Pepys", "<|entry|><|pepys|>\n14 October.\nToday was an absolute disaster. "), | |
| ("Disaster / Van Gogh", "<|entry|><|vangogh|>\n14 October.\nToday was an absolute disaster. "), | |
| ("Disaster / Mansfield", "<|entry|><|mansfield|>\n14 October.\nToday was an absolute disaster. "), | |
| ("Disaster / MacLane", "<|entry|><|maclane|>\n14 October.\nToday was an absolute disaster. "), | |
| ] | |
| def test_inference(cfg, model, tokenizer, train_summary: dict): | |
| gen_kwargs = dict( | |
| max_new_tokens=cfg["test_max_new_tokens"], | |
| temperature=cfg["test_temperature"], | |
| top_p=cfg["test_top_p"], | |
| repetition_penalty=cfg["test_repetition_penalty"], | |
| do_sample=True, | |
| pad_token_id=tokenizer.eos_token_id, | |
| ) | |
| shout("POST-TRAIN TEST INFERENCE — generation hparams", gen_kwargs) | |
| shout("POST-TRAIN TEST INFERENCE — training hparams in effect", train_summary) | |
| model.eval() | |
| device = next(model.parameters()).device | |
| eot = tokenizer.convert_tokens_to_ids("<|/entry|>") | |
| eos_tokens = [t for t in [eot, tokenizer.eos_token_id] if t is not None] | |
| for name, prompt in TEST_PROMPTS: | |
| ids = tokenizer(prompt, return_tensors="pt").to(device) | |
| with torch.no_grad(): | |
| out = model.generate(**ids, eos_token_id=eos_tokens, | |
| **gen_kwargs) | |
| text = tokenizer.decode(out[0][ids["input_ids"].shape[1]:], | |
| skip_special_tokens=False) | |
| print(f"\n----- [{name}] -----") | |
| print(f"PROMPT : {prompt!r}") | |
| print(f"OUTPUT : {text}") | |
| print(f"(gen: temp={gen_kwargs['temperature']} top_p={gen_kwargs['top_p']} " | |
| f"rep_pen={gen_kwargs['repetition_penalty']} " | |
| f"max_new={gen_kwargs['max_new_tokens']})", flush=True) | |
| # ------------------------------------------------------------ main | |
| def main(): | |
| cfg = load_config() | |
| set_seed(cfg["seed"]) | |
| t0 = time.time() | |
| meta = json.loads((Path(cfg["dataset_dir"]) / "meta.json").read_text()) | |
| special_tokens = meta["special_tokens"] | |
| shout("GHOST DIARY TRAINING — resolved hyperparameters", | |
| {**cfg, "special_tokens": special_tokens, | |
| "cuda": torch.cuda.is_available(), | |
| "device_name": torch.cuda.get_device_name(0) if torch.cuda.is_available() else "cpu"}) | |
| tokenizer = AutoTokenizer.from_pretrained(cfg["model_name"]) | |
| n_added = tokenizer.add_special_tokens( | |
| {"additional_special_tokens": special_tokens}) | |
| print(f"[tok] added {n_added} special tokens; vocab now {len(tokenizer)}") | |
| if tokenizer.pad_token is None: | |
| tokenizer.pad_token = tokenizer.eos_token | |
| train_ds, val_ds = load_and_pack(cfg, tokenizer) | |
| model = build_model(cfg, tokenizer, special_tokens) | |
| targs = TrainingArguments( | |
| output_dir=cfg["output_dir"], | |
| seed=cfg["seed"], | |
| learning_rate=float(cfg["learning_rate"]), | |
| num_train_epochs=cfg["num_train_epochs"], | |
| per_device_train_batch_size=cfg["per_device_train_batch_size"], | |
| gradient_accumulation_steps=cfg["gradient_accumulation_steps"], | |
| warmup_ratio=cfg["warmup_ratio"], | |
| weight_decay=cfg["weight_decay"], | |
| lr_scheduler_type=cfg["lr_scheduler_type"], | |
| max_grad_norm=cfg["max_grad_norm"], | |
| bf16=cfg["bf16"] and torch.cuda.is_available(), | |
| gradient_checkpointing=cfg["gradient_checkpointing"], | |
| logging_steps=cfg["logging_steps"], | |
| eval_strategy="steps", | |
| eval_steps=cfg["eval_steps"], | |
| save_strategy="no", | |
| save_total_limit=cfg["save_total_limit"], | |
| report_to=[], | |
| ) | |
| embed_lr = float(cfg["learning_rate"]) * float(cfg.get("embed_lr_multiplier", 10.0)) | |
| base_lr = float(cfg["learning_rate"]) | |
| embed_params, base_params = [], [] | |
| for name, param in model.named_parameters(): | |
| if not param.requires_grad: | |
| continue | |
| if "embed_tokens" in name or "lm_head" in name: | |
| embed_params.append(param) | |
| else: | |
| base_params.append(param) | |
| optimizer = torch.optim.AdamW([ | |
| {"params": embed_params, "lr": embed_lr}, | |
| {"params": base_params, "lr": base_lr}, | |
| ], weight_decay=cfg["weight_decay"]) | |
| print(f"[optim] Differential LRs configured: Embeddings/Head @ {embed_lr:.1e}, Base @ {base_lr:.1e}") | |
| trainer = Trainer( | |
| model=model, args=targs, | |
| train_dataset=train_ds, eval_dataset=val_ds, | |
| data_collator=DataCollatorForLanguageModeling(tokenizer, mlm=False), | |
| optimizers=(optimizer, None) | |
| ) | |
| eff_bs = cfg["per_device_train_batch_size"] * cfg["gradient_accumulation_steps"] | |
| steps_per_epoch = math.ceil(len(train_ds) / eff_bs) | |
| print(f"[train] effective batch size = {eff_bs} " | |
| f"({steps_per_epoch} steps/epoch, " | |
| f"{steps_per_epoch * cfg['num_train_epochs']} total)") | |
| trainer.train() | |
| metrics = trainer.evaluate() | |
| ppl = math.exp(metrics["eval_loss"]) if metrics.get("eval_loss") else float("nan") | |
| print(f"[train] final eval_loss={metrics.get('eval_loss'):.4f} ppl={ppl:.2f}") | |
| # ---- save | |
| if cfg["save"]: | |
| out = Path(cfg["output_dir"]) / "final" | |
| trainer.save_model(str(out)) | |
| tokenizer.save_pretrained(str(out)) | |
| run_record = {**cfg, "final_eval_loss": metrics.get("eval_loss"), | |
| "final_ppl": ppl, "train_blocks": len(train_ds), | |
| "wallclock_sec": round(time.time() - t0, 1)} | |
| (out / "run_config.json").write_text(json.dumps(run_record, indent=2)) | |
| shout("SAVED — final model + tokenizer + run_config.json", {"path": str(out)}) | |
| else: | |
| print("\n[save] Skipping model save (--save flag not provided).") | |
| # ---- prove the ghost speaks | |
| if cfg["run_test_inference"]: | |
| train_summary = {k: cfg[k] for k in | |
| ("model_name", "learning_rate", "num_train_epochs", | |
| "per_device_train_batch_size", | |
| "gradient_accumulation_steps", "max_seq_len", | |
| "lr_scheduler_type", "warmup_ratio", "seed", "lora")} | |
| train_summary["final_eval_loss"] = metrics.get("eval_loss") | |
| test_inference(cfg, model, tokenizer, train_summary) | |
| if __name__ == "__main__": | |
| main() | |