Download presentation/src/finetune.py from BuildITSystems/phishing-detector-encoder: direct link, hf CLI and curl.
- Browser
- Download file 93.9 kB
-
https://huggingface.co/BuildITSystems/phishing-detector-encoder/resolve/main/presentation/src/finetune.py
- Command line
-
hf download hf://BuildITSystems/phishing-detector-encoder/presentation/src/finetune.py
-
curl -L -o finetune.py https://huggingface.co/BuildITSystems/phishing-detector-encoder/resolve/main/presentation/src/finetune.py
93.9 kB
| """Phase 2: fine-tune ``EncoderForClassification`` -> legitimate / phishing / spam. | |
| The instructor's "experimentation dials" (number of layers, epochs, loss function, plus | |
| learning rate, batch size, frozen layers, synthetic data) are first-class CLI flags, and | |
| every run leaves ONE JSON line in reports/experiments.jsonl so the experimentation table | |
| (``finetune.py list``) is generated from evidence rather than typed up afterwards. | |
| Subcommands:: | |
| train [--run-name R] [--init pretrained:<dir>|scratch] [--n-layers N] [--freeze-layers K] | |
| [--loss cross_entropy|weighted_cross_entropy|focal] [--focal-gamma G] [--lr LR] | |
| [--batch-size B] [--micro-batch M] [--epochs E] [--max-steps S] [--eval-every S] | |
| [--log-every S] [--synthetic on|off] [--limit-rows N] [--num-labels 2|3] [--cpu] | |
| [--resume] [--overwrite] | |
| plot --run-name R [--out PNG] reports/finetune_curves.png | |
| list experiments.jsonl as a markdown table | |
| ML decisions, in brief: | |
| * **Three classes, partial labels (RUN_LOG entry 6).** ``finetune.num_labels`` (3) sizes the | |
| classification head; config ``model:`` is never touched because the live pretraining run | |
| refuses to resume if its model snapshot changes. Every row carries ``label3`` in | |
| {0 legitimate, 1 phishing, 2 spam, -1 unwanted-but-unsplit} and ``label_set`` in | |
| {legit, phish, spam, unwanted}; the binary ``label`` column (1 = phishing OR spam) stays. | |
| The TREC-05/07 and CEAS-08 positives never separated spam from phishing, so they train on | |
| the probability MASS of {phishing, spam}: per row an allowed-class mask ``[B, 3]`` built | |
| from label_set, and the loss is the partial-label negative log-likelihood | |
| ``logsumexp(all logits) - logsumexp(logits[allowed])`` = -log sum_{c in allowed} p_c | |
| (Jin & Ghahramani 2002; Cour et al. 2011). For a single allowed class this IS | |
| ``F.cross_entropy``; for an "unwanted" row it is ``-log(p_phish + p_spam)``, which still | |
| shapes the legitimate-vs-unwanted decision without asserting which kind it was. With | |
| ``num_labels: 2`` the old binary behaviour is reproduced exactly (label3 ignored; the mask | |
| is the one-hot label) so the binary baseline runs remain reproducible. | |
| * **Two metric sets.** (a) BINARY roll-up on all val rows: P(unwanted) = p1 + p2 vs the | |
| binary label at threshold 0.5 -> accuracy/precision/recall/F1/ROC-AUC/PR-AUC with the same | |
| definitions as the binary runs (so results stay comparable). (b) THREE-WAY on rows with | |
| label3 in {0, 1, 2}: argmax over 3 classes -> 3x3 confusion, per-class P/R/F1, macro-F1 | |
| over all three classes, plus a phishing-vs-spam confusion restricted to true unwanted rows | |
| (argmax over {phishing, spam} only: "given it is unwanted, which kind?"). Display names | |
| are {0: Legitimate, 1: Phishing, 2: Spam}; the roll-up is "Unwanted (phishing or spam)". | |
| Selection / early stopping follow ``finetune.early_stopping.metric``: ``val_f1_unwanted`` | |
| (default; = today's binary F1) or ``val_macro_f1_3way``. | |
| * **Data path.** Train = finetune_train.parquet (+ optional synthetic rows), selection = | |
| finetune_val.parquet. test_holdout.parquet (TREC-06) is refused by file name AND by the | |
| ``source`` column of anything loaded, so a misconfigured path cannot leak the holdout. | |
| Texts are encoded once with ``tokenizer.encode_batch`` (truncate to model.max_seq_len, | |
| pad to a multiple of finetune.dynamic_padding_multiple), stored trimmed, and re-padded per | |
| batch with the identical rule; the first batch is checked tensor-for-tensor against a | |
| direct ``encode_batch`` call so the app / evaluate.py path (which calls ``encode_batch`` | |
| per request) sees exactly the inputs the classifier was trained on. No sequence packing: | |
| the bool key-padding mask takes SDPA's memory-efficient kernel (research_model-math.md sec. 5). | |
| * **DataLoader.** Every batch goes through ``torch.utils.data.DataLoader`` (``make_loader``): | |
| a row-index ``Dataset``, the seeded length-bucketed batch order as ``batch_sampler`` and a | |
| collate function that pads per micro-batch. Shuffling, batching and preprocessing therefore | |
| live in the standard PyTorch data pipeline the course reference uses. | |
| * **Length bucketing.** Each epoch: seeded permutation -> mega-batches of 50 x batch_size -> | |
| sort by token length inside each -> cut into batches -> shuffle the batch order. Padding | |
| waste drops from ~50% (random batches over a 20..512 length spread) to a few percent while | |
| the batch composition stays random at the mega-batch level. The order is a pure function | |
| of (seed, epoch) so ``--resume`` can skip exactly the batches already consumed. | |
| * **Init.** ``pretrained:<dir>`` loads ``encoder.*`` from a Phase 1 checkpoint with strict | |
| key matching (``EncoderForClassification.from_pretrained_encoder``); ``scratch`` is the | |
| ablation that shows what MLM pretraining buys. ``--n-layers N`` keeps the FIRST N encoder | |
| layers (``encoder.layers.0..N-1``) of the pretrained stack and drops the rest; the final | |
| LayerNorm is kept although it was trained on layer-24 output (fine-tuning re-fits it). | |
| ``--freeze-layers K`` freezes embeddings + layers 0..K-1: cheaper steps (autograd stops at | |
| layer K) and less catastrophic forgetting on a small train set. | |
| * **Loss dial.** All three variants are defined over the partial-label NLL above (``nll``): | |
| cross_entropy = mean(nll); weighted_cross_entropy = sum(w_r nll_r) / sum(w_r) with | |
| inverse-frequency class weights w_c = N / (C * n_c) from the fully-labelled rows of the | |
| train split actually used and w_r = mean of w over the row's allowed classes (so an | |
| "unwanted" row gets mean(w_phish, w_spam)) -- the sum-of-weights normalisation is | |
| ``F.cross_entropy(weight=)``'s, so the two agree on fully-labelled batches; focal loss | |
| (Lin et al. 2017) ``(1 - exp(-nll))^gamma * nll`` = ``-(1 - p_t)^gamma log p_t`` without | |
| alpha, where p_t is the allowed-mass. Loss is computed in fp32 on bf16-autocast logits. | |
| * **Optimizer / schedule.** The same ``build_optimizer`` (AdamW, no weight decay on biases | |
| and LayerNorm, fused) and the same fwd/bwd -> clip 1.0 -> step -> zero_grad(set_to_none) | |
| pattern the smoke test measured. Linear warmup over finetune.warmup_fraction of the total | |
| optimizer steps, then linear decay to 0 (BERT fine-tuning schedule). Gradient | |
| accumulation: a logical batch of ``--batch-size`` rows is split into ``--micro-batch`` | |
| chunks (default 16, the smoke test's 13.65 GiB point for 16 x 512 with the heavier MLM | |
| head); each chunk's loss is scaled by its row share so the step gradient is the mean over | |
| the logical batch even when the last batch of an epoch is short. | |
| * **Selection / early stopping.** Full validation every ``--eval-every`` steps and at every | |
| epoch end; best/ is rewritten whenever the selection metric (val F1 of the unwanted | |
| roll-up at threshold 0.5 by default, or 3-way macro-F1) improves; | |
| finetune.early_stopping.patience evaluations without improvement stop the run. The | |
| F1-optimal threshold on val is recorded next to the 0.5 decision threshold so evaluate.py | |
| / the app can show both; selection itself uses 0.5 to stay comparable across runs. | |
| best/config.json carries num_labels, the label names, the selection metric, both metric | |
| sets and the thresholds. | |
| * **--cpu (dev only).** Runs the identical code path on the CPU with whatever config it is | |
| given (tiny encoder + fixture parquet files) so the whole script can be exercised while | |
| the GPU is busy; autocast and the VRAM guard are disabled and torch.cuda is never initialised: | |
| device info, allocator stats and the CUDA RNG snapshot are gated on the chosen device, and | |
| CUDA_VISIBLE_DEVICES is cleared for the process before the runtime is first queried (torch | |
| 2.14's ``AdamW.step`` health check would otherwise create a context on any visible GPU). If a | |
| device is still visible after that, the run refuses to start. The default path still requires CUDA. | |
| * **Resume.** last/ holds model + trainer_state.pt (optimizer, scheduler, step, epoch, | |
| batch-in-epoch, Python/NumPy/torch CPU/torch CUDA RNG states, best score, patience counter, | |
| cumulative seconds, the resolved dials). ``--resume`` restores all of it and skips the | |
| already-consumed batches of the current epoch. Dials that shape the optimizer state or the | |
| batch order (init, n_layers, freeze_layers, batch_size, seed) are refused on resume; ``--lr`` | |
| is re-applied to the restored optimizer/scheduler so the record never claims an lr that was | |
| not used; metrics.csv / epochs.csv rows logged after the checkpoint (a crash between | |
| checkpoints) are dropped before appending; a run already at its --max-steps / --epochs is | |
| reported as "nothing to resume" without training an extra step. SIGINT/SIGTERM save last/ | |
| and exit 0. | |
| * **VRAM guard.** ``PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True`` before CUDA starts; | |
| after every optimizer step the reserved peak is compared with config.vram.hard_cap_gb | |
| (GiB, the smoke test's convention) and the run aborts (after saving last/) on a breach. | |
| """ | |
| from __future__ import annotations | |
| import os | |
| os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True") | |
| import argparse # noqa: E402 | |
| import contextlib # noqa: E402 | |
| import csv # noqa: E402 | |
| import json # noqa: E402 | |
| import logging # noqa: E402 | |
| import math # noqa: E402 | |
| import random # noqa: E402 | |
| import signal # noqa: E402 | |
| import sys # noqa: E402 | |
| import time # noqa: E402 | |
| import zlib # noqa: E402 | |
| from dataclasses import asdict, dataclass, field, fields # noqa: E402 | |
| from pathlib import Path # noqa: E402 | |
| from typing import Any # noqa: E402 | |
| sys.path.insert(0, str(Path(__file__).parent)) | |
| import numpy as np # noqa: E402 | |
| import pyarrow.parquet as pq # noqa: E402 | |
| import torch # noqa: E402 | |
| import torch.nn.functional as F # noqa: E402 | |
| from safetensors import safe_open # noqa: E402 | |
| from safetensors.torch import load_file, save_file # noqa: E402 | |
| from torch import Tensor # noqa: E402 | |
| from torch.utils.data import DataLoader, Dataset # noqa: E402 | |
| import common # noqa: E402 | |
| from model import EncoderConfig, EncoderForClassification, count_parameters # noqa: E402 | |
| from smoke_test import build_optimizer # noqa: E402 (the measured optimizer code path) | |
| from tokenizer import encode_batch, load_tokenizer # noqa: E402 | |
| LOG = logging.getLogger("finetune") | |
| GIB = float(2**30) | |
| TRAIN_FILE = "finetune_train.parquet" | |
| VAL_FILE = "finetune_val.parquet" | |
| SYNTHETIC_FILE = "synthetic.parquet" | |
| HOLDOUT_FILE = "test_holdout.parquet" | |
| LOSSES = ("cross_entropy", "weighted_cross_entropy", "focal") | |
| MEGA_BATCH_FACTOR = 50 | |
| ENCODE_CHUNK = 2048 | |
| DECISION_THRESHOLD = 0.5 | |
| METRIC_KEYS = ("accuracy", "precision", "recall", "f1", "roc_auc", "pr_auc") | |
| RUN_LOG_MIN_STEPS = 100 | |
| # Dials whose change on --resume would leave the restored optimizer state / batch order / head shape inconsistent. | |
| RESUME_FIXED_DIALS = ("init", "n_layers", "freeze_layers", "batch_size", "seed", "num_labels") | |
| # The only implemented values of the config-only dials; anything else in config.yaml is an error, not a no-op. | |
| IMPLEMENTED = {"schedule": "linear_decay", "pooling": "cls"} | |
| CONFIG_PATH = {"schedule": "finetune.schedule", "pooling": "finetune.pooling", | |
| "selection_metric": "finetune.early_stopping.metric", "num_labels": "finetune.num_labels"} | |
| # ---- three-way label contract (RUN_LOG entry 6) ----------------------------------------------- | |
| LABEL_NAMES = {0: "Legitimate", 1: "Phishing", 2: "Spam"} | |
| UNWANTED_NAME = "Unwanted (phishing or spam)" | |
| LABEL_SETS: dict[str, tuple[int, ...]] = {"legit": (0,), "phish": (1,), "spam": (2,), "unwanted": (1, 2)} | |
| LABEL3_OF_SET = {"legit": 0, "phish": 1, "spam": 2, "unwanted": -1} | |
| NUM_LABELS_ALLOWED = (2, 3) | |
| # Selection metrics. val_f1_unwanted is today's binary F1 (p_unwanted = p1 + p2 at 0.5) so results stay | |
| # comparable with the binary runs; val_f1_phishing is the pre-entry-6 name of the same number and is | |
| # accepted so the recorded binary baselines resolve. val_macro_f1_3way needs num_labels == 3. | |
| SELECTION_METRICS = ("val_f1_unwanted", "val_macro_f1_3way", "val_f1_phishing") | |
| SELECTION_KEY = {"val_f1_unwanted": "f1", "val_f1_phishing": "f1", "val_macro_f1_3way": "macro_f1_3way"} | |
| CLASS_SLUGS = ("legitimate", "phishing", "spam") | |
| CM3_KEYS = tuple(f"cm3_{a[0]}{b[0]}" for a in CLASS_SLUGS for b in CLASS_SLUGS) # cm3_ll ... cm3_ss (true, pred) | |
| PVS_KEYS = ("pvs_phish_as_phish", "pvs_phish_as_spam", "pvs_spam_as_phish", "pvs_spam_as_spam") | |
| THREE_WAY_KEYS = ("n_3way", "accuracy_3way", "macro_f1_3way", | |
| *(f"{m}_{s}" for s in CLASS_SLUGS for m in ("precision", "recall", "f1")), | |
| *CM3_KEYS, "n_unwanted_3way", *PVS_KEYS) | |
| # validated categorical palette (dataviz skill): blue, orange, aqua, yellow | |
| PALETTE = ("#2a78d6", "#eb6834", "#1baf7a", "#eda100") | |
| # ---------------------------------------------------------------------------------- dials | |
| class TrainArgs: | |
| """Every dial of a run, resolved from CLI > saved state (on --resume) > config.yaml. | |
| Stored verbatim in trainer_state.pt and experiments.jsonl so a run is reproducible from | |
| its record alone. | |
| """ | |
| run_name: str = "finetune" | |
| init: str = "pretrained" # "pretrained" | "pretrained:<path>" | "scratch" | |
| n_layers: int = 24 | |
| freeze_layers: int = 0 | |
| loss: str = "cross_entropy" | |
| focal_gamma: float = 2.0 | |
| lr: float = 3e-5 | |
| batch_size: int = 32 | |
| micro_batch: int = 16 | |
| epochs: int = 3 | |
| max_steps: int | None = None | |
| eval_every: int = 500 | |
| log_every: int = 20 | |
| eval_batch_size: int = 64 | |
| warmup_fraction: float = 0.06 | |
| weight_decay: float = 0.01 | |
| betas: tuple[float, float] = (0.9, 0.98) | |
| eps: float = 1e-6 | |
| grad_clip: float = 1.0 | |
| patience: int = 2 | |
| schedule: str = "linear_decay" # config-only: finetune.schedule | |
| selection_metric: str = "val_f1_unwanted" # config: finetune.early_stopping.metric (val_f1_unwanted | val_macro_f1_3way) | |
| pooling: str = "cls" # config-only: finetune.pooling (EncoderForClassification reads token 0) | |
| num_labels: int = 3 # finetune.num_labels (NOT model.num_labels): 3 = legit/phish/spam, 2 = binary baseline | |
| synthetic: str = "off" | |
| limit_rows: int | None = None | |
| seed: int = 42 | |
| cpu: bool = False # dev only: run the whole script on the CPU | |
| def resolve(cls, ns: argparse.Namespace, cfg: dict, saved: dict | None) -> "TrainArgs": | |
| fcfg, mcfg = cfg["finetune"], cfg["model"] | |
| pcfg = cfg.get("pretrain", {}) | |
| base = cls( | |
| n_layers=int(mcfg["n_layers"]), | |
| # Absent key -> 2 so a config.yaml predating entry 6 still reproduces the binary baseline. | |
| num_labels=int(fcfg.get("num_labels", 2)), | |
| freeze_layers=int(fcfg.get("freeze_encoder_layers", 0)), | |
| loss=str(fcfg.get("loss", "cross_entropy")), | |
| focal_gamma=float(fcfg.get("focal_gamma", 2.0)), | |
| lr=float(fcfg["lr"]), | |
| batch_size=int(fcfg["batch_size"]), | |
| micro_batch=int(fcfg.get("micro_batch", 16)), | |
| epochs=int(fcfg["epochs"]), | |
| eval_every=int(fcfg.get("eval_every_steps", fcfg.get("checkpoint_every_steps", 500))), | |
| log_every=int(fcfg.get("log_every_steps", pcfg.get("log_every_steps", 20))), | |
| eval_batch_size=int(fcfg.get("eval_batch_size", 64)), | |
| warmup_fraction=float(fcfg["warmup_fraction"]), | |
| weight_decay=float(fcfg.get("weight_decay", pcfg.get("weight_decay", 0.01))), | |
| betas=tuple(fcfg.get("betas", pcfg.get("betas", (0.9, 0.98)))), | |
| eps=float(fcfg.get("eps", pcfg.get("eps", 1e-6))), | |
| grad_clip=float(fcfg.get("grad_clip", pcfg.get("grad_clip", 1.0))), | |
| patience=int(fcfg["early_stopping"]["patience"]), | |
| schedule=str(fcfg.get("schedule", IMPLEMENTED["schedule"])), | |
| selection_metric=str(fcfg["early_stopping"].get("metric", "val_f1_unwanted")), | |
| pooling=str(fcfg.get("pooling", IMPLEMENTED["pooling"])), | |
| synthetic="on" if cfg["data"].get("synthetic", {}).get("enabled") else "off", | |
| seed=int(cfg["seed"]), | |
| ) | |
| if saved: | |
| for k, v in saved.items(): | |
| if k in {f.name for f in fields(cls)}: | |
| setattr(base, k, tuple(v) if k == "betas" else v) | |
| for k, v in vars(ns).items(): | |
| if k in {f.name for f in fields(cls)} and v is not None and not (k == "cpu" and v is False): | |
| setattr(base, k, v) | |
| if getattr(ns, "micro_batch", None) is None: | |
| # A smaller --batch-size without an explicit --micro-batch means "one micro-batch per step". | |
| base.micro_batch = min(base.micro_batch, base.batch_size) | |
| for key, allowed in IMPLEMENTED.items(): | |
| if getattr(base, key) != allowed: | |
| raise ValueError(f"config {CONFIG_PATH[key]}={getattr(base, key)!r} is not implemented; only {allowed!r} is " | |
| f"(editing it would otherwise silently change nothing)") | |
| if base.num_labels not in NUM_LABELS_ALLOWED: | |
| raise ValueError(f"{CONFIG_PATH['num_labels']} must be one of {NUM_LABELS_ALLOWED}, got {base.num_labels!r}") | |
| if base.selection_metric not in SELECTION_METRICS: | |
| raise ValueError(f"{CONFIG_PATH['selection_metric']}={base.selection_metric!r} is not implemented; " | |
| f"one of {SELECTION_METRICS[:2]} (val_f1_phishing is the legacy alias of val_f1_unwanted)") | |
| if base.selection_metric == "val_macro_f1_3way" and base.num_labels != 3: | |
| raise ValueError("selection metric val_macro_f1_3way needs finetune.num_labels: 3") | |
| if base.loss not in LOSSES: | |
| raise ValueError(f"--loss must be one of {LOSSES}, got {base.loss!r}") | |
| if base.batch_size % base.micro_batch: | |
| raise ValueError(f"--batch-size {base.batch_size} must be a multiple of --micro-batch {base.micro_batch}") | |
| if not 0 <= base.freeze_layers <= base.n_layers: | |
| raise ValueError(f"--freeze-layers {base.freeze_layers} outside [0, {base.n_layers}]") | |
| return base | |
| def to_record(self) -> dict[str, Any]: | |
| d = asdict(self) | |
| d["betas"] = list(self.betas) | |
| return d | |
| # ----------------------------------------------------------------------------------- data | |
| class Labelled: | |
| """The label columns of one parquet split after validation against the three-way contract.""" | |
| texts: list[str] | |
| labels: np.ndarray # int64 [n] binary: 1 = phishing OR spam (unchanged from the binary build) | |
| label3: np.ndarray # int64 [n] in {0, 1, 2, -1}; with num_labels == 2 this is a copy of ``labels`` | |
| allowed: np.ndarray # bool [n, C] allowed-class mask built from label_set (one-hot for full labels) | |
| sources: list[str] | |
| def __len__(self) -> int: | |
| return len(self.texts) | |
| class EncodedSplit: | |
| """Token ids for one split, truncated to max_len and stored trimmed (flat int32 + offsets).""" | |
| name: str | |
| flat: np.ndarray # int32 concatenation of every row's ids | |
| offsets: np.ndarray # int64 [n + 1] | |
| labels: np.ndarray # int64 [n] binary roll-up target (metrics set a) | |
| label3: np.ndarray # int64 [n] three-way target, -1 = unwanted-but-unsplit (metrics set b uses >= 0) | |
| allowed: np.ndarray # bool [n, C] partial-label mask (the loss target) | |
| sources: list[str] | |
| texts: list[str] # kept for the encode_batch consistency check only | |
| def __len__(self) -> int: | |
| return int(self.labels.shape[0]) | |
| def lengths(self) -> np.ndarray: | |
| return np.diff(self.offsets) | |
| def num_labels(self) -> int: | |
| return int(self.allowed.shape[1]) | |
| def row(self, i: int) -> np.ndarray: | |
| return self.flat[self.offsets[i]:self.offsets[i + 1]] | |
| def collate(self, idx: np.ndarray, multiple: int, device: torch.device) -> tuple[Tensor, Tensor, Tensor, Tensor]: | |
| """Pad to the longest row in the batch rounded up to ``multiple`` (encode_batch's rule). | |
| Returns (ids int64 [B, L], key-padding mask bool [B, L], binary labels int64 [B], | |
| allowed-class mask bool [B, C]). | |
| """ | |
| lens = self.lengths[idx] | |
| L = int(lens.max()) | |
| if multiple: | |
| L = int(math.ceil(L / multiple) * multiple) | |
| ids = np.zeros((len(idx), L), dtype=np.int64) | |
| mask = np.zeros((len(idx), L), dtype=np.bool_) | |
| for r, i in enumerate(idx): | |
| n = int(lens[r]) | |
| ids[r, :n] = self.row(int(i)) | |
| mask[r, :n] = True | |
| return (torch.from_numpy(ids).to(device, non_blocking=True), | |
| torch.from_numpy(mask).to(device, non_blocking=True), | |
| torch.from_numpy(self.labels[idx]).to(device, non_blocking=True), | |
| torch.from_numpy(self.allowed[idx]).to(device, non_blocking=True)) | |
| def allowed_mask(label3: np.ndarray, label_set: list[str], num_labels: int) -> np.ndarray: | |
| """Per-row allowed-class mask ``[n, C]`` from label_set (3 classes) or the binary label (2 classes).""" | |
| out = np.zeros((len(label3), num_labels), dtype=np.bool_) | |
| if num_labels == 2: | |
| out[np.arange(len(label3)), label3] = True | |
| return out | |
| for r, s in enumerate(label_set): | |
| out[r, list(LABEL_SETS[s])] = True | |
| return out | |
| def _assert_not_holdout(path: Path, holdout_source: str) -> None: | |
| """Hard rule: TREC-06 is touched only by evaluate.py. Refuse by file name and by source value.""" | |
| if path.name == HOLDOUT_FILE: | |
| raise RuntimeError(f"refusing to load {path.name}: the test holdout is only read by evaluate.py") | |
| names = set(pq.read_schema(path).names) | |
| if "source" in names: | |
| values = {str(v) for v in pq.read_table(path, columns=["source"]).column("source").unique().to_pylist()} | |
| norm = holdout_source.lower().replace("_", "-") | |
| hits = {v for v in values if v.strip().lower().replace("_", "-") == norm} | |
| if hits: | |
| raise RuntimeError(f"{path.name} contains holdout source {hits}: test-set leakage") | |
| def read_split(path: Path, holdout_source: str, limit: int | None, seed: int, num_labels: int, | |
| force_source: str | None = None) -> Labelled: | |
| """Read text + labels of one split and validate the three-way contract. | |
| With ``num_labels == 3`` the ``label3`` / ``label_set`` columns are REQUIRED (a split built | |
| before RUN_LOG entry 6 is an error, not a silent all-"unwanted" run) and checked for | |
| internal consistency (label_set <-> label3 <-> binary label). With ``num_labels == 2`` | |
| they are ignored and the binary label is the target, reproducing the binary baseline. | |
| """ | |
| _assert_not_holdout(path, holdout_source) | |
| names = set(pq.read_schema(path).names) | |
| three = num_labels == 3 | |
| if three and not {"label3", "label_set"} <= names: | |
| raise ValueError(f"{path.name}: finetune.num_labels is 3 but the columns label3/label_set are missing " | |
| f"(have {sorted(names)}); rebuild the split with the three-way data_prep") | |
| cols = ["text", "label"] + (["source"] if "source" in names else []) + (["label3", "label_set"] if three else []) | |
| table = pq.read_table(path, columns=cols) | |
| if limit is not None and table.num_rows > limit: | |
| # Seeded random subsample (not a head slice) so a dev run still sees every source. | |
| idx = np.sort(np.random.default_rng([seed, zlib.crc32(path.name.encode())]).choice(table.num_rows, size=limit, replace=False)) | |
| table = table.take(idx) | |
| texts = [t or "" for t in table.column("text").to_pylist()] | |
| labels = np.asarray(table.column("label").to_pylist(), dtype=np.int64) | |
| if not set(np.unique(labels).tolist()) <= {0, 1}: | |
| raise ValueError(f"{path.name}: labels must be 0/1, got {np.unique(labels).tolist()}") | |
| if force_source is not None: | |
| sources = [force_source] * len(texts) | |
| else: | |
| sources = [str(s) for s in table.column("source").to_pylist()] if "source" in names else ["?"] * len(texts) | |
| if not three: | |
| return Labelled(texts, labels, labels.copy(), allowed_mask(labels, [], 2), sources) | |
| label3 = np.asarray(table.column("label3").to_pylist(), dtype=np.int64) | |
| label_set = [str(s) for s in table.column("label_set").to_pylist()] | |
| bad_sets = sorted(set(label_set) - set(LABEL_SETS)) | |
| if bad_sets: | |
| raise ValueError(f"{path.name}: label_set values outside {sorted(LABEL_SETS)}: {bad_sets}") | |
| expected3 = np.fromiter((LABEL3_OF_SET[s] for s in label_set), dtype=np.int64, count=len(label_set)) | |
| if not np.array_equal(expected3, label3): | |
| n_bad = int((expected3 != label3).sum()) | |
| raise ValueError(f"{path.name}: {n_bad} row(s) where label3 disagrees with label_set " | |
| f"(contract: legit->0, phish->1, spam->2, unwanted->-1)") | |
| expected_bin = (label3 != 0).astype(np.int64) | |
| if not np.array_equal(expected_bin, labels): | |
| raise ValueError(f"{path.name}: {int((expected_bin != labels).sum())} row(s) where the binary label disagrees " | |
| f"with label3 (label must be 1 for phish/spam/unwanted, 0 for legit)") | |
| return Labelled(texts, labels, label3, allowed_mask(label3, label_set, 3), sources) | |
| def concat_labelled(a: Labelled, b: Labelled) -> Labelled: | |
| return Labelled(a.texts + b.texts, np.concatenate([a.labels, b.labels]), np.concatenate([a.label3, b.label3]), | |
| np.concatenate([a.allowed, b.allowed]), a.sources + b.sources) | |
| def label_counts(label3: np.ndarray, num_labels: int) -> dict[str, int]: | |
| """Human-readable class counts for logs and records (keys: display names + 'unwanted').""" | |
| if num_labels == 2: | |
| return {LABEL_NAMES[0]: int((label3 == 0).sum()), UNWANTED_NAME: int((label3 == 1).sum())} | |
| out = {LABEL_NAMES[c]: int((label3 == c).sum()) for c in range(3)} | |
| out["unwanted (unsplit)"] = int((label3 == -1).sum()) | |
| return out | |
| def encode_split(name: str, tok, data: Labelled, max_len: int, multiple: int) -> EncodedSplit: | |
| """Encode once with the shared ``encode_batch`` contract, then store rows trimmed of padding.""" | |
| t0 = common.timer() | |
| texts, labels = data.texts, data.labels | |
| rows: list[np.ndarray] = [] | |
| for i in range(0, len(texts), ENCODE_CHUNK): | |
| ids, mask = encode_batch(tok, texts[i:i + ENCODE_CHUNK], max_len, pad_to_multiple_of=multiple) | |
| n = mask.sum(dim=1).numpy() | |
| ids_np = ids.numpy() | |
| rows.extend(ids_np[r, :n[r]].astype(np.int32) for r in range(ids_np.shape[0])) | |
| lengths = np.fromiter((len(r) for r in rows), dtype=np.int64, count=len(rows)) | |
| offsets = np.zeros(len(rows) + 1, dtype=np.int64) | |
| np.cumsum(lengths, out=offsets[1:]) | |
| flat = np.concatenate(rows) if rows else np.zeros(0, dtype=np.int32) | |
| split = EncodedSplit(name, flat, offsets, labels, data.label3, data.allowed, data.sources, texts) | |
| LOG.info("%s: %d rows encoded in %.1fs; tokens mean %.0f / median %.0f / max %d; %.1f%% truncated at %d; " | |
| "binary positives %d (%.1f%%); classes %s", name, len(rows), common.timer() - t0, lengths.mean(), | |
| np.median(lengths), lengths.max(), 100.0 * float((lengths >= max_len).mean()), max_len, int(labels.sum()), | |
| 100.0 * float(labels.mean()), label_counts(data.label3, split.num_labels)) | |
| return split | |
| def epoch_batches(lengths: np.ndarray, batch_size: int, seed: int, epoch: int) -> list[np.ndarray]: | |
| """Length-bucketed batch order for one epoch; a pure function of (seed, epoch) for resume.""" | |
| rng = np.random.default_rng([seed, epoch]) | |
| perm = rng.permutation(len(lengths)) | |
| mega = MEGA_BATCH_FACTOR * batch_size | |
| batches: list[np.ndarray] = [] | |
| for start in range(0, len(perm), mega): | |
| chunk = perm[start:start + mega] | |
| chunk = chunk[np.argsort(-lengths[chunk], kind="stable")] | |
| batches.extend(chunk[j:j + batch_size] for j in range(0, len(chunk), batch_size)) | |
| order = rng.permutation(len(batches)) | |
| return [batches[k] for k in order] | |
| def eval_batches(lengths: np.ndarray, batch_size: int) -> list[np.ndarray]: | |
| order = np.argsort(-lengths, kind="stable") | |
| return [order[i:i + batch_size] for i in range(0, len(order), batch_size)] | |
| class RowIndexDataset(Dataset): | |
| """``torch.utils.data.Dataset`` over an ``EncodedSplit``: an item is a row index. | |
| Membership and order come from the batch sampler (``epoch_batches`` / ``eval_batches``: | |
| seeded shuffling + length bucketing), padding from the collate function, so every batch | |
| the trainer sees goes through ``torch.utils.data.DataLoader`` exactly as the course | |
| reference does, while the resume-exact batch order is kept. | |
| """ | |
| def __init__(self, split: EncodedSplit) -> None: | |
| self.split = split | |
| def __len__(self) -> int: | |
| return len(self.split) | |
| def __getitem__(self, i: int) -> int: | |
| return int(i) | |
| def make_loader(split: EncodedSplit, batches: list[np.ndarray], multiple: int, | |
| micro_batch: int | None = None) -> DataLoader: | |
| """DataLoader whose batch sampler is the precomputed batch list. | |
| Yields ``(idx, ids, mask, labels, allowed)`` per batch, or with ``micro_batch`` set | |
| ``(idx, [(ids, mask, labels, allowed), ...])`` where each micro-batch is padded to ITS | |
| OWN longest row (the gradient-accumulation geometry the smoke test measured). Tensors | |
| are produced on the CPU; the training loop moves them to the device. ``num_workers=0`` | |
| keeps the order a pure function of (seed, epoch) and avoids forking next to a live | |
| CUDA context. | |
| """ | |
| cpu = torch.device("cpu") | |
| def collate(rows: list[int]): | |
| idx = np.asarray(rows, dtype=np.int64) | |
| if micro_batch is None: | |
| return (idx, *split.collate(idx, multiple, cpu)) | |
| return idx, [split.collate(idx[c:c + micro_batch], multiple, cpu) for c in range(0, len(idx), micro_batch)] | |
| # An explicit generator keeps the DataLoader from drawing its worker base seed from the | |
| # global torch RNG at iteration start, which would shift the dropout stream and break | |
| # bit-exact resume on the CPU dev path; the loader itself uses no randomness (num_workers=0, | |
| # explicit batch_sampler), so the seed value is irrelevant. | |
| return DataLoader(RowIndexDataset(split), batch_sampler=[[int(i) for i in b] for b in batches], | |
| collate_fn=collate, num_workers=0, generator=torch.Generator().manual_seed(0)) | |
| def load_data(cfg: dict, args: TrainArgs, tok) -> tuple[EncodedSplit, EncodedSplit, int]: | |
| processed = Path(cfg["paths"]["data_processed"]) | |
| holdout_source = str(cfg["data"]["holdout_test_source"]) | |
| max_len = int(cfg["model"]["max_seq_len"]) | |
| multiple = int(cfg["finetune"]["dynamic_padding_multiple"]) | |
| data = read_split(processed / TRAIN_FILE, holdout_source, args.limit_rows, args.seed, args.num_labels) | |
| n_synth = 0 | |
| if args.synthetic == "on": | |
| synth = processed / SYNTHETIC_FILE | |
| if synth.exists(): | |
| # Synthetic rows carry their own label3/label_set (category "1:*" -> phishing, "0:*" -> legit). | |
| sd = read_split(synth, holdout_source, int(cfg["data"]["synthetic"].get("max_rows", 2000)), | |
| args.seed, args.num_labels, force_source="synthetic") | |
| n_synth = len(sd) | |
| data = concat_labelled(data, sd) | |
| LOG.info("synthetic supplement: %d rows appended to TRAIN only; classes %s", n_synth, | |
| label_counts(sd.label3, args.num_labels)) | |
| else: | |
| LOG.warning("--synthetic on but %s is missing; training without synthetic rows", synth) | |
| train = encode_split("train", tok, data, max_len, multiple) | |
| vd = read_split(processed / VAL_FILE, holdout_source, args.limit_rows, args.seed, args.num_labels) | |
| if args.limit_rows is not None: | |
| LOG.warning("--limit-rows %d: validation is a seeded SUBSAMPLE too (dev only; metrics are not comparable)", | |
| args.limit_rows) | |
| val = encode_split("val", tok, vd, max_len, multiple) | |
| assert "synthetic" not in set(val.sources), "synthetic rows must never reach validation" | |
| if args.num_labels == 3 and args.selection_metric == "val_macro_f1_3way" and not (val.label3 >= 0).any(): | |
| raise ValueError("selection metric val_macro_f1_3way but finetune_val has no row with label3 in {0,1,2}") | |
| return train, val, n_synth | |
| def check_collate_matches_encode_batch(split: EncodedSplit, tok, idx: np.ndarray, max_len: int, multiple: int) -> None: | |
| """The trained-on tensors must equal what ``encode_batch`` yields for the same texts.""" | |
| ids, mask, _, _ = split.collate(idx, multiple, torch.device("cpu")) | |
| ref_ids, ref_mask = encode_batch(tok, [split.texts[int(i)] for i in idx], max_len, pad_to_multiple_of=multiple) | |
| if ids.shape != ref_ids.shape or not torch.equal(ids, ref_ids) or not torch.equal(mask, ref_mask): | |
| raise AssertionError("collated batch differs from tokenizer.encode_batch output") | |
| LOG.info("collate == encode_batch on the first batch (%s): OK", tuple(ids.shape)) | |
| # ---------------------------------------------------------------------------------- model | |
| def newest_pretrain_final(cfg: dict) -> Path | None: | |
| root = Path(cfg["paths"]["checkpoints"]) / "pretrain" | |
| finals = [p for p in root.glob("*/final") if (p / "model.safetensors").exists()] if root.exists() else [] | |
| return max(finals, key=lambda p: p.stat().st_mtime) if finals else None | |
| def read_pretrained(path: Path) -> tuple[dict[str, Tensor], dict[str, Any]]: | |
| """state_dict + EncoderConfig dict from a Phase 1 final/ or step-NNNNNNN/ directory.""" | |
| weights = path / "model.safetensors" | |
| if not weights.exists(): | |
| raise FileNotFoundError(f"{weights} not found") | |
| cfg_json = path / "config.json" | |
| if cfg_json.exists(): | |
| meta = common.read_json(cfg_json) | |
| else: | |
| with safe_open(str(weights), "pt") as fh: | |
| raw = (fh.metadata() or {}).get("config") | |
| if raw is None: | |
| raise RuntimeError(f"{path}: no config.json and no 'config' safetensors metadata") | |
| meta = json.loads(raw) | |
| model_cfg = meta["model"] if isinstance(meta, dict) and "model" in meta else meta | |
| return load_file(str(weights), device="cpu"), dict(model_cfg) | |
| def slice_layers(state: dict[str, Tensor], n_layers: int) -> dict[str, Tensor]: | |
| """Keep encoder.layers.0..n_layers-1 (the instructor's 'number of layers' dial on a pretrained stack).""" | |
| out: dict[str, Tensor] = {} | |
| dropped = set() | |
| for k, v in state.items(): | |
| if k.startswith("encoder.layers."): | |
| i = int(k.split(".")[2]) | |
| if i >= n_layers: | |
| dropped.add(i) | |
| continue | |
| out[k] = v | |
| if dropped: | |
| LOG.info("--n-layers %d: dropped pretrained layers %d..%d", n_layers, min(dropped), max(dropped)) | |
| return out | |
| def build_model(cfg: dict, args: TrainArgs, device: torch.device) -> tuple[EncoderForClassification, str]: | |
| """Returns (model, init_descriptor) where init_descriptor is 'scratch' or 'pretrained:<path>'.""" | |
| # The head size comes from finetune.num_labels, never from config model: (the pretraining snapshot). | |
| mcfg_dict = {**cfg["model"], "num_labels": args.num_labels} | |
| if args.init == "scratch": | |
| mcfg_dict["n_layers"] = args.n_layers | |
| ecfg = EncoderConfig.from_dict(mcfg_dict) | |
| common.set_seed(args.seed) | |
| with torch.device(device): | |
| model = EncoderForClassification(ecfg) | |
| return model, "scratch" | |
| path = Path(args.init.split(":", 1)[1]) if ":" in args.init else newest_pretrain_final(cfg) | |
| if path is None: | |
| raise FileNotFoundError("no checkpoints/pretrain/*/final found; pass --init pretrained:<dir> or --init scratch") | |
| if not path.is_absolute(): | |
| path = common.project_root() / path | |
| state, pre_cfg = read_pretrained(path) | |
| if int(pre_cfg["n_layers"]) < args.n_layers: | |
| raise ValueError(f"--n-layers {args.n_layers} > pretrained n_layers {pre_cfg['n_layers']}") | |
| # Architecture from the checkpoint; regularisation from config.yaml; depth from the dial. | |
| merged = {**pre_cfg, "n_layers": args.n_layers, "num_labels": args.num_labels, | |
| "dropout": mcfg_dict["dropout"], "attention_dropout": mcfg_dict["attention_dropout"], | |
| "grad_checkpointing": mcfg_dict.get("grad_checkpointing", False), "tie_mlm_head": pre_cfg.get("tie_mlm_head", True)} | |
| ecfg = EncoderConfig.from_dict(merged) | |
| common.set_seed(args.seed) | |
| with torch.device(device): | |
| model = EncoderForClassification.from_pretrained_encoder(slice_layers(state, args.n_layers), ecfg) | |
| LOG.info("loaded pretrained encoder from %s (%d of %d layers)", path, args.n_layers, pre_cfg["n_layers"]) | |
| return model, f"pretrained:{path}" | |
| def freeze(model: EncoderForClassification, k: int) -> None: | |
| if k <= 0: | |
| return | |
| for p in model.encoder.embeddings.parameters(): | |
| p.requires_grad_(False) | |
| for layer in list(model.encoder.layers)[:k]: | |
| for p in layer.parameters(): | |
| p.requires_grad_(False) | |
| LOG.info("frozen: embeddings + encoder layers 0..%d", k - 1) | |
| # ----------------------------------------------------------------------------------- loss | |
| def class_weights(label3: np.ndarray, num_labels: int, device: torch.device) -> Tensor: | |
| """Inverse-frequency class weights ``w_c = N / (C * n_c)`` over the FULLY labelled rows | |
| (label3 >= 0) of the train split actually used. Unwanted rows have no class of their own; | |
| they receive mean(w_phish, w_spam) through ``row_weights`` at loss time.""" | |
| full = label3[label3 >= 0] | |
| counts = np.bincount(full, minlength=num_labels).astype(np.float64) | |
| w = full.shape[0] / (float(num_labels) * np.maximum(counts, 1)) | |
| return torch.tensor(w, dtype=torch.float32, device=device) | |
| def partial_label_nll(logits: Tensor, allowed: Tensor) -> Tensor: | |
| """Per-row ``-log sum_{c in allowed} softmax(logits)_c`` = logsumexp(all) - logsumexp(allowed). | |
| One allowed class -> exactly ``F.cross_entropy(reduction="none")``; {phish, spam} -> | |
| ``-log(p1 + p2)``. Computed in fp32 from bf16-autocast logits. | |
| """ | |
| logits = logits.float() | |
| if allowed.shape != logits.shape: | |
| raise ValueError(f"allowed mask {tuple(allowed.shape)} does not match logits {tuple(logits.shape)}") | |
| lse_all = torch.logsumexp(logits, dim=-1) | |
| lse_allowed = torch.logsumexp(logits.masked_fill(~allowed, float("-inf")), dim=-1) | |
| return lse_all - lse_allowed | |
| def row_weights(allowed: Tensor, weights: Tensor) -> Tensor: | |
| """Mean of the class weights over each row's allowed classes: w[c] for a full label, | |
| mean(w1, w2) for an "unwanted" row.""" | |
| a = allowed.to(weights.dtype) | |
| return (a @ weights) / a.sum(dim=-1) | |
| def compute_loss(logits: Tensor, allowed: Tensor, kind: str, weights: Tensor | None, gamma: float) -> Tensor: | |
| """The three loss dials, all defined over the partial-label NLL (see module docstring). | |
| cross_entropy mean(nll) | |
| weighted_cross_entropy sum(w_r nll_r) / sum(w_r) (F.cross_entropy(weight=)'s normalisation) | |
| focal mean((1 - exp(-nll))^gamma * nll) (= -(1 - p_t)^gamma log p_t; gamma 0 = plain) | |
| """ | |
| nll = partial_label_nll(logits, allowed) | |
| if kind == "cross_entropy": | |
| return nll.mean() | |
| if kind == "weighted_cross_entropy": | |
| if weights is None: | |
| raise ValueError("weighted_cross_entropy needs class weights") | |
| w = row_weights(allowed, weights) | |
| return (w * nll).sum() / w.sum() | |
| if kind == "focal": | |
| return ((1.0 - torch.exp(-nll)).pow(gamma) * nll).mean() | |
| raise ValueError(f"unknown loss {kind!r}") | |
| # -------------------------------------------------------------------------------- metrics | |
| def classification_metrics(labels: np.ndarray, probs: np.ndarray, threshold: float = DECISION_THRESHOLD) -> dict[str, Any]: | |
| from sklearn.metrics import (accuracy_score, average_precision_score, confusion_matrix, | |
| precision_recall_curve, precision_recall_fscore_support, roc_auc_score) | |
| pred = (probs >= threshold).astype(np.int64) | |
| p, r, f1, _ = precision_recall_fscore_support(labels, pred, average="binary", pos_label=1, zero_division=0) | |
| cm = confusion_matrix(labels, pred, labels=[0, 1]) | |
| try: | |
| roc = float(roc_auc_score(labels, probs)) | |
| pr_auc = float(average_precision_score(labels, probs)) | |
| pc, rc, th = precision_recall_curve(labels, probs) | |
| f1s = 2 * pc[:-1] * rc[:-1] / np.maximum(pc[:-1] + rc[:-1], 1e-12) | |
| j = int(np.argmax(f1s)) | |
| opt_t, opt_f1 = float(th[j]), float(f1s[j]) | |
| except ValueError: # a single class present (tiny dev subsets) | |
| roc = pr_auc = opt_t = opt_f1 = float("nan") | |
| return { | |
| "accuracy": float(accuracy_score(labels, pred)), "precision": float(p), "recall": float(r), "f1": float(f1), | |
| "roc_auc": roc, "pr_auc": pr_auc, | |
| "tn": int(cm[0, 0]), "fp": int(cm[0, 1]), "fn": int(cm[1, 0]), "tp": int(cm[1, 1]), | |
| "threshold": threshold, "f1_optimal_threshold": opt_t, "f1_at_optimal_threshold": opt_f1, | |
| } | |
| def three_way_metrics(label3: np.ndarray, probs: np.ndarray) -> dict[str, Any]: | |
| """Metric set (b) on the rows with a full three-way label (label3 in {0, 1, 2}). | |
| ``probs`` is ``[n, 3]``. Prediction = argmax over the three classes. Keys (flat, so they | |
| drop straight into epochs.csv): n_3way, accuracy_3way, macro_f1_3way (macro over ALL three | |
| classes, a class absent from the slice scores 0, so tiny dev subsets read low), | |
| {precision,recall,f1}_{legitimate,phishing,spam}, cm3_<true><pred> with l/p/s initials | |
| (cm3_ps = true phishing predicted spam), and the phishing-vs-spam confusion restricted to | |
| true unwanted rows where the prediction is the argmax over {phishing, spam} only | |
| ("given it is unwanted, which kind?"): n_unwanted_3way, pvs_phish_as_phish, | |
| pvs_phish_as_spam, pvs_spam_as_phish, pvs_spam_as_spam. | |
| Returns None/0 placeholders (same keys) when the slice is empty or the model is binary: None, | |
| not NaN, so best/config.json and experiments.jsonl of a binary run stay strict JSON. | |
| """ | |
| from sklearn.metrics import confusion_matrix, precision_recall_fscore_support | |
| out: dict[str, Any] = {k: (0 if k.startswith(("n_", "cm3_", "pvs_")) else None) for k in THREE_WAY_KEYS} | |
| sel = label3 >= 0 | |
| if probs.ndim != 2 or probs.shape[1] != 3 or not sel.any(): | |
| return out | |
| y, p = label3[sel], probs[sel] | |
| pred = p.argmax(axis=1) | |
| cm = confusion_matrix(y, pred, labels=[0, 1, 2]) | |
| prec, rec, f1, _ = precision_recall_fscore_support(y, pred, labels=[0, 1, 2], zero_division=0) | |
| out.update(n_3way=int(sel.sum()), accuracy_3way=float((pred == y).mean()), macro_f1_3way=float(f1.mean())) | |
| for c, slug in enumerate(CLASS_SLUGS): | |
| out[f"precision_{slug}"], out[f"recall_{slug}"], out[f"f1_{slug}"] = float(prec[c]), float(rec[c]), float(f1[c]) | |
| for i in range(3): | |
| for j in range(3): | |
| out[f"cm3_{CLASS_SLUGS[i][0]}{CLASS_SLUGS[j][0]}"] = int(cm[i, j]) | |
| unw = y >= 1 | |
| if unw.any(): | |
| kind = 1 + p[unw][:, 1:3].argmax(axis=1) | |
| cm2 = confusion_matrix(y[unw], kind, labels=[1, 2]) | |
| out.update(n_unwanted_3way=int(unw.sum()), pvs_phish_as_phish=int(cm2[0, 0]), pvs_phish_as_spam=int(cm2[0, 1]), | |
| pvs_spam_as_phish=int(cm2[1, 0]), pvs_spam_as_spam=int(cm2[1, 1])) | |
| return out | |
| def metric_sets(res: dict[str, Any]) -> dict[str, Any]: | |
| """Structured view of a flat evaluation result for best/config.json and experiments.jsonl: | |
| ``binary_unwanted`` (metric set a) and ``three_way`` (metric set b, None for a binary model | |
| or an empty slice).""" | |
| binary = {k: res.get(k) for k in (*METRIC_KEYS, "tn", "fp", "fn", "tp", "threshold", "f1_optimal_threshold", | |
| "f1_at_optimal_threshold", "n")} | |
| binary["positive_class"] = UNWANTED_NAME | |
| three = None | |
| if res.get("n_3way"): | |
| three = { | |
| "n": res["n_3way"], "accuracy": res["accuracy_3way"], "macro_f1": res["macro_f1_3way"], | |
| "per_class": {LABEL_NAMES[c]: {m: res[f"{m}_{slug}"] for m in ("precision", "recall", "f1")} | |
| for c, slug in enumerate(CLASS_SLUGS)}, | |
| "confusion": {"order": [LABEL_NAMES[c] for c in range(3)], "rows_true_cols_pred": | |
| [[res[f"cm3_{a[0]}{b[0]}"] for b in CLASS_SLUGS] for a in CLASS_SLUGS]}, | |
| "phish_vs_spam_on_true_unwanted": { | |
| "n": res["n_unwanted_3way"], "order": [LABEL_NAMES[1], LABEL_NAMES[2]], | |
| "rows_true_cols_pred": [[res["pvs_phish_as_phish"], res["pvs_phish_as_spam"]], | |
| [res["pvs_spam_as_phish"], res["pvs_spam_as_spam"]]]}, | |
| } | |
| return {"binary_unwanted": binary, "three_way": three} | |
| def autocast_ctx(device: torch.device): | |
| """bf16 autocast on CUDA; a no-op on the CPU dev path (fp32 end to end).""" | |
| if device.type == "cuda": | |
| return torch.autocast(device_type="cuda", dtype=torch.bfloat16) | |
| return contextlib.nullcontext() | |
| def unwanted_prob(probs: Tensor) -> Tensor: | |
| """P(unwanted) = p_phish + p_spam (3 classes) or p_1 (binary).""" | |
| return probs[:, 1:].sum(dim=-1) | |
| def evaluate(model: EncoderForClassification, split: EncodedSplit, args: TrainArgs, multiple: int, | |
| device: torch.device) -> dict[str, Any]: | |
| """Both metric sets on ``split``. ``val_loss`` is the unweighted partial-label NLL (the | |
| plain cross_entropy dial) so it is comparable across loss variants.""" | |
| model.eval() | |
| t0 = common.timer() | |
| probs, labels, label3s, loss_sum = [], [], [], 0.0 | |
| loader = make_loader(split, eval_batches(split.lengths, args.eval_batch_size), multiple) | |
| for idx, ids, mask, y, allowed in loader: | |
| ids, mask, y, allowed = (t.to(device, non_blocking=True) for t in (ids, mask, y, allowed)) | |
| with autocast_ctx(device): | |
| logits = model(ids, mask) | |
| logits = logits.float() | |
| loss_sum += float(partial_label_nll(logits, allowed).sum()) | |
| probs.append(logits.softmax(-1).cpu().numpy()) | |
| labels.append(y.cpu().numpy()) | |
| # eval_batches() visits rows in length order, so EVERY target must be gathered with the same idx | |
| # as the predictions; split.label3 in storage order would score a permutation of the model. | |
| label3s.append(split.label3[idx]) | |
| model.train() | |
| probs_np, labels_np, label3_np = np.concatenate(probs), np.concatenate(labels), np.concatenate(label3s) | |
| out = classification_metrics(labels_np, unwanted_prob(torch.from_numpy(probs_np)).numpy()) | |
| out["f1_unwanted"] = out["f1"] | |
| out.update(three_way_metrics(label3_np, probs_np)) | |
| out["val_loss"] = loss_sum / max(1, len(labels_np)) | |
| out["n"] = int(len(labels_np)) | |
| out["eval_seconds"] = round(common.timer() - t0, 1) | |
| return out | |
| def selection_value(res: dict[str, Any], metric: str) -> float: | |
| v = res.get(SELECTION_KEY[metric]) | |
| return float("-inf") if v is None or (isinstance(v, float) and math.isnan(v)) else float(v) | |
| # ------------------------------------------------------------------------------- run state | |
| class RunState: | |
| step: int = 0 | |
| epoch: int = 0 | |
| batch_in_epoch: int = 0 | |
| best_score: float = -1.0 # value of the selection metric at best/ (named best_f1 before entry 6) | |
| best_step: int = 0 | |
| best_epoch: int = 0 | |
| evals_without_improvement: int = 0 | |
| train_seconds: float = 0.0 | |
| eval_seconds: float = 0.0 | |
| peak_reserved_gb: float = 0.0 | |
| peak_allocated_gb: float = 0.0 | |
| best_val: dict[str, Any] | None = None | |
| last_val: dict[str, Any] | None = None | |
| n_train: int = 0 | |
| n_val: int = 0 | |
| n_synthetic: int = 0 | |
| init: str = "" | |
| stopped_by: str = "" | |
| resumed_from_step: int | None = None | |
| metrics_tail: list[dict[str, Any]] = field(default_factory=list) | |
| class StopFlag: | |
| """SIGINT/SIGTERM -> finish the current micro-step, save last/, exit 0. Second signal -> default.""" | |
| def __init__(self) -> None: | |
| self.requested: str | None = None | |
| self._old = {s: signal.signal(s, self._handle) for s in (signal.SIGINT, signal.SIGTERM)} | |
| def _handle(self, signum: int, frame: Any) -> None: | |
| if self.requested: | |
| signal.signal(signum, self._old[signum]) | |
| raise KeyboardInterrupt | |
| self.requested = signal.Signals(signum).name | |
| LOG.warning("%s received: finishing the current step, saving last/ and exiting", self.requested) | |
| def rng_states(device: torch.device) -> dict[str, Any]: | |
| """RNG snapshot for resume. The CUDA generator is read only when training ON CUDA: | |
| ``torch.cuda.get_rng_state_all`` goes through ``_lazy_init`` and would create a CUDA context | |
| on a GPU the --cpu dev path must leave alone (the production pretraining run lives there).""" | |
| return {"python": random.getstate(), "numpy": np.random.get_state(), "torch_cpu": torch.get_rng_state(), | |
| "torch_cuda": torch.cuda.get_rng_state_all() if device.type == "cuda" else []} | |
| def restore_rng(states: dict[str, Any], device: torch.device) -> None: | |
| random.setstate(states["python"]) | |
| np.random.set_state(states["numpy"]) | |
| torch.set_rng_state(states["torch_cpu"]) | |
| if device.type == "cuda" and states.get("torch_cuda"): | |
| torch.cuda.set_rng_state_all(states["torch_cuda"]) | |
| def json_clean(obj: Any) -> Any: | |
| """Recursively replace non-finite floats (NaN/inf from a degenerate metric) with None so every | |
| JSON artifact (best/config.json, last/config.json, experiments.jsonl) is strict JSON; the stdlib | |
| encoder would otherwise emit the bare token ``NaN`` that non-Python parsers reject.""" | |
| if isinstance(obj, float): | |
| return obj if math.isfinite(obj) else None | |
| if isinstance(obj, np.floating): | |
| return float(obj) if np.isfinite(obj) else None | |
| if isinstance(obj, dict): | |
| return {k: json_clean(v) for k, v in obj.items()} | |
| if isinstance(obj, (list, tuple)): | |
| return [json_clean(v) for v in obj] | |
| return obj | |
| def save_model_dir(model: EncoderForClassification, out: Path, step: int, extra: dict[str, Any]) -> None: | |
| out.mkdir(parents=True, exist_ok=True) | |
| sd = {k: v.detach().to("cpu").contiguous() for k, v in model.state_dict().items()} | |
| cfg_dict = model.config.to_dict() | |
| save_file(sd, str(out / "model.safetensors"), | |
| metadata={"format": "pt", "step": str(step), "config": json.dumps(cfg_dict)}) | |
| common.write_json(json_clean({"model": cfg_dict, "tokenizer": "tokenizer.json", **extra}), out / "config.json") | |
| def save_last(run_dir: Path, model: EncoderForClassification, opt: torch.optim.Optimizer, | |
| sched: torch.optim.lr_scheduler.LRScheduler, state: RunState, args: TrainArgs, | |
| schedule: dict[str, int], device: torch.device) -> None: | |
| t0 = common.timer() | |
| last = run_dir / "last" | |
| save_model_dir(model, last, state.step, {"finetune": args.to_record(), "state": _state_record(state)}) | |
| torch.save({ | |
| "optimizer": opt.state_dict(), "scheduler": sched.state_dict(), "state": asdict(state), | |
| "args": args.to_record(), "schedule": schedule, "rng": rng_states(device), | |
| }, last / "trainer_state.pt") | |
| LOG.info("saved last/ at step %d (%.1fs)", state.step, common.timer() - t0) | |
| def _state_record(state: RunState) -> dict[str, Any]: | |
| d = asdict(state) | |
| d.pop("metrics_tail", None) | |
| return d | |
| def write_csv_row(path: Path, row: dict[str, Any], header: list[str]) -> None: | |
| new = not path.exists() | |
| with path.open("a", newline="", encoding="utf-8") as fh: | |
| w = csv.DictWriter(fh, fieldnames=header, extrasaction="ignore") | |
| if new: | |
| w.writeheader() | |
| w.writerow({k: ("" if row.get(k) is None else row.get(k)) for k in header}) | |
| def trim_csv(path: Path, max_step: int) -> None: | |
| """Drop rows with step > max_step: a crash between checkpoints leaves rows past the last/ being | |
| resumed, and the resumed steps would otherwise be appended a second time.""" | |
| if not path.exists(): | |
| return | |
| with path.open(newline="", encoding="utf-8") as fh: | |
| reader = csv.DictReader(fh) | |
| header, rows = list(reader.fieldnames or []), list(reader) | |
| keep = [r for r in rows if r.get("step") and int(float(r["step"])) <= max_step] | |
| if len(keep) == len(rows): | |
| return | |
| tmp = path.with_suffix(path.suffix + ".tmp") | |
| with tmp.open("w", newline="", encoding="utf-8") as fh: | |
| w = csv.DictWriter(fh, fieldnames=header) | |
| w.writeheader() | |
| w.writerows(keep) | |
| os.replace(tmp, path) | |
| LOG.info("%s: dropped %d row(s) logged after step %d (crash between checkpoints)", path.name, len(rows) - len(keep), max_step) | |
| METRICS_HEADER = ["step", "epoch", "loss", "lr", "examples_per_s", "max_alloc_gb", "max_reserved_gb", "elapsed_s", | |
| "eta_s", "val_loss", "val_f1", "val_roc_auc", "val_macro_f1_3way"] | |
| # Binary roll-up columns first (unchanged names, so old epochs.csv readers keep working), then the three-way set. | |
| EPOCHS_HEADER = ["epoch", "step", "is_epoch_end", "n", "val_loss", "accuracy", "precision", "recall", "f1", "roc_auc", | |
| "pr_auc", "tn", "fp", "fn", "tp", "threshold", "f1_optimal_threshold", "f1_at_optimal_threshold", | |
| "f1_unwanted", *THREE_WAY_KEYS, "eval_seconds", "is_best"] | |
| # ----------------------------------------------------------------------------- experiments | |
| def experiment_record(args: TrainArgs, state: RunState, params: dict[str, Any], steps_per_epoch: int) -> dict[str, Any]: | |
| val = state.best_val or {} | |
| return { | |
| "run_name": args.run_name, | |
| "timestamp": time.strftime("%Y-%m-%dT%H:%M:%S"), | |
| "init": state.init, | |
| "dials": { | |
| "n_layers": args.n_layers, "frozen_layers": args.freeze_layers, | |
| "epochs_planned": args.epochs, "epochs_run": round(state.step / max(1, steps_per_epoch), 3), | |
| "loss": args.loss, "focal_gamma": args.focal_gamma if args.loss == "focal" else None, | |
| "lr": args.lr, "batch_size": args.batch_size, "micro_batch": args.micro_batch, | |
| "class_weights": "inverse_frequency" if args.loss == "weighted_cross_entropy" else None, | |
| "synthetic": args.synthetic, "synthetic_rows": state.n_synthetic, | |
| "warmup_fraction": args.warmup_fraction, "max_steps": args.max_steps, "limit_rows": args.limit_rows, | |
| "seed": args.seed, "schedule": args.schedule, "selection_metric": args.selection_metric, | |
| "pooling": args.pooling, "num_labels": args.num_labels, "device": "cpu" if args.cpu else "cuda", | |
| }, | |
| "params": params, | |
| "steps_run": state.step, | |
| "n_train": state.n_train, "n_val": state.n_val, | |
| "train_seconds": round(state.train_seconds, 1), | |
| "peak_vram_gb": round(state.peak_reserved_gb, 3), | |
| "best_epoch": state.best_epoch, "best_step": state.best_step, | |
| "best_score": state.best_score if val else None, | |
| # Old binary keys kept verbatim (finetune.py list / evaluate.py read them); the three-way set is added. | |
| "val": ({**{k: val.get(k) for k in METRIC_KEYS}, "f1_unwanted": val.get("f1"), | |
| "macro_f1_3way": val.get("macro_f1_3way"), | |
| "per_class": {LABEL_NAMES[c]: {m: val.get(f"{m}_{slug}") for m in ("precision", "recall", "f1")} | |
| for c, slug in enumerate(CLASS_SLUGS)} if val.get("n_3way") else None, | |
| "three_way": metric_sets(val)["three_way"]} if val else None), | |
| "val_threshold": DECISION_THRESHOLD, | |
| "label_names": [LABEL_NAMES[c] for c in range(args.num_labels)] if args.num_labels == 3 else [LABEL_NAMES[0], UNWANTED_NAME], | |
| "stopped_by": state.stopped_by, | |
| "resumed_from_step": state.resumed_from_step, | |
| "test": None, | |
| } | |
| def upsert_experiment(path: Path, rec: dict[str, Any]) -> str: | |
| """ONE train line per run_name: a resumed invocation replaces its earlier train line instead | |
| of appending a duplicate; evaluate.py's lines (test != null) are never touched.""" | |
| if rec.get("test") is not None: | |
| raise ValueError("upsert_experiment writes train lines only (test must be None); a test record would " | |
| "overwrite the run's train line") | |
| lines = path.read_text(encoding="utf-8").splitlines() if path.exists() else [] | |
| out, replaced = [], False | |
| rec = json_clean(rec) # strict JSON: no bare NaN tokens for binary runs' three-way placeholders | |
| for line in lines: | |
| if not line.strip(): | |
| continue | |
| try: | |
| old = json.loads(line) | |
| except json.JSONDecodeError: | |
| out.append(line) | |
| continue | |
| if not replaced and old.get("run_name") == rec["run_name"] and old.get("test") is None: | |
| out.append(json.dumps(rec, allow_nan=False)) | |
| replaced = True | |
| else: | |
| out.append(line) | |
| if not replaced: | |
| out.append(json.dumps(rec, allow_nan=False)) | |
| path.parent.mkdir(parents=True, exist_ok=True) | |
| tmp = path.with_suffix(".jsonl.tmp") | |
| tmp.write_text("\n".join(out) + "\n", encoding="utf-8") | |
| os.replace(tmp, path) | |
| return "replaced" if replaced else "appended" | |
| def run_log_block(args: TrainArgs, state: RunState, run_dir: Path, params: dict[str, Any]) -> str: | |
| v = state.best_val or {} | |
| nan = float("nan") | |
| three = "" | |
| if v.get("n_3way"): | |
| three = (f" Three-way on {v['n_3way']:,} fully-labelled val rows: macro-F1 {v['macro_f1_3way']:.4f}, accuracy " | |
| f"{v['accuracy_3way']:.4f}; per-class F1 " + | |
| ", ".join(f"{LABEL_NAMES[c]} {v[f'f1_{s}']:.4f}" for c, s in enumerate(CLASS_SLUGS)) + | |
| f"; phishing-vs-spam on true unwanted (n={v['n_unwanted_3way']:,}): phish->phish {v['pvs_phish_as_phish']}, " | |
| f"phish->spam {v['pvs_phish_as_spam']}, spam->phish {v['pvs_spam_as_phish']}, spam->spam {v['pvs_spam_as_spam']}.") | |
| return "\n".join([ | |
| f"## Fine-tune run `{args.run_name}` — {time.strftime('%Y-%m-%d %H:%M')}", | |
| f"**Dials.** init {state.init}; {args.num_labels} classes; {args.n_layers} layers, {args.freeze_layers} frozen; loss {args.loss}" | |
| f"{f' (gamma {args.focal_gamma})' if args.loss == 'focal' else ''}; lr {args.lr:g}; batch {args.batch_size} " | |
| f"(micro {args.micro_batch}); epochs {args.epochs} planned; synthetic {args.synthetic} ({state.n_synthetic} rows); " | |
| f"selection {args.selection_metric}; train rows {state.n_train:,}, val rows {state.n_val:,}; params {params['total']:,} " | |
| f"({params['trainable']:,} trainable).", | |
| f"**Result.** {state.step} steps, stopped by {state.stopped_by}; training time {state.train_seconds / 60:.1f} min " | |
| f"(eval {state.eval_seconds / 60:.1f} min of it); peak reserved {state.peak_reserved_gb:.2f} GiB. Best val at step " | |
| f"{state.best_step} (epoch {state.best_epoch}), {UNWANTED_NAME} roll-up: F1 {v.get('f1', nan):.4f}, precision " | |
| f"{v.get('precision', nan):.4f}, recall {v.get('recall', nan):.4f}, accuracy {v.get('accuracy', nan):.4f}, ROC-AUC " | |
| f"{v.get('roc_auc', nan):.4f}, PR-AUC {v.get('pr_auc', nan):.4f}; F1-optimal threshold {v.get('f1_optimal_threshold', nan):.3f}." | |
| f"{three} Checkpoints: {run_dir}/best, {run_dir}/last.", | |
| ]) | |
| # ------------------------------------------------------------------------------- training | |
| CPU_VRAM = {"allocated_gb": 0.0, "reserved_gb": 0.0, "max_allocated_gb": 0.0, "max_reserved_gb": 0.0} | |
| def backfill_legacy_args(saved: dict[str, Any], run_dir: Path) -> None: | |
| """A trainer_state.pt written before RUN_LOG entry 6 has no ``num_labels`` / ``selection_metric`` | |
| in its args. Fill them from what that run actually was (the head size recorded in | |
| last/config.json model.num_labels; the only selection metric that existed, val_f1_phishing) so the | |
| saved-overrides-config rule resolves the run as the binary run it is, instead of the current config's | |
| 3-class head crashing in load_state_dict with a size mismatch.""" | |
| args = saved.get("args") or {} | |
| if "num_labels" not in args: | |
| try: | |
| n = int(common.read_json(run_dir / "last" / "config.json")["model"]["num_labels"]) | |
| except (OSError, KeyError, TypeError, ValueError, json.JSONDecodeError): | |
| n = 2 | |
| args["num_labels"] = n | |
| LOG.info("--resume: legacy trainer_state.pt without num_labels; using %d from last/config.json", n) | |
| if "selection_metric" not in args: | |
| args["selection_metric"] = "val_f1_phishing" | |
| saved["args"] = args | |
| def lr_lambda_factory(total_steps: int, warmup_steps: int): | |
| def lr_lambda(completed: int) -> float: | |
| if completed < warmup_steps: | |
| return (completed + 1) / warmup_steps | |
| return max(0.0, (total_steps - completed) / max(1, total_steps - warmup_steps)) | |
| return lr_lambda | |
| def train(ns: argparse.Namespace, cfg: dict) -> int: | |
| t_invocation = common.timer() | |
| # Decide the device FIRST and touch torch.cuda only on the CUDA branch. Under --cpu the GPU may be | |
| # (and today is) occupied by the production pretraining run: torch.cuda.current_device() / | |
| # mem_get_info() / get_rng_state_all() all run _lazy_init(), which creates a CUDA context there. | |
| # Hiding the devices from this process is the belt to that braces: nothing has called CUDA yet. | |
| if ns.cpu: | |
| # Hide the devices before the CUDA runtime is first queried (torch import does not query it): then | |
| # torch.cuda.is_available() is False and torch's own AdamW.step() health check, which otherwise calls | |
| # torch.accelerator.current_stream() -> _lazy_init() on any visible GPU, is skipped. If a GPU is still | |
| # visible the contract cannot be honoured from inside this process, so refuse rather than risk it. | |
| os.environ["CUDA_VISIBLE_DEVICES"] = "" | |
| if torch.cuda.is_initialized() or torch.cuda.is_available(): | |
| LOG.error("--cpu: a CUDA device is still visible to this process (CUDA was queried before the devices could " | |
| "be hidden); run with CUDA_VISIBLE_DEVICES=\"\" in the environment so the occupied GPU is never touched") | |
| return 2 | |
| device = torch.device("cpu") | |
| device_info = {"device": "cpu", "torch": torch.__version__} | |
| LOG.warning("--cpu: dev path on the CPU with the config as given (no autocast, no VRAM guard, CUDA never " | |
| "initialised; CUDA_VISIBLE_DEVICES cleared for this process); not for real runs") | |
| elif not torch.cuda.is_available(): | |
| LOG.error("CUDA is required for fine-tuning (--cpu is the dev-only escape hatch)") | |
| return 2 | |
| else: | |
| device = torch.device("cuda") | |
| device_info = common.print_device_info() | |
| LOG.info("PYTORCH_CUDA_ALLOC_CONF=%s", os.environ.get("PYTORCH_CUDA_ALLOC_CONF")) | |
| run_dir = Path(cfg["paths"]["checkpoints"]) / "finetune" / ns.run_name | |
| last_state_path = run_dir / "last" / "trainer_state.pt" | |
| saved: dict[str, Any] | None = None | |
| if ns.resume: | |
| if not last_state_path.exists(): | |
| LOG.error("--resume: %s not found", last_state_path) | |
| return 2 | |
| # Own file (optimizer tensors + Python/NumPy RNG tuples), hence weights_only=False. | |
| saved = torch.load(last_state_path, map_location="cpu", weights_only=False) | |
| backfill_legacy_args(saved, run_dir) | |
| elif run_dir.exists() and any(run_dir.iterdir()): | |
| if not ns.overwrite: | |
| LOG.error("%s exists; use --resume to continue it, --overwrite to discard it, or a new --run-name", run_dir) | |
| return 2 | |
| import shutil | |
| shutil.rmtree(run_dir) | |
| LOG.warning("--overwrite: removed %s", run_dir) | |
| try: | |
| args = TrainArgs.resolve(ns, cfg, saved["args"] if saved else None) | |
| except ValueError as e: | |
| LOG.error("dial error: %s", e) | |
| return 2 | |
| args.run_name = ns.run_name | |
| args.cpu = bool(ns.cpu) # the device is this invocation's choice, never restored from last/ | |
| if saved: | |
| changed = {k: (saved["args"].get(k), getattr(ns, k)) for k in RESUME_FIXED_DIALS | |
| if getattr(ns, k, None) is not None and getattr(ns, k) != saved["args"].get(k)} | |
| if changed: | |
| LOG.error("--resume: %s cannot change on resume (saved -> requested); the restored optimizer state and " | |
| "batch order would not match. Start a new --run-name instead", changed) | |
| return 2 | |
| LOG.info("resolved dials: %s", args.to_record()) | |
| common.set_seed(args.seed) | |
| vr = cfg["vram"] | |
| max_len = int(cfg["model"]["max_seq_len"]) | |
| multiple = int(cfg["finetune"]["dynamic_padding_multiple"]) | |
| tok = load_tokenizer(cfg["paths"]["tokenizer"]) | |
| train_split, val_split, n_synth = load_data(cfg, args, tok) | |
| check_collate_matches_encode_batch(train_split, tok, epoch_batches(train_split.lengths, args.batch_size, args.seed, 0)[0], | |
| max_len, multiple) | |
| if saved and (saved["state"]["n_train"] != len(train_split) or saved["state"]["n_val"] != len(val_split)): | |
| LOG.error("--resume: data changed (train %d -> %d, val %d -> %d); the batch order would not line up", | |
| saved["state"]["n_train"], len(train_split), saved["state"]["n_val"], len(val_split)) | |
| return 2 | |
| try: | |
| model, init_desc = build_model(cfg, args, device) | |
| except (FileNotFoundError, ValueError, RuntimeError) as e: | |
| LOG.error("model init failed: %s", e) | |
| return 2 | |
| if saved: | |
| sd = load_file(str(run_dir / "last" / "model.safetensors"), device="cpu") | |
| try: | |
| model.load_state_dict(sd, strict=True) | |
| except RuntimeError as e: # head / depth mismatch that slipped past the dial checks | |
| LOG.error("--resume: last/model.safetensors does not fit the resolved dials (num_labels %d, n_layers %d): %s", | |
| args.num_labels, args.n_layers, str(e).splitlines()[0]) | |
| return 2 | |
| freeze(model, args.freeze_layers) | |
| model.train() | |
| params = count_parameters(model) | |
| LOG.info("EncoderForClassification params: %s", params) | |
| if tok.get_vocab_size() > model.config.vocab_size: | |
| raise AssertionError("tokenizer vocab larger than model vocab") | |
| steps_per_epoch = math.ceil(len(train_split) / args.batch_size) | |
| total_steps = steps_per_epoch * args.epochs | |
| if args.max_steps is not None: | |
| total_steps = min(total_steps, args.max_steps) | |
| warmup_steps = max(1, math.ceil(args.warmup_fraction * total_steps)) | |
| schedule = {"steps_per_epoch": steps_per_epoch, "total_steps": total_steps, "warmup_steps": warmup_steps} | |
| LOG.info("schedule: %d steps/epoch x %d epochs -> %d optimizer steps (max_steps=%s), warmup %d, " | |
| "micro-batch %d x %d accumulation", steps_per_epoch, args.epochs, total_steps, args.max_steps, warmup_steps, | |
| args.micro_batch, args.batch_size // args.micro_batch) | |
| opt_cfg = {"weight_decay": args.weight_decay, "betas": list(args.betas), "eps": args.eps} | |
| opt = build_optimizer(model, args.lr, opt_cfg) | |
| lr_lambda = lr_lambda_factory(total_steps, warmup_steps) | |
| sched = torch.optim.lr_scheduler.LambdaLR(opt, lr_lambda) | |
| weights = class_weights(train_split.label3, args.num_labels, device) if args.loss == "weighted_cross_entropy" else None | |
| if weights is not None: | |
| names = [LABEL_NAMES[c] for c in range(3)] if args.num_labels == 3 else [LABEL_NAMES[0], UNWANTED_NAME] | |
| LOG.info("inverse-frequency class weights %s from fully-labelled counts %s%s", dict(zip(names, [round(float(w), 4) for w in weights])), | |
| np.bincount(train_split.label3[train_split.label3 >= 0], minlength=args.num_labels).tolist(), | |
| f"; {int((train_split.label3 < 0).sum())} unwanted rows weighted mean(w_phish, w_spam)={float(weights[1:].mean()):.4f}" | |
| if args.num_labels == 3 else "") | |
| if args.num_labels == 3: | |
| LOG.info("label contract: classes %s; binary roll-up '%s' = P(%s) + P(%s); selection metric %s", | |
| LABEL_NAMES, UNWANTED_NAME, LABEL_NAMES[1], LABEL_NAMES[2], args.selection_metric) | |
| state = RunState(n_train=len(train_split), n_val=len(val_split), n_synthetic=n_synth, init=init_desc) | |
| if saved: | |
| st = dict(saved["state"]) | |
| if "best_f1" in st: # trainer_state.pt written before entry 6 | |
| st["best_score"] = st.pop("best_f1") | |
| known = {f.name for f in fields(RunState)} | |
| state = RunState(**{**{k: v for k, v in st.items() if k in known}, "stopped_by": "", "init": st["init"]}) | |
| state.resumed_from_step = state.step | |
| opt.load_state_dict(saved["optimizer"]) | |
| sched.load_state_dict(saved["scheduler"]) | |
| if args.lr != float(saved["args"]["lr"]): | |
| # load_state_dict restored the saved base lr; re-apply the override so the next optimizer step | |
| # (which runs before the next sched.step()) and every later one use the lr the record claims. | |
| scale = lr_lambda(sched.last_epoch) | |
| for g in opt.param_groups: | |
| g["initial_lr"] = args.lr | |
| g["lr"] = args.lr * scale | |
| sched.base_lrs = [args.lr] * len(opt.param_groups) | |
| LOG.warning("--lr %g overrides the saved %g from step %d onward (steps 1..%d used the old lr); current lr %.3e", | |
| args.lr, float(saved["args"]["lr"]), state.step, state.step, args.lr * scale) | |
| if saved["schedule"] != schedule: | |
| LOG.warning("schedule changed on resume %s -> %s; the LR lambda now follows the new totals from step %d", | |
| saved["schedule"], schedule, state.step) | |
| restore_rng(saved["rng"], device) | |
| LOG.info("resumed %s at step %d (epoch %d, batch %d/%d); best %s %.4f at step %d; %.1f s trained so far", | |
| ns.run_name, state.step, state.epoch, state.batch_in_epoch, steps_per_epoch, args.selection_metric, | |
| state.best_score, state.best_step, state.train_seconds) | |
| if args.max_steps is not None and state.step >= args.max_steps: | |
| LOG.warning("nothing to resume: step %d already reached --max-steps %d; raise --max-steps or --epochs to continue", | |
| state.step, args.max_steps) | |
| return 0 | |
| if state.epoch >= args.epochs: | |
| LOG.warning("nothing to resume: epoch %d already reached --epochs %d; raise --epochs to continue", | |
| state.epoch, args.epochs) | |
| return 0 | |
| if state.evals_without_improvement >= args.patience: | |
| LOG.warning("resuming after early stopping (%d/%d evaluations without improvement): the run stops again at the " | |
| "next evaluation unless %s improves", state.evals_without_improvement, args.patience, args.selection_metric) | |
| run_dir.mkdir(parents=True, exist_ok=True) | |
| metrics_csv, epochs_csv = run_dir / "metrics.csv", run_dir / "epochs.csv" | |
| if saved: | |
| trim_csv(metrics_csv, state.step) | |
| trim_csv(epochs_csv, state.step) | |
| stop = StopFlag() | |
| if device.type == "cuda": | |
| torch.cuda.reset_peak_memory_stats() | |
| band_warned = False | |
| label_names = [LABEL_NAMES[c] for c in range(3)] if args.num_labels == 3 else [LABEL_NAMES[0], UNWANTED_NAME] | |
| def record_eval(is_epoch_end: bool) -> None: | |
| nonlocal state | |
| res = evaluate(model, val_split, args, multiple, device) | |
| state.eval_seconds += res["eval_seconds"] | |
| state.last_val = res | |
| score = selection_value(res, args.selection_metric) | |
| improved = score > state.best_score | |
| if improved: | |
| state.best_score, state.best_step, state.best_epoch, state.best_val = score, state.step, state.epoch, res | |
| state.evals_without_improvement = 0 | |
| save_model_dir(model, run_dir / "best", state.step, { | |
| "finetune": args.to_record(), "best_epoch": state.epoch, "best_step": state.step, | |
| "num_labels": args.num_labels, "label_names": label_names, "binary_rollup_name": UNWANTED_NAME, | |
| "selection_metric": args.selection_metric, "selection_value": score, | |
| "val_metrics": res, "val_metric_sets": metric_sets(res), | |
| "thresholds": {"decision": DECISION_THRESHOLD, "decision_applies_to": "P(unwanted) = sum of non-legitimate probabilities", | |
| "val_f1_optimal": res["f1_optimal_threshold"], "val_f1_at_optimal": res["f1_at_optimal_threshold"]}, | |
| "init": state.init, "params": params, | |
| }) | |
| else: | |
| state.evals_without_improvement += 1 | |
| three = "" | |
| if res.get("n_3way"): | |
| three = (f" | 3-way n={res['n_3way']} macro-F1 {res['macro_f1_3way']:.4f} acc {res['accuracy_3way']:.4f} F1[L P S]=[%s] " | |
| f"cm3[rows true L,P,S; cols pred]=[%s] phish/spam on unwanted [pp ps sp ss]=[%d %d %d %d]" % ( | |
| " ".join(f"{res[f'f1_{s}']:.3f}" for s in CLASS_SLUGS), | |
| " ".join(f"{res[k]}" for k in CM3_KEYS), *(res[k] for k in PVS_KEYS))) | |
| LOG.info("eval step %d epoch %d%s: n=%d loss %.4f | unwanted roll-up acc %.4f P %.4f R %.4f F1 %.4f ROC-AUC %.4f " | |
| "PR-AUC %.4f cm[tn fp fn tp]=[%d %d %d %d] F1-opt thr %.3f (F1 %.4f)%s | %s=%.4f %s (%.1fs)", | |
| state.step, state.epoch, " [epoch end]" if is_epoch_end else "", res["n"], res["val_loss"], res["accuracy"], | |
| res["precision"], res["recall"], res["f1"], res["roc_auc"], res["pr_auc"], res["tn"], res["fp"], res["fn"], | |
| res["tp"], res["f1_optimal_threshold"], res["f1_at_optimal_threshold"], three, args.selection_metric, score, | |
| "NEW BEST -> best/" if improved else f"no improvement ({state.evals_without_improvement}/{args.patience})", | |
| res["eval_seconds"]) | |
| write_csv_row(epochs_csv, {"epoch": state.epoch, "step": state.step, "is_epoch_end": int(is_epoch_end), | |
| "is_best": int(improved), **res}, EPOCHS_HEADER) | |
| t_loop = t_inv_start = common.timer() | |
| steps_this_invocation = 0 | |
| overhead_inv = 0.0 # eval + checkpoint seconds this invocation, excluded from the ETA's per-step cost | |
| window_loss, window_rows, window_steps, t_window = 0.0, 0, 0, common.timer() | |
| last_eval_step = state.step if saved else -1 | |
| exit_code = 0 | |
| def flush_time() -> None: | |
| nonlocal t_loop | |
| now = common.timer() | |
| state.train_seconds += now - t_loop | |
| t_loop = now | |
| try: | |
| while state.epoch < args.epochs and not state.stopped_by: | |
| batches = epoch_batches(train_split.lengths, args.batch_size, args.seed, state.epoch) | |
| loader = make_loader(train_split, batches[state.batch_in_epoch:], multiple, args.micro_batch) | |
| for b, (idx, micro_batches) in enumerate(loader, start=state.batch_in_epoch): | |
| n_rows = len(idx) | |
| loss_acc = 0.0 | |
| for ids, mask, _y_bin, allowed in micro_batches: | |
| ids, mask, allowed = (t.to(device, non_blocking=True) for t in (ids, mask, allowed)) | |
| n_sub = int(ids.shape[0]) | |
| with autocast_ctx(device): | |
| logits = model(ids, mask) | |
| loss = compute_loss(logits, allowed, args.loss, weights, args.focal_gamma) | |
| (loss * (n_sub / n_rows)).backward() | |
| loss_acc += float(loss.detach()) * n_sub / n_rows | |
| torch.nn.utils.clip_grad_norm_(model.parameters(), args.grad_clip) | |
| opt.step() | |
| opt.zero_grad(set_to_none=True) | |
| sched.step() | |
| state.step += 1 | |
| state.batch_in_epoch = b + 1 | |
| steps_this_invocation += 1 | |
| window_loss += loss_acc | |
| window_rows += n_rows | |
| window_steps += 1 | |
| if not math.isfinite(loss_acc): | |
| LOG.error("non-finite loss at step %d; stopping", state.step) | |
| state.stopped_by = "non_finite_loss" | |
| exit_code = 3 | |
| # Read the allocator only on CUDA; the CPU dev path reports zeros without touching torch.cuda. | |
| vram = common.vram_stats() if device.type == "cuda" else CPU_VRAM | |
| state.peak_reserved_gb = max(state.peak_reserved_gb, vram["max_reserved_gb"]) | |
| state.peak_allocated_gb = max(state.peak_allocated_gb, vram["max_allocated_gb"]) | |
| if device.type == "cuda" and vram["max_reserved_gb"] > float(vr["hard_cap_gb"]): | |
| LOG.error("VRAM hard cap breached: %.2f GiB reserved > %s GiB; lower --micro-batch. Saving last/ and aborting", | |
| vram["max_reserved_gb"], vr["hard_cap_gb"]) | |
| state.stopped_by = "vram_cap" | |
| exit_code = 3 | |
| elif not band_warned and vram["max_reserved_gb"] > float(vr["target_gb_high"]): | |
| LOG.warning("reserved peak %.2f GiB is above the target band [%s, %s] GiB", vram["max_reserved_gb"], | |
| vr["target_gb_low"], vr["target_gb_high"]) | |
| band_warned = True | |
| if stop.requested: | |
| state.stopped_by = f"signal:{stop.requested}" | |
| is_eval = args.eval_every > 0 and state.step % args.eval_every == 0 | |
| epoch_end = b == len(batches) - 1 | |
| hit_max = args.max_steps is not None and state.step >= args.max_steps | |
| if hit_max and not state.stopped_by: | |
| state.stopped_by = "max_steps" | |
| is_log = state.step % args.log_every == 0 or is_eval or epoch_end or bool(state.stopped_by) | |
| row: dict[str, Any] | None = None | |
| if is_log: | |
| now = common.timer() | |
| elapsed = state.train_seconds + (now - t_loop) | |
| per_step = (now - t_inv_start - overhead_inv) / max(1, steps_this_invocation) | |
| eta = per_step * max(0, total_steps - state.step) | |
| row = {"step": state.step, "epoch": state.epoch, "loss": round(window_loss / max(1, window_steps), 5), | |
| "lr": sched.get_last_lr()[0], "examples_per_s": round(window_rows / max(1e-9, now - t_window), 1), | |
| "max_alloc_gb": round(vram["max_allocated_gb"], 3), "max_reserved_gb": round(vram["max_reserved_gb"], 3), | |
| "elapsed_s": round(elapsed, 1), "eta_s": round(eta)} | |
| LOG.info("step %d/%d epoch %d loss %.4f lr %.2e %.1f ex/s alloc %.2f GiB reserved %.2f GiB elapsed %s ETA %s", | |
| state.step, total_steps, state.epoch, row["loss"], row["lr"], row["examples_per_s"], | |
| row["max_alloc_gb"], row["max_reserved_gb"], _hms(elapsed), _hms(eta)) | |
| window_loss, window_rows, window_steps, t_window = 0.0, 0, 0, now | |
| # Evaluate at the cadence, at every epoch end and when --max-steps ends the run; never | |
| # after a signal (fast exit), a VRAM breach or a NaN model (it must not become best/). | |
| do_eval = (is_eval or epoch_end or hit_max) and state.step != last_eval_step \ | |
| and state.stopped_by in ("", "max_steps") | |
| if do_eval: | |
| t_ev = common.timer() | |
| record_eval(epoch_end) | |
| overhead_inv += common.timer() - t_ev | |
| last_eval_step = state.step | |
| if row is not None: | |
| m3 = state.last_val.get("macro_f1_3way") | |
| row.update(val_loss=round(state.last_val["val_loss"], 5), val_f1=round(state.last_val["f1"], 5), | |
| val_roc_auc=round(state.last_val["roc_auc"], 5), | |
| val_macro_f1_3way=None if m3 is None or math.isnan(m3) else round(m3, 5)) | |
| if state.evals_without_improvement >= args.patience and not state.stopped_by: | |
| state.stopped_by = "early_stopping" | |
| LOG.info("early stopping: %d evaluations without %s improvement (best %.4f at step %d)", | |
| state.evals_without_improvement, args.selection_metric, state.best_score, state.best_step) | |
| if row is not None: | |
| write_csv_row(metrics_csv, row, METRICS_HEADER) | |
| state.metrics_tail = (state.metrics_tail + [row])[-20:] | |
| if state.stopped_by: | |
| break | |
| if do_eval: | |
| flush_time() | |
| t_sv = common.timer() | |
| save_last(run_dir, model, opt, sched, state, args, schedule, device) | |
| overhead_inv += common.timer() - t_sv | |
| t_window = common.timer() # examples/s must not absorb eval + checkpoint time | |
| if not state.stopped_by: | |
| state.batch_in_epoch = 0 | |
| state.epoch += 1 | |
| if state.epoch >= args.epochs: | |
| state.stopped_by = "epochs_done" | |
| except KeyboardInterrupt: | |
| LOG.error("second interrupt: hard stop without a checkpoint") | |
| return 130 | |
| flush_time() | |
| save_last(run_dir, model, opt, sched, state, args, schedule, device) | |
| if args.cpu and torch.cuda.is_initialized(): | |
| # The --cpu contract is "the GPU is never touched"; a regression here would OOM the pretraining run. | |
| LOG.error("--cpu run initialised CUDA; a code path touched torch.cuda on the CPU dev path (bug)") | |
| exit_code = exit_code or 3 | |
| rec = experiment_record(args, state, params, steps_per_epoch) | |
| exp_path = Path(cfg["paths"]["reports"]) / "experiments.jsonl" | |
| how = upsert_experiment(exp_path, rec) | |
| LOG.info("%s experiment record for %s in %s", how, args.run_name, exp_path) | |
| if state.step >= RUN_LOG_MIN_STEPS: | |
| common.append_run_log(run_log_block(args, state, run_dir, params)) | |
| LOG.info("appended a RUN_LOG.md entry") | |
| v = state.best_val or {} | |
| print(f"\nTotal training time: {state.train_seconds:.1f} s ({_hms(state.train_seconds)}) over {state.step} optimizer steps" | |
| f" [{state.stopped_by}]; this invocation {common.timer() - t_invocation:.1f} s wall") | |
| print(f"Peak VRAM reserved {state.peak_reserved_gb:.2f} GiB (allocated {state.peak_allocated_gb:.2f} GiB) on {device_info['device']}") | |
| if v: | |
| print(f"Best val by {args.selection_metric} (step {state.best_step}, epoch {state.best_epoch}), {UNWANTED_NAME} roll-up: " | |
| + " ".join(f"{k}={v[k]:.4f}" for k in METRIC_KEYS) + f" -> {run_dir / 'best'}") | |
| if v.get("n_3way"): | |
| print(f"Three-way (n={v['n_3way']}): macro-F1={v['macro_f1_3way']:.4f} accuracy={v['accuracy_3way']:.4f} " | |
| + " ".join(f"F1[{LABEL_NAMES[c]}]={v[f'f1_{s}']:.4f}" for c, s in enumerate(CLASS_SLUGS)) | |
| + f"; phishing-vs-spam on true unwanted [pp ps sp ss]=[{v['pvs_phish_as_phish']} {v['pvs_phish_as_spam']} " | |
| f"{v['pvs_spam_as_phish']} {v['pvs_spam_as_spam']}]") | |
| return exit_code | |
| def _hms(seconds: float) -> str: | |
| s = int(round(seconds)) | |
| return f"{s // 3600:d}:{(s % 3600) // 60:02d}:{s % 60:02d}" | |
| # ------------------------------------------------------------------------------------ plot | |
| def plot(ns: argparse.Namespace, cfg: dict) -> int: | |
| import matplotlib | |
| matplotlib.use("Agg") | |
| import matplotlib.pyplot as plt | |
| run_dir = Path(cfg["paths"]["checkpoints"]) / "finetune" / ns.run_name | |
| metrics_csv, epochs_csv = run_dir / "metrics.csv", run_dir / "epochs.csv" | |
| if not metrics_csv.exists(): | |
| LOG.error("%s not found", metrics_csv) | |
| return 2 | |
| m = _read_csv(metrics_csv) | |
| e = _read_csv(epochs_csv) if epochs_csv.exists() else [] | |
| out = Path(ns.out) if ns.out else Path(cfg["paths"]["reports"]) / "finetune_curves.png" | |
| out.parent.mkdir(parents=True, exist_ok=True) | |
| # Two panels, one y-axis each (loss and scores live on different scales; no dual axis). | |
| fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 4.2), dpi=150) | |
| steps = [float(r["step"]) for r in m] | |
| ax1.plot(steps, [float(r["loss"]) for r in m], color=PALETTE[0], lw=2, label="train loss") | |
| if e: | |
| ax1.plot([float(r["step"]) for r in e], [float(r["val_loss"]) for r in e], color=PALETTE[1], lw=2, | |
| marker="o", ms=4, label="val loss (CE)") | |
| ax1.set_xlabel("optimizer step") | |
| ax1.set_ylabel("loss") | |
| ax1.set_title(f"{ns.run_name}: loss") | |
| ax1.legend(frameon=False) | |
| if e: | |
| es = [float(r["step"]) for r in e] | |
| series = [("f1", "val F1 (unwanted roll-up)", PALETTE[0]), ("roc_auc", "val ROC-AUC", PALETTE[1]), | |
| ("pr_auc", "val PR-AUC", PALETTE[2])] | |
| if any(_num(r.get("macro_f1_3way")) is not None for r in e): | |
| series.append(("macro_f1_3way", "val macro-F1 (3-way)", PALETTE[3])) | |
| lows = [] | |
| for key, label, color in series: | |
| pts = [(s, _num(r.get(key))) for s, r in zip(es, e)] | |
| pts = [(s, y) for s, y in pts if y is not None] | |
| if not pts: | |
| continue | |
| ax2.plot([p[0] for p in pts], [p[1] for p in pts], color=color, lw=2, marker="o", ms=4, label=label) | |
| lows.append(min(p[1] for p in pts)) | |
| best = max(e, key=lambda r: float(r["f1"])) | |
| ax2.annotate(f"best F1 {float(best['f1']):.4f}\nstep {int(float(best['step']))}", (float(best["step"]), float(best["f1"])), | |
| textcoords="offset points", xytext=(8, -28), fontsize=8, color="#444444") | |
| ax2.set_ylim(max(0.0, min(0.5, min(lows) - 0.05)) if lows else 0.0, 1.0) | |
| ax2.legend(frameon=False, loc="lower right") | |
| else: | |
| ax2.text(0.5, 0.5, "no evaluations yet", ha="center", va="center", transform=ax2.transAxes) | |
| ax2.set_xlabel("optimizer step") | |
| ax2.set_ylabel("score") | |
| ax2.set_title(f"{ns.run_name}: validation") | |
| for ax in (ax1, ax2): | |
| ax.grid(True, color="#e6e6e6", lw=0.6) | |
| for side in ("top", "right"): | |
| ax.spines[side].set_visible(False) | |
| fig.tight_layout() | |
| fig.savefig(out) | |
| LOG.info("wrote %s (%d train rows, %d eval rows)", out, len(m), len(e)) | |
| print(out) | |
| return 0 | |
| def _read_csv(path: Path) -> list[dict[str, str]]: | |
| with path.open(newline="", encoding="utf-8") as fh: | |
| return [r for r in csv.DictReader(fh) if r.get("step")] | |
| def _num(s: str | None) -> float | None: | |
| """CSV cell -> float, None for blank / NaN (a binary run has no three-way columns).""" | |
| if s is None or s == "": | |
| return None | |
| try: | |
| v = float(s) | |
| except ValueError: | |
| return None | |
| return None if math.isnan(v) else v | |
| # ------------------------------------------------------------------------------------ list | |
| def list_experiments(ns: argparse.Namespace, cfg: dict) -> int: | |
| path = Path(cfg["paths"]["reports"]) / "experiments.jsonl" | |
| if not path.exists(): | |
| print(f"no experiments yet ({path})") | |
| return 0 | |
| runs: dict[str, dict[str, Any]] = {} | |
| for line in path.read_text(encoding="utf-8").splitlines(): | |
| if not line.strip(): | |
| continue | |
| rec = json.loads(line) | |
| slot = runs.setdefault(rec["run_name"], {}) | |
| slot["test" if rec.get("test") is not None else "train"] = rec | |
| def f(x: Any, nd: int = 4) -> str: | |
| return "-" if x is None or (isinstance(x, float) and math.isnan(x)) else (f"{x:.{nd}f}" if isinstance(x, float) else str(x)) | |
| # "val acc/P/R/F1" are the Unwanted (phishing or spam) roll-up at 0.5, identical to the binary runs' numbers; | |
| # the three-way columns are blank for binary (num_labels 2) runs. | |
| head = ["run", "init", "classes", "layers", "frozen", "epochs run/planned", "loss", "lr", "batch", "synthetic", "params (M)", | |
| "train min", "peak GiB", "best ep", "val acc", "val P", "val R", "val F1", "val ROC-AUC", "val PR-AUC", | |
| "val macro-F1 3w", "val P phish", "val R phish", "test F1", "test ROC-AUC", "stopped by"] | |
| rows = [] | |
| for name, slot in runs.items(): | |
| tr, te = slot.get("train"), slot.get("test") | |
| base = tr or te | |
| d = base.get("dials", {}) | |
| v = (tr or {}).get("val") or {} | |
| t = (te or {}).get("test") or {} | |
| phish = (v.get("per_class") or {}).get(LABEL_NAMES[1]) or {} | |
| init = base.get("init", "-") | |
| init = "scratch" if init == "scratch" else "pretrained" if init.startswith("pretrained") else init | |
| rows.append([name, init, f(d.get("num_labels", 2)), f(d.get("n_layers")), f(d.get("frozen_layers")), | |
| f"{f(d.get('epochs_run'), 2)}/{f(d.get('epochs_planned'))}", f(d.get("loss")), | |
| f"{d['lr']:.0e}" if d.get("lr") else "-", f(d.get("batch_size")), | |
| f"{d.get('synthetic', '-')}" + (f" ({d['synthetic_rows']})" if d.get("synthetic_rows") else ""), | |
| f((base.get("params") or {}).get("total", 0) / 1e6, 1) if base.get("params") else "-", | |
| f((tr or {}).get("train_seconds", 0) / 60.0, 1) if tr else "-", f((tr or {}).get("peak_vram_gb"), 2), | |
| f((tr or {}).get("best_epoch")), f(v.get("accuracy")), f(v.get("precision")), f(v.get("recall")), | |
| f(v.get("f1")), f(v.get("roc_auc")), f(v.get("pr_auc")), f(v.get("macro_f1_3way")), | |
| f(phish.get("precision")), f(phish.get("recall")), f(t.get("f1")), f(t.get("roc_auc")), | |
| f((tr or {}).get("stopped_by"))]) | |
| print("| " + " | ".join(head) + " |") | |
| print("|" + "|".join("---" for _ in head) + "|") | |
| for r in rows: | |
| print("| " + " | ".join(r) + " |") | |
| print(f"\n{len(rows)} run(s) from {path}") | |
| return 0 | |
| # ------------------------------------------------------------------------------------- CLI | |
| def parse_args(argv: list[str] | None = None) -> argparse.Namespace: | |
| ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) | |
| ap.add_argument("--config", default="config.yaml") | |
| sub = ap.add_subparsers(dest="cmd", required=True) | |
| tr = sub.add_parser("train", help="fine-tune the classifier (dials default to config.yaml; --resume restores the saved ones)") | |
| tr.add_argument("--run-name", default="finetune") | |
| tr.add_argument("--init", default=None, help="pretrained | pretrained:<dir> | scratch (default: newest checkpoints/pretrain/*/final)") | |
| tr.add_argument("--n-layers", type=int, default=None, help="use only the first N encoder layers") | |
| tr.add_argument("--freeze-layers", type=int, default=None, help="freeze embeddings + first K layers") | |
| tr.add_argument("--loss", choices=LOSSES, default=None) | |
| tr.add_argument("--focal-gamma", type=float, default=None) | |
| tr.add_argument("--lr", type=float, default=None) | |
| tr.add_argument("--batch-size", type=int, default=None, help="logical batch (rows per optimizer step)") | |
| tr.add_argument("--micro-batch", type=int, default=None, help="rows per fwd/bwd; batch-size must be a multiple (default 16)") | |
| tr.add_argument("--epochs", type=int, default=None) | |
| tr.add_argument("--max-steps", type=int, default=None, help="dev: stop after this many optimizer steps") | |
| tr.add_argument("--eval-every", type=int, default=None, help="optimizer steps between full val evaluations") | |
| tr.add_argument("--log-every", type=int, default=None) | |
| tr.add_argument("--eval-batch-size", type=int, default=None) | |
| tr.add_argument("--synthetic", choices=("on", "off"), default=None, help="append data/processed/synthetic.parquet to TRAIN") | |
| tr.add_argument("--limit-rows", type=int, default=None, help="dev: seeded subsample of train AND val") | |
| tr.add_argument("--seed", type=int, default=None) | |
| tr.add_argument("--num-labels", type=int, choices=NUM_LABELS_ALLOWED, default=None, | |
| help="override finetune.num_labels: 3 = legitimate/phishing/spam (partial-label loss), 2 = binary baseline") | |
| tr.add_argument("--cpu", action="store_true", help="dev only: run on the CPU with the config as given (tiny encoder + fixtures)") | |
| tr.add_argument("--resume", action="store_true", | |
| help="continue <run>/last (dials restored; CLI flags override, except --init/--n-layers/--freeze-layers/" | |
| "--batch-size/--seed/--num-labels which are refused; --lr is re-applied to the restored optimizer)") | |
| tr.add_argument("--overwrite", action="store_true", help="discard an existing run dir of the same name") | |
| pl = sub.add_parser("plot", help="train loss and val F1/AUC vs step -> reports/finetune_curves.png") | |
| pl.add_argument("--run-name", required=True) | |
| pl.add_argument("--out", default=None) | |
| sub.add_parser("list", help="reports/experiments.jsonl as a markdown table") | |
| return ap.parse_args(argv) | |
| def main(argv: list[str] | None = None) -> int: | |
| ns = parse_args(argv) | |
| cfg = common.load_config(ns.config) | |
| global LOG | |
| LOG = common.setup_logging("finetune", cfg["paths"]["logs"]) | |
| if ns.cmd == "train": | |
| return train(ns, cfg) | |
| if ns.cmd == "plot": | |
| return plot(ns, cfg) | |
| return list_experiments(ns, cfg) | |
| if __name__ == "__main__": | |
| sys.exit(main()) | |