diff --git a/src/rtichoke/_viz_spec_v2.py b/src/rtichoke/_viz_spec_v2.py index 70992627..8aacf154 100644 --- a/src/rtichoke/_viz_spec_v2.py +++ b/src/rtichoke/_viz_spec_v2.py @@ -102,9 +102,12 @@ def _lift_v2_spec_from_performance_data( references: list[dict[str, object]] = [ {"type": "horizontal", "value": 1.0, "scope": "global", "label": "Random"} ] + perfect_heights: list[float] = [] for population in populations: p = prevalence[population] if p > 0: + perfect_height = 1.0 / p + perfect_heights.append(perfect_height) references.append( { "type": "path", @@ -112,12 +115,16 @@ def _lift_v2_spec_from_performance_data( "population": population, "label": "Perfect Model", "points": [ - {"x": 0.0, "y": 1.0 / p}, - {"x": p, "y": 1.0 / p}, + {"x": 0.0, "y": perfect_height}, + {"x": p, "y": perfect_height}, {"x": 1.0, "y": 1.0}, ], } ) + spec["yAxis"] = { + "label": "Lift", + "domain": [0, _lift_y_axis_upper_bound(performance_data, perfect_heights)], + } spec["references"] = references return spec @@ -356,8 +363,11 @@ def _lift_times_v2_spec_from_performance_data( references: list[dict[str, object]] = [ {"type": "horizontal", "value": 1.0, "scope": "global", "label": "Random"} ] + perfect_heights: list[float] = [] for (population, horizon), risk in risks.items(): if risk > 0: + perfect_height = 1.0 / risk + perfect_heights.append(perfect_height) references.append( { "type": "path", @@ -366,8 +376,8 @@ def _lift_times_v2_spec_from_performance_data( "horizon": horizon, "label": "Perfect Model", "points": [ - {"x": 0.0, "y": 1.0 / risk}, - {"x": risk, "y": 1.0 / risk}, + {"x": 0.0, "y": perfect_height}, + {"x": risk, "y": perfect_height}, {"x": 1.0, "y": 1.0}, ], } @@ -382,7 +392,10 @@ def _lift_times_v2_spec_from_performance_data( "x": "ppcr", "y": "lift", "xAxis": {"label": "Predicted Positives (Rate)", "domain": [0, 1]}, - "yAxis": {"label": "Lift", "domain": [0, None]}, + "yAxis": { + "label": "Lift", + "domain": [0, _lift_y_axis_upper_bound(performance_data, perfect_heights)], + }, "references": references, } @@ -473,6 +486,16 @@ def _gains_population_prevalence( return prevalence +def _lift_y_axis_upper_bound( + performance_data: pl.DataFrame, + perfect_heights: list[float], +) -> float: + """Return the finite numeric Lift bound implied by existing plot quantities.""" + observed_lift = performance_data["lift"].max() + assert isinstance(observed_lift, (int, float)) + return max(1.0, float(observed_lift), *perfect_heights) + + def _curve_v2_spec_from_performance_data( performance_data: pl.DataFrame, evaluation_metadata: Mapping[str, _EvaluationMetadata], @@ -609,6 +632,9 @@ def _curve_v2_spec_from_performance_data( "x": "ppcr", "y": "lift", "xAxis": {"label": "Predicted Positives (Rate)", "domain": [0, 1]}, - "yAxis": {"label": "Lift", "domain": [0, None]}, + "yAxis": { + "label": "Lift", + "domain": [0, _lift_y_axis_upper_bound(performance_data, [])], + }, "references": [], } diff --git a/tests/test_lift_v2.py b/tests/test_lift_v2.py index e41c2bc5..7e242bed 100644 --- a/tests/test_lift_v2.py +++ b/tests/test_lift_v2.py @@ -61,6 +61,31 @@ def test_static_lift_v2_spec_single_model(): ] +def test_static_lift_v2_y_axis_domain_is_numeric_and_covers_plot(): + probs = { + "Population A": np.array([0.1, 0.2, 0.7, 0.9]), + "Population B": np.array([0.1, 0.3, 0.4, 0.8]), + } + reals = { + "Population A": np.array([0, 0, 1, 1]), + "Population B": np.array([0, 0, 0, 1]), + } + spec = _spec(probs, reals) + + upper = spec["yAxis"]["domain"][1] + observed_upper = max(float(row["lift"]) for row in spec["data"]) + perfect_upper = max( + float(point["y"]) + for reference in spec["references"] + if reference["type"] == "path" + for point in reference["points"] + ) + + assert isinstance(upper, (int, float)) + assert np.isfinite(upper) + assert spec["yAxis"]["domain"] == [0, max(1.0, observed_upper, perfect_upper)] + + def test_static_lift_v2_spec_shared_population(): probs, reals = _shared_model_inputs() spec = _spec(probs, reals) diff --git a/tests/test_time_lift_v2.py b/tests/test_time_lift_v2.py index cd31fd60..c7335390 100644 --- a/tests/test_time_lift_v2.py +++ b/tests/test_time_lift_v2.py @@ -86,6 +86,24 @@ def test_time_lift_uses_one_evaluation_and_series_per_model_horizon(): } +def test_time_lift_v2_y_axis_domain_is_numeric_and_covers_plot(): + probs, reals, times = _shared_inputs() + spec = _spec(probs, reals, times) + + upper = spec["yAxis"]["domain"][1] + observed_upper = max(float(row["lift"]) for row in spec["data"]) + perfect_upper = max( + float(point["y"]) + for reference in spec["references"] + if reference["type"] == "path" + for point in reference["points"] + ) + + assert isinstance(upper, (int, float)) + assert np.isfinite(upper) + assert spec["yAxis"]["domain"] == [0, max(1.0, observed_upper, perfect_upper)] + + def test_equal_risk_population_horizons_remain_distinct_lift_reference_owners(): probs = { "Population A": np.array([0.05, 0.2, 0.7, 0.95]),