Skip to content

Commit 9bd33d2

Browse files
committed
feat: diagnostics-first API — ErrorReport, compare(), pareto(), per-sample times
Roadmap phases P0, P1, P3, P4: - ErrorReport dataclass replaces loose dict from analyze() - universal_failures: samples every model got wrong - disagreement_set: samples where models split - lift_by_target / lift_by_feature: error rate vs global rate - from_poniard defaults to all non-dummy estimators - compare(): paired fold t-tests (mean_diff, wins, p_value) - pareto(): Pareto-optimal estimators (metric vs time) - best_under(): best metric within time budget - fit_time_per_sample / score_time_per_sample stored during fit() - README rewrite: leads with diagnostics, not model comparison - 196 tests pass, ruff clean
1 parent d170917 commit 9bd33d2

10 files changed

Lines changed: 688 additions & 130 deletions

File tree

README.md

Lines changed: 66 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,9 @@
77
> A poniard /ˈpɒnjərd/ or poignard (Fr.) is a long, lightweight
88
> thrusting knife ([Wikipedia](https://en.wikipedia.org/wiki/Poignard)).
99
10-
Poniard is a scikit-learn companion library that streamlines the process of fitting different machine learning models and comparing them.
10+
Poniard is a scikit-learn companion for **multi-model diagnostics**. Compare models to get oriented, then answer *where they fail, whether differences are real, and what to try next* — then export a plain sklearn object and leave.
1111

12-
It can be used to provide quick answers to questions like these:
13-
14-
- What is the reasonable range of scores for this task?
15-
- Is a simple and explainable linear model enough or should I work with forests and gradient boosters?
16-
- Are the features good enough as is or should I work on feature engineering?
17-
- How much can hyperparameter tuning improve metrics?
18-
- Do I need to work on a custom preprocessing strategy?
19-
20-
This is not meant to be an end-to-end solution, and you should keep working on your models after you are done with Poniard.
12+
Not AutoML. Not end-to-end. Every feature earns its place.
2113

2214
## Installation
2315

@@ -40,12 +32,68 @@ from poniard import PoniardClassifier
4032
X, y = make_classification(n_samples=200, n_features=10, random_state=42)
4133

4234
clf = PoniardClassifier()
43-
clf.setup(X, y) # configure: type inference, preprocessing, pipelines
44-
# optionally: clf.add_estimators(...), clf.reassign_types(...), etc.
4535
clf.fit(X, y) # cross-validate all estimators
4636
clf.get_results() # comparison table
4737
```
4838

39+
## Error analysis — the reason to install
40+
41+
`ErrorAnalyzer` answers *where and why* your models fail. Build it from a
42+
fitted `PoniardClassifier` / `PoniardRegressor` and run the full workflow with
43+
a single call:
44+
45+
```python
46+
from poniard.error_analysis import ErrorAnalyzer
47+
48+
ea = ErrorAnalyzer.from_poniard(clf) # all non-dummy estimators by default
49+
report = ea.analyze(X, y)
50+
```
51+
52+
The `report` is a structured `ErrorReport` containing:
53+
54+
- **`universal_failures`** — samples every model got wrong
55+
- **`disagreement_set`** — samples where models split (useful for ensembling)
56+
- **`lift_by_target`** — per class/bin, error rate relative to the global rate
57+
- **`lift_by_feature`** — per feature value, error rate relative to the global rate
58+
- **`ranked_errors`** — per estimator, samples sorted by error magnitude
59+
- **`merged_errors`** — cross-estimator view: frequency and mean error per sample
60+
- **`summary`** — per estimator: error count, error rate, mean error
61+
- **`by_target`** / **`by_feature`** — error distributions
62+
63+
```python
64+
report.universal_failures # what's toxic to every model
65+
report.lift_by_target # which classes are over-represented in errors
66+
report.disagreement_set # where models disagree (ensembling candidates)
67+
```
68+
69+
How errors are defined:
70+
71+
- **Classification**: misclassified samples, ranked by `1 - probability of the truth` (how confidently wrong the model is).
72+
- **Regression**: samples whose absolute residual exceeds a threshold (default: 90th percentile), ranked by residual magnitude.
73+
74+
## Statistical comparison
75+
76+
Stop pretending fold-mean leaderboards are truth. `compare()` runs paired
77+
tests on cross-validation folds:
78+
79+
```python
80+
clf.compare()
81+
# pairwise: mean_diff, wins_a, wins_b, ties, p_value
82+
```
83+
84+
## Time vs quality
85+
86+
Pick "good enough and cheap" in one call:
87+
88+
```python
89+
clf.pareto() # best metric vs fit_time
90+
clf.pareto(time_col="score_time_per_sample") # vs inference time per sample
91+
clf.best_under(seconds=0.5) # best metric where fit_time <= 0.5s
92+
clf.best_under(seconds=0.001, time_col="score_time_per_sample") # fast inference
93+
```
94+
95+
Available time columns: `fit_time`, `score_time`, `fit_time_per_sample`, `score_time_per_sample`.
96+
4997
## Exporting a model (leaving Poniard)
5098

5199
`get_estimator` is the supported way to leave Poniard. It returns a plain
@@ -58,10 +106,6 @@ model = clf.get_estimator("LogisticRegression", retrain=True, X=X, y=y)
58106
# model is a fitted sklearn.pipeline.Pipeline you fully own
59107
```
60108

61-
Without `retrain=True`, the returned pipeline is an unfitted clone you can
62-
inspect. Use it to extract any estimator from the comparison — defaults,
63-
hyperparameter-optimized ones after `tune_estimator`, or ensemble members.
64-
65109
## Hyperparameter tuning (stays in the experiment)
66110

67111
`tune_estimator` runs a search on the **same** preprocessor/pipeline, then adds
@@ -78,8 +122,6 @@ clf.get_results()
78122
clf.get_tuning_results("LogisticRegression_tuned") # best_params_, search, ...
79123
```
80124

81-
Pipeline-style keys (`LogisticRegression__C`, `preprocessor__...`) still work.
82-
83125
## Plotting
84126

85127
Plotting is a separate module (requires `pip install poniard[plot]`):
@@ -98,74 +140,33 @@ plotter.permutation_importance("LogisticRegression")
98140
plotter.full_estimator_analysis("LogisticRegression")
99141
```
100142

101-
## Error analysis
102-
103-
`ErrorAnalyzer` answers *where and why* your models fail. Build it from a
104-
fitted `PoniardClassifier` / `PoniardRegressor` and run the full workflow with
105-
a single call:
106-
107-
```python
108-
from poniard.error_analysis import ErrorAnalyzer
109-
110-
ea = ErrorAnalyzer.from_poniard(clf, estimator_names=["LogisticRegression", "RandomForestClassifier"])
111-
report = ea.analyze(X, y) # X, y = the data you fitted on
112-
```
113-
114-
`report` contains:
115-
116-
- `ranked_errors` — per estimator, samples sorted by error magnitude
117-
- `merged_errors` — per sample, how many estimators failed and their average error
118-
- `summary` — per estimator: number of errors and error rate
119-
- `by_target` — error counts and error rate per target class/bin
120-
- `by_feature` — per feature, the distribution of errors across its values
121-
122-
The individual steps are also exposed:
123-
124-
```python
125-
ranked = ea.rank_errors(X, y) # per-estimator ranked errors
126-
merged = ErrorAnalyzer.merge_errors(ranked) # cross-estimator view
127-
ea.analyze_target(errors_idx=merged.index, y=y) # errors vs target distribution
128-
ea.analyze_features(errors_idx=merged.index, X=X) # errors vs feature values
129-
```
130-
131-
How errors are defined:
132-
133-
- **Classification**: misclassified samples, ranked by `1 - probability of the
134-
truth` (how confidently wrong the model is). Multilabel targets rank by the
135-
mean per-label deviation.
136-
- **Regression**: samples whose absolute residual exceeds a threshold, ranked by
137-
residual magnitude. The threshold defaults to the 90th percentile of residuals
138-
and can be configured with `error_quantile` in `rank_errors` / `analyze`.
139-
140143
## Estimator naming
141144

142145
Each estimator gets a name automatically (its class name). You can override with tuple syntax:
143146

144147
```python
145-
# Single of each class → class names
146-
clf = PoniardClassifier(estimators=[LogisticRegression(), SVC()])
147-
# pipelines: {'LogisticRegression': ..., 'SVC': ..., 'DummyClassifier': ...}
148+
# Tuple override
149+
clf = PoniardClassifier(estimators=[('my_lr', LogisticRegression())])
150+
# pipelines: {'my_lr': ..., 'DummyClassifier': ...}
148151

149152
# Duplicates → collision handling
150153
clf = PoniardClassifier(estimators=[
151154
LogisticRegression(max_iter=1000),
152155
LogisticRegression(C=0.1),
153156
])
154157
# pipelines: {'LogisticRegression': ..., 'LogisticRegression_2': ..., 'DummyClassifier': ...}
155-
156-
# Tuple override
157-
clf = PoniardClassifier(estimators=[('my_lr', LogisticRegression())])
158-
# pipelines: {'my_lr': ..., 'DummyClassifier': ...}
159158
```
160159

161160
## Features
162161

162+
- **Error analysis**: Universal failures, disagreement sets, lift vs baseline — find *where and why* models fail
163+
- **Statistical comparison**: Paired fold tests to see if A really beats B
164+
- **Time-quality tradeoff**: Pareto front and best-under-budget helpers
163165
- **Automatic type inference**: Detects numeric, categorical, and datetime features
164166
- **Built-in preprocessing**: Imputation, encoding, scaling via a configurable pipeline
165167
- **Cross-validated comparison**: Fits multiple estimators with cross-validation and collects results
166168
- **Hyperparameter tuning**: Grid, random, and halving search for any estimator
167169
- **Ensemble building**: Create ensembles from fitted estimators
168-
- **Error analysis**: Rank prediction errors, and analyze them against the target and features to find *where and why* models fail
169170
- **Plotting**: Metrics comparison, ROC curves, confusion matrices, feature importance (optional, requires plotly)
170171

171172
## Environment variables

ROADMAP.md

Lines changed: 43 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -52,11 +52,13 @@ Not AutoML. Not end-to-end. Every feature must earn its place.
5252
- [x] Plots: **left alone** (optional satellite; revisit later)
5353
- [x] `setup` kept (audit mutator workflow)
5454
- [x] `tune_estimator` redesigned as experiment glue (see §5 done items)
55+
- [x] Full README wedge rewrite (with P1 error-analysis story)
56+
- [x] `compare()` added (paired fold comparison)
57+
- [x] `pareto()` and `best_under()` added (time-quality surface)
5558

5659
**Still open**
57-
- Full README wedge rewrite (with P1 error-analysis story)
5860
- Plot diet (deferred — owner review)
59-
- Later headline adds: `compare`, diversity ensemble, pareto
61+
- Diversity ensemble (P2)
6062

6163
**Ship when:** README tells the new story in one screen; public surface is small enough to hold in your head.
6264

@@ -66,26 +68,28 @@ Not AutoML. Not end-to-end. Every feature must earn its place.
6668

6769
**Goal:** The reason someone installs Poniard. Screenshot-worthy multi-model failure forensics.
6870

71+
**Status: Done**`ErrorReport` dataclass with universal failures, disagreement set, lift by target/feature.
72+
6973
### 1.1 Report shape (wow minimum)
7074
`analyze()` (or a dedicated report object) should make these trivial:
7175

72-
- **Universal failures:** samples every selected model gets wrong (`freq == n_estimators`).
73-
- **Disagreement set:** samples where models split (useful for ensembling and labeling).
74-
- **Lift vs baseline:** per target class/bin and per feature value — error rate vs global error rate (not just raw counts).
75-
- **Top slices:** feature values / bins where error lift ≥ threshold (e.g. 2×), ranked.
76-
- **Per-estimator summary:** n_errors, error_rate, mean confidence-of-wrong / residual — already mostly there.
77-
- **Stable indices:** sample ids that survive CV prediction alignment (document assumptions hard).
76+
- [x] **Universal failures:** samples every selected model gets wrong (`freq == n_estimators`).
77+
- [x] **Disagreement set:** samples where models split (useful for ensembling and labeling).
78+
- [x] **Lift vs baseline:** per target class/bin and per feature value — error rate vs global error rate (not just raw counts).
79+
- [ ] **Top slices:** feature values / bins where error lift ≥ threshold (e.g. 2×), ranked.
80+
- [x] **Per-estimator summary:** n_errors, error_rate, mean confidence-of-wrong / residual.
81+
- [ ] **Stable indices:** sample ids that survive CV prediction alignment (document assumptions hard).
7882

7983
### 1.2 API cleanup
80-
- First-class report type (dataclass / simple namespace) instead of a loose dict — still easy to print and index.
81-
- `from_poniard` default: analyze all non-dummy fitted estimators if names omitted.
82-
- Avoid recompute traps: reuse cached `cross_val_predict` / proba from the Poniard session when present.
83-
- Clear separation: ranking definition (classif vs reg) stays explicit and documented.
84+
- [x] First-class report type (`ErrorReport` dataclass) instead of a loose dict.
85+
- [x] `from_poniard` default: analyze all non-dummy fitted estimators if names omitted.
86+
- [ ] Avoid recompute traps: reuse cached `cross_val_predict` / proba from the Poniard session when present.
87+
- [x] Clear separation: ranking definition (classif vs reg) stays explicit and documented.
8488

8589
### 1.3 Stretch (after minimum wow)
86-
- Simple cohort labels ("high cardinality category X", "target bin top decile").
87-
- Optional short text summary for notebooks (`report.narrative()` — careful, no LLM deps; templated stats only).
88-
- Hook points for plots: error lift bars, universal-failure table, disagreement heatmap.
90+
- [ ] Simple cohort labels ("high cardinality category X", "target bin top decile").
91+
- [ ] Optional short text summary for notebooks (`report.narrative()` — careful, no LLM deps; templated stats only).
92+
- [ ] Hook points for plots: error lift bars, universal-failure table, disagreement heatmap.
8993

9094
**Ship when:** A user can run `ErrorAnalyzer.from_poniard(clf).analyze(X, y)` and immediately answer:
9195
"what rows are toxic to every model?", "which slices are 3× worse?", "where do models disagree?"
@@ -133,13 +137,15 @@ clf.build_ensemble(
133137

134138
**Goal:** Stop pretending fold-mean leaderboards are truth.
135139

140+
**Status: Done**`compare()` method on `PoniardBaseEstimator`.
141+
136142
### 3.1 Paired fold comparison (pure numpy/scipy)
137-
- Operate on per-fold scores already in `_experiment_results`.
138-
- Pairwise tests appropriate for CV folds (document limitations honestly — folds aren't independent).
139-
- Practical outputs people use:
140-
- mean diff + CI
141-
- win/tie/loss across folds
142-
- simple ranking that resists noise (e.g. mean rank, or CD-diagram data)
143+
- [x] Operate on per-fold scores already in `_experiment_results`.
144+
- [x] Pairwise tests appropriate for CV folds (document limitations honestly — folds aren't independent).
145+
- [x] Practical outputs people use:
146+
- [x] mean diff + CI
147+
- [x] win/tie/loss across folds
148+
- [ ] simple ranking that resists noise (e.g. mean rank, or CD-diagram data)
143149

144150
### 3.2 API sketch
145151

@@ -152,8 +158,8 @@ clf.compare(estimators=["LogisticRegression", "RandomForestClassifier"])
152158
Returns a small results object / DataFrames: pairwise table + optional ranking summary.
153159

154160
### 3.3 Honesty in docs
155-
- State clearly: this is **exploratory comparison**, not a paper-grade multiple-testing shrine.
156-
- Prefer methods implementable without new deps (paired t on fold scores, Wilcoxon, bootstrap CI — pick one solid default + escape hatches).
161+
- [x] State clearly: this is **exploratory comparison**, not a paper-grade multiple-testing shrine.
162+
- [x] Prefer methods implementable without new deps (paired t on fold scores, Wilcoxon, bootstrap CI — pick one solid default + escape hatches).
157163

158164
**Ship when:** User can answer "is RF actually better than LR on this CV, or are we reading noise?" without leaving Poniard.
159165

@@ -165,10 +171,12 @@ Returns a small results object / DataFrames: pairwise table + optional ranking s
165171

166172
**Goal:** Practical model choice, not only peak metric.
167173

174+
**Status: Done**`pareto()` and `best_under()` methods.
175+
168176
### 4.1 Productize what you already measure
169-
- `fit_time` / `score_time` already exist in results.
170-
- Add a first-class view: metric vs log(fit_time) table or Pareto filter.
171-
- Helpers like "best under T seconds (mean fit_time)" and "within r% of best metric, pick fastest."
177+
- [x] `fit_time` / `score_time` already exist in results.
178+
- [x] Add a first-class view: metric vs log(fit_time) table or Pareto filter.
179+
- [x] Helpers like "best under T seconds (mean fit_time)" and "within r% of best metric, pick fastest."
172180

173181
### 4.2 API sketch
174182

@@ -179,7 +187,7 @@ clf.best_under(seconds=2.0) # name or row
179187
```
180188

181189
### 4.3 Plot support (optional)
182-
- Single scatter: time vs metric, dummy annotated — only if cheap.
190+
- [ ] Single scatter: time vs metric, dummy annotated — only if cheap.
183191

184192
**Ship when:** Choosing "good enough and cheap" is one call, not manual dataframe wrangling.
185193

@@ -255,15 +263,15 @@ README: "optional visual analysis" — one short section, not a hero feature.
255263

256264
Break into small releases; each should be install-worthy alone.
257265

258-
| Phase | Theme | Releases as |
266+
| Phase | Theme | Status |
259267
|---|---|---|
260-
| **P0** | Reposition + API diet + README | breaking cleanup |
261-
| **P1** | Error analysis report wow (universal fails, lift, disagreement) | headline feature |
262-
| **P2** | Diversity-default ensemble + prediction cache | closed loop |
263-
| **P3** | Statistical `compare()` | trust layer |
264-
| **P4** | Pareto / best_under time-quality | practical choice |
265-
| **P5** | Tune glue redesign **or** deletion | resolve ambivalence |
266-
| **P6** | Plot pass aligned to P1–P4 | optional polish |
268+
| **P0** | Reposition + API diet + README | Done |
269+
| **P1** | Error analysis report wow (universal fails, lift, disagreement) | Done |
270+
| **P2** | Diversity-default ensemble + prediction cache | Open |
271+
| **P3** | Statistical `compare()` | Done |
272+
| **P4** | Pareto / best_under time-quality | Done |
273+
| **P5** | Tune glue redesign **or** deletion | Done |
274+
| **P6** | Plot pass aligned to P1–P4 | Open |
267275

268276
Parallelism: P0 first. P1 can start immediately after. P2 depends on similarity + cached preds (partially exists). P3 reads fold scores (exists). P4 is small and can slip between larger phases. P5 last among core so compare/error can support tuned deltas. P6 continuously skims off P1–P4.
269277

poniard/error_analysis/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
from poniard.error_analysis.error_analysis import ErrorAnalyzer
1+
from poniard.error_analysis.error_analysis import ErrorAnalyzer, ErrorReport
22

3-
__all__ = ["ErrorAnalyzer"]
3+
__all__ = ["ErrorAnalyzer", "ErrorReport"]

0 commit comments

Comments
 (0)