Repository: epic-open-source/seismometer

Issue:
ROC plot issue
ROC in the 2x3 plot scales incorrectly

Discussion:
@diehlbw: From our example notebook - note the annotation labels are inaccurate on this one plot.  
![image](https://github.com/user-attachments/assets/e7615292-f568-42cc-872f-6cfea1b13813)


Solution patch:
diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
index c032785b..84cb9654 100644
--- a/.devcontainer/devcontainer.json
+++ b/.devcontainer/devcontainer.json
@@ -4,6 +4,9 @@
     "dockerComposeFile": "docker-compose.yml",
     "service": "seismometer-dev",
     "workspaceFolder": "/home/seismo/workspace",
+    "features": {
+        "ghcr.io/devcontainers/features/git:1": {}
+    },
      // Use 'settings' to set *default* container specific settings.json values on container create.
     // You can edit these settings after create using File > Preferences > Settings > Remote.
     "customizations": {
diff --git a/changelog/124.bugfix.rst b/changelog/124.bugfix.rst
new file mode 100644
index 00000000..e98f2959
--- /dev/null
+++ b/changelog/124.bugfix.rst
@@ -0,0 +1 @@
+Fix a scaling issue causing annotations to be misplaced on the ROC plot of the evaluation 2x3
diff --git a/src/seismometer/api/plots.py b/src/seismometer/api/plots.py
index 67a8a561..c482e47e 100644
--- a/src/seismometer/api/plots.py
+++ b/src/seismometer/api/plots.py
@@ -566,8 +566,10 @@ def _model_evaluation(
         return template.render_title_message(
             "Evaluation Error", f"Model Evaluation requires exactly two classes but found {lcount}"
         )
+
+    # stats and ci handle percentile/percentage independently - evaluation wants 0-100 for displays
     stats = calculate_bin_stats(data[target], data[score_col])
-    ci_data = calculate_eval_ci(stats, data[target], data[score_col], conf=0.95)
+    ci_data = calculate_eval_ci(stats, data[target], data[score_col], conf=0.95, force_percentages=True)
     title = f"Overall Performance for {target_event} (Per {'Encounter' if per_context_id else 'Observation'})"
     svg = plot.evaluation(
         stats,
diff --git a/src/seismometer/data/performance.py b/src/seismometer/data/performance.py
index 546b9405..4f4cbc0b 100644
--- a/src/seismometer/data/performance.py
+++ b/src/seismometer/data/performance.py
@@ -390,7 +390,9 @@ def calculate_nnt(arr: np.ndarray, rho: Optional[Number | None] = None) -> np.nd
 
 
 @export
-def calculate_eval_ci(stats: pd.DataFrame, truth: pd.Series, output: pd.Series, conf: Number = 0.95) -> dict:
+def calculate_eval_ci(
+    stats: pd.DataFrame, truth: pd.Series, output: pd.Series, conf: Number = 0.95, force_percentages=False
+) -> dict:
     """
     Calculate confidence intervals for ROC, PR, and other performance metrics from a stats frame.
 
@@ -404,6 +406,8 @@ def calculate_eval_ci(stats: pd.DataFrame, truth: pd.Series, output: pd.Series,
         The series of model output associated with the stats frame.
     conf : Number, optional
         The confidence level for calculation, by default 0.95.
+    force_percentages : bool, optional
+        Flag to indicate that outputs should be converted to percentages (0-100), by default False.
 
     Returns
     -------
@@ -417,6 +421,9 @@ def calculate_eval_ci(stats: pd.DataFrame, truth: pd.Series, output: pd.Series,
     if truth is None or output is None:
         return conf, None, None, None
 
+    if force_percentages:
+        output = as_percentages(output)
+
     roc_conf = ROCConfidenceParam(conf)
     aucpr_conf = PRConfidenceParam(conf)
     with np.errstate(invalid="ignore", divide="ignore"):
diff --git a/src/seismometer/plot/mpl/_lines.py b/src/seismometer/plot/mpl/_lines.py
index e08c2bf1..c1273dae 100644
--- a/src/seismometer/plot/mpl/_lines.py
+++ b/src/seismometer/plot/mpl/_lines.py
@@ -225,7 +225,9 @@ def get_last_line_color(axis):
     return lines[-1].get_color()
 
 
-def add_radial_score_thresholds(
+# endregion
+# region Private Helpers
+def _add_radial_score_thresholds(
     axis: plt.Axes,
     x: np.ndarray,
     y: np.ndarray,
@@ -239,9 +241,6 @@ def add_radial_score_thresholds(
 
     Expected use is for ROC curves with either scores or n_scores roughly spaced along the curve.
 
-    If the three arrays (x, y, and labels) have more values then n_scores, labels will only be added
-    for a reduced list of len(n_scores) == 10 by default.
-
     Parameters
     ----------
     axis : plt.Axes
@@ -251,14 +250,13 @@ def add_radial_score_thresholds(
     y : np.ndarray
         Array of y-values.
     labels : list[str]
-        Array of thresholds to display.
-    colorIx : int, optional
-        Index of line being annotated in order to match color, by default 0.
-    n_scores : int, optional
-        Integer number of scores to display, if length of arrays is greater, will automatically
-        select 10 values to shows based on an assumed range of labels being 0-1 (default: 10).
+        Array of labels to display.
+    thresholds : list[Number]
+        List of threshold points to label.
     Q : int, optional
         The quadrant 1-4 to assume for offsets, by default 1.
+    colorIx : int, optional
+        Index of line being annotated in order to match color, by default 0.
     """
     if labels is None:
         return
@@ -295,7 +293,7 @@ def add_radial_score_thresholds(
     for i, ix in enumerate(val_ix):
         axis.annotate(
             f"{thresholds[i]:.0f}",
-            radial_annotations(x[ix], y[ix], Q=Q),
+            _radial_annotations(x[ix], y[ix], Q=Q),
             color=colors[i],
         )
 
@@ -313,7 +311,7 @@ def _find_thresholds(labels: list[float], thresholds: list[float]) -> list[int]:
     return val_ix
 
 
-def add_radial_score_labels(
+def _add_radial_score_labels(
     axis: plt.Axes,
     x: np.ndarray,
     y: np.ndarray,
@@ -353,7 +351,7 @@ def add_radial_score_labels(
 
     """
     if highlight is not None:
-        return add_radial_score_thresholds(axis, x, y, labels, thresholds=highlight, Q=Q)
+        return _add_radial_score_thresholds(axis, x, y, labels, thresholds=highlight, Q=Q)
 
     # For safety convert to handle indexing lists
     x = np.array(x)
@@ -379,14 +377,12 @@ def add_radial_score_labels(
     for ix in labelIx:
         axis.annotate(
             f"{labels[ix]:.1f}",
-            radial_annotations(x[ix], y[ix], Q),
+            _radial_annotations(x[ix], y[ix], Q),
             color=text_colors[colorIx],
         )
 
 
-# endregion
-# region Private Helpers
-def radial_annotations(x, y, Q=1) -> tuple[float, float]:
+def _radial_annotations(x, y, Q=1) -> tuple[float, float]:
     """
     Takes a single point and offsets it, returning the modified values.
 
diff --git a/src/seismometer/plot/mpl/binary_classifier.py b/src/seismometer/plot/mpl/binary_classifier.py
index 1e429391..c0f4b63d 100644
--- a/src/seismometer/plot/mpl/binary_classifier.py
+++ b/src/seismometer/plot/mpl/binary_classifier.py
@@ -151,7 +151,7 @@ def singleROC(
     lines.roc_plot(axis, fpr, tpr, label=modelLabel)
 
     if annotate:
-        lines.add_radial_score_labels(axis, fpr, tpr, thresholds, highlight=highlight)
+        lines._add_radial_score_labels(axis, fpr, tpr, thresholds, highlight=highlight)
     return axis.get_figure()
 
 
@@ -193,7 +193,7 @@ def recall_condition(
     lines.recall_condition_plot(axis, ppcr, recall, prevalence, show_reference)
 
     if annotate:
-        lines.add_radial_score_labels(axis, ppcr, recall, thresholds, highlight=highlight)
+        lines._add_radial_score_labels(axis, ppcr, recall, thresholds, highlight=highlight)
     return axis.get_figure()
 
 
@@ -321,7 +321,7 @@ def ppv_vs_sensitivity(
     lines.ppv_sensitivity_curve(axis, sensitivity, ppv, label=aucpr)
 
     if highlight is not None:
-        lines.add_radial_score_thresholds(axis, sensitivity, ppv, thresholds, thresholds=highlight, Q=4)
+        lines._add_radial_score_thresholds(axis, sensitivity, ppv, thresholds, thresholds=highlight, Q=4)
     return axis.get_figure()
 
 


Test patch:
diff --git a/tests/data/test_performance.py b/tests/data/test_performance.py
index c8a7c99c..aaa6f663 100644
--- a/tests/data/test_performance.py
+++ b/tests/data/test_performance.py
@@ -128,6 +128,7 @@ def ci_testcase0():
     return (stats, model.truth.values, model.output.values, roc, pr)
 
 
+@pytest.mark.parametrize("force", [True, False])
 class TestCalCi:
     @pytest.mark.parametrize(
         "key",
@@ -139,37 +140,45 @@ class TestCalCi:
             "interval",
         ],
     )
-    def test_calc_ci_maps_roc_values(self, key):
+    @pytest.mark.parametrize("input_percentages", [True, False])
+    def test_calc_ci_maps_roc_values(self, key, force, input_percentages):
         stats, truth, output, expected, _ = ci_testcase0()
-        actual = undertest.calculate_eval_ci(stats, truth, output)
+        if input_percentages:  # convert the input argument to percentages
+            output = output * 100
+
+        actual = undertest.calculate_eval_ci(stats, truth, output, force_percentages=force)
 
         # sklearn update 1.3.0 changed 0,0 threshold from 1+max to inf
         if key == "Threshold":
             assert actual["roc"][key][0] > 1
             actual["roc"][key][0] = np.inf
 
+        if force or input_percentages:  # expect 0-100 thresholds
+            expected["Threshold"] = [np.inf, 70, 60, 50, 40]
+
         for actual_val, expected_val in zip(actual["roc"][key], expected[key]):
             assert np.isclose(actual_val, expected_val).all()
 
-    def test_calc_ci_maps_pr(self):
+    def test_calc_ci_maps_pr(self, force):
         key = "interval"
         stats, truth, output, _, expected = ci_testcase0()
-        actual = undertest.calculate_eval_ci(stats, truth, output)
+
+        actual = undertest.calculate_eval_ci(stats, truth, output, force_percentages=force)
 
         for actual_val, expected_val in zip(actual["pr"][key], expected[key]):
             assert np.isclose(actual_val, expected_val).all()
 
     @pytest.mark.parametrize("conf_val", [0.99, 0.95, 0.90])
-    def test_calc_ci_returns_conf(self, conf_val):
+    def test_calc_ci_returns_conf(self, conf_val, force):
         stats, truth, output, _, _ = ci_testcase0()
-        actual = undertest.calculate_eval_ci(stats, truth, output, conf_val)
+        actual = undertest.calculate_eval_ci(stats, truth, output, conf_val, force_percentages=force)
         conf = actual["conf"]
         for plot in _SUPPORTED_PLOTS:
             assert "level" in conf[plot] and conf[plot]["level"] == conf_val
 
-    def test_no_truth_returns_conf(self):
+    def test_no_truth_returns_conf(self, force):
         stats, truth, output, _, _ = ci_testcase0()
-        actual = undertest.calculate_eval_ci(stats, None, output, 0.7)
+        actual = undertest.calculate_eval_ci(stats, None, output, 0.7, force_percentages=force)
 
         for plot in _SUPPORTED_PLOTS:
             assert "level" in actual[0][plot] and actual[0][plot]["level"] == 0.7
@@ -177,9 +186,9 @@ def test_no_truth_returns_conf(self):
         for i in [1, 2, 3]:
             assert actual[i] is None
 
-    def test_no_output_returns_conf(self):
+    def test_no_output_returns_conf(self, force):
         stats, truth, output, _, _ = ci_testcase0()
-        actual = undertest.calculate_eval_ci(stats, truth, None, 0.7)
+        actual = undertest.calculate_eval_ci(stats, truth, None, 0.7, force_percentages=force)
 
         for plot in _SUPPORTED_PLOTS:
             assert "level" in actual[0][plot] and actual[0][plot]["level"] == 0.7