Repository: epic-open-source/seismometer

Issue:
ROC AUC value differs between ExploreModelEvaluation() and ExploreAnalyticsTable()
<!--- Issues must not contain any PHI, PII, or other confidential information. --->

<!--- Provide a general summary of the issue in the Title above -->

## Problem Summary
ROC AUC value differs between ExploreModelEvaluation() and ExploreAnalyticsTable()

## Impact
<!--- Explain why this is a problem and to whom -->
Though, the ROC AUC value is similar, and may not be operationally meaningful, the difference is likely to decrease trust in the values output by this package (for people such as myself).
## Possible Solution
It appears the annotation value in the ROC AUC curve in ExploreModelEvaluation() is most consistent with the scikit-learn roc_auc function, using decimal thresholds 1-100 whereas the ExploreAnalyticsTable() is calculated from a dataframe with integer thresholds 1-100. I believe this accounts for the difference.

## Steps to Reproduce
<!--- Provide code to reproduce the issue that uses -->
<!--- a dataset from load_example_dataset() -->
~~~python
import numpy as np
import matplotlib.pyplot as plt
from sklearn.metrics import roc_curve, auc
sg = sm.Seismogram()

# derive comparison dataframes from the Seismogram object and calculate_bin_stats method
df = sg.dataframe
stats = sm.data.performance.calculate_bin_stats(y_true=df['Sepsis within 8 hours_Value'], y_pred=df['EPICSEPSISSCORE'])

# calculate the ROC AUC using sklearn
y_true=df['Sepsis within 8 hours_Value']
y_pred=df['EPICSEPSISSCORE']

fpr, tpr, thresholds = roc_curve(y_true, y_pred)

roc_auc_sklearn = auc(fpr, tpr)

#calculate the ROC AUC using the stats dataframe
stats_auc = auc(1 - stats["Specificity"], stats["Sensitivity"])

#print the results revelaing the difference between the two methods
print(roc_auc_sklearn.round(2))
print(stats_auc.round(2))
print((roc_auc_sklearn - stats_auc).round(3))
~~~
## Suggested fix
Use the scikit-learn methodology to produce roc auc for ExploreAnalyticsTable()

Discussion:

Solution patch:
diff --git a/changelog/153.bugfix.rst b/changelog/153.bugfix.rst
new file mode 100644
index 00000000..a6f06901
--- /dev/null
+++ b/changelog/153.bugfix.rst
@@ -0,0 +1 @@
+Improve threshold precision handling and formatting in analytics tables and slider widgets for more accurate AUC computation.
\ No newline at end of file
diff --git a/src/seismometer/controls/thresholds.py b/src/seismometer/controls/thresholds.py
index 857db83e..6229d2e4 100644
--- a/src/seismometer/controls/thresholds.py
+++ b/src/seismometer/controls/thresholds.py
@@ -15,41 +15,48 @@
 
 class ProbabilitySliderListWidget(ValueWidget, VBox):
     """
-    Vertical list of sliders, bounded between 0 and 1 with a step of 0.01.
+    Vertical list of sliders, bounded between 0 and 1 with a dynamic step size based on specified decimal precision.
     """
 
     value = traitlets.Dict(help="The names and values for the slider list")
 
-    def __init__(self, names: Iterable[str], value: Optional[Iterable[int]] = None):
-        """A vertical list of sliders, bounded between 0 and 1 with a step of 0.01.
+    def __init__(self, names: Iterable[str], value: Optional[Iterable[float]] = None, decimals: int = 2):
+        """
+        Vertical list of sliders, bounded between 0 and 1 with a dynamic step size
+        based on specified decimal precision.
 
         Parameters
         ----------
         names : Iterable[str]
             Slider names
-        value : Optional[Iterable[int]], optional
+        value : Optional[Iterable[float]], optional
             Slider start values, by default None, starts all sliders at zero.
