AQIT 0.1.0
Loading...
Searching...
No Matches
tabular.py
Go to the documentation of this file.
1# Copyright (c) 2025-present Aquin Labs Private Limited. All Rights Reserved.
2# This file is part of the Aquin Engine. Unauthorized copying, modification,
3# distribution, or use of this file, via any medium, is strictly prohibited.
4# Proprietary and confidential. See LICENSE for terms.
5
6"""Tabular backends: linear and boosting on CSV."""
7
8from __future__ import annotations
9
10import pickle
11from pathlib import Path
12from typing import Any
13
14import numpy as np
15import pandas as pd
16from sklearn.compose import ColumnTransformer
17from sklearn.ensemble import HistGradientBoostingClassifier, HistGradientBoostingRegressor
18from sklearn.impute import SimpleImputer
19from sklearn.inspection import permutation_importance
20from sklearn.linear_model import LogisticRegression, Ridge
21from sklearn.metrics import accuracy_score, f1_score, mean_squared_error, r2_score
22from sklearn.model_selection import train_test_split
23from sklearn.pipeline import Pipeline
24from sklearn.preprocessing import OneHotEncoder
25
26from aquin.recipe.schema import Checkpoint, EvalGate, Recipe, RecipeError
27from aquin.run import Run
28
29
30def _task(recipe: Recipe, y: pd.Series) -> str:
31 explicit = str(recipe.train.get("task") or "").lower()
32 if explicit in ("classification", "regression"):
33 return explicit
34 if y.dtype == object or str(y.dtype) in ("string", "bool", "boolean"):
35 return "classification"
36 if y.nunique() <= 20 and y.dtype.kind in "iu":
37 return "classification"
38 return "regression"
39
40
41def _build_pipeline(method: str, task: str, X: pd.DataFrame, train_cfg: dict[str, Any]) -> Pipeline:
42 num_cols = [c for c in X.columns if pd.api.types.is_numeric_dtype(X[c])]
43 cat_cols = [c for c in X.columns if c not in num_cols]
44 transformers: list[tuple[str, Any, list[str]]] = []
45 if num_cols:
46 transformers.append(("num", SimpleImputer(strategy="median"), num_cols))
47 if cat_cols:
48 transformers.append(
49 (
50 "cat",
51 Pipeline(
52 [
53 ("impute", SimpleImputer(strategy="most_frequent")),
54 ("oh", OneHotEncoder(handle_unknown="ignore", sparse_output=False)),
55 ]
56 ),
57 cat_cols,
58 )
59 )
60 if not transformers:
61 raise RecipeError("No usable columns in CSV.")
62 pre = ColumnTransformer(transformers, remainder="drop")
63 if task == "classification":
64 est: Any = (
65 LogisticRegression(max_iter=int(train_cfg.get("max_iter") or 400))
66 if method == "linear"
67 else HistGradientBoostingClassifier(
68 max_depth=int(train_cfg.get("max_depth") or 6),
69 min_samples_leaf=int(train_cfg.get("min_samples_leaf") or 2),
70 )
71 )
72 else:
73 est = (
74 Ridge(alpha=float(train_cfg.get("alpha") or 1.0))
75 if method == "linear"
76 else HistGradientBoostingRegressor(
77 max_depth=int(train_cfg.get("max_depth") or 6),
78 min_samples_leaf=int(train_cfg.get("min_samples_leaf") or 2),
79 )
80 )
81 return Pipeline([("pre", pre), ("est", est)])
82
83
84def _scores(task: str, y_true: Any, y_pred: Any) -> dict[str, float]:
85 if task == "classification":
86 return {
87 "accuracy": float(accuracy_score(y_true, y_pred)),
88 "f1_macro": float(f1_score(y_true, y_pred, average="macro", zero_division=0)),
89 }
90 rmse = float(np.sqrt(mean_squared_error(y_true, y_pred)))
91 return {"rmse": rmse, "r2": float(r2_score(y_true, y_pred))}
92
93
94def _gate_score(metric: str, scores: dict[str, float], task: str) -> float:
95 if metric in scores:
96 return scores[metric]
97 return scores["accuracy"] if task == "classification" else scores["rmse"]
99
100def train_tabular(recipe: Recipe, recorder: Run, out_dir: Path) -> dict[str, Any]:
101 path = Path(str(recipe.data["path"]))
102 target = str(recipe.data["target"])
103 df = pd.read_csv(path)
104 if target not in df.columns:
105 raise RecipeError(f"target column {target!r} not in {list(df.columns)}")
106 y = df[target]
107 X = df.drop(columns=[target])
108 if X.empty:
109 raise RecipeError("CSV has no feature columns.")
110 task = _task(recipe, y)
111 method = str(recipe.train["method"]).lower()
112 seed = int(recipe.train.get("seed") or 0)
113 test_size = float(recipe.train.get("test_size") or 0.2)
114
115 strat = y if task == "classification" and y.nunique() > 1 else None
116 try:
117 X_tr, X_te, y_tr, y_te = train_test_split(
118 X, y, test_size=test_size, random_state=seed, stratify=strat
119 )
120 except ValueError:
121 X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=test_size, random_state=seed)
122
123 pipe = _build_pipeline(method, task, X_tr, recipe.train)
124 pipe.fit(X_tr, y_tr)
125 pred = pipe.predict(X_te)
126 scores = _scores(task, y_te, pred)
127 recorder.log(1, loss=float(scores.get("rmse") or 1.0 - scores.get("accuracy", 0.0)))
128
129 ckpt_path = out_dir / "checkpoints" / "model.pkl"
130 ckpt_path.parent.mkdir(parents=True, exist_ok=True)
131 with ckpt_path.open("wb") as fh:
132 pickle.dump({"pipeline": pipe, "task": task, "target": target, "columns": list(X.columns)}, fh)
133 recorder.signal(1, f"checkpoint saved ({ckpt_path.name})")
134
135 inspect: dict[str, Any] = {"task": task, "features": list(X.columns)}
136 try:
137 perm = permutation_importance(pipe, X_te, y_te, n_repeats=5, random_state=seed)
138 ranking = sorted(
139 zip(X.columns, perm.importances_mean.tolist()),
140 key=lambda x: abs(x[1]),
141 reverse=True,
142 )
143 inspect["permutation_importance"] = [
144 {"feature": str(name), "importance": float(val)} for name, val in ranking
145 ]
146 except Exception as exc:
147 inspect["permutation_importance_error"] = str(exc)
148
149 metric = str(recipe.eval.get("metric") or ("accuracy" if task == "classification" else "rmse"))
150 score = _gate_score(metric, scores, task)
151 min_score = recipe.eval.get("min_score")
152 if min_score is None:
153 min_score = recipe.eval.get("max_score") if metric == "rmse" else None
154 lower_is_better = metric in ("rmse", "mae", "loss")
155 if min_score is None:
156 passed = True
157 skipped = True
158 else:
159 skipped = False
160 thresh = float(min_score)
161 passed = score <= thresh if lower_is_better else score >= thresh
162
163 gate = EvalGate(
164 passed=passed,
165 metric=metric,
166 score=round(float(score), 6),
167 min_score=None if min_score is None else float(min_score),
168 skipped=skipped,
169 details={"scores": scores, "n_train": int(len(X_tr)), "n_eval": int(len(X_te))},
170 )
171 return {
172 "checkpoint": Checkpoint(path=str(ckpt_path), kind="sklearn", step=1, extra={"task": task}),
173 "gate": gate,
174 "metrics": scores,
175 "inspect": inspect,
176 }
float _gate_score(str metric, dict[str, float] scores, str task)
Definition tabular.py:98
Pipeline _build_pipeline(str method, str task, pd.DataFrame X, dict[str, Any] train_cfg)
Definition tabular.py:45
str _task(Recipe recipe, pd.Series y)
Definition tabular.py:34
dict[str, Any] train_tabular(Recipe recipe, Run recorder, Path out_dir)
Definition tabular.py:104
dict[str, float] _scores(str task, Any y_true, Any y_pred)
Definition tabular.py:88