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/twoscorer.py ADDED
@@ -0,0 +1,504 @@
1
+ """Two scorers on the same records at one carried rule, and the paired method contrast.
2
+
3
+ compare() takes a reference and a final scorer on the same labelled records and one carried
4
+ threshold vector; method_contrast() gives method a's lead over b, carried and refitted, paired
5
+ by seed. Every level comes from evaluate(), every component from decompose(), every interval
6
+ from inference.paired_contrast()."""
7
+ import warnings
8
+
9
+ import numpy as np
10
+
11
+ from . import inference
12
+ from .crossfit import IDENTITY_TOL, SPLITS, SPLIT_BASE, decompose, evaluate
13
+ from .fitters import fit_exact, macro_f1
14
+ from .records import align_to
15
+
16
+ TIE_CONVENTION = ("a record is predicted positive when score >= threshold; fitted cutpoints are score values, so "
17
+ "tied records are predicted together; when two cutpoints give the same F1 the own-period fitter "
18
+ "chooses the higher; the shared fitter maximises a float sum over periods, so two cutpoints whose "
19
+ "objectives are equal in exact arithmetic may differ in the last bit and either can be chosen")
20
+ ABSENT_LABEL_CONVENTION = ("a label with no positive in a period has F1 0 there whatever is predicted "
21
+ "(zero_division = 0) and still counts in the macro mean; its fitted cutpoint lies above "
22
+ "every score")
23
+ SPLIT_RULE = ("partition b of period id j, seed s: b-th permutation of numpy default_rng(split_base + 100*s + j); "
24
+ "half 0 is the first n_j // 2 records; every rule is fitted on one half and scored on the other, "
25
+ "in both directions")
26
+ SIGNS = dict(deficit="U_ref - U_final (positive: the final scorer is worse at the carried rule)",
27
+ attainable_gap="V_ref - V_final (positive: the final scorer is worse after its own refit)",
28
+ mismatch_change="R_final - R_ref, R_x = V_x - U_x (positive: the carried rule fits the final "
29
+ "scorer worse than it fits the reference)",
30
+ identity="deficit = attainable_gap + mismatch_change")
31
+
32
+
33
+ # ------------------------------------------------------------------ input handling ---
34
+ def _periods(x, what):
35
+ """One (n, K) array, or a list/tuple of them (one per period). Returns (list, single).
36
+ A list or tuple always means one matrix per period, so a single matrix written as a
37
+ nested Python list (rows of numbers) is refused rather than read as one period per row."""
38
+ if isinstance(x, (list, tuple)):
39
+ if len(x) == 0:
40
+ raise ValueError(f"{what}: empty list of periods")
41
+ arrs = [np.asarray(a) for a in x]
42
+ if any(a.ndim == 0 for a in arrs):
43
+ raise ValueError(f"{what}: a list of numbers, not a list of per-period matrices; pass one period as a "
44
+ "numpy array, or several as a list with one (n_j, K) matrix per period")
45
+ if all(a.ndim == 1 for a in arrs) and len({a.shape for a in arrs}) == 1:
46
+ raise ValueError(f"{what}: a nested list that reads as one {len(arrs)} x {arrs[0].shape[0]} matrix, "
47
+ "while a list means one matrix per period; pass np.asarray(...) for one period, or a "
48
+ "list with one 2-d (n_j, K) matrix per period (an (n_j, 1) matrix for one label)")
49
+ return arrs, False
50
+ return [np.asarray(x)], True
51
+
52
+
53
+ def _as_matrix(a, what):
54
+ a = np.asarray(a)
55
+ if a.ndim == 1:
56
+ a = a[:, None]
57
+ if a.ndim != 2:
58
+ raise ValueError(f"{what}: need a (records, labels) matrix, got shape {a.shape}")
59
+ return a
60
+
61
+
62
+ def _ids_per_period(ids, N, single, what):
63
+ if single:
64
+ return [list(ids)]
65
+ if not isinstance(ids, (list, tuple)) or len(ids) != N:
66
+ raise ValueError(f"{what}: one id sequence per period ({N} periods)")
67
+ return [list(x) for x in ids]
68
+
69
+
70
+ def _check_ids(ref_ids, ids, what, period):
71
+ if len(set(ids)) != len(ids):
72
+ dup = next(i for i in ids if ids.count(i) > 1)
73
+ raise ValueError(f"period {period}: duplicate record id in {what} (e.g. {dup!r})")
74
+ a, b = set(ref_ids), set(ids)
75
+ if a != b:
76
+ raise ValueError(f"period {period}: the record ids of {what} and of the labels differ "
77
+ f"({len(b - a)} only in {what}, {len(a - b)} only in the labels); "
78
+ "compare() needs the same records for both scorers")
79
+
80
+
81
+ def _column_order(names_lab, names, what):
82
+ names_lab, names = list(names_lab), list(names)
83
+ for nm, w in ((names_lab, "labels"), (names, what)):
84
+ if len(set(nm)) != len(nm):
85
+ raise ValueError(f"label_ids of {w} contain duplicates")
86
+ if set(names_lab) != set(names):
87
+ raise ValueError(f"label_ids of {what} and of the labels are different label sets")
88
+ pos = {n: i for i, n in enumerate(names)}
89
+ return np.array([pos[n] for n in names_lab], dtype=np.int64)
90
+
91
+
92
+ def _prepare(scores_ref, scores_final, labels, rule, record_ids, label_ids, calibration_mask, labels_final,
93
+ period_ids, score_scale=None):
94
+ P_ref, single = _periods(scores_ref, "scores_ref")
95
+ P_fin, s2 = _periods(scores_final, "scores_final")
96
+ Y, s3 = _periods(labels, "labels")
97
+ if not (single == s2 == s3) or not (len(P_ref) == len(P_fin) == len(Y)):
98
+ raise ValueError("scores_ref, scores_final and labels must all be one matrix, or all lists with one "
99
+ "matrix per period")
100
+ N = len(Y)
101
+ Yf = None if labels_final is None else _periods(labels_final, "labels_final")[0]
102
+ if Yf is not None and len(Yf) != N:
103
+ raise ValueError("labels_final: one matrix per period")
104
+ period_ids = list(range(N)) if period_ids is None else [int(p) for p in period_ids]
105
+ if len(period_ids) != N:
106
+ raise ValueError(f"period_ids: one id per period ({N})")
107
+ P_ref = [_as_matrix(a, "scores_ref") for a in P_ref]
108
+ P_fin = [_as_matrix(a, "scores_final") for a in P_fin]
109
+ Y = [_as_matrix(a, "labels") for a in Y]
110
+ Yf = None if Yf is None else [_as_matrix(a, "labels_final") for a in Yf]
111
+
112
+ # records: align the scorers (and labels_final, which travels with the final scorer) to the labels' ids
113
+ if record_ids is not None:
114
+ rid = record_ids if isinstance(record_ids, dict) else dict(labels=record_ids)
115
+ bad = set(rid) - {"ref", "final", "labels"}
116
+ if bad or "labels" not in rid:
117
+ raise ValueError("record_ids: one id sequence (shared by every input) or a dict with key 'labels' "
118
+ "and optional keys 'ref' and 'final'")
119
+ ids_lab = _ids_per_period(rid["labels"], N, single, "record_ids['labels']")
120
+ ids_ref = _ids_per_period(rid.get("ref", rid["labels"]), N, single, "record_ids['ref']")
121
+ ids_fin = _ids_per_period(rid.get("final", rid["labels"]), N, single, "record_ids['final']")
122
+ for c in range(N):
123
+ j = period_ids[c]
124
+ for ids, a, w in ((ids_lab[c], Y[c], "labels"), (ids_ref[c], P_ref[c], "scores_ref"),
125
+ (ids_fin[c], P_fin[c], "scores_final")):
126
+ if len(ids) != a.shape[0]:
127
+ raise ValueError(f"period {j}: {w} has {a.shape[0]} rows for {len(ids)} record ids")
128
+ _check_ids(ids_lab[c], ids_lab[c], "the labels", j)
129
+ _check_ids(ids_lab[c], ids_ref[c], "scores_ref", j)
130
+ _check_ids(ids_lab[c], ids_fin[c], "scores_final", j)
131
+ P_ref[c] = align_to(ids_lab[c], ids_ref[c], P_ref[c])
132
+ if Yf is not None:
133
+ P_fin[c], Yf[c] = align_to(ids_lab[c], ids_fin[c], P_fin[c], Yf[c])
134
+ else:
135
+ P_fin[c] = align_to(ids_lab[c], ids_fin[c], P_fin[c])
136
+
137
+ # labels (columns): reorder the scorers' columns, and a rule given by name, to the labels' column order
138
+ names = None
139
+ if label_ids is not None:
140
+ lid = label_ids if isinstance(label_ids, dict) else dict(labels=label_ids)
141
+ bad = set(lid) - {"ref", "final", "labels"}
142
+ if bad or "labels" not in lid:
143
+ raise ValueError("label_ids: one name sequence (shared by every input) or a dict with key 'labels' "
144
+ "and optional keys 'ref' and 'final'")
145
+ names = [x for x in lid["labels"]]
146
+ o_ref = _column_order(names, lid.get("ref", names), "scores_ref")
147
+ o_fin = _column_order(names, lid.get("final", names), "scores_final")
148
+ for c in range(N):
149
+ for a, w in ((Y[c], "labels"), (P_ref[c], "scores_ref"), (P_fin[c], "scores_final")):
150
+ if a.shape[1] != len(names):
151
+ raise ValueError(f"period {period_ids[c]}: {w} has {a.shape[1]} columns for {len(names)} label ids")
152
+ P_ref[c], P_fin[c] = P_ref[c][:, o_ref], P_fin[c][:, o_fin]
153
+ if Yf is not None:
154
+ Yf[c] = Yf[c][:, o_fin]
155
+ if isinstance(rule, dict):
156
+ if names is None:
157
+ raise ValueError("a rule given as {label: threshold} needs label_ids")
158
+ if set(rule) != set(names):
159
+ raise ValueError("the rule's labels and the labels' label_ids are different label sets")
160
+ rule = np.array([rule[n] for n in names], np.float64)
161
+ rule = np.asarray(rule, np.float64).reshape(-1)
162
+
163
+ # shapes, labels, scores
164
+ K = Y[0].shape[1]
165
+ for c in range(N):
166
+ j = period_ids[c]
167
+ for a, w in ((P_ref[c], "scores_ref"), (P_fin[c], "scores_final")):
168
+ if a.shape != Y[c].shape:
169
+ raise ValueError(f"period {j}: {w} has shape {a.shape}, the labels {Y[c].shape}")
170
+ if Y[c].shape[1] != K:
171
+ raise ValueError(f"period {j}: {Y[c].shape[1]} labels, period {period_ids[0]} has {K}")
172
+ if not np.isin(Y[c], (0, 1)).all():
173
+ raise ValueError(f"period {j}: labels must be 0/1 (remove unlabelled records with drop_unlabelled)")
174
+ if Yf is not None:
175
+ if Yf[c].shape != Y[c].shape:
176
+ raise ValueError(f"period {j}: labels_final has shape {Yf[c].shape}, the labels {Y[c].shape}")
177
+ ndiff = int((np.asarray(Yf[c]) != np.asarray(Y[c])).sum())
178
+ if ndiff:
179
+ raise ValueError(f"period {j}: the two label matrices differ in {ndiff} cells after alignment; "
180
+ "both scorers must be evaluated against the same labels")
181
+ if rule.shape != (K,):
182
+ raise ValueError(f"rule: one threshold per label ({K}), got {rule.shape[0]}")
183
+ if not np.all(np.isfinite(rule)):
184
+ raise ValueError("rule: non-finite threshold")
185
+
186
+ # calibration mask: which records' labels the evaluation may use (in the labels' record order)
187
+ n_all = [int(y.shape[0]) for y in Y]
188
+ if calibration_mask is not None:
189
+ masks = [np.asarray(m) for m in (calibration_mask if not single else [calibration_mask])]
190
+ if len(masks) != N:
191
+ raise ValueError("calibration_mask: one boolean vector per period")
192
+ for c in range(N):
193
+ m = masks[c]
194
+ if m.dtype != bool or m.shape != (n_all[c],):
195
+ raise ValueError(f"period {period_ids[c]}: calibration_mask must be a boolean vector of "
196
+ f"length {n_all[c]} (the labels' records, in the labels' order)")
197
+ P_ref[c], P_fin[c], Y[c] = P_ref[c][m], P_fin[c][m], Y[c][m]
198
+
199
+ scale = "probability"
200
+ for c in range(N):
201
+ j = period_ids[c]
202
+ if Y[c].shape[0] < 2:
203
+ raise ValueError(f"period {j}: needs at least 2 labelled records to split in half")
204
+ for a, w in ((P_ref[c], "scores_ref"), (P_fin[c], "scores_final")):
205
+ if not np.all(np.isfinite(a)):
206
+ raise ValueError(f"period {j}: {w} has non-finite scores")
207
+ outside = [w for c in range(N) for a, w in ((P_ref[c], "scores_ref"), (P_fin[c], "scores_final"))
208
+ if a.size and (a.min() < 0 or a.max() > 1)]
209
+ if score_scale not in (None, "other"):
210
+ raise ValueError("score_scale: None (probability scale expected) or 'other'")
211
+ rule_out = bool(np.any((rule < 0) | (rule > 1)))
212
+ if outside or rule_out or score_scale == "other":
213
+ if score_scale is None and not rule_out:
214
+ raise ValueError(f"{sorted(set(outside))[0]} has scores outside [0, 1] while the rule lies in [0, 1]: "
215
+ "the scores and the carried rule look like different scales (for example logits "
216
+ "against a probability threshold). Map the scores onto the rule's scale first, "
217
+ "or pass score_scale='other' if both are on one other scale.")
218
+ if score_scale is None:
219
+ what = ("the rule lies outside [0, 1] while every score lies in [0, 1]: the scores and the carried rule "
220
+ "look like different scales (for example a logit threshold against probabilities)" if not outside
221
+ else "the scores and the rule lie outside [0, 1], so they are not on the probability scale")
222
+ raise ValueError(f"{what}. Map them onto one scale first, or pass score_scale='other' if both are on one "
223
+ "other scale.")
224
+ scale = "other"
225
+ warnings.warn("scores or rule not on the probability scale; the levels are valid only if the scores and "
226
+ "the carried rule are on the same scale", UserWarning, stacklevel=3)
227
+ return P_ref, P_fin, [y.astype(int) for y in Y], rule, names, period_ids, single, n_all, scale
228
+
229
+
230
+ # ------------------------------------------------------------------ compare ---
231
+ def _components(d):
232
+ out = dict(deficit=d["D"], attainable_gap=d["A"], mismatch_change=d["R"], mismatch_ref=d["R_ref"],
233
+ mismatch_final=d["R_final"])
234
+ if d.get("shared_terms") == "reported":
235
+ out.update(attainable_gap_shared=d["A_shared"], shared_cost_change=d["dC"], shared_mismatch_change=d["dM"],
236
+ shared_cost_ref=d["C_ref"], shared_cost_final=d["C_final"], shared_mismatch_ref=d["M_ref"],
237
+ shared_mismatch_final=d["M_final"])
238
+ return out
239
+
240
+
241
+ def _block(d, sr, sf, rule_name):
242
+ out = dict(carried=dict(ref=d["U_ref"], final=d["U_final"], convention=d["U"],
243
+ ref_half=sr["rules"][rule_name]["U_half"], final_half=sf["rules"][rule_name]["U_half"],
244
+ ref_full=sr["rules"][rule_name]["U_full"], final_full=sf["rules"][rule_name]["U_full"]),
245
+ refitted=dict(ref=d["V_ref"], final=d["V_final"]),
246
+ components=_components(d))
247
+ if sr["V_shared"] is not None and sf["V_shared"] is not None:
248
+ out["refitted_shared"] = dict(ref=sr["V_shared"], final=sf["V_shared"])
249
+ return out
250
+
251
+
252
+ def compare(scores_ref, scores_final, labels, rule, *, record_ids=None, label_ids=None, seed=None, splits=SPLITS,
253
+ calibration_mask=None, labels_final=None, period_ids=None, halves=False, rule_name="carried",
254
+ score_scale=None, fixed_split=None, fixed_split_by=None):
255
+ """Two (n, K) score matrices, (n, K) 0/1 labels and a (K,) carried rule (lists for several
256
+ periods) -> dict(carried, refitted, components, per_period, metadata, decomposition,
257
+ evaluations): both scorers' carried and refitted macro-F1, and deficit = attainable gap +
258
+ change in mismatch.
259
+
260
+ record_ids / label_ids: one sequence (inputs already in that order) or a dict of each
261
+ input's own ids, which are aligned to the labels; duplicates or mismatched sets raise.
262
+ seed keys the partitions (required unless fixed_split). splits: partitions per period.
263
+ calibration_mask: records whose labels may be used. labels_final: the labels exported with
264
+ the final scorer, checked cell by cell. halves: carried level on held-out halves.
265
+ score_scale=None wants scores and rule in [0, 1]; "other" declares another common scale.
266
+ fixed_split={"calibration": c, "evaluation": e} (masks, positions or ids; fixed_split_by
267
+ says which) swaps the cross-fitted estimator for one fixed split. Labels with no positive
268
+ take F1 0 and stay in the macro mean."""
269
+ from . import __version__
270
+ P_ref, P_fin, Y, rule, names, period_ids, single, n_all, scale = _prepare(
271
+ scores_ref, scores_final, labels, rule, record_ids, label_ids, calibration_mask, labels_final, period_ids,
272
+ score_scale)
273
+ if fixed_split is not None:
274
+ return _compare_fixed(P_ref, P_fin, Y, rule, names, period_ids, single, n_all, scale, record_ids,
275
+ calibration_mask, fixed_split, rule_name, __version__, fixed_split_by)
276
+ if fixed_split_by is not None:
277
+ raise ValueError("fixed_split_by applies only with fixed_split")
278
+ if seed is None:
279
+ raise ValueError("seed is required for the cross-fitted estimator (it keys the partitions)")
280
+ rules = {rule_name: rule}
281
+ ev_ref = evaluate(P_ref, Y, seed, period_ids, rules=rules, splits=splits)
282
+ ev_fin = evaluate(P_fin, Y, seed, period_ids, rules=rules, splits=splits)
283
+ d = decompose(ev_ref, ev_fin, rule_name, halves=halves)
284
+ out = _block(d, ev_ref.summary(), ev_fin.summary(), rule_name)
285
+ out["per_period"] = {}
286
+ for j in period_ids:
287
+ dj = decompose(ev_ref, ev_fin, rule_name, periods=[j], halves=halves)
288
+ out["per_period"][j] = _block(dj, ev_ref.summary([j]), ev_fin.summary([j]), rule_name)
289
+ K = Y[0].shape[1]
290
+ lab = (lambda k: names[k]) if names is not None else (lambda k: k)
291
+ absent = {j: [lab(k) for k in range(K) if Y[c][:, k].sum() == 0] for c, j in enumerate(period_ids)}
292
+ budget = {j: dict(records=int(len(Y[c])), records_before_mask=n_all[c],
293
+ fit_records_per_direction=[int(len(Y[c]) // 2), int(len(Y[c]) - len(Y[c]) // 2)],
294
+ positives_per_label=[int(v) for v in Y[c].sum(0)])
295
+ for c, j in enumerate(period_ids)}
296
+ out["metadata"] = dict(
297
+ rule_name=rule_name, rule=rule.tolist(), label_ids=names, n_labels=K, period_ids=period_ids,
298
+ single_period=single, seed=int(seed), splits=int(splits), split_base=SPLIT_BASE, split_rule=SPLIT_RULE,
299
+ carried_level=d["U"], label_budget=budget,
300
+ label_budget_note=("labelled records each refit is fitted on, per period and direction (half 0 then half 1); "
301
+ "the shared rule pools the fitting halves of all periods"),
302
+ calibration_mask_used=calibration_mask is not None, ties=TIE_CONVENTION,
303
+ absent_labels=ABSENT_LABEL_CONVENTION, labels_without_positive={j: v for j, v in absent.items() if v},
304
+ periods_without_positive=[j for c, j in enumerate(period_ids) if Y[c].sum() == 0],
305
+ score_scale=scale, signs=SIGNS, shared_terms=d["shared_terms"],
306
+ estimator="crossfit.evaluate on each scorer, crossfit.decompose on the pair (split-half cross-fitting)",
307
+ package_version=__version__)
308
+ out["decomposition"] = d
309
+ out["evaluations"] = dict(ref=ev_ref, final=ev_fin)
310
+ return out
311
+
312
+
313
+ # ------------------------------------------------------------------ fixed calibration split ---
314
+ FIXED_SPLIT_LABEL = "fixed calibration split, not the paper's cross-fitted estimator"
315
+
316
+
317
+ FIXED_SPLIT_BY = (None, "ids", "positions")
318
+
319
+
320
+ def _is_int(x):
321
+ return isinstance(x, (int, np.integer)) and not isinstance(x, (bool, np.bool_))
322
+
323
+
324
+ def _positions(sel, n, ids, what, j, by=None):
325
+ """One fixed_split set -> row positions. Masks are masks; otherwise `by` ("positions" or
326
+ "ids", or a guess when None). Values valid both ways that select different rows raise."""
327
+ sel = np.asarray(sel)
328
+ if sel.dtype == bool:
329
+ if sel.shape != (n,):
330
+ raise ValueError(f"period {j}: fixed_split['{what}'] mask must have length {n}")
331
+ return np.flatnonzero(sel)
332
+ if by is None:
333
+ if ids is None or (np.issubdtype(sel.dtype, np.integer) and not all(_is_int(r) for r in ids)):
334
+ by = "positions"
335
+ elif not np.issubdtype(sel.dtype, np.integer):
336
+ by = "ids"
337
+ else:
338
+ pos = {r: i for i, r in enumerate(ids)}
339
+ vals = sel.reshape(-1).tolist()
340
+ if all(v in pos for v in vals) and all(0 <= v < n for v in vals) and any(pos[v] != v for v in vals):
341
+ raise ValueError(f"period {j}: fixed_split['{what}'] holds integers that are valid record ids and "
342
+ "valid row positions, and the two readings select different records; pass "
343
+ "fixed_split_by='ids' or fixed_split_by='positions'")
344
+ by = "ids"
345
+ if by == "ids" and ids is None:
346
+ raise ValueError("fixed_split_by='ids' needs record_ids")
347
+ if by == "ids":
348
+ pos = {r: i for i, r in enumerate(ids)}
349
+ miss = [r for r in sel.tolist() if r not in pos]
350
+ if miss:
351
+ raise ValueError(f"period {j}: fixed_split['{what}'] has unknown record id {miss[0]!r}")
352
+ out = np.array([pos[r] for r in sel.tolist()], dtype=np.int64)
353
+ else:
354
+ out = sel.astype(np.int64).reshape(-1)
355
+ if out.size and (out.min() < 0 or out.max() >= n):
356
+ raise ValueError(f"period {j}: fixed_split['{what}'] positions outside 0..{n - 1}")
357
+ if len(set(out.tolist())) != len(out):
358
+ raise ValueError(f"period {j}: fixed_split['{what}'] lists a record twice")
359
+ return out
360
+
361
+
362
+ def _compare_fixed(P_ref, P_fin, Y, rule, names, period_ids, single, n_all, scale, record_ids, calibration_mask,
363
+ fixed_split, rule_name, version, fixed_split_by=None):
364
+ if fixed_split_by not in FIXED_SPLIT_BY:
365
+ raise ValueError("fixed_split_by: None, 'ids' or 'positions'")
366
+ if calibration_mask is not None:
367
+ raise ValueError("pass either calibration_mask or fixed_split, not both")
368
+ if not isinstance(fixed_split, dict) or set(fixed_split) != {"calibration", "evaluation"}:
369
+ raise ValueError("fixed_split: {'calibration': ..., 'evaluation': ...}")
370
+ N = len(Y)
371
+ cal, ev = fixed_split["calibration"], fixed_split["evaluation"]
372
+ cal, ev = ([cal], [ev]) if single else (list(cal), list(ev))
373
+ if len(cal) != N or len(ev) != N:
374
+ raise ValueError(f"fixed_split: one calibration and one evaluation set per period ({N})")
375
+ ids = None
376
+ if record_ids is not None:
377
+ lab = record_ids["labels"] if isinstance(record_ids, dict) else record_ids
378
+ ids = [list(lab)] if single else [list(x) for x in lab]
379
+ per, budget = {}, {}
380
+ for c, j in enumerate(period_ids):
381
+ n = len(Y[c])
382
+ ic = _positions(cal[c], n, None if ids is None else ids[c], "calibration", j, fixed_split_by)
383
+ ie = _positions(ev[c], n, None if ids is None else ids[c], "evaluation", j, fixed_split_by)
384
+ both = np.intersect1d(ic, ie)
385
+ if both.size:
386
+ r = both[0] if ids is None else ids[c][both[0]]
387
+ raise ValueError(f"period {j}: {both.size} record(s) in both the calibration and the evaluation set "
388
+ f"(e.g. {r!r}); the two sets must be disjoint")
389
+ if ic.size == 0 or ie.size == 0:
390
+ raise ValueError(f"period {j}: the calibration and the evaluation set must both be non-empty")
391
+ lv = {}
392
+ for side, P in (("ref", P_ref[c]), ("final", P_fin[c])):
393
+ th = fit_exact(P[ic], Y[c][ic])
394
+ lv["V_" + side] = macro_f1(P[ie], Y[c][ie], th)
395
+ lv["U_" + side] = macro_f1(P[ie], Y[c][ie], rule)
396
+ per[j] = lv
397
+ budget[j] = dict(records=n, calibration_records=int(ic.size), evaluation_records=int(ie.size),
398
+ unused_records=int(n - ic.size - ie.size),
399
+ calibration_positives_per_label=[int(v) for v in Y[c][ic].sum(0)])
400
+
401
+ def block(levels):
402
+ Ur, Uf, Vr, Vf = levels["U_ref"], levels["U_final"], levels["V_ref"], levels["V_final"]
403
+ D, A = Ur - Uf, Vr - Vf
404
+ Rr, Rf = Vr - Ur, Vf - Uf
405
+ comp = dict(deficit=D, attainable_gap=A, mismatch_change=Rf - Rr, mismatch_ref=Rr, mismatch_final=Rf)
406
+ res = abs(D - (A + comp["mismatch_change"]))
407
+ if res > IDENTITY_TOL:
408
+ raise AssertionError(f"deficit != attainable gap + change in mismatch: {res:.3g}")
409
+ return dict(carried=dict(ref=Ur, final=Uf, convention="U on the evaluation records"),
410
+ refitted=dict(ref=Vr, final=Vf, convention=FIXED_SPLIT_LABEL), components=comp)
411
+
412
+ mean = {k: float(np.mean([per[j][k] for j in period_ids])) for k in per[period_ids[0]]}
413
+ out = block(mean)
414
+ out["per_period"] = {j: block(per[j]) for j in period_ids}
415
+ K = Y[0].shape[1]
416
+ lab_ = (lambda k: names[k]) if names is not None else (lambda k: k)
417
+ out["metadata"] = dict(
418
+ rule_name=rule_name, rule=rule.tolist(), label_ids=names, n_labels=K, period_ids=period_ids,
419
+ single_period=single, seed=None, splits=None, carried_level="U on the evaluation records",
420
+ label_budget=budget, label_budget_note="labelled records the refit is fitted on (calibration), per period",
421
+ ties=TIE_CONVENTION, absent_labels=ABSENT_LABEL_CONVENTION,
422
+ labels_without_positive={j: v for j, v in
423
+ {j: [lab_(k) for k in range(K) if Y[c][:, k].sum() == 0]
424
+ for c, j in enumerate(period_ids)}.items() if v},
425
+ score_scale=scale, signs=SIGNS, shared_terms="not computed under a fixed calibration split",
426
+ estimator=FIXED_SPLIT_LABEL + " (fitters.fit_exact on the calibration records, macro_f1 on the evaluation "
427
+ "records; periods averaged uniformly)",
428
+ package_version=version)
429
+ out["decomposition"] = None
430
+ out["evaluations"] = None
431
+ return out
432
+
433
+
434
+ # ------------------------------------------------------------------ method contrast ---
435
+ def _per_partition(ev, key, rule, cols, w):
436
+ X = ev.sep if key == "V_sep" else ev.U_half[rule]
437
+ H = 0.5 * (X[:, 0, cols] + X[:, 1, cols])
438
+ return H @ w
439
+
440
+
441
+ TERMS = ("lead_at_carried_rule", "lead_after_refit", "placement")
442
+
443
+
444
+ def method_contrast(evals_a, evals_b, rule, *, periods=None, weights=None, halves=False, names=("a", "b"),
445
+ seeds=None, same_rule=True):
446
+ """{seed: Evaluation} for methods a and b, and the carried rule's name -> leads of a over b
447
+ at the carried rule (G_U), after both refit (G_V), and the change after refitting
448
+ (change = G_V - G_U, as in the paper), each with mean, 95 % t interval over seeds and
449
+ unadjusted class. Positive means a is ahead. "placement" (G_U - G_V) is the negative of
450
+ the change, kept for compatibility. The spread over
451
+ partitions within each seed is reported apart and never added to the seed interval.
452
+ same_rule=False lets each method carry its own vector under that name."""
453
+ if set(evals_a) != set(evals_b) and seeds is None:
454
+ raise ValueError(f"the two methods hold different seeds ({sorted(evals_a)} against {sorted(evals_b)})")
455
+ seeds = sorted(evals_a) if seeds is None else sorted(int(s) for s in seeds)
456
+ per_seed, vals, spread = {}, {t: {} for t in TERMS}, {t: {} for t in TERMS}
457
+ for s in seeds:
458
+ a, b = evals_a[s], evals_b[s]
459
+ d = decompose(a, b, rule, periods=periods, weights=weights, halves=halves, same_rule=same_rule)
460
+ per_seed[s] = d
461
+ vals["lead_at_carried_rule"][s], vals["lead_after_refit"][s], vals["placement"][s] = d["D"], d["A"], d["R"]
462
+ P, cols = a._cols(periods)
463
+ ww = weights
464
+ if ww is None and a.shared_weights is not None and P == list(a.shared_periods):
465
+ ww = a.shared_weights # summary()'s default on the shared periods
466
+ w = np.full(len(cols), 1.0 / len(cols)) if ww is None else np.asarray(ww, float) / np.sum(ww)
467
+ u = _per_partition(a, "U", rule, cols, w) - _per_partition(b, "U", rule, cols, w)
468
+ v = _per_partition(a, "V_sep", rule, cols, w) - _per_partition(b, "V_sep", rule, cols, w)
469
+ sd = lambda x: float(np.std(x, ddof=1)) if len(x) > 1 else None
470
+ spread["lead_at_carried_rule"][s], spread["lead_after_refit"][s], spread["placement"][s] = (
471
+ sd(u),
472
+ sd(v),
473
+ sd(u - v),
474
+ )
475
+ U_a = {s: per_seed[s]["U_ref"] for s in seeds}
476
+ U_b = {s: per_seed[s]["U_final"] for s in seeds}
477
+ V_a = {s: per_seed[s]["V_ref"] for s in seeds}
478
+ V_b = {s: per_seed[s]["V_final"] for s in seeds}
479
+ terms = dict(lead_at_carried_rule=inference.paired_contrast(U_a, U_b, seeds=seeds),
480
+ lead_after_refit=inference.paired_contrast(V_a, V_b, seeds=seeds),
481
+ # the paired difference of the two leads, seed by seed
482
+ placement=inference.paired_contrast(vals["lead_at_carried_rule"], vals["lead_after_refit"],
483
+ seeds=seeds))
484
+ for t in TERMS:
485
+ terms[t]["per_seed"] = {s: vals[t][s] for s in seeds}
486
+ # the change after refitting, refitted lead minus carried lead (the negative of placement)
487
+ vals["change"] = {s: vals["lead_after_refit"][s] - vals["lead_at_carried_rule"][s] for s in seeds}
488
+ terms["change"] = inference.paired_contrast(vals["lead_after_refit"], vals["lead_at_carried_rule"], seeds=seeds)
489
+ terms["change"]["per_seed"] = dict(vals["change"])
490
+ spread["change"] = dict(spread["placement"])
491
+ m_rule = terms["lead_at_carried_rule"]["mean"]
492
+ return dict(direction=f"{names[0]} minus {names[1]} (positive: {names[0]} scores higher)", names=list(names),
493
+ rule=rule, same_rule_per_seed={s: per_seed[s]["same_rule"] for s in seeds},
494
+ periods=per_seed[seeds[0]]["periods"], carried_level=per_seed[seeds[0]]["U"],
495
+ seeds=seeds, terms=terms,
496
+ placement_share=(terms["placement"]["mean"] / m_rule if m_rule != 0 else None),
497
+ training_run_uncertainty=("seed (training run): one value per seed, paired across methods; mean, "
498
+ "marginal 95% t interval over seeds, unadjusted class"),
499
+ record_split_variation=dict(
500
+ values=spread,
501
+ unit=("partition (record split) within one run: SD over the partitions of the per-partition "
502
+ "value, carried level on the held-out halves; not a standard error of record sampling "
503
+ "and not combined with the seed interval")),
504
+ per_seed=per_seed)
@@ -0,0 +1,128 @@
1
+ Metadata-Version: 2.4
2
+ Name: refitgap
3
+ Version: 0.1.0
4
+ Summary: Split a continual learner's thresholded forgetting into what the scores lost and what the carried rule lost, from stored scores.
5
+ Author-email: Hisham Ihshaish <hisham.ihshaish@uwe.ac.uk>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/ihshaish/refitgap
8
+ Project-URL: Repository, https://github.com/ihshaish/refitgap
9
+ Project-URL: Issues, https://github.com/ihshaish/refitgap/issues
10
+ Keywords: continual learning,multi-label classification,backward transfer,thresholds,evaluation
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3 :: Only
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
21
+ Classifier: Topic :: Scientific/Engineering :: Information Analysis
22
+ Requires-Python: >=3.10
23
+ Description-Content-Type: text/plain
24
+ License-File: LICENSE
25
+ Requires-Dist: numpy>=1.24
26
+ Requires-Dist: scipy>=1.10
27
+ Provides-Extra: plots
28
+ Requires-Dist: matplotlib>=3.5; extra == "plots"
29
+ Provides-Extra: test
30
+ Requires-Dist: pytest>=7.0; extra == "test"
31
+ Requires-Dist: scikit-learn>=1.3; extra == "test"
32
+ Dynamic: license-file
33
+
34
+ refitgap
35
+ ========
36
+
37
+ Train a multi-label classifier period by period, score the final model on an old period, and
38
+ its macro-F1 there drops. Part of the drop sits in the scores, which now separate the labels
39
+ less well, and part in the thresholds, carried from period one and now in the wrong place.
40
+ refitgap says how much of each, from stored scores alone:
41
+
42
+ deficit B = attainable gap A + change in mismatch dR
43
+
44
+ The attainable gap compares the two models after each refits its thresholds on equal terms
45
+ (split-half cross-fitting, same partitions); the change in mismatch is what each loses by
46
+ keeping the carried rule. Same trick for two methods: replay's lead over sequential training
47
+ at the inherited thresholds, after both refit, and the change. This is the code of
48
+ "Decomposing Backward Transfer in Continual Multi-Label Text Classification" (Ihshaish,
49
+ Mayhew and Aydin, under review), with its plans, stored results and scripts.
50
+
51
+ Install
52
+ -------
53
+
54
+ pip install refitgap # Python 3.10+, numpy and scipy, nothing else
55
+ pip install "refitgap[plots,test]" # matplotlib for figures, pytest etc. for the tests
56
+
57
+ (or pip install . from the clone). The entry points below read files in this repository, so
58
+ clone it too. On the command line, refitgap evaluate | report | example runs the same
59
+ evaluator on stored arrays (refitgap --help; API.txt, "Command line").
60
+
61
+ Reproduce Figure 2
62
+ ------------------
63
+
64
+ python examples/figure2/reproduce_figure2.py # a few minutes on 4 cores
65
+ python examples/figure2/reproduce_figure2.py --from-levels # about a second
66
+
67
+ All five paired comparisons of Figure 2 (supplement Table C.1) over periods 2 to 7: lead at
68
+ the inherited thresholds, after both refit, and the change. Paired t intervals over six
69
+ DistilBERT pairs (from stored per-pair levels: their 30 score files, about 60 MB, do not ship),
70
+ percentile bootstrap over ten BiGRU pairs (from raw scores in paper/results/, or stored levels
71
+ with --from-levels). Every lead, mean and bound must match examples/figure2/expected.json to
72
+ 1e-10.
73
+
74
+ python examples/two_scorer/table1.py
75
+
76
+ Table 1: two scorers with equal AUROC (6/11), average precision (5/18) and deficit (1/26),
77
+ one all change in mismatch, the other all attainable gap. Exact fractions, no luck involved!
78
+
79
+ Compare your own models
80
+ -----------------------
81
+
82
+ python examples/your_models/compare_your_models.py [DATA_DIR] --a mine --b baseline
83
+
84
+ DATA_DIR holds three kinds of CSV (examples/your_models/data/ is a tiny synthetic set):
85
+
86
+ labels.csv period, record_id, one 0/1 column per label
87
+ scores_<method>_seed<k>.csv period, record_id, one score column per label (the final
88
+ model of <method>, trained under seed k)
89
+ thresholds.csv method, seed, the carried rule (one threshold per label)
90
+
91
+ Out come a's leads over b at the carried rule and after both refit, and the change (refitted
92
+ lead minus carried lead), per seed and with paired t intervals. In Python: refitgap.compare()
93
+ and method_contrast(); API.txt has the lot.
94
+
95
+ Interface
96
+ ---------
97
+
98
+ In: aligned scores and labels, the carried rule, and a seed for the common partitions.
99
+ - Scores: one (records, labels) array per period and model, probabilities in [0, 1]; logits go
100
+ through a sigmoid first, or declare score_scale="other" with thresholds on the same scale.
101
+ Labels: 0/1, same shape.
102
+ - Alignment: rows match by record id, columns by label name. A shape mismatch, a duplicate or
103
+ missing id, a different label set, or different labels for the two models raises. Nothing
104
+ is truncated or dropped quietly.
105
+ - Ties: a score equal to its threshold is a positive. A refitted threshold is an observed
106
+ score; of two equally good cutpoints the higher wins.
107
+ - A label with no positive in a period has F1 0 there and stays in the macro mean.
108
+ - Partitions: partition b of period id j under seed s is the b-th permutation of numpy
109
+ default_rng(7000 + 100*s + j); first half fits, second scores, then the reverse. 200 in the
110
+ paper. Both models of a pair share them, so give both the same seed and period ids. Period
111
+ ids are integers in [0, 100); the paper's periods 2 to 7 are ids 1 to 6.
112
+ - Out: carried level U, refitted level V, deficit, attainable gap and change in mismatch, per
113
+ period and averaged; for two methods, leads per seed with means and 95% intervals. Positive
114
+ means the first-named model scores higher. Numbers only: refitgap recommends nothing, and
115
+ the attainable gap describes the scores, not what the representation kept.
116
+
117
+ Repository layout
118
+ -----------------
119
+
120
+ src/refitgap/ the evaluator; src/refitgap/API.txt is the reference
121
+ examples/ Table 1, Figure 2, your own models, walkthrough.ipynb (seconds, the lot)
122
+ paper/ plans, scripts, stored results, checks; README.txt maps tables to scripts,
123
+ DATA.txt the corpora (public, not redistributed, bar a 3200-record ASRS sample)
124
+ paper/threshold_policy_survey.txt how 34 sampled studies report their thresholds
125
+ tests/ python -m pytest (fast, a minute or two); -m slow adds the full Figure 2
126
+ recomputation from raw scores (minutes)
127
+
128
+ MIT licence. Please cite the paper and the software (CITATION.cff has both). Have fun with it!
@@ -0,0 +1,22 @@
1
+ refitgap/API.txt,sha256=29L4hrWvbfQaRfAzzvfvQIWakdFIvyne61Sgz-Ejp4A,9013
2
+ refitgap/__init__.py,sha256=wM4y6Q8u676qK_ml5gmp_hcHIn0xjpt5bi4vFzH7_gI,1898
3
+ refitgap/__main__.py,sha256=XRlT-v5jT-bYKqtjP2qc3yS7Q7xtPQ9oXMUcv-f_qiA,109
4
+ refitgap/analysis.py,sha256=qDyyg3wol7fabBgImxwQyujnyglGdDxNOikG77vWYDI,22542
5
+ refitgap/cli.py,sha256=WON-IjeaHOv77-VB8ELIdKxXIzqj7_jj_N-yKudXWOU,8982
6
+ refitgap/crossfit.py,sha256=H1z0IE3m8Q6mdaBrYBKNDuJyd3gRYZo1DpVqgq4DuR8,15603
7
+ refitgap/example.py,sha256=YVY1FRFjZD9f9Bt71H74Ccan2Ne8sVIYeYAD8Zn7KWw,5543
8
+ refitgap/fitters.py,sha256=lnO3EtYhkWQKYG3JkBguI_q9xX6jqdF_neUxJPy7f78,4859
9
+ refitgap/identification.py,sha256=hbPIAk9gMRAX5RJZB0Rvx2Nx3Q6uwBCETS3pmsvoI6s,4656
10
+ refitgap/inference.py,sha256=j5Wy9F1sqFrFcd9nEfk2HWFZLiCmIHQQTnqt34ZB1mI,13017
11
+ refitgap/plots.py,sha256=WgJ1FTI5iq2pb575pLWL0NU9CL_kbmWtJAnJhMYW67Q,10342
12
+ refitgap/records.py,sha256=j-9hLmYLHWJkQmtO0Y7bjJVOP5sXPwNs6LX4DU-grQ4,1736
13
+ refitgap/report.py,sha256=-CYbz8kJqrZqHQxqAzO7Jwt8l9cb8xBk2ug1JYo5aeU,7779
14
+ refitgap/runs.py,sha256=eAYMlRAc7sXLEEOZ8oVb50qBSVhwmoDEJaVu7A8IPqA,2574
15
+ refitgap/tiny.py,sha256=ulnnjq7enQ26palcmFudeqzWTA3gCe0ONfnWHwHx1I8,3094
16
+ refitgap/twoscorer.py,sha256=fy8V9E94u38MudKP9c1-sZ6sdnpayh3wI2suQKT8lJA,30205
17
+ refitgap-0.1.0.dist-info/licenses/LICENSE,sha256=B-C9c9N9lYv8ici8_SmsMRawSBawjXFGa8jOz8HuGHs,1072
18
+ refitgap-0.1.0.dist-info/METADATA,sha256=duMlRZohkab3Kg98ZsEF3_Ta1W_O7il8drLISSKUAt4,6792
19
+ refitgap-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
20
+ refitgap-0.1.0.dist-info/entry_points.txt,sha256=PAR24gjBRksWXkQ61Y_PouV5G1ygjjdrTONsbnNLpTA,47
21
+ refitgap-0.1.0.dist-info/top_level.txt,sha256=sgW0kITfI4B4AnoPF5g9O3IqvGaCqSyhKYmmaWDcOJg,9
22
+ refitgap-0.1.0.dist-info/RECORD,,