""" Clean raw DataFrame using detected schema. Returns typed inputs per task. Classification inputs come in two shapes: X_serve — report text + context tokens available at report time (category / airline / branch / area / form type). This is EXACTLY what the API can reconstruct at prediction time. X_aug — X_serve enriched with post-investigation text (Root Caused, Action Taken, ...). Used as training augmentation only; never evaluated on, never served. Target-leakage guard: any column the label was derived from is excluded from BOTH shapes for that role. """ from typing import Optional import numpy as np import pandas as pd from canonicalize import consolidate_labels from config import today from models.classifier import _clean_text, build_serve_text # Post-investigation text columns usable as training augmentation, per role. # root_cause's label is keyword-derived from 'Root Caused' → leakage → excluded. ROLE_AUG_COLUMNS: dict[str, list[str]] = { "category": ["Root Caused", "Action Taken"], "root_cause": ["Action Taken"], "subcategory": ["Root Caused", "Action Taken", "Identification of Root"], } # Higher floor for subcategory: many area-category classes have only 3-5 # examples which adds noise without adding signal. ROLE_MIN_SAMPLES: dict[str, int] = { "subcategory": 8, } KNOWN_CONTEXT_COLS = { "category": ["Report Category", "Category"], "airline": ["Airlines", "Airline"], "branch": ["Station", "Branch"], "area": ["Area"], } def _coerce_dates(series: pd.Series) -> pd.Series: """Convert Excel serial numbers or date strings (including 'January 25, 2025') to datetime.""" epoch = pd.Timestamp("1899-12-30") def _parse(v): if v is None or (isinstance(v, float) and np.isnan(v)): return pd.NaT if isinstance(v, pd.Timestamp): return v s = str(v).strip() if not s or s.lower() in ("none", "nan", ""): return pd.NaT if s.replace(".", "").isdigit(): try: return epoch + pd.Timedelta(days=float(s)) except Exception: return pd.NaT for fmt in ("%B %d, %Y", "%d %B %Y", "%Y-%m-%d", "%d/%m/%Y", "%m/%d/%Y", "%d-%m-%Y"): try: return pd.to_datetime(s, format=fmt) except Exception: continue return pd.to_datetime(s, errors="coerce", dayfirst=False) return series.apply(_parse) def prepare_time_series(df: pd.DataFrame, schema: dict) -> Optional[pd.Series]: """ Returns daily incident count Series indexed by date. Returns None if date column missing or fewer than 14 data points. """ date_col = schema.get("date") if not date_col or date_col not in df.columns: return None dates = _coerce_dates(df[date_col]) valid = dates.dropna() # Never let a future-dated row pad the series with phantom zero-days: # everything downstream (forecast horizon, STL, trend windows) anchors # to the series' max index. valid = valid[valid <= today()] if len(valid) < 14: print(f"[preprocess] Only {len(valid)} valid dates — skipping forecaster") return None ts = valid.dt.normalize().value_counts().sort_index() full_idx = pd.date_range(ts.index.min(), ts.index.max(), freq="D") ts = ts.reindex(full_idx, fill_value=0) return ts def _resolve_col(df: pd.DataFrame, candidates: list[str]) -> Optional[str]: for c in candidates: if c in df.columns: return c return None def _get_context_col(df: pd.DataFrame, schema: dict, role: str) -> Optional[str]: col = schema.get(role) if col and col in df.columns: return col return _resolve_col(df, KNOWN_CONTEXT_COLS.get(role, [])) def _cell(row: pd.Series, col: Optional[str]): if not col: return None v = row.get(col) if v is None or str(v).strip().lower() in ("", "none", "nan"): return None return v def prepare_classification( df: pd.DataFrame, schema: dict, label_role: str, min_samples_per_class: int = 3, ) -> Optional[tuple]: """ Returns (X_serve, y, X_aug, X_raw): X_serve — serve-shape training text (see module docstring) X_aug — augmentation-shape text (aligned with X_serve row-for-row) X_raw — raw report text for sentence embeddings (aligned) Rows are deduplicated on (cleaned report text, label) so template texts can't leak across CV folds. Returns None if insufficient data. """ label_col = schema.get(label_role) if not label_col or label_col not in df.columns: print(f"[preprocess] No '{label_role}' column in schema — skipping classifier") return None desc_col = schema.get("description") or _resolve_col(df, ["Report"]) if not desc_col or desc_col not in df.columns: print(f"[preprocess] No description column — skipping {label_role} classifier") return None # Context columns (serve-time legit). The label's own column is excluded. cat_col = _get_context_col(df, schema, "category") air_col = _get_context_col(df, schema, "airline") br_col = _get_context_col(df, schema, "branch") area_col = _get_context_col(df, schema, "area") sheet_col = "_sheet" if "_sheet" in df.columns else None leak_cols = {label_col} _nl = lambda c: None if c in leak_cols else c cat_col, air_col, br_col, area_col = _nl(cat_col), _nl(air_col), _nl(br_col), _nl(area_col) aug_cols = [c for c in ROLE_AUG_COLUMNS.get(label_role, []) if c in df.columns and c not in leak_cols] sub = df.dropna(subset=[label_col]) sub = sub[sub[label_col].astype(str).str.strip().ne("")] sub = sub[sub[desc_col].astype(str).str.strip().str.lower() .apply(lambda s: s not in ("", "none", "nan"))] if sub.empty: print(f"[preprocess] No labeled rows with text for {label_role}") return None def _serve_text(row): return build_serve_text( str(row.get(desc_col, "")), category=_cell(row, cat_col), airline=_cell(row, air_col), branch=_cell(row, br_col), area=_cell(row, area_col), sheet=_cell(row, sheet_col), ) def _aug_text(row): extra = [str(row[c]) for c in aug_cols if _cell(row, c) is not None] merged = " | ".join([str(row.get(desc_col, ""))] + extra) return build_serve_text( merged, category=_cell(row, cat_col), airline=_cell(row, air_col), branch=_cell(row, br_col), area=_cell(row, area_col), sheet=_cell(row, sheet_col), ) effective_min = ROLE_MIN_SAMPLES.get(label_role, min_samples_per_class) # Self-healing labels: collapse case/typo/suffix variants; rescue tail # classes into their token-superset parents instead of discarding them. y_raw = sub[label_col].astype(str).str.strip() y_canon = consolidate_labels(y_raw, min_samples=effective_min) tbl = pd.DataFrame({ "clean_report": sub[desc_col].astype(str).apply(_clean_text), "X_serve": sub.apply(_serve_text, axis=1), "X_aug": sub.apply(_aug_text, axis=1), "X_raw": sub[desc_col].astype(str).str.strip(), # for sentence embeddings "y": y_canon, }) tbl = tbl[tbl["clean_report"].str.len() > 0] # Dedup on the serve-visible report text + label (one vote per template) n_before = len(tbl) tbl = tbl.drop_duplicates(subset=["clean_report", "y"]).reset_index(drop=True) counts = tbl["y"].value_counts() tbl = tbl[tbl["y"].isin(counts[counts >= effective_min].index)].reset_index(drop=True) if len(tbl) < 10 or tbl["y"].nunique() < 2: print( f"[preprocess] Insufficient data for {label_role}: " f"{len(tbl)} rows, {tbl['y'].nunique()} classes" ) return None print( f"[preprocess] {label_role}: {len(tbl)} unique rows " f"({n_before - len(tbl)} template dupes collapsed), " f"{tbl['y'].nunique()} classes, aug cols: {aug_cols}" ) return tbl["X_serve"], tbl["y"], tbl["X_aug"], tbl["X_raw"] def prepare_risk_inputs(df: pd.DataFrame, schema: dict) -> dict: """Returns risk grouping inputs. Called by risk_scorer.score().""" return { role: schema[role] for role in ["airline", "branch", "area"] if schema.get(role) and schema[role] in df.columns }