prefit 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.
- prefit/__init__.py +23 -0
- prefit/_version.py +1 -0
- prefit/api.py +448 -0
- prefit/assemble.py +91 -0
- prefit/cli.py +61 -0
- prefit/config.py +39 -0
- prefit/data/track_record.json +57 -0
- prefit/enums.py +99 -0
- prefit/export.py +328 -0
- prefit/figures.py +305 -0
- prefit/fingerprint.py +92 -0
- prefit/frozen.py +70 -0
- prefit/guard.py +188 -0
- prefit/llm/__init__.py +4 -0
- prefit/llm/anthropic.py +39 -0
- prefit/llm/config.py +36 -0
- prefit/llm/gemini.py +37 -0
- prefit/llm/interpreter.py +162 -0
- prefit/llm/local.py +38 -0
- prefit/llm/payload.py +80 -0
- prefit/llm/provider.py +21 -0
- prefit/posttrain.py +394 -0
- prefit/profiling/__init__.py +0 -0
- prefit/profiling/kinds.py +82 -0
- prefit/profiling/profile.py +94 -0
- prefit/profiling/stats.py +236 -0
- prefit/profiling/tabular.py +227 -0
- prefit/profiling/task_inference.py +45 -0
- prefit/py.typed +0 -0
- prefit/render/__init__.py +0 -0
- prefit/render/common.py +172 -0
- prefit/render/html.py +484 -0
- prefit/render/tables.py +23 -0
- prefit/render/text.py +94 -0
- prefit/report/__init__.py +20 -0
- prefit/report/model.py +422 -0
- prefit/rules/__init__.py +0 -0
- prefit/rules/catalog.py +659 -0
- prefit/rules/engine.py +50 -0
- prefit/rules/metrics.py +140 -0
- prefit/rules/registry.py +165 -0
- prefit/verify.py +242 -0
- prefit-0.1.0.dist-info/METADATA +351 -0
- prefit-0.1.0.dist-info/RECORD +47 -0
- prefit-0.1.0.dist-info/WHEEL +4 -0
- prefit-0.1.0.dist-info/entry_points.txt +2 -0
- prefit-0.1.0.dist-info/licenses/LICENSE +21 -0
prefit/__init__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""prefit — decision-driven EDA for tabular supervised learning.
|
|
2
|
+
|
|
3
|
+
Profiles a dataset before training, turns the measurements into classified,
|
|
4
|
+
evidence-backed decisions (procedural / conditional / contextual / review),
|
|
5
|
+
optionally tests the conditional ones with bounded experiments, and exports a
|
|
6
|
+
scikit-learn pipeline that contains only safe, confirmed or verified steps.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from ._version import __version__
|
|
12
|
+
from .api import analyze, load_report, review, split
|
|
13
|
+
from .config import Budget, ProfileOptions
|
|
14
|
+
from .enums import ColumnKind, DecisionClass, DecisionStatus, Severity, Task
|
|
15
|
+
from .llm.config import LLMConfig
|
|
16
|
+
from .llm.provider import Provider
|
|
17
|
+
from .posttrain import ReviewReport
|
|
18
|
+
from .report.model import Report
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"split", "analyze", "load_report", "review", "Report", "ReviewReport", "Budget", "ProfileOptions", "LLMConfig", "Provider",
|
|
22
|
+
"Task", "Severity", "ColumnKind", "DecisionClass", "DecisionStatus", "__version__",
|
|
23
|
+
]
|
prefit/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
prefit/api.py
ADDED
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
"""Public entry points and orchestration.
|
|
2
|
+
|
|
3
|
+
pf.split(df, ...) -> (dev_df, test_df) data preparation, explicit
|
|
4
|
+
pf.analyze(dev_df, ...) -> Report profile -> rules -> figures
|
|
5
|
+
report.answer / verify / interpret / register_external_holdout -> new Report revision
|
|
6
|
+
report.export(...) -> model recipe
|
|
7
|
+
pf.review(...) -> post-training check
|
|
8
|
+
|
|
9
|
+
`analyze` never splits unless asked (`holdout=0.2`); then the holdout is set
|
|
10
|
+
aside before anything is computed and can be re-derived with `report.holdout(df)`.
|
|
11
|
+
Everything up to and including the figures is deterministic for a given seed.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import platform
|
|
17
|
+
import warnings
|
|
18
|
+
from importlib import metadata
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
import numpy as np
|
|
22
|
+
import pandas as pd
|
|
23
|
+
|
|
24
|
+
from ._version import __version__
|
|
25
|
+
from .assemble import build_limitations, build_roadmap, load_track_record
|
|
26
|
+
from .config import Budget, ProfileOptions
|
|
27
|
+
from .enums import DecisionClass, DecisionStatus, Task
|
|
28
|
+
from .figures import _link, build_figures, experiment_figure
|
|
29
|
+
from .fingerprint import check as check_fingerprint
|
|
30
|
+
from .fingerprint import fingerprint, row_hashes
|
|
31
|
+
from .frozen import evolve
|
|
32
|
+
from .guard import reserve_holdout, sample_rows, validate_inputs
|
|
33
|
+
from .profiling.tabular import profile_tabular
|
|
34
|
+
from .profiling.task_inference import infer_task
|
|
35
|
+
from .report.model import ContextState, Manifest, Report, ReportDetails
|
|
36
|
+
from .rules.engine import RuleOutput, rule_versions, run_rules
|
|
37
|
+
from .rules.registry import RuleContext
|
|
38
|
+
|
|
39
|
+
SPLIT_STRATEGIES = ("stratified", "random", "group", "time")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def split(
|
|
43
|
+
df: pd.DataFrame,
|
|
44
|
+
target: str,
|
|
45
|
+
*,
|
|
46
|
+
test_size: float = 0.2,
|
|
47
|
+
strategy: str = "stratified",
|
|
48
|
+
groups: str | None = None,
|
|
49
|
+
time_col: str | None = None,
|
|
50
|
+
random_state: int = 0,
|
|
51
|
+
) -> tuple[pd.DataFrame, pd.DataFrame]:
|
|
52
|
+
"""Split `df` into (dev_df, test_df) before any analysis.
|
|
53
|
+
|
|
54
|
+
strategy
|
|
55
|
+
"stratified" keep class ratios (classification targets only)
|
|
56
|
+
"random" plain random rows
|
|
57
|
+
"group" whole groups (needs `groups=` column) on one side
|
|
58
|
+
"time" the latest `test_size` share by `time_col` becomes the test set
|
|
59
|
+
Pass dev_df to `pf.analyze` and keep test_df untouched until the end;
|
|
60
|
+
`report.register_external_holdout(test_df, dev_df=dev_df)` records it.
|
|
61
|
+
"""
|
|
62
|
+
if strategy not in SPLIT_STRATEGIES:
|
|
63
|
+
raise ValueError(f"strategy must be one of {SPLIT_STRATEGIES}, got {strategy!r}.")
|
|
64
|
+
if strategy == "group" and groups is None:
|
|
65
|
+
raise ValueError("strategy='group' needs groups=<column name>.")
|
|
66
|
+
if strategy == "time" and time_col is None:
|
|
67
|
+
raise ValueError("strategy='time' needs time_col=<column name>.")
|
|
68
|
+
if strategy in ("stratified", "random") and (groups or time_col):
|
|
69
|
+
raise ValueError(f"strategy={strategy!r} does not use groups/time_col; use strategy='group' or 'time'.")
|
|
70
|
+
g = validate_inputs(df, target, groups, time_col)
|
|
71
|
+
if strategy == "stratified":
|
|
72
|
+
y = df[target].dropna()
|
|
73
|
+
if pd.api.types.is_numeric_dtype(y) and not pd.api.types.is_bool_dtype(y) and y.nunique() > 50:
|
|
74
|
+
raise ValueError("strategy='stratified' needs a discrete target; use strategy='random' for a "
|
|
75
|
+
"continuous target.")
|
|
76
|
+
task = Task.CLASSIFICATION
|
|
77
|
+
else:
|
|
78
|
+
task = Task.REGRESSION # no stratification
|
|
79
|
+
dev, test, info = reserve_holdout(df, target, task, test_size, g, time_col, random_state)
|
|
80
|
+
if not info["used"]:
|
|
81
|
+
raise ValueError(f"Cannot split: {info['reason']}")
|
|
82
|
+
return df.iloc[dev], df.iloc[test]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def analyze(
|
|
86
|
+
df: pd.DataFrame,
|
|
87
|
+
target: str,
|
|
88
|
+
*,
|
|
89
|
+
task: Task | str | None = None,
|
|
90
|
+
estimator: Any = None,
|
|
91
|
+
groups: str | Any = None,
|
|
92
|
+
time_col: str | None = None,
|
|
93
|
+
holdout: float | None = None,
|
|
94
|
+
split_columns_as_features: bool = False,
|
|
95
|
+
context: str | None = None,
|
|
96
|
+
answers: dict[str, str] | None = None,
|
|
97
|
+
verify: bool = False,
|
|
98
|
+
budget: Budget | None = None,
|
|
99
|
+
llm: Any = None,
|
|
100
|
+
seed: int = 0,
|
|
101
|
+
profile_options: ProfileOptions | None = None,
|
|
102
|
+
) -> Report:
|
|
103
|
+
"""Profile a dataset and produce an evidence-backed decision roadmap.
|
|
104
|
+
|
|
105
|
+
Parameters
|
|
106
|
+
----------
|
|
107
|
+
df : the development data (typically dev_df from `pf.split`).
|
|
108
|
+
target : name of the target column.
|
|
109
|
+
task : "classification" / "regression"; inferred (and flagged for
|
|
110
|
+
confirmation) when omitted.
|
|
111
|
+
estimator : your scikit-learn estimator. Experiments use it, and only it can
|
|
112
|
+
*verify* a conditional decision. Without it, proxy models give a
|
|
113
|
+
sensitivity analysis.
|
|
114
|
+
groups : column name (or array) identifying entities whose rows must stay on
|
|
115
|
+
one side of every split. Group-aware splitting is only used when given.
|
|
116
|
+
time_col : column defining time order; switches to TimeSeriesSplit.
|
|
117
|
+
holdout : None (default) analyzes all of `df`. A fraction (e.g. 0.2) asks
|
|
118
|
+
prefit to set that share aside *before* profiling; get it back with
|
|
119
|
+
`report.holdout(df)`.
|
|
120
|
+
split_columns_as_features : by default the groups / time_col columns are
|
|
121
|
+
kept out of the model features; True includes them (with a warning).
|
|
122
|
+
context : free-text description of the application (used by the LLM layer).
|
|
123
|
+
answers : answers to report questions, e.g. {"metric_priority": "miss_costly"}.
|
|
124
|
+
verify : run the bounded experiments for conditional decisions (needs the
|
|
125
|
+
`verify` extra: scikit-learn + scipy).
|
|
126
|
+
budget : experiment limits (see `prefit.Budget`).
|
|
127
|
+
llm : optional `prefit.LLMConfig` (or a provider name / Provider). Off by
|
|
128
|
+
default; see `Report.llm_payload()` for exactly what would be sent.
|
|
129
|
+
"""
|
|
130
|
+
options = profile_options or ProfileOptions()
|
|
131
|
+
budget = budget or Budget()
|
|
132
|
+
groups_s = validate_inputs(df, target, groups, time_col)
|
|
133
|
+
|
|
134
|
+
if task is None:
|
|
135
|
+
task_t, task_ev = infer_task(df[target])
|
|
136
|
+
inferred = True
|
|
137
|
+
else:
|
|
138
|
+
task_t = Task(task)
|
|
139
|
+
task_ev = {"reason": "given by the user", "dtype": str(df[target].dtype),
|
|
140
|
+
"n_unique": int(df[target].nunique())}
|
|
141
|
+
inferred = False
|
|
142
|
+
if task_t is Task.UNKNOWN:
|
|
143
|
+
raise ValueError("Could not infer the task from the target; pass task='classification' or 'regression'.")
|
|
144
|
+
|
|
145
|
+
dev_pos, hold_pos, hold_info = reserve_holdout(df, target, task_t, holdout, groups_s, time_col, seed)
|
|
146
|
+
dev = df.iloc[dev_pos]
|
|
147
|
+
roles: dict[str, str] = {}
|
|
148
|
+
if not split_columns_as_features:
|
|
149
|
+
if isinstance(groups, str):
|
|
150
|
+
roles[groups] = "groups"
|
|
151
|
+
if time_col:
|
|
152
|
+
roles[time_col] = "time_col"
|
|
153
|
+
strat = dev[target].astype(str) if task_t is Task.CLASSIFICATION else None
|
|
154
|
+
dev_s, sampling = sample_rows(dev, options.max_rows, seed, strat)
|
|
155
|
+
if sampling.get("sampled"):
|
|
156
|
+
sampling["of_rows"] = len(dev)
|
|
157
|
+
|
|
158
|
+
profile = profile_tabular(dev_s, target, task_t, inferred, task_ev, roles, options, seed,
|
|
159
|
+
n_rows_input=len(df), sampling=sampling, holdout=hold_info)
|
|
160
|
+
ctx_state = ContextState(
|
|
161
|
+
text=context, answers=_normalize_answers(answers or {}),
|
|
162
|
+
groups=groups if isinstance(groups, str) else ("<array>" if groups is not None else None),
|
|
163
|
+
time_col=time_col, estimator=_describe(estimator), split_columns_as_features=split_columns_as_features)
|
|
164
|
+
out = run_rules(profile, _rule_context(ctx_state, estimator, budget, None))
|
|
165
|
+
_validate_answers(ctx_state.answers, out)
|
|
166
|
+
figures = build_figures(dev_s, profile, out.evidence, out.decisions)
|
|
167
|
+
|
|
168
|
+
tr = load_track_record()
|
|
169
|
+
manifest = Manifest(
|
|
170
|
+
prefit_version=__version__, python=platform.python_version(), packages=_packages(), seed=seed,
|
|
171
|
+
rule_versions=rule_versions(), profile_options=options.model_dump(), budget=budget.model_dump(),
|
|
172
|
+
benchmark=tr.get("manifest_version"), source_fingerprint=fingerprint(df, target),
|
|
173
|
+
history=[{"revision": 0, "action": "analyze", "target": target, "task": task_t.value,
|
|
174
|
+
"task_inferred": inferred, "answers": dict(ctx_state.answers), "groups": ctx_state.groups,
|
|
175
|
+
"time_col": time_col, "holdout": holdout, "estimator": ctx_state.estimator}])
|
|
176
|
+
report = _make_report(profile, ctx_state, out, figures, (), {}, manifest)
|
|
177
|
+
report._holdout_positions = hold_pos
|
|
178
|
+
report._estimator = estimator
|
|
179
|
+
report._row_hashes = row_hashes(df)
|
|
180
|
+
if verify:
|
|
181
|
+
report = _verify(report, df, estimator=estimator, budget=budget, groups=groups)
|
|
182
|
+
if llm is not None:
|
|
183
|
+
report = report.interpret(llm)
|
|
184
|
+
return report
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def load_report(source: str) -> Report:
|
|
188
|
+
"""Load a report saved with `report.to_json(path)` (a path or a JSON string).
|
|
189
|
+
|
|
190
|
+
Holdout rows and the estimator are not stored; `report.holdout(df)` and
|
|
191
|
+
`report.verify(df)` re-derive what they need from `df` after checking its
|
|
192
|
+
fingerprint."""
|
|
193
|
+
text = source
|
|
194
|
+
if not source.lstrip().startswith("{"):
|
|
195
|
+
with open(source, encoding="utf-8") as fh:
|
|
196
|
+
text = fh.read()
|
|
197
|
+
return Report.model_validate_json(text)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def review(y_true: Any, y_pred: Any, report: Report | None = None, *, y_score: Any = None,
|
|
201
|
+
segments: Any = None, task: Task | str | None = None) -> Any:
|
|
202
|
+
"""Post-training review against the roadmap's assumptions (see `prefit.posttrain`)."""
|
|
203
|
+
from .posttrain import review as _review
|
|
204
|
+
|
|
205
|
+
return _review(y_true, y_pred, report=report, y_score=y_score, segments=segments, task=task)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
# --------------------------------------------------------------------------- #
|
|
209
|
+
# internals shared by the Report flow methods
|
|
210
|
+
# --------------------------------------------------------------------------- #
|
|
211
|
+
|
|
212
|
+
def _describe(estimator: Any) -> str | None:
|
|
213
|
+
return None if estimator is None else type(estimator).__name__
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _budget_of(report: Report) -> Budget:
|
|
217
|
+
return Budget(**(report.details.manifest.budget or {}))
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _rule_context(ctx: ContextState, estimator: Any, budget: Budget, external: dict | None) -> RuleContext:
|
|
221
|
+
params: set[str] = set()
|
|
222
|
+
if estimator is not None and hasattr(estimator, "get_params"):
|
|
223
|
+
params = set(estimator.get_params())
|
|
224
|
+
return RuleContext(answers=dict(ctx.answers), groups=ctx.groups, time_col=ctx.time_col,
|
|
225
|
+
has_estimator=estimator is not None, estimator_params=params, n_splits=budget.n_splits,
|
|
226
|
+
n_repeats=budget.n_repeats, split_columns_as_features=ctx.split_columns_as_features,
|
|
227
|
+
external_holdout=dict(external) if external else None)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _normalize_answers(answers: dict[str, str]) -> dict[str, str]:
|
|
231
|
+
return {(k if k.startswith("q.") else f"q.{k}"): str(v) for k, v in answers.items()}
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _validate_answers(answers: dict[str, str], out: RuleOutput) -> None:
|
|
235
|
+
qs = {q.id: q for q in out.questions}
|
|
236
|
+
for k, v in answers.items():
|
|
237
|
+
if k not in qs:
|
|
238
|
+
raise KeyError(f"Unknown question '{k}'. Open questions: {sorted(qs)}")
|
|
239
|
+
allowed = [o.value for o in qs[k].options]
|
|
240
|
+
if v not in allowed:
|
|
241
|
+
raise ValueError(f"Answer '{v}' is not an option for '{k}'. Options: {allowed}")
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _packages() -> dict[str, str]:
|
|
245
|
+
out = {}
|
|
246
|
+
for name in ("numpy", "pandas", "pydantic", "scikit-learn", "scipy", "plotly"):
|
|
247
|
+
try:
|
|
248
|
+
out[name] = metadata.version(name)
|
|
249
|
+
except metadata.PackageNotFoundError:
|
|
250
|
+
pass
|
|
251
|
+
return out
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _make_report(profile, ctx_state, out: RuleOutput, figures, experiments, baseline, manifest,
|
|
255
|
+
llm=None) -> Report:
|
|
256
|
+
has_conditional = any(d.cls is DecisionClass.CONDITIONAL for d in out.decisions)
|
|
257
|
+
tr = load_track_record()
|
|
258
|
+
details = ReportDetails(
|
|
259
|
+
findings=out.findings, evidence=out.evidence, profile=profile, figures=figures, manifest=manifest,
|
|
260
|
+
context=ctx_state, llm=llm,
|
|
261
|
+
limitations=build_limitations({"sampling": profile.sampling, "experiments": experiments,
|
|
262
|
+
"has_conditional": has_conditional}),
|
|
263
|
+
rule_track_record={k: v for k, v in tr.get("rules", {}).items()
|
|
264
|
+
if k in {f.rule_id for f in out.findings}})
|
|
265
|
+
return Report(target=profile.target.name, task=profile.task, decisions=out.decisions,
|
|
266
|
+
questions=out.questions, metric_plan=out.metric_plan, roadmap=build_roadmap(out.decisions),
|
|
267
|
+
experiments=experiments, baseline=baseline, details=details)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _next_revision(manifest: Manifest, action: str, **details: Any) -> Manifest:
|
|
271
|
+
"""A new manifest whose history records this step; earlier reports keep theirs."""
|
|
272
|
+
rev = manifest.revision + 1
|
|
273
|
+
return evolve(manifest, revision=rev, history=[*manifest.history, {"revision": rev, "action": action, **details}])
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _carry(report: Report, new: Report, estimator: Any = None) -> Report:
|
|
277
|
+
new._holdout_positions = report._holdout_positions
|
|
278
|
+
new._estimator = report._estimator if estimator is None else estimator
|
|
279
|
+
new._row_hashes = report._row_hashes
|
|
280
|
+
return new
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _apply_experiments(out: RuleOutput, experiments, notes: dict[str, str], prev: dict[str, Any]) -> None:
|
|
284
|
+
from .verify import status_from_experiments
|
|
285
|
+
|
|
286
|
+
by_dec: dict[str, list] = {}
|
|
287
|
+
for e in experiments:
|
|
288
|
+
by_dec.setdefault(e.decision_id, []).append(e)
|
|
289
|
+
for i, d in enumerate(out.decisions):
|
|
290
|
+
if d.cls is not DecisionClass.CONDITIONAL:
|
|
291
|
+
continue
|
|
292
|
+
if d.id in by_dec:
|
|
293
|
+
exps = by_dec[d.id]
|
|
294
|
+
status, note = status_from_experiments(d, exps, user=any(e.family == "user" for e in exps))
|
|
295
|
+
if d.trigger is not None and d.trigger.value == "uncertain":
|
|
296
|
+
note = (note + " " if note else "") + "The trigger itself was near its threshold."
|
|
297
|
+
if notes.get(d.id) == "skipped_budget":
|
|
298
|
+
note += " The budget ran out before every model family was tested."
|
|
299
|
+
if exps[0].metric != out.metric_plan.dev_metric:
|
|
300
|
+
note += (f" Experiments used {exps[0].metric}; the metric is now {out.metric_plan.dev_metric}: "
|
|
301
|
+
"re-run report.verify(df).")
|
|
302
|
+
out.decisions[i] = evolve(d, status=status, note=note.strip(), experiment_ids=[e.id for e in exps])
|
|
303
|
+
elif d.id in notes:
|
|
304
|
+
n = notes[d.id]
|
|
305
|
+
if n == "skipped_budget":
|
|
306
|
+
out.decisions[i] = evolve(d, status=DecisionStatus.SKIPPED_BUDGET)
|
|
307
|
+
elif n == "not_applicable":
|
|
308
|
+
out.decisions[i] = evolve(d, status=DecisionStatus.NOT_APPLICABLE,
|
|
309
|
+
note="The estimator has no parameter for this change.")
|
|
310
|
+
else:
|
|
311
|
+
out.decisions[i] = evolve(d, note=n)
|
|
312
|
+
elif d.id in prev:
|
|
313
|
+
out.decisions[i] = evolve(d, status=prev[d.id][0], note=prev[d.id][1])
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _rederive(report: Report, *, ctx_state: ContextState, manifest: Manifest, estimator: Any,
|
|
317
|
+
experiments: Any = None, baseline: Any = None, notes: dict[str, str] | None = None,
|
|
318
|
+
llm: Any = "keep") -> Report:
|
|
319
|
+
"""Re-run the rules on the stored profile and rebuild the report around them."""
|
|
320
|
+
d = report.details
|
|
321
|
+
out = run_rules(d.profile, _rule_context(ctx_state, estimator, Budget(**(manifest.budget or {})),
|
|
322
|
+
manifest.external_holdout))
|
|
323
|
+
_validate_answers(ctx_state.answers, out)
|
|
324
|
+
experiments = report.experiments if experiments is None else experiments
|
|
325
|
+
prev = {x.id: (x.status, x.note) for x in report.decisions
|
|
326
|
+
if x.cls is DecisionClass.CONDITIONAL and x.status in
|
|
327
|
+
(DecisionStatus.SKIPPED_BUDGET, DecisionStatus.NOT_APPLICABLE)} if notes is None else {}
|
|
328
|
+
_apply_experiments(out, experiments, notes or {}, prev)
|
|
329
|
+
figures = [f for f in d.figures if not f.id.startswith("fig.exp.")]
|
|
330
|
+
for dec in out.decisions:
|
|
331
|
+
exps = [e for e in experiments if e.decision_id == dec.id]
|
|
332
|
+
if exps:
|
|
333
|
+
figures.append(experiment_figure(dec, exps))
|
|
334
|
+
figures.sort(key=lambda f: (f.priority, f.id))
|
|
335
|
+
_link(figures, out.evidence, out.decisions)
|
|
336
|
+
new = _make_report(d.profile, ctx_state, out, figures, experiments,
|
|
337
|
+
report.baseline if baseline is None else baseline, manifest,
|
|
338
|
+
llm=d.llm if llm == "keep" else llm)
|
|
339
|
+
return _carry(report, new, estimator)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _apply_answers(report: Report, answers: dict[str, str]) -> Report:
|
|
343
|
+
answers = _normalize_answers(answers)
|
|
344
|
+
q_task = answers.get("q.task")
|
|
345
|
+
if q_task is not None and q_task != report.task.value:
|
|
346
|
+
raise ValueError(f"You answered that the task is {q_task}: re-run pf.analyze(df, target, task='{q_task}').")
|
|
347
|
+
ctx = report.details.context
|
|
348
|
+
ctx_state = evolve(ctx, answers={**ctx.answers, **answers})
|
|
349
|
+
manifest = _next_revision(report.details.manifest, "answer", answers=answers)
|
|
350
|
+
return _rederive(report, ctx_state=ctx_state, manifest=manifest, estimator=report._estimator)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _holdout_positions(report: Report, df: pd.DataFrame) -> np.ndarray:
|
|
354
|
+
"""Positions of the internal holdout; re-derived deterministically when not in memory."""
|
|
355
|
+
if report._holdout_positions is not None:
|
|
356
|
+
return np.asarray(report._holdout_positions, dtype=int)
|
|
357
|
+
h = report.details.profile.holdout
|
|
358
|
+
if not h.get("used"):
|
|
359
|
+
return np.array([], dtype=int)
|
|
360
|
+
ctx = report.details.context
|
|
361
|
+
if ctx.groups == "<array>":
|
|
362
|
+
raise ValueError("This report was built with a groups array; holdout positions cannot be re-derived "
|
|
363
|
+
"after loading. Re-run pf.analyze.")
|
|
364
|
+
g = df[ctx.groups] if ctx.groups else None
|
|
365
|
+
_, hold, _ = reserve_holdout(df, report.target, report.task, h["fraction"], g, ctx.time_col,
|
|
366
|
+
report.details.manifest.seed)
|
|
367
|
+
return hold
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _holdout(report: Report, df: pd.DataFrame) -> tuple[pd.DataFrame, pd.DataFrame]:
|
|
371
|
+
m = report.details.manifest
|
|
372
|
+
check_fingerprint(df, m.source_fingerprint, report.target, "report.holdout", report._row_hashes)
|
|
373
|
+
if not report.details.profile.holdout.get("used"):
|
|
374
|
+
raise ValueError("This report has no internal holdout (analyze ran with holdout=None). If you split with "
|
|
375
|
+
"pf.split, your test frame is already separate; record it with "
|
|
376
|
+
"report.register_external_holdout(test_df).")
|
|
377
|
+
hold = _holdout_positions(report, df)
|
|
378
|
+
mask = np.zeros(len(df), dtype=bool)
|
|
379
|
+
mask[hold] = True
|
|
380
|
+
return df.iloc[~mask], df.iloc[mask]
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _register_external_holdout(report: Report, holdout_df: Any, dev_df: Any = None,
|
|
384
|
+
name: str = "external holdout") -> Report:
|
|
385
|
+
if not isinstance(holdout_df, pd.DataFrame):
|
|
386
|
+
raise TypeError("holdout_df must be a pandas DataFrame.")
|
|
387
|
+
if report.target not in holdout_df.columns:
|
|
388
|
+
raise ValueError(f"holdout_df has no target column '{report.target}'.")
|
|
389
|
+
m = report.details.manifest
|
|
390
|
+
fp = fingerprint(holdout_df, report.target)
|
|
391
|
+
meta: dict[str, Any] = {
|
|
392
|
+
"name": name, "n_rows": fp["n_rows"], "n_columns": fp["n_columns"],
|
|
393
|
+
"same_columns_as_dev": fp["column_schema_digest"] == m.source_fingerprint.get("column_schema_digest"),
|
|
394
|
+
"content_digest": fp["content_digest"], "column_schema_digest": fp["column_schema_digest"],
|
|
395
|
+
"profiled": False, "overlap_rows": None,
|
|
396
|
+
}
|
|
397
|
+
if dev_df is not None:
|
|
398
|
+
check_fingerprint(dev_df, m.source_fingerprint, report.target, "register_external_holdout(dev_df=...)",
|
|
399
|
+
report._row_hashes)
|
|
400
|
+
common = [c for c in dev_df.columns if c in holdout_df.columns]
|
|
401
|
+
dev_rows = set(row_hashes(dev_df[common]).tolist())
|
|
402
|
+
hold_rows = row_hashes(holdout_df[common])
|
|
403
|
+
overlap = int(sum(h in dev_rows for h in hold_rows.tolist()))
|
|
404
|
+
meta["overlap_rows"] = overlap
|
|
405
|
+
meta["overlap_columns_compared"] = len(common)
|
|
406
|
+
if overlap:
|
|
407
|
+
warnings.warn(f"Exact row overlap detected between development and {name} ({overlap} rows). "
|
|
408
|
+
"This may inflate evaluation; inspect whether the duplicated observations are legitimate "
|
|
409
|
+
"repeats.", stacklevel=3)
|
|
410
|
+
manifest = evolve(_next_revision(m, "register_external_holdout", name=name, n_rows=fp["n_rows"],
|
|
411
|
+
overlap_rows=meta["overlap_rows"]), external_holdout=meta)
|
|
412
|
+
return _rederive(report, ctx_state=report.details.context, manifest=manifest, estimator=report._estimator)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _verify(report: Report, df: pd.DataFrame, estimator: Any = None, budget: Budget | None = None,
|
|
416
|
+
groups: Any = None) -> Report:
|
|
417
|
+
from .verify import run_verification
|
|
418
|
+
|
|
419
|
+
m = report.details.manifest
|
|
420
|
+
check_fingerprint(df, m.source_fingerprint, report.target, "report.verify", report._row_hashes)
|
|
421
|
+
budget = budget or _budget_of(report)
|
|
422
|
+
estimator = estimator if estimator is not None else report._estimator
|
|
423
|
+
hold = _holdout_positions(report, df)
|
|
424
|
+
mask = np.zeros(len(df), dtype=bool)
|
|
425
|
+
mask[hold] = True
|
|
426
|
+
dev = df.iloc[~mask]
|
|
427
|
+
ctx = report.details.context
|
|
428
|
+
g: Any = None
|
|
429
|
+
if ctx.groups and ctx.groups != "<array>":
|
|
430
|
+
g = ctx.groups
|
|
431
|
+
elif ctx.groups == "<array>":
|
|
432
|
+
if groups is None or isinstance(groups, str):
|
|
433
|
+
raise ValueError("This report was built with a groups array; pass the same array: "
|
|
434
|
+
"report.verify(df, groups=...).")
|
|
435
|
+
arr = np.asarray(groups).ravel()
|
|
436
|
+
if len(arr) != len(df):
|
|
437
|
+
raise ValueError("groups must have one value per row of df.")
|
|
438
|
+
g = arr[~mask]
|
|
439
|
+
|
|
440
|
+
experiments, baseline, notes = run_verification(report, dev, estimator, budget, g, m.seed)
|
|
441
|
+
ctx_state = evolve(ctx, estimator=_describe(estimator))
|
|
442
|
+
manifest = _next_revision(evolve(m, budget=budget.model_dump()), "verify",
|
|
443
|
+
estimator=_describe(estimator) or "proxies (linear + tree)",
|
|
444
|
+
experiments=len(experiments), budget=budget.model_dump(),
|
|
445
|
+
protocol=experiments[0].protocol if experiments else None,
|
|
446
|
+
cv=baseline.get("cv"))
|
|
447
|
+
return _rederive(report, ctx_state=ctx_state, manifest=manifest, estimator=estimator,
|
|
448
|
+
experiments=experiments, baseline=baseline, notes=notes)
|
prefit/assemble.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Assemble the ordered roadmap, the limitations and the rule track record."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from importlib import resources
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .enums import DecisionStatus
|
|
10
|
+
from .report.model import Decision, RoadmapStage
|
|
11
|
+
|
|
12
|
+
STAGES = [
|
|
13
|
+
("split", "Set up validation"),
|
|
14
|
+
("data_review", "Review the data"),
|
|
15
|
+
("columns", "Choose the columns"),
|
|
16
|
+
("preprocessing", "Preprocess inside the folds"),
|
|
17
|
+
("target", "Target and class handling"),
|
|
18
|
+
("metrics", "Pick the metric"),
|
|
19
|
+
("final", "Final evaluation"),
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
_STATUS_WORDS = {
|
|
23
|
+
DecisionStatus.APPLIED: "applied",
|
|
24
|
+
DecisionStatus.PROPOSED: "untested",
|
|
25
|
+
DecisionStatus.UNCERTAIN: "near threshold",
|
|
26
|
+
DecisionStatus.VERIFIED: "verified",
|
|
27
|
+
DecisionStatus.SUPPORTED_BY_PROXY: "proxy-supported",
|
|
28
|
+
DecisionStatus.INCONCLUSIVE: "inconclusive",
|
|
29
|
+
DecisionStatus.REFUTED: "refuted",
|
|
30
|
+
DecisionStatus.NOT_APPLICABLE: "not applicable",
|
|
31
|
+
DecisionStatus.SKIPPED_BUDGET: "skipped (budget)",
|
|
32
|
+
DecisionStatus.AWAITING_USER: "awaiting your answer",
|
|
33
|
+
DecisionStatus.CONFIRMED: "confirmed",
|
|
34
|
+
DecisionStatus.DECLINED: "declined",
|
|
35
|
+
DecisionStatus.NEEDS_REVIEW: "needs review",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def status_word(s: DecisionStatus) -> str:
|
|
40
|
+
return _STATUS_WORDS[s]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def build_roadmap(decisions: list[Decision]) -> list[RoadmapStage]:
|
|
44
|
+
"""Stages that have decisions, numbered consecutively (empty stages are skipped)."""
|
|
45
|
+
out: list[RoadmapStage] = []
|
|
46
|
+
for stage, title in STAGES:
|
|
47
|
+
ds = [d for d in decisions if d.stage == stage]
|
|
48
|
+
if not ds:
|
|
49
|
+
continue
|
|
50
|
+
counts: dict[str, int] = {}
|
|
51
|
+
for d in ds:
|
|
52
|
+
w = status_word(d.status)
|
|
53
|
+
counts[w] = counts.get(w, 0) + 1
|
|
54
|
+
out.append(RoadmapStage(stage=stage, title=f"{len(out) + 1}. {title}", decision_ids=[d.id for d in ds],
|
|
55
|
+
summary=" · ".join(f"{n} {w}" for w, n in counts.items())))
|
|
56
|
+
return out
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def build_limitations(report_like: dict[str, Any]) -> list[str]:
|
|
60
|
+
lim = [
|
|
61
|
+
"Leakage screening covers single-feature association and numeric correlation only. Leakage through "
|
|
62
|
+
"combinations of features, through time, through preprocessing, or through repeated entities is not "
|
|
63
|
+
"detected; a clean screen is not proof of no leakage.",
|
|
64
|
+
"Thresholds are rule triggers, not verdicts. Each finding shows its rule version and thresholds; values "
|
|
65
|
+
"whose interval straddles a threshold are marked 'near threshold'.",
|
|
66
|
+
"Column kinds and the task are inferred heuristically; check the schema table.",
|
|
67
|
+
]
|
|
68
|
+
sampling = report_like.get("sampling") or {}
|
|
69
|
+
if sampling.get("sampled"):
|
|
70
|
+
lim.append(f"Profiling used a seeded sample of {sampling['n_rows']} of {sampling['of_rows']} development rows.")
|
|
71
|
+
exps = report_like.get("experiments") or []
|
|
72
|
+
if exps:
|
|
73
|
+
lim.append("Experiments test one change at a time against the same base setup; interactions between "
|
|
74
|
+
"changes are not measured.")
|
|
75
|
+
lim.append("Experiment results are development evidence from cross-validation on the dev rows. A claim "
|
|
76
|
+
"about generalization needs the untouched holdout (or nested CV when there is none).")
|
|
77
|
+
if any(e.family != "user" for e in exps):
|
|
78
|
+
lim.append("Proxy-model results are a sensitivity analysis; they do not stand in for your final "
|
|
79
|
+
"model. Pass estimator= to verify decisions for your model.")
|
|
80
|
+
elif report_like.get("has_conditional"):
|
|
81
|
+
lim.append("Conditional decisions were not tested (verify was off); none of them is in the exported "
|
|
82
|
+
"pipeline.")
|
|
83
|
+
return lim
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def load_track_record() -> dict[str, Any]:
|
|
87
|
+
try:
|
|
88
|
+
text = resources.files("prefit").joinpath("data/track_record.json").read_text(encoding="utf-8")
|
|
89
|
+
except (FileNotFoundError, ModuleNotFoundError, OSError):
|
|
90
|
+
return {}
|
|
91
|
+
return json.loads(text)
|
prefit/cli.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Command line: `prefit report data.csv --target y --out report.html`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
import pandas as pd
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _read(path: str) -> pd.DataFrame:
|
|
12
|
+
if path.endswith((".parquet", ".pq")):
|
|
13
|
+
return pd.read_parquet(path)
|
|
14
|
+
if path.endswith((".tsv", ".tab")):
|
|
15
|
+
return pd.read_csv(path, sep="\t")
|
|
16
|
+
return pd.read_csv(path)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def main(argv: list[str] | None = None) -> int:
|
|
20
|
+
ap = argparse.ArgumentParser(prog="prefit", description="Decision-driven EDA for tabular supervised learning.")
|
|
21
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
22
|
+
rp = sub.add_parser("report", help="analyze a CSV/Parquet file and write an HTML report")
|
|
23
|
+
rp.add_argument("data")
|
|
24
|
+
rp.add_argument("--target", required=True)
|
|
25
|
+
rp.add_argument("--task", choices=["classification", "regression"])
|
|
26
|
+
rp.add_argument("--groups", help="column identifying entities that must not be split across folds")
|
|
27
|
+
rp.add_argument("--time-col", help="column defining time order")
|
|
28
|
+
rp.add_argument("--holdout", type=float, default=None,
|
|
29
|
+
help="ask prefit to set aside this fraction before profiling (default: none)")
|
|
30
|
+
rp.add_argument("--answer", action="append", default=[], metavar="QUESTION=OPTION",
|
|
31
|
+
help="answer a report question, e.g. metric_priority=miss_costly (repeatable)")
|
|
32
|
+
rp.add_argument("--verify", action="store_true", help="run the bounded experiments (proxy models)")
|
|
33
|
+
rp.add_argument("--max-experiments", type=int, default=6)
|
|
34
|
+
rp.add_argument("--seed", type=int, default=0)
|
|
35
|
+
rp.add_argument("--out", default="prefit_report.html")
|
|
36
|
+
rp.add_argument("--json", help="also write the report as JSON")
|
|
37
|
+
rp.add_argument("--plotlyjs", choices=["cdn", "inline", "none"], default="cdn")
|
|
38
|
+
a = ap.parse_args(argv)
|
|
39
|
+
|
|
40
|
+
import prefit as pf
|
|
41
|
+
|
|
42
|
+
answers = {}
|
|
43
|
+
for item in a.answer:
|
|
44
|
+
if "=" not in item:
|
|
45
|
+
ap.error(f"--answer expects QUESTION=OPTION, got {item!r}")
|
|
46
|
+
k, v = item.split("=", 1)
|
|
47
|
+
answers[k] = v
|
|
48
|
+
df = _read(a.data)
|
|
49
|
+
report = pf.analyze(df, a.target, task=a.task, groups=a.groups, time_col=a.time_col,
|
|
50
|
+
holdout=a.holdout, answers=answers, verify=a.verify,
|
|
51
|
+
budget=pf.Budget(max_experiments=a.max_experiments), seed=a.seed)
|
|
52
|
+
report.to_html(a.out, plotlyjs=a.plotlyjs)
|
|
53
|
+
if a.json:
|
|
54
|
+
report.to_json(a.json)
|
|
55
|
+
print(report.to_text())
|
|
56
|
+
print(f"HTML report written to {a.out}", file=sys.stderr)
|
|
57
|
+
return 0
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
if __name__ == "__main__": # pragma: no cover
|
|
61
|
+
raise SystemExit(main())
|
prefit/config.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""User-tunable limits. Defaults are deliberately conservative: prefit must
|
|
2
|
+
stay a fast pre-training step, never turn into an AutoML search."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, Field
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Budget(BaseModel):
|
|
10
|
+
"""Hard limits for the verification experiments.
|
|
11
|
+
|
|
12
|
+
Each conditional decision is tested at most once per model family, as
|
|
13
|
+
"current setup vs. current setup + this change" — no combinations, no search.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
max_experiments: int = Field(6, ge=0, description="Total experiment runs (decision x model family).")
|
|
17
|
+
max_seconds: float = Field(180.0, gt=0, description="Wall-clock budget for all experiments.")
|
|
18
|
+
max_rows: int = Field(20_000, ge=100, description="Dev rows used for experiments (stratified subsample above this).")
|
|
19
|
+
n_splits: int = Field(5, ge=2)
|
|
20
|
+
n_repeats: int = Field(2, ge=1)
|
|
21
|
+
alpha: float = Field(0.05, gt=0, lt=1, description="Family-wise error rate (Holm-corrected).")
|
|
22
|
+
min_relative_effect: float = Field(
|
|
23
|
+
0.01, ge=0, description="Smallest improvement that counts, as a fraction of |baseline score| "
|
|
24
|
+
"(e.g. 0.01 = 1%). Smaller significant differences are reported as negligible.")
|
|
25
|
+
min_effect: float | None = Field(
|
|
26
|
+
None, ge=0, description="Absolute practical threshold in metric units; overrides min_relative_effect.")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ProfileOptions(BaseModel):
|
|
30
|
+
"""Limits for the profiling stage."""
|
|
31
|
+
|
|
32
|
+
max_rows: int = Field(200_000, ge=1_000, description="Rows profiled; above this a seeded sample is used.")
|
|
33
|
+
n_bootstrap: int = Field(200, ge=0, description="Bootstrap resamples for threshold-relevant statistics.")
|
|
34
|
+
bootstrap_rows: int = Field(5_000, ge=100, description="Rows per bootstrap resample (subsampled above this).")
|
|
35
|
+
max_corr_features: int = Field(20, ge=2, description="Numeric features shown in the correlation matrix.")
|
|
36
|
+
redact_values: bool = Field(
|
|
37
|
+
False, description="Hide category labels everywhere (figures, report). Counts are kept.")
|
|
38
|
+
max_labelled_cardinality: int = Field(
|
|
39
|
+
50, description="Category labels are only shown for columns at or below this cardinality.")
|