diff --git a/src/rtichoke/calibration/__init__.py b/src/rtichoke/calibration/__init__.py index 6e89c060..afcfae45 100644 --- a/src/rtichoke/calibration/__init__.py +++ b/src/rtichoke/calibration/__init__.py @@ -2,6 +2,8 @@ Subpackage for Calibration """ +import numpy as np + from . import calibration as _calibration from ._interactive_aspect import enforce_square_calibration_panel @@ -16,8 +18,35 @@ ] +def _argument(args, kwargs, name, position): + return kwargs[name] if name in kwargs else args[position] + + +def _validate_probability_values(probs): + for values in probs.values(): + values = np.asarray(values) + if not np.all(np.isfinite(values)) or np.any((values < 0) | (values > 1)): + raise ValueError("Estimated probabilities must be between 0 and 1.") + + +def _validate_outcome_values(reals, allowed_values): + values = reals.values() if isinstance(reals, dict) else [reals] + for outcome_values in values: + if not np.all(np.isin(np.asarray(outcome_values), allowed_values)): + if allowed_values == (0, 1): + raise ValueError("Binary outcomes must contain only 0 and 1.") + raise ValueError( + "Time-dependent outcomes must contain only 0, 1, and 2." + ) + + def create_calibration_curve(*args, **kwargs): """Create an interactive calibration plot with a square main panel.""" + probs = _argument(args, kwargs, "probs", 0) + reals = _argument(args, kwargs, "reals", 1) + _validate_probability_values(probs) + _validate_outcome_values(reals, (0, 1)) + return enforce_square_calibration_panel( _original_create_calibration_curve(*args, **kwargs) ) @@ -25,6 +54,11 @@ def create_calibration_curve(*args, **kwargs): def create_calibration_curve_times(*args, **kwargs): """Create an interactive time-dependent calibration plot with a square main panel.""" + probs = _argument(args, kwargs, "probs", 0) + reals = _argument(args, kwargs, "reals", 1) + _validate_probability_values(probs) + _validate_outcome_values(reals, (0, 1, 2)) + heuristics_sets = kwargs.get("heuristics_sets") if heuristics_sets is None: heuristics_sets = [dict(_DEFAULT_TIME_CALIBRATION_HEURISTICS[0])] diff --git a/tests/test_calibration_input_validation.py b/tests/test_calibration_input_validation.py new file mode 100644 index 00000000..95af28ab --- /dev/null +++ b/tests/test_calibration_input_validation.py @@ -0,0 +1,82 @@ +import numpy as np +import pytest + +from rtichoke import ( + create_calibration_curve as create_calibration_curve_top_level, + create_calibration_curve_times as create_calibration_curve_times_top_level, +) +from rtichoke.calibration import ( + create_calibration_curve, + create_calibration_curve_times, +) +from rtichoke.calibration.calibration import ( + create_calibration_curve as create_calibration_curve_direct, + create_calibration_curve_times as create_calibration_curve_times_direct, +) + + +BINARY_ENTRY_POINTS = [ + create_calibration_curve_top_level, + create_calibration_curve, + create_calibration_curve_direct, +] +TIME_ENTRY_POINTS = [ + create_calibration_curve_times_top_level, + create_calibration_curve_times, + create_calibration_curve_times_direct, +] + + +@pytest.mark.parametrize("entry_point", BINARY_ENTRY_POINTS) +def test_binary_calibration_rejects_probabilities_outside_unit_interval(entry_point): + with pytest.raises(ValueError, match="between 0 and 1"): + entry_point( + probs={"model": np.array([0.1, 1.1])}, + reals=np.array([0, 1]), + ) + + +@pytest.mark.parametrize("entry_point", BINARY_ENTRY_POINTS) +def test_binary_calibration_rejects_nonbinary_outcomes(entry_point): + with pytest.raises(ValueError, match="only 0 and 1"): + entry_point( + probs={"model": np.array([0.1, 0.9])}, + reals=np.array([0, 2]), + ) + + +@pytest.mark.parametrize("entry_point", TIME_ENTRY_POINTS) +def test_time_calibration_rejects_probabilities_outside_unit_interval(entry_point): + with pytest.raises(ValueError, match="between 0 and 1"): + entry_point( + probs={"model": np.array([-0.1, 0.9])}, + reals=np.array([0, 1]), + times=np.array([1.0, 2.0]), + fixed_time_horizons=[1.5], + ) + + +@pytest.mark.parametrize("entry_point", TIME_ENTRY_POINTS) +def test_time_calibration_rejects_unsupported_event_codes(entry_point): + with pytest.raises(ValueError, match="only 0, 1, and 2"): + entry_point( + probs={"model": np.array([0.1, 0.9])}, + reals=np.array([0, 3]), + times=np.array([1.0, 2.0]), + fixed_time_horizons=[1.5], + ) + + +def test_calibration_validation_preserves_supported_multiple_population_shape(): + fig = create_calibration_curve( + probs={ + "population_a": np.array([0.1, 0.8]), + "population_b": np.array([0.2, 0.9]), + }, + reals={ + "population_a": np.array([0, 1]), + "population_b": np.array([0, 1]), + }, + ) + + assert fig is not None