agent-cost-optimizer / train_aco.py
narcolepticchicken's picture
Upload train_aco.py
581774a verified
Raw
History Blame Contribute Delete
13.8 kB
"""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
# ═══════════════════════════════════════════════════════════════
# Configuration β€” matches v2 training exactly
# ═══════════════════════════════════════════════════════════════
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, # we push manually after calibration
report_to="none",
)
# ═══════════════════════════════════════════════════════════════
# Focal Loss
# ═══════════════════════════════════════════════════════════════
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
# ═══════════════════════════════════════════════════════════════
# Trainer with 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
# Move alpha to correct device
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
# ═══════════════════════════════════════════════════════════════
# Metrics
# ═══════════════════════════════════════════════════════════════
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}
# ═══════════════════════════════════════════════════════════════
# Threshold calibration
# ═══════════════════════════════════════════════════════════════
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)
# Run inference
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)
# ═══════════════════════════════════════════════════════════════
# Compute class alpha from training labels (focal loss setup)
# ═══════════════════════════════════════════════════════════════
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
# ═══════════════════════════════════════════════════════════════
# Train one model
# ═══════════════════════════════════════════════════════════════
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"]
# Load data
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:
# Fallback: load from pre-split files
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)}")
# Class alpha
alpha = compute_alpha(train_ds, num_labels)
# Tokenizer
tokenizer = AutoTokenizer.from_pretrained(BASE_MODEL)
if tokenizer.pad_token is None:
tokenizer.pad_token = tokenizer.eos_token
# Tokenize
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 with explicit dropout
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,
)
# Training args
args = dict(TRAINING_ARGS)
args["output_dir"] = f"/tmp/aco_train_{task_name}"
training_args = TrainingArguments(**args)
# Trainer
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)],
)
# Train
print(" Starting training...")
trainer.train()
# Calibrate threshold (binary tasks)
device = "cuda" if torch.cuda.is_available() else "cpu"
model.to(device)
threshold = calibrate_threshold(model, tokenizer, eval_ds, device, num_labels)
# Save to model config
model.config.threshold = threshold
model.config.focal_gamma = 2.0
model.config.alpha = alpha
# Push to Hub
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}")
# Final eval
from sklearn.metrics import classification_report
# Quick eval on a subset for printing
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
# ═══════════════════════════════════════════════════════════════
# Main
# ═══════════════════════════════════════════════════════════════
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()