Spaces:
Running
Running
| """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)) | |