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.
@@ -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