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 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.")