PineForge HPO 1.11.0
Native hyperparameter optimization for PineForge strategies
Loading...
Searching...
No Matches
_rows.py
Go to the documentation of this file.
1"""Shared reader for finished HPO trial rows.
2
3One reader serves the post-hoc analysis modules. A source is a complete result
4object, a rows JSONL file or a list of rows, and every row is checked the way
5``continuation`` checks warm-start rows. Each refusal is a ``RowsError`` whose
6``kind`` names the rule that failed. Nothing here evaluates a trial, writes a
7file or keeps state: the only side effect is reading the one file named to
8``read_source``.
9"""
10
11from __future__ import annotations
12
13import copy
14from dataclasses import dataclass
15from itertools import chain
16import json
17import math
18import os
19from typing import Any, Iterable
20
21from .continuation import (
22 _dump,
23 _scalar,
24 canonical_space as _canonical_space,
25 recorded_space,
26)
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44KINDS = (
45 "input_invalid",
46 "rows_incomplete",
47 "space_missing",
48 "space_mismatch",
49 "context_mismatch",
50)
51STATUSES = (
52 "ok",
53 "constraint_violation",
54 "objective_error",
55 "constraint_error",
56 "engine_error",
57 "trial_error",
58 "trial_timeout",
59 "pruned",
60 "partial",
61)
62
63
64
65
66
67
68
69
70
71
72
73class RowsError(Exception):
74 """A rows source cannot be read or compared; ``kind`` names the refused rule.
75
76 ``ids`` holds offending trial ids, or (source_index, trial_id) pairs when the
77 rows may come from several sources, ascending; ``names`` holds offending
78 dimension, metric or field names. Either may be empty.
79 """
80
81 kind: str
82 detail: str
83 ids: tuple
84 names: tuple
85
87 self,
88 kind: str,
89 detail: str,
90 ids: Iterable = (),
91 names: Iterable = (),
92 ) -> None:
93 if kind not in KINDS:
94 raise ValueError(f"unknown rows error kind: {kind!r}")
95 super().__init__(f"{kind}: {detail}")
96 self.kindkind = kind
97 self.detaildetail = detail
98 self.idsids = tuple(sorted(ids))
99 self.namesnames = tuple(names)
100
101 def __reduce__(self):
102 return (type(self), (self.kindkind, self.detaildetail, self.idsids, self.namesnames))
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
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
178def loads_strict(text: str) -> Any:
179 """``json.loads`` refusing duplicate keys, NaN/Infinity tokens and overflow.
180
181 Float tokens that overflow to infinity, and integer tokens too large for a
182 binary64, raise ``RowsError("input_invalid")`` like every other defect.
183 """
184 if isinstance(text, (bytes, bytearray)):
185 try:
186 text = bytes(text).decode("utf-8")
187 except UnicodeDecodeError:
188 raise RowsError("input_invalid", "the text is not valid UTF-8") from None
189 if not isinstance(text, str):
190 raise RowsError("input_invalid", "the text to parse must be a string")
191 return _parse(text)
192
193
194@dataclass(frozen=True)
195class Row:
196 """One validated trial row; ``raw`` is the parsed object it came from.
197
198 ``parameters``, ``metrics``, ``backtest`` and ``space`` are None when the row
199 has no such key. Treat every dict as read-only.
200 """
201
202 source_index: int
203 trial_id: int
204 status: str
205 feasible: bool
206 objective: float | None
207 parameters: dict | None
208 metrics: dict | None
209 backtest: dict | None
210 space_hash: str | None
211 space: dict | None
212 raw: dict
213
214
215@dataclass(frozen=True)
216class Source:
217 """The rows of one result or rows file, in file order, read-only.
218
219 ``space`` is the result's own recorded space (None for a rows-only source);
220 ``direction`` is ``maximize`` or ``minimize`` when the result or a space
221 records it, else None.
222 """
223
224 index: int
225 rows: tuple
226 space: dict | None
227 direction: str | None
228
229
230
231
232
233
234def validate_row(raw: dict, source_index: int) -> Row:
235 """Check one row object (continuation.py:352-371) and return it as a Row."""
236 if type(source_index) is not int or source_index < 0:
237 raise RowsError(
238 "input_invalid", "the source index must be a non-negative integer"
239 )
240 if not isinstance(raw, dict):
241 raise RowsError("input_invalid", "a row must be a JSON object")
242 identifier = raw.get("trial_id")
243 ids = (identifier,) if type(identifier) is int else ()
244
245 def refuse(detail: str) -> RowsError:
246 return RowsError("input_invalid", detail, ids)
247
248 if type(identifier) is not int or not 0 <= identifier < _ID_LIMIT:
249 raise refuse("trial_id must be an integer from 0 to 2^64 - 2")
250 status = raw.get("status")
251 if type(status) is not str or status not in STATUSES:
252 raise refuse(f"unknown trial status {_clip(repr(status))}")
253 feasible = raw.get("feasible")
254 if type(feasible) is not bool or feasible != (status == "ok"):
255 raise refuse("feasible must be a boolean equal to (status is ok)")
256 if "objective" not in raw:
257 raise refuse("the row has no objective")
258 value = raw["objective"]
259 objective = None
260 if value is not None:
261 if type(value) not in (int, float):
262 raise refuse("objective must be a finite number or null")
263 try:
264 objective = float(value)
265 except OverflowError:
266 raise refuse("objective must be a finite number or null") from None
267 if not math.isfinite(objective):
268 raise refuse("objective must be a finite number or null")
269 if objective is None and status in ("ok", "constraint_violation"):
270 raise refuse(f"a {status} row needs a finite objective")
271 members = {}
272 for key in ("parameters", "metrics", "backtest", "space"):
273 if key in raw and not isinstance(raw[key], dict):
274 raise refuse(f"{key} must be an object when it is present")
275 members[key] = raw.get(key)
276 space_hash = raw.get("space_hash")
277 if "space_hash" in raw and type(space_hash) is not str:
278 raise refuse("space_hash must be a string when it is present")
279 return Row(
280 source_index,
281 identifier,
282 status,
283 feasible,
284 objective,
285 members["parameters"],
286 members["metrics"],
287 members["backtest"],
288 space_hash,
289 members["space"],
290 raw,
291 )
292
293
294def check_unique_ids(rows: Iterable[Row]) -> None:
295 """Refuse a trial id that repeats inside one source (continuation.py:358-359).
296
297 Rows are keyed by (source_index, trial_id), so rows of several sources may be
298 passed together; the repeated ids are plain trial ids when every row belongs
299 to one source and (source_index, trial_id) pairs otherwise.
300 """
301 seen: set = set()
302 repeated: set = set()
303 sources: set = set()
304 for row in rows:
305 key = _row_key(row)
306 sources.add(row.source_index)
307 if key in seen:
308 repeated.add(key)
309 seen.add(key)
310 if repeated:
311 ids = (
312 [trial_id for _, trial_id in repeated]
313 if len(sources) == 1
314 else list(repeated)
315 )
316 raise RowsError("input_invalid", "a trial id repeats inside one source", ids)
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
493def read_source(source: Any, index: int = 0) -> Source:
494 """Read one source into a Source whose rows are validated and in file order.
495
496 ``source`` is a path to a result JSON or a rows JSONL, an already-parsed
497 result dict, or an already-parsed list of rows. A result must keep every
498 trial (``trials_out`` all or absent) and its counts must agree with its rows,
499 else ``rows_incomplete``. A JSONL file holds one row object per non-blank line;
500 a bad line is refused with its 1-based line number. A source with no rows is
501 returned empty. Rows from several sources are told apart by ``index``.
502 """
503 if type(index) is not int or index < 0:
504 raise RowsError(
505 "input_invalid", "the source index must be a non-negative integer"
506 )
507 if isinstance(source, dict):
508 return _result_source(source, index)
509 if isinstance(source, list):
510 return _array_source(source, index)
511 if isinstance(source, (str, os.PathLike)):
512 return _file_source(source, index)
513 raise RowsError(
514 "input_invalid", "a source must be a path, a result object or a list of rows"
515 )
516
517
518def canonical_space(space: dict) -> str:
519 """Canonical v1 JSON of a recorded space (the form of continuation.py:143-181)."""
520 try:
521 return _canonical_space(space)
522 except (ValueError, KeyError, TypeError, AttributeError, OverflowError) as error:
523 raise RowsError(
524 "input_invalid",
525 f"the recorded space is malformed ({_clip(type(error).__name__)}: {_clip(error)})",
526 ) from None
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552def common_space(sources: Iterable[Source], passed_space: Any = None) -> dict:
553 """Return the one recorded space shared by every row and result of ``sources``.
554
555 A row's own ``space`` and a result's ``space`` must all have the same canonical
556 form (which includes the objective, its direction and its constraints), else
557 ``space_mismatch`` naming the dimensions that differ and the offending rows.
558 When nothing records a space, ``passed_space`` (a recorded-space dict or a
559 StudySpec) is used; with none passed the call is refused as ``space_missing``.
560 A passed space is not compared with recorded ones. Rows or sources that record
561 no space are not checked against it.
562 """
563 holders = []
564 for source in sources:
565 if source.space is not None:
566 holders.append((source.index, -1, source.space))
567 holders.extend(
568 (row.source_index, row.trial_id, row.space)
569 for row in source.rows
570 if row.space is not None
571 )
572 if not holders:
573 if passed_space is None:
574 raise RowsError(
575 "space_missing",
576 "no row or result records its space and none was passed",
577 )
578 space = _passed(passed_space)
579 canonical_space(space)
580 return copy.deepcopy(space)
581 forms: dict = {}
582
583 def form(space: Any) -> str:
584 try:
585 text = json.dumps(space, sort_keys=True)
586 except (TypeError, ValueError):
587 return canonical_space(space)
588 if text not in forms:
589 forms[text] = canonical_space(space)
590 return forms[text]
591
592 reference = min(holders, key=lambda holder: holder[:2])
593 expected = form(reference[2])
594 apart = [holder for holder in holders if form(holder[2]) != expected]
595 if apart:
596 involved = sorted({reference[0], *(holder[0] for holder in apart)})
597 raise RowsError(
598 "space_mismatch",
599 "rows or results record different spaces "
600 f"(sources {', '.join(str(number) for number in involved)})",
601 [holder[:2] for holder in apart if holder[1] >= 0],
602 _differing_names(expected, {form(holder[2]) for holder in apart}),
603 )
604 return copy.deepcopy(reference[2])
605
606
607
608
609
610
611def check_context(rows: Iterable[Row]) -> None:
612 """Refuse rows whose scoring context visibly differs (necessary, not sufficient).
613
614 Among rows with status ``ok`` that carry a ``backtest`` object, the tuple
615 (input_bars_processed, script_bars_processed, input_tf_seconds,
616 script_tf_seconds) must be equal; every row that carries a ``space_hash`` must
617 carry the same one. Equal counters and hashes do not prove the same data,
618 window, fixed inputs or strategy (study-spec.md:1018-1020). The refusal names
619 the rows as (source_index, trial_id) pairs, starting from the lowest pair.
620 """
621 rows = list(rows)
622 scored = [
623 (row, _counters(row))
624 for row in rows
625 if row.status == "ok" and row.backtest is not None
626 ]
627 if scored:
628 reference, expected = min(scored, key=lambda pair: _row_key(pair[0]))
629 apart = [row for row, counters in scored if counters != expected]
630 if apart:
631 differing = [
632 name
633 for position, name in enumerate(_COUNTERS)
634 if any(
635 counters[position] != expected[position] for _, counters in scored
636 )
637 ]
638 raise RowsError(
639 "context_mismatch",
640 "scored rows disagree on their backtest bar and timeframe counters",
641 [_row_key(row) for row in [reference, *apart]],
642 sorted(differing),
643 )
644 hashed = [row for row in rows if row.space_hash is not None]
645 if hashed:
646 reference = min(hashed, key=_row_key)
647 apart = [row for row in hashed if row.space_hash != reference.space_hash]
648 if apart:
649 raise RowsError(
650 "context_mismatch",
651 "rows record different space_hash values",
652 [_row_key(row) for row in [reference, *apart]],
653 ("space_hash",),
654 )
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693def vector_key(parameters: dict, space: dict) -> str:
694 """Canonical JSON key of a parameter vector (continuation.py:112-140, 363).
695
696 Each value becomes a typed scalar by its dimension's kind in ``space``; reals
697 are binary64 hex with -0.0 taken as +0.0 and an int in a real dimension taken
698 as the same real. Equality is exact: no tolerance and no lattice snapping.
699 """
700 dimensions = space.get("parameters") if isinstance(space, dict) else None
701 if not isinstance(dimensions, dict):
702 raise RowsError("input_invalid", "the space has no parameters object")
703 if not isinstance(parameters, dict) or parameters.keys() != dimensions.keys():
704 given = set(parameters) if isinstance(parameters, dict) else set()
705 raise RowsError(
706 "input_invalid",
707 "the parameter names differ from the space dimensions",
708 names=sorted(given ^ set(dimensions)),
709 )
710 return _dump(
711 {
712 name: _typed(name, parameters[name], dimension)
713 for name, dimension in dimensions.items()
714 }
715 )
None __init__(self, str kind, str detail, Iterable ids=(), Iterable names=())
Definition _rows.py:92
Row validate_row(dict raw, int source_index)
Definition _rows.py:234
None check_unique_ids(Iterable[Row] rows)
Definition _rows.py:294