Skip to content

Commit e90e039

Browse files
Add pooled OOF Method 1 selector
1 parent 86f848b commit e90e039

1 file changed

Lines changed: 174 additions & 0 deletions

File tree

src/nns/_stack_method1.py

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
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

Comments
 (0)