lighttar 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.
- lighttar/__init__.py +19 -0
- lighttar/_arms.py +93 -0
- lighttar/_columns.py +82 -0
- lighttar/_gate.py +356 -0
- lighttar/_version.py +3 -0
- lighttar/core.py +404 -0
- lighttar/encoder.py +293 -0
- lighttar/image.py +191 -0
- lighttar/py.typed +0 -0
- lighttar/table.py +416 -0
- lighttar-0.1.0.dist-info/METADATA +114 -0
- lighttar-0.1.0.dist-info/RECORD +14 -0
- lighttar-0.1.0.dist-info/WHEEL +4 -0
- lighttar-0.1.0.dist-info/licenses/LICENSE +21 -0
lighttar/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""LightTAR: target-aware tabular features from frozen encoder layers, in closed form."""
|
|
2
|
+
|
|
3
|
+
from ._version import __version__
|
|
4
|
+
from .core import LightTARProjection
|
|
5
|
+
from .encoder import LayerEncoder, PrecomputedEncoder
|
|
6
|
+
from .image import ImageLayerEncoder, detect_image_columns
|
|
7
|
+
from .table import GatedLightTAR, LightTAR, detect_text_columns
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"LightTAR",
|
|
11
|
+
"GatedLightTAR",
|
|
12
|
+
"LightTARProjection",
|
|
13
|
+
"LayerEncoder",
|
|
14
|
+
"PrecomputedEncoder",
|
|
15
|
+
"ImageLayerEncoder",
|
|
16
|
+
"detect_text_columns",
|
|
17
|
+
"detect_image_columns",
|
|
18
|
+
"__version__",
|
|
19
|
+
]
|
lighttar/_arms.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""The two arms of ``GatedLightTAR``: the frozen pipeline (raw last layer -> PCA-30) and LightTAR from
|
|
2
|
+
moment sums shared across the inner folds."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
from sklearn.base import BaseEstimator, TransformerMixin
|
|
11
|
+
|
|
12
|
+
from .core import _moment_sums, _split_layers, raw_target
|
|
13
|
+
|
|
14
|
+
BASELINE = "frozen"
|
|
15
|
+
CANDIDATE = "lighttar"
|
|
16
|
+
FROZEN_DIM = 30 # the frozen baseline's PCA dimension
|
|
17
|
+
FROZEN_SEED = 42 # the frozen baseline's PCA seed (the SEED of MulTaBench's frozen e5 + PCA-30 pipeline)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _first_layer32(H) -> np.ndarray:
|
|
21
|
+
"""The first layer of ``(n, d)``, ``(n, L, d)`` or a list of layers, as a float32 numpy array."""
|
|
22
|
+
return np.asarray(_split_layers(H, as_numpy=True)[0][0], dtype=np.float32)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class FrozenPCA(BaseEstimator, TransformerMixin):
|
|
26
|
+
"""The standard frozen pipeline on one column: scikit-learn PCA-30 of the raw first layer (the encoder's
|
|
27
|
+
last layer by default), fitted and applied on float32 states exactly as the upstream pipeline does
|
|
28
|
+
(a float64 fit rotates near-degenerate trailing components)."""
|
|
29
|
+
|
|
30
|
+
def fit(self, H, y=None) -> FrozenPCA:
|
|
31
|
+
from sklearn.decomposition import PCA
|
|
32
|
+
|
|
33
|
+
X = _first_layer32(H)
|
|
34
|
+
self.n_components_ = max(1, min(FROZEN_DIM, X.shape[1], len(X) - 1))
|
|
35
|
+
self.pca_ = PCA(n_components=self.n_components_, random_state=FROZEN_SEED).fit(X)
|
|
36
|
+
return self
|
|
37
|
+
|
|
38
|
+
def transform(self, H) -> np.ndarray:
|
|
39
|
+
return self.pca_.transform(_first_layer32(H)).astype(np.float32, copy=False)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class ColumnStates:
|
|
44
|
+
"""One column's float64 layers, its float32 first layer, and the moment sums of all its rows (shared by
|
|
45
|
+
the inner folds)."""
|
|
46
|
+
|
|
47
|
+
mats: list
|
|
48
|
+
first32: np.ndarray
|
|
49
|
+
Y: np.ndarray
|
|
50
|
+
classes: np.ndarray | None
|
|
51
|
+
total: np.ndarray
|
|
52
|
+
|
|
53
|
+
def rows(self, idx) -> list:
|
|
54
|
+
return [m[idx] for m in self.mats]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _frozen_arm(cs: ColumnStates, tr, va, head) -> tuple[np.ndarray, np.ndarray]:
|
|
58
|
+
fz = FrozenPCA().fit(cs.first32[tr])
|
|
59
|
+
return fz.transform(cs.first32[tr]), fz.transform(cs.first32[va])
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _lighttar_arm(cs: ColumnStates, tr, va, head) -> tuple[np.ndarray, np.ndarray]:
|
|
63
|
+
"""The projection of the inner-train rows from shared sums: all rows minus the inner-val part."""
|
|
64
|
+
A = cs.total - _moment_sums(cs.rows(va), cs.Y[va])
|
|
65
|
+
proj = head()._fit_from_sums(A, [m.shape[1] for m in cs.mats], cs.classes)
|
|
66
|
+
return proj.transform(cs.rows(tr)), proj.transform(cs.rows(va))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
ARMS: dict[str, Callable] = {BASELINE: _frozen_arm, CANDIDATE: _lighttar_arm}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def prepare_column(H, y, classification: bool) -> ColumnStates:
|
|
73
|
+
"""The per-column work shared by all inner folds: float64 layers, float32 first layer, total sums."""
|
|
74
|
+
raw = _split_layers(H, as_numpy=True)[0]
|
|
75
|
+
mats = [np.asarray(m, dtype=np.float64) for m in raw]
|
|
76
|
+
Y, classes = raw_target(y, classification)
|
|
77
|
+
if not classification:
|
|
78
|
+
Y = Y - Y.mean() # the sums are shift-invariant in y; centering keeps them well conditioned
|
|
79
|
+
return ColumnStates(mats, np.asarray(raw[0], dtype=np.float32), Y, classes, _moment_sums(mats, Y))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def fold_arms(cs: ColumnStates, tr, va, head: Callable) -> dict:
|
|
83
|
+
"""``{arm: (Z_inner_train, Z_inner_val)}`` of one column on one inner fold; ``head()`` makes an unfitted
|
|
84
|
+
``LightTARProjection``. The inner-train rows must be the complement of the inner-val rows."""
|
|
85
|
+
return {arm: fn(cs, tr, va, head) for arm, fn in ARMS.items()}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def column_arms(H, y, splits, classification: bool, head: Callable) -> list[dict]:
|
|
89
|
+
"""``fold_arms`` of one column for every inner fold, sequentially."""
|
|
90
|
+
if not splits:
|
|
91
|
+
return []
|
|
92
|
+
cs = prepare_column(H, y, classification)
|
|
93
|
+
return [fold_arms(cs, tr, va, head) for tr, va in splits]
|
lighttar/_columns.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Text-column detection and the cell texts handed to an encoder."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from numbers import Number
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
import pandas as pd
|
|
9
|
+
|
|
10
|
+
MIN_TEXT_UNIQUE = 100
|
|
11
|
+
MIN_TEXT_UNIQUE_RATIO = 0.8
|
|
12
|
+
MAX_NUMERIC_FOR_CATEGORICAL = 50
|
|
13
|
+
MISSING_TEXT = "Unknown Value"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _valid(values) -> list:
|
|
17
|
+
out = []
|
|
18
|
+
for v in values:
|
|
19
|
+
if isinstance(v, str):
|
|
20
|
+
out.append(v)
|
|
21
|
+
elif v is None or _isna(v) or (isinstance(v, Number) and not np.isfinite(v)):
|
|
22
|
+
continue
|
|
23
|
+
else:
|
|
24
|
+
out.append(v)
|
|
25
|
+
return out
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _isna(v) -> bool:
|
|
29
|
+
try:
|
|
30
|
+
return bool(pd.isna(v))
|
|
31
|
+
except (TypeError, ValueError):
|
|
32
|
+
return False
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _is_numeric_value(v) -> bool:
|
|
36
|
+
if v is None:
|
|
37
|
+
return False
|
|
38
|
+
if isinstance(v, str):
|
|
39
|
+
return v.isdigit()
|
|
40
|
+
if isinstance(v, (int, float)):
|
|
41
|
+
return True
|
|
42
|
+
try:
|
|
43
|
+
float(v)
|
|
44
|
+
return True
|
|
45
|
+
except (TypeError, ValueError):
|
|
46
|
+
return False
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def is_numerical(s) -> bool:
|
|
50
|
+
"""Numerical column: it has valid values and either a numeric (or bool) dtype or more than 50 distinct
|
|
51
|
+
valid values of which at most one is non-numeric."""
|
|
52
|
+
from pandas.api.types import is_numeric_dtype
|
|
53
|
+
|
|
54
|
+
if is_numeric_dtype(s.dtype):
|
|
55
|
+
return bool(np.isfinite(s.to_numpy(dtype=float, na_value=np.nan)).any())
|
|
56
|
+
vals = _valid(s.tolist())
|
|
57
|
+
if not vals:
|
|
58
|
+
return False
|
|
59
|
+
uniq = set(vals)
|
|
60
|
+
return len(uniq) > MAX_NUMERIC_FOR_CATEGORICAL and sum(not _is_numeric_value(v) for v in uniq) <= 1
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def is_text(s) -> bool:
|
|
64
|
+
"""A non-numerical column is text if its values as strings (missing values as "Unknown Value") have at
|
|
65
|
+
least 100 distinct values or a distinct ratio of at least 0.8; otherwise it is categorical."""
|
|
66
|
+
vals = column_texts(s)
|
|
67
|
+
n = len(set(vals))
|
|
68
|
+
return bool(vals) and (n >= MIN_TEXT_UNIQUE or n / len(vals) >= MIN_TEXT_UNIQUE_RATIO)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def detect_text_columns(df) -> list:
|
|
72
|
+
"""The text columns of a DataFrame: neither datetime nor ``is_numerical``, and ``is_text``."""
|
|
73
|
+
from pandas.api.types import is_datetime64_any_dtype
|
|
74
|
+
|
|
75
|
+
return [
|
|
76
|
+
c for c in df.columns if not is_datetime64_any_dtype(df[c].dtype) and not is_numerical(df[c]) and is_text(df[c])
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def column_texts(s) -> list:
|
|
81
|
+
"""Values as strings, with missing values as "Unknown Value"."""
|
|
82
|
+
return s.astype(object).fillna(MISSING_TEXT).astype(str).tolist()
|
lighttar/_gate.py
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
"""The machinery of ``GatedLightTAR``: inner splits, the two arms, the gate model, the proxy tables and the
|
|
2
|
+
majority vote."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import sys
|
|
7
|
+
import warnings
|
|
8
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
9
|
+
from concurrent.futures import FIRST_COMPLETED, wait
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from numbers import Integral
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
import pandas as pd
|
|
15
|
+
from sklearn.base import clone
|
|
16
|
+
|
|
17
|
+
from ._arms import ( # noqa: F401 (re-exported)
|
|
18
|
+
ARMS,
|
|
19
|
+
BASELINE,
|
|
20
|
+
CANDIDATE,
|
|
21
|
+
FROZEN_DIM,
|
|
22
|
+
FROZEN_SEED,
|
|
23
|
+
FrozenPCA,
|
|
24
|
+
column_arms,
|
|
25
|
+
fold_arms,
|
|
26
|
+
prepare_column,
|
|
27
|
+
)
|
|
28
|
+
from ._columns import MISSING_TEXT, column_texts, is_numerical, is_text
|
|
29
|
+
|
|
30
|
+
MISSING = MISSING_TEXT # the category of a missing or unseen value in a categorical proxy column
|
|
31
|
+
LIGHTGBM_PARAMS = dict(
|
|
32
|
+
n_estimators=100,
|
|
33
|
+
num_leaves=7,
|
|
34
|
+
max_depth=3,
|
|
35
|
+
learning_rate=0.1,
|
|
36
|
+
min_child_samples=20,
|
|
37
|
+
n_jobs=1, # replaced per fit by the gate's thread budget
|
|
38
|
+
random_state=0,
|
|
39
|
+
verbosity=-1,
|
|
40
|
+
force_col_wise=True, # skips LightGBM's row/col-wise timing test; the same trees, any thread count
|
|
41
|
+
)
|
|
42
|
+
_NO_LIGHTGBM = "GatedLightTAR needs lightgbm: pip install 'lighttar[gate]' (or pass your own estimator as gate_model)"
|
|
43
|
+
|
|
44
|
+
Splits = list[tuple[np.ndarray, np.ndarray]]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class GateResult:
|
|
49
|
+
"""Outcome of one gate run; ``scores`` is ``(k_eff, 2)``, baseline then candidate, NaN for invalid folds."""
|
|
50
|
+
|
|
51
|
+
decision: str
|
|
52
|
+
wins: int
|
|
53
|
+
n_valid: int
|
|
54
|
+
k_eff: int
|
|
55
|
+
note: str
|
|
56
|
+
scores: np.ndarray
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def check_gate_params(gate_features, gate_cv, n_jobs) -> None:
|
|
60
|
+
"""Validate the gate's own parameters of ``GatedLightTAR``."""
|
|
61
|
+
if gate_features not in ("all", "text"):
|
|
62
|
+
raise ValueError("gate_features must be 'all' or 'text'")
|
|
63
|
+
if isinstance(gate_cv, bool) or not isinstance(gate_cv, Integral) or gate_cv < 2:
|
|
64
|
+
raise ValueError("gate_cv must be an integer >= 2")
|
|
65
|
+
if n_jobs is not None and (isinstance(n_jobs, bool) or not isinstance(n_jobs, Integral) or n_jobs < 1):
|
|
66
|
+
raise ValueError("n_jobs must be None or an integer >= 1")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def inner_splits(y, k: int, classification: bool, random_state: int) -> tuple[Splits, int, str]:
|
|
70
|
+
"""Shuffled inner K-fold splits as positional ``(train, val)`` indices; returns ``(splits, k_eff, note)``.
|
|
71
|
+
|
|
72
|
+
Classification uses ``StratifiedKFold``; rows of a class with a single row always stay on the
|
|
73
|
+
train side. A binary minority smaller than ``k`` reduces ``k`` to the minority count, and so does the
|
|
74
|
+
smallest class when no class has ``k`` rows (stratification would be impossible). Regression
|
|
75
|
+
uses ``KFold`` (``k`` reduced to the number of rows if needed). When fewer than two folds are
|
|
76
|
+
possible, ``splits`` is empty and ``k_eff`` is 0.
|
|
77
|
+
"""
|
|
78
|
+
from sklearn.model_selection import KFold, StratifiedKFold
|
|
79
|
+
|
|
80
|
+
n = len(y)
|
|
81
|
+
pos = np.arange(n)
|
|
82
|
+
if not classification:
|
|
83
|
+
k_eff = min(int(k), n)
|
|
84
|
+
if k_eff < 2:
|
|
85
|
+
return [], 0, "too few rows"
|
|
86
|
+
kf = KFold(n_splits=k_eff, shuffle=True, random_state=random_state)
|
|
87
|
+
return list(kf.split(pos)), k_eff, "ok" if k_eff == k else f"gate_cv reduced to {k_eff} (too few rows)"
|
|
88
|
+
ys = np.asarray(y).astype(str)
|
|
89
|
+
vals, counts = np.unique(ys, return_counts=True)
|
|
90
|
+
single = np.isin(ys, vals[counts < 2])
|
|
91
|
+
elig, ye = pos[~single], ys[~single]
|
|
92
|
+
ev_counts = np.unique(ye, return_counts=True)[1]
|
|
93
|
+
if len(ev_counts) < 2:
|
|
94
|
+
return [], 0, "single class"
|
|
95
|
+
k_eff, notes = min(int(k), len(elig)), []
|
|
96
|
+
if len(ev_counts) == 2 and ev_counts.min() < k_eff:
|
|
97
|
+
k_eff = int(ev_counts.min())
|
|
98
|
+
notes.append(f"binary minority {k_eff} < {k}: gate_cv reduced to {k_eff}")
|
|
99
|
+
elif ev_counts.max() < k_eff:
|
|
100
|
+
k_eff = int(ev_counts.min())
|
|
101
|
+
notes.append(f"every class has fewer than {k} rows: gate_cv reduced to {k_eff}")
|
|
102
|
+
if k_eff < 2:
|
|
103
|
+
return [], 0, "too few rows"
|
|
104
|
+
skf = StratifiedKFold(n_splits=k_eff, shuffle=True, random_state=random_state)
|
|
105
|
+
with warnings.catch_warnings(): # a rare class with fewer rows than folds is expected here
|
|
106
|
+
warnings.simplefilter("ignore", UserWarning)
|
|
107
|
+
raw = list(skf.split(elig, ye))
|
|
108
|
+
splits = [(np.sort(np.concatenate([elig[tr], pos[single]])), np.sort(elig[va])) for tr, va in raw]
|
|
109
|
+
if single.any():
|
|
110
|
+
notes.append(f"{int(single.sum())} single-row-class rows kept on the train side")
|
|
111
|
+
return splits, k_eff, "; ".join(notes) or "ok"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _macro_auc(y_true, proba, classes) -> float:
|
|
115
|
+
"""Mean one-vs-rest AUC over the classes present in ``y_true`` (with both outcomes)."""
|
|
116
|
+
from sklearn.metrics import roc_auc_score
|
|
117
|
+
|
|
118
|
+
y_true = np.asarray(y_true)
|
|
119
|
+
aucs = []
|
|
120
|
+
for j, c in enumerate(classes):
|
|
121
|
+
pos = y_true == c
|
|
122
|
+
if 0 < pos.sum() < len(pos):
|
|
123
|
+
aucs.append(roc_auc_score(pos.astype(int), proba[:, j]))
|
|
124
|
+
if not aucs:
|
|
125
|
+
raise ValueError("no class with both outcomes in the validation rows")
|
|
126
|
+
return float(np.mean(aucs))
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def default_scorer(classification: bool, y) -> Callable:
|
|
130
|
+
"""The benchmark metric: AUC (binary: positive-class probability; multiclass: macro one-vs-rest),
|
|
131
|
+
or R² for regression. Probability columns follow the sorted classes of ``y``."""
|
|
132
|
+
from sklearn.metrics import r2_score
|
|
133
|
+
|
|
134
|
+
if not classification:
|
|
135
|
+
return lambda y_true, pred: float(r2_score(y_true, pred))
|
|
136
|
+
classes = np.unique(np.asarray(y))
|
|
137
|
+
if len(classes) == 2:
|
|
138
|
+
|
|
139
|
+
def binary_auc(y_true, proba):
|
|
140
|
+
from sklearn.metrics import roc_auc_score
|
|
141
|
+
|
|
142
|
+
return float(roc_auc_score((np.asarray(y_true) == classes[1]).astype(int), np.asarray(proba)[:, 1]))
|
|
143
|
+
|
|
144
|
+
return binary_auc
|
|
145
|
+
return lambda y_true, proba: _macro_auc(y_true, np.asarray(proba), classes)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def make_gate_model(spec, classification: bool):
|
|
149
|
+
"""An unfitted gate model: ``"lightgbm"`` (LightGBM-small, fixed parameters) or a clone of the given
|
|
150
|
+
scikit-learn estimator."""
|
|
151
|
+
if isinstance(spec, str):
|
|
152
|
+
if spec != "lightgbm":
|
|
153
|
+
raise ValueError(f"gate_model must be 'lightgbm' or an estimator, got {spec!r}")
|
|
154
|
+
try:
|
|
155
|
+
import lightgbm
|
|
156
|
+
except ImportError as err:
|
|
157
|
+
raise ImportError(_NO_LIGHTGBM) from err
|
|
158
|
+
cls = lightgbm.LGBMClassifier if classification else lightgbm.LGBMRegressor
|
|
159
|
+
return cls(**LIGHTGBM_PARAMS)
|
|
160
|
+
if not hasattr(spec, "fit"):
|
|
161
|
+
raise ValueError(f"gate_model must be 'lightgbm' or an estimator with fit, got {type(spec).__name__}")
|
|
162
|
+
return clone(spec)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _as_datetime(s: pd.Series) -> pd.Series:
|
|
166
|
+
"""As the upstream date parsing: values through ``pd.to_datetime(errors="coerce")`` (dates outside the
|
|
167
|
+
nanosecond range become missing), in local time."""
|
|
168
|
+
s = pd.to_datetime(s.apply(lambda v: v.replace('"', "") if isinstance(v, str) else v), errors="coerce")
|
|
169
|
+
if s.dt.tz is not None:
|
|
170
|
+
s = s.dt.tz_localize(None)
|
|
171
|
+
return s.where(s.between(pd.Timestamp.min, pd.Timestamp.max))
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _date_features(train: pd.Series, val: pd.Series) -> dict:
|
|
175
|
+
"""A datetime column as year, month, day (hour unless every train value is a date), seconds since the
|
|
176
|
+
epoch and ISO weekday, float32 values as float64, NaN where missing (skrub's ``DatetimeEncoder`` with
|
|
177
|
+
``add_weekday=True``, as the upstream date preprocessing)."""
|
|
178
|
+
a, b = _as_datetime(train), _as_datetime(val)
|
|
179
|
+
known = a.dropna()
|
|
180
|
+
feats = ["year", "month", "day"] + ([] if (known.dt.normalize() == known).all() else ["hour"])
|
|
181
|
+
out = {}
|
|
182
|
+
for f in [*feats, "total_seconds", "weekday"]:
|
|
183
|
+
pair = []
|
|
184
|
+
for s in (a, b):
|
|
185
|
+
if f == "total_seconds":
|
|
186
|
+
v = (s - pd.Timestamp(1970, 1, 1)) / pd.Timedelta("1s")
|
|
187
|
+
elif f == "weekday":
|
|
188
|
+
v = s.dt.day_of_week + 1
|
|
189
|
+
else:
|
|
190
|
+
v = getattr(s.dt, f)
|
|
191
|
+
pair.append(v.astype("float32").astype("float64"))
|
|
192
|
+
out[f"{train.name}_{f}"] = tuple(pair)
|
|
193
|
+
return out
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def encode_remainder(train_df: pd.DataFrame, val_df: pd.DataFrame) -> tuple[pd.DataFrame, pd.DataFrame]:
|
|
197
|
+
"""The non-replaced columns, encoded on ``train_df`` only, as the upstream (MulTaBench) preprocessing:
|
|
198
|
+
datetime columns are expanded by ``_date_features`` and placed last; then ``is_numerical`` columns become
|
|
199
|
+
float64 (non-numeric values become NaN) and any other column a pandas ``category`` over its sorted string
|
|
200
|
+
values in ``train_df`` plus ``MISSING``, which also takes missing and unseen values."""
|
|
201
|
+
from pandas.api.types import is_datetime64_any_dtype
|
|
202
|
+
|
|
203
|
+
cols, dates = {}, {}
|
|
204
|
+
for col in train_df.columns:
|
|
205
|
+
a, b = train_df[col], val_df[col]
|
|
206
|
+
if is_datetime64_any_dtype(a.dtype):
|
|
207
|
+
dates.update(_date_features(a, b))
|
|
208
|
+
else:
|
|
209
|
+
cols[col] = (a, b)
|
|
210
|
+
tr, va = {}, {}
|
|
211
|
+
for col, (a, b) in {**cols, **dates}.items():
|
|
212
|
+
if is_numerical(a):
|
|
213
|
+
tr[col], va[col] = (pd.to_numeric(v, errors="coerce").astype("float64") for v in (a, b))
|
|
214
|
+
else:
|
|
215
|
+
cats = sorted(set(column_texts(a)) | {MISSING})
|
|
216
|
+
for v, out in ((a, tr), (b, va)):
|
|
217
|
+
t = pd.Series(column_texts(v), index=v.index)
|
|
218
|
+
out[col] = pd.Categorical(t.where(t.isin(cats), MISSING), categories=cats)
|
|
219
|
+
return pd.DataFrame(tr, index=train_df.index), pd.DataFrame(va, index=val_df.index)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def fold_tables(arms: Mapping[str, Mapping], frame: pd.DataFrame, tr, va) -> dict:
|
|
223
|
+
"""``{arm: (X_inner_train, X_inner_val)}`` of one fold as DataFrames in the research gate's column order.
|
|
224
|
+
A replaced column counts as text on this fold only if its inner-train values are text (``is_text``, not
|
|
225
|
+
``is_numerical``); otherwise it is encoded with the other columns. The other columns of ``frame``
|
|
226
|
+
(encoded on inner-train, in frame order, dates last) come first, then the arm's block of every text column
|
|
227
|
+
in sorted column-name order. ``arms[c]`` is ``fold_arms(...)`` of column ``c``. Columns are named
|
|
228
|
+
``f0, f1, ...`` (LightGBM rejects some characters in feature names)."""
|
|
229
|
+
sub = frame.iloc[tr]
|
|
230
|
+
order = sorted((c for c in arms if not is_numerical(sub[c]) and is_text(sub[c])), key=str)
|
|
231
|
+
rest = [c for c in frame.columns if c not in order]
|
|
232
|
+
extra = encode_remainder(sub[rest], frame.iloc[va][rest])
|
|
233
|
+
fold = {}
|
|
234
|
+
for arm in ARMS:
|
|
235
|
+
sides = []
|
|
236
|
+
for side in (0, 1):
|
|
237
|
+
blk = [pd.DataFrame(np.asarray(arms[c][arm][side], dtype=np.float64)) for c in order]
|
|
238
|
+
rem = [extra[side].reset_index(drop=True)] if extra[side].shape[1] else []
|
|
239
|
+
X = pd.concat(rem + blk, axis=1) if rem + blk else pd.DataFrame(index=range(len(extra[side])))
|
|
240
|
+
X.columns = [f"f{i}" for i in range(X.shape[1])]
|
|
241
|
+
sides.append(X)
|
|
242
|
+
fold[arm] = tuple(sides)
|
|
243
|
+
return fold
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def proxy_tables(blocks: Mapping[str, Sequence[Mapping]], frame: pd.DataFrame, splits: Splits) -> list[dict]:
|
|
247
|
+
"""``fold_tables`` for every fold; ``blocks[c][j]`` is ``column_arms(...)[j]`` of column ``c``."""
|
|
248
|
+
return [fold_tables({c: b[j] for c, b in blocks.items()}, frame, tr, va) for j, (tr, va) in enumerate(splits)]
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _is_lightgbm(model) -> bool:
|
|
252
|
+
"""True for a LightGBM scikit-learn model (or a subclass); never imports lightgbm."""
|
|
253
|
+
lgb = sys.modules.get("lightgbm")
|
|
254
|
+
return lgb is not None and isinstance(model, lgb.LGBMModel)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def as_float(X: pd.DataFrame) -> np.ndarray:
|
|
258
|
+
"""A proxy table for estimators without categorical support: category columns as their codes."""
|
|
259
|
+
cols = [X[c].cat.codes.astype("float64") if isinstance(X[c].dtype, pd.CategoricalDtype) else X[c] for c in X]
|
|
260
|
+
return np.column_stack(cols).astype(np.float64) if cols else np.empty((len(X), 0))
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _predict(model, X, classification: bool, classes: np.ndarray):
|
|
264
|
+
"""``predict_proba`` with columns aligned to ``classes`` (classification), else ``predict``."""
|
|
265
|
+
if not classification:
|
|
266
|
+
return model.predict(X)
|
|
267
|
+
P = np.asarray(model.predict_proba(X))
|
|
268
|
+
seen = list(getattr(model, "classes_", classes))
|
|
269
|
+
if len(seen) == len(classes) and np.array_equal(np.asarray(seen), classes):
|
|
270
|
+
return P
|
|
271
|
+
out = np.zeros((len(P), len(classes)))
|
|
272
|
+
idx = {c: j for j, c in enumerate(classes.tolist())}
|
|
273
|
+
for j, c in enumerate(seen):
|
|
274
|
+
out[:, idx[c]] = P[:, j]
|
|
275
|
+
return out
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _fit_score(model, threads: int, tables, y, tr, va, classification: bool, classes, score) -> float:
|
|
279
|
+
"""One proxy fit (a fresh clone with ``threads`` threads when it has ``n_jobs``) and its inner-val score.
|
|
280
|
+
Estimators other than LightGBM get float tables, and joblib's thread backend (no worker processes)."""
|
|
281
|
+
fitted = clone(model)
|
|
282
|
+
if "n_jobs" in fitted.get_params(deep=False):
|
|
283
|
+
fitted.set_params(n_jobs=threads)
|
|
284
|
+
if _is_lightgbm(model):
|
|
285
|
+
fitted.fit(*tables[:1], y[tr])
|
|
286
|
+
return float(score(y[va], _predict(fitted, tables[1], classification, classes)))
|
|
287
|
+
from joblib import parallel_config
|
|
288
|
+
|
|
289
|
+
Xtr, Xva = as_float(tables[0]), as_float(tables[1])
|
|
290
|
+
with parallel_config(backend="threading"): # thread-local: an estimator's own joblib work stays in threads
|
|
291
|
+
fitted.fit(Xtr, y[tr])
|
|
292
|
+
return float(score(y[va], _predict(fitted, Xva, classification, classes)))
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def run_gate(pool, threads: int, preps: Mapping, frame: pd.DataFrame, y, splits: Splits, model, classification: bool,
|
|
296
|
+
scoring, head: Callable, k_eff: int = 0, note: str = "ok") -> GateResult: # fmt: skip
|
|
297
|
+
"""Run the gate on ``pool`` and take the majority vote.
|
|
298
|
+
|
|
299
|
+
``preps[c]`` is a future of ``prepare_column`` for text column ``c``. Per fold, the arms of every
|
|
300
|
+
column, then the fold's tables, then its two proxy fits are submitted as their inputs complete, so
|
|
301
|
+
folds overlap; each proxy fit gets ``threads`` threads. A fold whose proxy fit
|
|
302
|
+
or scorer raises is invalid. The candidate wins a fold when it scores strictly higher; it is chosen
|
|
303
|
+
iff ``2 * wins > n_valid``, and also when no fold is valid.
|
|
304
|
+
"""
|
|
305
|
+
y = np.asarray(y)
|
|
306
|
+
classes = np.unique(y) if classification else None
|
|
307
|
+
score = scoring or default_scorer(classification, y)
|
|
308
|
+
scores = np.full((len(splits), 2), np.nan)
|
|
309
|
+
errors: dict[int, list] = {}
|
|
310
|
+
arms: dict[int, dict] = {j: {} for j in range(len(splits))}
|
|
311
|
+
pending = {f: ("prep", c) for c, f in preps.items()} if splits else {}
|
|
312
|
+
while pending:
|
|
313
|
+
done, _ = wait(pending, return_when=FIRST_COMPLETED)
|
|
314
|
+
for f in sorted(done, key=lambda f: repr(pending[f])): # a reproducible submission order
|
|
315
|
+
kind, key = pending.pop(f)
|
|
316
|
+
if kind == "fit":
|
|
317
|
+
try:
|
|
318
|
+
scores[key] = f.result()
|
|
319
|
+
except Exception as err: # any failure of the gate model or the scorer invalidates the fold
|
|
320
|
+
errors.setdefault(key[0], []).append((key[1], f"{type(err).__name__}: {err}"))
|
|
321
|
+
continue
|
|
322
|
+
out = f.result() # representation and table errors propagate
|
|
323
|
+
if kind == "prep":
|
|
324
|
+
for j, (tr, va) in enumerate(splits):
|
|
325
|
+
pending[pool.submit(fold_arms, out, tr, va, head)] = ("arm", (key, j))
|
|
326
|
+
elif kind == "arm":
|
|
327
|
+
(c, j), (tr, va) = key, splits[key[1]]
|
|
328
|
+
arms[j][c] = out
|
|
329
|
+
if len(arms[j]) == len(preps):
|
|
330
|
+
pending[pool.submit(fold_tables, arms.pop(j), frame, tr, va)] = ("table", j)
|
|
331
|
+
else:
|
|
332
|
+
tr, va = splits[key]
|
|
333
|
+
for a, arm in enumerate(ARMS):
|
|
334
|
+
task = (model, threads, out[arm], y, tr, va, classification, classes, score)
|
|
335
|
+
pending[pool.submit(_fit_score, *task)] = ("fit", (key, a))
|
|
336
|
+
return vote(scores, [min(errors[j])[1] for j in sorted(errors)], k_eff, note)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def vote(scores: np.ndarray, errors: list, k_eff: int, note: str) -> GateResult:
|
|
340
|
+
"""The majority vote over the valid folds (``errors``: the first error of each invalid fold)."""
|
|
341
|
+
for j in range(len(scores)):
|
|
342
|
+
if np.isnan(scores[j]).any():
|
|
343
|
+
scores[j] = np.nan
|
|
344
|
+
valid = ~np.isnan(scores).any(1)
|
|
345
|
+
n_valid = int(valid.sum())
|
|
346
|
+
wins = int((scores[valid, 1] > scores[valid, 0]).sum())
|
|
347
|
+
notes = [] if note == "ok" else [note]
|
|
348
|
+
if errors:
|
|
349
|
+
notes.append(f"gate model failed on {len(errors)} fold{'s' if len(errors) > 1 else ''}")
|
|
350
|
+
warnings.warn(f"GatedLightTAR: {notes[-1]} (first error: {errors[0]})", stacklevel=4)
|
|
351
|
+
if n_valid == 0:
|
|
352
|
+
warnings.warn(
|
|
353
|
+
f"GatedLightTAR: the gate could not run ({'; '.join(notes) or 'no folds'}); keeping LightTAR", stacklevel=4
|
|
354
|
+
)
|
|
355
|
+
decision = CANDIDATE if n_valid == 0 or 2 * wins > n_valid else BASELINE
|
|
356
|
+
return GateResult(decision, wins, n_valid, int(k_eff), "; ".join(notes) or "ok", scores)
|
lighttar/_version.py
ADDED