PineForge HPO 1.11.0
Native hyperparameter optimization for PineForge strategies
Loading...
Searching...
No Matches
pareto.py
Go to the documentation of this file.
1"""Post hoc Pareto front over the metrics recorded per trial.
2
3``pareto_front`` takes the finished rows of one study (a complete result, a rows JSONL
4file or a list of rows, read by the shared reader ``_rows``) and two or more axes, each
5a recorded metric name with a caller-supplied direction, and returns the rows that no
6other eligible row dominates. Nothing here runs a trial, writes a file, keeps state or
7touches a native result: the only side effect is reading the one file named. There is
8no Pareto sampler, no vector objective, no rank, hypervolume or crowding.
9
10Contract ``pineforge-hpo-pareto-front/v1``, algorithm ``exact_all_pairs_v1``:
11
12* Only rows with status ``ok`` compete. Every other status is excluded with the reason
13 ``status_<status>``. An ``ok`` row whose ``metrics`` object, or an axis key in it, is
14 absent is excluded as ``metric_not_recorded``; one whose axis value is ``null`` as
15 ``metric_unavailable`` (the first reason that applies, in that order).
16* An axis value of an ``ok`` row must be a finite number or ``null`` (a bool, string,
17 array, object, non-finite number or overflowing integer refuses the call). A
18 non-finite number in an axis cell of any row refuses too, as the ``NaN`` token does
19 when the same data is read from a file.
20* Row ``a`` dominates row ``b`` when ``a`` is no worse on every axis (in that axis's
21 direction) and strictly better on at least one. Values are compared as exact binary64
22 numbers, ``-0.0`` equals ``+0.0``, and equal vectors never dominate each other: both
23 stay on the front. Every pair of eligible rows is compared in both directions.
24* The result is ordered by ascending trial id and does not depend on the order of the
25 rows or of the axes. ``input_sha256`` is the SHA-256 of the sorted per-row SHA-256
26 digests (raw bytes) of the compact, key-sorted, ASCII JSON of
27 ``[trial_id, status, {axis name: number as binary64, or null when absent or null}]``
28 for every row read; the digest does not include the directions.
29* ``interpretation`` is the fixed text of ``INTERPRETATION``: what the front is not.
30* The admission figure is rows read + N(N-1)/2 for N eligible rows. The call is
31 refused as ``pareto_resource_exceeded`` only against a ``max_work`` the caller gave.
32
33A refusal is raised as ``ParetoError``; ``ParetoError.document()`` renders it as the
34refused result document (no front). The checks run in this order, so the first defect
35decides the code: axes and options (``pareto_parameter_missing`` before
36``pareto_parameter_invalid``); the source (``pareto_input_invalid``,
37``pareto_rows_incomplete``); the context of the rows (``pareto_context_mismatch``); the
38axis names (``pareto_axis_unknown``, checked only when at least one row was read); the
39axis values (``pareto_input_invalid``); ``pareto_no_eligible_rows``; the admission
40figure (``pareto_resource_exceeded``).
41"""
42
43from __future__ import annotations
44
45import hashlib
46import json
47import math
48from typing import Any, Iterable
49
50from ._rows import RowsError, check_context, read_source
51
52
53
54
55
56
57
58
59
60
61
62CONTRACT = "pineforge-hpo-pareto-front/v1"
63ALGORITHM = "exact_all_pairs_v1"
64DIRECTIONS = ("maximize", "minimize")
65CODES = (
66 "pareto_input_invalid",
67 "pareto_rows_incomplete",
68 "pareto_context_mismatch",
69 "pareto_parameter_missing",
70 "pareto_parameter_invalid",
71 "pareto_axis_unknown",
72 "pareto_no_eligible_rows",
73 "pareto_resource_exceeded",
74)
75
76INTERPRETATION = (
77 "(a) No Pareto sampler and no multi-objective search (P-F8). "
78 "(b) The front is over the trials that a scalar-objective study chose to "
79 "evaluate: TPE learns from one value and abandons constraint-violating trials "
80 "(study-spec.md:765-767, 790-794), so the sampler never searched the other axes. "
81 "The front is the front of the evaluated rows, not the true front, and its extent "
82 "depends on the sampler, the seed and the budget. "
83 "(c) A row excluded for a missing value, or for violating a constraint, may belong "
84 "to the true front. "
85 "(d) In-sample: no holdout (study-spec.md:376-377). "
86 "(e) No claim of spread, diversity, optimality or hypervolume; equal vectors are "
87 "not merged. "
88 "(f) Directions are the caller's; the module does not know a metric's sign "
89 "convention (P-F5). "
90 "(g) Rows of one lineage may come from different scoring contexts that the space "
91 "hash cannot detect (P-F13); the clause 2(e) checks are necessary, not sufficient."
92)
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108class ParetoError(Exception):
109 """A refusal of the Pareto front contract; ``code`` is one of ``CODES``.
110
111 ``ids`` holds offending trial ids, ascending; ``names`` holds offending axis or
112 field names. Either may be empty.
113 """
114
115 code: str
116 detail: str
117 ids: tuple
118 names: tuple
119
121 self,
122 code: str,
123 detail: str,
124 ids: Iterable = (),
125 names: Iterable = (),
126 ) -> None:
127 if code not in CODES:
128 raise ValueError(f"unknown pareto refusal code: {code!r}")
129 super().__init__(f"{code}: {detail}")
130 self.codecode = code
131 self.detaildetail = detail
132 self.idsids = tuple(sorted(ids))
133 self.namesnames = tuple(names)
134
135 def __reduce__(self):
136 return (type(self), (self.codecode, self.detaildetail, self.idsids, self.namesnames))
137
138 def document(self) -> dict:
139 """The refused result document: the refusal and the offenders, no front."""
140 return {
141 "contract": CONTRACT,
142 "status": "refused",
143 "refusal": {
144 "code": self.codecode,
145 "detail": self.detaildetail,
146 "ids": list(self.idsids),
147 "names": list(self.namesnames),
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
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
323 source: Any,
324 axes: Any,
325 *,
326 with_domination_counts: bool = False,
327 max_work: int | None = None,
328) -> dict:
329 """Return the Pareto front of the eligible rows of ``source`` over ``axes``.
330
331 ``source`` is whatever ``_rows.read_source`` takes: a path to a result JSON or a
332 rows JSONL, a parsed result dict or a parsed list of rows. ``axes`` is a list of at
333 least two ``{"name": <metric name as recorded>, "direction": "maximize" or
334 "minimize"}`` objects with distinct names; a direction has no default.
335 ``with_domination_counts`` adds, for every eligible id, how many eligible rows
336 dominate it (0 exactly on the front). ``max_work`` is a positive integer bound on
337 the admission figure rows read + N(N-1)/2; None means no bound.
338
339 Returns the ``pineforge-hpo-pareto-front/v1`` document (see the module text).
340 Raises ``ParetoError`` for every refusal; nothing is returned for a refused call.
341 """
342 names, directions = _check_axes(axes)
343 _check_options(with_domination_counts, max_work)
344 try:
345 loaded = read_source(source)
346 check_context(loaded.rows)
347 except RowsError as error:
348 raise _from_rows_error(error) from None
349 rows = sorted(loaded.rows, key=lambda row: row.trial_id)
350 rows_read = len(rows)
351 if rows_read:
352 unknown = [
353 name
354 for name in names
355 if not any(row.metrics is not None and name in row.metrics for row in rows)
356 ]
357 if unknown:
358 raise ParetoError(
359 "pareto_axis_unknown",
360 "an axis name is recorded in no row's metrics object",
361 names=unknown,
362 )
363 excluded: dict = {}
364 involved: dict = {reason: set() for reason in _METRIC_REASONS}
365 eligible: list = []
366 entries: list = []
367 unfit_ids: list = []
368 unfit_names: set = set()
369 for row in rows:
370 cells, absent, null, unfit = _axis_cells(row, names)
371 if unfit:
372 unfit_ids.append(row.trial_id)
373 unfit_names.update(unfit)
374 continue
375 entries.append((row.trial_id, row.status, cells))
376 if row.status != "ok":
377 excluded.setdefault(f"status_{row.status}", []).append(row.trial_id)
378 elif absent:
379 excluded.setdefault("metric_not_recorded", []).append(row.trial_id)
380 involved["metric_not_recorded"].update(absent)
381 elif null:
382 excluded.setdefault("metric_unavailable", []).append(row.trial_id)
383 involved["metric_unavailable"].update(null)
384 else:
385 eligible.append((row.trial_id, tuple(cells[name] for name in names)))
386 if unfit_ids:
387 raise ParetoError(
388 "pareto_input_invalid",
389 "an axis value is not a finite number, or null on an ok row",
390 unfit_ids,
391 [name for name in names if name in unfit_names],
392 )
393 digest = _input_digest(entries)
394 count = len(eligible)
395 if not count:
396 reasons = ", ".join(
397 f"{reason} {len(excluded[reason])}" for reason in sorted(excluded)
398 )
399 raise ParetoError(
400 "pareto_no_eligible_rows",
401 f"no row remains after exclusions ({rows_read} rows read: {reasons})"
402 if rows_read
403 else "no row remains after exclusions (the source holds no rows)",
404 )
405 pairs = count * (count - 1) // 2
406 work = rows_read + pairs
407 if max_work is not None and work > max_work:
408 raise ParetoError(
409 "pareto_resource_exceeded",
410 f"the admission figure {work} (rows read {rows_read} + {pairs} pairs of "
411 f"{count} eligible rows) is above max_work {max_work}",
412 )
413 ids = [trial_id for trial_id, _ in eligible]
414 vectors = [vector for _, vector in eligible]
415 scored = [
416 tuple(
417 -value if direction == "minimize" else value
418 for value, direction in zip(vector, directions)
419 )
420 for vector in vectors
421 ]
422 dominated_by = [0] * count
423 for first in range(count):
424 for second in range(first + 1, count):
425 if _dominates(scored[first], scored[second]):
426 dominated_by[second] += 1
427 elif _dominates(scored[second], scored[first]):
428 dominated_by[first] += 1
429 front = [index for index in range(count) if dominated_by[index] == 0]
430 excluded_document: dict = {}
431 for reason in sorted(excluded):
432 entry: dict = {"count": len(excluded[reason]), "ids": excluded[reason]}
433 if reason in involved:
434 entry["axes"] = [name for name in names if name in involved[reason]]
435 excluded_document[reason] = entry
436 document: dict = {
437 "contract": CONTRACT,
438 "status": "ok",
439 "axes": [
440 {"name": name, "direction": direction}
441 for name, direction in zip(names, directions)
442 ],
443 "rows_read": rows_read,
444 "rows_eligible": count,
445 "rows_excluded": rows_read - count,
446 "excluded": excluded_document,
447 "pareto_front": [ids[index] for index in front],
448 "n_front": len(front),
449 "n_distinct_vectors_on_front": len({vectors[index] for index in front}),
450 }
451 if with_domination_counts:
452 document["domination_counts"] = {
453 ids[index]: dominated_by[index] for index in range(count)
454 }
455 document["algorithm"] = ALGORITHM
456 document["input_sha256"] = digest
457 document["interpretation"] = INTERPRETATION
458 return document
None __init__(self, str code, str detail, Iterable ids=(), Iterable names=())
Definition pareto.py:126
dict pareto_front(Any source, Any axes, *bool with_domination_counts=False, int|None max_work=None)
Definition pareto.py:328