zedstat 0.0.153__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. {zedstat-0.0.153/zedstat.egg-info → zedstat-0.2.0}/PKG-INFO +2 -2
  2. zedstat-0.2.0/tests/test_ci_logratio.py +39 -0
  3. zedstat-0.2.0/tests/test_getbounds_current_geometry.py +83 -0
  4. zedstat-0.2.0/tests/test_getbounds_preserves_nominal.py +55 -0
  5. zedstat-0.2.0/tests/test_lr_ci.py +54 -0
  6. zedstat-0.2.0/version.py +1 -0
  7. zedstat-0.2.0/zedstat/__init__.py +230 -0
  8. zedstat-0.2.0/zedstat/ci_logratio.py +166 -0
  9. zedstat-0.2.0/zedstat/lr_ci.py +190 -0
  10. zedstat-0.2.0/zedstat/pr_ci.py +185 -0
  11. {zedstat-0.0.153 → zedstat-0.2.0}/zedstat/zedstat.py +11 -0
  12. {zedstat-0.0.153 → zedstat-0.2.0/zedstat.egg-info}/PKG-INFO +2 -2
  13. {zedstat-0.0.153 → zedstat-0.2.0}/zedstat.egg-info/SOURCES.txt +7 -0
  14. zedstat-0.0.153/version.py +0 -1
  15. zedstat-0.0.153/zedstat/__init__.py +0 -18
  16. {zedstat-0.0.153 → zedstat-0.2.0}/LICENSE +0 -0
  17. {zedstat-0.0.153 → zedstat-0.2.0}/MANIFEST.in +0 -0
  18. {zedstat-0.0.153 → zedstat-0.2.0}/README.rst +0 -0
  19. {zedstat-0.0.153 → zedstat-0.2.0}/setup.cfg +0 -0
  20. {zedstat-0.0.153 → zedstat-0.2.0}/setup.py +0 -0
  21. {zedstat-0.0.153 → zedstat-0.2.0}/zedstat/.ipynb_checkpoints/Untitled-Copy1-checkpoint.ipynb +0 -0
  22. {zedstat-0.0.153 → zedstat-0.2.0}/zedstat/.ipynb_checkpoints/Untitled-checkpoint.ipynb +0 -0
  23. {zedstat-0.0.153 → zedstat-0.2.0}/zedstat/.ipynb_checkpoints/example3-checkpoint.ipynb +0 -0
  24. {zedstat-0.0.153 → zedstat-0.2.0}/zedstat/calibration.py +0 -0
  25. {zedstat-0.0.153 → zedstat-0.2.0}/zedstat/ci.py +0 -0
  26. {zedstat-0.0.153 → zedstat-0.2.0}/zedstat/example_combine.py +0 -0
  27. {zedstat-0.0.153 → zedstat-0.2.0}/zedstat/mwauc.py +0 -0
  28. {zedstat-0.0.153 → zedstat-0.2.0}/zedstat/rrci.py +0 -0
  29. {zedstat-0.0.153 → zedstat-0.2.0}/zedstat/textable.py +0 -0
  30. {zedstat-0.0.153 → zedstat-0.2.0}/zedstat/zcombine.py +0 -0
  31. {zedstat-0.0.153 → zedstat-0.2.0}/zedstat.egg-info/dependency_links.txt +0 -0
  32. {zedstat-0.0.153 → zedstat-0.2.0}/zedstat.egg-info/requires.txt +0 -0
  33. {zedstat-0.0.153 → zedstat-0.2.0}/zedstat.egg-info/top_level.txt +0 -0
@@ -1,9 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: zedstat
3
- Version: 0.0.153
3
+ Version: 0.2.0
4
4
  Summary: Statistics tools for ML models and deployment
5
5
  Home-page: https://github.com/zeroknowledgediscovery/zedstat
6
- Download-URL: https://github.com/zeroknowledgediscovery/zedstat/archive/0.0.153.tar.gz
6
+ Download-URL: https://github.com/zeroknowledgediscovery/zedstat/archive/0.2.0.tar.gz
7
7
  Author: zed.uchicago.edu
8
8
  Author-email: ishanu@uchicago.edu
9
9
  License: LICENSE
