diff --git a/src/rtichoke/performance_data/performance_data_times.py b/src/rtichoke/performance_data/performance_data_times.py index d3177406..7a00175a 100644 --- a/src/rtichoke/performance_data/performance_data_times.py +++ b/src/rtichoke/performance_data/performance_data_times.py @@ -10,6 +10,7 @@ create_aj_data_combinations, create_breaks_values, ) +from rtichoke.processing.time_input_validation import _validate_time_input_alignment from rtichoke.processing.transforms import ( _calculate_cumulative_aj_data, _create_list_data_to_adjust, @@ -187,6 +188,7 @@ def prepare_binned_classification_data_times( represents a unique combination of dataset, bin, time horizon, heuristic, and other strata. """ + _validate_time_input_alignment(probs=probs, reals=reals, times=times) fixed_time_horizons = [float(horizon) for horizon in fixed_time_horizons] breaks = create_breaks_values(None, "probability_threshold", by) diff --git a/src/rtichoke/processing/time_input_validation.py b/src/rtichoke/processing/time_input_validation.py new file mode 100644 index 00000000..32fd4c4c --- /dev/null +++ b/src/rtichoke/processing/time_input_validation.py @@ -0,0 +1,82 @@ +"""Validation helpers for time-dependent performance inputs.""" + +from typing import Dict, Union + +import numpy as np + + +def _validate_time_input_alignment( + probs: Dict[str, np.ndarray], + reals: Union[np.ndarray, Dict[str, np.ndarray]], + times: Union[np.ndarray, Dict[str, np.ndarray]], +) -> None: + """Validate supported array/dict layouts before time-dependent processing.""" + if not isinstance(probs, dict) or not probs: + raise ValueError("`probs` must be a non-empty dictionary of probability arrays.") + + groups = list(probs) + multiple_groups = len(groups) > 1 + reals_is_dict = isinstance(reals, dict) + times_is_dict = isinstance(times, dict) + + if multiple_groups and reals_is_dict != times_is_dict: + raise ValueError( + "For multiple groups, `reals` and `times` must both be arrays or both be dictionaries." + ) + + if multiple_groups and reals_is_dict: + expected_keys = set(groups) + if set(reals) != expected_keys or set(times) != expected_keys: + raise ValueError( + "For multiple populations, `reals` and `times` dictionary keys must exactly match `probs`." + ) + for group in groups: + n_probs = len(np.asarray(probs[group])) + n_reals = len(np.asarray(reals[group])) + n_times = len(np.asarray(times[group])) + if n_probs != n_reals or n_probs != n_times: + raise ValueError( + f"Input lengths must match within group {group!r}: " + f"len(probs)={n_probs}, len(reals)={n_reals}, len(times)={n_times}." + ) + return + + if multiple_groups: + n_reals = len(np.asarray(reals)) + n_times = len(np.asarray(times)) + if n_reals != n_times: + raise ValueError( + "For multiple models sharing outcomes, `reals` and `times` must have the same length." + ) + for group in groups: + n_probs = len(np.asarray(probs[group])) + if n_probs != n_reals: + raise ValueError( + f"Shared outcome length must match probabilities for group {group!r}: " + f"len(probs)={n_probs}, len(reals)={n_reals}, len(times)={n_times}." + ) + return + + group = groups[0] + if reals_is_dict: + if group not in reals: + raise ValueError(f"`reals` is missing the key {group!r} required by `probs`.") + reals_values = reals[group] + else: + reals_values = reals + + if times_is_dict: + if group not in times: + raise ValueError(f"`times` is missing the key {group!r} required by `probs`.") + times_values = times[group] + else: + times_values = times + + n_probs = len(np.asarray(probs[group])) + n_reals = len(np.asarray(reals_values)) + n_times = len(np.asarray(times_values)) + if n_probs != n_reals or n_probs != n_times: + raise ValueError( + f"Input lengths must match for group {group!r}: " + f"len(probs)={n_probs}, len(reals)={n_reals}, len(times)={n_times}." + ) diff --git a/tests/test_time_input_validation.py b/tests/test_time_input_validation.py new file mode 100644 index 00000000..3b34bf57 --- /dev/null +++ b/tests/test_time_input_validation.py @@ -0,0 +1,65 @@ +import numpy as np +import pytest + +from rtichoke import prepare_performance_data_times + + +PROBS = { + "train": np.array([0.1, 0.4, 0.7, 0.9]), + "test": np.array([0.2, 0.3, 0.6, 0.8]), +} +REALS = { + "train": np.array([0, 1, 0, 1]), + "test": np.array([0, 0, 1, 1]), +} +TIMES = { + "train": np.array([8.0, 2.0, 7.0, 3.0]), + "test": np.array([9.0, 8.0, 4.0, 2.0]), +} + + +def _call(probs=PROBS, reals=REALS, times=TIMES): + return prepare_performance_data_times( + probs=probs, + reals=reals, + times=times, + fixed_time_horizons=[5.0], + by=0.5, + ) + + +def test_multiple_population_keys_must_match_probs(): + bad_reals = {"train": REALS["train"], "validation": REALS["test"]} + + with pytest.raises(ValueError, match="keys must exactly match `probs`"): + _call(reals=bad_reals) + + +def test_multiple_groups_reject_mixed_dict_and_array_outcomes(): + with pytest.raises(ValueError, match="must both be arrays or both be dictionaries"): + _call(reals=REALS, times=TIMES["train"]) + + +def test_multiple_population_lengths_must_match_within_group(): + bad_times = {**TIMES, "test": TIMES["test"][:-1]} + + with pytest.raises(ValueError, match="Input lengths must match within group 'test'"): + _call(times=bad_times) + + +def test_multiple_models_can_share_array_outcomes(): + shared_reals = np.array([0, 1, 0, 1]) + shared_times = np.array([8.0, 2.0, 7.0, 3.0]) + + result = _call(reals=shared_reals, times=shared_times) + + assert set(result["reference_group"].to_list()) == {"train", "test"} + + +def test_single_group_accepts_keyed_reals_with_array_times(): + probs = {"train": PROBS["train"]} + reals = {"train": REALS["train"]} + + result = _call(probs=probs, reals=reals, times=TIMES["train"]) + + assert result.height > 0