diff --git a/src/rtichoke/_renderers.py b/src/rtichoke/_renderers.py index 2de79394..712d942e 100644 --- a/src/rtichoke/_renderers.py +++ b/src/rtichoke/_renderers.py @@ -54,6 +54,7 @@ def write_html(self, path: str | Path) -> Path: "calibration": "renderCalibrationV2", "precision_recall": "renderPrecisionRecallV2", "gains": "renderGainsV2", + "lift": "renderLiftV2", }.get(str(self.spec.get("type"))) if render_export is None: raise ValueError( @@ -189,3 +190,120 @@ def _render_gains_v2( if selected in {"browser", "rtichoke_viz"}: return RtichokeBrowserChart(spec=spec, size=size) raise ValueError("The Plotly renderer must use the existing production path.") + + +def _render_lift_matplotlib( + spec: dict[str, Any], *, size: int, color_values: list[str] +) -> Any: + """Render canonical lift quantities with an optional Matplotlib backend.""" + try: + from matplotlib.figure import Figure + except ImportError as error: + raise ImportError( + "The 'matplotlib' renderer requires the optional matplotlib dependency. " + "Install it with `pip install 'rtichoke[matplotlib]'`." + ) from error + + series = spec.get("series", []) + data = spec.get("data", []) + assert isinstance(series, list) and isinstance(data, list) + horizons = list( + dict.fromkeys( + item["horizon"] for item in series if item.get("horizon") is not None + ) + ) + panels: list[float | None] = horizons or [None] + figure = Figure(figsize=(size / 100 * len(panels), size / 100), dpi=100) + axes_value = figure.subplots(1, len(panels), squeeze=False) + axes = list(axes_value[0]) + references = spec.get("references", []) + assert isinstance(references, list) + display_groups = list(dict.fromkeys(item["display"]["group"] for item in series)) + colors = { + group: ( + "black" + if len(display_groups) == 1 + else color_values[index % len(color_values)] + ) + for index, group in enumerate(display_groups) + } + x_axis = spec["xAxis"] + y_axis = spec["yAxis"] + for axis, horizon in zip(axes, panels): + for reference in references: + if not isinstance(reference, dict): + continue + if ( + reference.get("scope") == "population_horizon" + and reference.get("horizon") != horizon + ): + continue + if reference.get("type") == "horizontal": + value = reference.get("value", 1.0) + axis.axhline( + y=value, + color="#BEBEBE", + linestyle="--", + linewidth=2, + ) + elif reference.get("type") == "path": + points = reference.get("points", []) + x_values = [point["x"] for point in points] + y_values = [point["y"] for point in points] + axis.plot( + x_values, + y_values, + color="#BEBEBE", + linestyle="--", + linewidth=2, + ) + else: + continue + + panel_series = [ + item + for item in series + if item.get("horizon") is None or item.get("horizon") == horizon + ] + for item in panel_series: + rows = [row for row in data if row["seriesId"] == item["id"]] + display = item["display"] + axis.plot( + [row["ppcr"] for row in rows], + [row["lift"] for row in rows], + label=display["label"], + color=colors[display["group"]], + linewidth=2, + ) + + axis.set_xlabel(x_axis["label"]) + axis.set_ylabel(y_axis["label"]) + axis.set_xlim(*x_axis["domain"]) + if y_axis["domain"][1] is not None: + axis.set_ylim(*y_axis["domain"]) + else: + axis.set_ylim(bottom=y_axis["domain"][0]) + if horizon is not None: + axis.set_title(f"Fixed Time Horizon: {horizon:g}") + if len(panel_series) > 1: + axis.legend() + figure.tight_layout() + return figure + + +def _render_lift_v2( + spec: dict[str, Any], + *, + renderer: str, + size: int, + color_values: list[str], +) -> Any: + """Render a canonical lift v2 spec with a non-default backend.""" + selected = _validate_renderer(renderer) + if selected == "matplotlib": + return _render_lift_matplotlib(spec, size=size, color_values=color_values) + if selected in {"browser", "rtichoke_viz"}: + raise ValueError( + "Browser rendering for Lift curves requires a newer vendored release of rtichoke_viz containing Lift support." + ) + raise ValueError("The Plotly renderer must use the existing production path.") diff --git a/src/rtichoke/_viz_spec_v2.py b/src/rtichoke/_viz_spec_v2.py index 7253d4b0..70992627 100644 --- a/src/rtichoke/_viz_spec_v2.py +++ b/src/rtichoke/_viz_spec_v2.py @@ -27,6 +27,14 @@ "real_positives", "n", } +_REQUIRED_LIFT_COLUMNS = { + "reference_group", + "chosen_cutoff", + "lift", + "ppcr", + "real_positives", + "n", +} def _roc_v2_spec_from_performance_data( @@ -76,6 +84,44 @@ def _gains_v2_spec_from_performance_data( return spec +def _lift_v2_spec_from_performance_data( + performance_data: pl.DataFrame, + evaluation_metadata: Mapping[str, _EvaluationMetadata], +) -> dict[str, object]: + """Build a canonical lift-v2 spec from production performance quantities.""" + spec = _curve_v2_spec_from_performance_data( + performance_data, + evaluation_metadata, + chart_type="lift", + ) + prevalence = _gains_population_prevalence(performance_data, evaluation_metadata) + + populations = list( + dict.fromkeys(metadata.population for metadata in evaluation_metadata.values()) + ) + references: list[dict[str, object]] = [ + {"type": "horizontal", "value": 1.0, "scope": "global", "label": "Random"} + ] + for population in populations: + p = prevalence[population] + if p > 0: + references.append( + { + "type": "path", + "scope": "population", + "population": population, + "label": "Perfect Model", + "points": [ + {"x": 0.0, "y": 1.0 / p}, + {"x": p, "y": 1.0 / p}, + {"x": 1.0, "y": 1.0}, + ], + } + ) + spec["references"] = references + return spec + + def _gains_times_v2_spec_from_performance_data( performance_data: pl.DataFrame, evaluation_metadata: Mapping[str, _EvaluationMetadata], @@ -207,6 +253,140 @@ def _gains_times_v2_spec_from_performance_data( } +def _lift_times_v2_spec_from_performance_data( + performance_data: pl.DataFrame, + evaluation_metadata: Mapping[str, _EvaluationMetadata], +) -> dict[str, object]: + """Build canonical time-dependent lift from calculated production data.""" + required = _REQUIRED_LIFT_COLUMNS | { + "fixed_time_horizon", + "censoring_heuristic", + "competing_heuristic", + } + missing = required.difference(performance_data.columns) + if missing: + raise ValueError( + "Time-dependent lift performance data is missing columns: " + + ", ".join(sorted(missing)) + ) + + rows = performance_data.select( + "reference_group", + "fixed_time_horizon", + "censoring_heuristic", + "competing_heuristic", + "chosen_cutoff", + "lift", + "ppcr", + ).to_dicts() + row_groups = {str(row["reference_group"]) for row in rows} + missing_metadata = row_groups.difference(evaluation_metadata) + if missing_metadata: + raise ValueError( + "Time-dependent lift rows are missing evaluation metadata: " + + ", ".join(sorted(missing_metadata)) + ) + + ordered_groups = [group for group in evaluation_metadata if group in row_groups] + evaluation_ids = { + group: f"evaluation-{index}" + for index, group in enumerate(ordered_groups, start=1) + } + evaluations = [] + for group in ordered_groups: + metadata = evaluation_metadata[group] + evaluation: dict[str, object] = { + "id": evaluation_ids[group], + "population": metadata.population, + } + if metadata.model is not None: + evaluation["model"] = metadata.model + evaluations.append(evaluation) + + series_keys = list( + dict.fromkeys( + ( + str(row["reference_group"]), + float(row["fixed_time_horizon"]), + str(row["censoring_heuristic"]), + str(row["competing_heuristic"]), + ) + for row in rows + ) + ) + series_ids = { + key: f"series-{index}" for index, key in enumerate(series_keys, start=1) + } + series = [] + for key in series_keys: + group, horizon, _, _ = key + metadata = evaluation_metadata[group] + display_value = metadata.model or metadata.population + series.append( + { + "id": series_ids[key], + "evaluationId": evaluation_ids[group], + "horizon": horizon, + "display": { + "label": display_value, + "group": display_value, + "role": "model" if metadata.model is not None else "population", + }, + } + ) + + data = [] + for row in rows: + key = ( + str(row["reference_group"]), + float(row["fixed_time_horizon"]), + str(row["censoring_heuristic"]), + str(row["competing_heuristic"]), + ) + data.append( + { + "seriesId": series_ids[key], + "cutoff": row["chosen_cutoff"], + "ppcr": row["ppcr"], + "lift": row["lift"], + } + ) + + risks = _gains_population_horizon_risk(performance_data, evaluation_metadata) + references: list[dict[str, object]] = [ + {"type": "horizontal", "value": 1.0, "scope": "global", "label": "Random"} + ] + for (population, horizon), risk in risks.items(): + if risk > 0: + references.append( + { + "type": "path", + "scope": "population_horizon", + "population": population, + "horizon": horizon, + "label": "Perfect Model", + "points": [ + {"x": 0.0, "y": 1.0 / risk}, + {"x": risk, "y": 1.0 / risk}, + {"x": 1.0, "y": 1.0}, + ], + } + ) + + return { + "schemaVersion": "2.0", + "type": "lift", + "evaluations": evaluations, + "series": series, + "data": data, + "x": "ppcr", + "y": "lift", + "xAxis": {"label": "Predicted Positives (Rate)", "domain": [0, 1]}, + "yAxis": {"label": "Lift", "domain": [0, None]}, + "references": references, + } + + def _gains_population_horizon_risk( performance_data: pl.DataFrame, evaluation_metadata: Mapping[str, _EvaluationMetadata], @@ -312,6 +492,9 @@ def _curve_v2_spec_from_performance_data( elif chart_type == "gains": required = _REQUIRED_GAINS_COLUMNS selected = ["reference_group", "chosen_cutoff", "sensitivity", "ppcr"] + elif chart_type == "lift": + required = _REQUIRED_LIFT_COLUMNS + selected = ["reference_group", "chosen_cutoff", "lift", "ppcr"] else: raise ValueError(f"Unsupported v2 curve type: {chart_type}") @@ -377,12 +560,16 @@ def _curve_v2_spec_from_performance_data( datum = { "seriesId": series_ids[str(row["reference_group"])], "cutoff": row["chosen_cutoff"], - "sensitivity": row["sensitivity"], } if chart_type == "roc": + datum["sensitivity"] = row["sensitivity"] datum["specificity"] = row["specificity"] - else: + elif chart_type == "gains": + datum["sensitivity"] = row["sensitivity"] datum["ppcr"] = row["ppcr"] + elif chart_type == "lift": + datum["ppcr"] = row["ppcr"] + datum["lift"] = row["lift"] data.append(datum) if chart_type == "roc": @@ -399,15 +586,29 @@ def _curve_v2_spec_from_performance_data( "references": [{"type": "identity", "scope": "global"}], } + if chart_type == "gains": + return { + "schemaVersion": "2.0", + "type": "gains", + "evaluations": evaluations, + "series": series, + "data": data, + "x": "ppcr", + "y": "sensitivity", + "xAxis": {"label": "Predicted Positives (Rate)", "domain": [0, 1]}, + "yAxis": {"label": "Sensitivity", "domain": [0, 1]}, + "references": [], + } + return { "schemaVersion": "2.0", - "type": "gains", + "type": "lift", "evaluations": evaluations, "series": series, "data": data, "x": "ppcr", - "y": "sensitivity", + "y": "lift", "xAxis": {"label": "Predicted Positives (Rate)", "domain": [0, 1]}, - "yAxis": {"label": "Sensitivity", "domain": [0, 1]}, + "yAxis": {"label": "Lift", "domain": [0, None]}, "references": [], } diff --git a/src/rtichoke/discrimination/lift.py b/src/rtichoke/discrimination/lift.py index b0fc7c2d..f95dcc7f 100644 --- a/src/rtichoke/discrimination/lift.py +++ b/src/rtichoke/discrimination/lift.py @@ -2,7 +2,7 @@ A module for Lift Curves using Plotly helpers """ -from typing import Dict, List, Sequence, Union +from typing import Any, Dict, List, Sequence, Union from plotly.graph_objs._figure import Figure from rtichoke.processing.binary_color_values import _apply_color_values_binary from rtichoke.processing.plotly_helper_functions import ( @@ -14,6 +14,13 @@ ) import numpy as np import polars as pl +from rtichoke._renderers import _render_lift_v2, _validate_renderer +from rtichoke._viz_spec_v2 import ( + _lift_times_v2_spec_from_performance_data, + _lift_v2_spec_from_performance_data, +) +from rtichoke.performance_data.performance_data import prepare_performance_data +from rtichoke.processing.evaluation_semantics import _build_evaluation_metadata def create_lift_curve( @@ -44,7 +51,8 @@ def create_lift_curve( "#D1603D", "#585123", ], -) -> Figure: + renderer: str = "plotly", +) -> Any: """Creates a Lift curve. A Lift curve is a visual tool used to evaluate the performance of a @@ -68,11 +76,37 @@ def create_lift_curve( color_values : List[str], optional A list of hex color strings for the plot lines. + renderer : {"plotly", "matplotlib", "browser", "rtichoke_viz"}, optional + Rendering backend. The default, ``"plotly"``, preserves the existing + return value and behavior. ``"matplotlib"`` requires the optional + Matplotlib dependency. ``"browser"`` and its ``"rtichoke_viz"`` alias + return an offline browser chart backed by the packaged TypeScript bundle. + Returns ------- - Figure - A Plotly ``Figure`` object representing the Lift curve. + Figure or RtichokeBrowserChart + A Plotly or Matplotlib figure, or an offline browser chart, depending + on ``renderer``. """ + selected_renderer = _validate_renderer(renderer) + if selected_renderer != "plotly": + performance_data = prepare_performance_data( + probs=probs, + reals=reals, + stratified_by=stratified_by, + by=by, + ) + evaluation_metadata = _build_evaluation_metadata(probs, reals, np.array([])) + spec = _lift_v2_spec_from_performance_data( + performance_data, evaluation_metadata + ) + return _render_lift_v2( + spec, + renderer=selected_renderer, + size=size, + color_values=color_values, + ) + fig = _create_rtichoke_plotly_curve_binary( probs, reals, @@ -156,7 +190,8 @@ def create_lift_curve_times( "#D1603D", "#585123", ], -) -> Figure: + renderer: str = "plotly", +) -> Any: """Creates a time-dependent Lift curve. Generates a Lift curve for time-to-event models, which is evaluated at @@ -183,11 +218,40 @@ def create_lift_curve_times( color_values : List[str], optional A list of hex color strings for the plot lines. + renderer : {"plotly", "matplotlib", "browser", "rtichoke_viz"}, optional + Rendering backend. Plotly remains the default production behavior. + Returns ------- - Figure - A Plotly ``Figure`` object for the time-dependent Lift curve. + Figure or RtichokeBrowserChart + A Plotly or Matplotlib figure, or an offline browser chart, depending + on ``renderer``. """ + selected_renderer = _validate_renderer(renderer) + if selected_renderer != "plotly": + from rtichoke.performance_data.performance_data_times import ( + prepare_performance_data_times, + ) + + performance_data = prepare_performance_data_times( + probs, + reals, + times, + fixed_time_horizons=fixed_time_horizons, + heuristics_sets=heuristics_sets, + by=by, + stratified_by=stratified_by, + ) + evaluation_metadata = _build_evaluation_metadata(probs, reals, times) + spec = _lift_times_v2_spec_from_performance_data( + performance_data, evaluation_metadata + ) + return _render_lift_v2( + spec, + renderer=selected_renderer, + size=size, + color_values=color_values, + ) fig = _create_rtichoke_plotly_curve_times_reference_safe( probs, diff --git a/tests/test_lift_v2.py b/tests/test_lift_v2.py new file mode 100644 index 00000000..e41c2bc5 --- /dev/null +++ b/tests/test_lift_v2.py @@ -0,0 +1,149 @@ +import matplotlib.figure +import numpy as np +import plotly.graph_objects as go +import pytest + +from rtichoke import create_lift_curve +from rtichoke._viz_spec_v2 import _lift_v2_spec_from_performance_data +from rtichoke.performance_data.performance_data import prepare_performance_data +from rtichoke.processing.evaluation_semantics import ( + _SHARED_POPULATION, + _build_evaluation_metadata, +) + + +def _shared_model_inputs(): + return ( + { + "Model A": np.array([0.05, 0.15, 0.35, 0.55, 0.75, 0.95]), + "Model B": np.array([0.10, 0.25, 0.45, 0.65, 0.80, 0.90]), + }, + np.array([0, 0, 0, 1, 1, 1]), + ) + + +def _spec(probs, reals): + performance = prepare_performance_data(probs, reals, by=0.25) + metadata = _build_evaluation_metadata(probs, reals, np.array([])) + return _lift_v2_spec_from_performance_data(performance, metadata) + + +def test_static_lift_v2_spec_single_model(): + probs = {"Model A": np.array([0.1, 0.4, 0.7, 0.9])} + reals = np.array([0, 0, 1, 1]) + spec = _spec(probs, reals) + + assert spec["schemaVersion"] == "2.0" + assert spec["type"] == "lift" + assert spec["x"] == "ppcr" + assert spec["y"] == "lift" + assert len(spec["evaluations"]) == 1 + assert len(spec["series"]) == 1 + assert spec["evaluations"][0]["id"] == "evaluation-1" + assert spec["series"][0]["id"] == "series-1" + assert spec["series"][0]["evaluationId"] == "evaluation-1" + + # Random Guess & Perfect Prediction + assert spec["references"][0] == { + "type": "horizontal", + "value": 1.0, + "scope": "global", + "label": "Random", + } + perfect = spec["references"][1] + assert perfect["type"] == "path" + assert perfect["scope"] == "population" + assert perfect["population"] == _SHARED_POPULATION + assert perfect["points"] == [ + {"x": 0.0, "y": 2.0}, + {"x": 0.5, "y": 2.0}, + {"x": 1.0, "y": 1.0}, + ] + + +def test_static_lift_v2_spec_shared_population(): + probs, reals = _shared_model_inputs() + spec = _spec(probs, reals) + + assert len(spec["evaluations"]) == 2 + assert len(spec["series"]) == 2 + assert spec["series"][0]["id"] == "series-1" + assert spec["series"][1]["id"] == "series-2" + + # Multiple models on shared population -> ONE Perfect Prediction path + assert len(spec["references"]) == 2 + assert spec["references"][1]["population"] == _SHARED_POPULATION + assert spec["references"][1]["points"] == [ + {"x": 0.0, "y": 2.0}, + {"x": 0.5, "y": 2.0}, + {"x": 1.0, "y": 1.0}, + ] + + +def test_static_lift_v2_spec_multiple_populations(): + 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) + + assert len(spec["evaluations"]) == 2 + assert len(spec["series"]) == 2 + assert len(spec["references"]) == 3 # 1 global Random + 2 population Perfect + + pop_a_ref = [ + r for r in spec["references"] if r.get("population") == "Population A" + ][0] + pop_b_ref = [ + r for r in spec["references"] if r.get("population") == "Population B" + ][0] + + # Pop A prevalence = 0.5 -> y = 2.0 + assert pop_a_ref["points"] == [ + {"x": 0.0, "y": 2.0}, + {"x": 0.5, "y": 2.0}, + {"x": 1.0, "y": 1.0}, + ] + # Pop B prevalence = 0.25 -> y = 4.0 + assert pop_b_ref["points"] == [ + {"x": 0.0, "y": 4.0}, + {"x": 0.25, "y": 4.0}, + {"x": 1.0, "y": 1.0}, + ] + + +def test_equal_prevalence_distinct_populations_maintain_separate_references(): + probs = { + "Population A": np.array([0.1, 0.2, 0.7, 0.9]), + "Population B": np.array([0.15, 0.25, 0.75, 0.85]), + } + reals = { + "Population A": np.array([0, 0, 1, 1]), + "Population B": np.array([0, 0, 1, 1]), + } + spec = _spec(probs, reals) + + perfect_refs = spec["references"][1:] + assert len(perfect_refs) == 2 + assert {r["population"] for r in perfect_refs} == {"Population A", "Population B"} + assert perfect_refs[0]["points"] == perfect_refs[1]["points"] + + +def test_static_lift_renderers(): + probs, reals = _shared_model_inputs() + + default_fig = create_lift_curve(probs, reals, by=0.25) + assert isinstance(default_fig, go.Figure) + + mpl_fig = create_lift_curve(probs, reals, by=0.25, renderer="matplotlib") + assert isinstance(mpl_fig, matplotlib.figure.Figure) + + with pytest.raises( + ValueError, + match="Browser rendering for Lift curves requires a newer vendored release of rtichoke_viz", + ): + create_lift_curve(probs, reals, by=0.25, renderer="browser") diff --git a/tests/test_time_lift_v2.py b/tests/test_time_lift_v2.py new file mode 100644 index 00000000..cd31fd60 --- /dev/null +++ b/tests/test_time_lift_v2.py @@ -0,0 +1,180 @@ +import matplotlib.figure +import numpy as np +import plotly.graph_objects as go +import pytest + +from rtichoke import create_lift_curve_times +from rtichoke._viz_spec_v2 import _lift_times_v2_spec_from_performance_data +from rtichoke.performance_data.performance_data_times import ( + prepare_performance_data_times, +) +from rtichoke.processing.evaluation_semantics import ( + _SHARED_POPULATION, + _build_evaluation_metadata, +) + +HORIZONS = [5.0, 10.0] +HEURISTICS = [ + { + "censoring_heuristic": "adjusted", + "competing_heuristic": "adjusted_as_negative", + } +] + + +def _shared_inputs(): + return ( + { + "Model A": np.array([0.05, 0.15, 0.35, 0.55, 0.75, 0.95]), + "Model B": np.array([0.10, 0.25, 0.45, 0.65, 0.80, 0.90]), + }, + np.array([1, 0, 1, 0, 1, 0]), + np.array([3.0, 12.0, 8.0, 13.0, 14.0, 15.0]), + ) + + +def _spec(probs, reals, times): + performance = prepare_performance_data_times( + probs, + reals, + times, + fixed_time_horizons=HORIZONS, + heuristics_sets=HEURISTICS, + by=0.25, + ) + return _lift_times_v2_spec_from_performance_data( + performance, _build_evaluation_metadata(probs, reals, times) + ) + + +def test_time_lift_uses_one_evaluation_and_series_per_model_horizon(): + probs, reals, times = _shared_inputs() + spec = _spec(probs, reals, times) + + assert spec["schemaVersion"] == "2.0" + assert spec["type"] == "lift" + assert spec["x"] == "ppcr" + assert spec["y"] == "lift" + assert len(spec["evaluations"]) == 2 + assert {evaluation["population"] for evaluation in spec["evaluations"]} == { + _SHARED_POPULATION + } + assert len(spec["series"]) == 4 + assert { + (series["display"]["group"], series["horizon"]) for series in spec["series"] + } == { + ("Model A", 5.0), + ("Model A", 10.0), + ("Model B", 5.0), + ("Model B", 10.0), + } + + # Perfect Prediction references (scope = population_horizon) + assert spec["references"][0] == { + "type": "horizontal", + "value": 1.0, + "scope": "global", + "label": "Random", + } + perfect = spec["references"][1:] + assert len(perfect) == 2 + assert { + (reference["population"], reference["horizon"]) for reference in perfect + } == { + (_SHARED_POPULATION, 5.0), + (_SHARED_POPULATION, 10.0), + } + + +def test_equal_risk_population_horizons_remain_distinct_lift_reference_owners(): + probs = { + "Population A": np.array([0.05, 0.2, 0.7, 0.95]), + "Population B": np.array([0.1, 0.4, 0.6, 0.9]), + } + reals = { + "Population A": np.array([1, 0, 0, 0]), + "Population B": np.array([1, 0, 0, 0]), + } + times = { + "Population A": np.array([3.0, 12.0, 13.0, 14.0]), + "Population B": np.array([3.0, 12.0, 13.0, 14.0]), + } + perfect = _spec(probs, reals, times)["references"][1:] + + assert len(perfect) == 4 + by_owner = { + (reference["population"], reference["horizon"]): reference["points"] + for reference in perfect + } + assert len(by_owner) == 4 + assert by_owner[("Population A", 5.0)] == by_owner[("Population B", 5.0)] + + +def test_time_lift_censoring_and_competing_risk_reference_comes_from_performance_layer(): + probs = {"Model A": np.array([0.05, 0.2, 0.4, 0.6, 0.8, 0.95])} + reals = np.array([1, 0, 2, 1, 0, 2]) + times = np.array([2.0, 3.0, 4.0, 8.0, 12.0, 14.0]) + performance = prepare_performance_data_times( + probs, + reals, + times, + fixed_time_horizons=HORIZONS, + heuristics_sets=HEURISTICS, + by=0.25, + ) + spec = _lift_times_v2_spec_from_performance_data( + performance, _build_evaluation_metadata(probs, reals, times) + ) + calculated_risks = { + float(row["fixed_time_horizon"]): float(row["real_positives"] / row["n"]) + for row in performance.filter(performance["chosen_cutoff"] == 0) + .select("fixed_time_horizon", "real_positives", "n") + .unique() + .to_dicts() + } + + for reference in spec["references"][1:]: + horizon = reference["horizon"] + risk = calculated_risks[horizon] + assert reference["points"] == [ + {"x": 0.0, "y": 1.0 / risk}, + {"x": risk, "y": 1.0 / risk}, + {"x": 1.0, "y": 1.0}, + ] + + +def test_time_lift_renderers_preserve_plotly_default_and_horizons(): + probs, reals, times = _shared_inputs() + default = create_lift_curve_times( + probs, reals, times, HORIZONS, heuristics_sets=HEURISTICS, by=0.25 + ) + assert isinstance(default, go.Figure) + + with pytest.raises( + ValueError, + match="Browser rendering for Lift curves requires a newer vendored release of rtichoke_viz", + ): + create_lift_curve_times( + probs, + reals, + times, + HORIZONS, + heuristics_sets=HEURISTICS, + by=0.25, + renderer="browser", + ) + + matplotlib_result = create_lift_curve_times( + probs, + reals, + times, + HORIZONS, + heuristics_sets=HEURISTICS, + by=0.25, + renderer="matplotlib", + ) + assert isinstance(matplotlib_result, matplotlib.figure.Figure) + assert [axis.get_title() for axis in matplotlib_result.axes] == [ + "Fixed Time Horizon: 5", + "Fixed Time Horizon: 10", + ]