diff --git a/ds_utils/__init__.py b/ds_utils/__init__.py index f856136..5640c46 100644 --- a/ds_utils/__init__.py +++ b/ds_utils/__init__.py @@ -1,3 +1,3 @@ """Data Science Utilities package.""" -__version__ = "1.10.0rc10" +__version__ = "1.10.0rc11" diff --git a/ds_utils/metrics/curves.py b/ds_utils/metrics/curves.py index ed8513a..5131979 100644 --- a/ds_utils/metrics/curves.py +++ b/ds_utils/metrics/curves.py @@ -6,7 +6,7 @@ import numpy as np from plotly import graph_objects as go from sklearn.exceptions import UndefinedMetricWarning -from sklearn.metrics import precision_recall_curve, roc_auc_score, roc_curve +from sklearn.metrics import average_precision_score, precision_recall_curve, roc_auc_score, roc_curve def plot_roc_curve_with_thresholds_annotations( @@ -23,6 +23,7 @@ def plot_roc_curve_with_thresholds_annotations( fig: Optional[go.Figure] = None, mode: Optional[str] = "lines+markers", add_random_classifier_line: bool = True, + random_classifier_line_kw: Optional[Dict] = None, show_legend: bool = True, **kwargs, ) -> go.Figure: @@ -32,7 +33,7 @@ def plot_roc_curve_with_thresholds_annotations( :param classifiers_names_and_scores_dict: mapping from classifier name to classifier's score. :param positive_label: int, float, bool or str, default=None. The label of the positive class. :param sample_weight: array-like of shape (n_samples,), default=None. Sample weights. - :param drop_intermediate: bool, default=True. Whether to drop some suboptimal thresholds that would not appear on + :param drop_intermediate: bool, default=True. Whether to drop some suboptimal thresholds which would not appear on a plotted ROC curve. :param average: {'micro', 'macro', 'samples', 'weighted'} or None, default='macro'. If not None, this determines the type of averaging performed on the data. @@ -46,6 +47,8 @@ def plot_roc_curve_with_thresholds_annotations( :param mode: str, default='lines+markers'. Determines the drawing mode for this scatter trace. :param add_random_classifier_line: bool, default=True. Whether to plot a diagonal dashed black line which represents a random classifier. + :param random_classifier_line_kw: dict, default=None. Keyword arguments to be passed to plotly's Scatter + for rendering the random classifier line (e.g., line color, style). :param show_legend: bool, default=True. Whether to display legend in the plot. :param kwargs: additional keyword arguments to be passed to the plot function. :return: The Figure object with the plot drawn onto it. @@ -95,17 +98,21 @@ def plot_roc_curve_with_thresholds_annotations( f"Prob: {threshold:.2f}
FPR: {fpr:.2f}
TPR: {tpr:.2f}" for fpr, tpr, threshold in zip(fpr_array, tpr_array, thresholds) ], + hoverinfo="text", name=f"{classifier_name} (AUC = {auc_score:.2f})", **kwargs, ) ) if add_random_classifier_line: # Add dashed line for random classifier - fig.add_trace( - go.Scatter( - x=[0, 1], y=[0, 1], mode="lines", line=dict(dash="dash", color="black"), name="Random Classifier" - ) - ) + default_random_classifier_kw = { + "line": dict(dash="dash", color="black"), + "name": "Random Classifier (AUC = 0.50)", + } + if random_classifier_line_kw is not None: + default_random_classifier_kw.update(random_classifier_line_kw) + + fig.add_trace(go.Scatter(x=[0, 1], y=[0, 1], mode="lines", hoverinfo="name", **default_random_classifier_kw)) fig.update_layout(xaxis_title="False Positive Rate", yaxis_title="True Positive Rate", showlegend=show_legend) @@ -121,7 +128,8 @@ def plot_precision_recall_curve_with_thresholds_annotations( drop_intermediate: bool = True, fig: Optional[go.Figure] = None, mode: Optional[str] = "lines+markers", - add_random_classifier_line: bool = False, + plot_chance_level: bool = False, + chance_level_kw: Optional[Dict] = None, show_legend: bool = True, **kwargs, ) -> go.Figure: @@ -131,12 +139,17 @@ def plot_precision_recall_curve_with_thresholds_annotations( :param classifiers_names_and_scores_dict: mapping from classifier name to classifier's score. :param positive_label: int, float, bool or str, default=None. The label of the positive class. :param sample_weight: array-like of shape (n_samples,), default=None. Sample weights. - :param drop_intermediate: bool, default=True. Whether to drop some suboptimal thresholds that would not appear on - a plotted Precision-Recall curve. + :param drop_intermediate: bool, default=True. Whether to drop some suboptimal thresholds that don't change the + precision. This is useful to create lighter Precision-Recall curves. :param fig: plotly's Figure object, optional. The figure to plot on. :param mode: str, default='lines+markers'. Determines the drawing mode for this scatter trace. - :param add_random_classifier_line: bool, default=False. Whether to plot a diagonal dashed black line which - represents a random classifier. + :param plot_chance_level: bool, default=False. Whether to plot the chance level. The chance level is the prevalence + of the positive label computed from the data passed. Behavior is like sklearn: + https://scikit-learn.org/stable/modules/generated/sklearn.metrics.PrecisionRecallDisplay.html + When positive_label is None, the positive class is inferred as the larger of the two + unique labels in y_true, consistent with scikit-learn's convention. + :param chance_level_kw: dict, default=None. Keyword arguments to be passed to plotly's Scatter for rendering the + chance level line (e.g., line color, style). :param show_legend: bool, default=True. Whether to display legend in the plot. :param kwargs: additional keyword arguments to be passed to the plot function. :return: The Figure object with the plot drawn onto it. @@ -145,6 +158,17 @@ def plot_precision_recall_curve_with_thresholds_annotations( if fig is None: fig = go.Figure() # Create a new figure if none is provided + # When plot_chance_level=True and positive_label=None, resolve to the larger unique label + # (sklearn convention) and validate binary input. Otherwise, keep positive_label as-is — + # sklearn will apply its own default when pos_label=None. + effective_positive_label = positive_label + if plot_chance_level and effective_positive_label is None: + unique_labels = np.unique(y_true) + if len(unique_labels) != 2: + raise ValueError("y_true must be binary for plotting chance level") + # Use the convention that the larger label is positive (consistent with sklearn) + effective_positive_label = unique_labels[1] + for classifier_name, y_scores in classifiers_names_and_scores_dict.items(): if y_true.shape != y_scores.shape: raise ValueError( @@ -154,13 +178,24 @@ def plot_precision_recall_curve_with_thresholds_annotations( precision_array, recall_array, thresholds = precision_recall_curve( y_true, y_scores, - pos_label=positive_label, + pos_label=effective_positive_label, sample_weight=sample_weight, drop_intermediate=drop_intermediate, ) except ValueError as e: raise ValueError(f"Error calculating Precision-Recall curve for classifier {classifier_name}: {str(e)}") + try: + ap_kwargs = {} + if effective_positive_label is not None: + ap_kwargs["pos_label"] = effective_positive_label + if sample_weight is not None: + ap_kwargs["sample_weight"] = sample_weight + + ap = average_precision_score(y_true, y_scores, **ap_kwargs) + except ValueError as e: + raise ValueError(f"Error calculating Average Precision for classifier {classifier_name}: {str(e)}") + display_thresholds = np.append(thresholds, np.nan) fig.add_trace( go.Scatter( @@ -171,15 +206,36 @@ def plot_precision_recall_curve_with_thresholds_annotations( f"Prob: {'N/A' if np.isnan(t) else f'{t:.2f}'}
Precision: {p:.2f}
Recall: {r:.2f}" for p, r, t in zip(precision_array, recall_array, display_thresholds) ], - name=classifier_name, + hoverinfo="text", + name=f"{classifier_name} (AP = {ap:0.2f})", **kwargs, ) ) - if add_random_classifier_line: # Add dashed line for random classifier + if plot_chance_level: + # Note: AP here refers to the chance-level Average Precision, which equals prevalence. + # This naming convention is consistent with scikit-learn's PrecisionRecallDisplay. + if sample_weight is not None: + prevalence = np.sum(sample_weight[y_true == effective_positive_label]) / np.sum(sample_weight) + else: + prevalence = np.sum(y_true == effective_positive_label) / len(y_true) + + # Default styling for chance level line + default_chance_level_kw = { + "line": dict(dash="dash", color="black"), + "name": f"Chance level (AP = {prevalence:0.2f})", + } + if chance_level_kw is not None: + default_chance_level_kw.update(chance_level_kw) + + # Plot horizontal line at prevalence fig.add_trace( go.Scatter( - x=[1, 0], y=[0, 1], mode="lines", line=dict(dash="dash", color="black"), name="Random Classifier" + x=[0, 1], + y=[prevalence, prevalence], + mode="lines", + hoverinfo="name", + **default_chance_level_kw, ) ) diff --git a/skills/metrics/SKILL.md b/skills/metrics/SKILL.md index 7888f09..f2cd89f 100644 --- a/skills/metrics/SKILL.md +++ b/skills/metrics/SKILL.md @@ -170,6 +170,7 @@ fig.show() - `multi_class` — str, Handling of multi-class ROC. (Default: "raise"). - `mode` — str, Plotly trace drawing mode. (Default: "lines+markers"). - `add_random_classifier_line` — bool. Plot the naive diagonal. (Default: True). +- `random_classifier_line_kw` — dict, optional. Keyword arguments for styling the random classifier line. **Returns:** Plotly Figure. @@ -207,7 +208,8 @@ fig.show() - `sample_weight` — array-like, optional. Sample weights. - `drop_intermediate` — bool, Whether to drop sub-optimal thresholds. (Default: True). - `mode` — str, Plotly trace drawing mode. (Default: "lines+markers"). -- `add_random_classifier_line` — bool. Plot the naive diagonal. (Default: False). +- `plot_chance_level` — bool. Whether to plot the chance level prevalence line. (Default: False). +- `chance_level_kw` — dict, optional. Keyword arguments for styling the chance level line. **Returns:** Plotly Figure. diff --git a/tests/baseline_images/test_metrics/test_curves/test_plot_precision_recall_curve_with_thresholds_annotations_chance_level_sample_weights.png b/tests/baseline_images/test_metrics/test_curves/test_plot_precision_recall_curve_with_thresholds_annotations_chance_level_sample_weights.png new file mode 100644 index 0000000..055111d Binary files /dev/null and b/tests/baseline_images/test_metrics/test_curves/test_plot_precision_recall_curve_with_thresholds_annotations_chance_level_sample_weights.png differ diff --git a/tests/baseline_images/test_metrics/test_curves/test_plot_precision_recall_curve_with_thresholds_annotations_chance_level_styling.png b/tests/baseline_images/test_metrics/test_curves/test_plot_precision_recall_curve_with_thresholds_annotations_chance_level_styling.png new file mode 100644 index 0000000..c124b3b Binary files /dev/null and b/tests/baseline_images/test_metrics/test_curves/test_plot_precision_recall_curve_with_thresholds_annotations_chance_level_styling.png differ diff --git a/tests/baseline_images/test_metrics/test_curves/test_plot_precision_recall_curve_with_thresholds_annotations_default.png b/tests/baseline_images/test_metrics/test_curves/test_plot_precision_recall_curve_with_thresholds_annotations_default.png index 20d5f29..4bce900 100644 Binary files a/tests/baseline_images/test_metrics/test_curves/test_plot_precision_recall_curve_with_thresholds_annotations_default.png and b/tests/baseline_images/test_metrics/test_curves/test_plot_precision_recall_curve_with_thresholds_annotations_default.png differ diff --git a/tests/baseline_images/test_metrics/test_curves/test_plot_precision_recall_curve_with_thresholds_annotations_exists_figure.png b/tests/baseline_images/test_metrics/test_curves/test_plot_precision_recall_curve_with_thresholds_annotations_exists_figure.png index 7db90df..791c666 100644 Binary files a/tests/baseline_images/test_metrics/test_curves/test_plot_precision_recall_curve_with_thresholds_annotations_exists_figure.png and b/tests/baseline_images/test_metrics/test_curves/test_plot_precision_recall_curve_with_thresholds_annotations_exists_figure.png differ diff --git a/tests/baseline_images/test_metrics/test_curves/test_plot_precision_recall_curve_with_thresholds_annotations_with_chance_level.png b/tests/baseline_images/test_metrics/test_curves/test_plot_precision_recall_curve_with_thresholds_annotations_with_chance_level.png new file mode 100644 index 0000000..6a1d08c Binary files /dev/null and b/tests/baseline_images/test_metrics/test_curves/test_plot_precision_recall_curve_with_thresholds_annotations_with_chance_level.png differ diff --git a/tests/baseline_images/test_metrics/test_curves/test_plot_precision_recall_curve_with_thresholds_annotations_with_random_classifier_line.png b/tests/baseline_images/test_metrics/test_curves/test_plot_precision_recall_curve_with_thresholds_annotations_with_random_classifier_line.png deleted file mode 100644 index 636cad9..0000000 Binary files a/tests/baseline_images/test_metrics/test_curves/test_plot_precision_recall_curve_with_thresholds_annotations_with_random_classifier_line.png and /dev/null differ diff --git a/tests/baseline_images/test_metrics/test_curves/test_plot_roc_curve_with_thresholds_annotations_default.png b/tests/baseline_images/test_metrics/test_curves/test_plot_roc_curve_with_thresholds_annotations_default.png index 8879e9c..370632e 100644 Binary files a/tests/baseline_images/test_metrics/test_curves/test_plot_roc_curve_with_thresholds_annotations_default.png and b/tests/baseline_images/test_metrics/test_curves/test_plot_roc_curve_with_thresholds_annotations_default.png differ diff --git a/tests/baseline_images/test_metrics/test_curves/test_plot_roc_curve_with_thresholds_annotations_exist_figure.png b/tests/baseline_images/test_metrics/test_curves/test_plot_roc_curve_with_thresholds_annotations_exist_figure.png index 4b697a6..32a07c5 100644 Binary files a/tests/baseline_images/test_metrics/test_curves/test_plot_roc_curve_with_thresholds_annotations_exist_figure.png and b/tests/baseline_images/test_metrics/test_curves/test_plot_roc_curve_with_thresholds_annotations_exist_figure.png differ diff --git a/tests/baseline_images/test_metrics/test_curves/test_plot_roc_curve_with_thresholds_annotations_random_classifier_styling.png b/tests/baseline_images/test_metrics/test_curves/test_plot_roc_curve_with_thresholds_annotations_random_classifier_styling.png new file mode 100644 index 0000000..18946d1 Binary files /dev/null and b/tests/baseline_images/test_metrics/test_curves/test_plot_roc_curve_with_thresholds_annotations_random_classifier_styling.png differ diff --git a/tests/baseline_images/test_metrics/test_curves/test_plot_roc_curve_with_thresholds_annotations_without_random_classifier.png b/tests/baseline_images/test_metrics/test_curves/test_plot_roc_curve_with_thresholds_annotations_without_random_classifier.png index 3ab9eb6..428274c 100644 Binary files a/tests/baseline_images/test_metrics/test_curves/test_plot_roc_curve_with_thresholds_annotations_without_random_classifier.png and b/tests/baseline_images/test_metrics/test_curves/test_plot_roc_curve_with_thresholds_annotations_without_random_classifier.png differ diff --git a/tests/resources/plotly_models_pr_curve.json b/tests/resources/plotly_models_pr_curve.json index e2625b5..aed1942 100644 --- a/tests/resources/plotly_models_pr_curve.json +++ b/tests/resources/plotly_models_pr_curve.json @@ -3091,7 +3091,8 @@ 0.8333333333333334, 1.0 ] - } + }, + "average_precision_score": 0.4185834814730732 }, "Random Forest": { "y_scores": [ @@ -4875,7 +4876,8 @@ 0.82, 0.84 ] - } + }, + "average_precision_score": 0.5775956529903145 }, "XGBoost": { "y_scores": [ @@ -12443,6 +12445,7 @@ 0.8735373778714516, 0.9029533781228013 ] - } + }, + "average_precision_score": 0.5745895726894851 } } \ No newline at end of file diff --git a/tests/test_metrics/test_curves.py b/tests/test_metrics/test_curves.py index d0772b2..16f76c6 100644 --- a/tests/test_metrics/test_curves.py +++ b/tests/test_metrics/test_curves.py @@ -46,9 +46,19 @@ def save_plotly_figure_and_return_matplot(fig: go.Figure, path_to_save: Path) -> @pytest.mark.mpl_image_compare(baseline_dir=BASELINE_DIR, tolerance=18) -@pytest.mark.parametrize("add_random_classifier_line", [True, False], ids=["default", "without_random_classifier"]) -def test_plot_roc_curve_with_thresholds_annotations(mocker, request, add_random_classifier_line, plotly_models_dict): - """Test ROC curve plotting when underlying calculations fail.""" +@pytest.mark.parametrize( + ("add_random_classifier_line", "random_classifier_line_kw"), + [ + (True, None), + (False, None), + (True, {"line": dict(dash="dot", color="red", width=3), "name": "Custom Chance Level Name"}), + ], + ids=["default", "without_random_classifier", "random_classifier_styling"], +) +def test_plot_roc_curve_with_thresholds_annotations( + mocker, request, add_random_classifier_line, random_classifier_line_kw, plotly_models_dict +): + """Test ROC curve plotting with various random classifier line configurations.""" y_true = np.array(plotly_models_dict["y_true"]) classifiers_names_and_scores_dict = { name: np.array(data["y_scores"]) for name, data in plotly_models_dict.items() if name != "y_true" @@ -70,7 +80,10 @@ def _mock_roc_auc_score(y_true, y_score, **kwargs): mocker.patch("ds_utils.metrics.curves.roc_auc_score", side_effect=_mock_roc_auc_score) fig = plot_roc_curve_with_thresholds_annotations( - y_true, classifiers_names_and_scores_dict, add_random_classifier_line=add_random_classifier_line + y_true, + classifiers_names_and_scores_dict, + add_random_classifier_line=add_random_classifier_line, + random_classifier_line_kw=random_classifier_line_kw, ) return save_plotly_figure_and_return_matplot(fig, RESULT_DIR / f"{request.node.name}.png") @@ -163,9 +176,23 @@ def _mock_roc_curve(y_true, y_score, **kwargs): @pytest.mark.mpl_image_compare(baseline_dir=BASELINE_DIR, tolerance=18) -@pytest.mark.parametrize("add_random_classifier_line", [False, True], ids=["default", "with_random_classifier_line"]) +@pytest.mark.parametrize( + ("plot_chance_level", "chance_level_kw", "use_weights"), + [ + (False, None, False), + (True, None, False), + (True, {"line": dict(dash="dot", color="red", width=3), "name": "Custom Chance Level Name"}, False), + (True, None, True), + ], + ids=[ + "default", + "with_chance_level", + "chance_level_styling", + "chance_level_sample_weights", + ], +) def test_plot_precision_recall_curve_with_thresholds_annotations( - mocker, request, add_random_classifier_line, plotly_models_pr_curve_dict + mocker, request, plot_chance_level, chance_level_kw, use_weights, plotly_models_pr_curve_dict ): """Test plotting a Precision-Recall curve with threshold annotations.""" y_true = np.array(plotly_models_pr_curve_dict["y_true"]) @@ -173,7 +200,9 @@ def test_plot_precision_recall_curve_with_thresholds_annotations( name: np.array(data["y_scores"]) for name, data in plotly_models_pr_curve_dict.items() if name != "y_true" } - def _mock_precision_recall_curve(y_true, y_score, **kwargs): + weights = np.random.RandomState(42).random(len(y_true)) if use_weights else None + + def _mock_precision_recall_curve(y_true, y_score, **kw): for classifier, scores in classifiers_names_and_scores_dict.items(): if np.array_equal(scores, y_score): data = plotly_models_pr_curve_dict[classifier]["precision_recall_curve"] @@ -181,14 +210,25 @@ def _mock_precision_recall_curve(y_true, y_score, **kwargs): mocker.patch("ds_utils.metrics.curves.precision_recall_curve", side_effect=_mock_precision_recall_curve) - fig = plot_precision_recall_curve_with_thresholds_annotations( - y_true, classifiers_names_and_scores_dict, add_random_classifier_line=add_random_classifier_line + def _mock_average_precision_score(y_true, y_score, **kw): + for classifier, scores in classifiers_names_and_scores_dict.items(): + if np.array_equal(scores, y_score): + return np.float64(plotly_models_pr_curve_dict[classifier]["average_precision_score"]) + + mocker.patch("ds_utils.metrics.curves.average_precision_score", side_effect=_mock_average_precision_score) + + fig_out = plot_precision_recall_curve_with_thresholds_annotations( + y_true, + classifiers_names_and_scores_dict, + plot_chance_level=plot_chance_level, + chance_level_kw=chance_level_kw, + sample_weight=weights, ) - return save_plotly_figure_and_return_matplot(fig, RESULT_DIR / f"{request.node.name}.png") + return save_plotly_figure_and_return_matplot(fig_out, RESULT_DIR / f"{request.node.name}.png") -@pytest.mark.mpl_image_compare(baseline_dir=BASELINE_DIR, tolerance=15) +@pytest.mark.mpl_image_compare(baseline_dir=BASELINE_DIR, tolerance=17) def test_plot_precision_recall_curve_with_thresholds_annotations_exists_figure( mocker, request, plotly_models_pr_curve_dict ): @@ -209,6 +249,13 @@ def _mock_precision_recall_curve(y_true, y_score, **kwargs): mocker.patch("ds_utils.metrics.curves.precision_recall_curve", side_effect=_mock_precision_recall_curve) + def _mock_average_precision_score(y_true, y_score, **kw): + for classifier, scores in classifiers_names_and_scores_dict.items(): + if np.array_equal(scores, y_score): + return np.float64(plotly_models_pr_curve_dict[classifier]["average_precision_score"]) + + mocker.patch("ds_utils.metrics.curves.average_precision_score", side_effect=_mock_average_precision_score) + fig = plot_precision_recall_curve_with_thresholds_annotations(y_true, classifiers_names_and_scores_dict, fig=fig) return save_plotly_figure_and_return_matplot(fig, RESULT_DIR / f"{request.node.name}.png") @@ -224,3 +271,93 @@ def test_plot_precision_recall_curve_with_thresholds_annotations_fail_calc(mocke mocker.patch("ds_utils.metrics.curves.precision_recall_curve", side_effect=ValueError) with pytest.raises(ValueError, match="Error calculating Precision-Recall curve for classifier Decision Tree:"): plot_precision_recall_curve_with_thresholds_annotations(y_true, classifiers_names_and_scores_dict) + + +def test_plot_precision_recall_curve_with_thresholds_annotations_fail_ap_calc(mocker, plotly_models_pr_curve_dict): + """Test PR curve plotting when average_precision_score fails.""" + y_true = np.array(plotly_models_pr_curve_dict["y_true"]) + classifiers_names_and_scores_dict = { + name: np.array(data["y_scores"]) for name, data in plotly_models_pr_curve_dict.items() if name != "y_true" + } + + def _mock_precision_recall_curve(y_true, y_score, **kw): + for classifier, scores in classifiers_names_and_scores_dict.items(): + if np.array_equal(scores, y_score): + data = plotly_models_pr_curve_dict[classifier]["precision_recall_curve"] + return np.array(data["precision_array"]), np.array(data["recall_array"]), np.array(data["thresholds"]) + + mocker.patch("ds_utils.metrics.curves.precision_recall_curve", side_effect=_mock_precision_recall_curve) + mocker.patch("ds_utils.metrics.curves.average_precision_score", side_effect=ValueError) + + with pytest.raises(ValueError, match="Error calculating Average Precision for classifier Decision Tree:"): + plot_precision_recall_curve_with_thresholds_annotations(y_true, classifiers_names_and_scores_dict) + + +def test_plot_precision_recall_curve_chance_level_non_binary(mocker): + """Test that chance level plotting raises ValueError if y_true is not binary.""" + mock_prc = mocker.patch("ds_utils.metrics.curves.precision_recall_curve") + y_true = np.array([0, 1, 2]) + classifiers_names_and_scores_dict = {"Model": np.array([0.1, 0.4, 0.9])} + with pytest.raises(ValueError, match="y_true must be binary for plotting chance level"): + plot_precision_recall_curve_with_thresholds_annotations( + y_true, classifiers_names_and_scores_dict, plot_chance_level=True + ) + mock_prc.assert_not_called() + + +def test_plot_precision_recall_curve_chance_level_prevalence(mocker, plotly_models_pr_curve_dict): + """Test that chance level line is plotted at correct prevalence.""" + y_true = np.array(plotly_models_pr_curve_dict["y_true"]) + classifiers_names_and_scores_dict = { + name: np.array(data["y_scores"]) for name, data in plotly_models_pr_curve_dict.items() if name != "y_true" + } + + prevalence = np.sum(y_true == 1) / len(y_true) + + def _mock_precision_recall_curve(y_true, y_score, **kwargs): + for classifier, scores in classifiers_names_and_scores_dict.items(): + if np.array_equal(scores, y_score): + data = plotly_models_pr_curve_dict[classifier]["precision_recall_curve"] + return np.array(data["precision_array"]), np.array(data["recall_array"]), np.array(data["thresholds"]) + + mocker.patch("ds_utils.metrics.curves.precision_recall_curve", side_effect=_mock_precision_recall_curve) + mocker.patch("ds_utils.metrics.curves.average_precision_score", return_value=0.5) + + fig = plot_precision_recall_curve_with_thresholds_annotations( + y_true, classifiers_names_and_scores_dict, plot_chance_level=True + ) + + chance_level_trace = next(trace for trace in fig.data if "Chance level" in trace.name) + assert np.allclose(chance_level_trace.x, [0, 1]) + assert np.allclose(chance_level_trace.y, [prevalence, prevalence]) + + +def test_plot_precision_recall_curve_chance_level_explicit_positive_label(mocker): + """Test that prevalence is correctly calculated with an explicit positive label.""" + y_true = np.array([0, 0, 1]) + classifiers_names_and_scores_dict = {"Model": np.array([0.1, 0.2, 0.9])} + + # Test with positive_label=0 (prevalence should be 2/3) + prevalence_0 = 2 / 3 + + mock_prc = mocker.patch( + "ds_utils.metrics.curves.precision_recall_curve", + return_value=(np.array([0, 1]), np.array([1, 0]), np.array([0.5])), + ) + mocker.patch("ds_utils.metrics.curves.average_precision_score", return_value=0.5) + + fig = plot_precision_recall_curve_with_thresholds_annotations( + y_true, classifiers_names_and_scores_dict, plot_chance_level=True, positive_label=0 + ) + + mock_prc.assert_called_once() + _, call_kwargs = mock_prc.call_args + assert call_kwargs.get("pos_label") == 0 + + chance_level_trace = next(trace for trace in fig.data if "Chance level" in trace.name) + assert np.allclose(chance_level_trace.y, [prevalence_0, prevalence_0]) + + # Note: AP here refers to the chance-level Average Precision, which equals prevalence. + # This naming convention is consistent with scikit-learn's PrecisionRecallDisplay. + expected_name = f"Chance level (AP = {prevalence_0:0.2f})" + assert chance_level_trace.name == expected_name diff --git a/tests/test_version.py b/tests/test_version.py index 98399bc..92f6083 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -5,4 +5,4 @@ def test_version(): """Test that the package version is correct.""" - assert ds_utils.__version__ == "1.10.0rc10" + assert ds_utils.__version__ == "1.10.0rc11"