| """Reproducible training pipeline for ACO specialist classifiers. |
| |
| Trains three models (tier-router, tool-gater, verifier-gater) using |
| ModernBERT-base with focal loss and threshold calibration. |
| |
| Loads data from narcolepticchicken/aco-traces (preprocessed source datasets) |
| and applies the same training recipe as the original v2 training. |
| |
| Usage: |
| # Train all three models |
| uv run --with transformers,torch,datasets,scikit-learn,huggingface_hub,trackio train_aco.py |
| |
| # Train a single model |
| uv run --with transformers,torch,datasets,scikit-learn,huggingface_hub train_aco.py --task tier_router |
| |
| # Via hf_jobs |
| hf_jobs run --script train_aco.py --deps transformers,torch,datasets,scikit-learn,huggingface_hub --hardware a10g-large --timeout 8h |
| """ |
| import argparse, torch, numpy as np, json, os, sys |
| from datasets import load_dataset, Dataset |
| from transformers import ( |
| AutoTokenizer, AutoModelForSequenceClassification, |
| TrainingArguments, Trainer, EarlyStoppingCallback |
| ) |
| from sklearn.metrics import accuracy_score, f1_score, precision_recall_fscore_support |
| import torch.nn as nn |
| import torch.nn.functional as F |
|
|
| |
| |
| |
|
|
| BASE_MODEL = "answerdotai/ModernBERT-base" |
| DATASET_REPO = "narcolepticchicken/aco-traces" |
| OUTPUT_REPO_TEMPLATE = "narcolepticchicken/aco-specialists-{task}" |
|
|
| TASK_CONFIG = { |
| "tier_router": { |
| "num_labels": 3, |
| "id2label": {0: "easy", 1: "medium", 2: "hard"}, |
| "label2id": {"easy": 0, "medium": 1, "hard": 2}, |
| }, |
| "tool_gater": { |
| "num_labels": 2, |
| "id2label": {0: "no_tool", 1: "call_tool"}, |
| "label2id": {"no_tool": 0, "call_tool": 1}, |
| }, |
| "verifier_gater": { |
| "num_labels": 2, |
| "id2label": {0: "no_verify", 1: "verify"}, |
| "label2id": {"no_verify": 0, "verify": 1}, |
| }, |
| } |
|
|
| TRAINING_ARGS = dict( |
| output_dir="/tmp/aco_train", |
| learning_rate=2e-5, |
| per_device_train_batch_size=16, |
| per_device_eval_batch_size=16, |
| num_train_epochs=10, |
| weight_decay=0.01, |
| warmup_ratio=0.1, |
| logging_strategy="steps", |
| logging_steps=20, |
| logging_first_step=True, |
| eval_strategy="steps", |
| eval_steps=50, |
| save_strategy="steps", |
| save_steps=200, |
| load_best_model_at_end=True, |
| metric_for_best_model="f1_macro", |
| greater_is_better=True, |
| save_total_limit=1, |
| disable_tqdm=True, |
| lr_scheduler_type="cosine", |
| bf16=torch.cuda.is_available(), |
| push_to_hub=False, |
| report_to="none", |
| ) |
|
|
| |
| |
| |
|
|
| class FocalLoss(nn.Module): |
| """Focal loss per Lin et al. (1708.02002).""" |
| def __init__(self, gamma=2.0, alpha=None, reduction="mean"): |
| super().__init__() |
| self.gamma = gamma |
| self.alpha = alpha |
| self.reduction = reduction |
|
|
| def forward(self, logits, labels): |
| ce_loss = F.cross_entropy(logits, labels, reduction="none", weight=self.alpha) |
| pt = torch.exp(-ce_loss) |
| focal_loss = ((1 - pt) ** self.gamma) * ce_loss |
| if self.reduction == "mean": |
| return focal_loss.mean() |
| elif self.reduction == "sum": |
| return focal_loss.sum() |
| return focal_loss |
|
|
| |
| |
| |
|
|
| class FocalTrainer(Trainer): |
| def __init__(self, focal_gamma=2.0, class_alpha=None, *args, **kwargs): |
| super().__init__(*args, **kwargs) |
| self.focal_gamma = focal_gamma |
| if class_alpha is not None: |
| self.class_alpha = torch.tensor(class_alpha, dtype=torch.float) |
| else: |
| self.class_alpha = None |
|
|
| def compute_loss(self, model, inputs, return_outputs=False, num_items_in_batch=None): |
| labels = inputs.pop("labels") |
| outputs = model(**inputs) |
| logits = outputs.logits |
|
|
| |
| if self.class_alpha is not None: |
| alpha = self.class_alpha.to(logits.device) |
| else: |
| alpha = None |
|
|
| loss_fn = FocalLoss(gamma=self.focal_gamma, alpha=alpha) |
| loss = loss_fn(logits, labels) |
|
|
| return (loss, outputs) if return_outputs else loss |
|
|
| |
| |
| |
|
|
| def compute_metrics(eval_pred, num_labels): |
| logits, labels = eval_pred |
| preds = np.argmax(logits, axis=-1) |
| acc = accuracy_score(labels, preds) |
| f1 = f1_score(labels, preds, average="macro", zero_division=0) |
| return {"accuracy": acc, "f1_macro": f1} |
|
|
| |
| |
| |
|
|
| def calibrate_threshold(model, tokenizer, eval_dataset, device, num_labels): |
| """Grid search for best decision threshold on binary tasks.""" |
| if num_labels != 2: |
| return 0.5 |
|
|
| model.eval() |
| texts = eval_dataset["text"] |
| labels_list = eval_dataset["labels"] |
| encodings = tokenizer(texts, truncation=True, max_length=2048, padding=True) |
|
|
| |
| input_ids = torch.tensor(encodings["input_ids"]).to(device) |
| attention_mask = torch.tensor(encodings["attention_mask"]).to(device) |
|
|
| all_probs = [] |
| bs = 32 |
| for i in range(0, len(input_ids), bs): |
| with torch.no_grad(): |
| logits = model( |
| input_ids=input_ids[i:i+bs], |
| attention_mask=attention_mask[i:i+bs] |
| ).logits |
| probs = torch.softmax(logits, dim=-1).cpu().numpy() |
| all_probs.append(probs) |
|
|
| probs = np.vstack(all_probs) |
| labels = np.array(labels_list) |
|
|
| best_f1 = 0 |
| best_threshold = 0.5 |
| for t in np.linspace(0.05, 0.95, 19): |
| preds = (probs[:, 1] >= t).astype(int) |
| f1 = f1_score(labels, preds, average="macro", zero_division=0) |
| if f1 > best_f1: |
| best_f1 = f1 |
| best_threshold = t |
|
|
| print(f" Calibrated threshold: {best_threshold:.3f} (best F1={best_f1:.4f})") |
| return float(best_threshold) |
|
|
| |
| |
| |
|
|
| def compute_alpha(train_dataset, num_labels): |
| """Compute alpha weights as inverse class frequency.""" |
| labels = train_dataset["labels"] |
| counts = {} |
| for lb in labels: |
| counts[lb] = counts.get(lb, 0) + 1 |
| total = len(labels) |
| alpha = [total / (num_labels * max(counts.get(i, 1), 1)) for i in range(num_labels)] |
| print(f" Class counts: {counts}, alpha: {alpha}") |
| return alpha |
|
|
| |
| |
| |
|
|
| def train_model(task_name, push=True): |
| print(f"\n{'='*60}") |
| print(f"TRAINING: {task_name}") |
| print(f"{'='*60}") |
|
|
| cfg = TASK_CONFIG[task_name] |
| num_labels = cfg["num_labels"] |
|
|
| |
| try: |
| ds = load_dataset(DATASET_REPO, data_dir=f"data/{task_name}", split="train") |
| ds_dict = ds.train_test_split(test_size=0.15, seed=42) |
| train_ds = ds_dict["train"] |
| eval_ds = ds_dict["test"] |
| except Exception: |
| |
| train_ds = Dataset.from_parquet(f"https://huggingface.co/datasets/{DATASET_REPO}/resolve/main/data/{task_name}/train.parquet") |
| eval_ds = Dataset.from_parquet(f"https://huggingface.co/datasets/{DATASET_REPO}/resolve/main/data/{task_name}/test.parquet") |
|
|
| print(f" Train: {len(train_ds)}, Eval: {len(eval_ds)}") |
|
|
| |
| alpha = compute_alpha(train_ds, num_labels) |
|
|
| |
| tokenizer = AutoTokenizer.from_pretrained(BASE_MODEL) |
| if tokenizer.pad_token is None: |
| tokenizer.pad_token = tokenizer.eos_token |
|
|
| |
| def tokenize_fn(examples): |
| return tokenizer(examples["text"], truncation=True, max_length=2048, padding=False) |
|
|
| train_tok = train_ds.map(tokenize_fn, batched=True) |
| eval_tok = eval_ds.map(tokenize_fn, batched=True) |
|
|
| |
| model = AutoModelForSequenceClassification.from_pretrained( |
| BASE_MODEL, |
| num_labels=num_labels, |
| id2label=cfg["id2label"], |
| label2id=cfg["label2id"], |
| attention_dropout=0.1, |
| embedding_dropout=0.1, |
| mlp_dropout=0.1, |
| classifier_dropout=0.1, |
| ignore_mismatched_sizes=True, |
| ) |
|
|
| |
| args = dict(TRAINING_ARGS) |
| args["output_dir"] = f"/tmp/aco_train_{task_name}" |
| training_args = TrainingArguments(**args) |
|
|
| |
| trainer = FocalTrainer( |
| focal_gamma=2.0, |
| class_alpha=alpha, |
| model=model, |
| args=training_args, |
| train_dataset=train_tok, |
| eval_dataset=eval_tok, |
| tokenizer=tokenizer, |
| compute_metrics=lambda p: compute_metrics(p, num_labels), |
| callbacks=[EarlyStoppingCallback(early_stopping_patience=4)], |
| ) |
|
|
| |
| print(" Starting training...") |
| trainer.train() |
|
|
| |
| device = "cuda" if torch.cuda.is_available() else "cpu" |
| model.to(device) |
| threshold = calibrate_threshold(model, tokenizer, eval_ds, device, num_labels) |
|
|
| |
| model.config.threshold = threshold |
| model.config.focal_gamma = 2.0 |
| model.config.alpha = alpha |
|
|
| |
| if push: |
| output_repo = OUTPUT_REPO_TEMPLATE.format(task=task_name) |
| print(f" Pushing to {output_repo}...") |
| model.push_to_hub(output_repo) |
| tokenizer.push_to_hub(output_repo) |
| print(f" Published: https://huggingface.co/{output_repo}") |
|
|
| |
| from sklearn.metrics import classification_report |
| |
| model.eval() |
| texts = eval_ds["text"][:500] |
| labels_list = eval_ds["labels"][:500] |
| enc = tokenizer(texts, truncation=True, max_length=2048, padding=True, return_tensors="pt").to(device) |
| with torch.no_grad(): |
| logits = model(**enc).logits |
| probs = torch.softmax(logits, dim=-1).cpu().numpy() |
|
|
| if num_labels == 2: |
| preds = (probs[:, 1] >= threshold).astype(int) |
| else: |
| preds = np.argmax(probs, axis=-1) |
|
|
| acc = accuracy_score(labels_list, preds) |
| f1 = f1_score(labels_list, preds, average="macro", zero_division=0) |
| print(f" Final eval (first 500): acc={acc:.4f}, f1_macro={f1:.4f}") |
| print(classification_report(labels_list, preds, zero_division=0)) |
|
|
| return model, tokenizer, threshold |
|
|
| |
| |
| |
|
|
| def main(): |
| parser = argparse.ArgumentParser() |
| parser.add_argument("--task", choices=["tier_router", "tool_gater", "verifier_gater", "all"], |
| default="all") |
| parser.add_argument("--no-push", action="store_true") |
| args = parser.parse_args() |
|
|
| tasks = ["tier_router", "tool_gater", "verifier_gater"] if args.task == "all" else [args.task] |
|
|
| for task in tasks: |
| try: |
| train_model(task, push=not args.no_push) |
| except Exception as e: |
| print(f" FAILED: {task}: {e}") |
| import traceback; traceback.print_exc() |
|
|
| if __name__ == "__main__": |
| main() |
|
|