hypara 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hypara/__init__.py +4 -0
- hypara/cli.py +192 -0
- hypara/event_log.py +44 -0
- hypara/metrics.py +153 -0
- hypara/problems/__init__.py +0 -0
- hypara/problems/_util.py +30 -0
- hypara/problems/base.py +62 -0
- hypara/problems/conditional_knobs.py +91 -0
- hypara/problems/cost_aware.py +58 -0
- hypara/problems/dispatch_policy.py +86 -0
- hypara/problems/image_pipeline.py +82 -0
- hypara/problems/multi_fidelity.py +70 -0
- hypara/problems/noisy_lab.py +52 -0
- hypara/problems/rag_pipeline.py +90 -0
- hypara/problems/rugged_trap.py +54 -0
- hypara/problems/smooth_hill.py +43 -0
- hypara/problems/sparse_needle.py +59 -0
- hypara/protocol.py +92 -0
- hypara/registry.py +42 -0
- hypara/runner.py +248 -0
- hypara/space.py +224 -0
- hypara/transport.py +116 -0
- hypara-0.1.0.dist-info/METADATA +264 -0
- hypara-0.1.0.dist-info/RECORD +28 -0
- hypara-0.1.0.dist-info/WHEEL +5 -0
- hypara-0.1.0.dist-info/entry_points.txt +2 -0
- hypara-0.1.0.dist-info/licenses/LICENSE +21 -0
- hypara-0.1.0.dist-info/top_level.txt +1 -0
hypara/__init__.py
ADDED
hypara/cli.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""CLI: `hypara list | run | suite | report`.
|
|
2
|
+
|
|
3
|
+
Suite configs are JSON:
|
|
4
|
+
{
|
|
5
|
+
"name": "smoke",
|
|
6
|
+
"problems": ["smooth_hill", ...],
|
|
7
|
+
"optimizers": ["optimizers/random_search", ...], // dirs with manifest.json
|
|
8
|
+
"seeds": [1, 2, 3],
|
|
9
|
+
"budget_overrides": {"smooth_hill": {"evaluations": 30}}, // optional
|
|
10
|
+
"ask_timeout_sec": 60 // optional
|
|
11
|
+
}
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import dataclasses
|
|
18
|
+
import json
|
|
19
|
+
import sys
|
|
20
|
+
import time
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from .metrics import aggregate, format_report, load_results
|
|
24
|
+
from .problems.base import Budget
|
|
25
|
+
from .registry import get_problem, problem_names
|
|
26
|
+
from .runner import ASK_TIMEOUT_SEC, RunSpec, execute_run
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def load_manifest(optimizer_dir: str | Path) -> tuple[str, list[str], Path]:
|
|
30
|
+
"""Returns (name, command, cwd) from <dir>/manifest.json."""
|
|
31
|
+
d = Path(optimizer_dir)
|
|
32
|
+
with open(d / "manifest.json", encoding="utf-8") as f:
|
|
33
|
+
manifest = json.load(f)
|
|
34
|
+
name = manifest.get("name", d.name)
|
|
35
|
+
command = manifest["command"]
|
|
36
|
+
if not isinstance(command, list) or not command:
|
|
37
|
+
raise ValueError(f"{d / 'manifest.json'}: command must be a non-empty list")
|
|
38
|
+
return name, command, d
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def override_budget(default: Budget, override: dict | None) -> Budget:
|
|
42
|
+
if not override:
|
|
43
|
+
return default
|
|
44
|
+
fields = dataclasses.asdict(default)
|
|
45
|
+
for key in ("evaluations", "cost_limit", "time_limit_sec"):
|
|
46
|
+
if key in override:
|
|
47
|
+
fields[key] = override[key]
|
|
48
|
+
return Budget(**fields)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def cmd_list(_args) -> int:
|
|
52
|
+
for name in problem_names():
|
|
53
|
+
problem = get_problem(name)
|
|
54
|
+
budget = problem.default_budget
|
|
55
|
+
parts = []
|
|
56
|
+
if budget.evaluations is not None:
|
|
57
|
+
parts.append(f"evals={budget.evaluations}")
|
|
58
|
+
if budget.cost_limit is not None:
|
|
59
|
+
parts.append(f"cost={budget.cost_limit}")
|
|
60
|
+
fid = f" fidelities={problem.fidelities}" if problem.fidelities else ""
|
|
61
|
+
print(f"{name:<22} {', '.join(parts)}{fid}")
|
|
62
|
+
return 0
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def cmd_run(args) -> int:
|
|
66
|
+
problem = get_problem(args.problem)
|
|
67
|
+
opt_name, command, cwd = load_manifest(args.optimizer)
|
|
68
|
+
override = {}
|
|
69
|
+
if args.evaluations is not None:
|
|
70
|
+
override["evaluations"] = args.evaluations
|
|
71
|
+
if args.cost_limit is not None:
|
|
72
|
+
override["cost_limit"] = args.cost_limit
|
|
73
|
+
if args.time_limit is not None:
|
|
74
|
+
override["time_limit_sec"] = args.time_limit
|
|
75
|
+
budget = override_budget(problem.default_budget, override)
|
|
76
|
+
|
|
77
|
+
run_id = f"{problem.name}--{opt_name}--s{args.seed}"
|
|
78
|
+
out_dir = Path(args.out) if args.out else Path("results") / "adhoc" / run_id
|
|
79
|
+
result = execute_run(
|
|
80
|
+
RunSpec(
|
|
81
|
+
problem=problem,
|
|
82
|
+
optimizer_name=opt_name,
|
|
83
|
+
command=command,
|
|
84
|
+
optimizer_cwd=cwd,
|
|
85
|
+
seed=args.seed,
|
|
86
|
+
out_dir=out_dir,
|
|
87
|
+
budget=budget,
|
|
88
|
+
ask_timeout_sec=args.ask_timeout,
|
|
89
|
+
)
|
|
90
|
+
)
|
|
91
|
+
print(json.dumps({k: v for k, v in result.items() if k != "best_so_far"},
|
|
92
|
+
ensure_ascii=False, indent=2))
|
|
93
|
+
print(f"\nlogs: {out_dir}", file=sys.stderr)
|
|
94
|
+
return 0 if result["status"] == "completed" else 1
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def cmd_suite(args) -> int:
|
|
98
|
+
with open(args.config, encoding="utf-8") as f:
|
|
99
|
+
config = json.load(f)
|
|
100
|
+
name = config.get("name", Path(args.config).stem)
|
|
101
|
+
out_root = (
|
|
102
|
+
Path(args.out)
|
|
103
|
+
if args.out
|
|
104
|
+
else Path("results") / f"{name}-{time.strftime('%Y%m%d-%H%M%S')}"
|
|
105
|
+
)
|
|
106
|
+
out_root.mkdir(parents=True, exist_ok=True)
|
|
107
|
+
with open(out_root / "suite.json", "w", encoding="utf-8") as f:
|
|
108
|
+
json.dump(config, f, ensure_ascii=False, indent=2)
|
|
109
|
+
|
|
110
|
+
overrides = config.get("budget_overrides", {})
|
|
111
|
+
ask_timeout = config.get("ask_timeout_sec", ASK_TIMEOUT_SEC)
|
|
112
|
+
failures = 0
|
|
113
|
+
for problem_name in config["problems"]:
|
|
114
|
+
for optimizer_dir in config["optimizers"]:
|
|
115
|
+
opt_name, command, cwd = load_manifest(optimizer_dir)
|
|
116
|
+
for seed in config["seeds"]:
|
|
117
|
+
problem = get_problem(problem_name)
|
|
118
|
+
budget = override_budget(
|
|
119
|
+
problem.default_budget, overrides.get(problem_name)
|
|
120
|
+
)
|
|
121
|
+
run_id = f"{problem_name}--{opt_name}--s{seed}"
|
|
122
|
+
result = execute_run(
|
|
123
|
+
RunSpec(
|
|
124
|
+
problem=problem,
|
|
125
|
+
optimizer_name=opt_name,
|
|
126
|
+
command=command,
|
|
127
|
+
optimizer_cwd=cwd,
|
|
128
|
+
seed=seed,
|
|
129
|
+
out_dir=out_root / run_id,
|
|
130
|
+
budget=budget,
|
|
131
|
+
ask_timeout_sec=ask_timeout,
|
|
132
|
+
)
|
|
133
|
+
)
|
|
134
|
+
best = result["best_score"]
|
|
135
|
+
best_s = "None" if best is None else f"{best:.4f}"
|
|
136
|
+
print(
|
|
137
|
+
f"{run_id:<48} {result['status']:<18} best={best_s} "
|
|
138
|
+
f"evals={result['evaluations_used']} "
|
|
139
|
+
f"t={result['wall_time_sec']:.1f}s"
|
|
140
|
+
)
|
|
141
|
+
if result["status"] != "completed":
|
|
142
|
+
failures += 1
|
|
143
|
+
print(f"\nsuite dir: {out_root}")
|
|
144
|
+
if failures:
|
|
145
|
+
print(f"{failures} run(s) did not complete", file=sys.stderr)
|
|
146
|
+
return 0
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def cmd_report(args) -> int:
|
|
150
|
+
results = load_results(args.dir)
|
|
151
|
+
if not results:
|
|
152
|
+
print(f"no result.json found under {args.dir}", file=sys.stderr)
|
|
153
|
+
return 1
|
|
154
|
+
report = aggregate(results, baseline=args.baseline)
|
|
155
|
+
print(format_report(report))
|
|
156
|
+
with open(Path(args.dir) / "report.json", "w", encoding="utf-8") as f:
|
|
157
|
+
json.dump(report, f, ensure_ascii=False, indent=2)
|
|
158
|
+
return 0
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def main(argv=None) -> int:
|
|
162
|
+
parser = argparse.ArgumentParser(prog="hypara")
|
|
163
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
164
|
+
|
|
165
|
+
sub.add_parser("list", help="list registered problems")
|
|
166
|
+
|
|
167
|
+
p_run = sub.add_parser("run", help="run one optimizer on one problem")
|
|
168
|
+
p_run.add_argument("--problem", required=True)
|
|
169
|
+
p_run.add_argument("--optimizer", required=True, help="optimizer dir with manifest.json")
|
|
170
|
+
p_run.add_argument("--seed", type=int, default=0)
|
|
171
|
+
p_run.add_argument("--out")
|
|
172
|
+
p_run.add_argument("--evaluations", type=int)
|
|
173
|
+
p_run.add_argument("--cost-limit", type=float)
|
|
174
|
+
p_run.add_argument("--time-limit", type=float)
|
|
175
|
+
p_run.add_argument("--ask-timeout", type=float, default=ASK_TIMEOUT_SEC)
|
|
176
|
+
|
|
177
|
+
p_suite = sub.add_parser("suite", help="run a suite from a JSON config")
|
|
178
|
+
p_suite.add_argument("--config", required=True)
|
|
179
|
+
p_suite.add_argument("--out")
|
|
180
|
+
|
|
181
|
+
p_report = sub.add_parser("report", help="aggregate metrics from a suite dir")
|
|
182
|
+
p_report.add_argument("--dir", required=True)
|
|
183
|
+
p_report.add_argument("--baseline", default="random_search")
|
|
184
|
+
|
|
185
|
+
args = parser.parse_args(argv)
|
|
186
|
+
return {"list": cmd_list, "run": cmd_run, "suite": cmd_suite, "report": cmd_report}[
|
|
187
|
+
args.cmd
|
|
188
|
+
](args)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
if __name__ == "__main__":
|
|
192
|
+
sys.exit(main())
|
hypara/event_log.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Append-only JSONL event log for a run.
|
|
2
|
+
|
|
3
|
+
Metrics are always recomputed from this log; the runner never finalizes
|
|
4
|
+
aggregates during the run. Each row carries schema_version.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import time
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from . import LOG_SCHEMA_VERSION
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class EventLog:
|
|
17
|
+
def __init__(self, path: str | Path):
|
|
18
|
+
self.path = Path(path)
|
|
19
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
20
|
+
self._f = open(self.path, "a", encoding="utf-8")
|
|
21
|
+
self._t0 = time.monotonic()
|
|
22
|
+
|
|
23
|
+
def emit(self, event: str, **fields) -> None:
|
|
24
|
+
row = {
|
|
25
|
+
"schema_version": LOG_SCHEMA_VERSION,
|
|
26
|
+
"event": event,
|
|
27
|
+
"elapsed_sec": round(time.monotonic() - self._t0, 4),
|
|
28
|
+
**fields,
|
|
29
|
+
}
|
|
30
|
+
self._f.write(json.dumps(row, ensure_ascii=False, allow_nan=False) + "\n")
|
|
31
|
+
self._f.flush()
|
|
32
|
+
|
|
33
|
+
def close(self) -> None:
|
|
34
|
+
self._f.close()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def read_events(path: str | Path) -> list[dict]:
|
|
38
|
+
rows = []
|
|
39
|
+
with open(path, encoding="utf-8") as f:
|
|
40
|
+
for line in f:
|
|
41
|
+
line = line.strip()
|
|
42
|
+
if line:
|
|
43
|
+
rows.append(json.loads(line))
|
|
44
|
+
return rows
|
hypara/metrics.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Compute per-run and aggregated metrics from saved run results.
|
|
2
|
+
|
|
3
|
+
Normalization is baseline-relative: within one problem, 0 is the median
|
|
4
|
+
best_score of the baseline optimizer's runs and 1 is the best score observed
|
|
5
|
+
across all runs of that problem. Normalized AUC is the mean of the
|
|
6
|
+
normalized best-so-far value over an evenly spaced budget-fraction grid
|
|
7
|
+
(evaluations axis, or cost axis when the budget is cost-limited); before the
|
|
8
|
+
first counting evaluation the value is 0.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from statistics import mean, median
|
|
16
|
+
|
|
17
|
+
GRID_POINTS = 101
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def load_results(suite_dir: str | Path) -> list[dict]:
|
|
21
|
+
results = []
|
|
22
|
+
for path in sorted(Path(suite_dir).rglob("result.json")):
|
|
23
|
+
with open(path, encoding="utf-8") as f:
|
|
24
|
+
results.append(json.load(f))
|
|
25
|
+
return results
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _budget_axis(result: dict) -> tuple[str, float]:
|
|
29
|
+
"""(axis, total): evaluations axis unless the budget is cost-limited."""
|
|
30
|
+
budget = result["budget"]
|
|
31
|
+
if budget.get("evaluations") is not None:
|
|
32
|
+
return "evaluations", float(budget["evaluations"])
|
|
33
|
+
return "cost", float(budget["cost_limit"])
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _position(row: dict, axis: str) -> float:
|
|
37
|
+
if axis == "evaluations":
|
|
38
|
+
return row["eval_index"] + 1.0 # score known after the eval completes
|
|
39
|
+
return row["cost_used"]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def best_so_far_values(result: dict) -> list[float | None]:
|
|
43
|
+
"""Best-so-far sampled on the budget-fraction grid; None before first score."""
|
|
44
|
+
axis, total = _budget_axis(result)
|
|
45
|
+
curve = result["best_so_far"]
|
|
46
|
+
values: list[float | None] = []
|
|
47
|
+
for i in range(GRID_POINTS):
|
|
48
|
+
pos = total * i / (GRID_POINTS - 1)
|
|
49
|
+
best = None
|
|
50
|
+
for row in curve:
|
|
51
|
+
if _position(row, axis) <= pos:
|
|
52
|
+
best = row["score"]
|
|
53
|
+
else:
|
|
54
|
+
break
|
|
55
|
+
values.append(best)
|
|
56
|
+
return values
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def normalized(value: float | None, base: float, ref: float) -> float:
|
|
60
|
+
"""Clipped to [0, 1]: worse-than-baseline runs score 0 rather than
|
|
61
|
+
dragging the mean with unbounded negatives when the spread is tiny."""
|
|
62
|
+
if value is None:
|
|
63
|
+
return 0.0
|
|
64
|
+
if ref <= base:
|
|
65
|
+
# degenerate problem column (e.g. every run tied): all runs score 0
|
|
66
|
+
return 0.0
|
|
67
|
+
return min(1.0, max(0.0, (value - base) / (ref - base)))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def aggregate(results: list[dict], baseline: str = "random_search") -> dict:
|
|
71
|
+
"""Aggregate suite results into a per-(problem, optimizer) report dict."""
|
|
72
|
+
by_problem: dict[str, list[dict]] = {}
|
|
73
|
+
for r in results:
|
|
74
|
+
by_problem.setdefault(r["problem"], []).append(r)
|
|
75
|
+
|
|
76
|
+
report: dict = {"baseline": baseline, "problems": {}, "overall": {}}
|
|
77
|
+
per_opt_norm_best: dict[str, list[float]] = {}
|
|
78
|
+
per_opt_norm_auc: dict[str, list[float]] = {}
|
|
79
|
+
|
|
80
|
+
for problem, runs in sorted(by_problem.items()):
|
|
81
|
+
base_scores = [
|
|
82
|
+
r["best_score"]
|
|
83
|
+
for r in runs
|
|
84
|
+
if r["optimizer"] == baseline and r["best_score"] is not None
|
|
85
|
+
]
|
|
86
|
+
all_scores = [r["best_score"] for r in runs if r["best_score"] is not None]
|
|
87
|
+
base = median(base_scores) if base_scores else 0.0
|
|
88
|
+
ref = max(all_scores) if all_scores else 1.0
|
|
89
|
+
|
|
90
|
+
by_opt: dict[str, list[dict]] = {}
|
|
91
|
+
for r in runs:
|
|
92
|
+
by_opt.setdefault(r["optimizer"], []).append(r)
|
|
93
|
+
|
|
94
|
+
rows = {}
|
|
95
|
+
for opt, opt_runs in sorted(by_opt.items()):
|
|
96
|
+
norm_bests, norm_aucs, valid_rates = [], [], []
|
|
97
|
+
statuses: dict[str, int] = {}
|
|
98
|
+
for r in opt_runs:
|
|
99
|
+
statuses[r["status"]] = statuses.get(r["status"], 0) + 1
|
|
100
|
+
norm_bests.append(normalized(r["best_score"], base, ref))
|
|
101
|
+
grid = best_so_far_values(r)
|
|
102
|
+
norm_aucs.append(mean(normalized(v, base, ref) for v in grid))
|
|
103
|
+
total = r["valid_count"] + r["invalid_count"]
|
|
104
|
+
valid_rates.append(r["valid_count"] / total if total else 0.0)
|
|
105
|
+
rows[opt] = {
|
|
106
|
+
"runs": len(opt_runs),
|
|
107
|
+
"statuses": statuses,
|
|
108
|
+
"mean_best": mean(
|
|
109
|
+
[r["best_score"] for r in opt_runs if r["best_score"] is not None] or [0.0]
|
|
110
|
+
),
|
|
111
|
+
"mean_norm_best": mean(norm_bests),
|
|
112
|
+
"mean_norm_auc": mean(norm_aucs),
|
|
113
|
+
"mean_valid_rate": mean(valid_rates),
|
|
114
|
+
}
|
|
115
|
+
per_opt_norm_best.setdefault(opt, []).append(rows[opt]["mean_norm_best"])
|
|
116
|
+
per_opt_norm_auc.setdefault(opt, []).append(rows[opt]["mean_norm_auc"])
|
|
117
|
+
|
|
118
|
+
report["problems"][problem] = {
|
|
119
|
+
"baseline_median": base,
|
|
120
|
+
"observed_best": ref,
|
|
121
|
+
"optimizers": rows,
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
for opt in per_opt_norm_best:
|
|
125
|
+
report["overall"][opt] = {
|
|
126
|
+
"mean_norm_best": mean(per_opt_norm_best[opt]),
|
|
127
|
+
"mean_norm_auc": mean(per_opt_norm_auc[opt]),
|
|
128
|
+
}
|
|
129
|
+
return report
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def format_report(report: dict) -> str:
|
|
133
|
+
lines = []
|
|
134
|
+
header = f"{'optimizer':<22} {'runs':>4} {'best':>8} {'n_best':>7} {'n_auc':>7} {'valid':>6} status"
|
|
135
|
+
for problem, pdata in report["problems"].items():
|
|
136
|
+
lines.append(f"## {problem} (baseline_median={pdata['baseline_median']:.4f}, "
|
|
137
|
+
f"observed_best={pdata['observed_best']:.4f})")
|
|
138
|
+
lines.append(header)
|
|
139
|
+
for opt, row in pdata["optimizers"].items():
|
|
140
|
+
status = ",".join(f"{k}:{v}" for k, v in sorted(row["statuses"].items()))
|
|
141
|
+
lines.append(
|
|
142
|
+
f"{opt:<22} {row['runs']:>4} {row['mean_best']:>8.4f} "
|
|
143
|
+
f"{row['mean_norm_best']:>7.3f} {row['mean_norm_auc']:>7.3f} "
|
|
144
|
+
f"{row['mean_valid_rate']:>6.2f} {status}"
|
|
145
|
+
)
|
|
146
|
+
lines.append("")
|
|
147
|
+
lines.append(f"## overall (mean over problems, normalized vs {report['baseline']})")
|
|
148
|
+
lines.append(f"{'optimizer':<22} {'n_best':>7} {'n_auc':>7}")
|
|
149
|
+
for opt, row in sorted(
|
|
150
|
+
report["overall"].items(), key=lambda kv: -kv[1]["mean_norm_best"]
|
|
151
|
+
):
|
|
152
|
+
lines.append(f"{opt:<22} {row['mean_norm_best']:>7.3f} {row['mean_norm_auc']:>7.3f}")
|
|
153
|
+
return "\n".join(lines)
|
|
File without changes
|
hypara/problems/_util.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Small math helpers shared by surrogate evaluators (stdlib only)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from random import Random
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def bump(dist2: float, width: float) -> float:
|
|
10
|
+
"""Gaussian bump in [0, 1]: 1 at dist2=0, width is the sigma."""
|
|
11
|
+
return math.exp(-dist2 / (2.0 * width * width))
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def random_rotations(rng: Random, dim: int, count: int) -> list[tuple[int, int, float, float]]:
|
|
15
|
+
"""Random plane (Givens) rotations; a cheap stand-in for a rotation matrix."""
|
|
16
|
+
rots = []
|
|
17
|
+
for _ in range(count):
|
|
18
|
+
i, j = rng.sample(range(dim), 2)
|
|
19
|
+
theta = rng.uniform(0, 2 * math.pi)
|
|
20
|
+
rots.append((i, j, math.cos(theta), math.sin(theta)))
|
|
21
|
+
return rots
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def apply_rotations(z: list[float], rots: list[tuple[int, int, float, float]]) -> list[float]:
|
|
25
|
+
z = list(z)
|
|
26
|
+
for i, j, c, s in rots:
|
|
27
|
+
zi, zj = z[i], z[j]
|
|
28
|
+
z[i] = c * zi - s * zj
|
|
29
|
+
z[j] = s * zi + c * zj
|
|
30
|
+
return z
|
hypara/problems/base.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Problem / Evaluator interfaces.
|
|
2
|
+
|
|
3
|
+
A Problem is the template of one task: description, space, default budget.
|
|
4
|
+
An Evaluator is that template instantiated with an instance seed; it must be
|
|
5
|
+
a pure function of (candidate, noise_rng, fidelity) so runs are reproducible.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from abc import ABC, abstractmethod
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from random import Random
|
|
13
|
+
|
|
14
|
+
from ..space import ParamSpec
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class Budget:
|
|
19
|
+
evaluations: int | None = None
|
|
20
|
+
cost_limit: float | None = None
|
|
21
|
+
time_limit_sec: float | None = 300.0
|
|
22
|
+
|
|
23
|
+
def __post_init__(self):
|
|
24
|
+
if self.evaluations is None and self.cost_limit is None:
|
|
25
|
+
raise ValueError("budget needs evaluations or cost_limit")
|
|
26
|
+
|
|
27
|
+
def to_json(self) -> dict:
|
|
28
|
+
return {
|
|
29
|
+
"evaluations": self.evaluations,
|
|
30
|
+
"cost_limit": self.cost_limit,
|
|
31
|
+
"time_limit_sec": self.time_limit_sec,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class EvalResult:
|
|
37
|
+
score: float
|
|
38
|
+
cost: float = 1.0
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Evaluator(ABC):
|
|
42
|
+
@abstractmethod
|
|
43
|
+
def evaluate(
|
|
44
|
+
self, candidate: dict, noise_rng: Random, fidelity: str | None = None
|
|
45
|
+
) -> EvalResult: ...
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Problem(ABC):
|
|
49
|
+
"""Subclasses set the class attributes and implement make_evaluator.
|
|
50
|
+
|
|
51
|
+
fidelities, when set, is ordered low to high; the last entry is the top
|
|
52
|
+
fidelity, and only top-fidelity evaluations count toward best_score.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
name: str
|
|
56
|
+
description: str
|
|
57
|
+
space: list[ParamSpec]
|
|
58
|
+
default_budget: Budget
|
|
59
|
+
fidelities: list[str] | None = None
|
|
60
|
+
|
|
61
|
+
@abstractmethod
|
|
62
|
+
def make_evaluator(self, instance_seed: int) -> Evaluator: ...
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""ConditionalKnobs: the categorical `algo` choice switches which knobs exist."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from random import Random
|
|
7
|
+
|
|
8
|
+
from .base import Budget, EvalResult, Evaluator, Problem
|
|
9
|
+
from ._util import bump
|
|
10
|
+
from ..space import Condition, ParamSpec
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class _ConditionalKnobsEvaluator(Evaluator):
|
|
14
|
+
def __init__(self, instance_seed: int):
|
|
15
|
+
rng = Random(instance_seed)
|
|
16
|
+
bases = [0.35, 0.50, 0.65]
|
|
17
|
+
rng.shuffle(bases)
|
|
18
|
+
self.base = dict(zip(("alpha", "beta", "gamma"), bases))
|
|
19
|
+
self.alpha_rate = math.exp(rng.uniform(math.log(1e-4), math.log(1.0)))
|
|
20
|
+
self.alpha_depth = rng.randint(2, 9)
|
|
21
|
+
self.beta_temp = rng.uniform(0.2, 1.8)
|
|
22
|
+
self.beta_mode = rng.choice(["fast", "safe", "hybrid"])
|
|
23
|
+
self.gamma_k = rng.randint(5, 45)
|
|
24
|
+
self.gamma_smooth = rng.random() < 0.5
|
|
25
|
+
|
|
26
|
+
def evaluate(self, candidate, noise_rng, fidelity=None) -> EvalResult:
|
|
27
|
+
algo = candidate["algo"]
|
|
28
|
+
score = self.base[algo]
|
|
29
|
+
if algo == "alpha":
|
|
30
|
+
d2 = (
|
|
31
|
+
((math.log(candidate["alpha_rate"]) - math.log(self.alpha_rate)) / 1.0) ** 2
|
|
32
|
+
+ ((candidate["alpha_depth"] - self.alpha_depth) / 1.5) ** 2
|
|
33
|
+
)
|
|
34
|
+
score += 0.35 * bump(d2, width=0.5)
|
|
35
|
+
elif algo == "beta":
|
|
36
|
+
d2 = ((candidate["beta_temp"] - self.beta_temp) / 0.2) ** 2
|
|
37
|
+
score += 0.25 * bump(d2, width=0.5)
|
|
38
|
+
if candidate["beta_mode"] == self.beta_mode:
|
|
39
|
+
score += 0.10
|
|
40
|
+
else: # gamma
|
|
41
|
+
d2 = ((candidate["gamma_k"] - self.gamma_k) / 4.0) ** 2
|
|
42
|
+
score += 0.25 * bump(d2, width=0.5)
|
|
43
|
+
if candidate["gamma_smooth"] == self.gamma_smooth:
|
|
44
|
+
score += 0.10
|
|
45
|
+
return EvalResult(score=score)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ConditionalKnobs(Problem):
|
|
49
|
+
name = "conditional_knobs"
|
|
50
|
+
description = (
|
|
51
|
+
"You are configuring a service that supports three interchangeable "
|
|
52
|
+
"backend algorithms: alpha, beta, and gamma. Each algorithm exposes "
|
|
53
|
+
"its own knobs (see the `condition` fields in the space); knobs of "
|
|
54
|
+
"unselected algorithms must not appear in a candidate. One of the "
|
|
55
|
+
"three algorithms is inherently better than the others on this "
|
|
56
|
+
"workload, and within it the knobs have a single fairly narrow sweet "
|
|
57
|
+
"spot. Picking the right algorithm with untuned knobs gets you to "
|
|
58
|
+
"about 0.65; tuning its knobs is worth up to 0.35 more, for a "
|
|
59
|
+
"maximum of 1.0. No measurement noise."
|
|
60
|
+
)
|
|
61
|
+
space = [
|
|
62
|
+
ParamSpec(name="algo", type="categorical", choices=("alpha", "beta", "gamma")),
|
|
63
|
+
ParamSpec(
|
|
64
|
+
name="alpha_rate", type="float", low=1e-4, high=1.0, log=True,
|
|
65
|
+
condition=Condition("algo", ("alpha",)),
|
|
66
|
+
),
|
|
67
|
+
ParamSpec(
|
|
68
|
+
name="alpha_depth", type="int", low=1, high=10,
|
|
69
|
+
condition=Condition("algo", ("alpha",)),
|
|
70
|
+
),
|
|
71
|
+
ParamSpec(
|
|
72
|
+
name="beta_temp", type="float", low=0.0, high=2.0,
|
|
73
|
+
condition=Condition("algo", ("beta",)),
|
|
74
|
+
),
|
|
75
|
+
ParamSpec(
|
|
76
|
+
name="beta_mode", type="categorical", choices=("fast", "safe", "hybrid"),
|
|
77
|
+
condition=Condition("algo", ("beta",)),
|
|
78
|
+
),
|
|
79
|
+
ParamSpec(
|
|
80
|
+
name="gamma_k", type="int", low=1, high=50,
|
|
81
|
+
condition=Condition("algo", ("gamma",)),
|
|
82
|
+
),
|
|
83
|
+
ParamSpec(
|
|
84
|
+
name="gamma_smooth", type="bool",
|
|
85
|
+
condition=Condition("algo", ("gamma",)),
|
|
86
|
+
),
|
|
87
|
+
]
|
|
88
|
+
default_budget = Budget(evaluations=100)
|
|
89
|
+
|
|
90
|
+
def make_evaluator(self, instance_seed: int) -> Evaluator:
|
|
91
|
+
return _ConditionalKnobsEvaluator(instance_seed)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""CostAware: the candidate itself decides how expensive its evaluation is."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from random import Random
|
|
7
|
+
|
|
8
|
+
from .base import Budget, EvalResult, Evaluator, Problem
|
|
9
|
+
from ._util import apply_rotations, bump, random_rotations
|
|
10
|
+
from ..space import ParamSpec
|
|
11
|
+
|
|
12
|
+
_DIM = 2
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class _CostAwareEvaluator(Evaluator):
|
|
16
|
+
def __init__(self, instance_seed: int):
|
|
17
|
+
rng = Random(instance_seed)
|
|
18
|
+
self.center = [rng.uniform(0.2, 0.8) for _ in range(_DIM)]
|
|
19
|
+
self.rots = random_rotations(rng, _DIM, count=2)
|
|
20
|
+
self.scales = [rng.uniform(1.5, 3.0) for _ in range(_DIM)]
|
|
21
|
+
self.best_method = rng.choice(["quick", "thorough"])
|
|
22
|
+
self.tau = rng.uniform(15.0, 35.0)
|
|
23
|
+
|
|
24
|
+
def evaluate(self, candidate, noise_rng, fidelity=None) -> EvalResult:
|
|
25
|
+
z = [candidate[f"x{i}"] - self.center[i] for i in range(_DIM)]
|
|
26
|
+
z = apply_rotations(z, self.rots)
|
|
27
|
+
d2 = sum((s * v) ** 2 for s, v in zip(self.scales, z))
|
|
28
|
+
quality = 0.8 * bump(d2, width=0.7) + (
|
|
29
|
+
0.2 if candidate["method"] == self.best_method else 0.0
|
|
30
|
+
)
|
|
31
|
+
saturation = 1.0 - math.exp(-candidate["samples"] / self.tau)
|
|
32
|
+
cost = 0.5 + candidate["samples"] / 20.0
|
|
33
|
+
return EvalResult(score=quality * saturation, cost=cost)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class CostAware(Problem):
|
|
37
|
+
name = "cost_aware"
|
|
38
|
+
description = (
|
|
39
|
+
"You are tuning a measurement job with two continuous parameters, a "
|
|
40
|
+
"method choice, and a `samples` count from 1 to 100. The budget is a "
|
|
41
|
+
"total COST limit, and each evaluation costs about 0.5 + samples/20 "
|
|
42
|
+
"budget units (so samples=100 costs roughly ten times samples=10). "
|
|
43
|
+
"The score is an underlying quality (smooth, single optimum, method "
|
|
44
|
+
"matters) multiplied by a saturating function of `samples`: few "
|
|
45
|
+
"samples cap the score well below its potential, many samples "
|
|
46
|
+
"approach it with diminishing returns. The saturation rate is "
|
|
47
|
+
"unknown. Cheap low-sample evaluations still reveal where quality is "
|
|
48
|
+
"high; spending everything on max-sample evaluations leaves too few "
|
|
49
|
+
"probes. Maximum achievable score is just under 1.0."
|
|
50
|
+
)
|
|
51
|
+
space = [ParamSpec(name=f"x{i}", type="float", low=0.0, high=1.0) for i in range(_DIM)] + [
|
|
52
|
+
ParamSpec(name="method", type="categorical", choices=("quick", "thorough")),
|
|
53
|
+
ParamSpec(name="samples", type="int", low=1, high=100),
|
|
54
|
+
]
|
|
55
|
+
default_budget = Budget(evaluations=None, cost_limit=200.0)
|
|
56
|
+
|
|
57
|
+
def make_evaluator(self, instance_seed: int) -> Evaluator:
|
|
58
|
+
return _CostAwareEvaluator(instance_seed)
|