Gridlock / src /feature_engineering.py
parvmittal07's picture
Initial Demo complete
01eb82e
Raw
History Blame Contribute Delete
12.1 kB
"""Tabular feature engineering (leakage-safe, creation-time information only).
All features here are computable at the moment an event is reported. Features
that require knowledge of how the event unfolded (resolution time, status,
assigned officers) are deliberately excluded - see config.LEAKAGE_COLUMNS.
History-dependent features (hotspot density, recurrence counts) are computed in
a strictly causal way: each row only sees events that were reported *before* it
in chronological order, so no future information leaks backward.
"""
from __future__ import annotations
import re
import numpy as np
import pandas as pd
from . import config as C
# --------------------------------------------------------------------------- #
# Helpers
# --------------------------------------------------------------------------- #
_PIN_RE = re.compile(r"(?:Pin-)?(\d{6})")
def _extract_pincode(address):
if not isinstance(address, str):
return np.nan
m = _PIN_RE.search(address.replace(" ", ""))
return m.group(1) if m else np.nan
# --------------------------------------------------------------------------- #
# Feature blocks
# --------------------------------------------------------------------------- #
def _temporal_features(df: pd.DataFrame) -> pd.DataFrame:
# Use IST (UTC+5:30); Bengaluru operational clock drives traffic patterns.
start_ist = df["start_datetime"].dt.tz_convert("Asia/Kolkata")
df["start_hour"] = start_ist.dt.hour
df["start_dow"] = start_ist.dt.dayofweek
df["start_month"] = start_ist.dt.month
df["start_weekofyear"] = start_ist.dt.isocalendar().week.astype(int)
df["start_day"] = start_ist.dt.day
df["is_weekend"] = (df["start_dow"] >= 5).astype(int)
df["is_morning_peak"] = df["start_hour"].between(8, 11).astype(int)
df["is_evening_peak"] = df["start_hour"].between(17, 21).astype(int)
df["is_night"] = ((df["start_hour"] >= 22) | (df["start_hour"] <= 5)).astype(int)
# Advance notice: planned events are logged hours/days before they start.
if "created_date" in df.columns:
lead = (df["start_datetime"] - df["created_date"]).dt.total_seconds() / 3600.0
# Negative lead (created after start) -> treat as no notice (0).
df["lead_time_hours"] = lead.clip(lower=0).fillna(0.0)
df["has_advance_notice"] = (lead > 0.5).fillna(False).astype(int)
else:
df["lead_time_hours"] = 0.0
df["has_advance_notice"] = 0
# Cyclical encodings.
df["hour_sin"] = np.sin(2 * np.pi * df["start_hour"] / 24)
df["hour_cos"] = np.cos(2 * np.pi * df["start_hour"] / 24)
df["dow_sin"] = np.sin(2 * np.pi * df["start_dow"] / 7)
df["dow_cos"] = np.cos(2 * np.pi * df["start_dow"] / 7)
df["month_sin"] = np.sin(2 * np.pi * df["start_month"] / 12)
df["month_cos"] = np.cos(2 * np.pi * df["start_month"] / 12)
return df
def _spatial_features(df: pd.DataFrame, training: bool = True) -> pd.DataFrame:
# NOTE: end-point coordinates and route_path describe the closure/diversion
# SEGMENT that is drawn as a consequence of the closure decision. They leak
# the target (see config.LEAKAGE_COLUMNS) and are deliberately NOT used.
# Only the report-time event location is kept.
df["pincode"] = df["address"].apply(_extract_pincode)
# Target-free spatial binning so trees can generalise across nearby points.
# KMeans on coordinates is unsupervised (no label) -> not leakage. The fitted
# model is persisted so inference assigns the SAME bins (cluster ids would
# otherwise permute on every refit and silently break the categorical).
coords = df[["latitude", "longitude"]].to_numpy()
try:
import joblib
from sklearn.cluster import MiniBatchKMeans
if training:
k = min(C.GEO_CLUSTERS, max(2, len(df) // 50))
km = MiniBatchKMeans(n_clusters=k, random_state=C.RANDOM_STATE, n_init=3)
km.fit(coords)
joblib.dump(km, C.GEO_KMEANS_PATH)
else:
km = joblib.load(C.GEO_KMEANS_PATH)
df["geo_cluster"] = km.predict(coords).astype(int).astype(str)
except Exception:
df["geo_cluster"] = "0"
return df
def _causal_history_features(df: pd.DataFrame) -> pd.DataFrame:
"""Strictly causal recurrence / hotspot features (past events only).
Rows are already sorted by ``order_time`` (report time) in cleaning, so a
simple expanding ``cumcount`` over location/cause groups counts only events
that were reported earlier.
"""
# Coarse geocell (~1.1 km) for hotspot aggregation.
cell = (
df["latitude"].round(2).astype(str) + "_" + df["longitude"].round(2).astype(str)
)
df["_geocell"] = cell
# How many events previously reported in this geocell.
df["hist_hotspot_count"] = df.groupby("_geocell").cumcount()
# Normalised local density (per elapsed day in dataset).
elapsed_days = (
(df["order_time"] - df["order_time"].min()).dt.total_seconds() / 86400.0
).clip(lower=1.0)
df["loc_event_density"] = df["hist_hotspot_count"] / elapsed_days
# Same location + same cause history (recurring construction/festival sites).
df["same_loc_cause_hist"] = df.groupby(["_geocell", "event_cause"]).cumcount()
# Same calendar day + location report burst (event-magnitude proxy).
day = df["start_datetime"].dt.tz_convert("Asia/Kolkata").dt.date.astype(str)
df["same_day_loc_reports"] = df.groupby([day, "_geocell"]).cumcount()
df.drop(columns=["_geocell"], inplace=True)
return df
def _categorical_features(df: pd.DataFrame) -> pd.DataFrame:
cause_norm = df["event_cause"].astype(str).str.strip().str.lower()
df["event_family"] = cause_norm.map(C.EVENT_FAMILY_MAP).fillna(C.EVENT_FAMILY_DEFAULT)
df["veh_type_missing"] = df["veh_type"].isna().astype(int)
# Normalise authenticated to string category for the encoder.
if "authenticated" in df.columns:
df["authenticated"] = df["authenticated"].map({True: "yes", False: "no"}).fillna("unknown")
return df
def _lexicon_features(df: pd.DataFrame) -> pd.DataFrame:
text = df[C.TEXT_COLUMN].fillna("").astype(str).str.lower()
df["desc_has_text"] = (text.str.len() > 0).astype(int)
df["desc_missing"] = (text.str.len() == 0).astype(int)
df["desc_len"] = text.str.len()
df["desc_word_count"] = text.str.split().apply(len)
# Kannada Unicode block presence.
df["desc_is_kannada"] = text.str.contains(r"[\u0c80-\u0cff]", regex=True).astype(int)
for feat, terms in C.LEXICON.items():
pattern = "|".join(re.escape(t) for t in terms)
df[feat] = text.str.contains(pattern, regex=True).astype(int)
return df
def _causal_target_features(df: pd.DataFrame, history: pd.DataFrame | None = None) -> pd.DataFrame:
"""Past-only, leakage-safe target encodings.
For each grouping key we build a smoothed *running* mean of the closure label
over events reported **earlier in time** (``shift(1)`` excludes the row's own
label). Low-count groups are shrunk toward the running global rate
(empirical-Bayes smoothing). This gives the rare closure target far more
signal than the static category alone and, because it is recomputed from
accumulated history, it tracks drift.
At inference ``history`` (the accumulated training frame) is prepended so a
new event sees the same historical rates the model trained on; its own
(unknown) label is never used.
"""
keys = [k for k in C.CLOSURE_RATE_KEYS if k in df.columns]
rate_cols = list(C.CLOSURE_RATE_KEYS.values()) + ["dur_recent_level"]
if C.TARGET_CLOSURE not in df.columns or not keys:
for col in rate_cols:
df[col] = np.nan
return df
needed = keys + [C.TARGET_CLOSURE, C.TARGET_DURATION, "duration_valid", "order_time"]
cur = df.reindex(columns=[c for c in needed if c in df.columns]).copy()
cur["_seg"] = 1
cur["_pos"] = np.arange(len(df))
if history is not None and len(history):
hist = history.reindex(columns=cur.columns.drop(["_seg", "_pos"])).copy()
hist["_seg"] = 0
hist["_pos"] = -1
base = pd.concat([hist, cur], ignore_index=True)
else:
base = cur.reset_index(drop=True)
base = base.sort_values("order_time", kind="stable").reset_index(drop=True)
# Closure label as float; unknown (inference) labels -> 0 so they never leak
# into a later row's running sum (shift(1) already excludes the row itself).
yc = pd.to_numeric(base[C.TARGET_CLOSURE], errors="coerce").fillna(0.0)
global_prior = yc.expanding().mean().shift(1).fillna(yc.mean())
m = C.CAUSAL_TARGET_SMOOTHING
rates = {}
for key in keys:
grp = yc.groupby(base[key].astype("string"), dropna=False)
csum = grp.cumsum().shift(1).fillna(0.0)
ccnt = grp.cumcount().shift(1).fillna(0.0)
rates[C.CLOSURE_RATE_KEYS[key]] = (csum + m * global_prior) / (ccnt + m)
# Ambient duration level: rolling mean of the last-N resolved log-durations.
logd = np.log1p(pd.to_numeric(base[C.TARGET_DURATION], errors="coerce"))
valid = (base["duration_valid"].fillna(False).astype(bool)
if "duration_valid" in base else logd.notna())
sv = logd.where(valid).dropna()
roll = sv.rolling(C.DUR_RECENT_WINDOW, min_periods=5).mean().shift(1)
recent = pd.Series(np.nan, index=base.index)
recent.loc[sv.index] = roll
recent = recent.ffill().fillna(sv.mean() if len(sv) else 0.0)
mask = base["_seg"].eq(1).to_numpy()
order = np.argsort(base.loc[mask, "_pos"].to_numpy())
for col, series in rates.items():
df[col] = series.to_numpy()[mask][order]
df["dur_recent_level"] = recent.to_numpy()[mask][order]
return df
def _numeric_cleanup(df: pd.DataFrame) -> pd.DataFrame:
df["age_of_truck"] = pd.to_numeric(df["age_of_truck"], errors="coerce")
return df
def build_features(df: pd.DataFrame, save: bool = True, training: bool = True,
history: pd.DataFrame | None = None) -> pd.DataFrame:
"""Run all feature blocks. Input must already have targets + order_time.
``training`` controls whether the spatial KMeans is fit (and persisted) or
loaded. ``history`` is the accumulated training frame, prepended for the
causal target encodings so inference matches training.
"""
df = df.copy()
df = _temporal_features(df)
df = _spatial_features(df, training=training)
df = _causal_history_features(df)
df = _categorical_features(df)
df = _lexicon_features(df)
df = _causal_target_features(df, history=history)
df = _numeric_cleanup(df)
if save:
df.to_parquet(C.FEATURES_PARQUET, index=False)
return df
def history_columns() -> list[str]:
"""Columns the causal target encodings need from accumulated history."""
keys = list(C.CLOSURE_RATE_KEYS)
extra = [C.TARGET_CLOSURE, C.TARGET_DURATION, "duration_valid", "order_time"]
return keys + extra
def save_history(df: pd.DataFrame) -> None:
"""Persist the minimal labeled history needed to reproduce, at inference
time, the same past-only target-rate encodings seen during training.
"""
cols = [c for c in history_columns() if c in df.columns]
df[cols].to_parquet(C.HISTORY_PARQUET, index=False)
def load_history() -> pd.DataFrame | None:
"""Load accumulated history for inference; ``None`` if not yet persisted."""
if C.HISTORY_PARQUET.exists():
return pd.read_parquet(C.HISTORY_PARQUET)
return None
if __name__ == "__main__": # pragma: no cover
from .targets import build_targets
d = pd.read_parquet(C.CLEAN_PARQUET)
if C.TARGET_CLOSURE not in d.columns:
d = build_targets(d, save=False)
d = build_features(d)
feats = [c for c in C.NUMERIC_FEATURES if c in d.columns]
print("rows:", len(d), "| numeric features present:", len(feats))
print(d[["event_family", "geo_cluster", "hist_hotspot_count",
"lead_time_hours", "lex_festival", "desc_is_kannada"]].describe(include="all").round(2))