+        decimals : int, optional
+            Number of decimal places (determines slider step size, e.g., 2 → 0.01), by default 2.
         """
         self.names = tuple(names)  # cast to static tuple
         if value and len(value) != len(names):
             raise ValueError(f"Value length {len(value)} does not match names length {len(names)}")
+        self.decimals = decimals
+        step_size = 1 / 10**decimals
+        readout_fmt = f".{decimals}f"
         values = tuple(0 for _ in names) if not value else tuple(value)  # set initial values
         values = tuple(min(1.0, max(0, val)) for val in values)  # clamp values to [0, 1]
-        self.value = {k: v for k, v in zip(self.names, values)}
+        self.value = {k: round(v, self.decimals) for k, v in zip(self.names, values)}
         self.sliders = {}
         for name, val in self.value.items():
             sub_slider = FloatSlider(
-                value=val,
+                value=round(val, self.decimals),
                 min=0,
                 max=1.0,
-                step=0.01,
+                step=step_size,
                 description=name,
                 tooltip=name,
                 disabled=False,
                 continuous_update=False,
                 orientation="horizontal",
                 readout=True,
-                readout_format=".2f",
+                readout_format=readout_fmt,
                 style=WIDE_LABEL_STYLE,
             )
             self.sliders[name] = sub_slider
@@ -77,7 +84,8 @@ def _on_slider_change(self, change=None):
             return
         if change:
             if (slider := change["owner"]) in self.sliders.values():
-                self.value[slider.description] = slider.value
+                rounded_val = round(slider.value, self.decimals)
+                self.value[slider.description] = rounded_val
 
     def _on_value_change(self, change=None):
         """Bubble up changes to sliders"""
@@ -85,33 +93,44 @@ def _on_value_change(self, change=None):
         if change:
             if val := change["new"]:
                 for key, value in val.items():
-                    self.sliders[key].value = value
+                    rounded_val = round(value, self.decimals)
+                    self.sliders[key].value = rounded_val
         self.value_update_in_progress = False
 
 
 class MonotonicProbabilitySliderListWidget(ProbabilitySliderListWidget):
     """
-    Vertical list of sliders, bounded between 0 and 1 with a step of 0.01.
+    A vertical list of probability sliders, each bounded between 0 and 1, with dynamic step size
+    based on the specified decimal precision.
+
     Monotonicity is maintained between the sliders so they are always ascending or descending in value.
+
+    Supports up to 6 sliders.
     """
 
-    def __init__(self, names: Iterable[str], value: Optional[Iterable[int]] = None, ascending: bool = True):
-        """A vertical list of sliders, bounded between 0 and 1 with a step of 0.01.
+    def __init__(
+        self, names: Iterable[str], value: Optional[Iterable[float]] = None, ascending: bool = True, decimals: int = 2
+    ):
+        """
+        A vertical list of sliders, bounded between 0 and 1 with dynamic step size
+        based on the specified decimal precision.
 
         Parameters
         ----------
         names : Iterable[str]
             Slider names
-        value : Optional[Iterable[int]], optional
+        value : Optional[Iterable[float]], optional
             Slider start values, by default None, starts all sliders at zero.
         ascending : bool, optional = True
             Forces sliders to be ascending, else decreasing.
             If initial values are not sorted, raise an ValueError.
+        decimals : int, optional
+            Number of decimal places for slider precision (affects step size and rounding), by default 2.
         """
         if len(names) > 6:
             raise ValueError("MonotonicProbabilitySliderListWidget only supports up to 6 sliders")
 
-        super().__init__(names, value)
+        super().__init__(names, value, decimals)
 
         thresholds = list(self.value.values())
         op = operator.le if ascending else operator.ge
@@ -158,4 +177,5 @@ def _on_slider_change(self, change=None):
                 new_tuple = [slider.value for slider in sliders[:slider_index]] + [
                     min(new, slider.value) for slider in sliders[slider_index:]
                 ]
+        new_tuple = [round(min(1.0, max(0.0, v)), self.decimals) for v in new_tuple]
         self.value = {k: v for k, v in zip(self.sliders.keys(), new_tuple)}
diff --git a/src/seismometer/data/binary_performance.py b/src/seismometer/data/binary_performance.py
index c462e8e9..4ea141fc 100644
--- a/src/seismometer/data/binary_performance.py
+++ b/src/seismometer/data/binary_performance.py
@@ -82,6 +82,7 @@ def calculate_stats(
         target_col=target_col,
         score_col=score_col,
         metrics=metrics_to_display,
+        threshold_precision=decimals - 2,
     )
     stats = stats.reset_index()
 
