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 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()