|
| 1 | +from __future__ import annotations |
| 2 | + |
| 3 | +import math |
| 4 | +from collections.abc import Callable, Mapping, Sequence |
| 5 | +from dataclasses import dataclass |
| 6 | +from typing import Literal, TypeAlias |
| 7 | + |
| 8 | +import numpy as np |
| 9 | +from numpy.typing import NDArray |
| 10 | + |
| 11 | +CandidateId: TypeAlias = int | Literal["all"] |
| 12 | +Objective: TypeAlias = Literal["min", "max"] |
| 13 | +CandidateEvaluator: TypeAlias = Callable[ |
| 14 | + [NDArray[np.float64], NDArray[np.float64]], tuple[float, float] |
| 15 | +] |
| 16 | + |
| 17 | + |
| 18 | +@dataclass(frozen=True) |
| 19 | +class Method1FoldPredictions: |
| 20 | + """Predictions for one CV fold keyed by conceptual Method 1 candidate.""" |
| 21 | + |
| 22 | + validation_idx: NDArray[np.int64] |
| 23 | + predictions: Mapping[CandidateId, NDArray[np.float64]] |
| 24 | + |
| 25 | + |
| 26 | +@dataclass(frozen=True) |
| 27 | +class Method1Selection: |
| 28 | + """Complete pooled-OOF Method 1 candidate selection result.""" |
| 29 | + |
| 30 | + selected: CandidateId |
| 31 | + selected_score: float |
| 32 | + selected_threshold: float |
| 33 | + pooled_predictions: Mapping[CandidateId, NDArray[np.float64]] |
| 34 | + count_vectors: Mapping[CandidateId, NDArray[np.int64]] |
| 35 | + scores: Mapping[CandidateId, float] |
| 36 | + thresholds: Mapping[CandidateId, float] |
| 37 | + eligible: tuple[CandidateId, ...] |
| 38 | + excluded: tuple[CandidateId, ...] |
| 39 | + stopping_k: int | None |
| 40 | + |
| 41 | + |
| 42 | +def select_method1_candidate( |
| 43 | + *, |
| 44 | + n_obs: int, |
| 45 | + actual: NDArray[np.float64], |
| 46 | + folds: Sequence[Method1FoldPredictions], |
| 47 | + local_candidates: Sequence[int], |
| 48 | + evaluator: CandidateEvaluator, |
| 49 | + objective: Objective, |
| 50 | +) -> Method1Selection: |
| 51 | + """Select Method 1 using complete pooled OOF predictions. |
| 52 | +
|
| 53 | + The repaired R semantics are deliberately candidate-global: |
| 54 | +
|
| 55 | + * each conceptual local ``k`` is accumulated across every fold; |
| 56 | + * candidate 1 defines the required OOF coverage pattern; |
| 57 | + * partial/empty candidates are excluded before scoring; |
| 58 | + * diminishing-returns stopping is applied only to complete pooled scores; |
| 59 | + * ``ALL`` is always scored separately and is never subject to local stopping. |
| 60 | + """ |
| 61 | + |
| 62 | + if n_obs < 1: |
| 63 | + raise ValueError("n_obs must be positive.") |
| 64 | + actual_values = np.asarray(actual, dtype=np.float64).reshape(-1) |
| 65 | + if actual_values.size != n_obs: |
| 66 | + raise ValueError("actual must contain n_obs values.") |
| 67 | + if objective not in {"min", "max"}: |
| 68 | + raise ValueError("objective must be 'min' or 'max'.") |
| 69 | + |
| 70 | + local_ids = tuple(dict.fromkeys(int(k) for k in local_candidates if int(k) >= 1)) |
| 71 | + if not local_ids or local_ids[0] != 1: |
| 72 | + raise ValueError("local_candidates must begin with candidate 1.") |
| 73 | + candidate_ids: tuple[CandidateId, ...] = (*local_ids, "all") |
| 74 | + |
| 75 | + sums = {candidate: np.zeros(n_obs, dtype=np.float64) for candidate in candidate_ids} |
| 76 | + counts = {candidate: np.zeros(n_obs, dtype=np.int64) for candidate in candidate_ids} |
| 77 | + |
| 78 | + for fold in folds: |
| 79 | + validation_idx = np.asarray(fold.validation_idx, dtype=np.int64).reshape(-1) |
| 80 | + if np.any(validation_idx < 0) or np.any(validation_idx >= n_obs): |
| 81 | + raise ValueError("fold validation indices are outside [0, n_obs).") |
| 82 | + if np.unique(validation_idx).size != validation_idx.size: |
| 83 | + raise ValueError("fold validation indices must be unique.") |
| 84 | + |
| 85 | + for candidate, raw_prediction in fold.predictions.items(): |
| 86 | + if candidate not in sums: |
| 87 | + continue |
| 88 | + prediction = np.asarray(raw_prediction, dtype=np.float64).reshape(-1) |
| 89 | + if prediction.size != validation_idx.size: |
| 90 | + raise ValueError( |
| 91 | + f"candidate {candidate!r} prediction length does not match validation indices." |
| 92 | + ) |
| 93 | + finite = np.isfinite(prediction) |
| 94 | + if not np.any(finite): |
| 95 | + continue |
| 96 | + rows = validation_idx[finite] |
| 97 | + sums[candidate][rows] += prediction[finite] |
| 98 | + counts[candidate][rows] += 1 |
| 99 | + |
| 100 | + reference_count = counts[1] |
| 101 | + if not np.any(reference_count > 0): |
| 102 | + raise ValueError("candidate 1 has no OOF coverage.") |
| 103 | + |
| 104 | + pooled: dict[CandidateId, NDArray[np.float64]] = {} |
| 105 | + scores: dict[CandidateId, float] = {} |
| 106 | + thresholds: dict[CandidateId, float] = {} |
| 107 | + complete: dict[CandidateId, bool] = {} |
| 108 | + |
| 109 | + for candidate in candidate_ids: |
| 110 | + count = counts[candidate] |
| 111 | + raw = np.full(n_obs, np.nan, dtype=np.float64) |
| 112 | + covered = count > 0 |
| 113 | + raw[covered] = sums[candidate][covered] / count[covered] |
| 114 | + pooled[candidate] = raw |
| 115 | + |
| 116 | + same_coverage = np.array_equal(count, reference_count) |
| 117 | + valid = covered & np.isfinite(raw) & np.isfinite(actual_values) |
| 118 | + if not same_coverage or not np.any(valid): |
| 119 | + complete[candidate] = False |
| 120 | + continue |
| 121 | + |
| 122 | + score, threshold = evaluator(raw[valid], actual_values[valid]) |
| 123 | + score_value = float(score) |
| 124 | + threshold_value = float(threshold) |
| 125 | + if not math.isfinite(score_value): |
| 126 | + complete[candidate] = False |
| 127 | + continue |
| 128 | + complete[candidate] = True |
| 129 | + scores[candidate] = score_value |
| 130 | + thresholds[candidate] = threshold_value |
| 131 | + |
| 132 | + evaluated_local: list[int] = [] |
| 133 | + stopping_k: int | None = None |
| 134 | + for candidate in local_ids: |
| 135 | + if not complete.get(candidate, False): |
| 136 | + break |
| 137 | + evaluated_local.append(candidate) |
| 138 | + if len(evaluated_local) < 4: |
| 139 | + continue |
| 140 | + recent = [scores[k] for k in evaluated_local[-3:]] |
| 141 | + stop = ( |
| 142 | + recent[2] >= recent[1] and recent[2] >= recent[0] |
| 143 | + if objective == "min" |
| 144 | + else recent[2] <= recent[1] and recent[2] <= recent[0] |
| 145 | + ) |
| 146 | + if stop: |
| 147 | + stopping_k = candidate |
| 148 | + break |
| 149 | + |
| 150 | + eligible: list[CandidateId] = list(evaluated_local) |
| 151 | + if complete.get("all", False): |
| 152 | + eligible.append("all") |
| 153 | + if not eligible: |
| 154 | + raise ValueError("no Method 1 candidate has complete finite OOF coverage.") |
| 155 | + |
| 156 | + if objective == "min": |
| 157 | + best_score = min(scores[candidate] for candidate in eligible) |
| 158 | + else: |
| 159 | + best_score = max(scores[candidate] for candidate in eligible) |
| 160 | + selected = next(candidate for candidate in eligible if scores[candidate] == best_score) |
| 161 | + |
| 162 | + excluded = tuple(candidate for candidate in candidate_ids if candidate not in eligible) |
| 163 | + return Method1Selection( |
| 164 | + selected=selected, |
| 165 | + selected_score=float(scores[selected]), |
| 166 | + selected_threshold=float(thresholds[selected]), |
| 167 | + pooled_predictions=pooled, |
| 168 | + count_vectors=counts, |
| 169 | + scores=scores, |
| 170 | + thresholds=thresholds, |
| 171 | + eligible=tuple(eligible), |
| 172 | + excluded=excluded, |
| 173 | + stopping_k=stopping_k, |
| 174 | + ) |
0 commit comments