diff --git a/src/seismometer/data/performance.py b/src/seismometer/data/performance.py
index ef7a7d65..c3af401c 100644
--- a/src/seismometer/data/performance.py
+++ b/src/seismometer/data/performance.py
@@ -164,7 +164,7 @@ def delegate_call(
         stats = stats.loc[score_threshold_integer]
         return stats.to_dict()
 
-    def calculate_binary_stats(self, dataframe, target_col, score_col, metrics):
+    def calculate_binary_stats(self, dataframe, target_col, score_col, metrics, threshold_precision=0):
         """
         Calculates binary stats for all thresholds.
 
@@ -178,10 +178,20 @@ def calculate_binary_stats(self, dataframe, target_col, score_col, metrics):
             The column in the dataframe that contains the predicted scores.
         metrics: list[str], optional
             List of metrics to filter down to.
+        threshold_precision : int, optional
+            Number of decimal places to use when generating thresholds as percentages.
+            - E.g., `threshold_precision=0` yields thresholds like 0, 1, ..., 100 (coarse).
+            - `threshold_precision=2` yields 0.00, 0.01, ..., 100.00 (fine-grained).
+            - Higher values improve AUC approximation but increase computation cost.
+            By default 0.
         """
         y_true = dataframe[target_col]
         y_pred = dataframe[score_col]
-        stats = calculate_bin_stats(y_true, y_pred, rho=self.rho).round(5).set_index(THRESHOLD)
+        stats = (
+            calculate_bin_stats(y_true, y_pred, rho=self.rho, threshold_precision=threshold_precision)
+            .round(5)
+            .set_index(THRESHOLD)
+        )
         for name, percent in zip(COUNTS, PERCENTS):
             stats[percent] = stats[name] * 100.0 / len(dataframe)
 
@@ -242,6 +252,7 @@ def calculate_bin_stats(
     keep_score_values: bool = False,
     not_point_thresholds: bool = False,
     rho: float = None,
+    threshold_precision: int = 0,
 ) -> pd.DataFrame:
     """
     Calculate summary statistics from y_true and y_pred (y_proba[:,1] for binary classification) arrays.
@@ -259,6 +270,12 @@ def calculate_bin_stats(
         If True, does not use point thresholds, by default False; uses 0-100.
     rho : float, optional
         The relative risk reduction for NNT calculation, by default DEFAULT_RHO.
+    threshold_precision : int, optional
+        Number of decimal places to use when generating thresholds as percentages.
+        - E.g., `threshold_precision=0` yields thresholds like 0, 1, ..., 100 (coarse).
+        - `threshold_precision=2` yields 0.00, 0.01, ..., 100.00 (fine-grained).
+        - Higher values improve AUC approximation but increase computation cost.
+        By default 0.
 
     Returns
     -------
@@ -297,7 +314,7 @@ def calculate_bin_stats(
     # Reduce thresholds to table 0-100
     # This can reintroduce redundant thresholds, particularly in sparse regions
     if not not_point_thresholds:
-        threshold_ix, thresholds = _point_thresholds(thresholds)
+        threshold_ix, thresholds = _point_thresholds(thresholds, threshold_precision)
         tps = tps[threshold_ix]
         fps = fps[threshold_ix]
 
@@ -434,14 +451,14 @@ def calculate_eval_ci(
         output = as_percentages(output)
 
     roc_conf = ROCConfidenceParam(conf)
-    aucpr_conf = PRConfidenceParam(conf)
+    auprc_conf = PRConfidenceParam(conf)
     with np.errstate(invalid="ignore", divide="ignore"):
         # roc
         thresholds, tpr, fpr, auc_region = roc_conf.region(roc_conf, truth, output)
         auc_interval = roc_conf.interval(roc_conf, truth, output)
         # pr
-        aucpr_interval = aucpr_conf.interval(
-            aucpr_conf,
+        auprc_interval = auprc_conf.interval(
+            auprc_conf,
             auc(stats.Sensitivity, stats.PPV),
             stats[["TP", "FP", "TN", "FN"]].iloc[0].sum(),
         )
@@ -454,7 +471,7 @@ def calculate_eval_ci(
             "region": auc_region,
             "interval": auc_interval,
         },
-        "pr": {"interval": aucpr_interval},
+        "pr": {"interval": auprc_interval},
         "conf": conf,
     }
     return ci_data
@@ -483,13 +500,19 @@ def _bin_class_curve(
     return fps, tps, y_pred[threshold_idxs]
 
 
-def _point_thresholds(orig_thresholds: np.ndarray) -> np.ndarray:
+def _point_thresholds(orig_thresholds: np.ndarray, threshold_precision: int = 0) -> np.ndarray:
     """
-    Convert thresholds to percent increments (0.01) between 0 to 1.
+    Convert float thresholds into evenly spaced percent thresholds between 0 and 100,
+    with granularity based on the specified number of decimal places.
     """
     if orig_thresholds.max() < 1:
         logger.warning("Passed thresholds do not extend to maximum of 1.")
-    thresholds = np.arange(0, 101)[::-1]
+
+    # Compute number of divisions per unit based on precision
+    step_size = 10**threshold_precision
+    num_steps = 100 * step_size
+
+    thresholds = (np.arange(0, num_steps + 1)[::-1]) / step_size  # Convert to percent scale
     ixs = np.digitize(thresholds, orig_thresholds, right=True) - 1
     ixs = np.where(ixs < 0, 0, ixs)  # Clip to 0
     ixs[-1] = -1  # Keep the last threshold
diff --git a/src/seismometer/plot/mpl/binary_classifier.py b/src/seismometer/plot/mpl/binary_classifier.py
index c0f4b63d..516f8d62 100644
--- a/src/seismometer/plot/mpl/binary_classifier.py
+++ b/src/seismometer/plot/mpl/binary_classifier.py
@@ -303,22 +303,22 @@ def ppv_vs_sensitivity(
     thresholds : pd.Series
         The thresholds of the model, used for annotations.
     conf_interval : Optional['ValueWithCI'], optional
-        The confidence interval for the AUCPR, by default None.
+        The confidence interval for the AUPRC, by default None.
     highlight : Optional[list[Number]], optional
         A list of thresholds to highlight on the plot, by default None.
     axis : Optional[plt.Axes], optional
         The matplotlib axis to draw, by default None; creates a new figure.
     """
-    aucpr = f"AUCPR = {metrics.auc(sensitivity, ppv):0.2f}"
+    auprc = f"AUPRC = {metrics.auc(sensitivity, ppv):0.2f}"
 
     if conf_interval is not None:
-        aucpr = "AUCPR = %0.2f (%0.2f, %0.2f)" % (
+        auprc = "AUPRC = %0.2f (%0.2f, %0.2f)" % (
             metrics.auc(sensitivity, ppv),
             conf_interval.lower,
             conf_interval.upper,
         )
 
-    lines.ppv_sensitivity_curve(axis, sensitivity, ppv, label=aucpr)
+    lines.ppv_sensitivity_curve(axis, sensitivity, ppv, label=auprc)
 
     if highlight is not None:
         lines._add_radial_score_thresholds(axis, sensitivity, ppv, thresholds, thresholds=highlight, Q=4)
diff --git a/src/seismometer/table/analytics_table.py b/src/seismometer/table/analytics_table.py
index 3f68f142..604b7116 100644
--- a/src/seismometer/table/analytics_table.py
+++ b/src/seismometer/table/analytics_table.py
@@ -250,6 +250,10 @@ def generate_initial_table(self, data) -> GT:
                 ],
                 decimals=self.decimals,
             )
