refitgap 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- refitgap/API.txt +150 -0
- refitgap/__init__.py +29 -0
- refitgap/__main__.py +6 -0
- refitgap/analysis.py +399 -0
- refitgap/cli.py +173 -0
- refitgap/crossfit.py +275 -0
- refitgap/example.py +96 -0
- refitgap/fitters.py +114 -0
- refitgap/identification.py +101 -0
- refitgap/inference.py +319 -0
- refitgap/plots.py +214 -0
- refitgap/records.py +38 -0
- refitgap/report.py +136 -0
- refitgap/runs.py +53 -0
- refitgap/tiny.py +68 -0
- refitgap/twoscorer.py +504 -0
- refitgap-0.1.0.dist-info/METADATA +128 -0
- refitgap-0.1.0.dist-info/RECORD +22 -0
- refitgap-0.1.0.dist-info/WHEEL +5 -0
- refitgap-0.1.0.dist-info/entry_points.txt +2 -0
- refitgap-0.1.0.dist-info/licenses/LICENSE +21 -0
- refitgap-0.1.0.dist-info/top_level.txt +1 -0
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
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}
|