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"