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.
- {zedstat-0.0.153/zedstat.egg-info → zedstat-0.2.0}/PKG-INFO +2 -2
- zedstat-0.2.0/tests/test_ci_logratio.py +39 -0
- zedstat-0.2.0/tests/test_getbounds_current_geometry.py +83 -0
- zedstat-0.2.0/tests/test_getbounds_preserves_nominal.py +55 -0
- zedstat-0.2.0/tests/test_lr_ci.py +54 -0
- zedstat-0.2.0/version.py +1 -0
- zedstat-0.2.0/zedstat/__init__.py +230 -0
- zedstat-0.2.0/zedstat/ci_logratio.py +166 -0
- zedstat-0.2.0/zedstat/lr_ci.py +190 -0
- zedstat-0.2.0/zedstat/pr_ci.py +185 -0
- {zedstat-0.0.153 → zedstat-0.2.0}/zedstat/zedstat.py +11 -0
- {zedstat-0.0.153 → zedstat-0.2.0/zedstat.egg-info}/PKG-INFO +2 -2
- {zedstat-0.0.153 → zedstat-0.2.0}/zedstat.egg-info/SOURCES.txt +7 -0
- zedstat-0.0.153/version.py +0 -1
- zedstat-0.0.153/zedstat/__init__.py +0 -18
- {zedstat-0.0.153 → zedstat-0.2.0}/LICENSE +0 -0
- {zedstat-0.0.153 → zedstat-0.2.0}/MANIFEST.in +0 -0
- {zedstat-0.0.153 → zedstat-0.2.0}/README.rst +0 -0
- {zedstat-0.0.153 → zedstat-0.2.0}/setup.cfg +0 -0
- {zedstat-0.0.153 → zedstat-0.2.0}/setup.py +0 -0
- {zedstat-0.0.153 → zedstat-0.2.0}/zedstat/.ipynb_checkpoints/Untitled-Copy1-checkpoint.ipynb +0 -0
- {zedstat-0.0.153 → zedstat-0.2.0}/zedstat/.ipynb_checkpoints/Untitled-checkpoint.ipynb +0 -0
- {zedstat-0.0.153 → zedstat-0.2.0}/zedstat/.ipynb_checkpoints/example3-checkpoint.ipynb +0 -0
- {zedstat-0.0.153 → zedstat-0.2.0}/zedstat/calibration.py +0 -0
- {zedstat-0.0.153 → zedstat-0.2.0}/zedstat/ci.py +0 -0
- {zedstat-0.0.153 → zedstat-0.2.0}/zedstat/example_combine.py +0 -0
- {zedstat-0.0.153 → zedstat-0.2.0}/zedstat/mwauc.py +0 -0
- {zedstat-0.0.153 → zedstat-0.2.0}/zedstat/rrci.py +0 -0
- {zedstat-0.0.153 → zedstat-0.2.0}/zedstat/textable.py +0 -0
- {zedstat-0.0.153 → zedstat-0.2.0}/zedstat/zcombine.py +0 -0
- {zedstat-0.0.153 → zedstat-0.2.0}/zedstat.egg-info/dependency_links.txt +0 -0
- {zedstat-0.0.153 → zedstat-0.2.0}/zedstat.egg-info/requires.txt +0 -0
- {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
|
|
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.
|
|
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()
|
zedstat-0.2.0/version.py
ADDED
|
@@ -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
|
|
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.
|
|
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
|
zedstat-0.0.153/version.py
DELETED
|
@@ -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
|
{zedstat-0.0.153 → zedstat-0.2.0}/zedstat/.ipynb_checkpoints/Untitled-Copy1-checkpoint.ipynb
RENAMED
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|