decoyfs 0.1.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.
decoyfs/__init__.py ADDED
@@ -0,0 +1,25 @@
1
+ """DecoyFS: decoy-referenced feature selection.
2
+
3
+ Quick start::
4
+
5
+ from decoyfs import DecoySelector
6
+ sel = DecoySelector().fit(X_train, y_train)
7
+ X_small = sel.transform(X_test)
8
+ sel.report_ # audit trail: votes and decoy comparison per feature
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from .compare import compare_selectors
13
+ from .decoy import DecoySelector
14
+ from .policies import list_policies, register_policy, unregister_policy
15
+ from .rankers import list_rankers, register_ranker, unregister_ranker
16
+ from .selectors import BorutaSelector, CVKSelector, FixedKSelector, NABFSSelector
17
+ from .validation import DecoyInputError
18
+
19
+ __version__ = "0.1.0"
20
+
21
+ __all__ = [
22
+ "DecoySelector", "FixedKSelector", "CVKSelector", "BorutaSelector", "NABFSSelector", "compare_selectors",
23
+ "register_ranker", "unregister_ranker", "list_rankers", "register_policy", "unregister_policy", "list_policies",
24
+ "DecoyInputError", "__version__",
25
+ ]
decoyfs/_base.py ADDED
@@ -0,0 +1,82 @@
1
+ """Common machinery of the selectors: validation, per-fold preprocessing and a scikit-learn compatible interface."""
2
+ from __future__ import annotations
3
+
4
+ import numpy as np
5
+ import pandas as pd
6
+ from sklearn.base import BaseEstimator, TransformerMixin
7
+ from sklearn.utils.validation import check_is_fitted
8
+
9
+ from .preprocessing import FoldPrep
10
+ from .validation import DecoyInputError, check_transform_X, check_X_y
11
+
12
+
13
+ class BaseSelector(TransformerMixin, BaseEstimator):
14
+ """Base class: ``fit`` validates and preprocesses, subclasses implement :meth:`_select` on the preprocessed matrix.
15
+
16
+ After ``fit``: ``support_`` (boolean mask over the ORIGINAL columns), ``selected_features_`` (names or indices),
17
+ ``n_features_in_``, ``classes_``, ``preprocessor_`` and ``fit_time_`` (seconds).
18
+ """
19
+
20
+ def _select(self, Z, y, k):
21
+ """Return the indices (into the columns of ``Z``) of the selected features."""
22
+ raise NotImplementedError
23
+
24
+ def fit(self, X, y):
25
+ import time
26
+ t0 = time.perf_counter()
27
+ Xa, yc, classes, names = check_X_y(X, y)
28
+ self.n_features_in_ = Xa.shape[1]
29
+ if names is not None:
30
+ self.feature_names_in_ = names
31
+ elif hasattr(self, "feature_names_in_"):
32
+ del self.feature_names_in_
33
+ self.classes_ = classes
34
+ self.preprocessor_ = FoldPrep().fit(Xa)
35
+ keep = self.preprocessor_.keep_
36
+ if len(keep) < 2:
37
+ raise DecoyInputError("Fewer than two non-constant features remain after preprocessing.")
38
+ Z = self.preprocessor_.transform(Xa)
39
+ sel = np.asarray(self._select(Z, yc, len(classes)), dtype=int)
40
+ mask = np.zeros(Xa.shape[1], bool)
41
+ mask[keep[sel]] = True
42
+ self.support_ = mask
43
+ self.fit_time_ = time.perf_counter() - t0
44
+ return self
45
+
46
+ def get_support(self, indices=False):
47
+ check_is_fitted(self, "support_")
48
+ return np.where(self.support_)[0] if indices else self.support_.copy()
49
+
50
+ @property
51
+ def selected_features_(self):
52
+ check_is_fitted(self, "support_")
53
+ if hasattr(self, "feature_names_in_"):
54
+ return list(self.feature_names_in_[self.support_])
55
+ return list(np.where(self.support_)[0])
56
+
57
+ def get_feature_names_out(self, input_features=None):
58
+ check_is_fitted(self, "support_")
59
+ if input_features is not None:
60
+ names = np.asarray(input_features, dtype=object)
61
+ elif hasattr(self, "feature_names_in_"):
62
+ names = self.feature_names_in_
63
+ else:
64
+ names = np.array([f"x{i}" for i in range(self.n_features_in_)], dtype=object)
65
+ return np.asarray(names[self.support_], dtype=object)
66
+
67
+ def transform(self, X):
68
+ check_is_fitted(self, "support_")
69
+ Xo = check_transform_X(X, self.n_features_in_, getattr(self, "feature_names_in_", None))
70
+ if isinstance(Xo, pd.DataFrame):
71
+ return Xo.loc[:, self.support_]
72
+ return Xo[:, self.support_]
73
+
74
+ def inverse_transform(self, X):
75
+ check_is_fitted(self, "support_")
76
+ X = np.asarray(X)
77
+ out = np.zeros((X.shape[0], self.n_features_in_), dtype=X.dtype)
78
+ out[:, self.support_] = X
79
+ return out
80
+
81
+ def fit_transform(self, X, y=None, **fit_params):
82
+ return self.fit(X, y).transform(X)
@@ -0,0 +1,4 @@
1
+ """Benchmark harness used for the DecoyFS paper (74 OpenML datasets, 4 downstream models, paired statistics).
2
+
3
+ Requires the ``bench`` extra: ``pip install "decoyfs[bench]"``.
4
+ """
decoyfs/bench/data.py ADDED
@@ -0,0 +1,258 @@
1
+ """Dataset registry (OpenML) and synthetic generator with known ground truth."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ from dataclasses import dataclass, field
7
+
8
+ import numpy as np
9
+
10
+ # Cache of downloaded datasets: override with DECOYFS_CACHE (e.g. to reuse an existing cache).
11
+ CACHE = os.environ.get("DECOYFS_CACHE") or os.path.join(os.path.expanduser("~"), ".cache", "decoyfs")
12
+
13
+ # short name -> OpenML data_id (verified). Tier order: smoke ⊂ laptop ⊂ full.
14
+ REAL = {
15
+ # --- tabulares clásicos (p pequeño/mediano)
16
+ "wdbc": 1510, "diabetes": 37, "credit-g": 31, "ilpd": 1480, "kc1": 1067, "pc4": 1049,
17
+ "climate": 1467, "steel-plates": 1504, "ozone": 1487, "spambase": 44, "qsar-biodeg": 1494,
18
+ "vehicle": 54, "segment": 36, "satimage": 182, "optdigits": 28, "texture": 40499,
19
+ "magic": 1120, "eeg-eye": 1471, "wall-robot": 1497,
20
+ # --- con atributos-ruido conocidos (AutoML/NIPS-2003)
21
+ "madelon": 1485, "philippine": 41145, "madeline": 41144, "sylvine": 41146, "jasmine": 41143,
22
+ "christine": 41142, "gina": 1038,
23
+ # --- alta dimensión
24
+ "cnae-9": 1468, "har": 1478, "isolet": 300, "bioresponse": 4134, "arcene": 1458,
25
+ "internet-ads": 40978, "micro-mass": 1514, "mfeat-factors": 12, "dna": 40670,
26
+ # --- grandes en n
27
+ "bank": 1461, "nomao": 1486, "electricity": 151,
28
+ }
29
+
30
+ # Extensión de validez externa: 20 datasets de OpenML-CC18 (estudio 99) no incluidos arriba, con n<=50k, p<=~1.6k,
31
+ # >=10 atributos, sin duplicados de REAL (segment/steel-plates/climate ya están bajo otros ids).
32
+ REAL_EXT = {
33
+ "vowel": 307, "adult": 1590, "letter": 6, "pendigits": 32, "eucalyptus": 188, "churn": 40701,
34
+ "jm1": 1053, "pc1": 1068, "sick": 38, "phishing": 4534, "gesture": 4538, "kr-vs-kp": 3,
35
+ "cylinder-bands": 6332, "mfeat-zernike": 22, "first-order-tp": 1475, "splice": 46,
36
+ "authorship": 458, "mice-protein": 40966, "semeion": 1501, "mfeat-fourier": 14,
37
+ }
38
+ REAL.update(REAL_EXT)
39
+
40
+ # (n, p, n_inf, n_red, clusters, heavy_tail, minority_frac, label_noise)
41
+ SYN = {
42
+ "syn01": (1000, 50, 5, 5, 1, False, 0.5, 0.01),
43
+ "syn02": (1000, 50, 5, 5, 2, True, 0.5, 0.01),
44
+ "syn03": (2000, 200, 10, 10, 2, False, 0.5, 0.01),
45
+ "syn04": (2000, 200, 10, 10, 2, True, 0.2, 0.03),
46
+ "syn05": (500, 500, 10, 10, 2, False, 0.5, 0.01),
47
+ "syn06": (500, 500, 10, 10, 2, True, 0.1, 0.03),
48
+ "syn07": (5000, 100, 8, 8, 3, True, 0.1, 0.01),
49
+ "syn08": (5000, 1000, 20, 20, 2, False, 0.5, 0.01),
50
+ "syn09": (200, 1000, 5, 5, 1, False, 0.5, 0.0),
51
+ "syn10": (200, 1000, 5, 5, 1, True, 0.5, 0.0),
52
+ "syn11": (3000, 300, 15, 15, 4, True, 0.3, 0.05),
53
+ "syn12": (1500, 30, 6, 0, 2, False, 0.5, 0.0),
54
+ }
55
+
56
+ # Sintéticos dependientes (bloques correlacionados, XOR, proxies): truth 2 causal, 1 proxy, 0 ruido (independiente o
57
+ # en bloques). kind: "block" (factor por bloques), "xor" (interacciones sin señal marginal), "proxy".
58
+ # dict(n, p, kind, block_size, rho, n_causal, proxies_per_causal, minority, label_noise, heavy)
59
+ SYN2 = {
60
+ "syn13": dict(n=1000, p=200, kind="block", block_size=10, rho=0.6, n_causal=8, minority=0.4, noise=0.02, heavy=False),
61
+ "syn14": dict(n=300, p=1000, kind="block", block_size=20, rho=0.8, n_causal=6, minority=0.5, noise=0.02, heavy=True),
62
+ "syn15": dict(n=2000, p=100, kind="xor", n_xor=3, n_lin=2, minority=0.5, noise=0.02, heavy=False),
63
+ "syn16": dict(n=2000, p=100, kind="proxy", n_causal=10, proxies_per_causal=2, rho=0.7, noise_block=10,
64
+ minority=0.3, noise=0.03, heavy=True),
65
+ }
66
+ SYN_ALL = list(SYN) + list(SYN2)
67
+
68
+ TIERS = {
69
+ "smoke": ["wdbc", "qsar-biodeg", "syn01", "syn02"],
70
+ "laptop": ["wdbc", "diabetes", "credit-g", "ilpd", "kc1", "pc4", "climate", "steel-plates", "ozone",
71
+ "spambase", "qsar-biodeg", "vehicle", "segment", "satimage", "optdigits", "texture",
72
+ "madelon", "philippine", "madeline", "sylvine", "jasmine", "gina", "cnae-9", "magic",
73
+ ] + list(SYN),
74
+ "full": list(REAL) + SYN_ALL,
75
+ }
76
+ # datasets grandes en n para el análisis de sensibilidad a max_n (se corren aparte, ver notebook)
77
+ MAXN_SENS = ["bank", "nomao", "electricity", "magic", "letter", "eeg-eye", "adult", "gina", "jm1"]
78
+
79
+
80
+ @dataclass
81
+ class Dataset:
82
+ name: str
83
+ X: np.ndarray # float32, puede tener NaN
84
+ y: np.ndarray # int 0..k-1
85
+ feature_names: list
86
+ truth: np.ndarray | None = None # 0 ruido, 1 redundante, 2 informativo (solo sintéticos)
87
+ meta: dict = field(default_factory=dict)
88
+
89
+
90
+ def _encode(df):
91
+ """Binarias -> 0/1; categóricas de 3..12 niveles -> one-hot; resto -> códigos ordinales."""
92
+ import pandas as pd
93
+ cols, names = [], []
94
+ for c in df.columns:
95
+ s = df[c]
96
+ if s.dtype.name in ("category", "object", "bool", "string"):
97
+ s = s.astype("category")
98
+ cats = list(s.cat.categories)
99
+ codes = s.cat.codes.to_numpy().astype("float32")
100
+ codes[codes < 0] = np.nan
101
+ if len(cats) <= 2 or len(cats) > 12:
102
+ cols.append(codes); names.append(str(c))
103
+ else:
104
+ for lv in cats:
105
+ cols.append((s == lv).to_numpy().astype("float32")); names.append(f"{c}={lv}")
106
+ else:
107
+ cols.append(pd.to_numeric(s, errors="coerce").to_numpy().astype("float32")); names.append(str(c))
108
+ return np.column_stack(cols), names
109
+
110
+
111
+ def _load_real(name):
112
+ path = os.path.join(CACHE, f"{name}.npz")
113
+ if os.path.exists(path):
114
+ z = np.load(path, allow_pickle=True)
115
+ return z["X"], z["y"], list(z["names"])
116
+ from sklearn.datasets import fetch_openml
117
+ d = fetch_openml(data_id=REAL[name], as_frame=True, parser="auto")
118
+ X, names = _encode(d.data)
119
+ classes, y = np.unique(d.target.astype(str).to_numpy(), return_inverse=True)
120
+ os.makedirs(CACHE, exist_ok=True)
121
+ np.savez_compressed(path, X=X, y=y, names=np.array(names, dtype=object))
122
+ return X, y, names
123
+
124
+
125
+ def make_synthetic(name, seed=0):
126
+ from sklearn.datasets import make_classification
127
+ n, p, n_inf, n_red, clus, heavy, minority, noise = SYN[name]
128
+ rs = int(name[3:]) * 1000 + seed
129
+ rng = np.random.default_rng(rs)
130
+ X, y = make_classification(n_samples=n, n_features=p, n_informative=n_inf, n_redundant=n_red,
131
+ n_repeated=0, n_classes=2, n_clusters_per_class=clus,
132
+ weights=[1 - minority, minority], flip_y=noise, class_sep=0.9,
133
+ shuffle=False, random_state=rs)
134
+ truth = np.zeros(p, int)
135
+ truth[:n_inf] = 2
136
+ truth[n_inf:n_inf + n_red] = 1
137
+ return _finish(name, X, y, truth, rng, heavy, SYN[name])
138
+
139
+
140
+ def _finish(name, X, y, truth, rng, heavy, cfg):
141
+ """Permuta columnas, escalas heterogéneas, colas pesadas y ruido discreto (común a todos los sintéticos)."""
142
+ p = X.shape[1]
143
+ perm = rng.permutation(p)
144
+ X, truth = X[:, perm].astype("float64"), truth[perm]
145
+ # escalas heterogéneas (provoca el artefacto de escala observado en ReliefF/MI sin normalizar)
146
+ X = X * 10 ** rng.uniform(-1, 2, p)
147
+ if heavy:
148
+ cols = rng.choice(p, size=int(0.4 * p), replace=False)
149
+ for j in cols:
150
+ z = (X[:, j] - X[:, j].mean()) / (X[:, j].std() + 1e-12)
151
+ X[:, j] = np.sinh(1.8 * z) # colas pesadas, transformación monótona
152
+ disc = rng.choice(np.where(truth == 0)[0], size=max(1, int(0.2 * (truth == 0).sum())), replace=False)
153
+ for j in disc: # ruido discreto (empates / baja cardinalidad)
154
+ X[:, j] = np.floor(np.clip((X[:, j] - X[:, j].mean()) / (X[:, j].std() + 1e-12) * 2, -4, 4))
155
+ names = [f"f{j}" for j in range(p)]
156
+ return Dataset(name, X.astype("float32"), y.astype(int), names, truth,
157
+ dict(kind="synthetic", cfg=cfg))
158
+
159
+
160
+ def _block_noise(n, p, bs, rho, rng):
161
+ """p columnas N(0,1) agrupadas en bloques de tamaño bs con correlación intra-bloque rho (modelo de un factor)."""
162
+ X = rng.normal(size=(n, p))
163
+ for s in range(0, p, bs):
164
+ f = rng.normal(size=(n, 1))
165
+ X[:, s:s + bs] = np.sqrt(rho) * f + np.sqrt(1 - rho) * X[:, s:s + bs]
166
+ return X
167
+
168
+
169
+ def _label(score, minority, noise, rng):
170
+ s = score + rng.logistic(size=len(score)) * 0.5
171
+ y = (s > np.quantile(s, 1 - minority)).astype(int)
172
+ flip = rng.random(len(y)) < noise
173
+ return np.where(flip, 1 - y, y)
174
+
175
+
176
+ def make_synthetic2(name, seed=0):
177
+ c = SYN2[name]
178
+ rs = int(name[3:]) * 1000 + seed
179
+ rng = np.random.default_rng(rs)
180
+ n, p = c["n"], c["p"]
181
+ truth = np.zeros(p, int)
182
+ if c["kind"] == "block":
183
+ bs = c["block_size"]
184
+ X = _block_noise(n, p, bs, c["rho"], rng)
185
+ blocks = rng.choice(p // bs, size=c["n_causal"], replace=False)
186
+ causal = blocks * bs # primer miembro de cada bloque elegido
187
+ for b in blocks:
188
+ truth[b * bs:(b + 1) * bs] = 1 # el resto del bloque: proxies correlacionados
189
+ truth[causal] = 2
190
+ w = rng.choice([-1.0, 1.0], size=len(causal)) * rng.uniform(0.7, 1.3, len(causal))
191
+ score = X[:, causal] @ w
192
+ score = score + 0.5 * np.tanh(X[:, causal[0]] * X[:, causal[1]])
193
+ elif c["kind"] == "xor":
194
+ X = rng.normal(size=(n, p))
195
+ npair = c["n_xor"]
196
+ for j in range(npair):
197
+ truth[2 * j:2 * j + 2] = 2
198
+ lin = np.arange(2 * npair, 2 * npair + c["n_lin"])
199
+ truth[lin] = 2
200
+ score = sum(2.0 * X[:, 2 * j] * X[:, 2 * j + 1] for j in range(npair)) + X[:, lin].sum(1)
201
+ elif c["kind"] == "proxy":
202
+ n_c, ppc = c["n_causal"], c["proxies_per_causal"]
203
+ X = rng.normal(size=(n, p))
204
+ rho = c["rho"]
205
+ for j in range(n_c):
206
+ for q in range(ppc):
207
+ col = n_c + j * ppc + q
208
+ X[:, col] = rho * X[:, j] + np.sqrt(1 - rho ** 2) * X[:, col]
209
+ truth[col] = 1
210
+ truth[:n_c] = 2
211
+ s0 = n_c * (1 + ppc)
212
+ X[:, s0:] = _block_noise(n, p - s0, c["noise_block"], 0.5, rng) # ruido que se agrupa en bloques
213
+ w = rng.choice([-1.0, 1.0], size=n_c) * rng.uniform(0.7, 1.3, n_c)
214
+ score = np.tanh(X[:, :n_c]) @ w
215
+ else:
216
+ raise ValueError(c["kind"])
217
+ score = (score - score.mean()) / (score.std() + 1e-12) * 1.5
218
+ y = _label(score, c["minority"], c["noise"], rng)
219
+ return _finish(name, X, y, truth, rng, c["heavy"], c)
220
+
221
+
222
+ def load_dataset(name, max_n=None, seed=0, min_class=10):
223
+ if name in SYN2:
224
+ ds = make_synthetic2(name, seed)
225
+ elif name in SYN:
226
+ ds = make_synthetic(name, seed)
227
+ else:
228
+ X, y, names = _load_real(name)
229
+ ds = Dataset(name, X, y, names, None, dict(kind="real"))
230
+ # clases demasiado pequeñas para CV estratificada
231
+ cnt = np.bincount(ds.y)
232
+ keep_c = np.where(cnt >= min_class)[0]
233
+ if len(keep_c) < len(cnt):
234
+ m = np.isin(ds.y, keep_c)
235
+ remap = {c: i for i, c in enumerate(keep_c)}
236
+ ds.X, ds.y = ds.X[m], np.array([remap[v] for v in ds.y[m]])
237
+ if max_n and len(ds.y) > max_n:
238
+ from sklearn.model_selection import train_test_split
239
+ idx, _ = train_test_split(np.arange(len(ds.y)), train_size=max_n, stratify=ds.y, random_state=seed)
240
+ idx = np.sort(idx)
241
+ ds.X, ds.y = ds.X[idx], ds.y[idx]
242
+ ds.meta.update(n=int(ds.X.shape[0]), p=int(ds.X.shape[1]), k=int(ds.y.max() + 1),
243
+ minority=float(np.bincount(ds.y).min() / len(ds.y)))
244
+ return ds
245
+
246
+
247
+ def describe(names):
248
+ rows = []
249
+ for n in names:
250
+ d = load_dataset(n)
251
+ rows.append(dict(dataset=n, **d.meta))
252
+ import pandas as pd
253
+ return pd.DataFrame(rows)
254
+
255
+
256
+ if __name__ == "__main__":
257
+ print(describe(list(REAL)[:3]).to_string())
258
+ json.dumps(TIERS)
@@ -0,0 +1,225 @@
1
+ """Downstream evaluation models (GPU when available). Metrics live in :mod:`decoyfs.metrics`."""
2
+ from __future__ import annotations
3
+
4
+ import warnings
5
+
6
+ import numpy as np
7
+ import torch
8
+
9
+ from ..device import device, xgb_device
10
+ from ..metrics import balanced_weights as _bal_weights
11
+ from ..metrics import evaluate, nogueira_stability # noqa: F401 (re-exported for the benchmark)
12
+
13
+
14
+ class LogReg:
15
+ def __init__(self, seed=0, C=0.5):
16
+ from sklearn.linear_model import LogisticRegression
17
+ self.m = LogisticRegression(C=C, max_iter=400, class_weight="balanced")
18
+
19
+ def fit(self, X, y):
20
+ with warnings.catch_warnings():
21
+ warnings.simplefilter("ignore")
22
+ self.m.fit(X, y)
23
+ return self
24
+
25
+ def predict_proba(self, X):
26
+ return self.m.predict_proba(X)
27
+
28
+
29
+ class KNNGPU:
30
+ def __init__(self, seed=0, k=15):
31
+ self.k = k
32
+
33
+ def fit(self, X, y):
34
+ self.dev = device()
35
+ self.Xt = torch.as_tensor(X, device=self.dev)
36
+ self.yt = torch.as_tensor(y, dtype=torch.long, device=self.dev)
37
+ self.nc = int(y.max()) + 1
38
+ prior = np.bincount(y, minlength=self.nc) / len(y)
39
+ self.w = torch.as_tensor(1.0 / np.maximum(prior, 1e-6), dtype=torch.float32, device=self.dev)
40
+ return self
41
+
42
+ def predict_proba(self, X):
43
+ out, k = [], min(self.k, len(self.yt))
44
+ T = torch.as_tensor(X, device=self.dev)
45
+ step = max(64, int(2e7 // max(1, len(self.yt))))
46
+ for s in range(0, len(T), step):
47
+ d = torch.cdist(T[s:s + step], self.Xt)
48
+ dv, di = torch.topk(d, k, dim=1, largest=False)
49
+ wt = 1.0 / (dv + 1e-3)
50
+ oh = torch.nn.functional.one_hot(self.yt[di], self.nc).float() * wt[..., None]
51
+ pr = oh.sum(1) * self.w[None, :] + 1e-3
52
+ out.append((pr / pr.sum(1, keepdim=True)).cpu().numpy())
53
+ return np.concatenate(out).astype("float64")
54
+
55
+
56
+ class XGB:
57
+ def __init__(self, seed=0, n_estimators=300, max_depth=5, learning_rate=0.08, colsample_bytree=0.8):
58
+ import xgboost as xgb
59
+ self.m = xgb.XGBClassifier(n_estimators=n_estimators, max_depth=max_depth, learning_rate=learning_rate,
60
+ subsample=0.8, colsample_bytree=colsample_bytree, tree_method="hist",
61
+ device=xgb_device(), random_state=seed, n_jobs=4, verbosity=0)
62
+
63
+ def fit(self, X, y):
64
+ self.m.fit(X, y, sample_weight=_bal_weights(y))
65
+ return self
66
+
67
+ def predict_proba(self, X):
68
+ return self.m.predict_proba(X)
69
+
70
+
71
+ class MLP:
72
+ def __init__(self, seed=0, epochs=60, patience=8, hidden=(256, 128), dropout=0.2, wd=1e-4, lr=2e-3):
73
+ self.seed, self.epochs, self.patience = seed, epochs, patience
74
+ self.hidden, self.dropout, self.wd, self.lr = hidden, dropout, wd, lr
75
+
76
+ def fit(self, X, y):
77
+ dev = device()
78
+ torch.manual_seed(self.seed)
79
+ n, p = X.shape
80
+ k = int(y.max()) + 1
81
+ rng = np.random.default_rng(self.seed)
82
+ perm = rng.permutation(n)
83
+ nv = max(int(0.1 * n), 2)
84
+ va, tr = perm[:nv], perm[nv:]
85
+ Xt = torch.as_tensor(X, device=dev)
86
+ yt = torch.as_tensor(y, dtype=torch.long, device=dev)
87
+ h1, h2 = self.hidden
88
+ self.net = torch.nn.Sequential(
89
+ torch.nn.Linear(p, h1), torch.nn.ReLU(), torch.nn.Dropout(self.dropout),
90
+ torch.nn.Linear(h1, h2), torch.nn.ReLU(), torch.nn.Dropout(self.dropout), torch.nn.Linear(h2, k)).to(dev)
91
+ cnt = np.bincount(y[tr], minlength=k).clip(min=1)
92
+ lw = torch.as_tensor(len(tr) / (k * cnt), dtype=torch.float32, device=dev)
93
+ opt = torch.optim.AdamW(self.net.parameters(), lr=self.lr, weight_decay=self.wd)
94
+ lossf = torch.nn.CrossEntropyLoss(weight=lw)
95
+ tr_t = torch.as_tensor(tr, device=dev)
96
+ va_t = torch.as_tensor(va, device=dev)
97
+ bs = 256 if n < 5000 else 1024
98
+ best, bad, state = 1e9, 0, None
99
+ for _ in range(self.epochs):
100
+ self.net.train()
101
+ sh = tr_t[torch.randperm(len(tr_t), device=dev)]
102
+ for s in range(0, len(sh), bs):
103
+ b = sh[s:s + bs]
104
+ opt.zero_grad()
105
+ lossf(self.net(Xt[b]), yt[b]).backward()
106
+ opt.step()
107
+ self.net.eval()
108
+ with torch.no_grad():
109
+ vl = lossf(self.net(Xt[va_t]), yt[va_t]).item()
110
+ if vl < best - 1e-4:
111
+ best, bad = vl, 0
112
+ state = {a: v.clone() for a, v in self.net.state_dict().items()}
113
+ else:
114
+ bad += 1
115
+ if bad >= self.patience:
116
+ break
117
+ if state:
118
+ self.net.load_state_dict(state)
119
+ self.dev = dev
120
+ return self
121
+
122
+ def predict_proba(self, X):
123
+ self.net.eval()
124
+ with torch.no_grad():
125
+ out = torch.softmax(self.net(torch.as_tensor(X, device=self.dev)), 1)
126
+ return out.cpu().numpy().astype("float64")
127
+
128
+
129
+ class ModelUnavailable(RuntimeError):
130
+ """Modelo opcional no utilizable (paquete/pesos/licencia): el runner lo desactiva y sigue."""
131
+
132
+
133
+ class RealMLP:
134
+ """RealMLP-TD (Holzmüller et al., 2025) vía pytabkit. Opcional."""
135
+
136
+ def __init__(self, seed=0, n_epochs=48, max_rows=10000):
137
+ self.seed, self.n_epochs, self.max_rows = seed, n_epochs, max_rows
138
+
139
+ def fit(self, X, y):
140
+ try:
141
+ from pytabkit import RealMLP_TD_Classifier
142
+ except Exception as e: # noqa: BLE001
143
+ raise ModelUnavailable(f"pytabkit no disponible: {repr(e)[:120]}") from e
144
+ self._k = int(y.max()) + 1
145
+ if len(y) > self.max_rows:
146
+ sub = np.random.default_rng(self.seed).choice(len(y), self.max_rows, replace=False)
147
+ X, y = X[sub], y[sub]
148
+ self.m = RealMLP_TD_Classifier(device=device(), random_state=self.seed, n_epochs=self.n_epochs, verbosity=0)
149
+ with warnings.catch_warnings():
150
+ warnings.simplefilter("ignore")
151
+ self.m.fit(X, y)
152
+ self.classes_ = np.unique(y)
153
+ return self
154
+
155
+ def predict_proba(self, X):
156
+ P = self.m.predict_proba(X)
157
+ return _full_proba(P, self.classes_, self._k)
158
+
159
+ _k = None
160
+
161
+
162
+ def _full_proba(P, classes, k):
163
+ if k is None or P.shape[1] == k:
164
+ return P
165
+ out = np.zeros((P.shape[0], k))
166
+ out[:, classes] = P
167
+ return out
168
+
169
+
170
+ class Tuned:
171
+ """Ajusta hiperparámetros con una partición interna (<=4000 filas, estratificada) y reentrena con todo el train.
172
+
173
+ Mide la sensibilidad de las conclusiones a los hiperparámetros fijos del protocolo por defecto.
174
+ """
175
+ GRIDS = {
176
+ "logreg": (LogReg, [dict(C=c) for c in (0.05, 0.5, 5.0)]),
177
+ "knn": (KNNGPU, [dict(k=k) for k in (5, 15, 40)]),
178
+ "xgb": (XGB, [dict(n_estimators=200, max_depth=3, learning_rate=0.10),
179
+ dict(n_estimators=300, max_depth=5, learning_rate=0.08),
180
+ dict(n_estimators=400, max_depth=7, learning_rate=0.05),
181
+ dict(n_estimators=400, max_depth=4, learning_rate=0.05, colsample_bytree=0.5)]),
182
+ "mlp": (MLP, [dict(hidden=(256, 128), dropout=0.2, wd=1e-4),
183
+ dict(hidden=(128, 64), dropout=0.3, wd=1e-3),
184
+ dict(hidden=(512, 256), dropout=0.4, wd=1e-4)]),
185
+ }
186
+
187
+ def __init__(self, base, seed=0, max_rows=4000):
188
+ self.base, self.seed, self.max_rows = base, seed, max_rows
189
+
190
+ def fit(self, X, y):
191
+ from sklearn.model_selection import train_test_split
192
+ cls, grid = self.GRIDS[self.base]
193
+ k = int(y.max()) + 1
194
+ idx = np.arange(len(y))
195
+ if len(y) > self.max_rows:
196
+ idx, _ = train_test_split(idx, train_size=self.max_rows, stratify=y, random_state=self.seed)
197
+ try:
198
+ a, b = train_test_split(idx, test_size=0.25, stratify=y[idx], random_state=self.seed)
199
+ except ValueError:
200
+ a, b = train_test_split(idx, test_size=0.25, random_state=self.seed)
201
+ best, best_kw = -np.inf, grid[0]
202
+ for kw in grid:
203
+ try:
204
+ P = cls(self.seed, **kw).fit(X[a], y[a]).predict_proba(X[b])
205
+ s = evaluate(y[b], P)
206
+ s = -s["logloss"] if np.isfinite(s.get("logloss", np.nan)) else -np.inf
207
+ except Exception: # noqa: BLE001
208
+ continue
209
+ if s > best:
210
+ best, best_kw = s, kw
211
+ self.best_kw = best_kw
212
+ self.m = cls(self.seed, **best_kw).fit(X, y)
213
+ return self
214
+
215
+ def predict_proba(self, X):
216
+ return self.m.predict_proba(X)
217
+
218
+
219
+ MODELS = {"logreg": LogReg, "knn": KNNGPU, "xgb": XGB, "mlp": MLP,
220
+ "logreg_t": lambda s: Tuned("logreg", s), "knn_t": lambda s: Tuned("knn", s),
221
+ "xgb_t": lambda s: Tuned("xgb", s), "mlp_t": lambda s: Tuned("mlp", s),
222
+ "realmlp": RealMLP}
223
+ CORE_MODELS = ("logreg", "knn", "xgb", "mlp")
224
+ TUNED_MODELS = ("logreg_t", "knn_t", "xgb_t", "mlp_t")
225
+ FOUNDATION_MODELS = ("realmlp",)
decoyfs/bench/plots.py ADDED
@@ -0,0 +1,42 @@
1
+ """Figuras estándar del protocolo."""
2
+ import matplotlib.pyplot as plt
3
+ import numpy as np
4
+
5
+
6
+ def cd_diagram(ranks, cd, ax=None, title="Rangos medios (menor = mejor)"):
7
+ """Diagrama de diferencia crítica simplificado: barra de rangos y grupos no distinguibles (Nemenyi)."""
8
+ r = ranks.sort_values()
9
+ fig, ax = (plt.subplots(figsize=(9, 0.35 * len(r) + 1.5)) if ax is None else (ax.figure, ax))
10
+ y = np.arange(len(r))[::-1]
11
+ ax.hlines(y, 0, r.values, color="#9ecae1")
12
+ ax.plot(r.values, y, "o", color="#08519c")
13
+ best = r.iloc[0]
14
+ for yi, (n, v) in zip(y, r.items()):
15
+ ax.text(v + 0.1, yi, f"{n} ({v:.2f})", va="center", fontsize=8,
16
+ color="#08519c" if v - best <= cd else "#444")
17
+ ax.axvline(best + cd, ls="--", c="r", lw=0.8)
18
+ ax.text(best + cd, y.max() + 0.6, f"mejor + CD={cd:.2f}", color="r", fontsize=8, ha="center")
19
+ ax.set_yticks([]); ax.set_xlabel("rango medio"); ax.set_title(title)
20
+ return fig
21
+
22
+
23
+ def pareto(tab, metric="auroc_mean", ax=None, ref="ALL"):
24
+ """Rendimiento vs fracción de atributos retenida."""
25
+ fig, ax = (plt.subplots(figsize=(7, 5)) if ax is None else (ax.figure, ax))
26
+ for n, r in tab.iterrows():
27
+ ctrl = n.startswith("CTRL")
28
+ ax.scatter(r.frac_feat_median, r[metric], c="#d62728" if ctrl else "#1f77b4", s=45 if ctrl else 30)
29
+ ax.annotate(n, (r.frac_feat_median, r[metric]), fontsize=7, xytext=(3, 3), textcoords="offset points")
30
+ ax.set_xlabel("fracción mediana de atributos retenida"); ax.set_ylabel(metric)
31
+ ax.set_title("Frontera rendimiento vs parsimonia (rojo = referenciadas a controles)")
32
+ return fig
33
+
34
+
35
+ def fpr_bars(sets, ax=None):
36
+ g = sets.groupby("policy")[["fpr_rand", "fpr_shad"]].mean().sort_values("fpr_shad")
37
+ fig, ax = (plt.subplots(figsize=(9, 4)) if ax is None else (ax.figure, ax))
38
+ g.plot.bar(ax=ax, width=0.8)
39
+ ax.set_ylabel("tasa de controles que 'pasan' (FPR estimada)")
40
+ ax.set_title("Calibración de falsos positivos con Random / Shadow")
41
+ ax.tick_params(axis="x", labelsize=7)
42
+ return fig
@@ -0,0 +1,21 @@
1
+ """Configuración por nivel (compartida por el notebook y run_benchmark.py)."""
2
+ from __future__ import annotations
3
+
4
+ POL_TUNED = ["ALL", "CTRL_VOTE_MAJ", "FIXK_25", "CVK_1SE", "BORDA_MATCH", "BORUTA", "NABFS"]
5
+ POL_EXT = ["ALL", "CTRL_VOTE_MAJ", "CTRL_BLOCK_VOTE_MAJ", "KO_BORDA_q10", "CVK_1SE", "FIXK_25", "BORUTA", "NABFS"]
6
+
7
+ # Extensiones que abordan las limitaciones 1-5 (ablaciones, baselines, controles con dependencia, modelos
8
+ # afinados y de fundación). La limitación 6 (max_n) se evalúa con una corrida aparte sobre MAXN_SENS.
9
+ EXT = dict(
10
+ ablations=True, baselines=["BORUTA", "NABFS"], boruta_iter=25, boruta_budget=240.0, nabfs_boot=20, pass2=True,
11
+ model_plan=[dict(models=["logreg_t", "knn_t", "xgb_t", "mlp_t"], policies=POL_TUNED, reps=[0], max_p=1000),
12
+ dict(models=["realmlp"], policies=POL_EXT, reps=[0], max_p=500)],
13
+ )
14
+
15
+ TIER_CFG = {
16
+ "smoke": dict(n_splits=3, n_repeats=1, max_n=5000),
17
+ "laptop": dict(n_splits=5, n_repeats=2, max_n=15000),
18
+ "full": dict(n_splits=5, n_repeats=2, max_n=15000, stab_B=5, **EXT),
19
+ }
20
+ SENS_MAXN = (3000, 10000)
21
+ SENS_CFG = dict(n_splits=5, n_repeats=2)