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 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
@@ -0,0 +1,3 @@
1
+ """Single source of the package version (read by hatchling and by ``lighttar.__version__``)."""
2
+
3
+ __version__ = "0.1.0"