+            .fmt_number(
+                columns=[col for col in data.columns if col.endswith(f"_{THRESHOLD}")],
+                decimals=self.decimals - 2,
+            )
             .fmt_percent(columns=self.columns_show_percentages, decimals=self.percentages_decimals)
             .tab_style(
                 style=[
@@ -278,7 +282,7 @@ def group_columns_by_metric_value(self, gt, columns, value) -> GT:
         gt : GT
             The table object with grouped columns and added borders.
         """
-        value = value * 100 if self.metric == "Threshold" else value
+        value = round(value * 100, max(0, self.decimals - 2)) if self.metric == THRESHOLD else value
         gt = gt.tab_spanner(label=f"{self.metric}={value}", columns=columns).cols_label(
             **{col: "_".join(col.split("_")[1:]) for col in columns}
         )
@@ -478,7 +482,7 @@ def __init__(self, title: Optional[str] = None):
             option_widget=AnalyticsTableOptionsWidget(
                 tuple(map(pdh.event_name, sg.get_binary_targets())),
                 sg.output_list,
-                metric="Threshold",
+                metric=THRESHOLD,
                 metric_values=None,
                 metrics_to_display=None,
                 cohort_dict=sg.available_cohort_groups,
@@ -571,7 +575,10 @@ def __init__(
         )
         metric_values = metric_values or (0.8, 0.2)
         self._metric_values = MonotonicProbabilitySliderListWidget(
-            names=("Metric Value 1", "Metric Value 2"), value=tuple(metric_values), ascending=False
+            names=("Metric Value 1", "Metric Value 2"),
+            value=tuple(metric_values),
+            ascending=False,
+            decimals=AnalyticsTableConfig().decimals,
         )
         for slider in self._metric_values.sliders.values():
             slider.style.description_width = "min-content"


Test patch:
diff --git a/tests/controls/test_explore.py b/tests/controls/test_explore.py
index f5afc7b1..dd1b9519 100644
--- a/tests/controls/test_explore.py
+++ b/tests/controls/test_explore.py
@@ -12,6 +12,7 @@
 )
 from seismometer.data.performance import MetricGenerator
 from seismometer.table.analytics_table import AnalyticsTableOptionsWidget, ExploreBinaryModelAnalytics
+from seismometer.table.analytics_table_config import AnalyticsTableConfig
 
 
 # region Test Base Classes
@@ -927,6 +928,38 @@ def test_generate_plot_args(self, mock_seismo):
         )
         assert kwargs == {"title": "Unit Test Title", "per_context": False}
 
+    @patch.object(seismogram, "Seismogram", return_value=Mock())
+    def test_slider_respects_decimals(self, mock_seismo):
+        fake_seismo = mock_seismo()
+        fake_seismo.get_binary_targets.return_value = ["T1_Value"]
+        fake_seismo.output_list = ["S1"]
+        fake_seismo.available_cohort_groups = {}
+
+        widget = ExploreBinaryModelAnalytics(title="Decimal Test")
+        decimals = widget.option_widget._metric_values.decimals
+        assert decimals == AnalyticsTableConfig().decimals
+
+    @patch.object(seismogram, "Seismogram", return_value=Mock())
+    def test_generate_plot_args_metric_slider_values_are_rounded(self, mock_seismo):
+        fake_seismo = mock_seismo()
+        fake_seismo.get_binary_targets.return_value = ["T1_Value"]
+        fake_seismo.output_list = ["S1"]
+        fake_seismo.available_cohort_groups = {}
+
+        widget = ExploreBinaryModelAnalytics(title="Slider Rounding Test")
+
+        # Simulate user moving sliders
+        widget.option_widget._metric_values.sliders["Metric Value 1"].value = 0.56789
+        widget.option_widget._metric_values.sliders["Metric Value 2"].value = 0.123456
+
+        args, _ = widget.generate_plot_args()
+
+        expected_rounded = (
+            round(0.56789, AnalyticsTableConfig().decimals),  # → 0.568
+            round(0.123456, AnalyticsTableConfig().decimals),  # → 0.123
+        )
+        assert args[3] == expected_rounded
+
 
 class TestAnalyticsTableOptionsWidget:
     @patch.object(seismogram, "Seismogram", return_value=Mock())
diff --git a/tests/controls/test_thresholds.py b/tests/controls/test_thresholds.py
index 26b0f375..6e861001 100644
--- a/tests/controls/test_thresholds.py
+++ b/tests/controls/test_thresholds.py
@@ -48,6 +48,12 @@ def test_disabled(self):
         assert widget.sliders["a"].disabled
         assert widget.sliders["b"].disabled
 
+    def test_values_are_rounded_to_decimal_precision(self):
+        widget = undertest.ProbabilitySliderListWidget(("a", "b"), (0.1234, 0.5678), decimals=3)
+        assert widget.sliders["a"].value == round(0.1234, 3)
+        assert widget.sliders["b"].value == round(0.5678, 3)
+        assert widget.value == {"a": 0.123, "b": 0.568}
+
 
 class TestMonotonicPercentSliderWidget:
     def test_init(self):
@@ -120,3 +126,15 @@ def test_too_many_thresholds_raises_value_error(self):
         with pytest.raises(ValueError):
             threshold_names = [str(x) for x in range(7)]
             undertest.MonotonicProbabilitySliderListWidget(threshold_names)
+
+    def test_slider_values_are_rounded_to_decimal_precision(self):
+        widget = undertest.MonotonicProbabilitySliderListWidget(("a", "b"), (0.1234, 0.5678), decimals=2)
+        assert widget.sliders["a"].value == round(0.1234, 2)
+        assert widget.sliders["b"].value == round(0.5678, 2)
+        assert widget.value == {"a": 0.12, "b": 0.57}
+
+    def test_slider_interaction_respects_decimals(self):
+        widget = undertest.MonotonicProbabilitySliderListWidget(("a", "b"), (0.1, 0.2), decimals=2)
+        widget.sliders["a"].value = 0.23456
+        assert widget.sliders["a"].value == round(0.23456, 2)
+        assert widget.value["a"] == 0.23
diff --git a/tests/data/test_binary_performance.py b/tests/data/test_binary_performance.py
index 04faf0ae..8a9f7570 100644
--- a/tests/data/test_binary_performance.py
+++ b/tests/data/test_binary_performance.py
@@ -247,3 +247,54 @@ def test_per_context_does_not_leak_state_across_iterations(self, fake_seismo):
 
         # Combining scores for each (score, target) pair should have two positives
         assert result["Positives"].tolist() == [2, 2, 2, 2]
+
+    def test_generate_analytics_data_metric_differs_but_is_close(self, fake_seismo):
+        fake_seismo.event_aggregation_method = lambda target: "max"
+
+        rng = np.random.default_rng(42)
+        y_true = rng.integers(0, 2, size=1000)  # Balanced 0s and 1s
+        y_score = rng.uniform(0, 1, size=1000)  # Uniform prediction scores between 0 and 1
+        df = pd.DataFrame(
+            {
+                "target1": y_true,
+                "score1": y_score,
+            }
+        )
+
+        fake_seismo.dataframe = df
+
+        result_low = generate_analytics_data(
+            score_columns=["score1"],
+            target_columns=["target1"],
+            metric="Sensitivity",
+            metric_values=[0.5],
+            decimals=3,
+            censor_threshold=1,
+        )
+        result_high = generate_analytics_data(
+            score_columns=["score1"],
+            target_columns=["target1"],
+            metric="Sensitivity",
+            metric_values=[0.5],
+            decimals=5,
+            censor_threshold=1,
+        )
+
+        assert result_low is not None and result_high is not None
+
+        # Ensure both versions yield same structure
+        assert list(result_low.columns) == list(result_high.columns)
+
+        for col, atol in [("0.5_Threshold", 0.1), ("AUROC", 0.001)]:
+            assert col in result_low.columns and col in result_high.columns
+
+            val_low = result_low[col].iloc[0]
+            val_high = result_high[col].iloc[0]
+
+            # Values differ (better precision affects threshold resolution)
+            assert (
+                val_low != val_high
+            ), "Differing precision expected, test seed was chosen to verify these two values are not equal"
+
+            # But still close enough (numerically stable)
+            assert np.isclose(val_low, val_high, atol=atol)
diff --git a/tests/data/test_perf_stats.py b/tests/data/test_perf_stats.py
index 0ad0cfc3..5f55487e 100644
--- a/tests/data/test_perf_stats.py
+++ b/tests/data/test_perf_stats.py
@@ -170,6 +170,58 @@ def test_bin_stats_point_thresholds():
     pd.testing.assert_frame_equal(actual, expected[actual.columns], check_dtype=False)
 
 
+def test_threshold_precision_increases_rows():
+    y_true = np.array([1, 0])
+    y_prob = np.array([0.5, 0.3])
+
+    # Use default threshold precision (0): 101 rows (0–100)
+    default_stats = undertest.calculate_bin_stats(y_true, y_prob)
+    assert len(default_stats) == 101
+
+    # Now try precision=2 → 10,001 thresholds
+    high_precision_stats = undertest.calculate_bin_stats(y_true, y_prob, threshold_precision=2)
+    assert len(high_precision_stats) == 100 * 10**2 + 1
+
+
+def test_thresholds_are_rounded_correctly():
+    y_true = np.array([1, 0])
+    y_prob = np.array([0.6, 0.3])
+    threshold_precision = 2
+
+    stats = undertest.calculate_bin_stats(y_true, y_prob, threshold_precision=threshold_precision)
+    thresholds = stats["Threshold"].to_numpy()
+
+    step = 1 / 10**threshold_precision
+    assert np.allclose(np.diff(thresholds[::-1]), step, atol=1e-6)
+
+
+def test_all_metrics_valid_with_high_threshold_precision():
+    y_true = np.array([1, 1, 0, 0])
+    y_prob = np.array([0.9, 0.7, 0.4, 0.2])
+
+    stats = undertest.calculate_bin_stats(y_true, y_prob, threshold_precision=2)
+
+    all_metrics = undertest.STATNAMES + [f"NNT@{undertest.DEFAULT_RHO:0.3n}"]
+
+    for metric in all_metrics:
+        assert metric in stats.columns
+        values = stats[metric].dropna()
+
+        if metric in {"Accuracy", "Sensitivity", "Specificity", "PPV", "NPV", "Flag Rate"}:
+            assert (0.0 <= values).all() and (values <= 1.0).all()
+
+        elif metric in {"TP", "FP", "TN", "FN"}:
+            assert (values >= 0).all()
+            assert pd.api.types.is_integer_dtype(values)
+
+        elif metric in {"LR+", "NetBenefitScore", "NNE", f"NNT@{undertest.DEFAULT_RHO:0.3n}"}:
+            finite_values = values[np.isfinite(values)]
+            assert (finite_values >= 0).all()
+
+        else:
+            assert pd.api.types.is_numeric_dtype(values)
+
+
 def sumall(a, b):
     return a.sum() + b.sum()
 
diff --git a/tests/data/test_performance.py b/tests/data/test_performance.py
index 42345ee9..eac05558 100644
--- a/tests/data/test_performance.py
+++ b/tests/data/test_performance.py
@@ -3,6 +3,7 @@
 import numpy as np
 import pandas as pd
 import pytest
+from sklearn.metrics import roc_auc_score
 
 import seismometer.data.performance as undertest
 from seismometer.data.confidence.calculations import ValueWithCI, _RocRegionResults
@@ -289,3 +290,60 @@ def test_calc_ci_maps_roc_values(self):
         )
 
         assert actual == expected
+
+    def test_binary_metric_generator_with_threshold_precision(self):
+        metrics = undertest.BinaryClassifierMetricGenerator()
+        y_true = np.array([1, 0])
+        y_prob = np.array([0.9, 0.2])
+        df = pd.DataFrame({"truth": y_true, "score": y_prob})
+
+        # Low precision: coarse thresholds
+        low_precision = metrics.calculate_binary_stats(df, "truth", "score", ["Accuracy"], threshold_precision=0)[0]
+
+        # High precision: fine-grained thresholds
+        high_precision = metrics.calculate_binary_stats(df, "truth", "score", ["Accuracy"], threshold_precision=2)[0]
+
+        assert len(high_precision) > len(low_precision)
+        assert "Accuracy" in high_precision.columns
+
+    def test_score_threshold_row_lookup(self):
+        metrics = undertest.BinaryClassifierMetricGenerator()
+        y_true = np.array([1, 0])
+        y_prob = np.array([0.55, 0.25])
+        df = pd.DataFrame({"truth": y_true, "score": y_prob})
+
+        stats, _ = metrics.calculate_binary_stats(df, "truth", "score", ["Accuracy"])
+        assert 55 in stats.index  # score_threshold=0.55 → index 55
+
+    def test_overall_stats_include_auc_with_precision(self):
+        metrics = undertest.BinaryClassifierMetricGenerator()
+        y_true = np.array([1, 1, 0, 0])
+        y_prob = np.array([0.9, 0.7, 0.4, 0.1])
+        df = pd.DataFrame({"truth": y_true, "score": y_prob})
+
+        _, overall = metrics.calculate_binary_stats(df, "truth", "score", ["Accuracy"], threshold_precision=2)
+
+        assert "AUROC" in overall
+        assert 0.0 <= overall["AUROC"] <= 1.0
+        assert "AUPRC" in overall
+        assert 0.0 <= overall["AUPRC"] <= 1.0
+
+    def test_auc_precision_effect_on_larger_data(self):
+        rng = np.random.default_rng(42)
+        y_true = rng.integers(0, 2, size=1000)
+        y_prob = rng.uniform(0, 1, size=1000)
+
+        true_auc = roc_auc_score(y_true, y_prob)
+
+        # Coarse threshold grid
+        stats_coarse = undertest.calculate_bin_stats(y_true, y_prob, threshold_precision=0)
+        auc_coarse = undertest.auc(1 - stats_coarse["Specificity"], stats_coarse["Sensitivity"])
+
+        # Fine threshold grid
+        stats_fine = undertest.calculate_bin_stats(y_true, y_prob, threshold_precision=1)
+        auc_fine = undertest.auc(1 - stats_fine["Specificity"], stats_fine["Sensitivity"])
+
+        # Assertions
+        assert abs(auc_fine - true_auc) < abs(auc_coarse - true_auc)
+        assert abs(auc_fine - true_auc) < 0.001
+        assert abs(auc_coarse - true_auc) < 0.01
diff --git a/tests/table/test_analytics_table.py b/tests/table/test_analytics_table.py
index 9ca1316a..62311373 100644
--- a/tests/table/test_analytics_table.py
+++ b/tests/table/test_analytics_table.py
@@ -1,3 +1,4 @@
+import re
 from unittest.mock import Mock
 
 import pandas as pd
@@ -6,6 +7,7 @@
 from seismometer.configuration import ConfigProvider
 from seismometer.configuration.model import Cohort, Event
 from seismometer.data.loader import SeismogramLoader
+from seismometer.data.performance import THRESHOLD
 from seismometer.seismogram import Seismogram
 from seismometer.table.analytics_table import AnalyticsTable
 from seismometer.table.analytics_table_config import AnalyticsTableConfig
@@ -320,3 +322,38 @@ def test_cohort_dict_filtering(self, fake_seismo):
 
         # Check if the data is None due to filtering to an empty set
         assert data is None
+
+    def test_all_columns_are_rounded_correctly_in_html(self, fake_seismo):
+        scores = ["score1"]
+        targets = ["target1"]
+        decimals = 3
+
+        table = AnalyticsTable(
+            score_columns=scores,
+            target_columns=targets,
+            metric="Threshold",
+            metric_values=[0.8],
+            table_config=AnalyticsTableConfig(decimals=decimals),
+            censor_threshold=1,
+            cohort_dict={"cohort1": ("A", "B")},
+        )
+
+        data = table._generate_table_data()
+        assert data is not None
+
+        gt = table.generate_initial_table(data)
+        assert gt is not None
+        html = gt.as_raw_html()
+
+        for col in data.columns:
+            if not pd.api.types.is_numeric_dtype(data[col]):
+                continue  # Skip non-numeric columns
+            if pd.api.types.is_integer_dtype(data[col]):
+                continue  # Skip integers (not formatted with decimals)
+
+            col_decimals = max(0, decimals - 2) if col.endswith(f"_{THRESHOLD}") else decimals
+
+            for val in data[col].dropna():
+                rounded = round(val, col_decimals)
+                formatted = f"{rounded:.{col_decimals}f}"
+                assert re.search(rf">\s*{formatted}\s*<", html)