Guide
Choose and evaluate a head¶
Which metric, which head, and how to tell a real difference from noise.
You need a decision table (or an onboarding run); no LLM except for the last section
Pick the metric for the decision kind¶
| Kind | Examples | Metric | Name in code |
|---|---|---|---|
ranking |
tool, skill, API, GUI-element shortlisting | P@R (R-precision), Recall@k | p@r, recall@5 |
multilabel |
application selection | example-based precision, recall and F1 | ml_precision, ml_recall, ml_f1 |
single |
operation type, task category | accuracy, macro-F1 | accuracy |
policy |
model routing, sub-agent routing, skill gating | route@1, the reward of the chosen arm | route@1 |
P@R is the share of a task's ground-truth candidates in the top R of the ranking, where R is the size of the relevant set; it equals Recall@R and captures completeness. Recall@k is the share recovered in the top k, at a fixed budget. When a method gives several candidates the same score, the metrics order the tied candidates uniformly at random and report the exact expected value, for every method (paper Sec. 4.3 and App. L). Details are in Decision kinds.
Evaluate with cross_validate¶
cross_validate is the only code that decides what is training data. For every fold and seed it clones the model,
fits it on the training groups and scores the held-out groups through a label-free view of the table.
from tabagent import TabAgent, TabHead, cross_validate, datasets
from tabagent.evaluation.cv import PAPER_SEEDS
name = "skillsbench/skill-select"
table = datasets.load(name, download=True)
agent = TabAgent(datasets.card(name), head=TabHead("ranking"))
result = cross_validate(agent, table, folds=datasets.folds(name), seeds=PAPER_SEEDS)
print(result.summary(["p@r", "recall@9"])) # estimate and 95% bootstrap interval over tasks
The protocol is 5-fold task-level cross-validation (the paper's folds are bundled with each public dataset), three
seeds, macro average over tasks (over steps for Mind2Web element selection), 95% bootstrap intervals, and paired
task-level bootstraps with 2,000 resamples for comparisons. For your own table, leave folds out and set
n_splits, fold_seed and stratify.
Compare two heads¶
A difference is real only when its paired interval excludes zero (paper App. M):
default = cross_validate(
TabAgent(datasets.card(name), head=TabHead("ranking", preset="catboost-default")),
table,
folds=datasets.folds(name),
seeds=PAPER_SEEDS,
)
diff = result.compare(default, "p@r") # paired bootstrap of (result - default) over tasks
print(diff, diff.excludes_zero)
Interval.excludes_zero is the paper's significance rule. Within one metric across several applications, correct for
multiple comparisons with tabagent.evaluation.holm.
Choose a classifier¶
TabHead(kind, preset=...) is CatBoost over a fold-fitted featurizer (TF-IDF for text, codes for categoricals,
medians for numbers). Two presets exist:
| Preset | Configuration |
|---|---|
paper (default) |
the one configuration the paper uses for every decision (Sec. 4.2): 300 iterations, learning rate 0.08, depth 6, CatBoost's defaults otherwise |
catboost-default |
CatBoost's own schedule (1000 iterations, automatic learning rate) |
Any scikit-learn-style classifier plugs in with estimator= (a seed -> classifier factory with fit,
predict_proba and classes_), as in the paper's head ablation (Fig. 3, App. K.3):
from sklearn.ensemble import HistGradientBoostingClassifier
head = TabHead("ranking", estimator=lambda seed: HistGradientBoostingClassifier(random_state=seed))
The paper's finding is that the representation, not the model, does the work: with the card held fixed, heads from
classical trees to tabular foundation models reach comparable P@R, and every head collapses to P@R 0.10 to 0.21
without the card. experiments.figures.head_ablation reproduces the comparison (Reproduce the
paper).
Read an onboarding report¶
head.report (and ranker.report(X, y)) compares the head with the training-free baselines on the same folds:
TabAgent, chance and BM25, each with a 95% interval, then the per-decision latency and its cost under the paper's
CPU cost model (App. C: 4 threads at $0.063 per vCPU-hour), and the CatBoost feature importance summed over each
feature's columns. Pass llm_cost_per_call (CLI --llm-cost-per-call) to see the savings against the LLM component
you replace. report.save(dir) writes report.md and report.json.
Estimate the whole pipeline, discovery included¶
onboard discovers the feature card once, on all the onboarding trajectories, and then cross-validates the
head on that card. Its folds keep the head's training rows and fitted statistics apart, but every held-out task
was among the trajectories the card was discovered from, so the report estimates the head on a fixed card: a
quick estimate (one discovery) that can be optimistic. The paper's protocol fits the TabSchema loop inside each
training fold too (Sec. 4.3). tabagent.cross_validate_discovery does that:
import tabagent
from tabagent.tabschema import TabSchemaConfig
res = tabagent.cross_validate_discovery(
"./logs",
"ToolShortlist",
config=TabSchemaConfig(max_cost_usd=100, prices={"gpt-5.4": (PRICE_IN, PRICE_OUT)}), # all folds together
run_dir="./infold_runs", # one cached run directory per fold: a rerun makes no new LLM calls
)
print(res.summary(["p@r", "recall@9"])) # the usual CVResult metrics and intervals
res.discoveries[0].card # the card discovered on fold 0's training tasks
For every fold it runs discovery on the training fold's trajectories only, builds the training and held-out
rows with that fold's extractor, selects the training recipe on the training rows (as onboard does on all of
them) and scores the held-out decisions. With the same n_splits, its folds are those of onboard's report,
so the two estimates can be compared directly. It costs one discovery per fold, about five times the LLM cost
of onboarding with the default five folds; one budget covers all folds. The folds' discoveries run three at a
time by default (fold_workers=), each with an equal share of the budget, so the wall time is about that of
two onboardings rather than five. redact= works as in onboard, and
sandbox=DockerSandbox() runs the generated code in Docker. The head you deploy is still the one onboard fits
on all the trajectories; this estimates how well that procedure generalizes to new tasks.