SurfSense / ml /train.py
Joshua Wehr
Claude Sonnet 4.6
Add ML scoring: model, training pipeline, and fallback fix
d88effc
Raw History Blame Contribute Delete
5.13 kB
"""
XGBoost training script for the surf condition model (Section 3.3.5).
Workflow:
1. Load train / val splits from ml/data/processed/.
2. Apply synthetic labels (ml/labels.py).
3. Extract features (app/ml/feature_extractor.py).
4. Fit a SimpleImputer on training data.
5. Grid-search XGBoost with TimeSeriesSplit (5-fold).
6. Evaluate best model on the val set.
7. Persist model + imputer + metadata to ml/models/.
Usage:
python -m ml.train
python -m ml.train --no-search # skip grid search, use default params
"""
from __future__ import annotations
import argparse
import hashlib
import json
from datetime import datetime, timezone
from pathlib import Path
import joblib
import numpy as np
import pandas as pd
from sklearn.ensemble import HistGradientBoostingRegressor
from sklearn.impute import SimpleImputer
from sklearn.metrics import r2_score, mean_absolute_error, mean_squared_error
from sklearn.model_selection import GridSearchCV, TimeSeriesSplit
from app.ml.feature_extractor import FEATURE_NAMES, ForecastPointFeatureExtractor
from ml.labels import compute_synthetic_score
from ml.splits import split
MODELS_DIR = Path("ml/models")
PROCESSED = Path("ml/data/processed")
PARAM_GRID = {
"max_iter": [100, 300, 500],
"max_depth": [3, 5, 7],
"learning_rate": [0.01, 0.05, 0.1],
"min_samples_leaf": [10, 20, 50],
}
DEFAULT_PARAMS = {
"max_iter": 300,
"max_depth": 5,
"learning_rate": 0.05,
"min_samples_leaf": 20,
"l2_regularization": 0.1,
"random_state": 42,
}
def _prepare(df: pd.DataFrame, extractor: ForecastPointFeatureExtractor) -> np.ndarray:
"""Extract features and add synthetic labels."""
X = extractor.transform_batch(df, skill_level="intermediate")
return X
def _add_labels(df: pd.DataFrame) -> pd.Series:
return df.apply(compute_synthetic_score, axis=1)
def _dataset_hash(df: pd.DataFrame) -> str:
return hashlib.md5(pd.util.hash_pandas_object(df, index=False).values).hexdigest()[:12]
def train(grid_search: bool = True) -> None:
MODELS_DIR.mkdir(parents=True, exist_ok=True)
print("Loading splits …")
train_df, val_df, _ = split()
print(f" train={len(train_df):,} val={len(val_df):,}")
extractor = ForecastPointFeatureExtractor()
print("Extracting features …")
X_train = _prepare(train_df, extractor)
X_val = _prepare(val_df, extractor)
print("Computing synthetic labels …")
y_train = _add_labels(train_df).values
y_val = _add_labels(val_df).values
print("Fitting imputer on training data …")
imputer = SimpleImputer(strategy="median")
X_train = imputer.fit_transform(X_train)
X_val = imputer.transform(X_val)
if grid_search:
print("Grid-searching hyperparameters (TimeSeriesSplit 5-fold) …")
base = HistGradientBoostingRegressor(random_state=42)
tscv = TimeSeriesSplit(n_splits=5)
gs = GridSearchCV(
base, PARAM_GRID, cv=tscv,
scoring="r2", n_jobs=-1, verbose=1,
refit=True,
)
gs.fit(X_train, y_train)
model = gs.best_estimator_
best_params = gs.best_params_
cv_r2 = float(gs.best_score_)
print(f" Best CV R²: {cv_r2:.4f}")
print(f" Best params: {best_params}")
else:
print("Training with default parameters …")
model = HistGradientBoostingRegressor(**DEFAULT_PARAMS)
model.fit(X_train, y_train)
best_params = DEFAULT_PARAMS
cv_r2 = float("nan")
print("Evaluating on validation set …")
y_pred = model.predict(X_val)
val_r2 = r2_score(y_val, y_pred)
val_mae = mean_absolute_error(y_val, y_pred)
val_rmse = float(np.sqrt(mean_squared_error(y_val, y_pred)))
print(f" Val R²={val_r2:.4f} MAE={val_mae:.2f} RMSE={val_rmse:.2f}")
model_path = MODELS_DIR / "surf_condition_model.joblib"
imputer_path = MODELS_DIR / "imputer.joblib"
meta_path = MODELS_DIR / "model_metadata.json"
joblib.dump(model, model_path)
joblib.dump(imputer, imputer_path)
metadata = {
"training_timestamp": datetime.now(timezone.utc).isoformat(),
"dataset_hash": _dataset_hash(train_df),
"feature_list": FEATURE_NAMES,
"best_params": best_params,
"cv_r2_mean": cv_r2,
"val_r2": val_r2,
"val_mae": val_mae,
"val_rmse": val_rmse,
"train_rows": len(train_df),
"val_rows": len(val_df),
}
with open(meta_path, "w") as f:
json.dump(metadata, f, indent=2)
print(f"\nSaved:")
print(f" {model_path} ({model_path.stat().st_size // 1024} KB)")
print(f" {imputer_path}")
print(f" {meta_path}")
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--no-search", action="store_true",
help="Skip grid search and use default hyperparameters")
args = parser.parse_args()
train(grid_search=not args.no_search)