PineForge HPO 1.11.0
Native hyperparameter optimization for PineForge strategies
Loading...
Searching...
No Matches
holdout.py
Go to the documentation of this file.
1"""Post hoc comparison of the best rows of two finished studies.
2
3``holdout_compare`` takes the finished rows of two studies that already exist - the
4SELECTION source (the study the user tuned on) and the HOLDOUT source (a second study
5the user ran, outside this module, on data the caller says was set aside). It reads
6both with the shared reader ``_rows``, joins their rows by the exact parameter vector
7and reports, for the ``k`` best eligible vectors of the selection source, what the
8holdout source recorded for the same vectors. Nothing here splits data, runs a trial,
9writes a file, keeps state or touches a native result: the only side effect is reading
10the two sources named. It is a library function; there is no command-line flag and no
11field in a native result.
12v1 analyses finished studies; native in-loop walk-forward comes later.
13
14Contract ``pineforge-hpo-holdout/v1``, algorithm ``exact_join_midrank_v1``:
15
16* Every row of both sources is keyed by ``_rows.vector_key(parameters, space)``, the
17 canonical key of the typed parameters of one space, which the rows and results of
18 both sources must share. Equality is exact: no tolerance and no lattice snapping, so
19 a near-equal vector is a different vector.
20* A row is eligible when its status is ``ok`` and its value for the named metric is a
21 finite number. Every other row is excluded with one reason: ``status_<status>`` for
22 a status other than ``ok``, else ``metric_not_recorded`` when the metric is a name
23 and the row records no such key, else ``metric_unavailable`` when the recorded cell
24 is ``null``. A metric cell that is a non-finite number, or an ``ok`` row's cell of
25 another type, or a cell that cannot be written as JSON, or an integer too large for
26 a binary64, refuses the call.
27* Eligible rows of one source that carry one vector are one vector. Their values must
28 all be equal; a disagreement refuses ``holdout_duplicate_vector``, and the vector is
29 otherwise represented by its eligible row of lowest ``trial_id`` and counted once.
30 When several vectors conflict, every eligible row of every conflicting vector of the
31 first conflicting source (selection first) is named; the other source is not examined.
32* The eligible selection vectors are ranked by their value in the caller's direction,
33 ties broken by ascending representative trial id, so every rank is a distinct
34 1-based integer. The followed vectors are the best ``min(k, n_sel)`` of them. A
35 followed vector with no eligible row in the holdout source is reported as unmatched,
36 with the reason ``absent_in_holdout`` or ``holdout_row_not_eligible`` and the ids of
37 the holdout rows that hold it; it is never given a value of zero.
38* The POOL is the eligible holdout vectors whose key matches the key of at least one
39 eligible selection vector, of any rank. Equal values in the pool share a mid-rank,
40 the exact integer or half-integer ``(p + q) / 2`` of the positions the tie group
41 occupies.
42* For every matched followed vector the document reports the difference
43 ``holdout - selection`` (one subtraction) and whether the holdout value is strictly
44 better in the direction. Over the matched vectors two statistics are reported: the
45 rank correlation of the matched pairs, whose two rank vectors mid-rank equal values
46 on each side within the matched set alone, and the share of matched vectors whose
47 pool mid-rank is at most ``(M + 1) / 2`` with ``M`` the pool size. Every rank,
48 mid-rank, term and cut is computed in exact rationals and reported as a
49 ``[numerator, denominator]`` pair of integers in lowest terms.
50 ``difference`` and the share are single correctly rounded binary64 operations;
51 ``spearman`` is one correctly rounded conversion of the exact ratio followed by
52 one correctly rounded square root.
53* ``spearman`` is ``null``, with a reason in ``spearman_undefined``, when fewer than
54 three vectors are matched or when one of the two rank vectors is constant. No
55 significance test, no p-value, no threshold and no verdict accompanies it.
56* ``input_sha256`` is the SHA-256 of the sorted per-row SHA-256 digests of the compact,
57 key-sorted, ASCII JSON of ``[source_index, trial_id, status, value, vector_key]`` for
58 every row read of both sources; the metric name, the direction and ``k`` are not
59 covered. The document depends only on the rows, the space and the arguments.
60* ``interpretation`` is the fixed text of ``INTERPRETATION``: what the comparison is
61 not.
62* The admission figure is ``rows_read + n_sel(n_sel - 1)/2 + n_hol(n_hol - 1)/2`` over
63 the eligible VECTOR counts. The call is refused as ``holdout_resource_exceeded`` only
64 against a ``max_work`` the caller gave; a figure equal to it is admitted.
65* A complete result may carry the ancestor rows of an earlier study it continued from.
66 The module counts them for each source and refuses ``holdout_ancestor_rows`` when the
67 holdout source's ancestor rows repeat a trial id of the selection source, because
68 such a source may be reporting the selection study's own rows; the trial id is the
69 only witness available, so an id that repeats by a coincidence of numbering is
70 refused like a real overlap.
71 A path is read twice to count ancestors. If the second read fails or yields a
72 different number of rows, the call refuses ``holdout_input_invalid`` for that
73 source rather than reporting zero ancestors. Parsed sources need no second read.
74
75A refusal is raised as ``HoldoutError``; ``HoldoutError.document()`` renders it as the
76refused result document (no comparison). The checks run in this order, so the first
77defect decides the code: the arguments (``holdout_parameter_missing`` before
78``holdout_parameter_invalid``); the two sources (``holdout_input_invalid``,
79``holdout_rows_incomplete``); the space of both sources (``holdout_space_missing``,
80``holdout_space_mismatch``); the direction rule; the context of each source on its own
81(``holdout_context_mismatch``); the second read of each path source, selection first
82(``holdout_input_invalid``); the ancestor rows of the holdout source
83(``holdout_ancestor_rows``); the key of every row (``holdout_input_invalid``); the
84metric name (``holdout_metric_unknown``); every metric value
85(``holdout_value_invalid``); the eligible rows of the selection source
86(``holdout_no_eligible_rows``); the repeated vectors (``holdout_duplicate_vector``);
87the admission figure (``holdout_resource_exceeded``); and a difference that is not
88finite (``holdout_value_invalid``).
89"""
90
91from __future__ import annotations
92
93import hashlib
94import json
95import math
96import os
97from fractions import Fraction
98from typing import Any, Iterable
99
100from ._rows import (
101 _content_lines,
102 _is_one_value,
103 _source_from_text,
104 RowsError,
105 check_context,
106 common_space,
107 loads_strict,
108 read_source,
109 vector_key,
110)
111from .study_spec import StudySpec
112
113
114
115
116
117
118
119
120
121
122
123CONTRACT = "pineforge-hpo-holdout/v1"
124ALGORITHM = "exact_join_midrank_v1"
125DIRECTIONS = ("maximize", "minimize")
126CODES = (
127 "holdout_input_invalid",
128 "holdout_rows_incomplete",
129 "holdout_space_missing",
130 "holdout_space_mismatch",
131 "holdout_context_mismatch",
132 "holdout_ancestor_rows",
133 "holdout_parameter_missing",
134 "holdout_parameter_invalid",
135 "holdout_metric_unknown",
136 "holdout_value_invalid",
137 "holdout_no_eligible_rows",
138 "holdout_duplicate_vector",
139 "holdout_resource_exceeded",
140)
141
142INTERPRETATION = (
143 "(a) The module splits no data and runs no trial: it reads two sets of rows that "
144 "two finished studies already wrote, and joins them by the exact parameter vector. "
145 "It cannot know what data either study used, so a value recorded by the second "
146 "study is only as held-out as the caller made it. "
147 "(b) A study is a holdout only when the caller ran it on data set aside before the "
148 "selection rows were read; the module cannot check that, and reading the best "
149 "selection rows while the second study's values are in view contaminates it. "
150 "(c) Equality of parameter vectors is exact: no tolerance and no lattice snapping, "
151 "so a near-equal vector is a different vector and is reported unmatched, never "
152 "merged. "
153 "(d) No significance test, no threshold and no verdict: a few vectors give a noisy "
154 "rank correlation, and the module states no probability, interval or error bar. "
155 "(e) The selection side is the best of many trials, so its values are optimistic; "
156 "every number is in-sample for the selection source and only as held-out as the "
157 "caller made it for the other. "
158 "(f) v1 analyses finished studies; native in-loop walk-forward comes later. "
159 "(g) The scoring-context check is necessary, not sufficient, and it runs on each "
160 "source on its own, never between the two studies: a space hash names the "
161 "parameter space and the objective, not the data, so an equal hash says nothing "
162 "about the data, and this module requires the two studies to share that space, "
163 "so their hashes are normally equal and can differ only through a space-hash "
164 "version change. "
165 "(h) When a source records no space, its rows are keyed with the space the other "
166 "source records or the caller passes, so the module cannot tell whether the second "
167 "study ran in that space. "
168 "(i) A complete result may carry the ancestor rows of a study it continued from: "
169 "the module counts them for each source, reports the counts, and refuses a holdout "
170 "source whose ancestor rows repeat a trial id of the selection source, because "
171 "such a source may be reporting the selection study's own rows; an overlap that "
172 "the trial ids do not show cannot be detected, and an id that repeats by a "
173 "coincidence of numbering is refused like a real one. "
174 "(j) No claim is made about the data of either source, about the run that produced "
175 "it, or about any strategy."
176)
177
178
179
180
181
182
183
184
185
186
187
188class HoldoutError(Exception):
189 """A refusal of the holdout comparison contract; ``code`` is one of ``CODES``.
190
191 ``ids`` holds offending rows as ``(source_index, trial_id)`` pairs, ascending;
192 ``names`` holds offending metric, parameter or argument names. Either may be empty.
193 """
194
195 code: str
196 detail: str
197 ids: tuple
198 names: tuple
199
201 self,
202 code: str,
203 detail: str,
204 ids: Iterable = (),
205 names: Iterable = (),
206 ) -> None:
207 if code not in CODES:
208 raise ValueError(f"unknown holdout refusal code: {code!r}")
209 super().__init__(f"{code}: {detail}")
210 self.codecode = code
211 self.detaildetail = detail
212 self.idsids = tuple(sorted(ids))
213 self.namesnames = tuple(names)
214
215 def __reduce__(self):
216 return (type(self), (self.codecode, self.detaildetail, self.idsids, self.namesnames))
217
218 def document(self) -> dict:
219 """The refused result document: the refusal and the offenders, no comparison."""
220 return {
221 "contract": CONTRACT,
222 "status": "refused",
223 "refusal": {
224 "code": self.codecode,
225 "detail": self.detaildetail,
226 "ids": list(self.idsids),
227 "names": list(self.namesnames),
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
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
502 selection: Any,
503 holdout: Any,
504 *,
505 metric: Any,
506 direction: Any,
507 k: Any,
508 space: Any = None,
509 max_work: Any = None,
510) -> dict:
511 """Return what ``holdout`` recorded for the best vectors of ``selection``.
512
513 ``selection`` (source 0) and ``holdout`` (source 1) are each whatever
514 ``_rows.read_source`` takes: a path to a complete result JSON or to a rows JSONL, a
515 parsed result dict or a parsed list of rows. ``metric`` is ``"objective"`` or the
516 exact name of a recorded metric, ``direction`` is ``"maximize"`` or ``"minimize"``
517 (``None`` only for ``"objective"``, and then only when both sources record the same
518 direction) and ``k`` is a positive integer, clipped to the eligible selection
519 vector count. ``space`` is used only when no row and no result records one, and
520 ``max_work`` is a positive integer bound on the admission figure; both default to
521 ``None``.
522
523 Returns the ``pineforge-hpo-holdout/v1`` document (see the module text). Raises
524 ``HoldoutError`` for every refusal; nothing is returned for a refused call.
525 """
526 _check_args(metric, k, direction, space, max_work)
527 try:
528 selection_source = read_source(selection, 0)
529 except RowsError as error:
530 raise _from_rows_error(error, 0) from None
531 try:
532 holdout_source = read_source(holdout, 1)
533 except RowsError as error:
534 raise _from_rows_error(error, 1) from None
535 try:
536 shared_space = common_space([selection_source, holdout_source], space)
537 except RowsError as error:
538 raise _from_rows_error(error) from None
539 used_direction = _direction_used(direction, selection_source, holdout_source)
540 for source in (selection_source, holdout_source):
541 try:
542 check_context(source.rows)
543 except RowsError as error:
544 raise _from_rows_error(error) from None
545 ancestors = (
546 _ancestor_ids(selection, selection_source),
547 _ancestor_ids(holdout, holdout_source),
548 )
549 selection_ids = {row.trial_id for row in selection_source.rows}
550 repeated = sorted(t for t in ancestors[1] if t in selection_ids)
551 if repeated:
552 raise HoldoutError(
553 "holdout_ancestor_rows",
554 "the holdout source repeats a trial id of the selection source among its "
555 "ancestor rows, so it may be reporting the selection study's own rows "
556 f"(selection ancestor rows {len(ancestors[0])}, holdout ancestor rows "
557 f"{len(ancestors[1])})",
558 [(1, trial_id) for trial_id in repeated],
559 )
560 ordered = [(0, row) for row in selection_source.rows]
561 ordered += [(1, row) for row in holdout_source.rows]
562 ordered.sort(key=lambda item: (item[0], item[1].trial_id))
563 keys: dict = {}
564 for index, row in ordered:
565 try:
566 keys[(index, row.trial_id)] = vector_key(row.parameters, shared_space)
567 except RowsError as error:
568 raise HoldoutError(
569 _CODES_BY_KIND[error.kind],
570 error.detail,
571 [(index, row.trial_id)],
572 error.names,
573 ) from None
574 if metric != "objective":
575 for source in (selection_source, holdout_source):
576 if not source.rows:
577 continue
578 holders = [row for row in source.rows if metric in (row.metrics or {})]
579 if not holders:
580 raise HoldoutError(
581 "holdout_metric_unknown",
582 "no row of the "
583 f"{_SOURCE_WORDS[source.index]} source records the metric",
584 names=(metric,),
585 )
586 cells: dict = {}
587 for index, row in ordered:
588 cells[(index, row.trial_id)] = _metric_cell(row, metric)
589 entries = []
590 for index, row in ordered:
591 where = (index, row.trial_id)
592 entries.append((index, row.trial_id, row.status, cells[where][0], keys[where]))
593 digest = _input_digest(entries)
594 excluded: dict = {0: {}, 1: {}}
595 eligible: dict = {0: [], 1: []}
596 for index, row in ordered:
597 where = (index, row.trial_id)
598 if row.status != "ok":
599 reason = f"status_{row.status}"
600 elif metric != "objective" and cells[where][1] is None:
601 metrics = row.metrics
602 if metrics is None or metric not in metrics:
603 reason = "metric_not_recorded"
604 else:
605 reason = "metric_unavailable"
606 else:
607 eligible[index].append((row.trial_id, cells[where][1], keys[where]))
608 continue
609 excluded[index].setdefault(reason, []).append(row.trial_id)
610 if not eligible[0]:
611 counts = ", ".join(
612 f"{reason} {len(excluded[0][reason])}" for reason in sorted(excluded[0])
613 )
614 if selection_source.rows:
615 detail = (
616 "no eligible row remains in the selection source "
617 f"({len(selection_source.rows)} rows read: {counts})"
618 )
619 else:
620 detail = "no eligible row remains in the selection source (no rows read)"
621 raise HoldoutError("holdout_no_eligible_rows", detail)
622 vectors: dict = {0: {}, 1: {}}
623 for index in (0, 1):
624 groups: dict = {}
625 for trial_id, value, key in sorted(eligible[index]):
626 groups.setdefault(key, []).append((trial_id, value))
627 conflicting = [key for key, rows in groups.items() if _differs(rows)]
628 if conflicting:
629 ids = [
630 (index, trial_id)
631 for trial_id, _, key in eligible[index]
632 if key in conflicting
633 ]
634 raise HoldoutError(
635 "holdout_duplicate_vector",
636 "two eligible rows of one source share a parameter vector and disagree "
637 f"on the metric value ({min(conflicting)})",
638 ids,
639 )
640 for key, rows in groups.items():
641 vectors[index][key] = rows[0]
642 rows_read = len(selection_source.rows) + len(holdout_source.rows)
643 n_sel = len(vectors[0])
644 n_hol = len(vectors[1])
645 selection_pairs = n_sel * (n_sel - 1) // 2
646 holdout_pairs = n_hol * (n_hol - 1) // 2
647 work = rows_read + selection_pairs + holdout_pairs
648 if max_work is not None and work > max_work:
649 raise HoldoutError(
650 "holdout_resource_exceeded",
651 f"the admission figure {work} (rows read {rows_read} + {selection_pairs} "
652 f"pairs of {n_sel} selection vectors + {holdout_pairs} pairs of {n_hol} "
653 f"holdout vectors) is above max_work {max_work}",
654 )
655 selection_order = _best_first(_vector_items(vectors[0]), used_direction)
656 selection_rank = {
657 key: position + 1 for position, (_, _, key) in enumerate(selection_order)
658 }
659 k_followed = min(k, n_sel)
660 pool = {key: vectors[1][key] for key in vectors[1] if key in vectors[0]}
661 pool_order = _best_first(_vector_items(pool), used_direction)
662 pool_ranks = _mid_ranks([item[1] for item in pool_order], used_direction)
663 holdout_rank = {
664 item[2]: pool_ranks[position] for position, item in enumerate(pool_order)
665 }
666 cut = Fraction(len(pool) + 1, 2)
667 followed: list = []
668 unmatched: list = []
669 matched: list = []
670 holder_ids: dict = {}
671 for index, row in ordered:
672 if index == 1:
673 holder_ids.setdefault(keys[(1, row.trial_id)], []).append(row.trial_id)
674 for _, value, key in selection_order[:k_followed]:
675 trial_id = vectors[0][key][0]
676 if key in pool:
677 holdout_trial_id, holdout_value = pool[key]
678 difference = holdout_value - value
679 if not math.isfinite(difference):
680 raise HoldoutError(
681 "holdout_value_invalid",
682 "the difference of a matched followed vector is not finite",
683 [(0, trial_id), (1, holdout_trial_id)],
684 )
685 if used_direction == "maximize":
686 improved = holdout_value > value
687 else:
688 improved = holdout_value < value
689 followed.append(
690 {
691 "selection_trial_id": trial_id,
692 "vector_key": key,
693 "selection_value": value,
694 "selection_rank": selection_rank[key],
695 "holdout_trial_id": holdout_trial_id,
696 "holdout_value": holdout_value,
697 "holdout_rank": _pair(holdout_rank[key]),
698 "difference": difference,
699 "improved": improved,
700 }
701 )
702 matched.append((holdout_rank[key], value, holdout_value))
703 continue
704 holds = holder_ids.get(key, [])
705 unmatched.append(
706 {
707 "selection_trial_id": trial_id,
708 "vector_key": key,
709 "selection_value": value,
710 "selection_rank": selection_rank[key],
711 "reason": "holdout_row_not_eligible" if holds else "absent_in_holdout",
712 "holdout_ids": holds,
713 }
714 )
715 count = len(matched)
716 if count:
717 xs = _mid_ranks([entry[1] for entry in matched], used_direction)
718 ys = _mid_ranks([entry[2] for entry in matched], used_direction)
719 sum_x = sum(xs)
720 sum_y = sum(ys)
721 xy = count * sum(x * y for x, y in zip(xs, ys)) - sum_x * sum_y
722 xx = count * sum(x * x for x in xs) - sum_x * sum_x
723 yy = count * sum(y * y for y in ys) - sum_y * sum_y
724 terms = {"xy": _pair(xy), "xx": _pair(xx), "yy": _pair(yy)}
725 ranks = {"x": [_pair(x) for x in xs], "y": [_pair(y) for y in ys]}
726 if count <= 2:
727 spearman = None
728 undefined = "fewer_than_three_matched"
729 elif xx == 0:
730 spearman = None
731 undefined = "constant_selection_ranks"
732 elif yy == 0:
733 spearman = None
734 undefined = "constant_holdout_ranks"
735 else:
736 root = math.sqrt(float(Fraction(xy * xy, xx * yy)))
737 spearman = root if xy > 0 else (-root if xy < 0 else 0.0)
738 undefined = None
739 else:
740 terms = {"xy": None, "xx": None, "yy": None}
741 ranks = {"x": None, "y": None}
742 spearman = None
743 undefined = "no_matched_vectors"
744 better = sum(1 for mid, _, _ in matched if mid <= cut)
745 holdout_only = []
746 for key, (only_trial_id, _) in vectors[1].items():
747 if key not in vectors[0]:
748 holdout_only.append(only_trial_id)
749 holdout_only.sort()
750 documents = [
751 _source_document(
752 selection_source,
753 excluded[0],
754 len(eligible[0]),
755 n_sel,
756 len(ancestors[0]),
757 ),
758 _source_document(
759 holdout_source,
760 excluded[1],
761 len(eligible[1]),
762 n_hol,
763 len(ancestors[1]),
764 ),
765 ]
766 return {
767 "contract": CONTRACT,
768 "status": "ok",
769 "metric": metric,
770 "direction": used_direction,
771 "k": k,
772 "k_followed": k_followed,
773 "n_sel": n_sel,
774 "n_hol": n_hol,
775 "selection": documents[0],
776 "holdout": documents[1],
777 "followed": followed,
778 "unmatched": unmatched,
779 "n_matched": count,
780 "pool": {
781 "size": len(pool),
782 "median_rank_cut": _pair(cut),
783 "holdout_only": {"count": len(holdout_only), "ids": holdout_only},
784 },
785 "statistics": {
786 "spearman": spearman,
787 "spearman_terms": terms,
788 "spearman_ranks": ranks,
789 "spearman_undefined": undefined,
790 "better_half": {
791 "numerator": better,
792 "denominator": count,
793 "value": better / count if count else None,
794 },
795 },
796 "algorithm": ALGORITHM,
797 "input_sha256": digest,
798 "admission": {
799 "rows_read": rows_read,
800 "selection_pairs": selection_pairs,
801 "holdout_pairs": holdout_pairs,
802 "work": work,
803 "max_work": max_work,
804 },
805 "interpretation": INTERPRETATION,
806 }
None __init__(self, str code, str detail, Iterable ids=(), Iterable names=())
Definition holdout.py:206
dict holdout_compare(Any selection, Any holdout, *Any metric, Any direction, Any k, Any space=None, Any max_work=None)
Definition holdout.py:510