easyclassifier 0.8.1__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.
- easyclassifier/__init__.py +23 -0
- easyclassifier/__main__.py +61 -0
- easyclassifier/dataset.py +142 -0
- easyclassifier/demo_data.py +32 -0
- easyclassifier/diagnostics.py +95 -0
- easyclassifier/distances.py +152 -0
- easyclassifier/evaluation.py +340 -0
- easyclassifier/figures.py +510 -0
- easyclassifier/help_texts.py +113 -0
- easyclassifier/importance.py +79 -0
- easyclassifier/latex_report.py +555 -0
- easyclassifier/logbook.py +31 -0
- easyclassifier/models.py +94 -0
- easyclassifier/preprocessing.py +219 -0
- easyclassifier/recommend.py +128 -0
- easyclassifier/reporting.py +70 -0
- easyclassifier/target.py +202 -0
- easyclassifier/ui.py +281 -0
- easyclassifier/wizard.py +1266 -0
- easyclassifier-0.8.1.dist-info/METADATA +267 -0
- easyclassifier-0.8.1.dist-info/RECORD +25 -0
- easyclassifier-0.8.1.dist-info/WHEEL +5 -0
- easyclassifier-0.8.1.dist-info/entry_points.txt +2 -0
- easyclassifier-0.8.1.dist-info/licenses/LICENSE +21 -0
- easyclassifier-0.8.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
"""Model training, validation, and metrics (Phases 11-13, 18)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Dict, List, Optional
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
from sklearn.metrics import (
|
|
10
|
+
accuracy_score,
|
|
11
|
+
balanced_accuracy_score,
|
|
12
|
+
cohen_kappa_score,
|
|
13
|
+
confusion_matrix,
|
|
14
|
+
f1_score,
|
|
15
|
+
log_loss,
|
|
16
|
+
matthews_corrcoef,
|
|
17
|
+
precision_score,
|
|
18
|
+
recall_score,
|
|
19
|
+
roc_auc_score,
|
|
20
|
+
)
|
|
21
|
+
from sklearn.model_selection import (
|
|
22
|
+
LeaveOneOut,
|
|
23
|
+
StratifiedKFold,
|
|
24
|
+
cross_val_predict,
|
|
25
|
+
train_test_split,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# Available metrics: key -> friendly label
|
|
30
|
+
METRICS = {
|
|
31
|
+
"accuracy": "Accuracy",
|
|
32
|
+
"precision": "Precision",
|
|
33
|
+
"recall": "Recall",
|
|
34
|
+
"f1": "F1",
|
|
35
|
+
"specificity": "Specificity",
|
|
36
|
+
"balanced_accuracy": "Balanced Accuracy",
|
|
37
|
+
"mcc": "MCC",
|
|
38
|
+
"cohen_kappa": "Cohen Kappa",
|
|
39
|
+
"roc_auc": "ROC AUC",
|
|
40
|
+
"log_loss": "Log Loss",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
VALIDATION = {
|
|
44
|
+
"holdout": "Hold-Out (single 80/20 split)",
|
|
45
|
+
"kfold5": "5-Fold Cross Validation",
|
|
46
|
+
"kfold10": "10-Fold Cross Validation",
|
|
47
|
+
"stratified": "Stratified 5-Fold Cross Validation",
|
|
48
|
+
"loo": "Leave-One-Out",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class Result:
|
|
54
|
+
classifier_key: str
|
|
55
|
+
classifier_name: str
|
|
56
|
+
metrics: Dict[str, float]
|
|
57
|
+
y_true: np.ndarray
|
|
58
|
+
y_pred: np.ndarray
|
|
59
|
+
y_proba: Optional[np.ndarray]
|
|
60
|
+
class_names: List[str]
|
|
61
|
+
confusion: np.ndarray = field(default=None)
|
|
62
|
+
selection_score: float = 0.0 # balanced accuracy, used for ranking
|
|
63
|
+
note: str = "" # e.g. which classifier won in each fold
|
|
64
|
+
fold_scores: List[float] = field(default_factory=list) # per test fold
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def primary_score(self) -> float:
|
|
68
|
+
return self.selection_score
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# The single criterion used to rank classifiers. Balanced accuracy works for
|
|
72
|
+
# two or more classes, is robust to class imbalance, and higher is better.
|
|
73
|
+
SELECTION_METRIC = "balanced_accuracy"
|
|
74
|
+
SELECTION_LABEL = "Balanced accuracy"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _specificity(y_true, y_pred, labels) -> float:
|
|
78
|
+
cm = confusion_matrix(y_true, y_pred, labels=labels)
|
|
79
|
+
specs = []
|
|
80
|
+
total = cm.sum()
|
|
81
|
+
for i in range(len(labels)):
|
|
82
|
+
tp = cm[i, i]
|
|
83
|
+
fp = cm[:, i].sum() - tp
|
|
84
|
+
fn = cm[i, :].sum() - tp
|
|
85
|
+
tn = total - tp - fp - fn
|
|
86
|
+
denom = tn + fp
|
|
87
|
+
specs.append(tn / denom if denom else 0.0)
|
|
88
|
+
return float(np.mean(specs))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def compute_metrics(y_true, y_pred, y_proba, selected: List[str],
|
|
92
|
+
class_names: List[str]) -> Dict[str, float]:
|
|
93
|
+
labels = list(range(len(class_names)))
|
|
94
|
+
binary = len(class_names) == 2
|
|
95
|
+
avg = "binary" if binary else "macro"
|
|
96
|
+
out: Dict[str, float] = {}
|
|
97
|
+
for key in selected:
|
|
98
|
+
try:
|
|
99
|
+
if key == "accuracy":
|
|
100
|
+
out[key] = accuracy_score(y_true, y_pred)
|
|
101
|
+
elif key == "precision":
|
|
102
|
+
out[key] = precision_score(y_true, y_pred, average=avg,
|
|
103
|
+
zero_division=0)
|
|
104
|
+
elif key == "recall":
|
|
105
|
+
out[key] = recall_score(y_true, y_pred, average=avg,
|
|
106
|
+
zero_division=0)
|
|
107
|
+
elif key == "f1":
|
|
108
|
+
out[key] = f1_score(y_true, y_pred, average=avg,
|
|
109
|
+
zero_division=0)
|
|
110
|
+
elif key == "specificity":
|
|
111
|
+
out[key] = _specificity(y_true, y_pred, labels)
|
|
112
|
+
elif key == "balanced_accuracy":
|
|
113
|
+
out[key] = balanced_accuracy_score(y_true, y_pred)
|
|
114
|
+
elif key == "mcc":
|
|
115
|
+
out[key] = matthews_corrcoef(y_true, y_pred)
|
|
116
|
+
elif key == "cohen_kappa":
|
|
117
|
+
out[key] = cohen_kappa_score(y_true, y_pred)
|
|
118
|
+
elif key == "roc_auc":
|
|
119
|
+
if y_proba is None:
|
|
120
|
+
continue
|
|
121
|
+
if binary:
|
|
122
|
+
out[key] = roc_auc_score(y_true, y_proba[:, 1])
|
|
123
|
+
else:
|
|
124
|
+
out[key] = roc_auc_score(y_true, y_proba,
|
|
125
|
+
multi_class="ovr", average="macro")
|
|
126
|
+
elif key == "log_loss":
|
|
127
|
+
if y_proba is None:
|
|
128
|
+
continue
|
|
129
|
+
out[key] = log_loss(y_true, y_proba, labels=labels)
|
|
130
|
+
except Exception: # noqa: BLE001
|
|
131
|
+
# A metric that can't be computed on this data is simply skipped.
|
|
132
|
+
continue
|
|
133
|
+
return out
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _cv_splitter(method: str, y):
|
|
137
|
+
if method == "loo":
|
|
138
|
+
return LeaveOneOut()
|
|
139
|
+
wanted = 10 if method == "kfold10" else 5
|
|
140
|
+
# Stratified folds need at least n_splits members in every class.
|
|
141
|
+
smallest = int(np.min(np.unique(y, return_counts=True)[1]))
|
|
142
|
+
n_splits = max(2, min(wanted, smallest))
|
|
143
|
+
return StratifiedKFold(n_splits=n_splits, shuffle=True, random_state=42)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def evaluate(spec, X, y, class_names, validation: str,
|
|
147
|
+
metrics: List[str], with_proba: bool = True) -> Result:
|
|
148
|
+
"""Train and evaluate a single classifier, returning a :class:`Result`.
|
|
149
|
+
|
|
150
|
+
``spec.factory()`` returns an unfitted Pipeline (preprocessing + model).
|
|
151
|
+
It is fitted on training data only - once for hold-out, once per fold for
|
|
152
|
+
cross-validation - so test data never influences preprocessing.
|
|
153
|
+
``X`` stays a DataFrame so the pipeline can select columns by name.
|
|
154
|
+
"""
|
|
155
|
+
y = np.asarray(y)
|
|
156
|
+
|
|
157
|
+
if validation == "holdout":
|
|
158
|
+
strat = y if len(np.unique(y)) > 1 else None
|
|
159
|
+
X_tr, X_te, y_tr, y_te = train_test_split(
|
|
160
|
+
X, y, test_size=0.2, random_state=42, stratify=strat
|
|
161
|
+
)
|
|
162
|
+
model = spec.factory()
|
|
163
|
+
model.fit(X_tr, y_tr)
|
|
164
|
+
y_pred = model.predict(X_te)
|
|
165
|
+
y_proba = (_safe_proba(model, X_te, class_names)
|
|
166
|
+
if with_proba else None)
|
|
167
|
+
y_true = y_te
|
|
168
|
+
fold_scores = [float(balanced_accuracy_score(y_te, y_pred))]
|
|
169
|
+
else:
|
|
170
|
+
splitter = _cv_splitter(validation, y)
|
|
171
|
+
model = spec.factory()
|
|
172
|
+
y_pred = cross_val_predict(model, X, y, cv=splitter)
|
|
173
|
+
y_proba = None
|
|
174
|
+
if with_proba:
|
|
175
|
+
try:
|
|
176
|
+
y_proba = cross_val_predict(model, X, y, cv=splitter,
|
|
177
|
+
method="predict_proba")
|
|
178
|
+
except Exception: # noqa: BLE001
|
|
179
|
+
y_proba = None
|
|
180
|
+
y_true = y
|
|
181
|
+
fold_scores = _fold_scores(splitter, X, y, y_pred)
|
|
182
|
+
|
|
183
|
+
return _make_result(spec.key, spec.name, y_true, y_pred, y_proba,
|
|
184
|
+
class_names, metrics, fold_scores=fold_scores)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _fold_scores(splitter, X, y, y_pred) -> List[float]:
|
|
188
|
+
"""Balanced accuracy of each cross-validation test fold (same folds as
|
|
189
|
+
used for the predictions). Empty for leave-one-out, where a single-row
|
|
190
|
+
fold has no meaningful balanced accuracy."""
|
|
191
|
+
if isinstance(splitter, LeaveOneOut):
|
|
192
|
+
return []
|
|
193
|
+
scores = []
|
|
194
|
+
for _, te in splitter.split(X, y):
|
|
195
|
+
scores.append(float(balanced_accuracy_score(y[te], y_pred[te])))
|
|
196
|
+
return scores
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _make_result(key, name, y_true, y_pred, y_proba, class_names,
|
|
200
|
+
metrics, note="", fold_scores=None) -> Result:
|
|
201
|
+
m = compute_metrics(y_true, y_pred, y_proba, metrics, class_names)
|
|
202
|
+
cm = confusion_matrix(y_true, y_pred, labels=list(range(len(class_names))))
|
|
203
|
+
return Result(
|
|
204
|
+
classifier_key=key,
|
|
205
|
+
classifier_name=name,
|
|
206
|
+
metrics=m,
|
|
207
|
+
y_true=np.asarray(y_true),
|
|
208
|
+
y_pred=np.asarray(y_pred),
|
|
209
|
+
y_proba=y_proba,
|
|
210
|
+
class_names=class_names,
|
|
211
|
+
confusion=cm,
|
|
212
|
+
selection_score=float(balanced_accuracy_score(y_true, y_pred)),
|
|
213
|
+
note=note,
|
|
214
|
+
fold_scores=list(fold_scores or []),
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# --------------------------------------------------------------------------- #
|
|
219
|
+
# Honest estimate for the selected (best) classifier
|
|
220
|
+
# --------------------------------------------------------------------------- #
|
|
221
|
+
#
|
|
222
|
+
# Picking the best of several classifiers by their validation scores and then
|
|
223
|
+
# reporting that same score is optimistic: the winner partly won by luck on
|
|
224
|
+
# those particular splits. Two remedies are offered:
|
|
225
|
+
#
|
|
226
|
+
# * "final_test": keep 20% of the rows aside before anything else. Classifiers
|
|
227
|
+
# are compared on the other 80% only; the winner is scored once on the
|
|
228
|
+
# untouched 20%.
|
|
229
|
+
# * "nested": nested cross-validation. In each outer fold the whole selection
|
|
230
|
+
# procedure (compare all classifiers with inner validation, pick the best)
|
|
231
|
+
# runs on the training part only, and the winner is scored on the outer
|
|
232
|
+
# test part. Better for small datasets, where 20% would be too few rows.
|
|
233
|
+
|
|
234
|
+
SELECTION = {
|
|
235
|
+
"auto": "Automatic (recommended)",
|
|
236
|
+
"final_test": "Keep 20% aside as a final, untouched test set",
|
|
237
|
+
"nested": "Nested cross-validation (best for small datasets)",
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
FINAL_TEST_SIZE = 0.2
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
# Up to this many rows, nested CV is used: in EasyClassifier's benchmarks it
|
|
244
|
+
# gave more accurate final scores than a 20% test set on small and medium
|
|
245
|
+
# data (a 20% test set of a few hundred rows is noisy). Larger datasets use
|
|
246
|
+
# the final test set, which is accurate there and about 5 times faster.
|
|
247
|
+
NESTED_MAX_ROWS = 2000
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def recommend_selection(y) -> str:
|
|
251
|
+
"""Nested CV up to NESTED_MAX_ROWS rows, otherwise a final test set
|
|
252
|
+
(provided every class has enough rows for a meaningful test set)."""
|
|
253
|
+
counts = np.unique(np.asarray(y), return_counts=True)[1]
|
|
254
|
+
if len(y) > NESTED_MAX_ROWS and counts.min() * FINAL_TEST_SIZE >= 5:
|
|
255
|
+
return "final_test"
|
|
256
|
+
return "nested"
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def split_final_test(X, y, seed: int = 42):
|
|
260
|
+
"""Stratified split into development (80%) and final test (20%) rows."""
|
|
261
|
+
y = np.asarray(y)
|
|
262
|
+
idx = np.arange(len(y))
|
|
263
|
+
dev, test = train_test_split(idx, test_size=FINAL_TEST_SIZE,
|
|
264
|
+
random_state=seed, stratify=y)
|
|
265
|
+
return dev, test
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def evaluate_on_test(spec, X_dev, y_dev, X_test, y_test, class_names,
|
|
269
|
+
metrics) -> Result:
|
|
270
|
+
"""Fit on the development rows, score once on the untouched test rows."""
|
|
271
|
+
model = spec.factory()
|
|
272
|
+
model.fit(X_dev, np.asarray(y_dev))
|
|
273
|
+
y_pred = model.predict(X_test)
|
|
274
|
+
y_proba = _safe_proba(model, X_test, class_names)
|
|
275
|
+
return _make_result(spec.key, spec.name, np.asarray(y_test), y_pred,
|
|
276
|
+
y_proba, class_names, metrics)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def nested_cv(specs, X, y, class_names, validation: str,
|
|
280
|
+
metrics: List[str], progress=None) -> Result:
|
|
281
|
+
"""Nested cross-validation estimate of the 'pick the best' procedure."""
|
|
282
|
+
y = np.asarray(y)
|
|
283
|
+
inner_validation = "kfold5" if validation == "loo" else validation
|
|
284
|
+
outer = _cv_splitter("kfold5", y)
|
|
285
|
+
y_pred = np.empty_like(y)
|
|
286
|
+
y_proba = np.zeros((len(y), len(class_names)))
|
|
287
|
+
have_proba = True
|
|
288
|
+
winners: List[str] = []
|
|
289
|
+
|
|
290
|
+
for fold, (tr, te) in enumerate(outer.split(X, y), start=1):
|
|
291
|
+
X_tr, y_tr = X.iloc[tr], y[tr]
|
|
292
|
+
best_spec, best_score = None, -np.inf
|
|
293
|
+
for spec in specs:
|
|
294
|
+
try:
|
|
295
|
+
# Selection needs predicted classes only, not probabilities.
|
|
296
|
+
r = evaluate(spec, X_tr, y_tr, class_names,
|
|
297
|
+
inner_validation, [], with_proba=False)
|
|
298
|
+
except Exception: # noqa: BLE001
|
|
299
|
+
continue
|
|
300
|
+
if r.selection_score > best_score:
|
|
301
|
+
best_spec, best_score = spec, r.selection_score
|
|
302
|
+
if best_spec is None:
|
|
303
|
+
raise RuntimeError("no classifier could be trained in a fold")
|
|
304
|
+
winners.append(best_spec.name)
|
|
305
|
+
if progress:
|
|
306
|
+
progress(fold, best_spec.name)
|
|
307
|
+
model = best_spec.factory()
|
|
308
|
+
model.fit(X_tr, y_tr)
|
|
309
|
+
X_te = X.iloc[te]
|
|
310
|
+
y_pred[te] = model.predict(X_te)
|
|
311
|
+
p = _safe_proba(model, X_te, class_names)
|
|
312
|
+
if p is None:
|
|
313
|
+
have_proba = False
|
|
314
|
+
else:
|
|
315
|
+
y_proba[te] = p
|
|
316
|
+
|
|
317
|
+
counts = {w: winners.count(w) for w in dict.fromkeys(winners)}
|
|
318
|
+
note = "; ".join(f"{w} chosen in {c}/{len(winners)} folds"
|
|
319
|
+
for w, c in counts.items())
|
|
320
|
+
return _make_result("nested", "Best classifier (nested CV estimate)",
|
|
321
|
+
y, y_pred, y_proba if have_proba else None,
|
|
322
|
+
class_names, metrics, note=note)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _safe_proba(model, X, class_names):
|
|
326
|
+
try:
|
|
327
|
+
proba = model.predict_proba(X)
|
|
328
|
+
# Ensure full class width even if a class was absent in training fold.
|
|
329
|
+
if proba.shape[1] == len(class_names):
|
|
330
|
+
return proba
|
|
331
|
+
except Exception: # noqa: BLE001
|
|
332
|
+
pass
|
|
333
|
+
return None
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def fit_final_model(spec, X, y):
|
|
337
|
+
"""Fit a model on all data for saving/predictions."""
|
|
338
|
+
model = spec.factory()
|
|
339
|
+
model.fit(X, np.asarray(y))
|
|
340
|
+
return model
|