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/report.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""analysis.analyse() result -> one self-contained HTML page (inline SVG figures, tables,
|
|
2
|
+
one sentence per comparison; no network, no script)."""
|
|
3
|
+
import html
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .analysis import CONTRAST_COLUMNS, DECOMP_COLUMNS, LEVEL_COLUMNS, VOCABULARY_NOTE
|
|
7
|
+
|
|
8
|
+
CSS = """
|
|
9
|
+
body{font-family:Georgia,'Times New Roman',serif;color:#222;background:#fff;max-width:72rem;margin:0 auto;
|
|
10
|
+
padding:1rem 16px 3rem;line-height:1.45}
|
|
11
|
+
h1{font-size:1.4rem;margin-bottom:.2rem}h2{font-size:1.1rem;margin-top:2rem;border-bottom:1px solid #ccc}
|
|
12
|
+
table{border-collapse:collapse;font-family:Menlo,Consolas,monospace;font-size:.72rem;margin:.5rem 0}
|
|
13
|
+
th,td{border-bottom:1px solid #e3e3e3;padding:2px 6px;text-align:right;white-space:nowrap}
|
|
14
|
+
th{background:#f4f4f4;position:sticky;top:0}td.t,th.t{text-align:left}
|
|
15
|
+
.scroll{overflow-x:auto;max-height:28rem;overflow-y:auto}
|
|
16
|
+
.note{color:#555;font-size:.9rem}figure{margin:1rem 0}figure svg{max-width:100%;height:auto}
|
|
17
|
+
figcaption{color:#555;font-size:.85rem}
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
TEXT_COLS = {"method", "model", "period", "rule", "direction", "sample_unit", "interval_type", "family", "adjustment",
|
|
21
|
+
"status", "U_convention", "comparison", "term", "policy", "periods", "seeds", "values", "class"}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _fmt(v):
|
|
25
|
+
if v is None:
|
|
26
|
+
return ""
|
|
27
|
+
if isinstance(v, bool):
|
|
28
|
+
return "yes" if v else "no"
|
|
29
|
+
if isinstance(v, float):
|
|
30
|
+
return f"{v:.6g}" if abs(v) < 1e-3 and v != 0 else f"{v:.4f}"
|
|
31
|
+
return str(v)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _table(rows, cols):
|
|
35
|
+
if not rows:
|
|
36
|
+
return "<p class='note'>No rows.</p>"
|
|
37
|
+
head = "".join(f"<th class='{'t' if c in TEXT_COLS else ''}'>{html.escape(c)}</th>" for c in cols)
|
|
38
|
+
body = "".join("<tr>" + "".join(f"<td class='{'t' if c in TEXT_COLS else ''}'>{html.escape(_fmt(r.get(c)))}</td>"
|
|
39
|
+
for c in cols) + "</tr>" for r in rows)
|
|
40
|
+
return f"<div class='scroll'><table><thead><tr>{head}</tr></thead><tbody>{body}</tbody></table></div>"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _kv(d):
|
|
44
|
+
rows = "".join(f"<tr><td class='t'>{html.escape(str(k))}</td><td class='t'>{html.escape(str(v))}</td></tr>"
|
|
45
|
+
for k, v in d.items())
|
|
46
|
+
return f"<div class='scroll'><table><tbody>{rows}</tbody></table></div>"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
CAPTIONS = {
|
|
50
|
+
"decomposition": "Deficit decomposition against the per-period reference: A (orange squares, solid line) and "
|
|
51
|
+
"dR, the change in mismatch (blue circles, dashed line), per period and their "
|
|
52
|
+
"mean, macro-F1 on the 0 to 1 scale, positive where the final model is below the reference. "
|
|
53
|
+
"B = A + dR holds in every row.",
|
|
54
|
+
"profile": "Period profile of each final model: V_sep under own-period rules (black circles, solid), V_shared "
|
|
55
|
+
"under one shared rule (grey squares, dashed) and U_full at the fixed rule (blue triangles, dotted), "
|
|
56
|
+
"macro-F1 on the 0 to 1 scale; the axis spans the data and its note gives the range.",
|
|
57
|
+
"comparison": "Paired leads at the fixed rule (black diamonds), after own-period refit (orange squares) and after "
|
|
58
|
+
"one shared refit (grey circles), with marginal 95% t intervals over seeds where there are two or "
|
|
59
|
+
"more. The dashed lines mark the margin, the dotted line zero.",
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def render(result, svgs=None, title="refitgap report"):
|
|
64
|
+
"""The HTML page as a string. svgs: {name: svg markup} (plots.svg_strings); None tries
|
|
65
|
+
to draw them and falls back to a note when matplotlib is unavailable."""
|
|
66
|
+
if svgs is None:
|
|
67
|
+
try:
|
|
68
|
+
from .plots import svg_strings
|
|
69
|
+
svgs = svg_strings(result)
|
|
70
|
+
except ImportError:
|
|
71
|
+
svgs = {}
|
|
72
|
+
st = result["settings"]
|
|
73
|
+
settings = {
|
|
74
|
+
"refitgap version": st.get("refitgap_version"),
|
|
75
|
+
"methods and inputs": "; ".join(f"{k}: {', '.join(v)}" for k, v in st["methods"].items()),
|
|
76
|
+
"seeds": " ".join(map(str, st["seeds"])),
|
|
77
|
+
"partitions (splits)": st["splits"], "partition rule": st["split_rule"] + f" (split_base {st['split_base']})",
|
|
78
|
+
"fixed rule of the decomposition": st["rule"],
|
|
79
|
+
"rule vectors": "; ".join(f"{src}: " + ", ".join(f"{k} = {[round(x, 6) for x in v]}" for k, v in r.items())
|
|
80
|
+
for src, r in st["rules"].items()),
|
|
81
|
+
"evaluated period ids": st["period_ids"], "periods averaged": st["mean_over"],
|
|
82
|
+
"shared periods": st["shared_periods"], "shared weights": st["shared_weights"] or "uniform",
|
|
83
|
+
"weights of the average": st["mean_weights"] or "uniform",
|
|
84
|
+
"U convention": ("U_full: the fixed-rule level on the whole period (decomposition B = A + dR as in Algorithm 1 "
|
|
85
|
+
"of the paper)" if st["U_convention"] == "full" else
|
|
86
|
+
"U_half: the fixed-rule level on the held-out halves (R = C + M exact per partition)"),
|
|
87
|
+
"margin": st["margin"], "family": st["family"] or "none (every row descriptive)",
|
|
88
|
+
}
|
|
89
|
+
parts = [f"<!doctype html><html lang='en'><head><meta charset='utf-8'>"
|
|
90
|
+
f"<meta name='viewport' content='width=device-width, initial-scale=1'><title>{html.escape(title)}</title>"
|
|
91
|
+
f"<style>{CSS}</style></head><body>",
|
|
92
|
+
f"<h1>{html.escape(title)}</h1>",
|
|
93
|
+
"<p class='note'>Evaluation of thresholded macro-F1 from stored per-period scores and labels. "
|
|
94
|
+
"Every quantity is a function of the scores, the labels, the rules, the seed and the period ids. The "
|
|
95
|
+
"terms describe what thresholded predictions built from these scores reach; they do not identify what "
|
|
96
|
+
"the model's internal features retain, and the report proposes no action.</p>",
|
|
97
|
+
"<h2>Inputs and validation</h2>",
|
|
98
|
+
_table(result["validation"], ["check", "result", "detail"]),
|
|
99
|
+
"<h2>Settings</h2>", _kv(settings),
|
|
100
|
+
"<h2>Interpretation</h2>"]
|
|
101
|
+
if result["interpretation"]:
|
|
102
|
+
parts.append("<ul>" + "".join(f"<li>{html.escape(s)}</li>" for s in result["interpretation"]) + "</ul>")
|
|
103
|
+
else:
|
|
104
|
+
parts.append("<p class='note'>No comparison was computed.</p>")
|
|
105
|
+
parts.append(f"<p class='note'>Rows are labelled descriptive unless a planned family was named. "
|
|
106
|
+
f"{html.escape(VOCABULARY_NOTE)}</p>")
|
|
107
|
+
parts.append("<h2>Figures</h2>")
|
|
108
|
+
if svgs:
|
|
109
|
+
for name in ("decomposition", "profile", "comparison"):
|
|
110
|
+
if name in svgs:
|
|
111
|
+
parts.append(f"<figure>{svgs[name]}<figcaption>{html.escape(CAPTIONS[name])}</figcaption></figure>")
|
|
112
|
+
else:
|
|
113
|
+
parts.append("<p class='note'>Figures were not drawn: matplotlib is not installed "
|
|
114
|
+
"(pip install 'refitgap[plots]'). The tables below hold every number.</p>")
|
|
115
|
+
parts += [
|
|
116
|
+
"<h2>Decomposition B = A + dR (reference minus final)</h2>",
|
|
117
|
+
_table(result["decomposition"], DECOMP_COLUMNS),
|
|
118
|
+
"<h2>Paired contrasts</h2>",
|
|
119
|
+
_table(result["contrasts"], CONTRAST_COLUMNS),
|
|
120
|
+
"<h2>Per-period levels</h2>",
|
|
121
|
+
_table(result["levels"], LEVEL_COLUMNS),
|
|
122
|
+
"<p class='note'>V_sep: own-period rules; V_shared: one shared rule; U_half, U_full: the fixed "
|
|
123
|
+
"rule on the held-out halves and on the whole period; C = V_sep - V_shared; M = V_shared - U_half; "
|
|
124
|
+
"R_half = V_sep - U_half = C + M; R_full = V_sep - U_full. In the decomposition, B = U_ref - U_final, "
|
|
125
|
+
"A = V_ref - V_final and dR = R_final - R_ref at the chosen U convention. Full-precision values are "
|
|
126
|
+
"in the CSV files.</p>",
|
|
127
|
+
"</body></html>",
|
|
128
|
+
]
|
|
129
|
+
return "\n".join(parts)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def write_report(result, path, svgs=None, title="refitgap report"):
|
|
133
|
+
path = Path(path)
|
|
134
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
135
|
+
path.write_text(render(result, svgs, title), encoding="utf-8")
|
|
136
|
+
return path
|
refitgap/runs.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Stored run files (*_scores.npz): P_<i>_<j> is model i on period j's test records, Y_<j>
|
|
2
|
+
the labels, `thresholds` theta_1, Pval_<i> / Yval the validation scores. Model N-1 is final."""
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def n_periods(z):
|
|
7
|
+
return max(int(k.split("_")[1]) for k in z.files if k.startswith("P_")) + 1
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def load_run(path, model=None, periods=None):
|
|
11
|
+
"""npz path -> dict(Ps, Ys, period_ids, thresholds, Pval, Yval, N) for one model (default the
|
|
12
|
+
final; "diagonal" gives P_j_j on period j)."""
|
|
13
|
+
z = np.load(path)
|
|
14
|
+
N = n_periods(z)
|
|
15
|
+
periods = list(range(N)) if periods is None else [int(p) for p in periods]
|
|
16
|
+
if model == "diagonal":
|
|
17
|
+
Ps = [z[f"P_{j}_{j}"] for j in periods]
|
|
18
|
+
mi = None
|
|
19
|
+
else:
|
|
20
|
+
mi = N - 1 if model is None else int(model)
|
|
21
|
+
Ps = [z[f"P_{mi}_{j}"] for j in periods]
|
|
22
|
+
Ys = [z[f"Y_{j}"] for j in periods]
|
|
23
|
+
has_val = mi is not None and f"Pval_{mi}" in z.files and "Yval" in z.files
|
|
24
|
+
return dict(Ps=Ps, Ys=Ys, period_ids=periods, N=N,
|
|
25
|
+
thresholds=z["thresholds"].astype(np.float64) if "thresholds" in z.files else None,
|
|
26
|
+
Pval=z[f"Pval_{mi}"] if has_val else None, Yval=z["Yval"] if has_val else None)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def evaluate_npz(path, seed, periods=None, model=None, splits=200, validation=False, scores_key="P",
|
|
30
|
+
shared_periods=None):
|
|
31
|
+
"""npz path and seed -> Evaluation of the final model (or `model`) at "inherited" and, with
|
|
32
|
+
validation=True, the grid rule on its validation scores. Picklable, for process pools."""
|
|
33
|
+
from .crossfit import evaluate, fit_validation_rule
|
|
34
|
+
if scores_key == "P":
|
|
35
|
+
r = load_run(path, model, periods)
|
|
36
|
+
else:
|
|
37
|
+
z = np.load(path)
|
|
38
|
+
N = n_periods(z)
|
|
39
|
+
mi = N - 1 if model is None else int(model)
|
|
40
|
+
periods = list(range(N)) if periods is None else [int(p) for p in periods]
|
|
41
|
+
r = dict(Ps=[z[f"{scores_key}_{mi}_{j}"] for j in periods], Ys=[z[f"Yx_{j}"] for j in periods],
|
|
42
|
+
period_ids=periods, thresholds=z["thresholds"].astype(np.float64), Pval=None, Yval=None)
|
|
43
|
+
rules = {"inherited": r["thresholds"]}
|
|
44
|
+
if validation and r["Pval"] is not None:
|
|
45
|
+
rules["validation"] = fit_validation_rule(r["Pval"], r["Yval"])
|
|
46
|
+
return evaluate(r["Ps"], r["Ys"], seed, r["period_ids"], rules=rules, splits=splits,
|
|
47
|
+
shared_periods=shared_periods, single_model=(model != "diagonal"))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _evaluate_task(task):
|
|
51
|
+
"""Pool helper: task = (path, seed, periods, kwargs)."""
|
|
52
|
+
path, seed, periods, kw = task
|
|
53
|
+
return evaluate_npz(path, seed, periods, **kw)
|
refitgap/tiny.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""The tiny example: eight records, two labels, two periods, two models, one partition.
|
|
2
|
+
Every number it prints is worked by hand in API.txt.
|
|
3
|
+
|
|
4
|
+
refitgap example"""
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
from .analysis import analyse, make_run
|
|
8
|
+
|
|
9
|
+
SEED, SPLITS = 0, 1
|
|
10
|
+
THETA = np.array([0.5, 0.5])
|
|
11
|
+
LABEL_NAMES = ["label a", "label b"]
|
|
12
|
+
# labels, columns (label a, label b), rows r0..r3 of each period
|
|
13
|
+
Y0 = np.array([[1, 0], [1, 0], [0, 1], [0, 1]])
|
|
14
|
+
Y1 = np.array([[1, 0], [0, 1], [1, 0], [0, 1]])
|
|
15
|
+
# final model
|
|
16
|
+
F0 = np.array([[0.4, 0.6], [0.4, 0.2], [0.1, 0.8], [0.1, 0.3]])
|
|
17
|
+
F1 = np.array([[0.9, 0.3], [0.2, 0.8], [0.9, 0.3], [0.2, 0.8]])
|
|
18
|
+
# reference model of period 0 (the reference of period 1 is the final model, F1)
|
|
19
|
+
S0 = np.array([[0.9, 0.3], [0.9, 0.3], [0.2, 0.8], [0.2, 0.8]])
|
|
20
|
+
|
|
21
|
+
# the values worked by hand in API.txt (fractions)
|
|
22
|
+
HAND = {
|
|
23
|
+
"final": {0: dict(V_sep=3 / 4, V_shared=3 / 4, U_half=1 / 4, U_full=1 / 4, C=0.0, M=1 / 2, R_half=1 / 2),
|
|
24
|
+
1: dict(V_sep=1.0, V_shared=11 / 12, U_half=1.0, U_full=1.0, C=1 / 12, M=-1 / 12, R_half=0.0),
|
|
25
|
+
"mean": dict(V_sep=7 / 8, V_shared=5 / 6, U_half=5 / 8, U_full=5 / 8, C=1 / 24, M=5 / 24, R_half=1 / 4)},
|
|
26
|
+
"reference": {0: dict(V_sep=1.0, U_half=1.0, U_full=1.0), 1: dict(V_sep=1.0, U_half=1.0, U_full=1.0),
|
|
27
|
+
"mean": dict(V_sep=1.0, U_half=1.0, U_full=1.0)},
|
|
28
|
+
"decomposition": {0: dict(B=3 / 4, A=1 / 4, dR=1 / 2), 1: dict(B=0.0, A=0.0, dR=0.0),
|
|
29
|
+
"mean": dict(B=3 / 8, A=1 / 8, dR=1 / 4)},
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def tiny_run():
|
|
34
|
+
return make_run([F0, F1], [Y0, Y1], SEED, {"inherited": THETA}, reference=[S0, F1], period_ids=[0, 1],
|
|
35
|
+
source="tiny example", label_names=LABEL_NAMES)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def tiny_result(u_convention="full"):
|
|
39
|
+
return analyse(
|
|
40
|
+
[tiny_run()], rule="inherited", splits=SPLITS, u_convention=u_convention, names=("tiny example", "b")
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def lines(result):
|
|
45
|
+
"""The printed lines: one per quantity, 15 significant digits, so a reader can compare
|
|
46
|
+
them with the hand values and a test can parse them."""
|
|
47
|
+
out = [f"tiny example: 2 periods x 4 records, 2 labels, seed {SEED}, {SPLITS} partition, inherited thresholds 0.5",
|
|
48
|
+
"levels (macro-F1, 0 to 1; descriptive):"]
|
|
49
|
+
for r in result["levels"]:
|
|
50
|
+
p = "mean" if str(r["period"]).startswith("mean") else r["period"]
|
|
51
|
+
vals = [("V_sep", r["V_sep"]), ("V_shared", r["V_shared"]), ("U_half", r["U_half"]), ("U_full", r["U_full"]),
|
|
52
|
+
("C", r["C"]), ("M", r["M"]), ("R_half", r["R_half"])]
|
|
53
|
+
out.append(f" {r['model']} period {p}: " + " ".join(f"{k} {v:.15g}" for k, v in vals if v is not None))
|
|
54
|
+
out.append("decomposition B = A + dR against the per-period reference, U_full (descriptive):")
|
|
55
|
+
for r in result["decomposition"]:
|
|
56
|
+
p = "mean" if str(r["period"]).startswith("mean") else r["period"]
|
|
57
|
+
out.append(f" period {p}: B {r['B']:.15g} A {r['A']:.15g} dR {r['dR']:.15g}")
|
|
58
|
+
out.extend(result["interpretation"])
|
|
59
|
+
return out
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def main(argv=None):
|
|
63
|
+
for line in lines(tiny_result()):
|
|
64
|
+
print(line)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
if __name__ == "__main__":
|
|
68
|
+
main()
|