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/cli.py ADDED
@@ -0,0 +1,173 @@
1
+ """Command line: refitgap {evaluate, report, example} and --version.
2
+
3
+ refitgap evaluate --npz run_s0.npz run_s1.npz --seeds 0 1 --out OUT
4
+ refitgap report (same inputs) --out OUT # adds figures and report.html
5
+ refitgap example [--out OUT]"""
6
+ import argparse
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from . import __version__
11
+ from .analysis import analyse, run_from_arrays, run_from_npz, write_tables
12
+ from .inference import MARGIN
13
+
14
+
15
+ def _inputs(p):
16
+ g = p.add_argument_group("inputs (method a, and optionally method b paired by seed)")
17
+ g.add_argument("--npz", nargs="+", default=[], help="stored-run files (*_scores.npz) of method a")
18
+ g.add_argument("--npz-b", nargs="+", default=[], help="stored-run files of method b")
19
+ g.add_argument("--arrays", nargs="+", default=[], help="per-period arrays files (npz) of method a")
20
+ g.add_argument("--arrays-b", nargs="+", default=[], help="per-period arrays files of method b")
21
+ g.add_argument("--seeds", nargs="+", type=int, help="run seed of each file, in file order (stored-run files; "
22
+ "arrays files may carry their own `seed`)")
23
+ g.add_argument("--names", nargs="+", default=["a", "b"], metavar="NAME",
24
+ help="method names in the outputs: one for a single method, two for a paired comparison")
25
+ s = p.add_argument_group("settings")
26
+ s.add_argument("--periods", nargs="+", type=int, help="period ids to evaluate (stored-run files; default all)")
27
+ s.add_argument("--mean-over", nargs="+", type=int, help="period ids averaged in summaries and contrasts "
28
+ "(default all evaluated)")
29
+ s.add_argument("--shared-periods", nargs="+", type=int, help="period ids of the shared rule (default all)")
30
+ s.add_argument("--shared-weights", nargs="+", type=float, help="weights of the shared periods (default uniform)")
31
+ s.add_argument("--splits", type=int, default=200, help="number of partitions (default 200, as in the paper)")
32
+ s.add_argument("--rule", default="inherited", help="fixed rule of the decomposition (default inherited)")
33
+ s.add_argument("--validation", action="store_true", help="stored-run files: add the rule fitted on the final "
34
+ "model's validation scores as rule 'validation'")
35
+ s.add_argument("--u-convention", choices=["full", "half"], default="full",
36
+ help="fixed-rule level used in the decomposition and contrasts: full = whole period (default; "
37
+ "B = A + dR of the paper), half = held-out halves")
38
+ s.add_argument("--no-reference", action="store_true", help="ignore per-period reference scores")
39
+ s.add_argument("--margin", type=float, default=MARGIN,
40
+ help="margin of the class rules (default 0.005): TOST, the class words and the non-inferiority "
41
+ "and inferiority tests all use it, and the figures draw it")
42
+ s.add_argument("--family", help="name of a family the caller specified in advance; without it every row is "
43
+ "descriptive")
44
+ s.add_argument("--workers", type=int, default=1, help="processes for the evaluations (default 1)")
45
+ p.add_argument("--out", required=True, help="output directory")
46
+ p.add_argument("--quiet", action="store_true", help="print only the output paths")
47
+
48
+
49
+ def load_runs(args):
50
+ if (args.npz or args.npz_b) and (args.arrays or args.arrays_b):
51
+ raise ValueError("use stored-run files or arrays files, not both")
52
+ ref = not args.no_reference
53
+ if args.npz:
54
+ if not args.seeds or len(args.seeds) != len(args.npz):
55
+ raise ValueError("--seeds needs one seed per stored-run file of method a (the same seeds pair method b)")
56
+ if args.npz_b and len(args.npz_b) != len(args.npz):
57
+ raise ValueError("--npz-b needs one file per seed, in the order of --seeds")
58
+ a = [run_from_npz(f, s, args.periods, args.validation, ref) for f, s in zip(args.npz, args.seeds)]
59
+ b = [run_from_npz(f, s, args.periods, args.validation, ref) for f, s in zip(args.npz_b, args.seeds)]
60
+ return a, b
61
+ if args.arrays:
62
+ if args.seeds and len(args.seeds) != len(args.arrays):
63
+ raise ValueError("--seeds needs one seed per arrays file of method a")
64
+ sa = args.seeds or [None] * len(args.arrays)
65
+ a = [run_from_arrays(f, s, ref) for f, s in zip(args.arrays, sa)]
66
+ if args.seeds and args.arrays_b and len(args.arrays_b) != len(args.seeds):
67
+ raise ValueError("--arrays-b needs one file per seed, in the order of --seeds")
68
+ sb = args.seeds or [None] * len(args.arrays_b)
69
+ b = [run_from_arrays(f, s, ref) for f, s in zip(args.arrays_b, sb)]
70
+ return a, b
71
+ raise ValueError("give --npz or --arrays")
72
+
73
+
74
+ def run_analysis(args):
75
+ a, b = load_runs(args)
76
+ return analyse(a, b, rule=args.rule, mean_over=args.mean_over, splits=args.splits,
77
+ shared_periods=args.shared_periods, shared_weights=args.shared_weights,
78
+ u_convention=args.u_convention, margin=args.margin, family=args.family, names=_names(args),
79
+ workers=args.workers)
80
+
81
+
82
+ def summary_lines(result):
83
+ conv = result["settings"]["U_convention"]
84
+ out = []
85
+ means = [r for r in result["decomposition"] if str(r["period"]).startswith("mean")]
86
+ if means:
87
+ out.append(f"decomposition against the per-period reference, rule '{result['settings']['rule']}', "
88
+ f"U_{conv} (descriptive):")
89
+ for r in means:
90
+ out.append(f" {r['method']} seed {r['seed']} {r['period']}: B {r['B']:+.4f} = A {r['A']:+.4f} "
91
+ f"+ dR {r['dR']:+.4f}")
92
+ if result["contrasts"]:
93
+ out.append("paired contrasts:")
94
+ for r in result["contrasts"]:
95
+ ci = (f" [{r['ci95_low']:+.4f}, {r['ci95_high']:+.4f}] (marginal 95% t)" if r["ci95_low"] is not None
96
+ else " (one run, no interval)")
97
+ cls = f" class {r['class']}" if r["class"] else ""
98
+ out.append(f" {r['comparison']}, {r['term']}: {r['mean']:+.4f}{ci} n {r['n']} status {r['status']}{cls}")
99
+ out.extend(result["interpretation"])
100
+ return out
101
+
102
+
103
+ def _write(result, args, report):
104
+ out = Path(args.out)
105
+ paths = write_tables(result, out)
106
+ if report:
107
+ from .report import write_report
108
+ try:
109
+ from .plots import save_figures, svg_strings
110
+ figs = save_figures(result, out)
111
+ svgs = svg_strings(result)
112
+ except ImportError as e:
113
+ print(f"note: {e}; the report is written without figures", file=sys.stderr)
114
+ figs, svgs = {}, {}
115
+ paths["report"] = write_report(result, out / "report.html", svgs)
116
+ for name, d in figs.items():
117
+ for fmt, p in d.items():
118
+ paths[f"fig_{name}.{fmt}"] = p
119
+ return paths
120
+
121
+
122
+ def _names(args):
123
+ """One or two method names; a single name is padded so the paired path never sees it."""
124
+ n = list(args.names)
125
+ if len(n) > 2:
126
+ raise SystemExit("refitgap: --names takes one or two names")
127
+ if len(n) == 1:
128
+ n.append("b")
129
+ if len(n) == 2 and (args.npz_b or args.arrays_b) and n[0] == n[1]:
130
+ raise SystemExit("refitgap: the two method names must differ")
131
+ return tuple(n)
132
+
133
+
134
+ def main(argv=None):
135
+ p = argparse.ArgumentParser(prog="refitgap",
136
+ description="Evaluation of thresholded forgetting from stored per-period scores.")
137
+ p.add_argument("--version", action="version", version=f"refitgap {__version__}")
138
+ sub = p.add_subparsers(dest="cmd")
139
+ pe = sub.add_parser("evaluate", help="levels, decomposition and contrasts as CSV and JSON")
140
+ _inputs(pe)
141
+ pr = sub.add_parser("report", help="evaluate, plus three figures (PDF, SVG) and a self-contained HTML report")
142
+ _inputs(pr)
143
+ px = sub.add_parser("example", help="the tiny example, worked by hand in API.txt")
144
+ px.add_argument("--out", help="also write its tables, figures and report here")
145
+ args = p.parse_args(argv)
146
+ if args.cmd is None:
147
+ p.print_help()
148
+ return 2
149
+ if args.cmd == "example":
150
+ from .tiny import lines, tiny_result
151
+ result = tiny_result()
152
+ for line in lines(result):
153
+ print(line)
154
+ if args.out:
155
+ args.quiet = True
156
+ for k, v in _write(result, args, report=True).items():
157
+ print(f"wrote {v}")
158
+ return 0
159
+ try:
160
+ result = run_analysis(args)
161
+ except (ValueError, KeyError) as e:
162
+ print(f"refitgap: error: {e}", file=sys.stderr)
163
+ return 2
164
+ if not args.quiet:
165
+ for line in summary_lines(result):
166
+ print(line)
167
+ for k, v in _write(result, args, report=args.cmd == "report").items():
168
+ print(f"wrote {v}")
169
+ return 0
170
+
171
+
172
+ if __name__ == "__main__":
173
+ sys.exit(main())
refitgap/crossfit.py ADDED
@@ -0,0 +1,275 @@
1
+ """Cross-fitted levels of one model, and the decompositions built from them.
2
+
3
+ Settings: sep (own-period rules, fitted per period), shared (one rule over `shared_periods`)
4
+ and fixed (any threshold vector under `rules`, e.g. "inherited"). Partition b of period id j,
5
+ seed s, is the b-th permutation of default_rng(7000 + 100*s + j); half 0 is the first n_j // 2
6
+ records. Each rule is fitted on one half and scored on the other, both ways, and averaged.
7
+ Every setting uses the same halves, so R = C + M and B = A + dR hold per partition."""
8
+ import hashlib
9
+ import numbers
10
+
11
+ import numpy as np
12
+
13
+ from .fitters import fit_exact, fit_grid, fit_shared_exact, macro_f1
14
+
15
+ SPLIT_BASE = 7000 # partition seed: 7000 + 100*seed + j
16
+ SPLITS = 200 # the paper's number of partitions
17
+ IDENTITY_TOL = 1e-12
18
+
19
+
20
+ MAX_PERIOD_ID = 100 # period ids must lie in [0, MAX_PERIOD_ID); see check_partition_key
21
+
22
+
23
+ def _is_int(x):
24
+ return isinstance(x, (numbers.Integral, np.integer)) and not isinstance(x, (bool, np.bool_))
25
+
26
+
27
+ def check_partition_key(seed, j=None, base=SPLIT_BASE):
28
+ """(seed, period id) -> ValueError unless seed >= 0 and 0 <= id < 100, the range in
29
+ which base + 100*seed + j keys partitions uniquely."""
30
+ if not _is_int(seed) or seed < 0:
31
+ raise ValueError(f"seed must be a non-negative integer (it keys the partitions through "
32
+ f"{base} + 100*seed + j), got {seed!r}")
33
+ if j is not None and (not _is_int(j) or not 0 <= j < MAX_PERIOD_ID):
34
+ raise ValueError(f"period id must be an integer in [0, {MAX_PERIOD_ID}), got {j!r}: the partition seed "
35
+ f"{base} + 100*seed + j keys (seed, period id) pairs uniquely only for such ids "
36
+ f"(period id 100 of seed s would repeat period id 0 of seed s + 1)")
37
+ if not _is_int(base) or base < 0:
38
+ raise ValueError(f"split base must be a non-negative integer, got {base!r}")
39
+
40
+
41
+ def partitions(n, seed, j, splits=SPLITS, base=SPLIT_BASE):
42
+ """The partitions of period id j: `splits` successive permutations of range(n)
43
+ from default_rng(base + 100*seed + j). Half 0 is perm[:n // 2].
44
+ seed: a non-negative integer; j: an integer in [0, 100) (check_partition_key)."""
45
+ check_partition_key(seed, j, base)
46
+ rng = np.random.default_rng(base + 100 * seed + j)
47
+ return [rng.permutation(n) for _ in range(splits)]
48
+
49
+
50
+ def fit_validation_rule(Pval, Yval):
51
+ """Validation scores and labels -> grid rule (19 points 0.05..0.95), as theta_1 was
52
+ fitted. Assumes probability-scale scores; for logits etc. pass your own rule under `rules`."""
53
+ return fit_grid(np.asarray(Pval, np.float64), np.asarray(Yval).astype(int)).astype(np.float64)
54
+
55
+
56
+ def _check_inputs(Ps, Ys, period_ids):
57
+ if len(Ps) != len(Ys) or len(Ps) == 0:
58
+ raise ValueError("Ps and Ys must be non-empty lists of the same length (one entry per period)")
59
+ K = np.asarray(Ps[0]).shape[1]
60
+ for j, (P, Y) in enumerate(zip(Ps, Ys)):
61
+ P, Y = np.asarray(P), np.asarray(Y)
62
+ if P.ndim != 2 or P.shape != Y.shape or P.shape[1] != K:
63
+ raise ValueError(f"period {period_ids[j]}: scores and labels must be (n_j, K) with one K for all periods")
64
+ if P.shape[0] < 2:
65
+ raise ValueError(f"period {period_ids[j]}: needs at least 2 records to split in half")
66
+ if not np.all(np.isfinite(P)):
67
+ raise ValueError(f"period {period_ids[j]}: non-finite scores")
68
+ if not np.isin(Y, (0, 1)).all():
69
+ raise ValueError(f"period {period_ids[j]}: labels must be 0/1")
70
+ if len(set(period_ids)) != len(period_ids):
71
+ raise ValueError("period ids must be distinct")
72
+
73
+
74
+ def _check_weights(w, n, what):
75
+ """Finite, non-negative, one per period, not all zero. Returned as a tuple of floats."""
76
+ w = np.asarray(w, float)
77
+ if w.shape != (n,) or not np.all(np.isfinite(w)) or np.any(w < 0) or w.sum() <= 0:
78
+ raise ValueError(f"{what}: one finite non-negative weight per period, not all zero")
79
+ return tuple(float(x) for x in w)
80
+
81
+
82
+ def array_hash(a, dtype):
83
+ """sha16 of an array's shape and its bytes in `dtype` (C order). Equal hashes mean the same
84
+ values in the same record order, which is what pairs two evaluations' partitions."""
85
+ a = np.ascontiguousarray(a, dtype=dtype)
86
+ h = hashlib.sha256(repr(a.shape).encode())
87
+ h.update(a.tobytes())
88
+ return h.hexdigest()[:16]
89
+
90
+
91
+ class Evaluation:
92
+ """Per-partition levels of one model, arrays of shape (splits, 2, periods): sep, shared,
93
+ U_half[name]; U_full[name] per period. Also rules, label hashes and settings. Made by evaluate()."""
94
+
95
+ def __init__(self, **kw):
96
+ self.__dict__.update(kw)
97
+
98
+ def _cols(self, periods):
99
+ periods = self.period_ids if periods is None else list(periods)
100
+ missing = [p for p in periods if p not in self.period_ids]
101
+ if missing:
102
+ raise KeyError(f"period ids {missing} were not evaluated")
103
+ return periods, [self.period_ids.index(p) for p in periods]
104
+
105
+ def per_period(self):
106
+ """Per-period levels: mean over partitions of 0.5 * (direction 0 + direction 1)."""
107
+ hm = lambda X, c: float(np.mean(0.5 * (X[:, 0, c] + X[:, 1, c])))
108
+ out = {}
109
+ for c, j in enumerate(self.period_ids):
110
+ r = dict(V_sep=hm(self.sep, c),
111
+ V_shared=(hm(self.shared, c) if self.single_model and j in self.shared_periods else None),
112
+ U_half={k: hm(v, c) for k, v in self.U_half.items()},
113
+ U_full={k: float(v[c]) for k, v in self.U_full.items()})
114
+ out[j] = r
115
+ return out
116
+
117
+ def summary(self, periods=None, weights=None):
118
+ """Periods and weights -> mean levels and R = C + M (period-weighted per partition, then
119
+ averaged). Weights default to uniform, or to shared_weights on the shared periods."""
120
+ periods, cols = self._cols(periods)
121
+ if weights is None and self.shared_weights is not None:
122
+ if periods != list(self.shared_periods):
123
+ raise ValueError("the shared rule was fitted with shared_weights; pass `weights` for periods "
124
+ f"{periods} (they differ from the shared periods {self.shared_periods})")
125
+ weights = self.shared_weights
126
+ if weights is None:
127
+ agg = lambda X: np.mean(0.5 * (X[:, 0, cols] + X[:, 1, cols]), axis=1)
128
+ aggf = lambda v: float(np.mean([v[c] for c in cols]))
129
+ else:
130
+ w = np.asarray(_check_weights(weights, len(cols), "weights"))
131
+ w = w / w.sum()
132
+ agg = lambda X: (0.5 * (X[:, 0, cols] + X[:, 1, cols])) @ w
133
+ aggf = lambda v: float(sum(wi * v[c] for wi, c in zip(w, cols)))
134
+ vsep_b = agg(self.sep)
135
+ out = dict(periods=periods, weights=(None if weights is None else w.tolist()), V_sep=float(vsep_b.mean()))
136
+ has_sh = self.single_model and all(p in self.shared_periods for p in periods)
137
+ if has_sh:
138
+ vsh_b = agg(self.shared)
139
+ out["V_shared"] = float(vsh_b.mean())
140
+ out["C"] = float(np.mean(vsep_b - vsh_b))
141
+ else:
142
+ out["V_shared"] = out["C"] = None
143
+ out["rules"] = {}
144
+ for name, Uh in self.U_half.items():
145
+ u_b = agg(Uh)
146
+ r = dict(U_half=float(u_b.mean()), U_full=aggf(self.U_full[name]),
147
+ R=float(np.mean(vsep_b - u_b)))
148
+ r["R_full"] = out["V_sep"] - r["U_full"]
149
+ if has_sh:
150
+ r["M"] = float(np.mean(vsh_b - u_b))
151
+ r["identity_residual"] = abs(out["C"] + r["M"] - r["R"])
152
+ if r["identity_residual"] > IDENTITY_TOL:
153
+ raise AssertionError(f"C + M != R for rule {name}: {r['identity_residual']:.3g}")
154
+ out["rules"][name] = r
155
+ return out
156
+
157
+
158
+ def evaluate(Ps, Ys, seed, period_ids=None, rules=None, shared_periods=None, shared_weights=None,
159
+ splits=SPLITS, split_base=SPLIT_BASE, single_model=True):
160
+ """Per-period scores Ps and 0/1 labels Ys, (n_j, K) each -> Evaluation.
161
+
162
+ seed and period_ids (ints in [0, 100)) key the partitions: two models compared on the same
163
+ records need the same of both. rules: {name: (K,) thresholds}. shared_periods and
164
+ shared_weights: where the shared rule is fitted and how periods weigh (default all, uniform).
165
+ splits: partitions per period (200 in the paper). single_model=False for a per-period
166
+ reference (P_j_j on period j): no shared rule is fitted for it."""
167
+ N = len(Ps)
168
+ period_ids = list(range(N)) if period_ids is None else list(period_ids)
169
+ check_partition_key(seed, None, split_base)
170
+ for p in period_ids:
171
+ check_partition_key(seed, p, split_base)
172
+ period_ids = [int(p) for p in period_ids]
173
+ _check_inputs(Ps, Ys, period_ids)
174
+ Ps = [np.asarray(P, np.float64) for P in Ps]
175
+ Ys = [np.asarray(Y).astype(int) for Y in Ys]
176
+ rules = {} if rules is None else {k: np.asarray(v, np.float64) for k, v in rules.items()}
177
+ for k, v in rules.items():
178
+ if v.shape != (Ps[0].shape[1],):
179
+ raise ValueError(f"rule {k}: need one threshold per label")
180
+ if not np.all(np.isfinite(v)):
181
+ raise ValueError(f"rule {k}: non-finite threshold")
182
+ shared_periods = list(period_ids) if shared_periods is None else [int(p) for p in shared_periods]
183
+ if [p for p in shared_periods if p not in period_ids] or len(set(shared_periods)) != len(shared_periods):
184
+ raise ValueError("shared_periods: distinct ids of evaluated periods")
185
+ S = [period_ids.index(p) for p in shared_periods]
186
+ if shared_weights is not None:
187
+ shared_weights = _check_weights(shared_weights, len(S), "shared_weights")
188
+ perms = [partitions(len(Ys[c]), seed, period_ids[c], splits, split_base) for c in range(N)]
189
+ nan = np.full((splits, 2, N), np.nan)
190
+ sep, sh = nan.copy(), nan.copy()
191
+ U = {k: nan.copy() for k in rules}
192
+ for b in range(splits):
193
+ for d in (0, 1):
194
+ fit, ev = [], []
195
+ for c in range(N):
196
+ h = len(Ys[c]) // 2
197
+ A, B = perms[c][b][:h], perms[c][b][h:]
198
+ fit.append(A if d == 0 else B); ev.append(B if d == 0 else A)
199
+ for c in range(N):
200
+ Pe, Ye = Ps[c][ev[c]], Ys[c][ev[c]]
201
+ sep[b, d, c] = macro_f1(Pe, Ye, fit_exact(Ps[c][fit[c]], Ys[c][fit[c]]))
202
+ for k, th in rules.items():
203
+ U[k][b, d, c] = macro_f1(Pe, Ye, th)
204
+ if not single_model:
205
+ continue # no shared rule across different models' scores
206
+ th_sh = fit_shared_exact([Ps[c][fit[c]] for c in S], [Ys[c][fit[c]] for c in S], shared_weights)
207
+ for c in S:
208
+ sh[b, d, c] = macro_f1(Ps[c][ev[c]], Ys[c][ev[c]], th_sh)
209
+ U_full = {k: np.array([macro_f1(Ps[c], Ys[c], th) for c in range(N)]) for k, th in rules.items()}
210
+ # pairing checks for decompose: label counts (readable) and a hash of each label matrix in
211
+ # record order (a permuted or different record set gives a different hash); the fixed rules
212
+ # are kept, with their hashes, so decompose can confirm both models were scored at one rule
213
+ sig = [(int(len(Y)), int(Y.sum())) for Y in Ys]
214
+ label_hash = [array_hash(Y, np.int8) for Y in Ys]
215
+ rule_hash = {k: array_hash(v, np.float64) for k, v in rules.items()}
216
+ return Evaluation(seed=seed, splits=splits, split_base=split_base, period_ids=period_ids,
217
+ shared_periods=shared_periods, shared_weights=shared_weights, sep=sep, shared=sh,
218
+ U_half=U, U_full=U_full, label_signature=sig, label_hash=label_hash,
219
+ rules={k: v.copy() for k, v in rules.items()}, rule_hash=rule_hash,
220
+ single_model=bool(single_model))
221
+
222
+
223
+ def decompose(ref, final, rule, periods=None, weights=None, halves=False, same_rule=True):
224
+ """Two Evaluations on the same records -> dict with B = A + dR at `rule`, per period and mean.
225
+
226
+ B = U_ref - U_final (the deficit), A = V_sep,ref - V_sep,final, dR = R_final - R_ref with
227
+ R_x = V_sep,x - U_x; for two single models also dR = dC + dM. B and dR are also returned
228
+ as D and R (the earlier names). U on the full period unless
229
+ halves=True. Raises unless seed, period ids, splits, labels and the rule match;
230
+ same_rule=False allows a model-specific rule under one name."""
231
+ if ref.seed != final.seed or ref.period_ids != final.period_ids or ref.splits != final.splits \
232
+ or ref.split_base != final.split_base:
233
+ raise ValueError("ref and final must share seed, period ids and splits (same partitions)")
234
+ if ref.label_signature != final.label_signature or ref.label_hash != final.label_hash:
235
+ bad = [p for p, a, b in zip(ref.period_ids, ref.label_hash, final.label_hash) if a != b]
236
+ raise ValueError(f"ref and final were evaluated on different labels or record order (periods {bad})")
237
+ if rule not in ref.rules or rule not in final.rules:
238
+ raise KeyError(f"rule {rule!r} was not evaluated for both models")
239
+ matched = ref.rule_hash[rule] == final.rule_hash[rule]
240
+ if same_rule and not matched:
241
+ raise ValueError(f"rule {rule!r} is a different threshold vector in ref and final; "
242
+ "pass same_rule=False only for a rule that is model-specific by design")
243
+ if weights is None and ref.shared_weights != final.shared_weights:
244
+ raise ValueError("ref and final were evaluated with different shared_weights "
245
+ f"({ref.shared_weights} against {final.shared_weights}), so their default period "
246
+ "weightings differ and D, A and R would mix two weightings; pass `weights` explicitly")
247
+ sr, sf = ref.summary(periods, weights), final.summary(periods, weights)
248
+ key = "U_half" if halves else "U_full"
249
+ ur, uf = sr["rules"][rule][key], sf["rules"][rule][key]
250
+ out = dict(periods=sr["periods"], rule=rule, same_rule=bool(matched), U=key, U_ref=ur, U_final=uf,
251
+ V_ref=sr["V_sep"], V_final=sf["V_sep"],
252
+ D=ur - uf, A=sr["V_sep"] - sf["V_sep"], R_ref=sr["V_sep"] - ur, R_final=sf["V_sep"] - uf)
253
+ out["R"] = out["R_final"] - out["R_ref"]
254
+ out["residual_D_A_R"] = abs(out["D"] - (out["A"] + out["R"]))
255
+ out["B"], out["dR"] = out["D"], out["R"] # the paper's names; D and R kept for compatibility
256
+ if not (ref.single_model and final.single_model):
257
+ out["shared_terms"] = "omitted: the reference holds different models on different periods"
258
+ elif ref.shared_periods != final.shared_periods or ref.shared_weights != final.shared_weights:
259
+ out["shared_terms"] = "omitted: the two shared rules were fitted on different periods or weights"
260
+ elif sr["C"] is None or sf["C"] is None:
261
+ out["shared_terms"] = "omitted: the periods are not all in the shared set"
262
+ else:
263
+ out["shared_terms"] = "reported"
264
+ out["C_ref"], out["C_final"] = sr["C"], sf["C"]
265
+ out["M_ref"] = sr["V_shared"] - ur
266
+ out["M_final"] = sf["V_shared"] - uf
267
+ out["dC"] = out["C_final"] - out["C_ref"]
268
+ out["dM"] = out["M_final"] - out["M_ref"]
269
+ out["A_shared"] = sr["V_shared"] - sf["V_shared"]
270
+ out["residual_R_C_M"] = abs(out["R"] - (out["dC"] + out["dM"]))
271
+ out["residual_D_Ash_M"] = abs(out["D"] - (out["A_shared"] + out["dM"]))
272
+ for k in [k for k in out if k.startswith("residual")]:
273
+ if out[k] > IDENTITY_TOL:
274
+ raise AssertionError(f"{k} = {out[k]:.3g}")
275
+ return out
refitgap/example.py ADDED
@@ -0,0 +1,96 @@
1
+ """Synthetic two-learner data for the tests: four periods, four labels, six seeds per method.
2
+
3
+ make_seed(s) builds one seed's labels and scores; main() prints a decomposition on them. The
4
+ forgetful learner loses separation on early periods and its mismatch grows; the
5
+ other loses less. The built-in example to run is `refitgap example` (src/refitgap/tiny.py)."""
6
+ import numpy as np
7
+
8
+ from . import crossfit, inference
9
+
10
+ N_PERIODS, K, N_REC = 4, 4, 400
11
+ SEEDS = range(6)
12
+ SPLITS = 50 # the paper uses 200; 50 keeps the example quick
13
+
14
+
15
+ def synth_period(rng, n, prev):
16
+ return (rng.random((n, K)) < prev).astype(int)
17
+
18
+
19
+ def scores(rng, Y, sep, offset):
20
+ z = sep * Y + rng.normal(0, 1, Y.shape) + offset
21
+ return 1 / (1 + np.exp(-z))
22
+
23
+
24
+ def make_seed(seed):
25
+ """Labels per period, the reference model of each period (s_j on period j), the final
26
+ model of two learners, and a validation split from period 0 that fits theta_1."""
27
+ rng = np.random.default_rng(seed)
28
+ prev = np.array([0.10, 0.20, 0.30, 0.15])
29
+ Ys = [synth_period(rng, N_REC, prev) for _ in range(N_PERIODS)]
30
+ Yval = synth_period(rng, 300, prev)
31
+ ref = [scores(rng, Ys[j], 2.5, -1.5) for j in range(N_PERIODS)] # each period's own model
32
+ Pval_first = scores(rng, Yval, 2.5, -1.5)
33
+ # final models: separation lost on early periods, period-specific offset
34
+ sequential = [scores(rng, Ys[j], 2.5 - 0.30 * (N_PERIODS - 1 - j), -1.5 + 0.5 * (N_PERIODS - 1 - j))
35
+ for j in range(N_PERIODS)]
36
+ replay = [scores(rng, Ys[j], 2.5 - 0.10 * (N_PERIODS - 1 - j), -1.5 + 0.15 * (N_PERIODS - 1 - j))
37
+ for j in range(N_PERIODS)]
38
+ return Ys, Yval, Pval_first, ref, sequential, replay
39
+
40
+
41
+ def main():
42
+ hist = [0, 1, 2] # historical periods; period 3 is the current one
43
+ per_seed = {"sequential": {}, "replay": {}}
44
+ alone = {"sequential": {}, "replay": {}}
45
+ method = {}
46
+ for s in SEEDS:
47
+ Ys, Yval, Pval_first, ref, sequential, replay = make_seed(s)
48
+ theta_1 = crossfit.fit_validation_rule(Pval_first, Yval) # the inherited thresholds
49
+ rules = {"inherited": theta_1}
50
+ # the reference holds a different model per period: no shared-rule terms against it
51
+ ev_ref = crossfit.evaluate(ref, Ys, s, rules=rules, splits=SPLITS, single_model=False)
52
+ ev = {}
53
+ for name, Ps in (("sequential", sequential), ("replay", replay)):
54
+ ev[name] = crossfit.evaluate(Ps, Ys, s, rules=rules, splits=SPLITS)
55
+ per_seed[name][s] = crossfit.decompose(ev_ref, ev[name], "inherited", periods=hist)
56
+ alone[name][s] = ev[name].summary(periods=hist)
57
+ # method contrast: both are single final models, so R splits into dC + dM
58
+ method[s] = crossfit.decompose(ev["replay"], ev["sequential"], "inherited", periods=hist)
59
+ if s == 0:
60
+ d = per_seed["sequential"][0]
61
+ print("seed 0, sequential learner, historical periods 0-2, inherited thresholds theta_1:")
62
+ print(f" against the per-period reference models: B = {d['B']:+.4f} = A {d['A']:+.4f} + dR {d['dR']:+.4f}"
63
+ f" (residual {d['residual_D_A_R']:.1e})")
64
+ sm = alone["sequential"][0]
65
+ r = sm["rules"]["inherited"]
66
+ print(f" final model alone: V_sep {sm['V_sep']:.4f} V_shared {sm['V_shared']:.4f} U(theta_1) "
67
+ f"{r['U_half']:.4f}, so R {r['R']:+.4f} = C {sm['C']:+.4f} + M {r['M']:+.4f}")
68
+ m = method[0]
69
+ print(
70
+ f" against the replay learner's final model: B = {m['B']:+.4f} = A {m['A']:+.4f} + dR {m['dR']:+.4f};"
71
+ f" dR = dC {m['dC']:+.4f} + dM {m['dM']:+.4f}"
72
+ )
73
+ print("\nper-seed decomposition, sequential learner (historical mean; B, A, dR against the reference models,"
74
+ " C and M of the final model alone):")
75
+ for s in SEEDS:
76
+ d, sm = per_seed["sequential"][s], alone["sequential"][s]
77
+ print(f" seed {s}: B {d['B']:+.4f} A {d['A']:+.4f} dR {d['dR']:+.4f} C {sm['C']:+.4f} "
78
+ f"M {sm['rules']['inherited']['M']:+.4f}")
79
+ # paired contrasts, sequential minus replay, one family of three read together
80
+ members = {}
81
+ for term in ("B", "A", "dR"):
82
+ _, members[term] = inference.paired_vector({s: per_seed["sequential"][s][term] for s in SEEDS},
83
+ {s: per_seed["replay"][s][term] for s in SEEDS},
84
+ seeds=SEEDS)
85
+ fam = inference.family(members)
86
+ print("\nsequential minus replay, paired over 6 seeds (Holm over B, A, dR; margin 0.005):")
87
+ for term, st in fam.items():
88
+ print(f" {term}: mean {st['mean']:+.4f} 95% CI (marginal) [{st['ci95'][0]:+.4f}, {st['ci95'][1]:+.4f}]"
89
+ f" Holm p_t {st['p_holm']:.3g} class {st['class']}")
90
+ c = inference.paired_contrast({s: alone["replay"][s]["V_shared"] for s in SEEDS},
91
+ {s: alone["sequential"][s]["V_shared"] for s in SEEDS}, seeds=SEEDS)
92
+ print(f"\nshared-rule level, replay minus sequential: {c['mean']:+.4f} [{c['ci95'][0]:+.4f}, {c['ci95'][1]:+.4f}]"
93
+ f" class {c['class']} (unadjusted)")
94
+ c = inference.paired_contrast({s: method[s]["dC"] for s in SEEDS}, {s: 0.0 for s in SEEDS}, seeds=SEEDS)
95
+ print(f"dC, sequential final against replay final (cost of one rule for periods 0-2): {c['mean']:+.4f} "
96
+ f"[{c['ci95'][0]:+.4f}, {c['ci95'][1]:+.4f}] class {c['class']} (unadjusted)")
refitgap/fitters.py ADDED
@@ -0,0 +1,114 @@
1
+ """Threshold fitters and thresholded F1.
2
+
3
+ f1_vec, macro_f1 (per-label F1 and its mean), fit_exact (best cutpoint per label over every
4
+ observed score), fit_shared_exact (one vector for several periods), fit_grid (19 points
5
+ 0.05..0.95, how theta_1 was fitted). Positive means score >= theta; F1 is 0 when nothing is
6
+ predicted and nothing is positive. Fitters refuse non-finite scores and non-0/1 labels."""
7
+ import numpy as np
8
+
9
+ GRID = np.linspace(0.05, 0.95, 19)
10
+
11
+
12
+ def _check_PY(P, Y, what):
13
+ if not np.all(np.isfinite(P)):
14
+ raise ValueError(f"{what}: non-finite scores")
15
+ Y = np.asarray(Y)
16
+ if Y.dtype != bool and not np.isin(Y, (0, 1)).all():
17
+ raise ValueError(f"{what}: labels must be 0/1")
18
+
19
+
20
+ def _above(top):
21
+ """A cutpoint above every score: top + 1e-9 (the ported rule) wherever that is larger than
22
+ top, else the next float above top. top + 1e-9 == top only for |top| above about 1.7e7, so
23
+ for scores in [0, 1] (and up to that magnitude) the result is top + 1e-9, unchanged."""
24
+ top = np.asarray(top, np.float64)
25
+ c = top + 1e-9
26
+ return np.where(c > top, c, np.nextafter(top, np.inf))
27
+
28
+
29
+ def f1_vec(P, Y, theta):
30
+ """Per-label F1 at thresholds theta, zero_division = 0 (as sklearn's f1_score with zero_division=0)."""
31
+ pred = P >= theta
32
+ Yb = Y.astype(bool)
33
+ tp = (pred & Yb).sum(0)
34
+ den = pred.sum(0) + Yb.sum(0)
35
+ return np.where(den > 0, 2.0 * tp / np.maximum(den, 1), 0.0)
36
+
37
+
38
+ def macro_f1(P, Y, theta):
39
+ return float(f1_vec(P, Y, theta).mean())
40
+
41
+
42
+ def fit_exact(P, Y):
43
+ """Per label, the cutpoint over all score-induced cutpoints that maximises F1 on (P, Y).
44
+ Ties go to the highest maximiser (exactly: each F1 value is one correctly rounded division
45
+ of integers, so equal F1 values are equal floats). A label with no positive gets a cutpoint
46
+ above every score (predicts nothing; see _above)."""
47
+ P = np.asarray(P, dtype=np.float64)
48
+ _check_PY(P, Y, "fit_exact")
49
+ Yb = np.asarray(Y).astype(bool)
50
+ n, K = P.shape
51
+ o = np.argsort(-P, axis=0, kind="mergesort") # per column, as the 1-d sort in decomposition.py
52
+ ss = np.take_along_axis(P, o, 0)
53
+ ys = np.take_along_axis(Yb, o, 0)
54
+ npos = ys.sum(0)
55
+ f1 = 2 * np.cumsum(ys, 0) / (np.arange(1, n + 1)[:, None] + npos[None, :])
56
+ ok = np.ones((n, K), bool)
57
+ ok[:-1] = ss[:-1] > ss[1:] # only cut between distinct scores
58
+ idx = np.argmax(np.where(ok, f1, -1.0), 0)
59
+ out = ss[idx, np.arange(K)].astype(np.float64)
60
+ none = npos == 0
61
+ out[none] = _above(ss[0, none])
62
+ return out
63
+
64
+
65
+ def fit_shared_exact(Ps, Ys, w=None):
66
+ """Per-period scores and labels (and weights w) -> one (K,) threshold vector maximising
67
+ sum_j w_j F1_jk(t) per label over all periods' cutpoints. Ties go to the higher cutpoint, up
68
+ to float rounding of the sum; a label with no positive anywhere gets a cutpoint above every
69
+ score. One period gives fit_exact."""
70
+ J = len(Ps)
71
+ K = Ys[0].shape[1]
72
+ w = np.full(J, 1.0 / J) if w is None else np.asarray(w, float)
73
+ for j in range(J):
74
+ _check_PY(np.asarray(Ps[j], np.float64), Ys[j], f"fit_shared_exact, period {j}")
75
+ out = np.empty(K)
76
+ for k in range(K):
77
+ cols = [np.asarray(P[:, k], np.float64) for P in Ps]
78
+ ys = [np.asarray(Y[:, k]).astype(bool) for Y in Ys]
79
+ cand = np.unique(np.concatenate(cols))[::-1] # distinct cutpoints, descending
80
+ npos = [int(y.sum()) for y in ys]
81
+ if sum(npos) == 0:
82
+ out[k] = float(_above(cand[0]))
83
+ continue
84
+ obj = np.zeros(len(cand))
85
+ for j in range(J):
86
+ if npos[j] == 0:
87
+ continue
88
+ a = np.sort(cols[j])
89
+ p = np.sort(cols[j][ys[j]])
90
+ cnt = len(a) - np.searchsorted(a, cand, "left") # predicted positives at score >= t
91
+ tp = npos[j] - np.searchsorted(p, cand, "left")
92
+ obj += w[j] * (2 * tp / (cnt + npos[j]))
93
+ out[k] = float(cand[int(np.argmax(obj))])
94
+ return out
95
+
96
+
97
+ def shared_objective(Ps, Ys, theta, w=None):
98
+ """sum_j w_j macroF1_j(theta), the quantity fit_shared_exact maximises label by label."""
99
+ J = len(Ps)
100
+ w = np.full(J, 1.0 / J) if w is None else np.asarray(w, float)
101
+ return float(sum(w[j] * macro_f1(np.asarray(Ps[j], np.float64), np.asarray(Ys[j]), theta) for j in range(J)))
102
+
103
+
104
+ def fit_grid(P, Y, grid=GRID):
105
+ """The coarse grid fitter (first index of the best grid value). This is how the
106
+ inherited theta_1 and the same-scorer validation rule were fitted in the paper."""
107
+ P = np.asarray(P, np.float64)
108
+ _check_PY(P, Y, "fit_grid")
109
+ Y = np.asarray(Y).astype(int)
110
+ th = np.empty(Y.shape[1])
111
+ for k in range(Y.shape[1]):
112
+ f = [macro_f1(P[:, [k]], Y[:, [k]], np.array([g])) for g in grid]
113
+ th[k] = grid[int(np.argmax(f))]
114
+ return th