PineForge HPO 1.11.0
Native hyperparameter optimization for PineForge strategies
Loading...
Searching...
No Matches
walkforward.py
Go to the documentation of this file.
1"""Post hoc walk-forward analysis of finished per-window studies.
2
3``walkforward(windows, sources, *, metric, direction, space=None, max_work=None)``
4reads the caller's ordered plan and one finished study per supplied window. It runs
5no window, evaluates nothing, writes no file, keeps no state and changes no native
6result. v1 analyses finished studies; native in-loop walk-forward comes later.
7
8Contract ``pineforge-hpo-walkforward/v1``, algorithm ``exact_per_window_v1``:
9
10Only ``ok`` rows with finite metric cells compete. Other statuses are excluded as
11``status_<status>``, absent cells as ``metric_not_recorded`` and null cells as
12``metric_unavailable``, in that order. An invalid cell on an ok row, or a non-finite
13or JSON-unwritable cell on any row, or an integer too large for binary64 on any
14row, refuses the call. Numeric cells are binary64;
15booleans are not numbers. The metric is ``objective`` or an exact recorded name.
16
17Selection minimises ``(sign * value, trial_id)``, with sign -1.0 for maximize and
18+1.0 for minimize. A supplied window with no eligible row has no selection; none
19anywhere refuses. Missing sources appear in ``never_run`` in plan order. Gaps
20between consecutive supplied windows appear in ``chain_breaks``.
21
22For adjacent supplied windows whose first has a selection, its exact parameter
23key is looked up in the target's rows. Every such pair appears, including a target
24without eligible rows. Eligible matches must have one value; unequal values refuse,
25otherwise the lowest id is reported with ``row_found`` true. Its mid-rank is
26``better + (equal + 1) / 2`` among target eligible rows. Without an eligible match,
27``row_found`` is false, numeric fields are null, and the reason is that of the
28lowest-id matching row, or ``no_row``. When ``row_found`` is true, parameters belong
29to the matching row; when false, they are the source window's selected parameters
30(the vector looked up). No value is inferred.
31
32Stability lists selected parameters as recorded, in plan order, with distinct
33typed-scalar counts and exact numeric minimum and maximum for both integer and real
34dimensions, keeping the first recorded value on equality. Boolean and categorical
35extremes are null. Real identity equates integer and real spellings and signed zeros.
36There is no tolerance, averaging, interpolation, threshold or robustness verdict.
37
38The shared reader checks each supplied study and its context separately. Recorded
39spaces and row space hashes must agree across windows; bar and timeframe counters
40are compared only inside a window. Top-level result hashes are not compared. For
41objective, the recorded common space's direction is checked first, then source
42directions; a passed space's direction is not compared.
43Hash-mismatch ``ids`` ascend: one id per differing row, so an id that occurs in
44several windows is listed once for each.
45
46Refusals raise ``WalkforwardError`` with its refused ``document()``. The first
47defect decides: missing options before invalid options; unknown labels; no source;
48reading and each window's context; recorded spaces then row hashes; direction;
49metric name; cells; every row's parameters; carried duplicates; no eligible row;
50admission figure. The figure is rows read + sum e(e-1)/2 + sum target eligible
51counts over carry-over pairs, bounded only by a caller-supplied ``max_work``.
52
53The document and digest ignore row and mapping insertion order. The digest is
54SHA-256 of sorted raw per-row SHA-256 digests of compact, key-sorted ASCII JSON
55``[window label, trial_id, status, cell]``. It covers every status, normalises numeric
56cells to binary64, writes absent/null as null, and preserves other JSON cells on
57excluded rows. It omits metric name, direction and plan position. Listings follow
58the plan, dimensions follow code-point name order, exclusion status reasons precede the
59missing and null metric reasons, and ids ascend. ``interpretation`` is fixed.
60"""
61
62from __future__ import annotations
63
64from collections.abc import Mapping
65import copy
66from dataclasses import dataclass
67import hashlib
68import json
69import math
70from typing import Any, Iterable
71
72from ._rows import (
73 Row,
74 RowsError,
75 Source,
76 canonical_space,
77 check_context,
78 common_space,
79 read_source,
80 vector_key,
81)
82from .study_spec import StudySpec
83
84
85
86
87
88
89
90
91
92
93
94CONTRACT = "pineforge-hpo-walkforward/v1"
95ALGORITHM = "exact_per_window_v1"
96DIRECTIONS = ("maximize", "minimize")
97CODES = (
98 "walkforward_parameter_missing",
99 "walkforward_parameter_invalid",
100 "walkforward_window_unknown",
101 "walkforward_no_supplied_sources",
102 "walkforward_input_invalid",
103 "walkforward_rows_incomplete",
104 "walkforward_space_missing",
105 "walkforward_space_mismatch",
106 "walkforward_context_mismatch",
107 "walkforward_direction_mismatch",
108 "walkforward_metric_unknown",
109 "walkforward_duplicate_vector",
110 "walkforward_no_eligible_rows",
111 "walkforward_resource_exceeded",
112)
113
114INTERPRETATION = (
115 "(a) This module runs no window and evaluates nothing. The studies were run by "
116 "the caller's own application; this module reads rows that already exist.\n"
117 "(b) No claim is made about what data any window's study used. The context checks "
118 "are necessary, not sufficient: equal bar counters, one shared space and one "
119 "shared space hash do not prove the same data, the same fixed inputs or the same "
120 "strategy, and the windows may overlap, touch or be separated.\n"
121 "(c) Each window's selected value is in-sample for that window: it is the best "
122 "scored row of that window's rows.\n"
123 "(d) A carry-over value is a row that the user's own study recorded, for the "
124 "vector this contract's rule selects in the previous window, and nothing more. "
125 "No value here is claimed to be out of sample. When the previous window has a "
126 "selected vector the pair is always reported, and a target window that holds no "
127 "eligible row with that vector is reported with row_found false and a reason: "
128 "that is not a zero and not a drop.\n"
129 "(e) The number of windows is small. The stability table is a summary of the "
130 "selected values in window order; it is not a trajectory, not a robustness "
131 "measure, and no threshold and no verdict is produced.\n"
132 "(f) v1 analyses finished studies; native in-loop walk-forward comes later."
133)
134
135
136class WalkforwardError(Exception):
137 """A contract refusal, with ascending trial ``ids`` and ordered ``names``."""
138
140 self, code: str, detail: str, ids: Iterable = (), names: Iterable = ()
141 ) -> None:
142 if code not in CODES:
143 raise ValueError(f"unknown walkforward refusal code: {code!r}")
144 super().__init__(f"{code}: {detail}")
145 self.code = code
146 self.detail = detail
147 self.ids = tuple(sorted(ids))
148 self.names = tuple(names)
149
150 def __reduce__(self):
151 return (type(self), (self.code, self.detail, self.ids, self.names))
152
153 def document(self) -> dict:
154 """Render the refused document; a refused call returns no analysis."""
155 return {
156 "contract": CONTRACT,
157 "status": "refused",
158 "refusal": {
159 "code": self.code,
160 "detail": self.detail,
161 "ids": list(self.ids),
162 "names": list(self.names),
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
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
472def walkforward(
473 windows: Any,
474 sources: Any,
475 *,
476 metric: str,
477 direction: str,
478 space: Any = None,
479 max_work: int | None = None,
480) -> dict:
481 """Summarise finished studies in plan order; raise ``WalkforwardError`` on refusal.
482
483 ``windows`` is a non-empty list or tuple of distinct non-empty labels, and
484 ``sources`` maps supplied labels to complete results, rows lists or file paths.
485 ``metric`` and ``direction`` are required; there is no inferred direction.
486 ``space`` is used only when no source records one. ``max_work`` bounds the
487 declared admission figure, with equality admitted and None unbounded.
488 """
489 _options(windows, sources, metric, direction, space, max_work)
490 windows = list(windows)
491 unknown = [label for label in sources if label not in windows]
492 if unknown:
493 raise WalkforwardError(
494 "walkforward_window_unknown",
495 "sources holds labels absent from the plan",
496 names=sorted(unknown, key=lambda label: (str(label), type(label).__name__)),
497 )
498 supplied = [label for label in windows if label in sources]
499 if not supplied:
500 raise WalkforwardError(
501 "walkforward_no_supplied_sources", "no plan window has a supplied source"
502 )
503 loaded = []
504 for index, label in enumerate(windows):
505 if label not in sources:
506 continue
507 try:
508 source = read_source(sources[label], index)
509 check_context(source.rows)
510 except RowsError as error:
511 raise _reader_error(error, (label,)) from None
512 loaded.append(source)
513 shared, recorded = _space(loaded, windows, space)
514 _direction(shared, recorded, loaded, windows, metric, direction)
515 rows_read = sum(len(source.rows) for source in loaded)
516 if (
517 rows_read
518 and metric != "objective"
519 and not any(
520 row.metrics is not None and metric in row.metrics
521 for source in loaded
522 for row in source.rows
523 )
524 ):
525 raise WalkforwardError(
526 "walkforward_metric_unknown",
527 "the metric name is recorded in no row's metrics object",
528 names=(metric,),
529 )
530 digests = []
531 states = [
532 _cells(source, windows[source.index], metric, digests) for source in loaded
533 ]
534 for state in states:
535 for entry in state.entries:
536 try:
537 entry.key = vector_key(entry.row.parameters, shared)
538 except RowsError as error:
539 raise WalkforwardError(
540 "walkforward_input_invalid",
541 error.detail,
542 (entry.row.trial_id,),
543 (state.label, *error.names),
544 ) from None
545 sign = -1.0 if direction == "maximize" else 1.0
546 for state in states:
547 if state.eligible:
548 state.selected = min(
549 state.eligible,
550 key=lambda entry: (sign * entry.value, entry.row.trial_id),
551 )
552 pairs = []
553 breaks = []
554 for source, target in zip(states, states[1:]):
555 if target.index != source.index + 1:
556 breaks.append(
557 {
558 "from": source.label,
559 "to": target.label,
560 "skipped": windows[source.index + 1 : target.index],
561 }
562 )
563 elif source.selected is not None:
564 pairs.append((source, target))
565 for source, target in pairs:
566 eligible = [entry for entry in _matches(source, target) if entry.reason is None]
567 if eligible and any(entry.value != eligible[0].value for entry in eligible):
568 raise WalkforwardError(
569 "walkforward_duplicate_vector",
570 "eligible rows carrying the selected vector record different values",
571 [entry.row.trial_id for entry in eligible],
572 (target.label,),
573 )
574 rows_eligible = sum(len(state.eligible) for state in states)
575 if not rows_eligible:
576 raise WalkforwardError(
577 "walkforward_no_eligible_rows", "no supplied window holds an eligible row"
578 )
579 work = (
580 rows_read
581 + sum(len(state.eligible) * (len(state.eligible) - 1) // 2 for state in states)
582 + sum(len(target.eligible) for _, target in pairs)
583 )
584 if max_work is not None and work > max_work:
585 raise WalkforwardError(
586 "walkforward_resource_exceeded",
587 f"the admission figure {work} is above max_work {max_work}",
588 )
589 return {
590 "contract": CONTRACT,
591 "status": "ok",
592 "metric": metric,
593 "direction": direction,
594 "windows": windows,
595 "supplied": supplied,
596 "never_run": [label for label in windows if label not in sources],
597 "rows_read": rows_read,
598 "rows_eligible": rows_eligible,
599 "rows_excluded": rows_read - rows_eligible,
600 "per_window": [_per_window(state) for state in states],
601 "carry_over": [_carry(source, target, direction) for source, target in pairs],
602 "chain_breaks": breaks,
603 "stability": _stability(states, shared),
604 "work": work,
605 "algorithm": ALGORITHM,
606 "input_sha256": hashlib.sha256(b"".join(sorted(digests))).hexdigest(),
607 "interpretation": INTERPRETATION,
608 }
None __init__(self, str code, str detail, Iterable ids=(), Iterable names=())