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.
@@ -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)