refitgap 0.1.0__py3-none-any.whl
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.
- refitgap/API.txt +150 -0
- refitgap/__init__.py +29 -0
- refitgap/__main__.py +6 -0
- refitgap/analysis.py +399 -0
- refitgap/cli.py +173 -0
- refitgap/crossfit.py +275 -0
- refitgap/example.py +96 -0
- refitgap/fitters.py +114 -0
- refitgap/identification.py +101 -0
- refitgap/inference.py +319 -0
- refitgap/plots.py +214 -0
- refitgap/records.py +38 -0
- refitgap/report.py +136 -0
- refitgap/runs.py +53 -0
- refitgap/tiny.py +68 -0
- refitgap/twoscorer.py +504 -0
- refitgap-0.1.0.dist-info/METADATA +128 -0
- refitgap-0.1.0.dist-info/RECORD +22 -0
- refitgap-0.1.0.dist-info/WHEEL +5 -0
- refitgap-0.1.0.dist-info/entry_points.txt +2 -0
- refitgap-0.1.0.dist-info/licenses/LICENSE +21 -0
- refitgap-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""The limits of rank summaries and archived counts, in exact fractions.
|
|
2
|
+
|
|
3
|
+
identification_example(): fourteen records, three scorers with equal AUROC (6/11) and average
|
|
4
|
+
precision (5/18), equal deficits (1/26), one all mismatch and one all attainable gap.
|
|
5
|
+
f1_bounds_from_counts(): the range of best single-threshold F1 compatible with one confusion
|
|
6
|
+
table. Both are in-sample statements, with no cross-fitting and no sampling error."""
|
|
7
|
+
from fractions import Fraction
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
# ------------------------------------------------------------------ rank arithmetic ---
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def f1_top(pos_ranks, k):
|
|
15
|
+
"""F1 with the top k ranks predicted positive; 0 when k and the positives are both 0."""
|
|
16
|
+
tp = sum(1 for r in pos_ranks if r <= k)
|
|
17
|
+
den = k + len(pos_ranks)
|
|
18
|
+
return Fraction(2 * tp, den) if den else Fraction(0)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def best_f1(pos_ranks, n):
|
|
22
|
+
"""The best F1 over every single threshold (every top-k, k = 0..n) on these records."""
|
|
23
|
+
return max(f1_top(pos_ranks, k) for k in range(n + 1))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def auroc(pos_ranks, n):
|
|
27
|
+
"""AUROC (no ties; rank 1 is the top)."""
|
|
28
|
+
P = sorted(pos_ranks)
|
|
29
|
+
neg = n - len(P)
|
|
30
|
+
above = sum(sum(1 for q in range(r + 1, n + 1) if q not in P) for r in P)
|
|
31
|
+
return Fraction(above, len(P) * neg)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def average_precision(pos_ranks):
|
|
35
|
+
"""Average precision (no ties)."""
|
|
36
|
+
P = sorted(pos_ranks)
|
|
37
|
+
return sum(Fraction(i + 1, r) for i, r in enumerate(P)) / len(P)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def rank_scores(n, selected, threshold=0.5):
|
|
41
|
+
"""Scores 1..n ranks apart such that exactly the top `selected` ranks reach `threshold`
|
|
42
|
+
(rank r gets threshold + (selected + 1/2 - r) / (2(n + 1)); all in (0, 1) for threshold 1/2)."""
|
|
43
|
+
r = np.arange(1, n + 1, dtype=np.float64)
|
|
44
|
+
return threshold + (selected + 0.5 - r) / (2.0 * (n + 1))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ------------------------------------------------------------------ the example ---
|
|
48
|
+
N_RECORDS = 14
|
|
49
|
+
SCORERS = { # positive ranks (1 = top), records the carried rule selects
|
|
50
|
+
"reference": dict(positives=(4, 8, 9), selected=9),
|
|
51
|
+
"final A": dict(positives=(4, 8, 9), selected=10), # same ranking, rule now takes one more record
|
|
52
|
+
"final B": dict(positives=(5, 6, 10), selected=10), # different ranking
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def identification_example():
|
|
57
|
+
"""() -> dict of exact Fractions: per scorer the carried and best F1, AUROC and average
|
|
58
|
+
precision; per final scorer the deficit, attainable gap and change in mismatch; plus float
|
|
59
|
+
scores and a threshold of 1/2 that realise the example."""
|
|
60
|
+
out = dict(n_records=N_RECORDS, n_labels=1, threshold=0.5, scorers={}, decomposition={},
|
|
61
|
+
note=("in-sample: the best F1 is fitted and scored on the same fourteen records; no cross-fitting, "
|
|
62
|
+
"no sampling uncertainty"))
|
|
63
|
+
for name, s in SCORERS.items():
|
|
64
|
+
P = s["positives"]
|
|
65
|
+
y = np.zeros(N_RECORDS, int)
|
|
66
|
+
y[[r - 1 for r in P]] = 1
|
|
67
|
+
out["scorers"][name] = dict(positives=P, selected=s["selected"], carried_f1=f1_top(P, s["selected"]),
|
|
68
|
+
best_f1=best_f1(P, N_RECORDS), auroc=auroc(P, N_RECORDS),
|
|
69
|
+
average_precision=average_precision(P),
|
|
70
|
+
scores=rank_scores(N_RECORDS, s["selected"]), labels=y)
|
|
71
|
+
ref = out["scorers"]["reference"]
|
|
72
|
+
for name in ("final A", "final B"):
|
|
73
|
+
f = out["scorers"][name]
|
|
74
|
+
D = ref["carried_f1"] - f["carried_f1"]
|
|
75
|
+
A = ref["best_f1"] - f["best_f1"]
|
|
76
|
+
out["decomposition"][name] = dict(deficit=D, attainable_gap=A, mismatch_change=D - A)
|
|
77
|
+
return out
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ------------------------------------------------------------------ counts ---
|
|
81
|
+
def _f1(tp, pred, pos):
|
|
82
|
+
den = pred + pos
|
|
83
|
+
return Fraction(2 * tp, den) if den else Fraction(0)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def f1_bounds_from_counts(tp, fp, fn, tn, exact=False):
|
|
87
|
+
"""(tp, fp, fn, tn) at one threshold -> (lower, upper) sharp bounds on the best F1 over all
|
|
88
|
+
thresholds and all rankings compatible with the table (Fractions with exact=True):
|
|
89
|
+
lower = max(2tp / (2tp + fp + fn), 2P / (2P + N)), upper = max(2tp / (2tp + fn), 2P / (2P + fp))
|
|
90
|
+
with P = tp + fn, N = fp + tn. A bound on what the counts identify, not a confidence interval."""
|
|
91
|
+
vals = [int(v) for v in (tp, fp, fn, tn)]
|
|
92
|
+
if any(v < 0 for v in vals) or any(int(v) != v for v in (tp, fp, fn, tn)):
|
|
93
|
+
raise ValueError("counts must be non-negative integers")
|
|
94
|
+
tp, fp, fn, tn = vals
|
|
95
|
+
P, N = tp + fn, fp + tn
|
|
96
|
+
if P == 0:
|
|
97
|
+
lo = hi = Fraction(0)
|
|
98
|
+
else:
|
|
99
|
+
lo = max(_f1(tp, tp + fp, P), _f1(P, P + N, P))
|
|
100
|
+
hi = max(_f1(tp, tp, P), _f1(P, P + fp, P))
|
|
101
|
+
return (lo, hi) if exact else (float(lo), float(hi))
|
refitgap/inference.py
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
"""Seed-paired contrasts: t intervals, TOST, sign-flip tests, Holm families and outcome classes.
|
|
2
|
+
|
|
3
|
+
A contrast is a per-seed vector x (method minus comparator). family() Holm-adjusts the t and
|
|
4
|
+
TOST p values across its members and assigns, at `margin` (default MARGIN = 0.005):
|
|
5
|
+
equivalent Holm TOST p < ALPHA
|
|
6
|
+
material Holm t p < ALPHA and |mean| >= margin
|
|
7
|
+
small non-zero Holm t p < ALPHA and |mean| < margin
|
|
8
|
+
unresolved otherwise, or when the sign-flip p >= ALPHA overrules the two above
|
|
9
|
+
Intervals are marginal (unadjusted). Sign-flip tests are exact up to SIGNFLIP_MAX_N seeds and
|
|
10
|
+
Monte Carlo above (the result then says so). Non-finite values raise ValueError.
|
|
11
|
+
"""
|
|
12
|
+
import inspect
|
|
13
|
+
import itertools
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
from scipy import stats
|
|
17
|
+
|
|
18
|
+
MARGIN, ALPHA = 0.005, 0.05
|
|
19
|
+
BCA_N, BCA_SEED = 10_000, 20261029
|
|
20
|
+
SIGNFLIP_MAX_N = 20 # exact enumeration up to here (2^20 patterns)
|
|
21
|
+
SIGNFLIP_MC_DRAWS = 100_000
|
|
22
|
+
SIGNFLIP_MC_SEED = 20261114
|
|
23
|
+
_SIGNFLIP_MC_CHUNK = 10_000
|
|
24
|
+
|
|
25
|
+
# scipy renamed bootstrap's random_state= to rng=
|
|
26
|
+
try:
|
|
27
|
+
_BOOT_RNG_KW = "rng" if "rng" in inspect.signature(stats.bootstrap).parameters else "random_state"
|
|
28
|
+
except (TypeError, ValueError):
|
|
29
|
+
_BOOT_RNG_KW = "random_state"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ------------------------------------------------------------------ sign-flip tests ---
|
|
33
|
+
def _signflip_p_enumerated(x):
|
|
34
|
+
x = np.asarray(x, float)
|
|
35
|
+
n, obs = len(x), abs(x.mean())
|
|
36
|
+
signs = np.array(list(itertools.product([-1.0, 1.0], repeat=n)))
|
|
37
|
+
means = np.abs((signs * x).mean(axis=1))
|
|
38
|
+
return float(np.mean(means >= obs - 1e-12))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _ni_signflip_p_enumerated(x, margin=MARGIN):
|
|
42
|
+
y = np.asarray(x, float) + margin
|
|
43
|
+
obs = y.mean()
|
|
44
|
+
if np.allclose(y, 0):
|
|
45
|
+
return 1.0
|
|
46
|
+
signs = np.array(list(itertools.product([-1.0, 1.0], repeat=len(y))))
|
|
47
|
+
return float(np.mean((signs * np.abs(y)).mean(1) >= obs - 1e-12)) if obs > 0 else 1.0
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def signflip_method(n):
|
|
51
|
+
"""n seeds -> how the sign-flip p is computed, as text."""
|
|
52
|
+
if n <= SIGNFLIP_MAX_N:
|
|
53
|
+
return f"exact (all 2^{n} sign patterns)"
|
|
54
|
+
return (f"Monte Carlo ({SIGNFLIP_MC_DRAWS} random sign patterns from numpy default_rng({SIGNFLIP_MC_SEED}); "
|
|
55
|
+
f"p = (1 + count) / (1 + {SIGNFLIP_MC_DRAWS}), the observed pattern counted once)")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _mc_sign_chunks(n):
|
|
59
|
+
rng = np.random.default_rng(SIGNFLIP_MC_SEED)
|
|
60
|
+
left = SIGNFLIP_MC_DRAWS
|
|
61
|
+
while left > 0:
|
|
62
|
+
b = min(_SIGNFLIP_MC_CHUNK, left)
|
|
63
|
+
yield rng.integers(0, 2, size=(b, n)).astype(float) * 2.0 - 1.0
|
|
64
|
+
left -= b
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def signflip_p(x):
|
|
68
|
+
"""Per-seed vector -> two-sided sign-flip p of the mean."""
|
|
69
|
+
x = np.asarray(x, float)
|
|
70
|
+
n = len(x)
|
|
71
|
+
if n <= SIGNFLIP_MAX_N:
|
|
72
|
+
return _signflip_p_enumerated(x)
|
|
73
|
+
obs = abs(x.mean())
|
|
74
|
+
count = sum(int(np.sum(np.abs((s * x).mean(axis=1)) >= obs - 1e-12)) for s in _mc_sign_chunks(n))
|
|
75
|
+
return float((1 + count) / (1 + SIGNFLIP_MC_DRAWS))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def ni_signflip_p(x, margin=MARGIN):
|
|
79
|
+
"""Per-seed vector -> one-sided sign-flip p of H0 'symmetric about -margin'."""
|
|
80
|
+
x = np.asarray(x, float)
|
|
81
|
+
n = len(x)
|
|
82
|
+
if n <= SIGNFLIP_MAX_N:
|
|
83
|
+
return _ni_signflip_p_enumerated(x, margin)
|
|
84
|
+
y = x + margin
|
|
85
|
+
obs = y.mean()
|
|
86
|
+
if np.allclose(y, 0) or not obs > 0:
|
|
87
|
+
return 1.0
|
|
88
|
+
ay = np.abs(y)
|
|
89
|
+
count = sum(int(np.sum((s * ay).mean(1) >= obs - 1e-12)) for s in _mc_sign_chunks(n))
|
|
90
|
+
return float((1 + count) / (1 + SIGNFLIP_MC_DRAWS))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def ni_p(x, margin=MARGIN):
|
|
94
|
+
"""Per-seed vector -> one-sided paired t p for non-inferiority (H0: mean <= -margin)."""
|
|
95
|
+
x = np.asarray(x, float); n = len(x); m = float(x.mean())
|
|
96
|
+
se = float(x.std(ddof=1) / np.sqrt(n)) if n > 1 else 0.0
|
|
97
|
+
if se == 0:
|
|
98
|
+
return 0.0 if m > -margin else 1.0
|
|
99
|
+
return float(1 - stats.t.cdf((m + margin) / se, n - 1))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# ------------------------------------------------------------------ input checks ---
|
|
103
|
+
def _check_finite(x, what):
|
|
104
|
+
x = np.asarray(x, float)
|
|
105
|
+
if x.ndim != 1:
|
|
106
|
+
raise ValueError(f"{what}: a per-seed vector (1-d) is expected, got shape {x.shape}")
|
|
107
|
+
bad = np.flatnonzero(~np.isfinite(x))
|
|
108
|
+
if bad.size:
|
|
109
|
+
raise ValueError(
|
|
110
|
+
f"{what}: non-finite value {float(x[bad[0]])!r} at position {int(bad[0])} ({bad.size} non-finite in "
|
|
111
|
+
"all); a failed run must be dropped (with the seed set adjusted openly) or re-run, not "
|
|
112
|
+
"stored as NaN or inf"
|
|
113
|
+
)
|
|
114
|
+
return x
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _check_margin(margin):
|
|
118
|
+
m = float(margin)
|
|
119
|
+
if not np.isfinite(m) or m < 0:
|
|
120
|
+
raise ValueError(f"margin must be finite and non-negative, got {margin!r}")
|
|
121
|
+
return m
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ------------------------------------------------------------------ one contrast ---
|
|
125
|
+
def bca_ci(x):
|
|
126
|
+
"""Per-seed vector -> BCa 95 % interval of the mean, or None (fewer than 3 values, constant,
|
|
127
|
+
or BCa undefined). Sensitivity only; it never decides a class."""
|
|
128
|
+
x = np.asarray(x, float)
|
|
129
|
+
if len(x) < 3 or np.ptp(x) == 0:
|
|
130
|
+
return None
|
|
131
|
+
try:
|
|
132
|
+
r = stats.bootstrap((x,), np.mean, method="BCa", n_resamples=BCA_N,
|
|
133
|
+
**{_BOOT_RNG_KW: np.random.default_rng(BCA_SEED)})
|
|
134
|
+
except (ValueError, ArithmeticError):
|
|
135
|
+
return None
|
|
136
|
+
return [float(r.confidence_interval.low), float(r.confidence_interval.high)]
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def paired_stats(x, margin=MARGIN, name="x"):
|
|
140
|
+
"""Per-seed vector -> dict: mean, sd, se, 95 % and 90 % t intervals, t p, TOST p at +-margin,
|
|
141
|
+
sign-flip p, BCa interval. All values equal: p_t is 1 for mean 0 and 0 otherwise."""
|
|
142
|
+
x = _check_finite(x, name)
|
|
143
|
+
margin = _check_margin(margin)
|
|
144
|
+
n = len(x)
|
|
145
|
+
m = float(x.mean())
|
|
146
|
+
if n < 2:
|
|
147
|
+
return dict(n=n, mean=m)
|
|
148
|
+
se = float(x.std(ddof=1) / np.sqrt(n))
|
|
149
|
+
t95 = stats.t.ppf(0.975, n - 1) * se
|
|
150
|
+
t90 = stats.t.ppf(0.95, n - 1) * se
|
|
151
|
+
p = float(stats.ttest_1samp(x, 0.0).pvalue) if se > 0 else 0.0
|
|
152
|
+
if se > 0:
|
|
153
|
+
p_lo = float(1 - stats.t.cdf((m + margin) / se, n - 1))
|
|
154
|
+
p_hi = float(stats.t.cdf((m - margin) / se, n - 1))
|
|
155
|
+
else:
|
|
156
|
+
p_lo = p_hi = 0.0 if abs(m) < margin else 1.0
|
|
157
|
+
p_tost = max(p_lo, p_hi)
|
|
158
|
+
st = dict(n=n, mean=m, se=se, sd=float(x.std(ddof=1)), ci95=[m - t95, m + t95], ci90=[m - t90, m + t90],
|
|
159
|
+
p_t=p, p_tost=p_tost, p_lo=p_lo, p_hi=p_hi, tost_pass=bool(p_tost < ALPHA),
|
|
160
|
+
p_signflip=signflip_p(x), values=x.tolist(), bca95=bca_ci(x))
|
|
161
|
+
if se == 0 and m == 0:
|
|
162
|
+
st["p_t"] = 1.0
|
|
163
|
+
if n > SIGNFLIP_MAX_N:
|
|
164
|
+
st["p_signflip_method"] = signflip_method(n)
|
|
165
|
+
return st
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _holm_core(ps):
|
|
169
|
+
m = len(ps)
|
|
170
|
+
order = np.argsort(ps)
|
|
171
|
+
adj = np.empty(m)
|
|
172
|
+
running = 0.0
|
|
173
|
+
for rank, i in enumerate(order):
|
|
174
|
+
running = max(running, (m - rank) * ps[i])
|
|
175
|
+
adj[i] = min(1.0, running)
|
|
176
|
+
return adj.tolist()
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def holm(ps):
|
|
180
|
+
"""p values in [0, 1] -> Holm step-down adjusted p values (a NaN raises)."""
|
|
181
|
+
ps = np.asarray(ps, float)
|
|
182
|
+
if ps.ndim != 1:
|
|
183
|
+
raise ValueError(f"holm: a 1-d sequence of p values is expected, got shape {ps.shape}")
|
|
184
|
+
bad = np.flatnonzero(~np.isfinite(ps) | (ps < 0) | (ps > 1))
|
|
185
|
+
if bad.size:
|
|
186
|
+
raise ValueError(f"holm: p value {float(ps[bad[0]])!r} at position {int(bad[0])} is not a number in [0, 1]")
|
|
187
|
+
return _holm_core(ps)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def classify(st, margin=MARGIN):
|
|
191
|
+
"""paired_stats dict with p_holm, p_tost_holm -> class (before the sign-flip override)."""
|
|
192
|
+
if st.get("n", 0) < 2:
|
|
193
|
+
return "unresolved"
|
|
194
|
+
if st["p_tost_holm"] < ALPHA:
|
|
195
|
+
return "equivalent"
|
|
196
|
+
if st["p_holm"] < ALPHA:
|
|
197
|
+
return "material" if abs(st["mean"]) >= margin else "small non-zero"
|
|
198
|
+
return "unresolved"
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def signflip_override(st, margin=MARGIN):
|
|
202
|
+
"""Sets st['class']: classify(), turned unresolved when the sign-flip test disagrees."""
|
|
203
|
+
cls = classify(st, margin)
|
|
204
|
+
st["class_before_signflip"] = cls
|
|
205
|
+
st["signflip_disagrees"] = False
|
|
206
|
+
if cls in ("material", "small non-zero") and st["p_signflip"] >= ALPHA:
|
|
207
|
+
st["signflip_disagrees"] = True
|
|
208
|
+
cls = "unresolved"
|
|
209
|
+
st["class"] = cls
|
|
210
|
+
return st
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def classify_unadjusted(st, margin=MARGIN):
|
|
214
|
+
"""paired_stats dict -> the same with its class as a family of one (no Holm)."""
|
|
215
|
+
if st.get("n", 0) < 2:
|
|
216
|
+
st["class"] = "unresolved"
|
|
217
|
+
return st
|
|
218
|
+
st["p_holm"], st["p_tost_holm"] = st["p_t"], st["p_tost"]
|
|
219
|
+
st["holm_family_size"] = 1
|
|
220
|
+
return signflip_override(st, margin)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
# ------------------------------------------------------------------ families ---
|
|
224
|
+
def family(members, margin=MARGIN):
|
|
225
|
+
"""{name: per-seed vector} -> {name: paired_stats dict with Holm p values and class}."""
|
|
226
|
+
margin = _check_margin(margin)
|
|
227
|
+
names = list(members)
|
|
228
|
+
sts = {k: paired_stats(members[k], margin, name=f"family member {k!r}") for k in names}
|
|
229
|
+
pt = holm([sts[k]["p_t"] for k in names])
|
|
230
|
+
pq = holm([sts[k]["p_tost"] for k in names])
|
|
231
|
+
for k, a, b in zip(names, pt, pq):
|
|
232
|
+
sts[k]["p_holm"], sts[k]["p_tost_holm"], sts[k]["holm_family_size"] = a, b, len(names)
|
|
233
|
+
signflip_override(sts[k], margin)
|
|
234
|
+
return sts
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def family_at(members, margin):
|
|
238
|
+
"""Same as family(members, margin)."""
|
|
239
|
+
return family(members, margin)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def family_ni(members, margin=MARGIN):
|
|
243
|
+
"""family() plus, per member, non-inferiority at -margin ('ni': Holm one-sided t and
|
|
244
|
+
sign-flip both < ALPHA) and the mirror inferiority test ('inf': below -margin)."""
|
|
245
|
+
margin = _check_margin(margin)
|
|
246
|
+
names = list(members)
|
|
247
|
+
vals = {k: _check_finite(members[k], f"family member {k!r}") for k in names}
|
|
248
|
+
sts = family(vals, margin)
|
|
249
|
+
p_ni = [ni_p(vals[k], margin) for k in names]
|
|
250
|
+
for k, p, q in zip(names, p_ni, holm(p_ni)):
|
|
251
|
+
s = sts[k]
|
|
252
|
+
s["p_ni"], s["p_ni_holm"] = p, q
|
|
253
|
+
s["p_ni_signflip"] = ni_signflip_p(vals[k], margin)
|
|
254
|
+
s["ni_signflip_disagrees"] = bool(q < ALPHA and s["p_ni_signflip"] >= ALPHA)
|
|
255
|
+
s["ni"] = bool(q < ALPHA and not s["ni_signflip_disagrees"])
|
|
256
|
+
s["margin"] = margin
|
|
257
|
+
p_inf = [ni_p(-vals[k], -margin) for k in names]
|
|
258
|
+
for k, p, q in zip(names, p_inf, holm(p_inf)):
|
|
259
|
+
s = sts[k]
|
|
260
|
+
s["p_inf"], s["p_inf_holm"] = p, q
|
|
261
|
+
s["p_inf_signflip"] = ni_signflip_p(-vals[k], -margin)
|
|
262
|
+
s["inf"] = bool(q < ALPHA and s["p_inf_signflip"] < ALPHA)
|
|
263
|
+
if len(vals[k]) > SIGNFLIP_MAX_N:
|
|
264
|
+
s["p_ni_signflip_method"] = s["p_inf_signflip_method"] = signflip_method(len(vals[k]))
|
|
265
|
+
return sts
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def tost_power(sd, n=10, alphas=(0.0125, 0.05), reps=20_000, seed=20261029, margin=MARGIN):
|
|
269
|
+
"""sd of a paired contrast -> {alpha: TOST power at a true difference of 0} (normal draws;
|
|
270
|
+
optimistic when sd comes from the same pairs)."""
|
|
271
|
+
if not sd or not np.isfinite(sd):
|
|
272
|
+
return {a: None for a in alphas}
|
|
273
|
+
x = np.random.default_rng(seed).normal(0.0, sd, (reps, n))
|
|
274
|
+
m, se = x.mean(1), x.std(1, ddof=1) / np.sqrt(n)
|
|
275
|
+
p = np.maximum(1 - stats.t.cdf((m + margin) / se, n - 1), stats.t.cdf((m - margin) / se, n - 1))
|
|
276
|
+
return {a: float(np.mean(p < a)) for a in alphas}
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
# ------------------------------------------------------------------ dicts by seed ---
|
|
280
|
+
def paired_vector(a, b, seeds=None, strict=True):
|
|
281
|
+
"""{seed: value} twice -> (seeds, a - b in sorted seed order). Different seed sets raise
|
|
282
|
+
unless seeds= names the planned ones or strict=False takes the common ones; NaN raises."""
|
|
283
|
+
if seeds is not None:
|
|
284
|
+
seeds = sorted(int(s) for s in seeds)
|
|
285
|
+
miss = {"a": [s for s in seeds if s not in a], "b": [s for s in seeds if s not in b]}
|
|
286
|
+
if miss["a"] or miss["b"]:
|
|
287
|
+
raise ValueError(f"planned seeds missing: {miss}")
|
|
288
|
+
elif set(a) != set(b):
|
|
289
|
+
if strict:
|
|
290
|
+
raise ValueError(f"the two sides hold different seeds (only in a: {sorted(set(a) - set(b))}, "
|
|
291
|
+
f"only in b: {sorted(set(b) - set(a))}); pass seeds= or strict=False")
|
|
292
|
+
seeds = sorted(set(a) & set(b))
|
|
293
|
+
else:
|
|
294
|
+
seeds = sorted(a)
|
|
295
|
+
if not seeds:
|
|
296
|
+
raise ValueError("no common seeds")
|
|
297
|
+
for side, d in (("a", a), ("b", b)):
|
|
298
|
+
bad = [s for s in seeds if not np.isfinite(float(d[s]))]
|
|
299
|
+
if bad:
|
|
300
|
+
raise ValueError(f"side {side}, seed {bad[0]}: non-finite value {float(d[bad[0]])!r}; a failed run must be "
|
|
301
|
+
"dropped (seeds=, strict=False) or re-run, not stored as NaN or inf")
|
|
302
|
+
return seeds, np.array([a[s] - b[s] for s in seeds], float)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def dropped_seeds(a, b, seeds=None):
|
|
306
|
+
"""Seeds on one side only (or planned seeds missing from either)."""
|
|
307
|
+
if seeds is not None:
|
|
308
|
+
return sorted(s for s in seeds if s not in a or s not in b)
|
|
309
|
+
return sorted(set(a) ^ set(b))
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def paired_contrast(a, b, seeds=None, strict=True, margin=MARGIN):
|
|
313
|
+
"""{seed: value} twice -> paired_stats of a - b with its unadjusted class, seeds used and
|
|
314
|
+
seeds dropped."""
|
|
315
|
+
used, x = paired_vector(a, b, seeds, strict)
|
|
316
|
+
st = classify_unadjusted(paired_stats(x, margin, name="paired contrast a - b"), margin)
|
|
317
|
+
st["seeds"] = used
|
|
318
|
+
st["dropped_seeds"] = [] if seeds is not None else dropped_seeds(a, b)
|
|
319
|
+
return st
|
refitgap/plots.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""The three figures of a report, from analysis.analyse() tables: deficit decomposition
|
|
2
|
+
(A and R per period), period profile (U, V_sep, V_shared) and method comparison (leads with
|
|
3
|
+
intervals and the margin). PDF and SVG; needs matplotlib (extra "plots")."""
|
|
4
|
+
import io
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
A_COL = "#D95319" # attainable term A (as in the paper)
|
|
10
|
+
R_COL = "#0072BD" # mismatch terms R, dR, C, M (as in the paper)
|
|
11
|
+
INK = "#222222"
|
|
12
|
+
MUTED = "#555555"
|
|
13
|
+
GREY = "#7a7a7a"
|
|
14
|
+
FIGURES = ("decomposition", "profile", "comparison")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _plt():
|
|
18
|
+
try:
|
|
19
|
+
import matplotlib
|
|
20
|
+
matplotlib.use("Agg")
|
|
21
|
+
import matplotlib.pyplot as plt
|
|
22
|
+
except ImportError as e: # pragma: no cover
|
|
23
|
+
raise ImportError("figures need matplotlib: pip install 'refitgap[plots]'") from e
|
|
24
|
+
# fixed salt for the ids matplotlib writes into SVG, so a figure is the same byte for byte
|
|
25
|
+
plt.rcParams.update({"font.family": "sans-serif", "font.size": 8.5, "axes.labelsize": 8.5,
|
|
26
|
+
"axes.titlesize": 9, "xtick.labelsize": 8, "ytick.labelsize": 8, "legend.fontsize": 7.5,
|
|
27
|
+
"axes.edgecolor": MUTED, "axes.labelcolor": INK, "xtick.color": MUTED,
|
|
28
|
+
"ytick.color": MUTED, "axes.spines.top": False, "axes.spines.right": False,
|
|
29
|
+
"svg.fonttype": "none", "svg.hashsalt": "refitgap", "pdf.fonttype": 42,
|
|
30
|
+
"lines.linewidth": 1.2})
|
|
31
|
+
return plt
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _is_mean(p):
|
|
35
|
+
return str(p).startswith("mean")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _note(fig, text):
|
|
39
|
+
import textwrap
|
|
40
|
+
fig.text(0.01, -0.01, "\n".join(textwrap.wrap(text, 95)), ha="left", va="top", fontsize=7, color=MUTED)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _jitter(n, width=0.12):
|
|
44
|
+
return np.zeros(1) if n == 1 else np.linspace(-width, width, n)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def figure_decomposition(result):
|
|
48
|
+
"""Signed A and dR per period and their mean over the selected periods, against the
|
|
49
|
+
per-period reference. One panel per method that has a reference."""
|
|
50
|
+
plt = _plt()
|
|
51
|
+
rows = result["decomposition"]
|
|
52
|
+
methods = list(dict.fromkeys(r["method"] for r in rows))
|
|
53
|
+
if not methods:
|
|
54
|
+
fig, ax = plt.subplots(figsize=(4.2, 1.2))
|
|
55
|
+
ax.axis("off")
|
|
56
|
+
ax.text(0.5, 0.5, "No per-period reference scores were given,\nso B = A + dR is not computed.",
|
|
57
|
+
ha="center", va="center", color=MUTED)
|
|
58
|
+
return fig
|
|
59
|
+
fig, axes = plt.subplots(1, len(methods), figsize=(3.6 * len(methods) + 0.6, 2.9), squeeze=False)
|
|
60
|
+
conv = rows[0]["U_convention"]
|
|
61
|
+
for ax, m in zip(axes[0], methods):
|
|
62
|
+
mr = [r for r in rows if r["method"] == m]
|
|
63
|
+
cats = list(dict.fromkeys(r["period"] for r in mr))
|
|
64
|
+
xs = np.arange(len(cats), dtype=float)
|
|
65
|
+
seeds = sorted({r["seed"] for r in mr})
|
|
66
|
+
for term, col, mk, ls, off in (("A", A_COL, "s", "-", -0.17), ("dR", R_COL, "o", "--", 0.17)):
|
|
67
|
+
means = []
|
|
68
|
+
for i, c in enumerate(cats):
|
|
69
|
+
v = np.array([r[term] for r in mr if r["period"] == c])
|
|
70
|
+
if len(seeds) > 1:
|
|
71
|
+
ax.plot(xs[i] + off + _jitter(len(v), 0.06), v, mk, ms=2.8, mfc="white", mec=col, mew=0.8,
|
|
72
|
+
alpha=0.9, zorder=2)
|
|
73
|
+
means.append(v.mean())
|
|
74
|
+
per = [i for i, c in enumerate(cats) if not _is_mean(c)]
|
|
75
|
+
ax.plot(xs[per] + off, np.array(means)[per], ls=ls, color=col, lw=0.9, zorder=3)
|
|
76
|
+
ax.plot(xs + off, means, mk, ms=6, color=col, mec="white", mew=0.6, zorder=4,
|
|
77
|
+
label="A, attainable gap" if term == "A" else "dR, change in mismatch")
|
|
78
|
+
ax.axhline(0, color=MUTED, lw=0.7, ls=":")
|
|
79
|
+
if any(_is_mean(c) for c in cats):
|
|
80
|
+
ax.axvline(len(cats) - 1.5, color=GREY, lw=0.5)
|
|
81
|
+
ax.set_xticks(xs)
|
|
82
|
+
ax.set_xticklabels(["mean" if _is_mean(c) else str(c) for c in cats])
|
|
83
|
+
ax.set_xlabel("period id")
|
|
84
|
+
ax.set_ylabel("reference minus final\nmacro-F1 difference (0 to 1 scale)")
|
|
85
|
+
ax.set_title(f"{m}: B = A + dR, rule '{rows[0]['rule']}', {conv}", loc="left")
|
|
86
|
+
n = len(seeds)
|
|
87
|
+
axes[0][0].legend(frameon=False, loc="best")
|
|
88
|
+
fig.tight_layout()
|
|
89
|
+
_note(fig, f"{'Small hollow markers: single runs. ' if n > 1 else ''}Filled markers: mean over {n} "
|
|
90
|
+
f"run{'s' if n > 1 else ''}. Positive where the final model is below the reference. Descriptive.")
|
|
91
|
+
return fig
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def figure_profile(result):
|
|
95
|
+
"""U at the fixed rule, V_sep and V_shared per period for each method's final model."""
|
|
96
|
+
plt = _plt()
|
|
97
|
+
rule = result["settings"]["rule"]
|
|
98
|
+
rows = [r for r in result["levels"] if r["model"] == "final" and r["rule"] == rule and not _is_mean(r["period"])]
|
|
99
|
+
methods = list(dict.fromkeys(r["method"] for r in rows))
|
|
100
|
+
fig, axes = plt.subplots(1, len(methods), figsize=(3.6 * len(methods) + 0.6, 2.9), squeeze=False, sharey=True)
|
|
101
|
+
series = (("V_sep", "V_sep, own-period rules", INK, "o", "-"),
|
|
102
|
+
("V_shared", "V_shared, one shared rule", GREY, "s", "--"),
|
|
103
|
+
("U_full", f"U_full, fixed rule '{rule}'", R_COL, "^", ":"))
|
|
104
|
+
seen = []
|
|
105
|
+
for ax, m in zip(axes[0], methods):
|
|
106
|
+
mr = [r for r in rows if r["method"] == m]
|
|
107
|
+
per = list(dict.fromkeys(r["period"] for r in mr))
|
|
108
|
+
xs = np.arange(len(per), dtype=float)
|
|
109
|
+
nseed = len({r["seed"] for r in mr})
|
|
110
|
+
for key, lab, col, mk, ls in series:
|
|
111
|
+
seen.extend(r[key] for r in mr if r[key] is not None)
|
|
112
|
+
vals = [[r[key] for r in mr if r["period"] == p] for p in per]
|
|
113
|
+
if any(v is None for vv in vals for v in vv):
|
|
114
|
+
continue
|
|
115
|
+
if nseed > 1:
|
|
116
|
+
for i, vv in enumerate(vals):
|
|
117
|
+
ax.plot(xs[i] + _jitter(len(vv), 0.08), vv, mk, ms=2.6, mfc="white", mec=col, mew=0.7, zorder=2)
|
|
118
|
+
ax.plot(xs, [np.mean(v) for v in vals], ls=ls, marker=mk, color=col, ms=5, label=lab, zorder=3)
|
|
119
|
+
ax.set_xticks(xs)
|
|
120
|
+
ax.set_xticklabels([str(p) for p in per])
|
|
121
|
+
ax.set_xlabel("period id")
|
|
122
|
+
ax.set_title(f"{m}: final model", loc="left")
|
|
123
|
+
# the axis spans the data (at least 0.1 wide, clipped to 0..1) so the gaps between the
|
|
124
|
+
# three levels stay visible; the note states the scale
|
|
125
|
+
lo, hi = float(min(seen)), float(max(seen))
|
|
126
|
+
pad = max(0.02, 0.15 * (hi - lo), (0.1 - (hi - lo)) / 2)
|
|
127
|
+
y0, y1 = max(0.0, lo - pad), min(1.0, hi + pad)
|
|
128
|
+
for ax in axes[0]:
|
|
129
|
+
ax.set_ylim(y0, y1)
|
|
130
|
+
axes[0][0].set_ylabel("macro-F1 (0 to 1 scale)")
|
|
131
|
+
axes[0][0].legend(frameon=False, loc="best", fontsize=7)
|
|
132
|
+
fig.tight_layout()
|
|
133
|
+
_note(fig, f"{'Small hollow markers: single runs. ' if nseed > 1 else ''}Lines: mean over {nseed} "
|
|
134
|
+
f"run{'s' if nseed > 1 else ''}. The axis spans the data, from {y0:.2f} to {y1:.2f} of the "
|
|
135
|
+
f"0 to 1 scale. Descriptive.")
|
|
136
|
+
return fig
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def figure_comparison(result):
|
|
140
|
+
"""The paired lead at the fixed rule and after refit, one row per term and comparison,
|
|
141
|
+
with its marginal 95% t interval (two or more seeds) and the margin drawn."""
|
|
142
|
+
plt = _plt()
|
|
143
|
+
rows = result["contrasts"]
|
|
144
|
+
margin = result["settings"]["margin"]
|
|
145
|
+
if not rows:
|
|
146
|
+
fig, ax = plt.subplots(figsize=(4.2, 1.2))
|
|
147
|
+
ax.axis("off")
|
|
148
|
+
ax.text(0.5, 0.5, "No comparison: give a second method or per-period reference scores.", ha="center",
|
|
149
|
+
va="center", color=MUTED)
|
|
150
|
+
return fig
|
|
151
|
+
style = {"at the rule": (INK, "D"), "after own-period refit": (A_COL, "s"), "after one shared refit": (GREY, "o")}
|
|
152
|
+
fig, ax = plt.subplots(figsize=(5.4, 0.5 * len(rows) + 1.3))
|
|
153
|
+
ys = np.arange(len(rows))[::-1].astype(float)
|
|
154
|
+
for y, r in zip(ys, rows):
|
|
155
|
+
col, mk = style.get(r["term"], (INK, "D"))
|
|
156
|
+
if r["ci95_low"] is not None:
|
|
157
|
+
ax.plot([r["ci95_low"], r["ci95_high"]], [y, y], color=col, lw=1.3, zorder=2)
|
|
158
|
+
ax.plot(r["mean"], y, mk, color=col, ms=6.5, mec="white", mew=0.6, zorder=4)
|
|
159
|
+
vals = [float(v) for v in str(r.get("values") or "").split()]
|
|
160
|
+
if len(vals) > 1:
|
|
161
|
+
ax.plot(vals, y + 0.22 + _jitter(len(vals), 0.05), "s", ms=2.6, mfc="white", mec=col, mew=0.7, zorder=3)
|
|
162
|
+
ax.axvline(0, color=MUTED, lw=0.7, ls=":")
|
|
163
|
+
for s in (-margin, margin):
|
|
164
|
+
ax.axvline(s, color=MUTED, lw=0.7, ls="--")
|
|
165
|
+
ax.set_ylim(-0.6, len(rows) - 0.4)
|
|
166
|
+
lo, hi = ax.get_xlim()
|
|
167
|
+
ax.set_xlim(min(lo, -2 * margin), max(hi, 2 * margin))
|
|
168
|
+
ax.set_yticks(ys)
|
|
169
|
+
ax.set_yticklabels([f"{r['comparison']}\n{r['term']}" for r in rows], fontsize=7.5)
|
|
170
|
+
ax.set_xlabel("lead in macro-F1 (difference on the 0 to 1 scale)")
|
|
171
|
+
n = max(r["n"] for r in rows)
|
|
172
|
+
ax.set_title("paired leads, positive where the first named side is ahead", loc="left", fontsize=8)
|
|
173
|
+
fig.tight_layout()
|
|
174
|
+
note = ("Diamonds, squares and circles: means. Bars: marginal 95% t intervals over seeds. Small hollow squares: "
|
|
175
|
+
"single runs." if n > 1 else "One run: no interval is computed.")
|
|
176
|
+
_note(fig, note + f" Dashed lines: margin {margin:g} either side of zero (dotted). Descriptive unless a "
|
|
177
|
+
"planned family is named.")
|
|
178
|
+
return fig
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def build(result):
|
|
182
|
+
"""{name: matplotlib Figure} for the three default figures."""
|
|
183
|
+
return {"decomposition": figure_decomposition(result), "profile": figure_profile(result),
|
|
184
|
+
"comparison": figure_comparison(result)}
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def save_figures(result, outdir, formats=("pdf", "svg")):
|
|
188
|
+
"""Write fig_<name>.<fmt> for the three figures; returns {name: {fmt: path}}."""
|
|
189
|
+
plt = _plt()
|
|
190
|
+
outdir = Path(outdir)
|
|
191
|
+
outdir.mkdir(parents=True, exist_ok=True)
|
|
192
|
+
paths = {}
|
|
193
|
+
for name, fig in build(result).items():
|
|
194
|
+
paths[name] = {}
|
|
195
|
+
for fmt in formats:
|
|
196
|
+
p = outdir / f"fig_{name}.{fmt}"
|
|
197
|
+
fig.savefig(p, format=fmt, bbox_inches="tight", metadata={"Date": None} if fmt == "svg" else
|
|
198
|
+
{"CreationDate": None})
|
|
199
|
+
paths[name][fmt] = p
|
|
200
|
+
plt.close(fig)
|
|
201
|
+
return paths
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def svg_strings(result):
|
|
205
|
+
"""{name: inline SVG markup} for the HTML report (no XML prolog, no script)."""
|
|
206
|
+
plt = _plt()
|
|
207
|
+
out = {}
|
|
208
|
+
for name, fig in build(result).items():
|
|
209
|
+
buf = io.StringIO()
|
|
210
|
+
fig.savefig(buf, format="svg", bbox_inches="tight", metadata={"Date": None})
|
|
211
|
+
plt.close(fig)
|
|
212
|
+
s = buf.getvalue()
|
|
213
|
+
out[name] = s[s.index("<svg"):]
|
|
214
|
+
return out
|
refitgap/records.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Record alignment for scores made elsewhere: evaluate() pairs rows by position, so put
|
|
2
|
+
every model's rows in the labels' order first."""
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def align_to(reference_ids, ids, *arrays):
|
|
7
|
+
"""(reference_ids, ids, *arrays) -> arrays with rows reordered to reference_ids. Duplicate
|
|
8
|
+
ids or a reference id missing from ids raise; extra ids are dropped."""
|
|
9
|
+
ref = list(reference_ids)
|
|
10
|
+
cur = list(ids)
|
|
11
|
+
if len(set(ref)) != len(ref):
|
|
12
|
+
raise ValueError("reference_ids contain duplicates")
|
|
13
|
+
if len(set(cur)) != len(cur):
|
|
14
|
+
raise ValueError("ids contain duplicates")
|
|
15
|
+
pos = {r: i for i, r in enumerate(cur)}
|
|
16
|
+
missing = [r for r in ref if r not in pos]
|
|
17
|
+
if missing:
|
|
18
|
+
raise ValueError(f"{len(missing)} reference ids have no row (e.g. {missing[0]!r})")
|
|
19
|
+
idx = np.array([pos[r] for r in ref], dtype=np.int64)
|
|
20
|
+
out = []
|
|
21
|
+
for a in arrays:
|
|
22
|
+
a = np.asarray(a)
|
|
23
|
+
if a.shape[0] != len(cur):
|
|
24
|
+
raise ValueError(f"an array has {a.shape[0]} rows for {len(cur)} ids")
|
|
25
|
+
out.append(a[idx])
|
|
26
|
+
return out[0] if len(out) == 1 else tuple(out)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def drop_unlabelled(ids, Y, *arrays):
|
|
30
|
+
"""Records whose label row has any missing entry (NaN) removed, from `ids`, `Y` and every
|
|
31
|
+
array in `arrays`. evaluate() refuses labels other than 0/1, so a missing label must be
|
|
32
|
+
removed before evaluation, and the same records removed for every model compared on that
|
|
33
|
+
period (align every model to the returned ids). Returns (ids, Y as int, *arrays)."""
|
|
34
|
+
Y = np.asarray(Y, dtype=float)
|
|
35
|
+
keep = ~np.isnan(Y).any(axis=1)
|
|
36
|
+
ids = [i for i, k in zip(ids, keep) if k]
|
|
37
|
+
rest = [np.asarray(a)[keep] for a in arrays]
|
|
38
|
+
return (ids, Y[keep].astype(int), *rest)
|