Skip to content

Commit 89f7eb1

Browse files
authored
Quality review + bugfix: v0.3 surface (#111)
Quality pass over the v0.3 surface (foundation forecast-residual baseline, uncertainty calibration, grown/IGSO dataset + splits) before docs and release. Leakage verified clean across all three sub-systems. - Foundation: a non-finite forecast token no longer zeros a whole channel's residual score (median/MAD fit + standardisation run on finite residuals only), which was silently suppressing detections; adds a NaN regression test and the first cross-track (inclination-channel) detection test. - Calibration: _as_pairs and TemperatureScaling.transform reject non-finite confidences — a NaN previously slipped past the [0, 1] range check and poisoned ECE/Brier and any fitted temperature/conformal quantile to NaN. - docs: corrected benchmark.md's reliability-diagram publishing claim to match what the model cards actually publish (per-class operating points; reliability diagnostics are a calibration-API surface). The latent make_temporal_split per-class running-total imbalance is left as-is (the shipped v0.3 split is non-degenerate; fixing it would regenerate splits.json and invalidate published checkpoint numbers). Closes #63
1 parent ccdd2c6 commit 89f7eb1

5 files changed

Lines changed: 78 additions & 6 deletions

File tree

docs/benchmark.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,8 +88,9 @@ ece = expected_calibration_error(leo.confidences, leo.outcomes)
8888
```
8989

9090
Calibration only rescales the confidence column — it does not change which gaps a detector fires on, just how
91-
confident it says it is. The reliability diagrams and per-class operating points for each shipped detector are
92-
published with the versioned models.
91+
confident it says it is. The per-class operating points for each shipped detector are published with the
92+
versioned models; the reliability diagnostics above (`reliability_curve`, `expected_calibration_error`) are
93+
available from the calibration API for any detector and split.
9394

9495
## Scorer
9596

src/maneuver_detect/calibration.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,11 @@ def _as_pairs(confidences: npt.ArrayLike, outcomes: npt.ArrayLike) -> tuple[Floa
6767
)
6868
if conf.ndim != 1:
6969
raise ValueError(f"confidences and outcomes must be 1-D, got {conf.ndim}-D")
70+
# A NaN slips past the range check below (``nan < 0`` and ``nan > 1`` are both False), so guard
71+
# it explicitly — an unfiltered NaN confidence silently poisons ECE/Brier and biases a fitted
72+
# temperature or conformal quantile to NaN with no error.
73+
if conf.size and not np.isfinite(conf).all():
74+
raise ValueError("confidences must be finite")
7075
if conf.size and (conf.min() < 0.0 or conf.max() > 1.0):
7176
raise ValueError("confidences must lie in [0, 1]")
7277
if out.size and not np.isin(out, (0.0, 1.0)).all():
@@ -239,6 +244,8 @@ def fit(
239244
def transform(self, confidences: npt.ArrayLike) -> FloatArray:
240245
"""Rescale ``confidences`` through the fitted temperature, staying in ``[0, 1]``."""
241246
conf = np.asarray(confidences, dtype=np.float64)
247+
if conf.size and not np.isfinite(conf).all():
248+
raise ValueError("confidences must be finite")
242249
if conf.size and (conf.min() < 0.0 or conf.max() > 1.0):
243250
raise ValueError("confidences must lie in [0, 1]")
244251
return _sigmoid(_logit(conf) / self.temperature)

src/maneuver_detect/detectors/foundation.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -358,12 +358,21 @@ def _residual_score(self, channels: RawChannels, elements: _AlignedElements) ->
358358
forecast = self._forecaster.forecast(series) # type: ignore[union-attr]
359359
residual = series - forecast.mean # residual[0] is undefined (no forecast at token 0)
360360
body = residual[1:]
361-
center = float(np.median(body))
362-
mad = 1.4826 * float(np.median(np.abs(body - center)))
361+
# Centre and scale on the *finite* residuals only. A single non-finite forecast value
362+
# (a forecaster edge case, or a cleaned series with an interior gap) would otherwise
363+
# poison the per-object median/MAD into NaN, drive the scale to NaN, and zero this
364+
# channel's score for every token — silently suppressing real detections. Instead a
365+
# non-finite token simply scores 0 (it cannot trigger a detection) while the rest of
366+
# the channel scores normally.
367+
finite = np.isfinite(body)
368+
if not finite.any():
369+
continue
370+
center = float(np.median(body[finite]))
371+
mad = 1.4826 * float(np.median(np.abs(body[finite] - center)))
363372
scale = max(mad, floor)
364373
z = np.zeros(n, dtype=np.float64)
365-
z[1:] = np.abs(body - center) / scale
366-
score = np.maximum(score, np.nan_to_num(z, nan=0.0, posinf=0.0, neginf=0.0))
374+
z[1:][finite] = np.abs(body[finite] - center) / scale
375+
score = np.maximum(score, z)
367376
return score
368377

369378

tests/test_calibration.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ def test_empty_sample_scores_zero() -> None:
6262
([0.5, 0.5], [1.0], "same shape"),
6363
([1.5], [1.0], r"\[0, 1\]"),
6464
([0.5], [2.0], "0.0 .* or 1.0"),
65+
([float("nan"), 0.5], [1.0, 0.0], "finite"), # a NaN must not slip past the range check
6566
],
6667
)
6768
def test_input_guard_rejects_bad_pairs(
@@ -113,6 +114,12 @@ def test_temperature_fit_rejects_empty_sample() -> None:
113114
TemperatureScaling.fit([], [])
114115

115116

117+
def test_temperature_transform_rejects_non_finite() -> None:
118+
# A NaN confidence must not pass through transform un-flagged (it would emit a NaN confidence).
119+
with pytest.raises(ValueError, match="finite"):
120+
TemperatureScaling(temperature=2.0).transform([0.5, float("nan")])
121+
122+
116123
# --- split conformal ---------------------------------------------------------------------------
117124

118125

tests/test_detectors_foundation.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,21 @@ def _stand_in_detector(threshold: float | None = None) -> ChronosResidualDetecto
3636
)
3737

3838

39+
class _NaNInjectingForecaster:
40+
"""The drift stand-in with one non-finite forecast token poked in, to prove a single bad
41+
forecast value cannot zero a whole channel's residual score."""
42+
43+
def __init__(self, bad_index: int) -> None:
44+
self._inner = DriftContinuationForecaster()
45+
self._bad_index = bad_index
46+
47+
def forecast(self, series: np.ndarray) -> Forecast:
48+
base = self._inner.forecast(series)
49+
mean = base.mean.copy()
50+
mean[self._bad_index] = np.nan
51+
return Forecast(mean=mean, scale=base.scale)
52+
53+
3954
def test_foundation_detector_is_registered() -> None:
4055
assert "chronos-residual" in set(available_models())
4156
assert isinstance(get_detector("chronos-residual"), ChronosResidualDetector)
@@ -56,6 +71,39 @@ def test_detects_an_injected_in_track_burn() -> None:
5671
assert strongest["delta_v_estimate"] > 0.0 # a 4 m/s in-track burn clears the LEO floor
5772

5873

74+
def test_detects_an_injected_cross_track_burn() -> None:
75+
# A cross-track burn steps the inclination, so the detection comes from the *inclination*
76+
# channel — every other detection assertion in this suite rides the in-track / semi-major-axis
77+
# channel, so this is the one place the cross-track channel is pinned to actually fire.
78+
frame = synthetic_series(norad_id=1, seed=3, n=120, burns=(Burn(45, "cross_track_ms", 4.0),))
79+
out = _stand_in_detector().detect(frame)
80+
81+
validate_frame(out)
82+
assert not out.empty
83+
near_burn = out[
84+
(out["elset_epoch_after"] >= frame["epoch"].iloc[44])
85+
& (out["elset_epoch_after"] <= frame["epoch"].iloc[46])
86+
]
87+
assert not near_burn.empty # the inclination channel fires on the cross-track burn's gap
88+
assert (near_burn["confidence"].between(0.0, 1.0)).all()
89+
90+
91+
def test_a_single_nonfinite_forecast_does_not_zero_the_channel() -> None:
92+
# A non-finite forecast at an unrelated quiet token must not wipe the whole channel's score:
93+
# the in-track burn at token 45 is still detected (the pre-fix median/MAD would NaN the scale
94+
# and zero every token of the channel).
95+
frame = synthetic_series(norad_id=1, seed=3, n=120, burns=(Burn(45, "in_track_ms", 4.0),))
96+
detector = ChronosResidualDetector(
97+
forecaster=_NaNInjectingForecaster(bad_index=10),
98+
class_thresholds=_THRESHOLDS,
99+
)
100+
out = detector.detect(frame)
101+
102+
assert not out.empty
103+
strongest = out.loc[out["confidence"].idxmax()]
104+
assert frame["epoch"].iloc[44] <= strongest["elset_epoch_after"] <= frame["epoch"].iloc[46]
105+
106+
59107
def test_threshold_gates_detection_count() -> None:
60108
frame = synthetic_series(norad_id=1, seed=4, n=120, burns=(Burn(60, "in_track_ms", 4.0),))
61109
low = _stand_in_detector(threshold=2.0).detect(frame)

0 commit comments

Comments
 (0)