refitgap 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
refitgap/API.txt ADDED
@@ -0,0 +1,150 @@
1
+ refitgap API
2
+ ============
3
+
4
+ Stored per-period scores and labels in; cross-fitted macro-F1 levels, the split of a
5
+ thresholded deficit into attainable gap and change in mismatch, and seed-paired contrasts
6
+ out. numpy and scipy only; figures want matplotlib (pip install "refitgap[plots]").
7
+
8
+ The estimator
9
+ -------------
10
+
11
+ For period id j and seed s, partition b is the b-th permutation of
12
+ numpy.random.default_rng(7000 + 100*s + j); half 0 is the first n_j // 2 records. A refit
13
+ picks, per label, the F1-maximising cutpoint on one half and is scored on the other, both
14
+ ways round; a level is the mean over the partitions (200 in the paper). With U a model's
15
+ macro-F1 at the carried rule theta_1 and V its refitted (attainable) level,
16
+
17
+ B = U_ref - U_final = (V_ref - V_final) + (R_final - R_ref) = A + dR, R = V - U
18
+
19
+ U is taken on the whole period by default (U_full), or on the held-out halves (U_half).
20
+ For one rule shared across periods, R = C + M with C = V_sep - V_shared and
21
+ M = V_shared - U, exact per partition. A score equal to its threshold is a positive; a label
22
+ with no positive and no prediction has F1 0 and stays in the macro mean. Period ids are ints
23
+ in [0, 100) and seeds non-negative ints, so no two (seed, period) pairs share partitions.
24
+
25
+ Outcome rules
26
+ -------------
27
+
28
+ A contrast is a per-seed vector (method a minus method b, paired by seed). At margin 0.005:
29
+ - equivalent: TOST p < 0.05 (Holm-adjusted within a family);
30
+ - material: t p < 0.05 and |mean| >= margin;
31
+ - small non-zero: t p < 0.05 and |mean| < margin;
32
+ - unresolved: anything else, or a material / small non-zero call that the sign-flip test
33
+ (exact up to 20 seeds, Monte Carlo above) does not back;
34
+ - non-inferior / inferior (family_ni): one-sided t and sign-flip tests at -margin.
35
+ Intervals are marginal 95 % t intervals over seeds. Rows say "descriptive" unless you name
36
+ a family fixed in advance (--family NAME); the class words then describe your numbers under
37
+ the paper's rules, nothing grander.
38
+
39
+ Functions
40
+ ---------
41
+
42
+ compare(scores_ref, scores_final, labels, rule, *, seed, splits=200, record_ids=None,
43
+ label_ids=None, period_ids=None, labels_final=None, rule_name="carried", halves=False,
44
+ score_scale=None, fixed_split=None, ...)
45
+ Two scorers on the same labelled records, (n, K) each (lists for several periods), and
46
+ the carried rule `rule`, one threshold per label -> dict(carried, refitted, components,
47
+ per_period, metadata, decomposition, evaluations). rule_name is the name the vector goes by in the
48
+ returned Evaluations (evaluations["ref"], evaluations["final"]), which is what
49
+ method_contrast takes. labels_final: the labels exported with the final scorer, checked
50
+ cell by cell against `labels`. Mismatched shapes, ids or labels raise; scores or rule
51
+ outside [0, 1] raise unless score_scale="other".
52
+ method_contrast(evals_a, evals_b, rule, *, periods=None, same_rule=True, names=("a", "b"), ...)
53
+ {seed: Evaluation} for two methods, and `rule`, the name of a rule held by every
54
+ Evaluation -> terms lead_at_carried_rule (G_U), lead_after_refit (G_V) and change
55
+ (G_V - G_U, the change after refitting, as in the paper), each with per-seed values,
56
+ mean, t interval and class. "placement" is G_U - G_V, the negative of change, kept for
57
+ compatibility. same_rule=False lets each method carry its own vector under that name.
58
+ evaluate(Ps, Ys, seed, period_ids=None, rules=None, shared_periods=None, splits=200, ...)
59
+ One model's per-period scores and labels, and rules={name: (K,) vector} -> Evaluation
60
+ (levels per partition, both directions, every period); .summary() and .per_period()
61
+ give the means.
62
+ decompose(ref, final, rule, periods=None, halves=False, same_rule=True)
63
+ Two Evaluations on the same records and a rule name -> B, A, dR (and dC, dM) per period
64
+ and mean. B and dR are also returned as D and R, their earlier names.
65
+
66
+ Two methods from Python, end to end (synthetic scores, two periods, five seeds):
67
+
68
+ import numpy as np, refitgap as rg
69
+ rng = np.random.default_rng(0); Y = [(rng.random((300, 3)) < 0.3).astype(int) for _ in range(2)]
70
+ ev = lambda sep: {s: rg.evaluate([1 / (1 + np.exp(1 - sep * y - rng.normal(size=y.shape))) for y in Y], Y, s,
71
+ rules={"carried": np.full(3, 0.5)}) for s in range(5)}
72
+ mc = rg.method_contrast(ev(2.5), ev(1.5), "carried", names=("mine", "baseline"))
73
+ print({t: round(mc["terms"][t]["mean"], 4) for t in ("lead_at_carried_rule", "lead_after_refit", "change")})
74
+
75
+ prints {'lead_at_carried_rule': 0.1484, 'lead_after_refit': 0.1903, 'change': 0.0419}.
76
+ fit_exact(P, Y), fit_grid(P, Y), fit_shared_exact(Ps, Ys, w=None), fit_validation_rule(Pval, Yval)
77
+ Scores and labels -> (K,) thresholds: best cutpoint per label; the 19-point grid
78
+ 0.05..0.95 (how theta_1 was fitted); one vector for several periods; the grid on a
79
+ validation split.
80
+ f1_vec(P, Y, theta), macro_f1(P, Y, theta)
81
+ Per-label F1 and its mean at theta.
82
+ partitions(n, seed, j, splits=200)
83
+ The index permutations behind every level.
84
+ paired_stats(x), paired_contrast(a, b), family(members), family_ni(members), holm(ps)
85
+ Per-seed vectors (or {seed: value} dicts) -> mean, intervals, t / TOST / sign-flip p and
86
+ classes, as above.
87
+ analyse(runs_a, runs_b=None, rule="inherited", mean_over=None, u_convention="full", ...)
88
+ Runs (make_run, run_from_npz, run_from_arrays) -> levels, decomposition and contrast
89
+ tables; write_tables(result, dir) writes levels.csv, decomposition.csv, contrasts.csv and
90
+ result.json; plots.save_figures and report.write_report draw from the same tables.
91
+ make_run(final, labels, seed, rules, reference=None, ...), save_arrays(path, run)
92
+ A run from arrays; saved as an arrays file (scores_<j>, labels_<j>, optional
93
+ reference_<j>, rule_<name>, seed, label_names).
94
+ load_run(path), evaluate_npz(path, seed, ...)
95
+ Read or evaluate a stored run file (P_<i>_<j>: model i on period j; Y_<j>; thresholds).
96
+ align_to(reference_ids, ids, *arrays), drop_unlabelled(ids, Y, *arrays)
97
+ Reorder rows to a reference id order (missing or duplicate ids raise); drop records with
98
+ a missing (NaN) label.
99
+ identification_example()
100
+ The fourteen-record case in exact fractions: equal AUROC (6/11) and average precision
101
+ (5/18), equal deficits (1/26), one all mismatch, one all attainable gap.
102
+ f1_bounds_from_counts(tp, fp, fn, tn)
103
+ Sharp range of the best single-threshold F1 behind one confusion table
104
+ (40, 10, 10, 40 gives [0.8, 10/11]). A bound, not a confidence interval.
105
+
106
+ Command line
107
+ ------------
108
+
109
+ refitgap evaluate --npz run_s0.npz run_s1.npz --seeds 0 1 [--npz-b ...] --out DIR
110
+ refitgap evaluate --arrays a_s0.npz ... [--arrays-b b_s0.npz ...] --out DIR
111
+ refitgap report (same inputs) --out DIR # adds three figures and report.html
112
+ refitgap example [--out DIR] # the tiny example below
113
+ refitgap --version
114
+
115
+ Settings: --periods, --mean-over, --shared-periods, --shared-weights, --splits (200),
116
+ --rule (inherited), --validation, --u-convention full|half, --no-reference, --margin
117
+ (0.005), --family NAME, --names, --workers. Two methods are paired by seed; stored label
118
+ names must agree. levels.csv: V_sep, V_shared, C, U_half, U_full, R, M per method, seed,
119
+ model, period and rule. decomposition.csv: B, A, dR per period against the per-period
120
+ reference. contrasts.csv: paired leads with per-seed values, mean, interval and p.
121
+
122
+ Tiny example
123
+ ------------
124
+
125
+ Two periods of four records, two labels (a, b), seed 0, one partition, inherited thresholds 0.5.
126
+ The reference of period 1 is the final model itself.
127
+
128
+ period record labels a,b final a,b reference a,b
129
+ 0 r0 1,0 0.4,0.6 0.9,0.3
130
+ 0 r1 1,0 0.4,0.2 0.9,0.3
131
+ 0 r2 0,1 0.1,0.8 0.2,0.8
132
+ 0 r3 0,1 0.1,0.3 0.2,0.8
133
+ 1 r0, r2 1,0 0.9,0.3 same as final
134
+ 1 r1, r3 0,1 0.2,0.8 same as final
135
+
136
+ default_rng(7000).permutation(4) = [1, 2, 0, 3] and default_rng(7001) gives [3, 0, 1, 2],
137
+ so period 0 splits {r1, r2} | {r0, r3} and period 1 {r3, r0} | {r1, r2}. The reference
138
+ separates everything: V = U = 1. The final model on period 0 reaches V_sep = V_shared = 3/4
139
+ and U = 1/4 at 0.5, so C = 0 and M = R = 1/2; on period 1 it is the reference, V_sep = U = 1
140
+ and V_shared = 11/12. Period 0 thus splits B = 3/4 into A = 1/4 (all label b: positive r3 now
141
+ scores below negative r0) and dR = 1/2 (all label a: still separated, but below 0.5). The
142
+ tests check every printed value against these fractions to 1e-12.
143
+
144
+ Limits
145
+ ------
146
+
147
+ A, C, M and R describe what thresholded predictions from the stored scores can reach: a
148
+ large A says the scores lost separation, not that the representation lost the information.
149
+ Every level survives any strictly increasing per-label map of scores and rules. The package
150
+ recommends nothing; retrain-or-recalibrate needs costs the scores do not carry.
refitgap/__init__.py ADDED
@@ -0,0 +1,29 @@
1
+ """refitgap: thresholded forgetting, split into what the scores lost and what the thresholds lost.
2
+
3
+ Stored per-period scores and labels in; cross-fitted levels, the split
4
+ deficit = attainable gap + change in mismatch, and seed-paired contrasts out.
5
+ compare() handles two scorers on the same records, method_contrast() the paired lead
6
+ of one method over another. API.txt in this directory has the details. numpy and scipy
7
+ only; figures need matplotlib (extra "plots"). Command line: refitgap.
8
+ """
9
+ from .fitters import GRID, f1_vec, fit_exact, fit_grid, fit_shared_exact, macro_f1, shared_objective
10
+ from .crossfit import (SPLITS, SPLIT_BASE, Evaluation, array_hash, decompose, evaluate, fit_validation_rule,
11
+ partitions)
12
+ from .inference import (ALPHA, MARGIN, classify, classify_unadjusted, family, family_ni, holm, paired_contrast,
13
+ paired_stats, paired_vector, dropped_seeds)
14
+ from .records import align_to, drop_unlabelled
15
+ from .runs import evaluate_npz, load_run
16
+ from .analysis import analyse, make_run, run_from_arrays, run_from_npz, save_arrays, write_tables
17
+ from .twoscorer import compare, method_contrast
18
+ from .identification import f1_bounds_from_counts, identification_example
19
+
20
+ __version__ = "0.1.0"
21
+
22
+ __all__ = [
23
+ "GRID", "f1_vec", "fit_exact", "fit_grid", "fit_shared_exact", "macro_f1", "shared_objective", "SPLITS",
24
+ "SPLIT_BASE", "Evaluation", "array_hash", "decompose", "evaluate", "fit_validation_rule", "partitions",
25
+ "ALPHA", "MARGIN", "classify", "classify_unadjusted", "family", "family_ni", "holm", "paired_contrast",
26
+ "paired_stats", "paired_vector", "dropped_seeds", "align_to", "drop_unlabelled", "evaluate_npz",
27
+ "load_run", "analyse", "make_run", "run_from_arrays", "run_from_npz", "save_arrays", "write_tables",
28
+ "compare", "method_contrast", "f1_bounds_from_counts", "identification_example", "__version__",
29
+ ]
refitgap/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """python -m refitgap: the command line (see cli.py)."""
2
+ import sys
3
+
4
+ from .cli import main
5
+
6
+ sys.exit(main())
refitgap/analysis.py ADDED
@@ -0,0 +1,399 @@
1
+ """One analysis path for the command line, the figures and the report.
2
+
3
+ analyse() evaluates one or two sets of runs (crossfit.evaluate), splits each deficit
4
+ (crossfit.decompose) and returns plain tables; write_tables() writes them as CSV and JSON.
5
+ Rows read "descriptive" unless the caller names a family fixed in advance, which then gets
6
+ the paper's class words (material, small non-zero, equivalent, unresolved, (non-)inferior)."""
7
+ import csv
8
+ import json
9
+ from concurrent.futures import ProcessPoolExecutor
10
+
11
+ import numpy as np
12
+
13
+ from . import crossfit, inference
14
+ from .crossfit import SPLITS, SPLIT_BASE
15
+ from .inference import MARGIN
16
+
17
+ U_CONVENTIONS = ("full", "half")
18
+ LEVEL_COLUMNS = ["method", "seed", "model", "period", "rule", "V_sep", "V_shared", "C", "U_half", "U_full",
19
+ "R_half", "R_full", "M", "direction", "sample_unit", "interval_type", "family", "adjustment",
20
+ "margin", "status"]
21
+ DECOMP_COLUMNS = ["method", "seed", "period", "rule", "U_convention", "U_ref", "U_final", "V_ref", "V_final",
22
+ "B", "A", "dR", "residual_B_A_dR", "direction", "sample_unit", "interval_type", "family",
23
+ "adjustment", "margin", "status"]
24
+ CONTRAST_COLUMNS = ["comparison", "term", "policy", "direction", "periods", "n", "seeds", "values", "mean", "ci95_low",
25
+ "ci95_high", "p_t_unadjusted", "p_t_holm", "class", "non_inferior_at_margin",
26
+ "inferior_at_margin", "sample_unit", "interval_type", "family", "adjustment", "margin",
27
+ "status"]
28
+ VOCABULARY_NOTE = (
29
+ "The class words (material, small non-zero, equivalent, unresolved, inferior, non-inferior) are the "
30
+ "vocabulary the paper fixed before its runs. Applied here they describe these numbers under the paper's "
31
+ "rules and confer no confirmatory status on the data analysed."
32
+ )
33
+
34
+
35
+ # ------------------------------------------------------------------ runs ---
36
+ def make_run(final, labels, seed, rules, reference=None, period_ids=None, source="arrays", label_names=None):
37
+ """One run: dict(seed, period_ids, final, labels, reference, rules, source, label_names).
38
+ final, labels, reference: lists with one (n_j, K) array per period (reference: the
39
+ reference model of each period on that period's records, or None)."""
40
+ N = len(final)
41
+ period_ids = list(range(N)) if period_ids is None else list(period_ids)
42
+ for p in period_ids:
43
+ crossfit.check_partition_key(seed, p)
44
+ period_ids = [int(p) for p in period_ids]
45
+ return dict(seed=int(seed), period_ids=period_ids, final=[np.asarray(P) for P in final],
46
+ labels=[np.asarray(Y) for Y in labels],
47
+ reference=None if reference is None else [np.asarray(P) for P in reference],
48
+ rules={k: np.asarray(v, np.float64) for k, v in rules.items()}, source=str(source),
49
+ label_names=None if label_names is None else [str(x) for x in label_names])
50
+
51
+
52
+ def run_from_npz(path, seed, periods=None, validation=False, reference=True):
53
+ """A run from a stored-run file (*_scores.npz; see runs.load_run): the final model
54
+ P_<N-1>_<j>, the per-period reference P_<j>_<j> (reference=True), the file's
55
+ `thresholds` as rule "inherited" and, with validation=True, the grid rule fitted on the
56
+ final model's validation scores as rule "validation" (as runs.evaluate_npz)."""
57
+ from .runs import load_run
58
+ r = load_run(path, None, periods)
59
+ if r["thresholds"] is None:
60
+ raise ValueError(f"{path}: no `thresholds` array (the inherited thresholds)")
61
+ rules = {"inherited": r["thresholds"]}
62
+ if validation and r["Pval"] is not None:
63
+ rules["validation"] = crossfit.fit_validation_rule(r["Pval"], r["Yval"])
64
+ ref = load_run(path, "diagonal", periods)["Ps"] if reference else None
65
+ return make_run(r["Ps"], r["Ys"], seed, rules, ref, r["period_ids"], source=str(path))
66
+
67
+
68
+ def run_from_arrays(path, seed=None, reference=True):
69
+ """A run from a per-period arrays file (npz): scores_<j> and labels_<j> for each period
70
+ id j, optional reference_<j>, one or more rule_<name> vectors, optional `seed` (used
71
+ when seed is None) and optional `label_names`. See save_arrays."""
72
+ z = np.load(path, allow_pickle=False)
73
+ ids = sorted(int(k.split("_", 1)[1]) for k in z.files if k.startswith("scores_"))
74
+ if not ids:
75
+ raise ValueError(f"{path}: no scores_<j> arrays")
76
+ missing = [j for j in ids if f"labels_{j}" not in z.files]
77
+ if missing:
78
+ raise ValueError(f"{path}: labels_<j> missing for periods {missing}")
79
+ rules = {k[len("rule_"):]: z[k] for k in z.files if k.startswith("rule_")}
80
+ if not rules:
81
+ raise ValueError(f"{path}: no rule_<name> vector")
82
+ if seed is None:
83
+ if "seed" not in z.files:
84
+ raise ValueError(f"{path}: no `seed` array; pass the seed")
85
+ seed = int(z["seed"])
86
+ has_ref = reference and all(f"reference_{j}" in z.files for j in ids)
87
+ ref = [z[f"reference_{j}"] for j in ids] if has_ref else None
88
+ names = [str(x) for x in z["label_names"]] if "label_names" in z.files else None
89
+ return make_run([z[f"scores_{j}"] for j in ids], [z[f"labels_{j}"] for j in ids], seed, rules, ref, ids,
90
+ source=str(path), label_names=names)
91
+
92
+
93
+ def save_arrays(path, run):
94
+ """Write a run in the per-period arrays format read by run_from_arrays."""
95
+ d = {f"scores_{j}": P for j, P in zip(run["period_ids"], run["final"])}
96
+ d.update({f"labels_{j}": Y for j, Y in zip(run["period_ids"], run["labels"])})
97
+ if run["reference"] is not None:
98
+ d.update({f"reference_{j}": P for j, P in zip(run["period_ids"], run["reference"])})
99
+ d.update({f"rule_{k}": v for k, v in run["rules"].items()})
100
+ d["seed"] = np.array(run["seed"])
101
+ if run["label_names"] is not None:
102
+ d["label_names"] = np.array(run["label_names"])
103
+ np.savez(path, **d)
104
+
105
+
106
+ # ------------------------------------------------------------- validation ---
107
+ def validate(runs_a, runs_b, rule):
108
+ """Checks before any evaluation. Returns a list of dict(check, result, detail); raises
109
+ ValueError on a failure that would make the tables meaningless."""
110
+ out = []
111
+ allruns = list(runs_a) + list(runs_b or [])
112
+ if not runs_a:
113
+ raise ValueError("no runs for method a")
114
+ pid = allruns[0]["period_ids"]
115
+ if len(set(pid)) != len(pid):
116
+ raise ValueError("period ids must be distinct")
117
+ bad = [r["source"] for r in allruns if r["period_ids"] != pid]
118
+ if bad:
119
+ raise ValueError(f"period ids differ between runs (e.g. {bad[0]})")
120
+ out.append(dict(check="period ids", result="passed",
121
+ detail=f"ids {pid} in every run; the id keys the partition seed"))
122
+ K = allruns[0]["final"][0].shape[1]
123
+ for r in allruns:
124
+ for P, Y, j in zip(r["final"], r["labels"], r["period_ids"]):
125
+ if P.shape != Y.shape or P.shape[1] != K:
126
+ raise ValueError(f"{r['source']}, period {j}: scores {P.shape} and labels {Y.shape} must be (n_j, {K})")
127
+ if r["reference"] is not None:
128
+ for P, Y, j in zip(r["reference"], r["labels"], r["period_ids"]):
129
+ if P.shape != Y.shape:
130
+ raise ValueError(f"{r['source']}, period {j}: reference scores {P.shape} do not match labels")
131
+ names = [r["label_names"] for r in allruns if r["label_names"] is not None]
132
+ if names and any(n != names[0] for n in names):
133
+ raise ValueError("label names or their order differ between runs")
134
+ out.append(dict(check="label order", result="passed",
135
+ detail=f"K = {K} label columns in every period and run"
136
+ + (f"; names in one order: {', '.join(names[0])}" if names else
137
+ "; no label names supplied, so the column order is the caller's responsibility")))
138
+ for r in allruns:
139
+ if rule not in r["rules"]:
140
+ raise ValueError(f"{r['source']}: rule {rule!r} not supplied (have {sorted(r['rules'])})")
141
+ hashes = {}
142
+ for side, runs in (("a", runs_a), ("b", runs_b or [])):
143
+ seeds = [r["seed"] for r in runs]
144
+ if len(set(seeds)) != len(seeds):
145
+ raise ValueError(f"method {side}: a seed appears twice")
146
+ for r in runs:
147
+ hashes[(side, r["seed"])] = [crossfit.array_hash(np.asarray(Y).astype(int), np.int8) for Y in r["labels"]]
148
+ nref = sum(r["reference"] is not None for r in allruns)
149
+ out.append(dict(check="alignment", result="passed",
150
+ detail="records are paired by position; reference scores (where given) share the run's labels "
151
+ f"({nref} of {len(allruns)} runs carry a per-period reference)"))
152
+ if runs_b:
153
+ sa, sb = sorted(r["seed"] for r in runs_a), sorted(r["seed"] for r in runs_b)
154
+ if sa != sb:
155
+ raise ValueError(f"pairing: methods a and b hold different seeds ({sa} against {sb})")
156
+ diff = [s for s in sa if hashes[("a", s)] != hashes[("b", s)]]
157
+ if diff:
158
+ raise ValueError(f"pairing: labels or record order differ between a and b for seeds {diff}")
159
+ same_rule = all(np.array_equal(ra["rules"][rule], rb["rules"][rule])
160
+ for ra in runs_a for rb in runs_b if ra["seed"] == rb["seed"])
161
+ out.append(dict(check="pairing", result="passed",
162
+ detail=f"seeds {sa} on both sides; labels equal in record order (label hash) for every seed; "
163
+ + ("rule '" + rule + "' is the same vector on both sides" if same_rule else
164
+ "rule '" + rule + "' differs between a and b, so each model is scored at its own "
165
+ "vector")))
166
+ else:
167
+ out.append(dict(check="pairing", result="not applicable",
168
+ detail="one method; contrasts are reference minus final within each run"))
169
+ return out
170
+
171
+
172
+ # ------------------------------------------------------------- evaluation ---
173
+ def _evaluate_one(task):
174
+ """Pool helper: (scores, labels, seed, period_ids, rules, kwargs) -> Evaluation."""
175
+ Ps, Ys, seed, period_ids, rules, kw = task
176
+ return crossfit.evaluate(Ps, Ys, seed, period_ids, rules=rules, **kw)
177
+
178
+
179
+ def _f(x):
180
+ return None if x is None else float(x)
181
+
182
+
183
+ def analyse(runs_a, runs_b=None, rule="inherited", mean_over=None, splits=SPLITS, shared_periods=None,
184
+ shared_weights=None, mean_weights=None, u_convention="full", margin=MARGIN, family=None,
185
+ names=("a", "b"), workers=1):
186
+ """Runs of method a (and optionally b, paired by seed) -> dict(settings, validation, levels,
187
+ decomposition, contrasts, interpretation, evaluations).
188
+
189
+ rule: the fixed rule; mean_over: period ids averaged; u_convention: "full" (U on the whole
190
+ period, the paper's B = A + dR) or "half" (held-out halves, R = C + M per partition); margin:
191
+ for TOST and the classes (0.005); family: None for descriptive rows, or a name to Holm-adjust
192
+ the contrasts together."""
193
+ if u_convention not in U_CONVENTIONS:
194
+ raise ValueError(f"u_convention must be one of {U_CONVENTIONS}")
195
+ runs_b = list(runs_b or [])
196
+ runs_a = list(runs_a)
197
+ validation = validate(runs_a, runs_b, rule)
198
+ pid = runs_a[0]["period_ids"]
199
+ mean_over = list(pid) if mean_over is None else [int(p) for p in mean_over]
200
+ kw = dict(shared_periods=shared_periods, shared_weights=shared_weights, splits=splits)
201
+ tasks, keys = [], []
202
+ for side, runs in (("a", runs_a), ("b", runs_b)):
203
+ for r in runs:
204
+ tasks.append((r["final"], r["labels"], r["seed"], r["period_ids"], r["rules"], dict(kw)))
205
+ keys.append((side, r["seed"], "final"))
206
+ if r["reference"] is not None:
207
+ tasks.append((r["reference"], r["labels"], r["seed"], r["period_ids"], r["rules"],
208
+ dict(kw, single_model=False)))
209
+ keys.append((side, r["seed"], "reference"))
210
+ if workers and workers > 1 and len(tasks) > 1:
211
+ with ProcessPoolExecutor(max_workers=workers) as ex:
212
+ evs = list(ex.map(_evaluate_one, tasks))
213
+ else:
214
+ evs = [_evaluate_one(t) for t in tasks]
215
+ E = dict(zip(keys, evs))
216
+ label = {"a": names[0], "b": names[1]}
217
+ meta = dict(direction="", sample_unit="", interval_type="none", family="none", adjustment="none",
218
+ margin=float(margin), status="descriptive")
219
+
220
+ levels = []
221
+ for (side, seed, model), ev in E.items():
222
+ pp = ev.per_period()
223
+ sm = ev.summary(mean_over, mean_weights)
224
+ rows = [(j, pp[j]) for j in ev.period_ids] + [("mean", None)]
225
+ for j, p in rows:
226
+ for rn in ev.rules:
227
+ if p is not None:
228
+ vsep, vsh = p["V_sep"], p["V_shared"]
229
+ uh, uf = p["U_half"][rn], p["U_full"][rn]
230
+ C = None if vsh is None else vsep - vsh
231
+ M = None if vsh is None else vsh - uh
232
+ Rh = vsep - uh
233
+ else:
234
+ vsep, vsh, C = sm["V_sep"], sm["V_shared"], sm["C"]
235
+ rr = sm["rules"][rn]
236
+ uh, uf, Rh, M = rr["U_half"], rr["U_full"], rr["R"], rr.get("M")
237
+ if model == "reference": # one rule across different models: not reported
238
+ vsh = C = M = None
239
+ levels.append(dict(meta, method=label[side], seed=seed, model=model,
240
+ period=j if p is not None else "mean " + ",".join(map(str, mean_over)),
241
+ rule=rn, V_sep=_f(vsep), V_shared=_f(vsh), C=_f(C), U_half=_f(uh), U_full=_f(uf),
242
+ R_half=_f(Rh), R_full=_f(vsep - uf), M=_f(M), direction="level (no contrast)",
243
+ sample_unit="mean over partitions of one run (split-half cross-fitting)"))
244
+
245
+ halves = u_convention == "half"
246
+ decomposition, decomp_by_run = [], {}
247
+ for (side, seed, model), ev in E.items():
248
+ if model != "reference":
249
+ continue
250
+ fin = E[(side, seed, "final")]
251
+ per = {}
252
+ for j in list(ev.period_ids) + ["mean"]:
253
+ # one period has no weighting; weights=[1] keeps summary() from asking for weights
254
+ # when the runs were evaluated with shared_weights (identical numbers otherwise)
255
+ one = [1.0] if ev.shared_weights is not None else None
256
+ d = crossfit.decompose(ev, fin, rule, periods=[j] if j != "mean" else mean_over,
257
+ weights=one if j != "mean" else mean_weights, halves=halves)
258
+ per[j] = d
259
+ decomposition.append(dict(meta, method=label[side], seed=seed,
260
+ period=j if j != "mean" else "mean " + ",".join(map(str, mean_over)),
261
+ rule=rule, U_convention=d["U"], U_ref=d["U_ref"], U_final=d["U_final"],
262
+ V_ref=d["V_ref"], V_final=d["V_final"], B=d["D"], A=d["A"], dR=d["R"],
263
+ residual_B_A_dR=d["residual_D_A_R"], direction="reference minus final",
264
+ sample_unit="mean over partitions of one run (split-half cross-fitting)"))
265
+ decomp_by_run[(side, seed)] = per
266
+
267
+ # contrasts: per-seed values over mean_over
268
+ ukey = "U_half" if halves else "U_full"
269
+ def per_seed(side, model, what):
270
+ out = {}
271
+ for (s_, seed, m_), ev in E.items():
272
+ if s_ == side and m_ == model:
273
+ sm = ev.summary(mean_over, mean_weights)
274
+ out[seed] = sm["rules"][rule][ukey] if what == "U" else sm[what]
275
+ return out
276
+
277
+ comparisons = []
278
+ if runs_b:
279
+ cname = f"{label['a']} minus {label['b']}"
280
+ comparisons.append((cname, f"{label['a']} minus {label['b']} (final models, paired by seed)", [
281
+ ("at the rule", f"fixed rule '{rule}' ({ukey})", per_seed("a", "final", "U"), per_seed("b", "final", "U")),
282
+ ("after own-period refit", "own-period rules (V_sep)", per_seed("a", "final", "V_sep"),
283
+ per_seed("b", "final", "V_sep")),
284
+ ("after one shared refit", "one shared rule (V_shared)", per_seed("a", "final", "V_shared"),
285
+ per_seed("b", "final", "V_shared"))]))
286
+ for side in ("a", "b"):
287
+ if any(k[0] == side and k[2] == "reference" for k in E):
288
+ cname = f"reference minus final ({label[side]})"
289
+ comparisons.append((cname, f"per-period reference minus final model of {label[side]}, within each run", [
290
+ ("at the rule", f"fixed rule '{rule}' ({ukey})", per_seed(side, "reference", "U"),
291
+ per_seed(side, "final", "U")),
292
+ ("after own-period refit", "own-period rules (V_sep)", per_seed(side, "reference", "V_sep"),
293
+ per_seed(side, "final", "V_sep"))]))
294
+
295
+ contrasts, interpretation = [], []
296
+ for cname, direction, terms in comparisons:
297
+ vecs = {}
298
+ for term, policy, a, b in terms:
299
+ if any(v is None for v in list(a.values()) + list(b.values())):
300
+ continue # shared level undefined for these periods
301
+ seeds, x = inference.paired_vector(a, b, strict=True)
302
+ vecs[term] = (policy, seeds, x)
303
+ fam = None
304
+ if family is not None and vecs and all(len(v[2]) >= 2 for v in vecs.values()):
305
+ fam = inference.family_ni({t: v[2] for t, v in vecs.items()}, margin=margin)
306
+ for term, (policy, seeds, x) in vecs.items():
307
+ n = len(x)
308
+ st = inference.paired_stats(x, margin) if n >= 2 else dict(n=n, mean=float(x.mean()))
309
+ row = dict(meta, comparison=cname, term=term, policy=policy, direction=direction,
310
+ periods=",".join(map(str, mean_over)), n=n, seeds=" ".join(map(str, seeds)),
311
+ values=" ".join(repr(float(v)) for v in x),
312
+ mean=float(st["mean"]), ci95_low=st["ci95"][0] if n >= 2 else None,
313
+ ci95_high=st["ci95"][1] if n >= 2 else None,
314
+ p_t_unadjusted=st.get("p_t"), p_t_holm=None, non_inferior_at_margin=None,
315
+ inferior_at_margin=None,
316
+ sample_unit="seed (one value per run, paired)" if n >= 2 else "one run",
317
+ interval_type=("marginal 95% t interval over seeds" if n >= 2 else
318
+ "none (one run; an interval needs two or more seeds)"))
319
+ row["class"] = None
320
+ if fam is not None:
321
+ f = fam[term]
322
+ row.update(p_t_holm=f["p_holm"], non_inferior_at_margin=bool(f["ni"]),
323
+ inferior_at_margin=bool(f["inf"]), family=str(family),
324
+ adjustment=f"Holm over {len(vecs)} contrasts", status=f"planned family {family}")
325
+ row["class"] = f["class"]
326
+ contrasts.append(row)
327
+ interpretation.extend(_sentences(cname, contrasts))
328
+ settings = dict(seeds=sorted({r["seed"] for r in runs_a}), splits=splits, split_base=SPLIT_BASE,
329
+ split_rule="partition b of period id j, seed s: b-th permutation of "
330
+ "numpy default_rng(split_base + 100*s + j); half 0 is the first n_j // 2 records",
331
+ rules={r["source"]: {k: v.tolist() for k, v in r["rules"].items()} for r in runs_a + runs_b},
332
+ rule=rule, period_ids=list(pid), mean_over=mean_over,
333
+ shared_periods=list(pid) if shared_periods is None else [int(p) for p in shared_periods],
334
+ shared_weights=None if shared_weights is None else [float(w) for w in shared_weights],
335
+ mean_weights=None if mean_weights is None else [float(w) for w in mean_weights],
336
+ U_convention=u_convention, margin=float(margin), family=family, names=list(names),
337
+ methods={label["a"]: [r["source"] for r in runs_a], label["b"]: [r["source"] for r in runs_b]}
338
+ if runs_b else {label["a"]: [r["source"] for r in runs_a]})
339
+ from . import __version__
340
+ settings["refitgap_version"] = __version__
341
+ return dict(settings=settings, validation=validation, levels=levels, decomposition=decomposition,
342
+ contrasts=contrasts, interpretation=interpretation, vocabulary_note=VOCABULARY_NOTE,
343
+ evaluations=E, decomposition_by_run=decomp_by_run)
344
+
345
+
346
+ def _num(x):
347
+ return f"{x:+.4f}"
348
+
349
+
350
+ def _sentences(cname, contrasts):
351
+ rows = {r["term"]: r for r in contrasts if r["comparison"] == cname}
352
+ if "at the rule" not in rows:
353
+ return []
354
+ x = rows["at the rule"]
355
+ out = []
356
+ for term in ("after own-period refit", "after one shared refit"):
357
+ if term not in rows:
358
+ continue
359
+ y = rows[term]
360
+ tail = ("the remaining lead is shown with its interval "
361
+ f"[{y['ci95_low']:+.4f}, {y['ci95_high']:+.4f}]." if y["ci95_low"] is not None else
362
+ "with one run no interval is computed.")
363
+ out.append(f"{cname}: {term} the lead goes from {_num(x['mean'])} to {_num(y['mean'])}; {tail}")
364
+ return out
365
+
366
+
367
+ # ------------------------------------------------------------------ output ---
368
+ def _cell(v):
369
+ if v is None:
370
+ return ""
371
+ if isinstance(v, (float, np.floating)):
372
+ return repr(float(v)) # float(): numpy >= 2 reprs np.float64 as 'np.float64(x)'
373
+ return str(v)
374
+
375
+
376
+ def write_tables(result, outdir):
377
+ """levels.csv, decomposition.csv, contrasts.csv and result.json in outdir. Floats are
378
+ written with repr, so a CSV value read back with float() equals the API value exactly."""
379
+ from pathlib import Path
380
+ outdir = Path(outdir)
381
+ outdir.mkdir(parents=True, exist_ok=True)
382
+ paths = {}
383
+ for name, cols in (("levels", LEVEL_COLUMNS), ("decomposition", DECOMP_COLUMNS), ("contrasts", CONTRAST_COLUMNS)):
384
+ p = outdir / f"{name}.csv"
385
+ with open(p, "w", newline="") as fh:
386
+ w = csv.writer(fh)
387
+ w.writerow(cols)
388
+ for r in result[name]:
389
+ w.writerow([_cell(r.get(c)) for c in cols])
390
+ paths[name] = p
391
+ p = outdir / "result.json"
392
+ p.write_text(json.dumps(to_json(result), indent=1))
393
+ paths["json"] = p
394
+ return paths
395
+
396
+
397
+ def to_json(result):
398
+ keep = ("settings", "validation", "levels", "decomposition", "contrasts", "interpretation", "vocabulary_note")
399
+ return {k: result[k] for k in keep}