Spaces:
Running on Zero
Running on Zero
File size: 8,936 Bytes
31252db | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 | """Choose a hard eval set from a candidate pool, without looking at any model's results on the eval tasks.
python -m rail_core.select --pool ../tasks/pool-eval --calib ../../results/rollouts/<run>/index.json \
--n 45 --out ../tasks/rail-v1-eval
How:
1. Calibrate on the train split. A model is run on a few train tasks (eval/run_eval.py --split train); its rewards
and the tasks' hardness features (`hardness.features`, model-free) are the calibration set.
2. Fit a ridge regression from standardized features to reward on that set (pure Python, leave-one-out error
reported). Features whose coefficient sign is unstable under leave-one-out are dropped.
3. Predict every pool task's reward (the pool is built on held-out days only) and keep the `n` with the lowest
prediction, with at most `max_per_day` per day and every tier in the pool represented (`min_per_tier`).
The eval tasks themselves are never scored by a model before selection: the choice depends only on train-split
calibration and on features any solver-built task has, so the eval stays a fair test.
Writes the pack (tasks.jsonl, manifest.json with the selection) and selection.json: the fit, its leave-one-out
error, the weights, and every pool task's prediction.
"""
from __future__ import annotations
import argparse
import json
import math
from pathlib import Path
from .build import write_pack
from .pack import TaskPack
FEATURES = ["runs", "conflicts", "decisions", "holds", "short_turns", "cancels", "turn_points", "avoidable",
"greedy_gap", "closed_min", "slow_min", "track_min", "single_closed", "mid_line", "late_sets"]
def _vec(h: dict, feats: list[str]) -> list[float]:
out = []
for f in feats:
v = h.get(f, 0)
v = float(v) if not isinstance(v, bool) else float(int(v))
if f in ("avoidable",):
v = math.log1p(max(0.0, v))
out.append(v)
return out
def _solve(A: list[list[float]], b: list[float]) -> list[float]:
"""Gaussian elimination with partial pivoting."""
n = len(A)
M = [row[:] + [b[i]] for i, row in enumerate(A)]
for c in range(n):
p = max(range(c, n), key=lambda r: abs(M[r][c]))
M[c], M[p] = M[p], M[c]
if abs(M[c][c]) < 1e-12:
continue
for r in range(n):
if r != c:
f = M[r][c] / M[c][c]
for k in range(c, n + 1):
M[r][k] -= f * M[c][k]
return [M[i][n] / M[i][i] if abs(M[i][i]) > 1e-12 else 0.0 for i in range(n)]
def ridge(X: list[list[float]], y: list[float], lam: float) -> list[float]:
"""w (intercept first) minimizing |y - [1 X] w|^2 + lam |w[1:]|^2."""
Z = [[1.0] + row for row in X]
d = len(Z[0])
A = [[sum(z[i] * z[j] for z in Z) + (lam if i == j and i > 0 else 0.0) for j in range(d)] for i in range(d)]
b = [sum(z[i] * yy for z, yy in zip(Z, y)) for i in range(d)]
return _solve(A, b)
def predict(w: list[float], x: list[float]) -> float:
return w[0] + sum(a * b for a, b in zip(w[1:], x))
class Scaler:
def __init__(self, rows: list[list[float]]):
n = len(rows)
self.mu = [sum(r[j] for r in rows) / n for j in range(len(rows[0]))]
self.sd = [math.sqrt(sum((r[j] - self.mu[j]) ** 2 for r in rows) / n) or 1.0 for j in range(len(rows[0]))]
def __call__(self, r: list[float]) -> list[float]:
return [(v - m) / s for v, m, s in zip(r, self.mu, self.sd)]
def fit(calib: list[tuple[dict, float]], pool_feats: list[dict], feats: list[str], lam: float):
X_raw = [_vec(h, feats) for h, _ in calib]
scale = Scaler(X_raw + [_vec(h, feats) for h in pool_feats])
X = [scale(r) for r in X_raw]
y = [r for _, r in calib]
w = ridge(X, y, lam)
loo, signs = [], []
for i in range(len(X)):
wi = ridge(X[:i] + X[i + 1:], y[:i] + y[i + 1:], lam)
loo.append(predict(wi, X[i]) - y[i])
signs.append([math.copysign(1, v) if abs(v) > 1e-9 else 0 for v in wi[1:]])
stable = [all(s[j] == signs[0][j] for s in signs) for j in range(len(feats))]
return w, scale, loo, stable
def _spearman(a: list[float], b: list[float]) -> float:
def ranks(v):
o = sorted(range(len(v)), key=lambda i: v[i])
r = [0.0] * len(v)
for k, i in enumerate(o):
r[i] = k
return r
ra, rb = ranks(a), ranks(b)
n = len(a)
ma, mb = sum(ra) / n, sum(rb) / n
num = sum((x - ma) * (y - mb) for x, y in zip(ra, rb))
den = math.sqrt(sum((x - ma) ** 2 for x in ra) * sum((y - mb) ** 2 for y in rb)) or 1.0
return num / den
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--pool", required=True)
ap.add_argument("--calib", nargs="+", required=True, help="index.json of runs on train tasks (with hardness)")
ap.add_argument("--model", default=None, help="only this model's calibration episodes")
ap.add_argument("--n", type=int, default=45)
ap.add_argument("--out", required=True)
ap.add_argument("--max-per-day", type=int, default=3)
ap.add_argument("--min-per-tier", type=int, default=4)
ap.add_argument("--lam", type=float, default=3.0)
args = ap.parse_args()
calib = []
for path in args.calib:
for e in json.loads(Path(path).read_text())["episodes"]:
if e.get("split") == "train" and e.get("hardness") and (not args.model or e["model"] == args.model):
calib.append((e["hardness"], float(e["reward"])))
pool = TaskPack(args.pool)
tasks = [t for t in pool.tasks if t.split == "eval"]
feats = list(FEATURES)
w, scale, loo, stable = fit(calib, [t.reference["hardness"] for t in tasks], feats, args.lam)
keep = [f for f, s in zip(feats, stable) if s]
if keep and len(keep) < len(feats):
feats = keep
w, scale, loo, stable = fit(calib, [t.reference["hardness"] for t in tasks], feats, args.lam)
preds = {t.task_id: predict(w, scale(_vec(t.reference["hardness"], feats))) for t in tasks}
y = [r for _, r in calib]
loo_pred = [e + yy for e, yy in zip(loo, y)]
# pick: lowest predicted reward, every tier represented, at most max_per_day per day
order = sorted(tasks, key=lambda t: preds[t.task_id])
chosen, per_day, per_tier = [], {}, {}
tiers = sorted({t.difficulty for t in tasks})
for tier in tiers: # the hardest few of each tier first
for t in [x for x in order if x.difficulty == tier]:
if per_tier.get(tier, 0) >= args.min_per_tier or len(chosen) >= args.n:
break
if per_day.get(t.day, 0) < args.max_per_day:
chosen.append(t)
per_day[t.day] = per_day.get(t.day, 0) + 1
per_tier[tier] = per_tier.get(tier, 0) + 1
for t in order:
if len(chosen) >= args.n:
break
if t in chosen or per_day.get(t.day, 0) >= args.max_per_day:
continue
chosen.append(t)
per_day[t.day] = per_day.get(t.day, 0) + 1
per_tier[t.difficulty] = per_tier.get(t.difficulty, 0) + 1
report = {
"calibration_episodes": len(calib), "features": feats, "lambda": args.lam,
"weights": dict(zip(["intercept"] + feats, [round(v, 4) for v in w])),
"loo_mae": round(sum(abs(e) for e in loo) / max(1, len(loo)), 4),
"loo_spearman": round(_spearman(loo_pred, y), 4) if len(y) > 2 else None,
"calibration_mean_reward": round(sum(y) / max(1, len(y)), 4),
"pool": len(tasks), "chosen": len(chosen), "per_tier": per_tier,
"predicted_mean_chosen": round(sum(preds[t.task_id] for t in chosen) / max(1, len(chosen)), 4),
"predicted_mean_pool": round(sum(preds.values()) / max(1, len(preds)), 4),
"predictions": {k: round(v, 4) for k, v in sorted(preds.items(), key=lambda kv: kv[1])},
}
out = Path(args.out)
out.mkdir(parents=True, exist_ok=True)
for t in chosen:
t.reference["predicted_reward"] = round(preds[t.task_id], 4)
picked = sorted((t.to_dict() for t in chosen), key=lambda d: (d["difficulty"], d["day"], d["start"]))
log = [json.loads(line) for line in (Path(args.pool) / "build_log.jsonl").read_text().splitlines()] \
if (Path(args.pool) / "build_log.jsonl").is_file() else []
manifest = write_pack(out, picked, log, "eval", extra={"selected_from": Path(args.pool).name,
"selection": {k: report[k] for k in report
if k != "predictions"}})
(out / "selection.json").write_text(json.dumps(report, indent=1) + "\n")
print(json.dumps({k: v for k, v in report.items() if k != "predictions"}, indent=1))
print(json.dumps({k: manifest[k] for k in ("tasks", "tiers", "runs")}))
if __name__ == "__main__":
main()
|