gridguard-ai / 02_train_model.py
Samdutse's picture
Upload folder using huggingface_hub
2d71770 verified
Raw
History Blame Contribute Delete
8.19 kB
"""
GridGuard AI — Hierarchical Energy-Theft Detection Model
=========================================================
Implements the 3-level detection logic described in the project pitch:
Level 1 Transformer-level anomaly -> which feeders have abnormal loss%
Level 2 Neighbourhood peer anomaly -> which households deviate from peers
of similar income class / house size within their neighbourhood
Level 3 Household behavioural anomaly -> consumption drop + Isolation
Forest unsupervised score, combined into a final Fraud Risk Score
Output: data/household_risk_scores.csv (used by the Gradio app)
models/isolation_forest.joblib, models/scaler.joblib
models/xgb_classifier.joblib (supervised — trained on ground truth,
included to SHOW evaluation metrics in your presentation; in a real
deployment you would not have these labels and would lean on the
unsupervised + rules layers)
"""
import os
import numpy as np
import pandas as pd
import joblib
from sklearn.ensemble import IsolationForest
from sklearn.preprocessing import StandardScaler
from sklearn.model_selection import train_test_split
from sklearn.metrics import precision_score, recall_score, f1_score, roc_auc_score
import xgboost as xgb
BASE = os.path.dirname(__file__)
DATA_DIR = os.path.join(BASE, "data")
MODEL_DIR = os.path.join(BASE, "models")
os.makedirs(MODEL_DIR, exist_ok=True)
households = pd.read_csv(os.path.join(DATA_DIR, "households.csv"))
readings = pd.read_csv(os.path.join(DATA_DIR, "readings_monthly.csv"))
tx_monthly = pd.read_csv(os.path.join(DATA_DIR, "transformers.csv"))
N_MONTHS = readings["month_idx"].max() + 1
LAST3 = sorted(readings["month_idx"].unique())[-3:]
PRIOR3 = sorted(readings["month_idx"].unique())[:3]
# ---------------------------------------------------------------------------
# Household-level behavioural features
# ---------------------------------------------------------------------------
last3 = readings[readings.month_idx.isin(LAST3)].groupby("household_id")["metered_kwh"].mean().rename("avg_last3_kwh")
prior3 = readings[readings.month_idx.isin(PRIOR3)].groupby("household_id")["metered_kwh"].mean().rename("avg_prior3_kwh")
# linear trend slope of metered consumption over time (theft -> negative slope)
def slope(group):
x = group["month_idx"].values
y = group["metered_kwh"].values
if len(x) < 2:
return 0.0
return np.polyfit(x, y, 1)[0]
trend = readings.groupby("household_id").apply(slope).rename("consumption_trend_slope")
feat = households.merge(last3, on="household_id").merge(prior3, on="household_id").merge(trend, on="household_id")
feat["drop_ratio"] = (feat["avg_prior3_kwh"] - feat["avg_last3_kwh"]) / feat["avg_prior3_kwh"].replace(0, np.nan)
feat["drop_ratio"] = feat["drop_ratio"].fillna(0)
# ---------------------------------------------------------------------------
# Level 2 — Neighbourhood peer comparison (z-score vs similar peers)
# Peer group = same neighbourhood_id + same income_class
# ---------------------------------------------------------------------------
peer_stats = feat.groupby(["neighbourhood_id", "income_class"])["avg_last3_kwh"].agg(["mean", "std"]).rename(
columns={"mean": "peer_mean_kwh", "std": "peer_std_kwh"}
)
feat = feat.merge(peer_stats, on=["neighbourhood_id", "income_class"], how="left")
feat["peer_std_kwh"] = feat["peer_std_kwh"].replace(0, np.nan)
feat["peer_zscore"] = (feat["avg_last3_kwh"] - feat["peer_mean_kwh"]) / feat["peer_std_kwh"]
feat["peer_zscore"] = feat["peer_zscore"].fillna(0)
# ---------------------------------------------------------------------------
# Level 1 — Transformer-level loss flag (propagated down to its households)
# ---------------------------------------------------------------------------
latest_tx = tx_monthly[tx_monthly.month_idx == tx_monthly.month_idx.max()][["transformer_id", "loss_pct"]]
latest_tx = latest_tx.rename(columns={"loss_pct": "transformer_loss_pct"})
feat = feat.merge(latest_tx, on="transformer_id", how="left")
feat["transformer_flagged"] = feat["transformer_loss_pct"] > 0.08 # >8% loss = above normal technical loss
# ---------------------------------------------------------------------------
# Level 3 — Unsupervised anomaly score (Isolation Forest)
# ---------------------------------------------------------------------------
unsup_cols = ["drop_ratio", "peer_zscore", "consumption_trend_slope", "transformer_loss_pct"]
X = feat[unsup_cols].fillna(0).values
scaler = StandardScaler()
X_scaled = scaler.fit_transform(X)
iso = IsolationForest(n_estimators=300, contamination=0.08, random_state=42)
iso.fit(X_scaled)
# decision_function: higher = more normal. Flip & rescale to 0-1 "anomaly score"
raw_score = -iso.decision_function(X_scaled)
feat["anomaly_score"] = (raw_score - raw_score.min()) / (raw_score.max() - raw_score.min())
# ---------------------------------------------------------------------------
# Combine into a single Fraud Risk Score (0-100), used for triage
# Weighted blend: behavioural anomaly + peer deviation + transformer context
# ---------------------------------------------------------------------------
norm_drop = np.clip(feat["drop_ratio"], 0, 1)
norm_peer = np.clip(-feat["peer_zscore"] / 3, 0, 1) # big NEGATIVE z-score (below peers) = risky
tx_boost = feat["transformer_flagged"].astype(float) * 0.15
feat["fraud_risk_score"] = np.clip(
100 * (0.45 * feat["anomaly_score"] + 0.30 * norm_drop + 0.25 * norm_peer + tx_boost), 0, 100
)
feat["risk_tier"] = pd.cut(
feat["fraud_risk_score"], bins=[-1, 40, 70, 100], labels=["Low", "Medium", "High"]
)
# ---------------------------------------------------------------------------
# Supervised model — trained on ground truth, FOR EVALUATION / DEMO ONLY
# (shows precision/recall you can quote in the presentation)
# ---------------------------------------------------------------------------
y = feat["is_fraud_ground_truth"].astype(int)
X_train, X_test, y_train, y_test = train_test_split(X_scaled, y, test_size=0.3, stratify=y, random_state=42)
xgb_clf = xgb.XGBClassifier(
n_estimators=150, max_depth=3, learning_rate=0.08,
scale_pos_weight=(y_train == 0).sum() / max((y_train == 1).sum(), 1),
eval_metric="logloss", random_state=42,
)
xgb_clf.fit(X_train, y_train)
y_pred = xgb_clf.predict(X_test)
y_proba = xgb_clf.predict_proba(X_test)[:, 1]
print("=== Supervised model evaluation (ground-truth labels, demo only) ===")
print(f"Precision : {precision_score(y_test, y_pred):.3f}")
print(f"Recall : {recall_score(y_test, y_pred):.3f}")
print(f"F1 : {f1_score(y_test, y_pred):.3f}")
print(f"ROC-AUC : {roc_auc_score(y_test, y_proba):.3f}")
# Unsupervised-only evaluation (what you'd realistically have in production, no labels)
high_risk = feat["risk_tier"] == "High"
print("\n=== Unsupervised risk-tier vs ground truth (sanity check) ===")
print(f"Households flagged High risk : {high_risk.sum()} ({high_risk.mean()*100:.1f}% of base)")
print(f"Of those, actually fraud : {feat.loc[high_risk, 'is_fraud_ground_truth'].mean()*100:.1f}%")
print(f"Fraud households caught in High/Medium tier: "
f"{feat.loc[feat.is_fraud_ground_truth, 'risk_tier'].isin(['High','Medium']).mean()*100:.1f}%")
# ---------------------------------------------------------------------------
# Save artifacts
# ---------------------------------------------------------------------------
feat_out = feat.drop(columns=["fraud_start_month_idx", "bypass_severity", "is_genuine_low_consumer", "is_lifestyle_change"]) # keep ground truth flag only, hide leak cols
feat_out.to_csv(os.path.join(DATA_DIR, "household_risk_scores.csv"), index=False)
tx_monthly.to_csv(os.path.join(DATA_DIR, "transformers.csv"), index=False)
joblib.dump(iso, os.path.join(MODEL_DIR, "isolation_forest.joblib"))
joblib.dump(scaler, os.path.join(MODEL_DIR, "scaler.joblib"))
joblib.dump(xgb_clf, os.path.join(MODEL_DIR, "xgb_classifier.joblib"))
joblib.dump(unsup_cols, os.path.join(MODEL_DIR, "feature_cols.joblib"))
print(f"\nSaved: {os.path.join(DATA_DIR, 'household_risk_scores.csv')}")
print(f"Saved models to: {MODEL_DIR}")