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 ADDED
@@ -0,0 +1,4 @@
1
+ """hypara: benchmark harness for black-box optimizers speaking JSON Lines."""
2
+
3
+ PROTOCOL_VERSION = 1
4
+ LOG_SCHEMA_VERSION = 1
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
@@ -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
@@ -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)