PineForge HPO 1.11.0
Native hyperparameter optimization for PineForge strategies
Loading...
Searching...
No Matches
neighbourhood.py
Go to the documentation of this file.
1"""Neighbourhood stability of finished trials, read after the fact (version 1).
2
3A candidate's neighbours are the parameter vectors one move away from it on a
4single dimension: one lattice step, the other values, a fraction of the range
5or a ratio, by the kind of the dimension. The module looks the neighbours up in
6the rows it is given and reports how many of them score near the best row. It
7evaluates nothing and starts nothing: a neighbour that has no row is returned
8as a missing vector, and the caller may evaluate it and call again.
9
10Rows are read by ``_rows`` and every row is checked against the recorded space.
11Every refusal is a ``NeighbourhoodError`` with a module-local code and no
12number. The result document is a pure function of the rows, the space, the
13centres and the caller's rules: any row order gives the same bytes when it is
14written as JSON with sorted keys. See ``docs/neighbourhood.md``.
15"""
16
17from __future__ import annotations
18
19from dataclasses import dataclass
20from fractions import Fraction
21import hashlib
22import json
23import math
24from typing import Any, NamedTuple
25
26from ._rows import (
27 Row,
28 RowsError,
29 Source,
30 check_context,
31 common_space,
32 read_source,
33 vector_key,
34)
35from .study_spec import ParameterSpec, _finite_parameter_cardinality, _fused_grid_value
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57CONTRACT = "pineforge-hpo-neighbourhood/v1"
58
59CODES = (
60 "neighbourhood_input_invalid",
61 "neighbourhood_rows_incomplete",
62 "neighbourhood_space_missing",
63 "neighbourhood_space_mismatch",
64 "neighbourhood_context_mismatch",
65 "neighbourhood_parameter_missing",
66 "neighbourhood_parameter_invalid",
67 "neighbourhood_band_degenerate",
68 "neighbourhood_centre_invalid",
69 "neighbourhood_no_varying_dimension",
70 "neighbourhood_no_feasible_rows",
71 "neighbourhood_duplicate_conflict",
72 "neighbourhood_resource_exceeded",
73)
74
75SCORED_OK = "scored_ok"
76SCORED_INFEASIBLE = "scored_infeasible"
77UNSCORABLE = "unscorable"
78NEVER_EVALUATED = "never_evaluated"
79CLASSES = (SCORED_OK, SCORED_INFEASIBLE, UNSCORABLE, NEVER_EVALUATED)
80
81INTERPRETATION = (
82 "This describes the trials that were supplied and nothing else. "
83 "It states no significance, confidence or probability. "
84 "A neighbour that was never evaluated is not imputed, and nothing is said "
85 "about it. "
86 "The reference value is the best of many trials, so it is optimistic. "
87 "With an adaptive sampler the neighbours that exist are not a random draw: "
88 "the share near the best describes its stated neighbourhood only when "
89 "coverage is 1. "
90 "Every number is in-sample. "
91 "Constraints, relations between parameters and whether two runs used the "
92 "same data are not checked."
93)
94
95
96
97
98
99
100class NeighbourhoodError(Exception):
101 """A refusal of the neighbourhood analysis; ``code`` names the refused rule.
102
103 ``ids`` holds the offending rows as (source_index, trial_id) pairs,
104 ascending; ``names`` holds offending dimension or parameter names. Either may
105 be empty. No number of the analysis accompanies a refusal.
106 """
107
108 code: str
109 detail: str
110 ids: tuple
111 names: tuple
112
114 self,
115 code: str,
116 detail: str,
117 ids: Any = (),
118 names: Any = (),
119 ) -> None:
120 if code not in CODES:
121 raise ValueError(f"unknown neighbourhood error code: {code!r}")
122 super().__init__(f"{code}: {detail}")
123 self.codecode = code
124 self.detaildetail = detail
125 self.idsids = tuple(sorted(tuple(item) for item in ids))
126 self.namesnames = tuple(names)
127
128 def __reduce__(self):
129 return (type(self), (self.codecode, self.detaildetail, self.idsids, self.namesnames))
130
131 def document(self) -> dict:
132 """The refusal as a JSON-ready document that carries no number."""
133 return {
134 "contract": CONTRACT,
135 "status": "refused",
136 "code": self.codecode,
137 "detail": self.detaildetail,
138 "ids": [list(item) for item in self.idsids],
139 "names": list(self.namesnames),
140 }
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160def name_key(name: str) -> bytes:
161 """Sort key of a dimension name: its UTF-8 bytes (byte-wise name order)."""
162 return name.encode("utf-8", "surrogatepass")
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180class Moves(NamedTuple):
181 """The neighbours of one value on one dimension, lower first, and the drops.
182
183 ``bounds`` counts candidate moves outside the bounds or beyond the last
184 lattice ordinal; ``same_as_centre`` counts moves that land on the value
185 itself (a log integer rounding back, or a real step too small to move).
186 """
187
188 values: tuple
189 bounds: int
190 same_as_centre: int
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206@dataclass(frozen=True)
208 """One search dimension of the recorded space, with its neighbour rule.
209
210 ``rule`` names how a value moves: ``step`` (integer lattice), ``ratio_rounded``
211 (log integer), ``ordinal`` (stepped real), ``fraction`` (continuous real),
212 ``ratio`` (log real), ``other_value`` (boolean), ``other_choices``
213 (categorical) or ``constant`` (exactly one legal value, no neighbours).
214 ``last`` is the last lattice ordinal of a stepped real.
215 """
216
217 name: str
218 kind: str
219 rule: str
220 low: Any = None
221 high: Any = None
222 step: Any = None
223 log: bool = False
224 choices: tuple = ()
225 last: int | None = None
226
227 @property
228 def constant(self) -> bool:
229 return self.rulerule == "constant"
230
231 def decode(self, ordinal: int) -> float:
232 """Value of a stepped real at ``ordinal``: one fused multiply-add.
233
234 The last ordinal snaps to ``high`` when the product is above it (the
235 study spec lets it exceed ``high`` by one representable step).
236 """
237 value = _fused_grid_value(ordinal, self.stepstep, self.lowlow)
238 if ordinal == self.last and value > self.highhigh:
239 return self.highhigh
240 return value
241
242 def ordinal(self, value: float) -> int:
243 """Nearest lattice ordinal of a stepped-real value (continuation.py:228)."""
244 return round((value - self.lowlow) / self.stepstep)
245
246 def normalise(self, value: Any) -> Any:
247 """The value as this dimension holds it; ValueError when it does not fit.
248
249 The checks are those of the continuation loader for one parameter. A
250 stepped real within four units in the last place of a lattice point is
251 taken as that point, and -0.0 is taken as 0.0.
252 """
253 if self.kindkind == "integer":
254 if (
255 type(value) is int
256 and self.lowlow <= value <= self.highhigh
257 and (value - self.lowlow) % self.stepstep == 0
258 ):
259 return value
260 elif self.kindkind == "real":
261 if type(value) in (int, float):
262 number = float(value)
263 if self.lowlow <= number <= self.highhigh:
264 if self.stepstep is None:
265 return number + 0.0
266 ordinal = self.ordinal(number)
267 if 0 <= ordinal <= self.last:
268 decoded = self.decode(ordinal)
269 slack = max(math.ulp(number), math.ulp(decoded)) * 4
270 if abs(decoded - number) <= slack:
271 return decoded + 0.0
272 elif self.kindkind == "boolean":
273 if type(value) is bool:
274 return value
275 else:
276 for choice in self.choiceschoices:
277 if type(value) is type(choice) and value == choice:
278 return choice
279 if type(value) is int:
280 for choice in self.choiceschoices:
281 if type(choice) is float and float(value) == choice:
282 return choice
283 raise ValueError("the value does not fit the dimension")
284
286 self,
287 value: Any,
288 fraction: float | None = None,
289 ratio: float | None = None,
290 ) -> Moves:
291 """The values one move away from ``value`` and the moves that were dropped.
292
293 ``value`` must be a normalised value of this dimension. Order: lower
294 first; a categorical keeps its declared order. A rule that needs
295 ``fraction`` or ``ratio`` raises ValueError when it is None. No log,
296 exp or pow function is called: log integers use exact rational
297 arithmetic, log reals use one binary64 multiplication or division.
298 """
299 rule = self.rulerule
300 if rule == "constant":
301 return Moves((), 0, 0)
302 if rule == "step":
303 return _within(
304 (value - self.stepstep, value + self.stepstep), value, self.lowlow, self.highhigh
305 )
306 if rule == "ordinal":
307 ordinal = self.ordinal(value)
308 kept = []
309 bounds = same = 0
310 for target in (ordinal - 1, ordinal + 1):
311 if not 0 <= target <= self.last:
312 bounds += 1
313 continue
314 candidate = self.decode(target)
315 if candidate == value:
316 same += 1
317 else:
318 kept.append(candidate + 0.0)
319 return Moves(tuple(kept), bounds, same)
320 if rule == "fraction":
321 if fraction is None:
322 raise ValueError("this dimension needs a fraction")
323 delta = fraction * (self.highhigh - self.lowlow)
324 return _within((value - delta, value + delta), value, self.lowlow, self.highhigh)
325 if rule == "ratio":
326 if ratio is None:
327 raise ValueError("this dimension needs a ratio")
328 return _within((value / ratio, value * ratio), value, self.lowlow, self.highhigh)
329 if rule == "ratio_rounded":
330 if ratio is None:
331 raise ValueError("this dimension needs a ratio")
332 exact = Fraction(ratio)
333 half = Fraction(1, 2)
334 lower = math.floor(Fraction(value) / exact + half)
335 upper = math.floor(Fraction(value) * exact + half)
336 return _within((lower, upper), value, self.lowlow, self.highhigh)
337 if rule == "other_value":
338 return Moves((not value,), 0, 0)
339 others = tuple(
340 choice
341 for choice in self.choiceschoices
342 if not (type(choice) is type(value) and choice == value)
343 )
344 return Moves(others, 0, 0)
345
346 def rank(self, value: Any) -> Any:
347 """A sortable position of a value inside this dimension's own order."""
348 if self.kindkind == "boolean":
349 return int(value)
350 if self.kindkind == "categorical":
351 for position, choice in enumerate(self.choiceschoices):
352 if type(value) is type(choice) and value == choice:
353 return position
354 raise ValueError("the value is not a choice of the dimension")
355 return value
356
357 def sort_values(self, values: Any) -> list:
358 """The values in this dimension's order: ascending, or declared order."""
359 return sorted(values, key=self.rank)
360
361
362def dimension(name: str, spec: Any) -> Dimension:
363 """Build the dimension called ``name`` from its recorded-space entry.
364
365 The entry is checked the way the study spec checks a dimension: integer
366 bounds and a positive integer step with ``low <= high``; real bounds with an
367 optional positive step; a log dimension has positive bounds, and a log
368 integer step 1 while a log real has no step; a stepped real must have at
369 most 2^53 ordinals. A dimension with exactly one legal value is constant.
370 A categorical keeps the first of equal choices. Anything else raises
371 ``NeighbourhoodError("neighbourhood_input_invalid")``.
372 """
373
374 def refuse(reason: str) -> NeighbourhoodError:
375 return _refusal(
376 "input_invalid", f"dimension {_clip(name)}: {reason}", names=(name,)
377 )
378
379 if not isinstance(spec, dict):
380 raise refuse("the recorded entry must be an object")
381 kind = spec.get("kind")
382 log = spec.get("log", False)
383 if kind in ("integer", "real") and type(log) is not bool:
384 raise refuse("log must be a boolean")
385 if kind == "integer":
386 low, high, step = spec.get("low"), spec.get("high"), spec.get("step")
387 if type(low) is not int or type(high) is not int:
388 raise refuse("integer bounds must be integers")
389 if type(step) is not int or step < 1:
390 raise refuse("the integer step must be a positive integer")
391 if low > high:
392 raise refuse("low exceeds high")
393 if log and (low < 1 or step != 1):
394 raise refuse("a log integer needs positive bounds and step 1")
395 if (high - low) // step == 0:
396 rule = "constant"
397 else:
398 rule = "ratio_rounded" if log else "step"
399 return Dimension(
400 name=name, kind=kind, rule=rule, low=low, high=high, step=step, log=log
401 )
402 if kind == "real":
403 low, high = _number(spec.get("low")), _number(spec.get("high"))
404 if low is None or high is None:
405 raise refuse("real bounds must be finite numbers")
406 if low > high:
407 raise refuse("low exceeds high")
408 step = spec.get("step")
409 if step is not None:
410 step = _number(step)
411 if step is None or step <= 0:
412 raise refuse("the real step must be a positive finite number")
413 if log and (low <= 0 or step is not None):
414 raise refuse("a log real needs positive bounds and no step")
415 last = None
416 if low == high:
417 rule = "constant"
418 last = None if step is None else 0
419 elif step is None:
420 rule = "ratio" if log else "fraction"
421 else:
422 issues: list = []
423 count = _finite_parameter_cardinality(
424 ParameterSpec(kind="real", low=low, high=high, step=step),
425 name,
426 issues,
427 )
428 if count is None or issues:
429 raise refuse("the stepped real has no usable lattice (over 2^53)")
430 last = count - 1
431 rule = "constant" if last == 0 else "ordinal"
432 return Dimension(
433 name=name,
434 kind=kind,
435 rule=rule,
436 low=low,
437 high=high,
438 step=step,
439 log=log,
440 last=last,
441 )
442 if kind == "boolean":
443 return Dimension(name=name, kind=kind, rule="other_value")
444 if kind == "categorical":
445 listed = spec.get("choices")
446 if not isinstance(listed, list) or not listed:
447 raise refuse("choices must be a non-empty list")
448 choices: list = []
449 seen: set = set()
450 for choice in listed:
451 if type(choice) not in (str, int, float, bool) or (
452 type(choice) is float and not math.isfinite(choice)
453 ):
454 raise refuse("every choice must be a finite scalar")
455 marker = (type(choice), choice)
456 if marker not in seen:
457 seen.add(marker)
458 choices.append(choice)
459 rule = "constant" if len(choices) == 1 else "other_choices"
460 return Dimension(name=name, kind=kind, rule=rule, choices=tuple(choices))
461 raise refuse("the kind must be integer, real, boolean or categorical")
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
493class Study:
494 """The rows of the supplied sources indexed by parameter vector.
495
496 Built by ``prepare``. It holds the common recorded space, one ``Dimension``
497 per parameter in byte-wise name order, the groups of rows that share a
498 vector, the reference value and the input digest. ``bind`` adds the band so
499 that ``cell`` can classify a vector. Treat every attribute as read-only.
500 """
501
502 def __init__(self, sources: list, passed_space: Any = None) -> None:
503 rows = sorted((row for source in sources for row in source.rows), key=_row_id)
504 try:
505 self.space = common_space(sources, passed_space)
506 except RowsError as error:
507 raise _from_rows_error(error) from None
508 self.direction = _direction(self.space, sources)
509 try:
510 check_context(rows)
511 except RowsError as error:
512 raise _from_rows_error(error) from None
513 specs = self.space["parameters"]
514 names = sorted(specs, key=name_key)
515 self.dimensions = {name: dimension(name, specs[name]) for name in names}
516 self.names = tuple(names)
517 self.varying = tuple(n for n in names if not self.dimensions[n].constant)
518 self.constants = tuple(n for n in names if self.dimensions[n].constant)
519 self.rows = tuple(rows)
520 self.row_count = len(rows)
521 self.by_id = {_row_id(row): row for row in rows}
522 self.summaries = [_summary(source) for source in sources]
523 self.groups: dict = {}
524 digests = []
525 for row in rows:
526 key = self.key_of(self.row_values(row))
527 self.groups.setdefault(key, []).append(row)
528 digests.append(_digest(row, key))
529 self.input_sha256 = hashlib.sha256(b"".join(sorted(digests))).hexdigest()
530 self.duplicate_rows = sum(len(group) - 1 for group in self.groups.values())
531 self._check_duplicates()
532 ok_rows = [row for row in rows if row.status == "ok"]
533 if not ok_rows:
534 raise _refusal(
535 "no_feasible_rows",
536 "no row has status ok, so there is no reference value",
537 )
538 pick = max if self.direction == "maximize" else min
539 self.reference = pick(row.objective for row in ok_rows)
540 self.reference_id = min(
541 _row_id(row) for row in ok_rows if row.objective == self.reference
542 )
543 self.band: dict | None = None
544 self.delta: float | None = None
545 self.fraction: float | None = None
546 self.ratio: float | None = None
547
548 def key_of(self, values: dict) -> str:
549 """Canonical key of a normalised parameter vector."""
550 try:
551 return vector_key(values, self.space)
552 except RowsError as error:
553 raise _from_rows_error(error) from None
554
555 def row_values(self, row: Row) -> dict:
556 """The normalised parameter vector of a row, or an input_invalid refusal."""
557 parameters = row.parameters
558 if (
559 not isinstance(parameters, dict)
560 or parameters.keys() != self.dimensions.keys()
561 ):
562 given = set(parameters) if isinstance(parameters, dict) else set()
563 raise _refusal(
564 "input_invalid",
565 "the parameters of a row differ from the dimensions of the space",
566 [_row_id(row)],
567 sorted(given ^ set(self.dimensions)),
568 )
569 values = {}
570 for name, dim in self.dimensions.items():
571 try:
572 values[name] = dim.normalise(parameters[name])
573 except (ValueError, OverflowError):
574 raise _refusal(
575 "input_invalid",
576 f"parameter {_clip(name)} is off the space or off its lattice",
577 [_row_id(row)],
578 (name,),
579 ) from None
580 return values
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599 def bind(self, band: dict, fraction: float | None, ratio: float | None) -> None:
600 """Set the band and the move rules; computes the near-best distance."""
601 value = band["value"]
602 delta = value if band["mode"] == "absolute" else value * abs(self.reference)
603 if not math.isfinite(delta):
604 raise _refusal(
605 "parameter_invalid",
606 "the band distance is not a finite number",
607 names=("band",),
608 )
609 self.band = {"mode": band["mode"], "value": value, "delta": delta}
610 self.delta = delta
611 self.fraction = fraction
612 self.ratio = ratio
613
614 def gap(self, objective: float) -> float:
615 """Distance from the reference value; one subtraction, never negative."""
616 if self.direction == "maximize":
617 return self.reference - objective
618 return objective - self.reference
619
620 def better(self, objective: float, other: float) -> bool:
621 """Whether ``objective`` is strictly better than ``other``."""
622 if self.direction == "maximize":
623 return objective > other
624 return objective < other
625
626 def rank(self, name: str, value: Any) -> Any:
627 return self.dimensions[name].rank(value)
628
629 def sort_values(self, name: str, values: Any) -> list:
630 return self.dimensions[name].sort_values(values)
631
632 def cell(self, values: dict) -> dict:
633 """Class, objective, gap, near flag and row ids of one full vector.
634
635 Classes: ``scored_ok`` (status ok), ``scored_infeasible`` (constraint
636 violation), ``unscorable`` (any other status; the recorded objective of
637 the first row is shown, a pruned row's is a prefix value) and
638 ``never_evaluated``. Scored rows of one vector agree exactly, so the
639 first scored row speaks for the vector; otherwise the first row does.
640 """
641 if self.delta is None:
642 raise ValueError("bind the band before classifying vectors")
643 group = self.groups.get(self.key_of(values), ())
644 rows = [[row.source_index, row.trial_id] for row in group]
645 scored = [row for row in group if row.status in _SCORED_STATUSES]
646 if scored:
647 head = scored[0]
648 if head.status == "ok":
649 gap = self.gap(head.objective)
650 return {
651 "class": SCORED_OK,
652 "objective": head.objective,
653 "gap": gap,
654 "near": gap <= self.delta,
655 "rows": rows,
656 }
657 return {
658 "class": SCORED_INFEASIBLE,
659 "objective": head.objective,
660 "gap": None,
661 "near": False,
662 "rows": rows,
663 }
664 if group:
665 return {
666 "class": UNSCORABLE,
667 "objective": group[0].objective,
668 "gap": None,
669 "near": False,
670 "rows": rows,
671 }
672 return {
673 "class": NEVER_EVALUATED,
674 "objective": None,
675 "gap": None,
676 "near": False,
677 "rows": rows,
678 }
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694def prepare(sources: Any, *, space: Any = None) -> Study:
695 """Read the sources and index their rows; see ``neighbourhood`` for refusals.
696
697 ``sources`` is a non-empty list; an item is a path to a complete result JSON
698 or a rows JSONL, a parsed result, or a parsed list of rows. The position in
699 the list is the source index. ``space`` is a recorded-space dict or a
700 ``StudySpec`` and is used only when no row or result records its space.
701 """
702 if not isinstance(sources, (list, tuple)) or not sources:
703 raise _refusal(
704 "input_invalid",
705 "sources must be a non-empty list of result files, results or row lists",
706 names=("sources",),
707 )
708 read = []
709 for index, item in enumerate(sources):
710 try:
711 read.append(read_source(item, index))
712 except RowsError as error:
713 raise _from_rows_error(error, index) from None
714 return Study(read, space)
715
716
717def top_ids(sources: Any, k: int, *, space: Any = None) -> list:
718 """The (source, trial_id) of the ``k`` best distinct parameter vectors.
719
720 Only rows with status ok count. A vector is represented by its lowest
721 (source, trial_id); best first, ties by the lower (source, trial_id). Fewer
722 than ``k`` are returned when fewer vectors have an ok row. The sources are
723 read and refused exactly as in ``neighbourhood``.
724 """
725 if type(k) is not int or k < 1:
726 raise _refusal(
727 "parameter_invalid", "k must be a positive integer", names=("k",)
728 )
729 study = prepare(sources, space=space)
730 heads = []
731 for group in study.groups.values():
732 ok_rows = [row for row in group if row.status == "ok"]
733 if ok_rows:
734 heads.append(ok_rows[0])
735 sign = -1.0 if study.direction == "maximize" else 1.0
736 heads.sort(key=lambda row: (sign * row.objective, *_row_id(row)))
737 return [_row_id(row) for row in heads[:k]]
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991def neighbourhood(
992 sources: Any,
993 *,
994 centres: Any,
995 band: Any,
996 fraction: Any = None,
997 ratio: Any = None,
998 pair_k: Any,
999 max_work: Any = None,
1000 space: Any = None,
1001) -> dict:
1002 """Describe the neighbourhoods of the given centres from finished rows.
1003
1004 ``sources`` is a non-empty list; an item is a path to a complete result JSON
1005 or a rows JSONL, a parsed result, or a parsed list of rows, and its position
1006 in the list is its source index. ``centres`` is a list of (source, trial_id)
1007 pairs, each a row with status ok (``top_ids`` returns such a list). ``band``
1008 is ``{"mode": "absolute" | "relative", "value": v}``: a scored ok neighbour
1009 is near the best iff its gap to the best ok objective is at most ``v`` or
1010 ``v`` times the absolute best. ``fraction`` (continuous reals) and ``ratio``
1011 (log dimensions) are needed only when a varying dimension uses them.
1012 ``pair_k`` must be passed: it has no default, and None refuses with
1013 ``neighbourhood_parameter_missing``. That many pair maps per centre are
1014 built from the pairs with the most not-near neighbours, and an explicit 0
1015 builds none. ``max_work`` bounds the rows plus the lookups. ``space`` is
1016 used only when no row or result records one. No default exists for the
1017 band, the fraction, the ratio, ``pair_k`` or ``max_work``.
1018
1019 The result is a JSON-ready dict (see ``docs/neighbourhood.md``). Refusals
1020 raise ``NeighbourhoodError`` with one of ``CODES`` and, when several defects
1021 exist, report the first in this order: caller parameters, reading the
1022 sources, space and direction, context, dimensions, rows against the space,
1023 duplicate conflicts, no ok row, no varying dimension, missing fraction or
1024 ratio, a relative band at a zero reference, centres, work above
1025 ``max_work``.
1026 """
1027 centre_ids, band, fraction, ratio, pair_k, max_work = _check_parameters(
1028 centres, band, fraction, ratio, pair_k, max_work
1029 )
1030 study = prepare(sources, space=space)
1031 _require_varying(study)
1032 _require_rules(study, fraction, ratio)
1033 if band["mode"] == "relative" and study.reference == 0.0:
1034 raise _refusal(
1035 "band_degenerate",
1036 "a relative band needs a reference value other than zero",
1037 )
1038 study.bind(band, fraction, ratio)
1039 chosen = _centre_rows(study, centre_ids)
1040 values = [study.row_values(row) for row in chosen]
1041 moves = [
1042 {
1043 name: study.dimensions[name].neighbours(vector[name], fraction, ratio)
1044 for name in study.varying
1045 }
1046 for vector in values
1047 ]
1048 axis = sum(len(item.values) for centre in moves for item in centre.values())
1049 _admit(study, axis, max_work)
1050 profiles = [
1051 _profile(study, vector, centre) for vector, centre in zip(values, moves)
1052 ]
1053 maps: list = [[] for _ in chosen]
1054 pair_lookups = 0
1055 if pair_k:
1056 from . import sensitivity # imported here: sensitivity imports this module
1057
1058 plans = [
1059 sensitivity.plan_pairs(study, vector, profile, pair_k)
1060 for vector, profile in zip(values, profiles)
1061 ]
1062 pair_lookups = sum(plan.lookups for centre in plans for plan in centre)
1063 _admit(study, axis + pair_lookups, max_work)
1064 maps = [
1065 sensitivity.pair_maps(study, vector, centre)
1066 for vector, centre in zip(values, plans)
1067 ]
1068 results = [
1069 _centre_result(study, row, centre, profile, pair_maps)
1070 for row, centre, profile, pair_maps in zip(chosen, moves, profiles, maps)
1071 ]
1072 lookups = axis + pair_lookups
1073 return {
1074 "contract": CONTRACT,
1075 "status": "ok",
1076 "direction": study.direction,
1077 "reference": {
1078 "objective": study.reference,
1079 "source": study.reference_id[0],
1080 "trial_id": study.reference_id[1],
1081 },
1082 "band": dict(study.band),
1083 "rules": {
1084 "fraction": fraction,
1085 "ratio": ratio,
1086 "pair_k": pair_k,
1087 "by_dimension": {
1088 name: study.dimensions[name].rule for name in study.varying
1089 },
1090 },
1091 "sources": [dict(item) for item in study.summaries],
1092 "duplicate_rows": study.duplicate_rows,
1093 "constant_dimensions": list(study.constants),
1094 "missing_vectors": _missing_vectors(study, list(zip(values, results))),
1095 "admission": {
1096 "rows": study.row_count,
1097 "lookups": lookups,
1098 "work": study.row_count + lookups,
1099 "max_work": max_work,
1100 },
1101 "input_sha256": study.input_sha256,
1102 "interpretation": INTERPRETATION,
1103 "centres": results,
1104 }
Moves neighbours(self, Any value, float|None fraction=None, float|None ratio=None)
None __init__(self, str code, str detail, Any ids=(), Any names=())
dict cell(self, dict values)
None bind(self, dict band, float|None fraction, float|None ratio)
str key_of(self, dict values)
Any rank(self, str name, Any value)
bool better(self, float objective, float other)
None __init__(self, list sources, Any passed_space=None)
list sort_values(self, str name, Any values)
float gap(self, float objective)
Study prepare(Any sources, *Any space=None)
list top_ids(Any sources, int k, *Any space=None)
Dimension dimension(str name, Any spec)