| """ |
| 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) |
|
|
| |
| FEATURE_COLUMNS: list[str] = [ |
| "elevation_m", |
| "temperature_c", |
| "humidity_pct", |
| "wind_speed_kmh", |
| "wind_direction_deg", |
| "wind_u", "wind_v", |
| "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()) |
|
|