microclimate-x-demo / scripts /3_train_model.py
W1nd5pac's picture
Deploy 2026-05-20T07:09:24Z — 11e81c5 (code)
a8358d8 verified
"""
Step 3 / Random Forest Training
================================
Trains a Random Forest classifier on the processed dataset using:
- Time-based CV (NOT random split — would leak temporal autocorrelation)
- class_weight='balanced' (rain is the minority class)
- Hold-out test = last 20 % of the time-ordered dataset
Outputs:
models/rf_model.pkl — fitted estimator
models/feature_columns.json — exact feature order used at train time
models/training_report.json — metrics + feature importance + meta
Run: python scripts/3_train_model.py
"""
from __future__ import annotations
import json
from pathlib import Path
import joblib
import numpy as np
import pandas as pd
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import (
classification_report,
confusion_matrix,
f1_score,
fbeta_score,
precision_recall_fscore_support,
roc_auc_score,
)
from sklearn.model_selection import TimeSeriesSplit
ROOT = Path(__file__).resolve().parent.parent
DATA_DIR = ROOT / "data"
MODEL_DIR = ROOT / "models"
MODEL_DIR.mkdir(exist_ok=True)
# Features fed to the model (X). Order matters — saved alongside the model.
FEATURE_COLUMNS: list[str] = [
"elevation_m",
"temperature_c",
"humidity_pct",
"wind_speed_kmh",
"wind_direction_deg", # kept for interpretability comparison
"wind_u", "wind_v", # mathematically correct circular decomposition
"pressure_hpa",
"pressure_change_3h",
"dew_point_c",
"dew_point_depression",
"cloud_cover_pct",
"cape_jkg",
"precipitation_lag_1h",
"hour_sin", "hour_cos",
"month_sin", "month_cos",
]
TARGET = "is_rain_event"
def load_dataset() -> pd.DataFrame:
p = DATA_DIR / "processed.csv"
if not p.exists():
raise SystemExit("ERROR: data/processed.csv not found. "
"Run scripts/2_preprocess.py first.")
df = pd.read_csv(p, parse_dates=["time"])
df = df.sort_values(["site", "time"]).reset_index(drop=True)
return df
def time_based_split(df: pd.DataFrame, test_frac: float = 0.2) -> tuple[pd.DataFrame, pd.DataFrame]:
"""Last `test_frac` of the time-ordered data per site is held out."""
train_parts, test_parts = [], []
for _, g in df.groupby("site", sort=False):
cut = int(len(g) * (1.0 - test_frac))
train_parts.append(g.iloc[:cut])
test_parts.append(g.iloc[cut:])
return pd.concat(train_parts, ignore_index=True), pd.concat(test_parts, ignore_index=True)
def crossval_score(X: np.ndarray, y: np.ndarray, n_splits: int = 5) -> list[dict]:
"""TimeSeriesSplit gives a fair temporal-CV estimate."""
tscv = TimeSeriesSplit(n_splits=n_splits)
fold_metrics: list[dict] = []
for fold, (tr, va) in enumerate(tscv.split(X), start=1):
model = RandomForestClassifier(
n_estimators=200,
max_depth=15,
min_samples_leaf=20,
class_weight="balanced",
n_jobs=-1,
random_state=42,
)
model.fit(X[tr], y[tr])
proba = model.predict_proba(X[va])[:, 1]
pred = (proba >= 0.5).astype(int)
p, r, f1, _ = precision_recall_fscore_support(y[va], pred, average="binary", zero_division=0)
try:
auc = roc_auc_score(y[va], proba)
except ValueError:
auc = float("nan")
f2 = fbeta_score(y[va], pred, beta=2.0, zero_division=0)
print(f" fold {fold}: P={p:.3f} R={r:.3f} F1={f1:.3f} F2={f2:.3f} AUC={auc:.3f}")
fold_metrics.append({"fold": fold, "precision": p, "recall": r,
"f1": f1, "f2": f2, "auc": auc})
return fold_metrics
def main() -> int:
print("Loading processed dataset…")
df = load_dataset()
print(f" rows: {len(df):,} features: {len(FEATURE_COLUMNS)}")
print(f" class balance (Y=1): {df[TARGET].mean():.1%}")
print("\nTime-based train/test split (last 20% per site held out)…")
train_df, test_df = time_based_split(df, test_frac=0.20)
print(f" train: {len(train_df):,} test: {len(test_df):,}")
X_train = train_df[FEATURE_COLUMNS].to_numpy()
y_train = train_df[TARGET].to_numpy()
X_test = test_df[FEATURE_COLUMNS].to_numpy()
y_test = test_df[TARGET].to_numpy()
print("\nTime-series cross-validation on training fold (5 splits)…")
fold_metrics = crossval_score(X_train, y_train, n_splits=5)
print("\nFitting final model on full training set…")
model = RandomForestClassifier(
n_estimators=300,
max_depth=20,
min_samples_leaf=10,
class_weight="balanced",
n_jobs=-1,
random_state=42,
)
model.fit(X_train, y_train)
print("\nEvaluating on held-out test set…")
proba = model.predict_proba(X_test)[:, 1]
pred = (proba >= 0.5).astype(int)
print(classification_report(y_test, pred, target_names=["NoRain", "Rain"], digits=3))
cm = confusion_matrix(y_test, pred)
print("Confusion matrix:")
print(f" [[TN={cm[0,0]:>6} FP={cm[0,1]:>6}]")
print(f" [FN={cm[1,0]:>6} TP={cm[1,1]:>6}]]")
auc_test = roc_auc_score(y_test, proba)
f2_test = fbeta_score(y_test, pred, beta=2.0, zero_division=0)
print(f"AUC = {auc_test:.3f} F2 = {f2_test:.3f}")
print("\nFeature importances:")
fi = sorted(zip(FEATURE_COLUMNS, model.feature_importances_), key=lambda x: -x[1])
for name, imp in fi:
bar = "█" * int(imp * 200)
print(f" {name:<24} {imp:.4f} {bar}")
print("\nSaving artefacts…")
joblib.dump(model, MODEL_DIR / "rf_model.pkl")
with open(MODEL_DIR / "feature_columns.json", "w") as f:
json.dump(FEATURE_COLUMNS, f, indent=2)
with open(MODEL_DIR / "training_report.json", "w") as f:
json.dump({
"n_train": len(train_df),
"n_test": len(test_df),
"class_balance": float(df[TARGET].mean()),
"cv_fold_metrics": fold_metrics,
"test_metrics": {
"f1": float(f1_score(y_test, pred, zero_division=0)),
"f2": float(f2_test),
"auc": float(auc_test),
"confusion_matrix": cm.tolist(),
},
"feature_importance": {name: float(imp) for name, imp in fi},
}, f, indent=2)
print(f" → {MODEL_DIR/'rf_model.pkl'}")
print(f" → {MODEL_DIR/'feature_columns.json'}")
print(f" → {MODEL_DIR/'training_report.json'}")
print("\nNext step: uvicorn backend.main:app --reload --port 8000")
return 0
if __name__ == "__main__":
raise SystemExit(main())