@@ -0,0 +1,39 @@
1
+ import unittest
2
+ import numpy as np
3
+ import pandas as pd
4
+ from zedstat.ci_logratio import ppv_logratio_ci, lr_logratio_ci
5
+
6
+ class TestLogRatioCI(unittest.TestCase):
7
+ def test_ppv_formula_and_bracketing(self):
8
+ roc = pd.DataFrame({'fpr':[0, 0.02, 0.05,0.2], 'tpr':[0,0.15,0.5,0.9]})
9
+ out=ppv_logratio_ci(roc, positive_samples=400, negative_samples=2000,
10
+ prevalence=.1, min_tp=10, min_fp=10, smooth_sigma=2)
11
+ self.assertTrue(np.isnan(out['ppv_logratio_lower'].iloc[0]))
12
+ center=.1*.5/(.1*.5+.9*.05)
13
+ self.assertAlmostEqual(center, float(out['ppv'].iloc[2]) if 'ppv' in out else center)
14
+ row=out.iloc[2]
15
+ self.assertTrue(row['ppv_logratio_lower'] < center < row['ppv_logratio_upper'])
16
+
17
+ def test_lr_asymptotic_intervals_enclose_nominal_and_preserve_nan(self):
18
+ counts = pd.DataFrame({'TP':[5,30,100,250],'FP':[2,20,90,160],
19
+ 'FN':[395,370,300,150], 'TN':[1998,1980,1910,1840]})
20
+ counts['LR+'] = (counts['TP']/400)/(counts['FP']/2000)
21
+ counts['LR-'] = (counts['FN']/400)/(counts['TN']/2000)
22
+ a=lr_logratio_ci(counts, smooth_sigma=0)
23
+ b=lr_logratio_ci(counts, smooth_sigma=1.8)
24
+ self.assertTrue(np.isnan(a['LR+_logratio_lower'].iloc[0]))
25
+ for metric in ('LR+', 'LR-'):
26
+ np.testing.assert_array_equal(a[f'{metric}_logratio_lower'],b[f'{metric}_logratio_lower'])
27
+ valid=b[f'{metric}_logratio_lower_display'].notna()
28
+ self.assertTrue((b.loc[valid, f'{metric}_logratio_lower_display'] <= b.loc[valid,metric]).all())
29
+ self.assertTrue((b.loc[valid,metric] <= b.loc[valid,f'{metric}_logratio_upper_display']).all())
30
+
31
+ def test_boundary_support_is_not_reconstructed(self):
32
+ roc=pd.DataFrame({'fpr':[0,0.005,.01,.02], 'tpr':[0,.1,.2,.4]})
33
+ out=ppv_logratio_ci(roc,positive_samples=200,negative_samples=2000,prevalence=.1,
34
+ min_fp=20,smooth_sigma=10)
35
+ self.assertTrue(np.isnan(out['ppv_logratio_lower_display'].iloc[0]))
36
+ self.assertTrue(np.isnan(out['ppv_logratio_lower_display'].iloc[1]))
37
+ self.assertTrue(np.isfinite(out['ppv_logratio_lower_display'].iloc[2]))
38
+
39
+ if __name__ == '__main__': unittest.main()
@@ -0,0 +1,83 @@
1
+ import inspect
2
+ import unittest
3
+
4
+ import numpy as np
5
+ import pandas as pd
6
+ from pandas.testing import assert_frame_equal
7
+
8
+ from zedstat import zedstat
9
+
10
+
11
+ class GetBoundsCurrentGeometryTest(unittest.TestCase):
12
+ @staticmethod
13
+ def _make_zt():
14
+ # Deliberately fluctuating ROC so convexification materially changes it.
15
+ roc = pd.DataFrame(
16
+ {
17
+ "fpr": [0.0, 0.04, 0.08, 0.13, 0.20, 0.31, 0.44, 0.60, 0.78, 1.0],
18
+ "tpr": [0.0, 0.31, 0.27, 0.53, 0.48, 0.70, 0.66, 0.84, 0.88, 1.0],
19
+ }
20
+ )
21
+ zt = zedstat.processRoc(
22
+ df=roc,
23
+ total_samples=1200,
24
+ positive_samples=600,
25
+ alpha=0.01,
26
+ prevalence=0.5,
27
+ )
28
+ zt.smooth(STEP=0.001, interpolate=True, convexify=True)
29
+ zt.allmeasures(interpolate=False)
30
+ return zt
31
+
32
+ def test_getbounds_defaults_to_current_geometry(self):
33
+ zt = self._make_zt()
34
+ before = zt.get().copy(deep=True)
35
+ auc_before = zt.auc(alpha=0.05)[0]
36
+
37
+ zt.getBounds()
38
+
39
+ after = zt.get().copy(deep=True)
40
+ auc_after = zt.auc(alpha=0.05)[0]
41
+
42
+ # getBounds is side-effect free with respect to the nominal ROC.
43
+ assert_frame_equal(before, after, check_exact=True)
44
+ self.assertAlmostEqual(auc_before, auc_after, places=14)
45
+
46
+ self.assertEqual(zt.df_measure_bounds_["geometry"], "current")
47
+ self.assertIn("L", zt.df_lim)
48
+ self.assertIn("U", zt.df_lim)
49
+
50
+ nominal = zt.get()["tpr"].to_numpy(dtype=float)
51
+ lower = zt.df_lim["L"]["tpr"].to_numpy(dtype=float)
52
+ upper = zt.df_lim["U"]["tpr"].to_numpy(dtype=float)
53
+
54
+ finite = np.isfinite(nominal) & np.isfinite(lower) & np.isfinite(upper)
55
+ self.assertTrue(np.all(lower[finite] <= nominal[finite] + 1e-12))
56
+ self.assertTrue(np.all(upper[finite] >= nominal[finite] - 1e-12))
57
+
58
+ # Display ROC limits follow monotone ROC geometry after smoothing.
59
+ self.assertTrue(np.all(np.diff(lower[np.isfinite(lower)]) >= -1e-12))
60
+ self.assertTrue(np.all(np.diff(upper[np.isfinite(upper)]) >= -1e-12))
61
+
62
+ def test_empirical_geometry_remains_available(self):
63
+ zt = self._make_zt()
64
+ before = zt.get().copy(deep=True)
65
+
66
+ zt.getBounds(geometry="empirical")
67
+
68
+ assert_frame_equal(before, zt.get(), check_exact=True)
69
+ self.assertEqual(zt.df_measure_bounds_["geometry"], "empirical")
70
+
71
+ def test_geometry_parameter_is_visible(self):
72
+ sig = inspect.signature(zedstat.processRoc.getBounds)
73
+ self.assertIn("geometry", sig.parameters)
74
+ self.assertEqual(sig.parameters["geometry"].default, "current")
75
+
76
+ def test_invalid_geometry_raises(self):
77
+ zt = self._make_zt()
78
+ with self.assertRaises(ValueError):
79
+ zt.getBounds(geometry="banana")
80
+
81
+
82
+ if __name__ == "__main__":
83
+ unittest.main()
@@ -0,0 +1,55 @@
1
+ import unittest
2
+
3
+ import numpy as np
4
+ import pandas as pd
5
+ from pandas.testing import assert_frame_equal
6
+
7
+ from zedstat import zedstat
8
+
9
+
10
+ class GetBoundsNominalInvariantTest(unittest.TestCase):
11
+ def test_getbounds_preserves_convexified_nominal_roc(self):
12
+ # Deliberately fluctuating ROC so convexification materially changes it.
13
+ roc = pd.DataFrame(
14
+ {
15
+ "fpr": [0.0, 0.05, 0.10, 0.18, 0.25, 0.40, 0.55, 0.75, 1.0],
16
+ "tpr": [0.0, 0.36, 0.31, 0.60, 0.54, 0.78, 0.74, 0.91, 1.0],
17
+ }
18
+ )
19
+
20
+ zt = zedstat.processRoc(
21
+ df=roc,
22
+ total_samples=1000,
23
+ positive_samples=500,
24
+ alpha=0.01,
25
+ prevalence=0.5,
26
+ )
27
+ zt.smooth(STEP=0.001, interpolate=True, convexify=True)
28
+ zt.allmeasures(interpolate=False)
29
+
30
+ before = zt.get().copy(deep=True)
31
+ auc_before = zt.auc(alpha=0.05)[0]
32
+
33
+ # Confirm the fixture actually exercises the ROCCH path.
34
+ fpr = before.index.to_numpy(dtype=float)
35
+ tpr = before["tpr"].to_numpy(dtype=float)
36
+ dx = np.diff(fpr)
37
+ dy = np.diff(tpr)
38
+ slopes = dy / dx
39
+ self.assertTrue(np.all(dx > 0))
40
+ self.assertTrue(np.all(dy >= -1e-12))
41
+ self.assertTrue(np.all(np.diff(slopes) <= 1e-9))
42
+
43
+ zt.getBounds()
44
+
45
+ after = zt.get().copy(deep=True)
46
+ auc_after = zt.auc(alpha=0.05)[0]
47
+
48
+ assert_frame_equal(before, after, check_exact=True)
49
+ self.assertAlmostEqual(auc_before, auc_after, places=14)
50
+ self.assertIn("L", zt.df_lim)
51
+ self.assertIn("U", zt.df_lim)
52
+
53
+
54
+ if __name__ == "__main__":
55
+ unittest.main()
@@ -0,0 +1,54 @@
1
+ """Regression tests for zedstat LR frontier confidence intervals."""
2
+ import unittest
3
+ import numpy as np
4
+ import pandas as pd
5
+ from pandas.testing import assert_frame_equal
6
+
7
+ from zedstat.lr_ci import lr_curve_ci_from_scores
8
+
9
+
10
+ class TestLRCurveCI(unittest.TestCase):
11
+ @staticmethod
12
+ def mock(n_pos=350, n_neg=1800, seed=8):
13
+ rng = np.random.default_rng(seed)
14
+ positive = rng.beta(4, 3, n_pos)
15
+ negative = rng.beta(2, 5, n_neg)
16
+ return pd.DataFrame({"target": np.r_[np.ones(n_pos), np.zeros(n_neg)],
17
+ "predicted_risk": np.r_[positive, negative]})
18
+
19
+ def test_ci_encloses_nominal_and_supports_all_points(self):
20
+ data = self.mock()
21
+ out = lr_curve_ci_from_scores(data, n_boot=400, random_state=1)
22
+ self.assertGreater(len(out), 10)
23
+ self.assertTrue((out[["TP", "FP", "FN", "TN"]].min(axis=1) >= 10).all())
24
+ self.assertTrue(out["LR-"].is_monotonic_increasing)
25
+ self.assertTrue(out["LR+"].is_monotonic_increasing)
26
+ for metric in ("LR+", "LR-"):
27
+ complete = out[[f"{metric}_lower", f"{metric}_upper"]].notna().all(axis=1)
28
+ self.assertGreater(complete.sum(), 0)
29
+ self.assertTrue((out.loc[complete, f"{metric}_lower"] <= out.loc[complete, metric]).all())
30
+ self.assertTrue((out.loc[complete, metric] <= out.loc[complete, f"{metric}_upper"]).all())
31
+
32
+ def test_reproducible_and_smoothing_does_not_modify_raw_ci(self):
33
+ data = self.mock()
34
+ a = lr_curve_ci_from_scores(data, n_boot=400, random_state=19)
35
+ b = lr_curve_ci_from_scores(data, n_boot=400, random_state=19, smooth_sigma=1.2)
36
+ for col in ["LR+", "LR-", "LR+_lower", "LR+_upper", "LR-_lower", "LR-_upper"]:
37
+ np.testing.assert_array_equal(a[col].to_numpy(), b[col].to_numpy())
38
+ self.assertTrue(np.isfinite(b["LR+_lower_display"]).sum() > 0)
39
+
40
+ def test_few_positives_are_handled_without_endpoint_extension(self):
41
+ data = self.mock(n_pos=45, n_neg=1600, seed=93)
42
+ out = lr_curve_ci_from_scores(data, n_boot=400, random_state=22)
43
+ self.assertGreater(len(out), 0)
44
+ self.assertTrue((out[["TP", "FP", "FN", "TN"]].min(axis=1) >= 10).all())
45
+ for end in ("lower", "upper"):
46
+ self.assertTrue(out[f"LR+_{end}"].isna().equals(out[f"LR+_{end}_display"].isna()))
47
+
48
+ def test_reject_insufficient_counts(self):
49
+ with self.assertRaisesRegex(ValueError, "Too few"):
50
+ lr_curve_ci_from_scores(self.mock(n_pos=15, n_neg=1000), n_boot=400)
51
+
52
+
53
+ if __name__ == "__main__":
54
+ unittest.main()
@@ -0,0 +1 @@
1
+ __version__ = '0.2.0'
@@ -0,0 +1,230 @@
1
+ """
2
+ # zedstat
3
+
4
+ Utilities for uncertainty quantification and deployment of ML models.
5
+
6
+ Installation::
7
+
8
+ pip install zedstat
9
+
10
+ Useful for calculation of likelihood ratios, confidence intervals on AUC,
11
+ and simple performance interpretation.
12
+ """
13
+
14
+ from functools import wraps
15
+ import inspect
16
+
17
+ import numpy as np
18
+ import pandas as pd
19
+
20
+ # Import the public submodule during package initialization so every supported
21
+ # import style receives the same processRoc behavior.
22
+ from . import zedstat as zedstat
23
+
24
+
25
+ def _bounds_on_current_geometry(self, nominal_df, prevalence):
26
+ """Re-center empirical pointwise uncertainty on the current ROC geometry.
27
+
28
+ The uncertainty widths are still estimated from the raw empirical ROC by
29
+ processRoc.getBounds(). This helper only changes the display geometry:
30
+ those empirical lower/upper distances are interpolated to the current FPR
31
+ grid and re-centered on the current (possibly smoothed/convexified) ROC.
32
+
33
+ This avoids treating a convex-hull/interpolated TPR as if it were itself an
34
+ independent binomial observation while ensuring the displayed confidence
35
+ band follows the ROC that the user actually requested.
36
+ """
37
+ info = getattr(self, "df_measure_bounds_", None) or {}
38
+ nominal_emp = info.get("nominal_empirical")
39
+ lower_emp = info.get("L_empirical")
40
+ upper_emp = info.get("U_empirical")
41
+
42
+ if nominal_emp is None or lower_emp is None or upper_emp is None:
43
+ return
44
+
45
+ # Recompute derived measures from the current FPR/TPR coordinates so the
46
+ # display nominal is internally consistent with the smoothed ROC.
47
+ nominal = self._compute_measures(
48
+ nominal_df,
49
+ prevalence=float(prevalence),
50
+ apply_ppv_isotonic=True,
51
+ )
52
+ target_index = nominal.index.to_numpy(dtype=float)
53
+
54
+ cols = [
55
+ c
56
+ for c in ["tpr", "ppv", "acc", "npv", "LR+", "LR-"]
57
+ if c in nominal.columns
58
+ and c in nominal_emp.columns
59
+ and c in lower_emp.columns
60
+ and c in upper_emp.columns
61
+ ]
62
+
63
+ # Empirical one-sided uncertainty widths around the raw nominal curve.
64
+ lower_gap = pd.DataFrame(index=nominal_emp.index.copy())
65
+ upper_gap = pd.DataFrame(index=nominal_emp.index.copy())
66
+ lower_gap.index.name = self.fprcol
67
+ upper_gap.index.name = self.fprcol
68
+
69
+ for col in cols:
70
+ n = pd.to_numeric(nominal_emp[col], errors="coerce").to_numpy(dtype=float)
71
+ lo = pd.to_numeric(lower_emp[col], errors="coerce").to_numpy(dtype=float)
72
+ hi = pd.to_numeric(upper_emp[col], errors="coerce").to_numpy(dtype=float)
73
+
74
+ lower_gap[col] = np.where(
75
+ np.isfinite(n) & np.isfinite(lo),
76
+ np.maximum(n - lo, 0.0),
77
+ np.nan,
78
+ )
79
+ upper_gap[col] = np.where(
80
+ np.isfinite(n) & np.isfinite(hi),
81
+ np.maximum(hi - n, 0.0),
82
+ np.nan,
83
+ )
84
+
85
+ lower_gap = zedstat._numeric_interp_frame_to_target_index(
86
+ lower_gap,
87
+ target_index,
88
+ index_name=self.fprcol,
89
+ )
90
+ upper_gap = zedstat._numeric_interp_frame_to_target_index(
91
+ upper_gap,
92
+ target_index,
93
+ index_name=self.fprcol,
94
+ )
95
+
96
+ lower = nominal.copy(deep=True)
97
+ upper = nominal.copy(deep=True)
98
+
99
+ for col in cols:
100
+ n = pd.to_numeric(nominal[col], errors="coerce").to_numpy(dtype=float)
101
+ lg = pd.to_numeric(lower_gap[col], errors="coerce").to_numpy(dtype=float)
102
+ ug = pd.to_numeric(upper_gap[col], errors="coerce").to_numpy(dtype=float)
103
+ lower[col] = n - lg
104
+ upper[col] = n + ug
105
+
106
+ # Probability-like quantities are bounded by [0, 1].
107
+ for col in ["tpr", "ppv", "acc", "npv"]:
108
+ if col in lower.columns:
109
+ lower[col] = np.clip(
110
+ pd.to_numeric(lower[col], errors="coerce").to_numpy(dtype=float),
111
+ 0.0,
112
+ 1.0,
113
+ )
114
+ upper[col] = np.clip(
115
+ pd.to_numeric(upper[col], errors="coerce").to_numpy(dtype=float),
116
+ 0.0,
117
+ 1.0,
118
+ )
119
+
120
+ # ROC display limits should respect monotonic ROC geometry. We do not
121
+ # independently convex-hull the limits because that would alter their
122
+ # statistical interpretation more aggressively.
123
+ if "tpr" in lower.columns:
124
+ lower["tpr"] = np.maximum.accumulate(
125
+ pd.to_numeric(lower["tpr"], errors="coerce").to_numpy(dtype=float)
126
+ )
127
+ upper["tpr"] = np.maximum.accumulate(
128
+ pd.to_numeric(upper["tpr"], errors="coerce").to_numpy(dtype=float)
129
+ )
130
+
131
+ lower, upper = zedstat._enforce_bounds_around_nominal(
132
+ nominal,
133
+ lower,
134
+ upper,
135
+ cols=cols,
136
+ )
137
+
138
+ nominal = zedstat._apply_lr_floor(
139
+ nominal,
140
+ lr_fpr_floor=self.lr_fpr_floor,
141
+ lr_sp_floor=getattr(self, "lr_sp_floor", self.lr_fpr_floor),
142
+ fprcol=self.fprcol,
143
+ )
144
+ lower = zedstat._apply_lr_floor(
145
+ lower,
146
+ lr_fpr_floor=self.lr_fpr_floor,
147
+ lr_sp_floor=getattr(self, "lr_sp_floor", self.lr_fpr_floor),
148
+ fprcol=self.fprcol,
149
+ )
150
+ upper = zedstat._apply_lr_floor(
151
+ upper,
152
+ lr_fpr_floor=self.lr_fpr_floor,
153
+ lr_sp_floor=getattr(self, "lr_sp_floor", self.lr_fpr_floor),
154
+ fprcol=self.fprcol,
155
+ )
156
+
157
+ self.df_lim["L"] = lower
158
+ self.df_lim["U"] = upper
159
+ self.df_measure_bounds_["nominal_display"] = nominal
160
+ self.df_measure_bounds_["L_display"] = lower
161
+ self.df_measure_bounds_["U_display"] = upper
162
+ self.df_measure_bounds_["geometry"] = "current"
163
+
164
+
165
+ def _install_getbounds_geometry_guard():
166
+ """Keep the nominal ROC invariant and align display bounds when requested."""
167
+ original = zedstat.processRoc.getBounds
168
+
169
+ # If an older package reload left the nominal-only guard in place, unwrap it
170
+ # before installing the newer geometry-aware wrapper.
171
+ if getattr(original, "_bounds_geometry_v2", False):
172
+ return
173
+ if getattr(original, "_preserves_nominal_df", False) and hasattr(original, "__wrapped__"):
174
+ original = original.__wrapped__
175
+
176
+ original_sig = inspect.signature(original)
177
+
178
+ @wraps(original)
179
+ def guarded_getBounds(self, *args, geometry="current", **kwargs):
180
+ geometry = str(geometry).lower()
181
+ if geometry not in {"current", "empirical"}:
182
+ raise ValueError("geometry must be 'current' or 'empirical'")
183
+
184
+ nominal_df = self.df.copy(deep=True)
185
+ bound = original_sig.bind_partial(self, *args, **kwargs)
186
+ prevalence = bound.arguments.get("prevalence", self.prevalence)
187
+
188
+ try:
189
+ result = original(self, *args, **kwargs)
190
+
191
+ if geometry == "current":
192
+ if prevalence is None:
193
+ raise ValueError("prevalence undefined")
194
+ _bounds_on_current_geometry(
195
+ self,
196
+ nominal_df=nominal_df,
197
+ prevalence=prevalence,
198
+ )
199
+ else:
200
+ self.df_measure_bounds_["geometry"] = "empirical"
201
+
202
+ return result
203
+ finally:
204
+ # Bounds are side products. The nominal/display ROC selected by
205
+ # smooth()/usample() must never be replaced by getBounds().
206
+ self.df = nominal_df
207
+
208
+ # Preserve introspection while exposing the new keyword-only option.
209
+ params = list(original_sig.parameters.values())
210
+ params.append(
211
+ inspect.Parameter(
212
+ "geometry",
213
+ kind=inspect.Parameter.KEYWORD_ONLY,
214
+ default="current",
215
+ )
216
+ )
217
+ guarded_getBounds.__signature__ = original_sig.replace(parameters=params)
218
+ guarded_getBounds._preserves_nominal_df = True
219
+ guarded_getBounds._bounds_geometry_v2 = True
220
+ zedstat.processRoc.getBounds = guarded_getBounds
221
+
222
+
223
+ _install_getbounds_geometry_guard()
224
+ del _install_getbounds_geometry_guard
225
+
226
+ # Pointwise score-level LR confidence intervals and fill-only plot helper.
227
+ from .lr_ci import lr_curve_ci_from_scores, plot_lr_ci_fill
228
+
229
+ # Score-based CI extensions (pointwise; display smoothing is non-inferential)
230
+ from .pr_ci import precision_recall_ci_from_scores, plot_precision_recall_ci_fill
@@ -0,0 +1,166 @@
1
+ """Approximate pointwise log-ratio CIs and separate display-only smoothing.
2
+
3
+ Statistical scope:
4
+ - Large-sample log-delta intervals at fixed score thresholds, conditional on
5
+ the validation sample's positive/negative totals and a fixed target prevalence.
6
+ - No simultaneous confidence-band or data-driven frontier selection guarantee.
7
+ - When rates are read from a smoothed ROC, inference is approximate; for a
8
+ rigorous coverage claim compute the estimates on empirical thresholds first.
9
+ - *_display columns are *cosmetic*, not separately calibrated 95% intervals.
10
+
11
+ No extrapolation, clipping of large widths, or empirical-CI shrinkage is used.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import numpy as np
17
+ import pandas as pd
18
+ from scipy.ndimage import gaussian_filter1d
19
+ from scipy.special import expit, logit
20
+ from scipy.stats import norm
21
+
22
+
23
+ def _smooth_log_widths(nominal, lower, upper, *, sigma=0.0, scale="log"):
24
+ """Smooth lower/upper distances around nominal, respecting NaN gaps.
25
+
26
+ log for positive LR, logit for probability. Never changes nominal, never
27
+ extrapolates or bridges a missing interval. The outputs are display-only.
28
+ """
29
+ y = np.asarray(nominal, dtype=float)
30
+ lo = np.asarray(lower, dtype=float)
31
+ hi = np.asarray(upper, dtype=float)
32
+ d_lo = np.full(y.shape, np.nan)
33
+ d_hi = np.full(y.shape, np.nan)
34
+ if scale == "logit":
35
+ transform, inv = logit, expit
36
+ valid = (np.isfinite(y) & np.isfinite(lo) & np.isfinite(hi)
37
+ & (y > 0) & (y < 1) & (lo > 0) & (lo < 1)
38
+ & (hi > 0) & (hi < 1) & (lo <= y) & (y <= hi))
39
+ elif scale == "log":
40
+ transform, inv = np.log, np.exp
41
+ valid = (np.isfinite(y) & np.isfinite(lo) & np.isfinite(hi)
42
+ & (y > 0) & (lo > 0) & (hi > 0)
43
+ & (lo <= y) & (y <= hi))
44
+ else:
45
+ raise ValueError("scale must be 'log' or 'logit'")
46
+ indices = np.flatnonzero(valid)
47
+ if not len(indices):
48
+ return d_lo, d_hi
49
+ for seg in np.split(indices, np.flatnonzero(np.diff(indices) > 1) + 1):
50
+ center = transform(y[seg])
51
+ lower_width = np.maximum(center - transform(lo[seg]), 0)
52
+ upper_width = np.maximum(transform(hi[seg]) - center, 0)
53
+ if sigma > 0 and len(seg) >= 3:
54
+ lower_width = gaussian_filter1d(lower_width, sigma=float(sigma), mode="reflect")
55
+ upper_width = gaussian_filter1d(upper_width, sigma=float(sigma), mode="reflect")
56
+ d_lo[seg] = inv(center - lower_width)
57
+ d_hi[seg] = inv(center + upper_width)
58
+ return d_lo, d_hi
59
+
60
+
61
+ def ppv_logratio_ci(
62
+ roc: pd.DataFrame,
63
+ *,
64
+ positive_samples: int,
65
+ negative_samples: int,
66
+ prevalence: float,
67
+ confidence: float = 0.95,
68
+ min_tp: int = 10,
69
+ min_fp: int = 10,
70
+ smooth_sigma: float = 0.0,
71
+ ) -> pd.DataFrame:
72
+ """Return approximate pointwise PPV CIs on a ROC-derived data frame.
73
+
74
+ Under fixed-prevalence transport, logit(PPV) = logit(pi) + log(TPR/FPR).
75
+ Assuming independent binomial TPR/FPR sampling, delta-method variance is
76
+ Var(log(TPR/FPR)) ≈ 1/TP - 1/n_pos + 1/FP - 1/n_neg.
77
+
78
+ The nominal PPV column, if supplied, is used as the center; when it has
79
+ been isotonic-corrected, CIs are *approximate display-aligned* intervals,
80
+ not a coverage assertion for a constrained estimator.
81
+ """
82
+ if not 0 < prevalence < 1 or not 0 < confidence < 1:
83
+ raise ValueError("prevalence and confidence must be in (0,1)")
84
+ if min(positive_samples, negative_samples) <= 0 or min_tp < 1 or min_fp < 1:
85
+ raise ValueError("Sample sizes must be positive and min counts >=1")
86
+ if smooth_sigma < 0:
87
+ raise ValueError("smooth_sigma must be >= 0")
88
+ work = roc.copy()
89
+ fpr = (pd.to_numeric(work["fpr"], errors="coerce").to_numpy(dtype=float)
90
+ if "fpr" in work.columns else work.index.to_numpy(dtype=float))
91
+ tpr = pd.to_numeric(work["tpr"], errors="coerce").to_numpy(dtype=float)
92
+ tp = positive_samples * tpr
93
+ fp = negative_samples * fpr
94
+ with np.errstate(invalid="ignore", divide="ignore"):
95
+ ppv_raw = prevalence*tpr / (prevalence*tpr + (1-prevalence)*fpr)
96
+ y = (pd.to_numeric(work["ppv"], errors="coerce").to_numpy(dtype=float)
97
+ if "ppv" in work.columns else ppv_raw)
98
+ mask = (np.isfinite(y) & (y > 0) & (y < 1) & (tpr > 0) & (fpr > 0)
99
+ & (fpr <= 1) & (tpr <= 1) & (tp >= min_tp) & (fp >= min_fp))
100
+ se = np.full(y.shape, np.nan, dtype=float)
101
+ se[mask] = np.sqrt(np.maximum(
102
+ 1/tp[mask] - 1/positive_samples + 1/fp[mask] - 1/negative_samples, 0))
103
+ z = norm.ppf(0.5 + confidence/2)
104
+ lower = np.full(y.shape, np.nan)
105
+ upper = np.full(y.shape, np.nan)
106
+ lower[mask] = expit(logit(y[mask]) - z*se[mask])
107
+ upper[mask] = expit(logit(y[mask]) + z*se[mask])
108
+ work["ppv_logratio_lower"] = lower
109
+ work["ppv_logratio_upper"] = upper
110
+ work["ppv_logratio_se_logit"] = se
111
+ display_lo, display_hi = _smooth_log_widths(y, lower, upper, sigma=smooth_sigma, scale="logit")
112
+ work["ppv_logratio_lower_display"] = display_lo
113
+ work["ppv_logratio_upper_display"] = display_hi
114
+ work.attrs["ppv_logratio_ci"] = {
115
+ "confidence": confidence, "method": "fixed_threshold_log_delta_approximation",
116
+ "min_tp": min_tp, "min_fp": min_fp,
117
+ "display_sigma": smooth_sigma,
118
+ "warning": "Display smoothing and ROC isotonic correction can alter nominal coverage"
119
+ }
120
+ return work
121
+
122
+
123
+ def lr_logratio_ci(
124
+ curve: pd.DataFrame,
125
+ *,
126
+ confidence: float = 0.95,
127
+ min_count: int = 10,
128
+ smooth_sigma: float = 0.0,
129
+ ) -> pd.DataFrame:
130
+ """Approximate log-delta 95% LR+ and LR- CIs from observed cell counts.
131
+
132
+ SE(log LR+)² = 1/TP - 1/(TP+FN) + 1/FP - 1/(FP+TN)
133
+ SE(log LR-)² = 1/FN - 1/(TP+FN) + 1/TN - 1/(FP+TN)
134
+ with nonzero support. Raw and display-smoothed limits are separate.
135
+ """
136
+ if not 0 < confidence < 1 or min_count < 1 or smooth_sigma < 0:
137
+ raise ValueError("Invalid confidence, min_count or smooth_sigma")
138
+ out = curve.copy()
139
+ tp, fp, fn, tn = (pd.to_numeric(out[k], errors="coerce").to_numpy(dtype=float)
140
+ for k in ("TP", "FP", "FN", "TN"))
141
+ n_pos, n_neg = tp+fn, fp+tn
142
+ support = (np.isfinite(tp) & np.isfinite(fp) & np.isfinite(fn) & np.isfinite(tn)
143
+ & (np.minimum.reduce([tp,fp,fn,tn]) >= min_count)
144
+ & (n_pos > 0) & (n_neg > 0))
145
+ z = norm.ppf(0.5 + confidence/2)
146
+ for metric, a,b in (("LR+",tp,fp),("LR-",fn,tn)):
147
+ nominal = pd.to_numeric(out[metric], errors="coerce").to_numpy(dtype=float)
148
+ valid = support & np.isfinite(nominal) & (nominal > 0)
149
+ se = np.full(nominal.shape, np.nan)
150
+ se[valid] = np.sqrt(np.maximum(1/a[valid] - 1/n_pos[valid]
151
+ + 1/b[valid] - 1/n_neg[valid], 0))
152
+ lo, hi = np.full(nominal.shape, np.nan), np.full(nominal.shape, np.nan)
153
+ lo[valid] = nominal[valid] * np.exp(-z*se[valid])
154
+ hi[valid] = nominal[valid] * np.exp(z*se[valid])
155
+ out[f"{metric}_logratio_lower"] = lo
156
+ out[f"{metric}_logratio_upper"] = hi
157
+ out[f"{metric}_logratio_se_log"] = se
158
+ dlo, dhi = _smooth_log_widths(nominal, lo, hi, sigma=smooth_sigma, scale="log")
159
+ out[f"{metric}_logratio_lower_display"] = dlo
160
+ out[f"{metric}_logratio_upper_display"] = dhi
161
+ out.attrs["lr_logratio_ci"] = {
162
+ "confidence": confidence, "method": "fixed_threshold_log_delta_approximation",
163
+ "min_count": min_count, "display_sigma": smooth_sigma,
164
+ "warning": "Not simultaneous or post-frontier-selection adjusted; display smoothing not calibrated"
165
+ }
166
+ return out
@@ -0,0 +1,190 @@
1
+ """Pointwise likelihood-ratio confidence intervals from raw scores.
2
+
3
+ This module deliberately does not use interpolated ROC or getBounds() tables.
4
+ CIs are estimated at fixed score thresholds using case/control binomial
5
+ resampling; they do not account for frontier selection and are not simultaneous
6
+ confidence bands. Display smoothing is separated from raw inference.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import numpy as np
12
+ import pandas as pd
13
+
14
+
15
+ _METRICS = ("LR+", "LR-")
16
+ _CI_COLS = tuple(f"{m}_{lim}" for m in _METRICS for lim in ("lower", "upper"))
17
+
18
+
19
+ def _smooth_lr_widths(curve: pd.DataFrame, sigma: float) -> pd.DataFrame:
20
+ """Add *_lower_display/*_upper_display (not inferential CIs).
21
+
22
+ Smooth log-relative widths only within consecutive fully finite segments.
23
+ Never bridge invalid thresholds, extrapolate, or modify nominal values.
24
+ """
25
+ out = curve.copy()
26
+ if sigma <= 0:
27
+ for m in _METRICS:
28
+ for end in ("lower", "upper"):
29
+ out[f"{m}_{end}_display"] = out[f"{m}_{end}"]
30
+ return out
31
+
32
+ from scipy.ndimage import gaussian_filter1d
33
+
34
+ for metric in _METRICS:
35
+ nominal = out[metric].to_numpy(dtype=float)
36
+ lo = out[f"{metric}_lower"].to_numpy(dtype=float)
37
+ hi = out[f"{metric}_upper"].to_numpy(dtype=float)
38
+ lo_display = np.full(len(out), np.nan, dtype=float)
39
+ hi_display = np.full(len(out), np.nan, dtype=float)
40
+ valid = (np.isfinite(nominal) & (nominal > 0) &
41
+ np.isfinite(lo) & (lo > 0) &
42
+ np.isfinite(hi) & (hi > 0) &
43
+ (lo <= nominal) & (nominal <= hi))
44
+ indices = np.flatnonzero(valid)
45
+ if len(indices):
46
+ for segment in np.split(indices, np.flatnonzero(np.diff(indices) > 1) + 1):
47
+ low_gap = np.log(nominal[segment] / lo[segment])
48
+ high_gap = np.log(hi[segment] / nominal[segment])
49
+ if len(segment) >= 4:
50
+ low_gap = gaussian_filter1d(low_gap, sigma=sigma, mode="nearest")
51
+ high_gap = gaussian_filter1d(high_gap, sigma=sigma, mode="nearest")
52
+ lo_display[segment] = nominal[segment] * np.exp(-np.maximum(low_gap, 0))
53
+ hi_display[segment] = nominal[segment] * np.exp(np.maximum(high_gap, 0))
54
+ out[f"{metric}_lower_display"] = lo_display
55
+ out[f"{metric}_upper_display"] = hi_display
56
+ return out
57
+
58
+
59
+ def lr_curve_ci_from_scores(
60
+ data: pd.DataFrame,
61
+ score_col: str = "predicted_risk",
62
+ label_col: str = "target",
63
+ *,
64
+ min_count: int = 10,
65
+ fpr_min: float = 0.005,
66
+ fpr_max: float = 0.95,
67
+ min_lrplus: float = 1.001,
68
+ confidence: float = 0.95,
69
+ n_boot: int = 3000,
70
+ random_state: int = 1769,
71
+ chunk_size: int = 48,
72
+ smooth_sigma: float = 0.0,
73
+ ) -> pd.DataFrame:
74
+ """Return pointwise LR+/LR- bootstrap CIs along a non-dominated frontier.
75
+
76
+ TP* and FP* are independently drawn from Binomial(n_pos, empirical TPR)
77
+ and Binomial(n_neg, empirical FPR), respectively, AT EACH FIXED THRESHOLD;
78
+ the same simulated TP*/FP* are used for both likelihood ratios. Thus the
79
+ columns give marginal, pointwise CIs. They are not joint CIs, simultaneous
80
+ curve bands, or valid post-selection coverage guarantees for the frontier.
81
+
82
+ The returned nominal curve is sorted by LR- and restricted to non-dominated
83
+ operating points with all four confusion-matrix cells >= min_count.
84
+ Both raw *_lower/*_upper and optional smoothed *_display columns are
85
+ returned. Smoothing is cosmetic: only RAW columns are inferential outputs.
86
+
87
+ Score direction: a larger score denotes greater positive-class risk.
88
+ """
89
+ if not 0 < float(fpr_min) < float(fpr_max) < 1:
90
+ raise ValueError("Require 0 < fpr_min < fpr_max < 1")
91
+ if not 0 < float(confidence) < 1:
92
+ raise ValueError("confidence must be strictly between 0 and 1")
93
+ if min_count < 1 or n_boot < 100 or chunk_size < 1 or smooth_sigma < 0:
94
+ raise ValueError("min_count>=1, n_boot>=100, chunk_size>=1, smooth_sigma>=0 required")
95
+
96
+ labels = pd.to_numeric(data[label_col], errors="coerce").to_numpy(dtype=float)
97
+ scores = pd.to_numeric(data[score_col], errors="coerce").to_numpy(dtype=float)
98
+ ok = np.isfinite(labels) & np.isfinite(scores) & np.isin(labels, (0, 1))
99
+ labels, scores = labels[ok], scores[ok]
100
+ positive_scores = np.sort(scores[labels == 1])
101
+ negative_scores = np.sort(scores[labels == 0])
102
+ n_pos, n_neg = len(positive_scores), len(negative_scores)
103
+ if min(n_pos, n_neg) < 2 * min_count:
104
+ raise ValueError(f"Too few positives or negatives: n_pos={n_pos}, n_neg={n_neg}")
105
+
106
+ lo_fpr = max(float(fpr_min), 1 / n_neg)
107
+ if lo_fpr >= fpr_max:
108
+ raise ValueError("No FPR search range supports the specified sample size")
109
+ knee = min(max(0.10, lo_fpr), fpr_max)
110
+ levels = np.unique(np.r_[np.geomspace(lo_fpr, knee, 90),
111
+ np.linspace(knee, fpr_max, 170)])
112
+ idx = np.clip(np.floor((1 - levels) * (n_neg - 1)).astype(int), 0, n_neg - 1)
113
+ thresholds = np.unique(negative_scores[idx])[::-1]
114
+ tp = n_pos - np.searchsorted(positive_scores, thresholds, side="left")
115
+ fp = n_neg - np.searchsorted(negative_scores, thresholds, side="left")
116
+ fn, tn = n_pos - tp, n_neg - fp
117
+ tpr, fpr = tp / n_pos, fp / n_neg
118
+
119
+ valid = ((tp >= min_count) & (fp >= min_count) &
120
+ (fn >= min_count) & (tn >= min_count) &
121
+ (fpr >= fpr_min) & (fpr <= fpr_max))
122
+ with np.errstate(divide="ignore", invalid="ignore"):
123
+ lr_plus = tpr / fpr
124
+ lr_minus = (1 - tpr) / (1 - fpr)
125
+ valid &= (np.isfinite(lr_plus) & np.isfinite(lr_minus) &
126
+ (lr_plus > min_lrplus) & (lr_minus > 0) & (lr_minus < 1))
127
+ out = pd.DataFrame({
128
+ "LR-": lr_minus[valid], "LR+": lr_plus[valid],
129
+ "TP": tp[valid], "FP": fp[valid], "FN": fn[valid], "TN": tn[valid],
130
+ "TPR": tpr[valid], "FPR": fpr[valid], "threshold": thresholds[valid],
131
+ }).sort_values(["LR-", "LR+"], ascending=[True, False]).reset_index(drop=True)
132
+
133
+ if len(out):
134
+ values = out["LR+"].to_numpy(dtype=float)
135
+ prev = np.r_[-np.inf, np.maximum.accumulate(values)[:-1]]
136
+ out = out.loc[values > prev + 1e-10].reset_index(drop=True)
137
+
138
+ out["finite_bootstrap_fraction"] = np.nan
139
+ for column in _CI_COLS:
140
+ out[column] = np.nan
141
+
142
+ rng = np.random.default_rng(random_state)
143
+ qlow, qhigh = (1 - confidence) / 2, (1 + confidence) / 2
144
+ for start in range(0, len(out), chunk_size):
145
+ stop = min(start + chunk_size, len(out))
146
+ part = out.iloc[start:stop]
147
+ bs_tp = rng.binomial(n_pos, part["TPR"].to_numpy(), size=(n_boot, len(part)))
148
+ bs_fp = rng.binomial(n_neg, part["FPR"].to_numpy(), size=(n_boot, len(part)))
149
+ bs_fn, bs_tn = n_pos - bs_tp, n_neg - bs_fp
150
+ bootstrap_valid = (bs_tp > 0) & (bs_fp > 0) & (bs_fn > 0) & (bs_tn > 0)
151
+ finite_fraction = bootstrap_valid.mean(axis=0)
152
+ out.loc[start:stop-1, "finite_bootstrap_fraction"] = finite_fraction
153
+ columns_to_compute = np.flatnonzero(finite_fraction >= 0.98)
154
+ if not len(columns_to_compute):
155
+ continue
156
+ with np.errstate(divide="ignore", invalid="ignore"):
157
+ bs_plus = (bs_tp / n_pos) / (bs_fp / n_neg)
158
+ bs_minus = (bs_fn / n_pos) / (bs_tn / n_neg)
159
+ for metric, sample in (("LR+", bs_plus), ("LR-", bs_minus)):
160
+ s = sample[:, columns_to_compute].astype(float)
161
+ s[~bootstrap_valid[:, columns_to_compute]] = np.nan
162
+ lo, hi = np.nanquantile(s, [qlow, qhigh], axis=0)
163
+ index = np.arange(start, stop)[columns_to_compute]
164
+ nominal = out.loc[index, metric].to_numpy(dtype=float)
165
+ out.loc[index, f"{metric}_lower"] = np.minimum(lo, nominal)
166
+ out.loc[index, f"{metric}_upper"] = np.maximum(hi, nominal)
167
+
168
+ out = _smooth_lr_widths(out, sigma=float(smooth_sigma))
169
+ out.attrs.update(n_positive=n_pos, n_negative=n_neg,
170
+ confidence=confidence, method="pointwise_binomial_bootstrap",
171
+ frontier_selected=True, valid_bootstrap_min=0.98,
172
+ display_smoothed=bool(smooth_sigma > 0),
173
+ warning="Pointwise marginal CIs; no joint, simultaneous, or frontier post-selection coverage")
174
+ return out
175
+
176
+
177
+ def plot_lr_ci_fill(ax, curve: pd.DataFrame, *, color: str, alpha: float = 0.2,
178
+ use_display: bool = False, **kwargs):
179
+ """Draw an LR+ pointwise marginal CI fill against nominal LR- (no edges).
180
+
181
+ LR- uncertainty is available in the returned curve table but is not shown
182
+ in this vertical ribbon. This helper is visual, NOT a joint LR CI region.
183
+ """
184
+ suffix = "_display" if use_display else ""
185
+ x = curve["LR-"].to_numpy(dtype=float)
186
+ lo = curve[f"LR+_lower{suffix}"].to_numpy(dtype=float)
187
+ hi = curve[f"LR+_upper{suffix}"].to_numpy(dtype=float)
188
+ good = np.isfinite(x) & np.isfinite(lo) & np.isfinite(hi) & (lo <= hi)
189
+ return ax.fill_between(x, lo, hi, where=good, color=color,
190
+ alpha=alpha, linewidth=0, interpolate=False, **kwargs)
@@ -0,0 +1,185 @@
1
+ """Score-level precision--recall curves and approximate fixed-threshold CIs.
2
+
3
+ The PR curve is computed from actual score thresholds, never by regridding a
4
+ ROC in FPR or by inserting the (recall=0, precision=1) plotting convention.
5
+ Unsupported endpoints are omitted rather than imputed or extrapolated.
6
+
7
+ The pointwise intervals use a log-ratio delta approximation. Their coverage
8
+ is not simultaneous over the curve, does not account for threshold selection,
9
+ and the optional smoothed display boundaries are purely graphical.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import numpy as np
15
+ import pandas as pd
16
+ from scipy.ndimage import gaussian_filter1d
17
+ from scipy.special import expit, logit
18
+ from scipy.stats import norm
19
+
20
+
21
+ _COLUMNS = ("threshold", "tpr", "fpr", "ppv", "TP", "FP", "FN", "TN", "flags",
22
+ "ppv_lower", "ppv_upper", "ppv_se_logit",
23
+ "ppv_lower_display", "ppv_upper_display")
24
+
25
+
26
+ def precision_recall_ci_from_scores(
27
+ data,
28
+ *,
29
+ score_col="predicted_risk",
30
+ label_col="target",
31
+ prevalence=None,
32
+ prevalence_sample_size=None,
33
+ confidence=0.95,
34
+ min_tp=10,
35
+ min_fp=5,
36
+ min_flags=20,
37
+ max_points=1000,
38
+ display_sigma=0.0,
39
+ ):
40
+ """Return a supported PR curve with optional 95% pointwise PPV intervals.
41
+
42
+ Rates are computed from *observed score thresholds*. ``prevalence`` is
43
+ the target/deployment prevalence; when omitted, the sample prevalence is
44
+ used, and its binomial estimation variance is included by default.
45
+
46
+ When a target prevalence is supplied, it is treated as fixed unless
47
+ ``prevalence_sample_size`` is provided. If target prevalence was estimated
48
+ from an independent representative cohort, provide its sample size to
49
+ include approximate prevalence uncertainty. For weights, clustering, or
50
+ dependent data, these simple iid delta-method intervals are insufficient.
51
+
52
+ The nominal PR curve starts at the first real threshold meeting the
53
+ minimum cell-count support. It never inserts the (0,1) plot convention.
54
+ For identical recalls the last threshold (largest FPR) is retained.
55
+
56
+ The raw CI limits are in ``ppv_lower`` and ``ppv_upper``; smoothed limits
57
+ are separate, and are not guaranteed 95% intervals. The supplied target
58
+ prevalence is not modified by smoothing or regularization.
59
+ """
60
+ if not (0 < float(confidence) < 1):
61
+ raise ValueError("confidence must lie in (0,1)")
62
+ if min_tp < 1 or min_fp < 1 or min_flags < 1:
63
+ raise ValueError("min_tp, min_fp, min_flags must all be >= 1")
64
+ if max_points is not None and int(max_points) < 2:
65
+ raise ValueError("max_points must be >= 2 or None")
66
+ if float(display_sigma) < 0:
67
+ raise ValueError("display_sigma must be nonnegative")
68
+
69
+ labels = pd.to_numeric(data[label_col], errors="coerce").to_numpy(dtype=float)
70
+ scores = pd.to_numeric(data[score_col], errors="coerce").to_numpy(dtype=float)
71
+ good = np.isfinite(labels) & np.isfinite(scores) & np.isin(labels, [0, 1])
72
+ labels = labels[good].astype(np.int8)
73
+ scores = scores[good]
74
+ n = len(labels)
75
+ n_pos = int(labels.sum())
76
+ n_neg = int(n - n_pos)
77
+ if not n_pos or not n_neg:
78
+ raise ValueError("At least one valid positive and negative label is required")
79
+
80
+ if prevalence is None:
81
+ pi = n_pos / n
82
+ prevalence_n = n if prevalence_sample_size is None else int(prevalence_sample_size)
83
+ else:
84
+ pi = float(prevalence)
85
+ prevalence_n = None if prevalence_sample_size is None else int(prevalence_sample_size)
86
+ if not 0 < pi < 1:
87
+ raise ValueError("prevalence must be in (0,1)")
88
+ if prevalence_n is not None and prevalence_n <= 0:
89
+ raise ValueError("prevalence_sample_size must be positive")
90
+
91
+ # Sort once and form confusion-matrix counts only at real, unique scores.
92
+ order = np.argsort(-scores, kind="stable")
93
+ sorted_scores = scores[order]
94
+ sorted_labels = labels[order]
95
+ last = np.flatnonzero(np.r_[sorted_scores[:-1] != sorted_scores[1:], True])
96
+ tp = np.cumsum(sorted_labels, dtype=np.int64)[last]
97
+ fp = (last + 1) - tp
98
+ fn = n_pos - tp
99
+ tn = n_neg - fp
100
+ flags = last + 1
101
+ tpr = tp / n_pos
102
+ fpr = fp / n_neg
103
+ denom = pi * tpr + (1 - pi) * fpr
104
+ with np.errstate(divide="ignore", invalid="ignore"):
105
+ ppv = pi * tpr / denom
106
+
107
+ # Suppress low-count regimes, not merely x=0. Those operating points are
108
+ # precisely where the legacy ROC-grid origin produces spurious PRC spikes.
109
+ supported = ((tp >= min_tp) & (fp >= min_fp) & (flags >= min_flags) &
110
+ np.isfinite(ppv) & (denom > 0))
111
+ df = pd.DataFrame({
112
+ "threshold": sorted_scores[last][supported],
113
+ "tpr": tpr[supported], "fpr": fpr[supported], "ppv": ppv[supported],
114
+ "TP": tp[supported], "FP": fp[supported],
115
+ "FN": fn[supported], "TN": tn[supported], "flags": flags[supported],
116
+ })
117
+
118
+ # A PRC is a graph against recall: a unique recall coordinate gives a
119
+ # non-self-intersecting polyline. The last empirical threshold at that
120
+ # recall is retained, never an interpolated or fictitious threshold.
121
+ df = df.drop_duplicates(subset="tpr", keep="last").reset_index(drop=True)
122
+ if max_points is not None and len(df) > int(max_points):
123
+ r = df["tpr"].to_numpy(dtype=float)
124
+ wanted = np.linspace(r[0], r[-1], int(max_points))
125
+ keep = np.unique(np.r_[0, np.searchsorted(r, wanted, side="left"), len(r)-1])
126
+ df = df.iloc[keep].reset_index(drop=True)
127
+
128
+ tp_v = df["TP"].to_numpy(dtype=float)
129
+ fp_v = df["FP"].to_numpy(dtype=float)
130
+ center = df["ppv"].to_numpy(dtype=float)
131
+ # Standard fixed-threshold delta variance on logit(PPV) = logit(pi) + log(LR+).
132
+ if len(df):
133
+ variance = (1.0 / tp_v - 1.0 / n_pos +
134
+ 1.0 / fp_v - 1.0 / n_neg)
135
+ if prevalence_n is not None:
136
+ variance += 1.0 / (prevalence_n * pi * (1 - pi))
137
+ se = np.sqrt(np.maximum(variance, 0))
138
+ z = norm.ppf(0.5 + float(confidence) / 2.0)
139
+ # With min counts, 0<PPV<1. Do not infer limits at degenerate points.
140
+ lo = expit(logit(center) - z * se)
141
+ hi = expit(logit(center) + z * se)
142
+ if display_sigma > 0 and len(df) > 2:
143
+ # Display-only: smooth uncertainty widths, NEVER nominal precision.
144
+ dlo = np.maximum(logit(center) - logit(lo), 0.0)
145
+ dhi = np.maximum(logit(hi) - logit(center), 0.0)
146
+ dlo = gaussian_filter1d(dlo, float(display_sigma), mode="nearest")
147
+ dhi = gaussian_filter1d(dhi, float(display_sigma), mode="nearest")
148
+ lo_display = expit(logit(center) - dlo)
149
+ hi_display = expit(logit(center) + dhi)
150
+ else:
151
+ lo_display, hi_display = lo.copy(), hi.copy()
152
+ else:
153
+ se = lo = hi = lo_display = hi_display = np.empty(0, dtype=float)
154
+ df["ppv_lower"] = lo
155
+ df["ppv_upper"] = hi
156
+ df["ppv_se_logit"] = se
157
+ df["ppv_lower_display"] = lo_display
158
+ df["ppv_upper_display"] = hi_display
159
+ df = df.loc[:, _COLUMNS]
160
+ df.attrs["precision_recall_ci"] = {
161
+ "confidence": float(confidence),
162
+ "method": "score_level_fixed_threshold_log_ratio_delta",
163
+ "prevalence": pi,
164
+ "prevalence_sample_size": prevalence_n,
165
+ "sample_n_pos": n_pos,
166
+ "sample_n_neg": n_neg,
167
+ "min_tp": min_tp, "min_fp": min_fp, "min_flags": min_flags,
168
+ "display_sigma": display_sigma,
169
+ "warning": "Pointwise approximate intervals only; not simultaneous or threshold-selection adjusted."
170
+ }
171
+ return df
172
+
173
+
174
+ def plot_precision_recall_ci_fill(ax, curve, *, color=None, alpha=0.2,
175
+ use_display=False, zorder=1, **kwargs):
176
+ """Plot only finite score-level CI support; never invent endpoint values."""
177
+ suffix = "_display" if use_display else ""
178
+ x = curve["tpr"].to_numpy(dtype=float)
179
+ y = curve["ppv"].to_numpy(dtype=float)
180
+ lo = curve[f"ppv_lower{suffix}"].to_numpy(dtype=float)
181
+ hi = curve[f"ppv_upper{suffix}"].to_numpy(dtype=float)
182
+ ok = (np.isfinite(x) & np.isfinite(y) & np.isfinite(lo) & np.isfinite(hi)
183
+ & (lo <= y) & (y <= hi))
184
+ return ax.fill_between(x, lo, hi, where=ok, color=color, alpha=alpha,
185
+ linewidth=0, interpolate=False, zorder=zorder, **kwargs)
@@ -1918,3 +1918,14 @@ try:
1918
1918
  __all__.append('lr_nondominated_frontier')
1919
1919
  except Exception:
1920
1920
  pass
1921
+
1922
+ # Raw-score-based, pointwise LR confidence intervals; separate from the
1923
+ # historical analytic getBounds() API and its ROC interpolation.
1924
+ from .lr_ci import lr_curve_ci_from_scores, plot_lr_ci_fill
1925
+ try:
1926
+ __all__.extend(('lr_curve_ci_from_scores', 'plot_lr_ci_fill'))
1927
+ except Exception:
1928
+ pass
1929
+
1930
+ # Score-based CI extensions (pointwise; display smoothing is non-inferential)
1931
+ from .pr_ci import precision_recall_ci_from_scores, plot_precision_recall_ci_fill
@@ -1,9 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: zedstat
3
- Version: 0.0.153
3
+ Version: 0.2.0
4
4
  Summary: Statistics tools for ML models and deployment
5
5
  Home-page: https://github.com/zeroknowledgediscovery/zedstat
6
- Download-URL: https://github.com/zeroknowledgediscovery/zedstat/archive/0.0.153.tar.gz
6
+ Download-URL: https://github.com/zeroknowledgediscovery/zedstat/archive/0.2.0.tar.gz
7
7
  Author: zed.uchicago.edu
8
8
  Author-email: ishanu@uchicago.edu
9
9
  License: LICENSE
@@ -3,11 +3,18 @@ MANIFEST.in
3
3
  README.rst
4
4
  setup.py
5
5
  version.py
6
+ tests/test_ci_logratio.py
7
+ tests/test_getbounds_current_geometry.py
8
+ tests/test_getbounds_preserves_nominal.py
9
+ tests/test_lr_ci.py
6
10
  zedstat/__init__.py
7
11
  zedstat/calibration.py
8
12
  zedstat/ci.py
13
+ zedstat/ci_logratio.py
9
14
  zedstat/example_combine.py
15
+ zedstat/lr_ci.py
10
16
  zedstat/mwauc.py
17
+ zedstat/pr_ci.py
11
18
  zedstat/rrci.py
12
19
  zedstat/textable.py
13
20
  zedstat/zcombine.py
@@ -1 +0,0 @@
1
- __version__ = '0.0.153'
@@ -1,18 +0,0 @@
1
- """
2
- # zedstat
3
-
4
- Description:
5
- Utilities for uncertainty quantification and deployment of ML models
6
-
7
- ---
8
-
9
- Installation:
10
- ```
11
- pip install zedstat
12
- ```
13
- Notes:
14
- Useful for calculation of likelihood ratios, confidence intervals on AUC,
15
- simple performance interpretation (list to be expanded)
16
-
17
-
18
- """
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes