fde-tabular 1.0.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.
- fde/__init__.py +12 -0
- fde/classifier.py +308 -0
- fde/cli.py +216 -0
- fde/combiners.py +129 -0
- fde/deploy_test.py +96 -0
- fde/device.py +29 -0
- fde/extended.py +202 -0
- fde/learners.py +202 -0
- fde/members.py +161 -0
- fde_tabular-1.0.0.dist-info/METADATA +307 -0
- fde_tabular-1.0.0.dist-info/RECORD +15 -0
- fde_tabular-1.0.0.dist-info/WHEEL +5 -0
- fde_tabular-1.0.0.dist-info/entry_points.txt +2 -0
- fde_tabular-1.0.0.dist-info/licenses/LICENSE +21 -0
- fde_tabular-1.0.0.dist-info/top_level.txt +1 -0
fde/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""FDE: Feasible Diverse Ensembles for tabular classification."""
|
|
2
|
+
__version__ = "1.0.0"
|
|
3
|
+
__all__ = ["FDEClassifier", "__version__"]
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def __getattr__(name):
|
|
7
|
+
# FDEClassifier is imported on first use, so that the command line can print its first message before the
|
|
8
|
+
# learner libraries load
|
|
9
|
+
if name == "FDEClassifier":
|
|
10
|
+
from .classifier import FDEClassifier
|
|
11
|
+
return FDEClassifier
|
|
12
|
+
raise AttributeError(name)
|
fde/classifier.py
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
"""FDEClassifier: Feasible Diverse Ensemble.
|
|
2
|
+
|
|
3
|
+
Procedure (the configuration evaluated in the accompanying paper):
|
|
4
|
+
1. Metric of a run: macro-F1 when there are two classes and the minority share is below 0.15, weighted-F1
|
|
5
|
+
otherwise.
|
|
6
|
+
2. The training rows are cut into 3 stratified outer folds. For each fold, on its fit rows, and once on all rows,
|
|
7
|
+
two things are built: the best tuned learner, and the members — class-weighted, imbalance-specific and
|
|
8
|
+
rebalanced ones, plus those of fde.extended (large-budget GBDTs, an MLP, large forests).
|
|
9
|
+
3. Combiner: stacking (multinomial logistic regression over the probabilities of the best learner and the
|
|
10
|
+
members) followed by per-class probability scales; both are fitted on out-of-fold probabilities.
|
|
11
|
+
4. Acceptance rule, cross-fitted over the pooled outer-fold predictions: the ensemble is used only if three
|
|
12
|
+
conditions hold — the lower 5% bootstrap quantile of its gain over the best tuned learner is above 0, its
|
|
13
|
+
weight in the all-rows fit is non-zero, and it differs from the best learner on at least one outer-fold row.
|
|
14
|
+
Otherwise the best tuned learner is kept. No imbalance level is treated differently.
|
|
15
|
+
members="light" drops the extended members of step 2, which makes the ensemble lighter and faster but somewhat less
|
|
16
|
+
accurate in the accompanying paper.
|
|
17
|
+
"""
|
|
18
|
+
import json, os, sys, time
|
|
19
|
+
|
|
20
|
+
import joblib
|
|
21
|
+
import numpy as np
|
|
22
|
+
import pandas as pd
|
|
23
|
+
from sklearn.model_selection import StratifiedKFold
|
|
24
|
+
|
|
25
|
+
from . import __version__, device
|
|
26
|
+
from .combiners import GRID_WIDE, apply, fast_score, fit_combiner
|
|
27
|
+
from .deploy_test import OUTER_FOLDS, acceptance_rule, gate_metric
|
|
28
|
+
from .extended import KINDS, fit_member, raw_frame
|
|
29
|
+
from .learners import Member
|
|
30
|
+
from .members import inner_splits, member_task, pick_best, recipes, tune_one, tuned_types
|
|
31
|
+
|
|
32
|
+
FAMILIES = ("cwc2", "imb", "rbc2")
|
|
33
|
+
MARGIN = 0.0 # the ensemble is used when the lower bound of its gain lies above 0
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def imbalance_group(y):
|
|
37
|
+
cnt = np.bincount(y); cnt = cnt[cnt > 0]; ir = cnt.max() / cnt.min()
|
|
38
|
+
return ("balanced" if ir < 3 else "low" if ir < 10 else "moderate" if ir < 50 else "extreme"), float(ir)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def prepare_frame(X):
|
|
42
|
+
"""Column names as strings, non-numeric columns as objects holding NaN for missing values, and numbers that
|
|
43
|
+
arrive as text converted to numbers. Categorical columns are left raw; each learner encodes them on its own
|
|
44
|
+
training rows."""
|
|
45
|
+
X = pd.DataFrame(X).copy()
|
|
46
|
+
X.columns = [str(c) for c in X.columns]
|
|
47
|
+
for c in X.columns:
|
|
48
|
+
if not pd.api.types.is_numeric_dtype(X[c]):
|
|
49
|
+
X[c] = X[c].astype("object").where(X[c].notna(), np.nan)
|
|
50
|
+
for c in X.columns:
|
|
51
|
+
if not pd.api.types.is_numeric_dtype(X[c]):
|
|
52
|
+
v = pd.to_numeric(X[c].astype("string").str.strip(), errors="coerce")
|
|
53
|
+
nn = X[c].notna() & (X[c].astype("string").str.strip() != "")
|
|
54
|
+
if nn.sum() > 0 and v[nn].notna().mean() >= 0.95:
|
|
55
|
+
X[c] = v.astype(float)
|
|
56
|
+
return X.reset_index(drop=True)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def console():
|
|
60
|
+
"""Rich console on stderr for progress and messages (plain text when stderr is not a terminal)."""
|
|
61
|
+
from rich.console import Console
|
|
62
|
+
return Console(stderr=True, highlight=False)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _say(con, verbose):
|
|
66
|
+
from rich.markup import escape
|
|
67
|
+
return (lambda msg, style="": con.print(f"[bold cyan]fde[/] " + (f"[{style}]{escape(msg)}[/]" if style else escape(msg)))) \
|
|
68
|
+
if verbose else (lambda msg, style="": None)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def plan_jobs(n_jobs, X):
|
|
72
|
+
"""Parallel jobs from what the machine has free: usable CPU cores (one is left for the system) and available
|
|
73
|
+
memory (each job is given about 30 times the size of the data, at least 300 MB). Returns (jobs, description)."""
|
|
74
|
+
import psutil
|
|
75
|
+
try:
|
|
76
|
+
cores = len(os.sched_getaffinity(0))
|
|
77
|
+
except AttributeError: # not available on Windows and macOS
|
|
78
|
+
cores = os.cpu_count() or 1
|
|
79
|
+
free = psutil.virtual_memory().available
|
|
80
|
+
per_job = max(300e6, 30 * float(pd.DataFrame(X).memory_usage(deep=True).sum()))
|
|
81
|
+
auto = max(1, min(cores - 1, int(free // per_job)))
|
|
82
|
+
jobs = auto if n_jobs in (None, -1) else max(1, min(int(n_jobs), cores))
|
|
83
|
+
return jobs, f"{cores} CPU cores, {free / 1e9:.1f} GB memory free -> {jobs} parallel jobs"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def gpu_note(want):
|
|
87
|
+
"""Device actually used, and a line for the system check."""
|
|
88
|
+
name = device.available()
|
|
89
|
+
if want == "gpu" and name:
|
|
90
|
+
return "gpu", f"{name}, used for XGBoost, CatBoost and the MLP (results can differ slightly from the CPU)"
|
|
91
|
+
if want == "gpu":
|
|
92
|
+
return "cpu", "no CUDA GPU found, so the CPU is used"
|
|
93
|
+
return "cpu", (f"{name} found, not used (--device gpu uses it)" if name else "none found; the CPU is used")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _call(key, fn, args, dev="cpu"):
|
|
97
|
+
"""One parallel task on device `dev`; library notices (deprecations, CUDA probing) are not shown."""
|
|
98
|
+
import warnings
|
|
99
|
+
device.use(dev)
|
|
100
|
+
with warnings.catch_warnings():
|
|
101
|
+
warnings.simplefilter("ignore")
|
|
102
|
+
return key, fn(*args)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# rough relative cost of tuning each learner type, used only to start the slow tasks first and to weight the progress bar
|
|
106
|
+
COST = {"cat": 20, "rf": 3, "et": 3, "xgb": 2, "lgbm": 2, "hgb": 2, "svc": 2, "mlp": 2, "catn_cat": 6, "nn_mlp": 3}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _weight(key):
|
|
110
|
+
return float(COST.get(key[-1] if isinstance(key, tuple) else key, 1))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _ansi_ok(stream):
|
|
114
|
+
"""True when the terminal shows colours; on Windows 10 and later this switches on the console's colour mode."""
|
|
115
|
+
if not stream.isatty():
|
|
116
|
+
return False
|
|
117
|
+
if os.name != "nt":
|
|
118
|
+
return True
|
|
119
|
+
try:
|
|
120
|
+
import ctypes
|
|
121
|
+
k = ctypes.windll.kernel32; h = k.GetStdHandle(-12); mode = ctypes.c_uint32()
|
|
122
|
+
return bool(k.GetConsoleMode(h, ctypes.byref(mode)) and k.SetConsoleMode(h, mode.value | 0x0004))
|
|
123
|
+
except Exception:
|
|
124
|
+
return False
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class _Bar:
|
|
128
|
+
"""One-line progress bar on stderr (terminal only): stage, bar, percentage, tasks done, elapsed and remaining time,
|
|
129
|
+
and the tasks still running. Redrawn twice a second, so that the clock keeps moving while a long task runs."""
|
|
130
|
+
|
|
131
|
+
def __init__(self, n, work, stage, on):
|
|
132
|
+
self.n, self.work, self.stage, self.done, self.got, self.t0, self.what = n, max(work, 1e-9), stage, 0, 0.0, time.time(), "starting"
|
|
133
|
+
self.on = on and sys.stderr.isatty()
|
|
134
|
+
if not self.on:
|
|
135
|
+
return
|
|
136
|
+
enc = (getattr(sys.stderr, "encoding", "") or "").lower().replace("-", "")
|
|
137
|
+
self.full, self.empty = ("\u2588", "\u2591") if enc in ("utf8", "utf16", "utf16le") else ("#", "-")
|
|
138
|
+
c = _ansi_ok(sys.stderr)
|
|
139
|
+
self.c = (lambda code, t: f"\033[{code}m{t}\033[0m") if c else (lambda code, t: t)
|
|
140
|
+
import threading
|
|
141
|
+
self.stop = threading.Event(); threading.Thread(target=self._tick, daemon=True).start()
|
|
142
|
+
|
|
143
|
+
def _tick(self):
|
|
144
|
+
while not self.stop.wait(0.5):
|
|
145
|
+
self.draw()
|
|
146
|
+
|
|
147
|
+
def step(self, weight, what):
|
|
148
|
+
self.done += 1; self.got += weight; self.what = what; self.draw()
|
|
149
|
+
|
|
150
|
+
def draw(self):
|
|
151
|
+
if not self.on:
|
|
152
|
+
return
|
|
153
|
+
el = time.time() - self.t0; frac = min(self.got / self.work, 1.0); w = 26; k = int(w * frac)
|
|
154
|
+
rem = max(el / frac - el, 0) if frac > 0 else 0
|
|
155
|
+
fmt = lambda t: f"{int(t // 60)}:{int(t % 60):02d}"
|
|
156
|
+
bar = self.c("32", self.full * k) + self.c("90", self.empty * (w - k))
|
|
157
|
+
line = (f"\r{self.c('1', self.stage)} {bar} {self.c('1', f'{100 * frac:3.0f}%')} {self.done}/{self.n} "
|
|
158
|
+
f"{fmt(el)} elapsed {self.c('36', '~' + fmt(rem) + ' left')} {self.c('90', self.what[:30]):30s}")
|
|
159
|
+
sys.stderr.write(line); sys.stderr.flush()
|
|
160
|
+
|
|
161
|
+
def close(self):
|
|
162
|
+
if self.on:
|
|
163
|
+
self.stop.set(); sys.stderr.write("\r" + " " * 130 + "\r"); sys.stderr.flush()
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _run(tasks, n_jobs, stage, verbose, say, dev="cpu", con=None):
|
|
167
|
+
"""Runs {key: (fn, args, label)} on n_jobs processes, slow tasks first, with a progress bar; returns {key: result}."""
|
|
168
|
+
from joblib import Parallel, delayed
|
|
169
|
+
order = sorted(tasks, key=_weight, reverse=True); out, t0 = {}, time.time()
|
|
170
|
+
bar = _Bar(len(order), sum(_weight(k) for k in order), stage, verbose)
|
|
171
|
+
try:
|
|
172
|
+
for key, r in Parallel(n_jobs=n_jobs, backend="loky", return_as="generator_unordered")(
|
|
173
|
+
delayed(_call)(key, tasks[key][0], tasks[key][1], dev) for key in order):
|
|
174
|
+
out[key] = r; left = [k for k in order if k not in out]
|
|
175
|
+
names = sorted({str(k[-1]) if isinstance(k, tuple) else str(k) for k in left})
|
|
176
|
+
bar.step(_weight(key), f"{len(left)} left: {', '.join(names)}" if left else "finishing")
|
|
177
|
+
finally:
|
|
178
|
+
bar.close()
|
|
179
|
+
say(f"{stage}: {len(order)} tasks in {time.time() - t0:.0f} s", "green")
|
|
180
|
+
return out
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _probs(models, labs, X, Xr=None):
|
|
184
|
+
return {l: models[l].predict_proba(Xr if l in KINDS else X).astype(np.float32).astype(np.float64)
|
|
185
|
+
for l in ["P1"] + labs if l in models}
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class FDEClassifier:
|
|
189
|
+
"""Feasible Diverse Ensemble for tabular classification, in scikit-learn style.
|
|
190
|
+
|
|
191
|
+
Parameters
|
|
192
|
+
----------
|
|
193
|
+
random_state : int, seed shared by every split, search and member.
|
|
194
|
+
n_jobs : int, parallel tasks; -1 (default) chooses from the free CPU cores and memory (see plan_jobs). Each tuned
|
|
195
|
+
learner is single-threaded. Results do not depend on n_jobs.
|
|
196
|
+
trials : int, Optuna trials for each learner type (20 in the evaluated configuration).
|
|
197
|
+
members : "full" (default) or "light", the latter keeping only the imbalance-handling members: lighter and
|
|
198
|
+
faster.
|
|
199
|
+
member_threads : int, threads per extended member (4 in the evaluated configuration).
|
|
200
|
+
device : "cpu" (default; the results of the accompanying paper) or "gpu" (XGBoost, CatBoost and the MLP on the first
|
|
201
|
+
CUDA GPU; faster on large data, results can differ slightly from the CPU; falls back to the CPU without a GPU).
|
|
202
|
+
verbose : int, 0 silent; 1 progress messages on stderr; 2 also prints the report when fitting ends.
|
|
203
|
+
"""
|
|
204
|
+
|
|
205
|
+
def __init__(self, random_state=0, n_jobs=-1, trials=20, members="full", member_threads=4, device="cpu", verbose=0):
|
|
206
|
+
self.random_state, self.n_jobs, self.trials = random_state, n_jobs, trials
|
|
207
|
+
self.members, self.member_threads, self.device, self.verbose = members, member_threads, device, verbose
|
|
208
|
+
|
|
209
|
+
def fit(self, X, y):
|
|
210
|
+
t0 = time.time()
|
|
211
|
+
if self.members not in ("full", "light"):
|
|
212
|
+
raise ValueError('members must be "full" or "light"')
|
|
213
|
+
if self.device not in ("cpu", "gpu"):
|
|
214
|
+
raise ValueError('device must be "cpu" or "gpu"')
|
|
215
|
+
Xr, self.cat_ = raw_frame(X)
|
|
216
|
+
X = prepare_frame(X); y_raw = pd.Series(np.asarray(y)).astype(str)
|
|
217
|
+
self.classes_ = np.array(sorted(y_raw.unique()))
|
|
218
|
+
y = y_raw.map({c: i for i, c in enumerate(self.classes_)}).to_numpy(dtype=np.int64)
|
|
219
|
+
self.columns_ = list(X.columns); K = len(self.classes_); seed = self.random_state
|
|
220
|
+
if K < 2:
|
|
221
|
+
raise ValueError("at least two classes are required")
|
|
222
|
+
self.metric_ = gate_metric(y); self.group_, ir = imbalance_group(y)
|
|
223
|
+
rounds = list(StratifiedKFold(OUTER_FOLDS, shuffle=True, random_state=seed).split(np.zeros(len(y)), y))
|
|
224
|
+
sub = lambda D, i: D.iloc[i].reset_index(drop=True)
|
|
225
|
+
sets = [(sub(X, i), y[i], sub(Xr, i)) for i, _ in rounds] + [(X, y, Xr)]
|
|
226
|
+
names = [f"fold {k + 1}/3" for k in range(3)] + ["all rows"]
|
|
227
|
+
con = console(); say = _say(con, self.verbose)
|
|
228
|
+
say(f"{len(y)} rows, {K} classes, metric {self.metric_}, members: {self.members}")
|
|
229
|
+
say("checking the system ...", "dim")
|
|
230
|
+
jobs, machine = plan_jobs(self.n_jobs, X)
|
|
231
|
+
say(f" CPU and memory: {machine}")
|
|
232
|
+
self.device_used_, gpu_line = gpu_note(self.device)
|
|
233
|
+
say(f" GPU: {gpu_line}")
|
|
234
|
+
splits = [inner_splits(yf, seed, 3) for _, yf, _ in sets]
|
|
235
|
+
types = tuned_types(len(y))
|
|
236
|
+
# stage 1: every learner type in every fit set is tuned as its own task
|
|
237
|
+
tuned = _run({(k, mt): (tune_one, (Xf, yf, K, self.metric_, seed, splits[k], self.trials, mt), f"{names[k]}: {mt}")
|
|
238
|
+
for k, (Xf, yf, _) in enumerate(sets) for mt in types}, jobs, "Stage 1/3 Tuning learners", self.verbose, say, self.device_used_, con)
|
|
239
|
+
# stage 2: the best learner and every member of every fit set, each as its own task
|
|
240
|
+
best, tasks = [], {}
|
|
241
|
+
for k, (Xf, yf, Xrf) in enumerate(sets):
|
|
242
|
+
mt, p, inner, pool, tune = pick_best({t: tuned[(k, t)] for t in types})
|
|
243
|
+
rec, others = recipes(Xf, K, seed, mt, p, pool, FAMILIES)
|
|
244
|
+
best.append({"best_type": mt, "best_params": p, "inner_score": inner, "pool": {t: round(v[0], 5) for t, v in pool.items()},
|
|
245
|
+
"tune_seconds": tune, "other_types": others, "n_rows": len(yf)})
|
|
246
|
+
cols = list(Xf.columns)
|
|
247
|
+
tasks[(k, "P1")] = (member_task, ((lambda mt=mt, p=p: Member(mt, p, cols, K, seed)), Xf, yf, splits[k], K, True), f"{names[k]}: P1")
|
|
248
|
+
for lab, make in rec:
|
|
249
|
+
tasks[(k, lab)] = (member_task, (make, Xf, yf, splits[k], K, True), f"{names[k]}: {lab}")
|
|
250
|
+
for kind in KINDS if self.members == "full" else ():
|
|
251
|
+
tasks[(k, kind)] = (fit_member, (kind, Xrf, yf, K, seed, self.cat_, self.member_threads), f"{names[k]}: {kind}")
|
|
252
|
+
built = _run(tasks, jobs, "Stage 2/3 Building members", self.verbose, say, self.device_used_, con)
|
|
253
|
+
oofs, models = [{} for _ in sets], [{} for _ in sets]
|
|
254
|
+
for (k, lab), (o, m) in built.items():
|
|
255
|
+
oofs[k][lab] = o.astype(np.float32).astype(np.float64); models[k][lab] = m
|
|
256
|
+
# stage 3: the combiner of every fit set
|
|
257
|
+
labs = [sorted(l for l in oofs[k] if l != "P1") for k in range(4)]
|
|
258
|
+
comb = _run({k: (fit_combiner, ("stack_scaled", oofs[k], labs[k], sets[k][1], K, self.metric_, seed, GRID_WIDE), names[k])
|
|
259
|
+
for k in range(4)}, jobs, "Stage 3/3 Combiners", self.verbose, say, "cpu", con)
|
|
260
|
+
out = [{"models": models[k], "spec": comb[k][0], "weight": comb[k][2], "info": best[k], "labs": labs[k]} for k in range(4)]
|
|
261
|
+
cf_ref, cf_cand = np.zeros(len(y), int), np.zeros(len(y), int)
|
|
262
|
+
for (i, j), r in zip(rounds, out[:3]):
|
|
263
|
+
p = _probs(r["models"], r["labs"], sub(X, j), sub(Xr, j))
|
|
264
|
+
cf_ref[j] = p["P1"].argmax(1); cf_cand[j] = apply(r["spec"], p).argmax(1)
|
|
265
|
+
rule = acceptance_rule(y, cf_ref, cf_cand, metric=self.metric_, seed=0, K=K, margin=MARGIN)
|
|
266
|
+
full = out[3]
|
|
267
|
+
self.uses_ensemble_ = bool(rule.accept and full["weight"] > 0 and np.any(cf_cand != cf_ref))
|
|
268
|
+
self.decision_ = ("used" if self.uses_ensemble_ else "not_consistent" if not rule.accept
|
|
269
|
+
else "same_predictions" if not np.any(cf_cand != cf_ref) else "no_member_weight")
|
|
270
|
+
if self.uses_ensemble_:
|
|
271
|
+
self.model_ = full
|
|
272
|
+
else:
|
|
273
|
+
self.model_ = {"models": {"P1": full["models"]["P1"]}, "spec": {"kind": "best"}, "labs": [], "info": full["info"]}
|
|
274
|
+
self.crossfit_ = {"reference": fast_score(y, cf_ref, K, self.metric_), "ensemble": fast_score(y, cf_cand, K, self.metric_)}
|
|
275
|
+
if self.uses_ensemble_:
|
|
276
|
+
say(f"Acceptance rule: the ensemble predicted better on the training folds, so it is used ({1 + len(full['labs'])} models)", "bold green")
|
|
277
|
+
else:
|
|
278
|
+
say("Acceptance rule: the best tuned learner alone was as good on the training folds, so it is kept (one model)", "bold cyan")
|
|
279
|
+
say(f"Done in {time.time() - t0:.0f} s", "bold green")
|
|
280
|
+
info = self.model_["info"]
|
|
281
|
+
self.report_ = {"version": __version__, "rows": len(y), "classes": [str(c) for c in self.classes_], "imbalance_ratio": round(ir, 3),
|
|
282
|
+
"imbalance_group": self.group_, "metric": self.metric_, "best_learner": info["best_type"],
|
|
283
|
+
"best_learner_inner_score": round(info["inner_score"], 5),
|
|
284
|
+
"acceptance_rule": {"lower_bound": round(rule.lower_bound, 5), "gain": round(rule.gain, 5), "margin": MARGIN,
|
|
285
|
+
"accepted": bool(rule.accept)},
|
|
286
|
+
"crossfit_scores": self.crossfit_, "decision": self.decision_,
|
|
287
|
+
"uses_ensemble": self.uses_ensemble_, "models_in_predictor": 1 + (len(self.model_["labs"]) if self.uses_ensemble_ else 0),
|
|
288
|
+
"options": {"members": self.members, "trials": self.trials, "member_threads": self.member_threads,
|
|
289
|
+
"device": self.device_used_},
|
|
290
|
+
"fit_seconds": round(time.time() - t0, 1)}
|
|
291
|
+
if self.verbose >= 2:
|
|
292
|
+
print(json.dumps(self.report_, indent=1))
|
|
293
|
+
return self
|
|
294
|
+
|
|
295
|
+
def predict_proba(self, X):
|
|
296
|
+
Xr = raw_frame(X, self.cat_)[0]
|
|
297
|
+
X = prepare_frame(X)[self.columns_]
|
|
298
|
+
return apply(self.model_["spec"], _probs(self.model_["models"], self.model_["labs"], X, Xr))
|
|
299
|
+
|
|
300
|
+
def predict(self, X):
|
|
301
|
+
return self.classes_[self.predict_proba(X).argmax(1)]
|
|
302
|
+
|
|
303
|
+
def save(self, path):
|
|
304
|
+
joblib.dump(self, path)
|
|
305
|
+
|
|
306
|
+
@staticmethod
|
|
307
|
+
def load(path):
|
|
308
|
+
return joblib.load(path)
|
fde/cli.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""Command line: fde demo | fit | predict | evaluate.
|
|
2
|
+
|
|
3
|
+
fde demo [--members full|light] [--trials 5] [--jobs -1] (evaluate on the breast cancer data shipped with
|
|
4
|
+
scikit-learn; no file or download needed; quick settings by default)
|
|
5
|
+
|
|
6
|
+
fde fit --data train.csv --target y --out model.fde [--seed 0] [--jobs -1] [--trials 20] [--members full|light]
|
|
7
|
+
fde predict --model model.fde --data new.csv [--proba] [--out predictions.csv]
|
|
8
|
+
fde evaluate --data data.csv --target y [--test-size 0.2] [--seed 0] ... (holds out a stratified part of the
|
|
9
|
+
data and scores FDE against the best tuned learner on it, reporting macro-F1, weighted-F1 and
|
|
10
|
+
balanced accuracy; the run metric is whichever F1 column applies)
|
|
11
|
+
"""
|
|
12
|
+
import argparse, json, sys
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _read(path):
|
|
16
|
+
import pandas as pd
|
|
17
|
+
return pd.read_parquet(path) if path.endswith(".parquet") else pd.read_csv(path, low_memory=False)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _clf(a):
|
|
21
|
+
from .classifier import FDEClassifier
|
|
22
|
+
return FDEClassifier(random_state=a.seed, n_jobs=a.jobs, trials=a.trials, members=a.members, device=a.device,
|
|
23
|
+
verbose=0 if a.quiet else 1)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _scores(y, p):
|
|
27
|
+
from sklearn.metrics import balanced_accuracy_score, f1_score
|
|
28
|
+
return {"macro_f1": round(f1_score(y, p, average="macro"), 5), "weighted_f1": round(f1_score(y, p, average="weighted"), 5),
|
|
29
|
+
"balanced_accuracy": round(balanced_accuracy_score(y, p), 5)}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
NAMES = {"hgb": "HistGradientBoosting", "xgb": "XGBoost", "lgbm": "LightGBM", "cat": "CatBoost", "rf": "random forest",
|
|
33
|
+
"et": "extra trees", "svc": "SVC", "knn": "k-nearest neighbours", "lr": "logistic regression", "nb": "naive Bayes",
|
|
34
|
+
"mlp": "MLP", "dt": "decision tree"}
|
|
35
|
+
METRIC = {"wF1": "weighted-F1", "macroF1": "macro-F1"}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _summary(r, test=None, n_test=None, saved=None):
|
|
39
|
+
"""Readable result on stdout: the decision and the held-out scores first (highlighted), then the details, the
|
|
40
|
+
reasons and how the data were split. Plain text when stdout is not a terminal."""
|
|
41
|
+
from rich import box
|
|
42
|
+
from rich.console import Console, Group
|
|
43
|
+
from rich.panel import Panel
|
|
44
|
+
from rich.table import Table
|
|
45
|
+
from rich.text import Text
|
|
46
|
+
con, o, used = Console(highlight=False), r["options"], r["uses_ensemble"]
|
|
47
|
+
n = r["models_in_predictor"]
|
|
48
|
+
head = (Text(f"Ensemble used ({n} models: best learner, members and combiner)", style="bold green") if used else
|
|
49
|
+
Text("Best tuned learner kept (one model; the ensemble was not needed)", style="bold cyan"))
|
|
50
|
+
parts = [head]
|
|
51
|
+
if test:
|
|
52
|
+
t = Table(box=box.SIMPLE_HEAD, show_edge=False, pad_edge=False, header_style="bold")
|
|
53
|
+
t.add_column(f"held-out rows ({n_test})"); t.add_column("FDE", justify="right"); t.add_column("best tuned learner", justify="right")
|
|
54
|
+
for k, lab in (("macro_f1", "macro-F1"), ("weighted_f1", "weighted-F1"), ("balanced_accuracy", "balanced accuracy")):
|
|
55
|
+
a, b = test["fde"][k], test["best_tuned_learner"][k]
|
|
56
|
+
t.add_row(lab, Text(f"{a:.4f}", style="bold green" if a > b else ""), Text(f"{b:.4f}", style="bold green" if b > a else ""))
|
|
57
|
+
parts += [Text(""), t, Text("FDE kept the best tuned learner, so both columns are the same." if not used else
|
|
58
|
+
"Higher scores are in green.", style="dim")]
|
|
59
|
+
con.print(); con.print(Panel(Group(*parts), title="[bold]FDE result[/]", border_style="green" if used else "cyan", expand=False))
|
|
60
|
+
g = Table.grid(padding=(0, 2)); g.add_column(style="bold"); g.add_column()
|
|
61
|
+
cls = ", ".join(r["classes"][:6]) + (", ..." if len(r["classes"]) > 6 else "")
|
|
62
|
+
imb = "balanced" if r["imbalance_group"] == "balanced" else f"{r['imbalance_group']} imbalance"
|
|
63
|
+
g.add_row("Data", f"{r['rows']} training rows, {len(r['classes'])} classes ({cls}), {imb} (ratio {r['imbalance_ratio']:.1f})")
|
|
64
|
+
g.add_row("Best learner", f"{NAMES.get(r['best_learner'], r['best_learner'])} (inner {METRIC[r['metric']]} {r['best_learner_inner_score']:.4f})")
|
|
65
|
+
g.add_row("Fit time", f"{r['fit_seconds']:.0f} s ({o['trials']} trials, {o['members']} members, {o['device'].upper()})")
|
|
66
|
+
if saved:
|
|
67
|
+
g.add_row("Saved to", saved)
|
|
68
|
+
g.add_row("Why", _why(r))
|
|
69
|
+
con.print(g)
|
|
70
|
+
steps = ([f"{n_test} rows were set aside before fitting and used only for the held-out scores."] if test else []) + [
|
|
71
|
+
"On the training rows, 3 outer folds decided whether the ensemble is used, judged on rows that each fold's "
|
|
72
|
+
"models had not seen.",
|
|
73
|
+
"Inside every fold, the learners were tuned and the combiner fitted on 3 inner folds; every imputer, encoder "
|
|
74
|
+
"and scaler was fitted on its own training rows only."]
|
|
75
|
+
con.print(f"\n[bold]How {'it was evaluated' if test else 'the model was chosen'}[/] [dim](no row is used both to choose a model and to score it)[/]")
|
|
76
|
+
from rich.padding import Padding
|
|
77
|
+
lst = Table.grid(padding=(0, 1)); lst.add_column(justify="right", style="dim"); lst.add_column(style="dim")
|
|
78
|
+
for i, x in enumerate(steps, 1):
|
|
79
|
+
lst.add_row(f"{i}.", x)
|
|
80
|
+
lst.add_row("", "To compare with another tool, score it on the same held-out rows; scores computed on rows that were "
|
|
81
|
+
"also used for tuning come out higher than they will be on new data." if test else
|
|
82
|
+
"To see how the model does on new data, use `fde evaluate` or score your own held-out rows.")
|
|
83
|
+
con.print(Padding(lst, (0, 0, 0, 2)))
|
|
84
|
+
con.print()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# share of runs that used the ensemble in the accompanying study (53 datasets x 5 seeds), by imbalance group
|
|
88
|
+
STUDY = {"balanced": 8, "low": 54, "moderate": 83, "extreme": 83}
|
|
89
|
+
GROUP = {"balanced": "balanced data (imbalance ratio below 3)", "low": "data with low imbalance (ratio 3 to 10)",
|
|
90
|
+
"moderate": "data with moderate to extreme imbalance (ratio 10 or more)",
|
|
91
|
+
"extreme": "data with moderate to extreme imbalance (ratio 10 or more)"}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _why(r):
|
|
95
|
+
"""Plain-language reasons for the decision, from the cross-fitted scores of the training folds."""
|
|
96
|
+
cf, m = r["crossfit_scores"], METRIC[r["metric"]]
|
|
97
|
+
ref, ens, d = cf["reference"], cf["ensemble"], r.get("decision")
|
|
98
|
+
seen = (f"On training rows that each fold's models had not seen, the ensemble scored {ens:.4f} and the best tuned "
|
|
99
|
+
f"learner {ref:.4f} ({m}).")
|
|
100
|
+
if d == "used":
|
|
101
|
+
why = (" The ensemble was better, and its advantage held in at least 95% of the bootstrap resamples of those rows, "
|
|
102
|
+
"so FDE uses it. Its members (learners refitted with class weights, on rebalanced samples, and "
|
|
103
|
+
"imbalance-specific ensembles" + (", plus large-budget GBDTs, an MLP and large forests" if r["options"]["members"] == "full" else "")
|
|
104
|
+
+ ") correct some of the errors of the best tuned learner.")
|
|
105
|
+
elif d == "not_consistent" and ens > ref:
|
|
106
|
+
why = (" The ensemble scored a little higher, but the advantage did not hold in at least 95% of the bootstrap "
|
|
107
|
+
"resamples, so it may be chance. FDE keeps the single model, which is simpler and faster to predict.")
|
|
108
|
+
elif d == "not_consistent":
|
|
109
|
+
why = (" The ensemble was not better, so FDE keeps the single model, which is simpler and faster to predict.")
|
|
110
|
+
elif d == "same_predictions":
|
|
111
|
+
why = " The ensemble made the same predictions as the best tuned learner, so FDE keeps the single model."
|
|
112
|
+
else:
|
|
113
|
+
why = " The combiner gave the members no weight, so FDE keeps the single model."
|
|
114
|
+
ctx = (f" For comparison: in the accompanying study (full member set), the ensemble was used in "
|
|
115
|
+
f"{STUDY[r['imbalance_group']]}% of runs on {GROUP[r['imbalance_group']]}.")
|
|
116
|
+
return seen + why + ctx
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _evaluate(X, y, a):
|
|
120
|
+
import numpy as np
|
|
121
|
+
import pandas as pd
|
|
122
|
+
from sklearn.model_selection import train_test_split
|
|
123
|
+
from .classifier import prepare_frame
|
|
124
|
+
y = pd.Series(y).astype(str).to_numpy()
|
|
125
|
+
tr, te = train_test_split(np.arange(len(y)), test_size=a.test_size, stratify=y, random_state=a.seed)
|
|
126
|
+
clf = _clf(a).fit(X.iloc[tr], y[tr])
|
|
127
|
+
p = clf.predict(X.iloc[te]); best = clf.model_["models"]["P1"]
|
|
128
|
+
pb = clf.classes_[best.predict_proba(prepare_frame(X.iloc[te])[clf.columns_]).argmax(1)]
|
|
129
|
+
test = {"fde": _scores(y[te], p), "best_tuned_learner": _scores(y[te], pb)}
|
|
130
|
+
if a.json:
|
|
131
|
+
print(json.dumps({"report": clf.report_, "test": test}, indent=1))
|
|
132
|
+
else:
|
|
133
|
+
_summary(clf.report_, test, len(te))
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _common(s, trials=20, members="full"):
|
|
137
|
+
s.add_argument("--seed", type=int, default=0, help="seed shared by every split, search and member (default: 0)")
|
|
138
|
+
s.add_argument("--jobs", type=int, default=-1, help="parallel tasks; -1 chooses from the free CPU cores and memory (default: -1)")
|
|
139
|
+
s.add_argument("--trials", type=int, default=trials, help=f"Optuna trials for each learner type (default: {trials})")
|
|
140
|
+
s.add_argument("--members", choices=("full", "light"), default=members,
|
|
141
|
+
help=f"light keeps the imbalance-handling members only: faster, slightly less accurate (default: {members})")
|
|
142
|
+
s.add_argument("--device", choices=("cpu", "gpu"), default="cpu",
|
|
143
|
+
help="gpu trains XGBoost, CatBoost and the MLP on a CUDA GPU: faster on large data, results can differ "
|
|
144
|
+
"slightly from the CPU (default: cpu)")
|
|
145
|
+
s.add_argument("--quiet", action="store_true", help="no progress messages")
|
|
146
|
+
s.add_argument("--json", action="store_true", help="print the full report as JSON instead of the summary")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def main(argv=None):
|
|
150
|
+
ap = argparse.ArgumentParser(prog="fde", description="Feasible Diverse Ensembles for tabular classification")
|
|
151
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
152
|
+
for name, text in (("fit", "fit FDE and save the model"),
|
|
153
|
+
("evaluate", "fit on part of the data, then score the holdout")):
|
|
154
|
+
s = sub.add_parser(name, help=text, description=text)
|
|
155
|
+
s.add_argument("--data", required=True, help="input data (CSV or Parquet)")
|
|
156
|
+
s.add_argument("--target", required=True, help="column that holds the labels")
|
|
157
|
+
_common(s)
|
|
158
|
+
if name == "fit":
|
|
159
|
+
s.add_argument("--out", required=True, help="where to save the fitted model")
|
|
160
|
+
else:
|
|
161
|
+
s.add_argument("--test-size", type=float, default=0.2,
|
|
162
|
+
help="fraction of rows held out, stratified by class (default: 0.2)")
|
|
163
|
+
text = ("evaluate FDE on example data, no file or download needed; quick settings by default (5 trials, light "
|
|
164
|
+
"members). --example breast-cancer: the best tuned learner is enough; --example imbalanced: the ensemble is used")
|
|
165
|
+
s = sub.add_parser("demo", help=text, description=text); _common(s, trials=5, members="light")
|
|
166
|
+
s.add_argument("--example", choices=("breast-cancer", "imbalanced"), default="breast-cancer",
|
|
167
|
+
help="breast-cancer: 569 rows, balanced classes; imbalanced: 2,000 synthetic rows, 10%% minority (default: breast-cancer)")
|
|
168
|
+
s.add_argument("--test-size", type=float, default=0.2, help="fraction of rows held out, stratified by class (default: 0.2)")
|
|
169
|
+
s = sub.add_parser("predict", help="score new rows with a fitted model, as labels or probabilities",
|
|
170
|
+
description="score new rows with a fitted model, as labels or probabilities")
|
|
171
|
+
s.add_argument("--model", required=True, help="model saved by fde fit")
|
|
172
|
+
s.add_argument("--data", required=True, help="input data (CSV or Parquet)")
|
|
173
|
+
s.add_argument("--proba", action="store_true", help="write class probabilities rather than labels")
|
|
174
|
+
s.add_argument("--out", default=None, help="CSV to write (default: standard output)")
|
|
175
|
+
a = ap.parse_args(argv)
|
|
176
|
+
if a.cmd in ("demo", "fit", "evaluate") and not a.quiet:
|
|
177
|
+
print("fde loading the learner libraries (a few seconds on the first run) ...", file=sys.stderr, flush=True)
|
|
178
|
+
if a.cmd == "fit":
|
|
179
|
+
d = _read(a.data); clf = _clf(a).fit(d.drop(columns=[a.target]), d[a.target]); clf.save(a.out)
|
|
180
|
+
if a.json:
|
|
181
|
+
print(json.dumps(clf.report_, indent=1))
|
|
182
|
+
else:
|
|
183
|
+
_summary(clf.report_, saved=a.out)
|
|
184
|
+
elif a.cmd == "predict":
|
|
185
|
+
import pandas as pd
|
|
186
|
+
from .classifier import FDEClassifier
|
|
187
|
+
clf = FDEClassifier.load(a.model); d = _read(a.data)
|
|
188
|
+
out = pd.DataFrame(clf.predict_proba(d), columns=[f"p_{c}" for c in clf.classes_]) if a.proba else pd.DataFrame({"prediction": clf.predict(d)})
|
|
189
|
+
if a.out:
|
|
190
|
+
out.to_csv(a.out, index=False)
|
|
191
|
+
else:
|
|
192
|
+
out.to_csv(sys.stdout, index=False)
|
|
193
|
+
elif a.cmd == "demo":
|
|
194
|
+
quick = f"quick settings: {a.trials} trials, {a.members} members (the paper's settings: --trials 20 --members full)"
|
|
195
|
+
if a.example == "breast-cancer":
|
|
196
|
+
from sklearn.datasets import load_breast_cancer
|
|
197
|
+
d = load_breast_cancer(as_frame=True); X, y = d.data, d.target.map(dict(enumerate(d.target_names)))
|
|
198
|
+
what = "breast cancer data (569 rows, 30 columns, 2 balanced classes)"
|
|
199
|
+
else:
|
|
200
|
+
import numpy as np
|
|
201
|
+
import pandas as pd
|
|
202
|
+
from sklearn.datasets import make_classification
|
|
203
|
+
Xa, ya = make_classification(n_samples=2000, n_features=20, n_informative=6, n_redundant=4, weights=[0.9, 0.1],
|
|
204
|
+
flip_y=0.02, class_sep=0.8, random_state=1)
|
|
205
|
+
X, y = pd.DataFrame(Xa, columns=[f"x{i}" for i in range(20)]), np.where(ya == 1, "minority", "majority")
|
|
206
|
+
what = "synthetic imbalanced data (2,000 rows, 20 columns, 10% minority class)"
|
|
207
|
+
if not a.quiet:
|
|
208
|
+
from rich.console import Console
|
|
209
|
+
Console(stderr=True, highlight=False).print(f"[bold cyan]fde[/] demo: {what}; [dim]{quick}[/]")
|
|
210
|
+
_evaluate(X, y, a)
|
|
211
|
+
else:
|
|
212
|
+
d = _read(a.data); _evaluate(d.drop(columns=[a.target]), d[a.target], a)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
if __name__ == "__main__":
|
|
216
|
+
main()
|