Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/rtichoke/performance_data/performance_data_times.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
82 changes: 82 additions & 0 deletions src/rtichoke/processing/time_input_validation.py
Original file line number Diff line number Diff line change
@@ -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}."
)
65 changes: 65 additions & 0 deletions tests/test_time_input_validation.py
Original file line number Diff line number Diff line change
@@ -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
Loading