selection-fragility 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- selection_fragility/__init__.py +73 -0
- selection_fragility/__main__.py +39 -0
- selection_fragility/_internals.py +52 -0
- selection_fragility/compare.py +276 -0
- selection_fragility/fragility.py +885 -0
- selection_fragility/identify.py +312 -0
- selection_fragility/mcs.py +258 -0
- selection_fragility/panel.py +794 -0
- selection_fragility/pivot.py +154 -0
- selection_fragility/prop22.py +90 -0
- selection_fragility/report.py +253 -0
- selection_fragility/resolution.py +435 -0
- selection_fragility-1.0.0.dist-info/METADATA +449 -0
- selection_fragility-1.0.0.dist-info/RECORD +17 -0
- selection_fragility-1.0.0.dist-info/WHEEL +5 -0
- selection_fragility-1.0.0.dist-info/licenses/LICENSE +21 -0
- selection_fragility-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""selection_fragility — decision breakdown point (k*) and ranking-fragility diagnostics for
|
|
2
|
+
forecast model selection.
|
|
3
|
+
|
|
4
|
+
Given per-period losses for a set of candidate models, answers: is the "best model" a real,
|
|
5
|
+
stable choice, or a fragile artifact of a few periods?
|
|
6
|
+
|
|
7
|
+
Start with the staged v1.0 pipeline:
|
|
8
|
+
from selection_fragility import LossPanel, report
|
|
9
|
+
panel = LossPanel.from_losses({"ar1": ..., "nbeats": ..., "ets": ...})
|
|
10
|
+
print(report(panel))
|
|
11
|
+
|
|
12
|
+
`report()` prints identification (Model Confidence Set), the leaderboard, resolution (observed
|
|
13
|
+
edge vs. minimum detectable edge, MCB bound), and the pivot (k*, responsible periods,
|
|
14
|
+
concentration). See the project's README (rendered on its PyPI page) for the full quick start and
|
|
15
|
+
`EVALUATION_CARD.md` (in the source repository -- CORRECTED 2026-09-07, round-4 audit: this file
|
|
16
|
+
does not ship inside a `pip install`, only the source repo has it) for what each diagnostic does
|
|
17
|
+
and does not support.
|
|
18
|
+
|
|
19
|
+
CONTRIBUTORS/EXTENDERS: `selection_fragility.fragility`, `.mcs`, `.compare`, and `.report` are
|
|
20
|
+
FUNCTIONS here (re-exported below), not the submodules of the same name -- `import
|
|
21
|
+
selection_fragility.mcs as m` gives you the `mcs()` function, not the module, so `m._block_idx`
|
|
22
|
+
fails confusingly. Use `from selection_fragility._internals import mcs, fragility, compare,
|
|
23
|
+
report` (or `panel`/`identify`/`resolution`/`prop22`/`pivot`) for a stable, non-shadowed path to
|
|
24
|
+
the real submodules and their private helpers. See `_internals.py` for the full explanation.
|
|
25
|
+
"""
|
|
26
|
+
from .fragility import (
|
|
27
|
+
fragility,
|
|
28
|
+
decision_breakdown,
|
|
29
|
+
breakdown_number,
|
|
30
|
+
winner_stability,
|
|
31
|
+
exchangeable_benchmark,
|
|
32
|
+
surprise_concentration,
|
|
33
|
+
pooled_winner,
|
|
34
|
+
per_period_winner,
|
|
35
|
+
condorcet_winner,
|
|
36
|
+
condorcet_status,
|
|
37
|
+
)
|
|
38
|
+
from .mcs import mcs, model_confidence_set
|
|
39
|
+
|
|
40
|
+
# --- Round 10 (v1.0) surface: the staged pipeline the redesign introduced ------------
|
|
41
|
+
from .panel import LossPanel # Stage 0
|
|
42
|
+
from .identify import identified, mcs_size # Stage 1
|
|
43
|
+
from .resolution import ( # Stage 2
|
|
44
|
+
resolution_report,
|
|
45
|
+
minimum_detectable_edge,
|
|
46
|
+
selection_regret,
|
|
47
|
+
mcb_bound,
|
|
48
|
+
significance_boundary,
|
|
49
|
+
)
|
|
50
|
+
from .prop22 import prop22_certifies, certified_tied_subset # Stage 3
|
|
51
|
+
from .pivot import concentration_share, pivot_agreement # Stage 4
|
|
52
|
+
from .compare import compare, ChangeReport # Stage 5
|
|
53
|
+
from .report import report # Stage 6
|
|
54
|
+
|
|
55
|
+
__version__ = "1.0.0"
|
|
56
|
+
|
|
57
|
+
__all__ = [
|
|
58
|
+
# instrument
|
|
59
|
+
"fragility", "decision_breakdown", "breakdown_number", "winner_stability",
|
|
60
|
+
"exchangeable_benchmark", "surprise_concentration", "pooled_winner",
|
|
61
|
+
"per_period_winner", "condorcet_winner", "condorcet_status",
|
|
62
|
+
"mcs", "model_confidence_set",
|
|
63
|
+
# v1.0 staged surface
|
|
64
|
+
"LossPanel",
|
|
65
|
+
"identified", "mcs_size",
|
|
66
|
+
"resolution_report", "minimum_detectable_edge", "selection_regret", "mcb_bound",
|
|
67
|
+
"significance_boundary",
|
|
68
|
+
"prop22_certifies", "certified_tied_subset",
|
|
69
|
+
"concentration_share", "pivot_agreement",
|
|
70
|
+
"compare", "ChangeReport",
|
|
71
|
+
"report",
|
|
72
|
+
"__version__",
|
|
73
|
+
]
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""python -m selection_fragility -- CLI entry point. Currently one subcommand: `compare`, so
|
|
2
|
+
`compare()` can gate a CI/pipeline promotion step without writing any Python."""
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from .panel import LossPanel
|
|
7
|
+
from .compare import compare
|
|
8
|
+
def main(argv=None):
|
|
9
|
+
parser = argparse.ArgumentParser(prog="selection_fragility")
|
|
10
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
11
|
+
p_compare = sub.add_parser("compare", help="compare two saved LossPanels (previous vs current run)")
|
|
12
|
+
p_compare.add_argument("previous", help="path to a LossPanel saved with .save()")
|
|
13
|
+
p_compare.add_argument("current", help="path to a LossPanel saved with .save()")
|
|
14
|
+
p_compare.add_argument("--alpha", type=float, default=0.10, help="Model Confidence Set level")
|
|
15
|
+
p_compare.add_argument("--exit-code", action="store_true",
|
|
16
|
+
help="exit 1 when the report recommends action (.act=True); exit 0 otherwise")
|
|
17
|
+
args = parser.parse_args(argv)
|
|
18
|
+
if args.command == "compare":
|
|
19
|
+
# FIXED 2026-08-26 (round-2 review, "does it deliver on its promises" lens): CHANGELOG.md
|
|
20
|
+
# already claimed "every CLI error path... exits 2", but nothing here ever caught anything --
|
|
21
|
+
# a missing/malformed file surfaced as a raw Python traceback at exit 1 (Python's default for
|
|
22
|
+
# an unhandled exception), indistinguishable from a genuine internal bug. Catch only the
|
|
23
|
+
# user-input-class failures LossPanel.load()/compare() are documented to raise for bad data
|
|
24
|
+
# (a missing file, corrupt/non-LossPanel JSON, or a real data problem like a mismatched model
|
|
25
|
+
# set) and exit 2 with a clean one-line message; anything else (a real programming error) is
|
|
26
|
+
# deliberately left to propagate as an uncaught exception so it isn't mistaken for bad input.
|
|
27
|
+
try:
|
|
28
|
+
prev = LossPanel.load(args.previous)
|
|
29
|
+
curr = LossPanel.load(args.current)
|
|
30
|
+
r = compare(prev, curr, alpha=args.alpha)
|
|
31
|
+
except (FileNotFoundError, json.JSONDecodeError, KeyError, ValueError) as e:
|
|
32
|
+
print(f"selection_fragility compare: {e}", file=sys.stderr)
|
|
33
|
+
sys.exit(2)
|
|
34
|
+
print(r)
|
|
35
|
+
if args.exit_code and r.act:
|
|
36
|
+
sys.exit(1)
|
|
37
|
+
sys.exit(0)
|
|
38
|
+
if __name__ == "__main__":
|
|
39
|
+
main()
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""selection_fragility._internals — stable access to real submodules for contributors.
|
|
2
|
+
|
|
3
|
+
`selection_fragility/__init__.py` re-exports several public functions/classes under the SAME
|
|
4
|
+
name as the submodule that defines them (`from .fragility import fragility`, `from .mcs import
|
|
5
|
+
mcs`, `from .compare import compare`, `from .report import report`). Each of those `from .X
|
|
6
|
+
import X` lines rebinds the package attribute `selection_fragility.X` to the re-exported
|
|
7
|
+
function/class, shadowing the actual SUBMODULE that was at that attribute path a moment
|
|
8
|
+
earlier. `import selection_fragility.mcs as m; m._block_idx` therefore fails with a confusing
|
|
9
|
+
`AttributeError: 'function' object has no attribute '_block_idx'` -- `m` is the `mcs()`
|
|
10
|
+
function, not the `mcs` module. Found in round-5 review (2026-08-27) while testing whether a
|
|
11
|
+
contributor could reach package internals to build an extension.
|
|
12
|
+
|
|
13
|
+
This is deliberate, unchanged public-API behavior (`from selection_fragility import mcs` must
|
|
14
|
+
keep returning the function for existing users) -- not something to "fix" by breaking v1.0's
|
|
15
|
+
API on the eve of release. This module is the fix for CONTRIBUTORS instead: a stable, explicit,
|
|
16
|
+
documented path to the real submodule objects (and their private helpers) that never shadows.
|
|
17
|
+
|
|
18
|
+
Usage, instead of the confusing `import selection_fragility.mcs as m`:
|
|
19
|
+
from selection_fragility._internals import mcs, fragility, compare, report
|
|
20
|
+
mcs._block_idx(...) # the real submodule, its private helpers included
|
|
21
|
+
fragility._MAX_ABS_LOSS # etc.
|
|
22
|
+
|
|
23
|
+
The other submodules (panel, identify, resolution, prop22, pivot) were never shadowed --
|
|
24
|
+
their re-exported names differ from the submodule name -- but are re-exposed here too, for one
|
|
25
|
+
consistent import path regardless of which internals a contributor needs.
|
|
26
|
+
"""
|
|
27
|
+
import sys as _sys
|
|
28
|
+
|
|
29
|
+
_PKG = __name__.rsplit(".", 1)[0] # "selection_fragility"
|
|
30
|
+
|
|
31
|
+
# Each of these is looked up in sys.modules by dotted name, NOT via `from . import X` or
|
|
32
|
+
# `getattr(package, X)` -- both of those would just return whatever the (possibly-shadowed)
|
|
33
|
+
# package attribute currently holds. sys.modules always holds the actual module object,
|
|
34
|
+
# regardless of what the package's own top-level attribute of the same name was rebound to.
|
|
35
|
+
# Requires this module to be imported only after selection_fragility/__init__.py has already
|
|
36
|
+
# executed its own `from .X import ...` lines (which is when Python registers each submodule
|
|
37
|
+
# in sys.modules) -- true whenever a caller does `from selection_fragility._internals import
|
|
38
|
+
# ...`, since importing the submodule `selection_fragility._internals` always first finishes
|
|
39
|
+
# importing and running its parent package `selection_fragility/__init__.py`.
|
|
40
|
+
fragility = _sys.modules[f"{_PKG}.fragility"]
|
|
41
|
+
mcs = _sys.modules[f"{_PKG}.mcs"]
|
|
42
|
+
compare = _sys.modules[f"{_PKG}.compare"]
|
|
43
|
+
report = _sys.modules[f"{_PKG}.report"]
|
|
44
|
+
panel = _sys.modules[f"{_PKG}.panel"]
|
|
45
|
+
identify = _sys.modules[f"{_PKG}.identify"]
|
|
46
|
+
resolution = _sys.modules[f"{_PKG}.resolution"]
|
|
47
|
+
prop22 = _sys.modules[f"{_PKG}.prop22"]
|
|
48
|
+
pivot = _sys.modules[f"{_PKG}.pivot"]
|
|
49
|
+
|
|
50
|
+
__all__ = [
|
|
51
|
+
"fragility", "mcs", "compare", "report", "panel", "identify", "resolution", "prop22", "pivot",
|
|
52
|
+
]
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""
|
|
2
|
+
selection_fragility.compare — compare(previous, current), the run-over-run champion-change diagnostic.
|
|
3
|
+
The single most-requested missing feature from the practitioner review: "the champion changed this
|
|
4
|
+
week -- is that signal or noise?"
|
|
5
|
+
"""
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
from .fragility import _validate_losses, pooled_winner, decision_breakdown
|
|
9
|
+
from .identify import mcs_size, _run_mcs
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ChangeReport:
|
|
13
|
+
"""The result of `compare(previous, current)`. `.act` is the single bool a CI/pipeline promotion
|
|
14
|
+
step should gate on: True only when the champion changed AND the old champion has genuinely left
|
|
15
|
+
the Model Confidence Set (a real separation, not two models still statistically tied).
|
|
16
|
+
|
|
17
|
+
ROUND-8 FIX (2026-08-27, fresh-eyes full-codebase review): on a genuinely non-uniform-weight
|
|
18
|
+
panel, `arch.bootstrap.MCS` has no native support for the weights -- the same refusal
|
|
19
|
+
`report()`/`resolution_report()` already surface cleanly (round 6/7 fixes) instead of computing
|
|
20
|
+
an MCS on a silently different (unweighted) question than the champion itself was determined
|
|
21
|
+
with. `compare()` previously let that ValueError propagate raw and uncaught. Now caught here:
|
|
22
|
+
`old_champion_still_in_mcs` is `None` (undetermined, not a silent False) and `mcs_error` carries
|
|
23
|
+
the reason. `.act` is conservatively forced to `False` in this case -- a CI/pipeline promotion
|
|
24
|
+
step must never treat "MCS couldn't be computed" as "the old champion left the MCS."
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, *, previous_champion, current_champion, champion_changed,
|
|
28
|
+
champion_without_new_periods, n_new_periods, old_champion_still_in_mcs,
|
|
29
|
+
k_star, churn_base_rate, act, mcs_error=None):
|
|
30
|
+
self.previous_champion = previous_champion
|
|
31
|
+
self.current_champion = current_champion
|
|
32
|
+
self.champion_changed = champion_changed
|
|
33
|
+
self.champion_without_new_periods = champion_without_new_periods
|
|
34
|
+
self.n_new_periods = n_new_periods
|
|
35
|
+
self.old_champion_still_in_mcs = old_champion_still_in_mcs
|
|
36
|
+
self.k_star = k_star
|
|
37
|
+
self.churn_base_rate = churn_base_rate
|
|
38
|
+
self.act = act
|
|
39
|
+
self.mcs_error = mcs_error
|
|
40
|
+
|
|
41
|
+
def __repr__(self):
|
|
42
|
+
if not self.champion_changed:
|
|
43
|
+
head = f"no champion change ({self.current_champion}); act=False"
|
|
44
|
+
elif self.champion_without_new_periods is None:
|
|
45
|
+
head = (f"champion changed {self.previous_champion} -> {self.current_champion}; current "
|
|
46
|
+
f"shares no periods with previous -- cannot attribute the change; act={self.act}")
|
|
47
|
+
elif self.champion_without_new_periods == self.previous_champion:
|
|
48
|
+
head = (f"champion changed {self.previous_champion} -> {self.current_champion}, entirely "
|
|
49
|
+
f"due to the {self.n_new_periods} new period(s) (removing them reverts to the old "
|
|
50
|
+
f"champion); act={self.act}")
|
|
51
|
+
else:
|
|
52
|
+
head = f"champion changed {self.previous_champion} -> {self.current_champion}; act={self.act}"
|
|
53
|
+
if self.mcs_error is not None:
|
|
54
|
+
mcs = f"MCS undetermined ({self.mcs_error})"
|
|
55
|
+
else:
|
|
56
|
+
mcs = "still in the MCS" if self.old_champion_still_in_mcs else "left the MCS"
|
|
57
|
+
return (f"ChangeReport({head}, old champion {mcs}, k*={self.k_star}, "
|
|
58
|
+
f"churn_base_rate={self.churn_base_rate:.3f})")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _churn_base_rate(L, w, n_perm=400, seed=0):
|
|
62
|
+
"""How often would the champion appear to change, RUN-OVER-RUN, out of pure noise -- i.e. if ONE
|
|
63
|
+
new period arrives that behaves like a typical historical period in shape but carries no
|
|
64
|
+
consistent model identity (a within-period label permutation applied to a single, randomly-drawn
|
|
65
|
+
EXISTING period, then appended), how often does that alone flip the pooled champion away from the
|
|
66
|
+
one observed on the T periods actually in hand? Distribution-free, built entirely from the
|
|
67
|
+
caller's own data.
|
|
68
|
+
|
|
69
|
+
REDESIGNED 2026-08-16 (independent 'wild' review): the original version independently permuted
|
|
70
|
+
EVERY period's model-assignment on EVERY trial -- a full reshuffle of the whole T-period panel,
|
|
71
|
+
not a single new period arriving. Verified directly this made the field measure almost exactly
|
|
72
|
+
(K-1)/K regardless of the actual data (K=6 gave 0.83 whether the panel had no true skill
|
|
73
|
+
difference OR one model with a massive true edge -- literally the wrong direction, since more
|
|
74
|
+
real signal should make the champion MORE robust to a single new period, not equally fragile).
|
|
75
|
+
The root cause: a full reshuffle destroys ALL cross-period structure, so every model's post-
|
|
76
|
+
permutation pooled mean becomes an independent draw from the same pool and the "champion" is
|
|
77
|
+
essentially a uniform pick among K labels. The fix keeps the T periods actually observed
|
|
78
|
+
UNCHANGED (preserving whatever real, if fragile, lead the data has) and only randomizes the
|
|
79
|
+
identity assignment of ONE new incoming period, drawn from the shape of a real historical period
|
|
80
|
+
-- matching what a single real `compare()` call actually represents. Verified against
|
|
81
|
+
tests/test_stage5_compare.py::test_base_rate_churn_cross_check's own independently-validated
|
|
82
|
+
target (a TRUE null panel, one new null period added, ~0.08-0.35 with target ~0.18): the redesign
|
|
83
|
+
lands at ~0.13, inside that range; the OLD version gave ~0.83, roughly 5x outside it, and the
|
|
84
|
+
cross-check test only ever validated `champion_changed` from real `compare()` calls, never this
|
|
85
|
+
internal field directly, which is how the gap went undetected across 4 prior review rounds.
|
|
86
|
+
|
|
87
|
+
CONFIRMED REGRESSION (determinism/statelessness audit, 2026-08-17): `models = list(L)` used the
|
|
88
|
+
caller's dict insertion order to build M's column order, so the SAME logical panel (same model
|
|
89
|
+
names, same values) produced a DIFFERENT churn_base_rate depending purely on how the caller's
|
|
90
|
+
dict happened to be constructed -- every other stochastic field in this package (winner_stability,
|
|
91
|
+
pivot_agreement) avoids this since they re-dispatch through pooled_winner/decision_breakdown,
|
|
92
|
+
which sort internally; this was the one function still shuffling at raw array-position level.
|
|
93
|
+
Verified directly: same seed, same 6-model/30-period panel, reversed dict key order moved
|
|
94
|
+
churn_base_rate from 0.42 to 0.465 -- a headline user-facing diagnostic swinging ~0.05 absolute
|
|
95
|
+
purely from dict construction order, against a documented ~0.08-0.35 target band. Fixed the same
|
|
96
|
+
way as identify.py::_to_frame and mcs.py::model_confidence_set's earlier order-dependence fixes:
|
|
97
|
+
sort models by name so M's column order is a function of model identity, not insertion order."""
|
|
98
|
+
# FIXED 2026-09-02 (10-agent code-review pass, CONFIRMED BUG): n_perm is a PUBLIC, documented
|
|
99
|
+
# kwarg of compare() (e.g. a caller computing it from a config value that can go negative by
|
|
100
|
+
# mistake). A negative n_perm made `range(n_perm)` empty (the tally stayed 0) so
|
|
101
|
+
# `changes/n_perm` silently returned -0.0 -- indistinguishable in comparison semantics from a
|
|
102
|
+
# genuine "zero churn expected by chance" reading -- with no resampling ever actually
|
|
103
|
+
# performed and no error raised.
|
|
104
|
+
if n_perm <= 0:
|
|
105
|
+
raise ValueError(f"n_perm must be a positive integer; got {n_perm}.")
|
|
106
|
+
models = sorted(L)
|
|
107
|
+
M = np.column_stack([np.asarray(L[m], float) for m in models]) # T x K, REAL unmodified data
|
|
108
|
+
obs_champ_idx = int(np.argmin(np.average(M, axis=0, weights=w)))
|
|
109
|
+
rng = np.random.default_rng(seed)
|
|
110
|
+
T = M.shape[0]
|
|
111
|
+
mean_w = float(np.mean(w))
|
|
112
|
+
changes = 0
|
|
113
|
+
for _ in range(n_perm):
|
|
114
|
+
t_star = int(rng.integers(0, T))
|
|
115
|
+
new_period = rng.permutation(M[t_star]) # same values as a real period, random model assignment
|
|
116
|
+
Mp = np.vstack([M, new_period])
|
|
117
|
+
wp = np.append(w, mean_w)
|
|
118
|
+
perm_champ_idx = int(np.argmin(np.average(Mp, axis=0, weights=wp)))
|
|
119
|
+
if perm_champ_idx != obs_champ_idx:
|
|
120
|
+
changes += 1
|
|
121
|
+
return changes / n_perm
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def compare(previous, current, *, alpha=0.10, n_perm=400, seed=0):
|
|
125
|
+
"""Compare two LossPanels from consecutive runs of the same evaluation and report whether the
|
|
126
|
+
pooled champion changed, and whether that change looks like signal or noise.
|
|
127
|
+
|
|
128
|
+
Computes, all cheap: (1) did the pooled champion change; (2) recompute the champion on `current`
|
|
129
|
+
restricted to the periods it shares with `previous` (by LABEL, not position) -- if that reverts to
|
|
130
|
+
the OLD champion, the change is entirely the new data, exact and free; (3) is the old champion
|
|
131
|
+
still in the current Model Confidence Set (if both remain tied, there is no real separation to act
|
|
132
|
+
on); (4) k* of the new champion; (5) the within-period-permutation churn base rate (§2.6) -- how
|
|
133
|
+
often the champion would appear to change out of pure label noise on data this size.
|
|
134
|
+
|
|
135
|
+
A period in `current` counts as "new" iff its label does not appear anywhere in `previous.labels`
|
|
136
|
+
-- this is a set-membership test, not a positional slice, so it is correct whether the new
|
|
137
|
+
period(s) land at the end (the common append case), get inserted mid-panel by a merge/sort, or a
|
|
138
|
+
rolling window drops an old period while adding a new one at the same total T (previously
|
|
139
|
+
invisible to this diagnostic, since `n_new` was computed purely from `len(current) - len(previous)`
|
|
140
|
+
and came out 0). If `current` shares NO periods with `previous` at all (e.g. two genuinely
|
|
141
|
+
unrelated panels), there is nothing to restrict to -- `champion_without_new_periods` is reported as
|
|
142
|
+
None rather than silently attributing the change to data it never actually removed.
|
|
143
|
+
|
|
144
|
+
Raises ValueError if `previous` and `current` have different model sets -- comparing mismatched
|
|
145
|
+
columns silently would be worse than refusing."""
|
|
146
|
+
# FIXED 2026-09-02 (10-agent code-review pass, CONFIRMED GAP): compare() used to access
|
|
147
|
+
# previous.models/.losses directly with no type check, so raw dicts (an easy mistake for a
|
|
148
|
+
# caller who built L={'a':...} for the raw-array tier and assumes it also works here) raised a
|
|
149
|
+
# bare AttributeError instead of a clear message. The CI/pipeline-gating use case this function
|
|
150
|
+
# is built for makes an uncaught internal AttributeError especially bad: a promotion script
|
|
151
|
+
# catching a narrow exception type would not catch this and would crash the pipeline rather
|
|
152
|
+
# than fail cleanly.
|
|
153
|
+
for _name, _p in (("previous", previous), ("current", current)):
|
|
154
|
+
if not (hasattr(_p, "losses") and hasattr(_p, "models") and hasattr(_p, "weights")
|
|
155
|
+
and hasattr(_p, "labels")):
|
|
156
|
+
raise TypeError(
|
|
157
|
+
f"compare() expects two LossPanel objects, got {type(_p).__name__} for {_name!r}. "
|
|
158
|
+
f"Build one first with LossPanel.from_losses(...) or LossPanel.from_forecasts(...)."
|
|
159
|
+
)
|
|
160
|
+
if set(previous.models) != set(current.models):
|
|
161
|
+
raise ValueError(
|
|
162
|
+
f"previous and current panels have different model sets -- cannot compare mismatched "
|
|
163
|
+
f"models: {sorted(previous.models)} vs {sorted(current.models)}."
|
|
164
|
+
)
|
|
165
|
+
prev_L, prev_w = previous.losses, previous.weights
|
|
166
|
+
curr_L, curr_w = current.losses, current.weights
|
|
167
|
+
_validate_losses(curr_L, curr_w)
|
|
168
|
+
|
|
169
|
+
prev_champ = pooled_winner(prev_L, prev_w)
|
|
170
|
+
curr_champ = pooled_winner(curr_L, curr_w)
|
|
171
|
+
champion_changed = prev_champ != curr_champ
|
|
172
|
+
|
|
173
|
+
curr_labels = list(current.labels)
|
|
174
|
+
T_curr = len(curr_labels)
|
|
175
|
+
# POSITIONAL-LABEL FALLBACK, FIXED 2026-08-17 (independent 'wild' review, cross-fix interaction
|
|
176
|
+
# lens): the label-based new-period detection above assumes labels carry real cross-panel period
|
|
177
|
+
# IDENTITY. `LossPanel.from_losses()`'s default construction (no explicit `labels=`, the most
|
|
178
|
+
# common real usage -- a bare dict of arrays or an ndarray) assigns synthetic positional labels
|
|
179
|
+
# `0..T-1` and sets `labels_are_positional=True`. Two ENTIRELY UNRELATED panels built this way
|
|
180
|
+
# collide on labels purely by list-index coincidence, defeating this function's own "unrelated
|
|
181
|
+
# panels -> None, don't guess" safety net -- verified directly: two panels built from independent
|
|
182
|
+
# random draws (no shared periods in any real sense) still reported the champion change as
|
|
183
|
+
# "entirely due to new periods" or wrongly claimed 10 shared periods, purely because both used
|
|
184
|
+
# the default 0..9 labels. Positional labels carry NO real identity information at all outside
|
|
185
|
+
# the one panel that assigned them, so label-set comparison is meaningless here -- fall back to
|
|
186
|
+
# the position/count-based detection (assume the newest periods are the trailing rows) whenever
|
|
187
|
+
# EITHER side lacks real labels, which is the best available signal absent any identity
|
|
188
|
+
# information the caller chose not to provide. Real, non-positional labels (the case this
|
|
189
|
+
# function's label-based redesign was actually built for) are unaffected.
|
|
190
|
+
if previous.labels_are_positional or current.labels_are_positional:
|
|
191
|
+
T_prev = len(previous.labels)
|
|
192
|
+
n_new = max(0, T_curr - T_prev)
|
|
193
|
+
if n_new == 0:
|
|
194
|
+
# CONFIRMED REGRESSION (control-flow/static-logic audit, 2026-08-17): this function's own
|
|
195
|
+
# docstring promises "if `current` shares NO periods with `previous` at all... reported
|
|
196
|
+
# as None rather than silently attributing the change to data it never actually removed"
|
|
197
|
+
# -- the label-based branch below honors that (n_new==T_curr -> None), but this positional
|
|
198
|
+
# branch did not: `n_new = max(0, T_curr - T_prev)` is forced to 0 whenever T_curr <= T_prev,
|
|
199
|
+
# regardless of whether the two panels share anything real, so it fell straight through to
|
|
200
|
+
# a confident `curr_champ` instead. Positional labels carry NO identity information at
|
|
201
|
+
# all (both panels always trivially have labels 0..T-1), so unlike the label-based branch,
|
|
202
|
+
# there is no way to tell "the literal same panel, nothing new" apart from "an entirely
|
|
203
|
+
# unrelated panel of the same or smaller size" here -- verified directly: two panels built
|
|
204
|
+
# from independent random draws, both length 10, positional labels, reported a confident
|
|
205
|
+
# champion_without_new_periods instead of the honest None this diagnostic exists to give
|
|
206
|
+
# when it cannot actually attribute the change to specific removed data. T_curr < T_prev
|
|
207
|
+
# is even less determinable (nothing to even align positionally), so None applies there
|
|
208
|
+
# too, not just at T_curr == T_prev.
|
|
209
|
+
champion_without_new_periods = None
|
|
210
|
+
else:
|
|
211
|
+
keep = list(range(T_curr - n_new))
|
|
212
|
+
L_trunc = {m: np.asarray(v, float)[keep] for m, v in curr_L.items()}
|
|
213
|
+
w_trunc = np.asarray(curr_w, float)[keep]
|
|
214
|
+
champion_without_new_periods = pooled_winner(L_trunc, w_trunc)
|
|
215
|
+
else:
|
|
216
|
+
# STR-NORMALIZED, FIXED 2026-08-17 (caught in self-review before shipping): `LossPanel.save()`
|
|
217
|
+
# stringifies every label (`[str(l) for l in self.labels]`), so a panel round-tripped through
|
|
218
|
+
# save()/load() has str labels while a freshly-built panel with the SAME periods can have
|
|
219
|
+
# Timestamp (or other) labels -- exactly the real `compare()` workflow (load last run's saved
|
|
220
|
+
# baseline, compare against a freshly computed current run). Comparing raw label objects made
|
|
221
|
+
# EVERY period in `current` look "new" whenever only one side had been through a save/load
|
|
222
|
+
# round trip (Timestamp('2020-01-31') != '2020-01-31 00:00:00' by identity, even though they
|
|
223
|
+
# name the same period) -- reproduced directly: n_new_periods came out as T instead of 0 on two
|
|
224
|
+
# otherwise-identical panels. Comparing str(label) on both sides matches what save() already
|
|
225
|
+
# does, so a loaded and a freshly-built panel over the same periods agree regardless of which
|
|
226
|
+
# side (if either) went through a round trip.
|
|
227
|
+
prev_label_set = {str(lbl) for lbl in previous.labels}
|
|
228
|
+
is_new = [str(lbl) not in prev_label_set for lbl in curr_labels]
|
|
229
|
+
n_new = sum(is_new)
|
|
230
|
+
if n_new == 0:
|
|
231
|
+
champion_without_new_periods = curr_champ
|
|
232
|
+
elif n_new == T_curr:
|
|
233
|
+
champion_without_new_periods = None
|
|
234
|
+
else:
|
|
235
|
+
keep = [i for i, new in enumerate(is_new) if not new]
|
|
236
|
+
L_trunc = {m: np.asarray(v, float)[keep] for m, v in curr_L.items()}
|
|
237
|
+
w_trunc = np.asarray(curr_w, float)[keep]
|
|
238
|
+
champion_without_new_periods = pooled_winner(L_trunc, w_trunc)
|
|
239
|
+
|
|
240
|
+
# w THREADED THROUGH, FIXED 2026-08-16 (independent 'wild' review): matches the same fix in
|
|
241
|
+
# report()/resolution_report() -- the MCS must see the same weights the champion is computed
|
|
242
|
+
# with, or `old_champion_still_in_mcs` can silently answer a different, unweighted question.
|
|
243
|
+
#
|
|
244
|
+
# ROUND-8 FIX (2026-08-27): a genuinely non-uniform curr_w has no native MCS support in
|
|
245
|
+
# arch.bootstrap.MCS -- report()/resolution_report() already refuse cleanly rather than compute
|
|
246
|
+
# the MCS on a silently different (unweighted) question; this call previously let that
|
|
247
|
+
# ValueError propagate raw and uncaught. Caught here the same way, undetermined not silently
|
|
248
|
+
# False, and `.act` is forced False so a CI/pipeline promotion step never treats "couldn't
|
|
249
|
+
# compute" as "genuinely left the MCS."
|
|
250
|
+
mcs_error = None
|
|
251
|
+
try:
|
|
252
|
+
mcs_size(curr_L, alpha=alpha, seed=seed, w=curr_w)
|
|
253
|
+
m = _run_mcs(curr_L, alpha=alpha, seed=seed, w=curr_w)
|
|
254
|
+
old_champion_still_in_mcs = prev_champ in set(m.included)
|
|
255
|
+
except ValueError as e:
|
|
256
|
+
old_champion_still_in_mcs = None
|
|
257
|
+
mcs_error = str(e)
|
|
258
|
+
|
|
259
|
+
k_curr, _opp_curr, _removed_curr = decision_breakdown(curr_L, curr_w, a=curr_champ)
|
|
260
|
+
|
|
261
|
+
churn = _churn_base_rate(curr_L, curr_w, n_perm=n_perm, seed=seed)
|
|
262
|
+
|
|
263
|
+
act = bool(champion_changed and old_champion_still_in_mcs is False)
|
|
264
|
+
|
|
265
|
+
return ChangeReport(
|
|
266
|
+
previous_champion=prev_champ,
|
|
267
|
+
current_champion=curr_champ,
|
|
268
|
+
champion_changed=champion_changed,
|
|
269
|
+
champion_without_new_periods=champion_without_new_periods,
|
|
270
|
+
n_new_periods=n_new,
|
|
271
|
+
old_champion_still_in_mcs=old_champion_still_in_mcs,
|
|
272
|
+
k_star=k_curr,
|
|
273
|
+
churn_base_rate=churn,
|
|
274
|
+
act=act,
|
|
275
|
+
mcs_error=mcs_error,
|
|
276
|
+
)
|