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 +25 -0
- decoyfs/_base.py +82 -0
- decoyfs/bench/__init__.py +4 -0
- decoyfs/bench/data.py +258 -0
- decoyfs/bench/models.py +225 -0
- decoyfs/bench/plots.py +42 -0
- decoyfs/bench/presets.py +21 -0
- decoyfs/bench/progress.py +237 -0
- decoyfs/bench/runner.py +240 -0
- decoyfs/bench/stats.py +282 -0
- decoyfs/comparators.py +127 -0
- decoyfs/compare.py +60 -0
- decoyfs/controls.py +102 -0
- decoyfs/decoy.py +200 -0
- decoyfs/dependence.py +93 -0
- decoyfs/device.py +68 -0
- decoyfs/metrics.py +46 -0
- decoyfs/policies.py +248 -0
- decoyfs/preprocessing.py +42 -0
- decoyfs/py.typed +0 -0
- decoyfs/rankers.py +211 -0
- decoyfs/selectors.py +94 -0
- decoyfs/validation.py +97 -0
- decoyfs-0.1.0.dist-info/METADATA +161 -0
- decoyfs-0.1.0.dist-info/RECORD +28 -0
- decoyfs-0.1.0.dist-info/WHEEL +5 -0
- decoyfs-0.1.0.dist-info/licenses/LICENSE +21 -0
- decoyfs-0.1.0.dist-info/top_level.txt +1 -0
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)
|
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)
|
decoyfs/bench/models.py
ADDED
|
@@ -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
|
decoyfs/bench/presets.py
ADDED
|
@@ -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)
|