MLATE / mlate /models.py
Saeed's picture
MLATE V3: batched optimiser, all model families, OpenRouter protocol generation
77dc6ef verified
Raw History Blame Contribute Delete
14.5 kB
"""
The classifier zoo
==================
One registry, so that every step of the paper draws its models from the same
place and a model cannot be configured differently in two scripts.
Each entry carries what the runner needs to schedule it, not just how to build
it: whether it parallelises internally (so the outer loop does not oversubscribe
the CPU), whether it wants a GPU, whether it can produce calibrated
probabilities, and roughly how expensive it is. Getting this wrong is the
difference between a benchmark that finishes and one that thrashes.
All models receive the same input: the fold-fitted preprocessor's output, which
is dense, numeric, MinMax scaled to [0, 1] and free of NaN. Scaling is therefore
not a per-model concern, which is why no entry carries a scaler of its own.
Two dummy baselines are included deliberately and are not padding. On a task
where the majority class holds 49.9% of the corpus, an accuracy of 0.55 means
nothing until it is placed against the 0.499 a constant prediction achieves, and
the grouped protocol makes that comparison sharper still.
"""
from __future__ import annotations
import warnings
from dataclasses import dataclass, field
from typing import Callable
import numpy as np
from sklearn.discriminant_analysis import (LinearDiscriminantAnalysis,
QuadraticDiscriminantAnalysis)
from sklearn.dummy import DummyClassifier
from sklearn.ensemble import (AdaBoostClassifier, BaggingClassifier,
ExtraTreesClassifier, GradientBoostingClassifier,
HistGradientBoostingClassifier,
RandomForestClassifier, StackingClassifier,
VotingClassifier)
from sklearn.linear_model import (LogisticRegression, PassiveAggressiveClassifier,
Perceptron, RidgeClassifier, SGDClassifier)
from sklearn.naive_bayes import BernoulliNB, GaussianNB
from sklearn.neighbors import KNeighborsClassifier, NearestCentroid
from sklearn.neural_network import MLPClassifier
from sklearn.svm import SVC, LinearSVC
from sklearn.tree import DecisionTreeClassifier, ExtraTreeClassifier
from mlate import config as cfg
SEED = cfg.RANDOM_STATE
@dataclass(frozen=True)
class Spec:
"""How to build one classifier and how to schedule it."""
name: str
family: str
build: Callable[..., object]
threaded: bool = False # uses n_jobs / OMP internally
gpu: bool = False # can be placed on a CUDA device
proba: bool = True # exposes predict_proba
cost: str = "low" # low | medium | high, for scheduling order
notes: str = ""
def _zoo() -> list[Spec]:
S = Spec
return [
# ── baselines ───────────────────────────────────────────────────────
S("Dummy (majority)", "baseline",
lambda **k: DummyClassifier(strategy="most_frequent"),
notes="predicts the training majority class for every sample"),
S("Dummy (stratified)", "baseline",
lambda **k: DummyClassifier(strategy="stratified", random_state=SEED),
notes="samples from the training class distribution"),
# ── linear ──────────────────────────────────────────────────────────
S("Logistic Regression", "linear",
lambda n_jobs=1, **k: LogisticRegression(
max_iter=3000, C=1.0, n_jobs=n_jobs, random_state=SEED),
threaded=True),
S("Logistic Regression (balanced)", "linear",
lambda n_jobs=1, **k: LogisticRegression(
max_iter=3000, C=1.0, class_weight="balanced", n_jobs=n_jobs,
random_state=SEED),
threaded=True,
notes="class_weight balanced; the minority classes are small"),
S("Ridge Classifier", "linear",
lambda **k: RidgeClassifier(alpha=1.0, random_state=SEED),
proba=False),
S("SGD (hinge)", "linear",
lambda **k: SGDClassifier(loss="hinge", max_iter=3000, tol=1e-4,
random_state=SEED),
proba=False),
S("Passive Aggressive", "linear",
lambda **k: PassiveAggressiveClassifier(max_iter=3000, tol=1e-4,
random_state=SEED),
proba=False),
S("Perceptron", "linear",
lambda **k: Perceptron(max_iter=3000, tol=1e-4, random_state=SEED),
proba=False),
# ── discriminant and probabilistic ──────────────────────────────────
S("Linear Discriminant", "discriminant",
lambda **k: LinearDiscriminantAnalysis(solver="lsqr",
shrinkage="auto")),
S("Quadratic Discriminant", "discriminant", _qda,
notes="PCA to 40 components first; QDA estimates a covariance per "
"class and the smallest class has fewer samples than the 153 "
"features, which makes those covariances singular"),
S("Gaussian Naive Bayes", "naive_bayes", lambda **k: GaussianNB()),
S("Bernoulli Naive Bayes", "naive_bayes",
lambda **k: BernoulliNB(),
notes="binarises at 0.5; suits the sparse biomaterial block"),
# ── instance based ──────────────────────────────────────────────────
S("k-Nearest Neighbours", "neighbours",
lambda n_jobs=1, **k: KNeighborsClassifier(n_neighbors=5,
n_jobs=n_jobs),
threaded=True),
S("k-NN (distance weighted)", "neighbours",
lambda n_jobs=1, **k: KNeighborsClassifier(
n_neighbors=15, weights="distance", n_jobs=n_jobs),
threaded=True),
S("Nearest Centroid", "neighbours",
lambda **k: NearestCentroid(), proba=False),
# ── kernel machines ─────────────────────────────────────────────────
S("SVM (RBF)", "svm",
lambda **k: SVC(kernel="rbf", C=10.0, gamma="scale",
probability=True, random_state=SEED),
cost="high"),
S("SVM (polynomial)", "svm",
lambda **k: SVC(kernel="poly", degree=3, C=10.0, gamma="scale",
probability=True, random_state=SEED),
cost="high"),
S("Linear SVM", "svm",
lambda **k: LinearSVC(C=1.0, max_iter=5000, dual="auto",
random_state=SEED),
proba=False),
# ── single trees ────────────────────────────────────────────────────
S("Decision Tree", "tree",
lambda **k: DecisionTreeClassifier(max_depth=12, min_samples_leaf=3,
random_state=SEED)),
S("Extra Tree", "tree",
lambda **k: ExtraTreeClassifier(max_depth=12, min_samples_leaf=3,
random_state=SEED)),
# ── bagging ensembles ───────────────────────────────────────────────
S("Random Forest", "bagging",
lambda n_jobs=1, **k: RandomForestClassifier(
n_estimators=500, min_samples_leaf=2, n_jobs=n_jobs,
random_state=SEED),
threaded=True, cost="medium"),
S("Random Forest (balanced)", "bagging",
lambda n_jobs=1, **k: RandomForestClassifier(
n_estimators=500, min_samples_leaf=2,
class_weight="balanced_subsample", n_jobs=n_jobs,
random_state=SEED),
threaded=True, cost="medium"),
S("Extra Trees", "bagging",
lambda n_jobs=1, **k: ExtraTreesClassifier(
n_estimators=500, min_samples_leaf=2, n_jobs=n_jobs,
random_state=SEED),
threaded=True, cost="medium"),
S("Bagged Trees", "bagging",
lambda n_jobs=1, **k: BaggingClassifier(
estimator=DecisionTreeClassifier(max_depth=12,
random_state=SEED),
n_estimators=200, n_jobs=n_jobs, random_state=SEED),
threaded=True, cost="medium"),
# ── boosting ────────────────────────────────────────────────────────
S("AdaBoost", "boosting",
lambda **k: AdaBoostClassifier(n_estimators=300, learning_rate=0.5,
random_state=SEED),
cost="medium"),
S("Gradient Boosting", "boosting",
lambda **k: GradientBoostingClassifier(
n_estimators=300, learning_rate=0.1, max_depth=3,
random_state=SEED),
cost="high"),
S("Hist Gradient Boosting", "boosting",
lambda **k: HistGradientBoostingClassifier(
max_iter=400, learning_rate=0.08, early_stopping=True,
validation_fraction=0.15, random_state=SEED),
threaded=True, cost="medium"),
S("XGBoost", "boosting", _xgboost, threaded=True, gpu=True,
cost="medium"),
S("LightGBM", "boosting", _lightgbm, threaded=True, cost="medium"),
S("CatBoost", "boosting", _catboost, threaded=True, cost="medium",
notes="ordered boosting; symmetric trees"),
# ── neural ──────────────────────────────────────────────────────────
S("MLP (256-128)", "neural",
lambda **k: MLPClassifier(
hidden_layer_sizes=(256, 128), alpha=1e-3, max_iter=600,
early_stopping=True, n_iter_no_change=25, random_state=SEED),
threaded=True, cost="medium"),
# ── meta-ensembles ──────────────────────────────────────────────────
S("Soft Voting (RF+XGB+LR)", "meta", _voting, threaded=True,
cost="high",
notes="soft vote over a bagging, a boosting and a linear learner"),
S("Stacking (RF+XGB+LR -> LR)", "meta", _stacking, threaded=True,
cost="high",
notes="5-fold internal stacking, logistic meta-learner"),
]
# ── library-backed builders, kept out of the table for readability ──────────
def _xgboost(n_jobs: int = 1, device: str | None = None, **kwargs):
from xgboost import XGBClassifier
params = dict(n_estimators=600, learning_rate=0.06, max_depth=6,
subsample=0.85, colsample_bytree=0.85, reg_lambda=1.0,
tree_method="hist", n_jobs=n_jobs, random_state=SEED,
eval_metric="mlogloss")
if device:
params["device"] = device
return XGBClassifier(**params)
def _lightgbm(n_jobs: int = 1, **kwargs):
from lightgbm import LGBMClassifier
return LGBMClassifier(n_estimators=600, learning_rate=0.06, max_depth=-1,
num_leaves=63, subsample=0.85, subsample_freq=1,
colsample_bytree=0.85, n_jobs=n_jobs,
random_state=SEED, verbose=-1)
def _qda(**kwargs):
from sklearn.decomposition import PCA
from sklearn.pipeline import Pipeline
return Pipeline([
("pca", PCA(n_components=40, random_state=SEED)),
("qda", QuadraticDiscriminantAnalysis(reg_param=0.3)),
])
def _catboost(n_jobs: int = 1, **kwargs):
from catboost import CatBoostClassifier
return CatBoostClassifier(iterations=600, learning_rate=0.06, depth=6,
l2_leaf_reg=3.0, thread_count=n_jobs,
random_seed=SEED, verbose=0,
allow_writing_files=False)
def _bases(n_jobs: int = 1):
return [
("rf", RandomForestClassifier(n_estimators=300, min_samples_leaf=2,
n_jobs=n_jobs, random_state=SEED)),
("xgb", _xgboost(n_jobs=n_jobs)),
("lr", LogisticRegression(max_iter=3000, random_state=SEED)),
]
def _voting(n_jobs: int = 1, **kwargs):
return VotingClassifier(estimators=_bases(n_jobs), voting="soft",
n_jobs=1)
def _stacking(n_jobs: int = 1, **kwargs):
return StackingClassifier(
estimators=_bases(n_jobs),
final_estimator=LogisticRegression(max_iter=3000, random_state=SEED),
cv=5, n_jobs=1, passthrough=False)
# ── public API ──────────────────────────────────────────────────────────────
REGISTRY: dict[str, Spec] = {s.name: s for s in _zoo()}
def names(exclude_baselines: bool = False) -> list[str]:
return [n for n, s in REGISTRY.items()
if not (exclude_baselines and s.family == "baseline")]
def build(name: str, n_jobs: int = 1, device: str | None = None):
"""Instantiate a fresh, unfitted estimator."""
spec = REGISTRY[name]
kwargs = {}
if spec.threaded:
kwargs["n_jobs"] = n_jobs
if spec.gpu and device:
kwargs["device"] = device
with warnings.catch_warnings():
warnings.simplefilter("ignore")
return spec.build(**kwargs)
def summary():
import pandas as pd
return pd.DataFrame([{
"model": s.name, "family": s.family, "threaded": s.threaded,
"gpu": s.gpu, "predict_proba": s.proba, "cost": s.cost,
"notes": s.notes,
} for s in REGISTRY.values()])