File size: 6,932 Bytes
af879c2
 
 
67eae2d
 
 
 
 
 
 
 
 
 
 
af879c2
 
 
 
 
 
 
 
 
 
 
 
 
 
67eae2d
af879c2
 
 
 
 
 
67eae2d
 
 
 
 
 
af879c2
 
 
 
 
67eae2d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
af879c2
 
 
 
 
 
 
 
 
 
 
67eae2d
af879c2
 
 
 
 
67eae2d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
af879c2
67eae2d
af879c2
67eae2d
af879c2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67eae2d
af879c2
 
 
 
 
 
 
67eae2d
 
af879c2
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
"""FeatureBuilder: the single fitted object that turns raw account records into
the model's input matrix. Used identically by training and serving for parity.

Pipeline (v2 — native-NaN):
  clean -> keep numerics WITH NaN (LightGBM splits on missingness natively)
        -> one-hot encode the semantic categoricals
        -> add per-feature missingness indicators + a row missing-count
        -> append an Isolation Forest anomaly score (computed on a median-imputed
           copy, since IsolationForest cannot consume NaN)
        -> select a stable top-K subset (importance voting + domain priors).

Why native-NaN: ~28% of values are missing and mules are characterised largely
by a distinctive *missingness* pattern; median-imputation erased that signal.
The classifier sees raw NaNs; only the anomaly model gets an imputed copy.
"""
from __future__ import annotations

import numpy as np
import pandas as pd
from lightgbm import LGBMClassifier
from sklearn.ensemble import IsolationForest
from sklearn.model_selection import RepeatedStratifiedKFold
from sklearn.preprocessing import OneHotEncoder

from src import config
from src.features.clean import clean_frame, split_column_types

ANOMALY_COL = "anomaly_score"
MISSCOUNT_COL = "missing_count"


class FeatureBuilder:
    def __init__(self, n_select: int = config.N_SELECT, seed: int = config.SEED):
        self.n_select = n_select
        self.seed = seed
        self.num_cols_: list[str] = []
        self.cat_cols_: list[str] = []
        self.ohe_: OneHotEncoder | None = None
        self.ohe_cols_: list[str] = []
        self.num_medians_: np.ndarray | None = None   # only to feed the anomaly model
        self.iso_: IsolationForest | None = None
        self.feature_names_full_: list[str] = []
        self.selected_features_: list[str] = []
        self.selection_freq_: dict[str, float] = {}

    # ---- internal helpers --------------------------------------------------
    def _numeric_frame(self, cleaned: pd.DataFrame) -> pd.DataFrame:
        """Numeric columns as float WITH NaN preserved (reindexed for serve safety)."""
        return (cleaned.reindex(columns=self.num_cols_)
                .apply(pd.to_numeric, errors="coerce").astype("float64"))

    def _ohe_frame(self, cleaned: pd.DataFrame) -> pd.DataFrame:
        if not self.cat_cols_:
            return pd.DataFrame(index=cleaned.index)
        vals = cleaned.reindex(columns=self.cat_cols_).astype("object")
        vals = vals.where(pd.notna(vals), "NA")
        mat = self.ohe_.transform(vals)
        return pd.DataFrame(mat, columns=self.ohe_cols_, index=cleaned.index)

    def _impute_for_iso(self, num: pd.DataFrame) -> np.ndarray:
        arr = num.values.copy()
        pos = np.where(np.isnan(arr))
        arr[pos] = np.take(self.num_medians_, pos[1])
        return arr

    def _assemble(self, cleaned: pd.DataFrame) -> pd.DataFrame:
        num = self._numeric_frame(cleaned)
        ind = num.isna().astype("float64")
        ind.columns = [f"{c}__isna" for c in num.columns]
        out = pd.concat([num, self._ohe_frame(cleaned), ind], axis=1)
        out[MISSCOUNT_COL] = num.isna().sum(axis=1).astype("float64").values
        out[ANOMALY_COL] = -self.iso_.decision_function(self._impute_for_iso(num))
        return out

    def _known_important_columns(self, all_names: list[str]) -> list[str]:
        """Map bank-flagged features to their (possibly one-hot-expanded) columns."""
        keep = set()
        priors = set(config.KNOWN_IMPORTANT) | {"F3888_age_days", "F3889_recency_ord"}
        for name in all_names:
            base = name.split("_")[0]  # e.g. 'F3891_salaried' -> 'F3891'
            if name in priors or base in priors:
                keep.add(name)
        keep.add(ANOMALY_COL)
        keep.add(MISSCOUNT_COL)
        return sorted(keep)

    # ---- public API --------------------------------------------------------
    def fit(self, X: pd.DataFrame, y: pd.Series) -> "FeatureBuilder":
        cleaned = clean_frame(X)
        self.num_cols_, self.cat_cols_ = split_column_types(cleaned)
        num = cleaned[self.num_cols_].apply(pd.to_numeric, errors="coerce").astype("float64")

        # Per-column median — used ONLY to feed the anomaly model (cannot take NaN).
        self.num_medians_ = np.nan_to_num(np.nanmedian(num.values, axis=0), nan=0.0)

        # One-hot encoder for the semantic categoricals.
        if self.cat_cols_:
            self.ohe_ = OneHotEncoder(handle_unknown="ignore", min_frequency=20, sparse_output=False)
            vals = cleaned[self.cat_cols_].astype("object")
            vals = vals.where(pd.notna(vals), "NA")
            self.ohe_.fit(vals)
            self.ohe_cols_ = list(self.ohe_.get_feature_names_out(self.cat_cols_))
        else:
            self.ohe_cols_ = []

        # Unsupervised anomaly model on median-imputed training rows only.
        self.iso_ = IsolationForest(
            n_estimators=200, contamination="auto", random_state=self.seed, n_jobs=-1
        ).fit(self._impute_for_iso(num))

        full = self._assemble(cleaned)
        self.feature_names_full_ = list(full.columns)

        # ---- feature selection via importance voting across CV folds -------
        cv = RepeatedStratifiedKFold(n_splits=5, n_repeats=2, random_state=self.seed)
        pos = int(y.sum())
        neg = int((y == 0).sum())
        votes = pd.Series(0.0, index=full.columns)
        for tr_idx, _ in cv.split(full, y):
            clf = LGBMClassifier(
                n_estimators=300, num_leaves=31, learning_rate=0.05,
                subsample=0.8, colsample_bytree=0.5, reg_lambda=1.0,
                scale_pos_weight=neg / max(pos, 1), random_state=self.seed,
                n_jobs=-1, verbose=-1,
            )
            clf.fit(full.iloc[tr_idx], y.iloc[tr_idx])
            imp = pd.Series(clf.feature_importances_, index=full.columns)
            top = imp.sort_values(ascending=False).head(self.n_select).index
            votes[top] += 1.0
        self.selection_freq_ = (votes / (cv.get_n_splits())).to_dict()

        ranked = votes.sort_values(ascending=False)
        selected = list(ranked.head(self.n_select).index)

        # Always retain domain priors + anomaly score + missing-count.
        for col in self._known_important_columns(self.feature_names_full_):
            if col not in selected:
                selected.append(col)
        self.selected_features_ = selected
        return self

    def transform(self, X: pd.DataFrame) -> pd.DataFrame:
        full = self._assemble(clean_frame(X))
        # Serving robustness: guarantee every selected column exists.
        for col in self.selected_features_:
            if col not in full.columns:
                full[col] = 0.0
        return full[self.selected_features_]

    def fit_transform(self, X: pd.DataFrame, y: pd.Series) -> pd.DataFrame:
        return self.fit(X, y).transform(X)