Spaces:
Sleeping
Sleeping
| import pandas as pd | |
| import numpy as np | |
| from pathlib import Path | |
| from typing import Tuple | |
| import sys | |
| sys.path.append(str(Path(__file__).resolve().parents[3])) | |
| from src.logger import get_logger | |
| logger = get_logger("ieee_cis.feature_engineer") | |
| START_DATE = pd.Timestamp("2017-12-01") | |
| PEAK_FRAUD_HOURS = [5, 6, 7, 8, 9] | |
| RISKY_BROWSERS = ["opera", "android", "samsung", "firefox", "mobile"] | |
| PROTON_DOMAINS = ["protonmail.com", "pm.me"] | |
| HIGH_RISK_EMAIL_DOMAINS = [ | |
| "mail.com", "outlook.es", "aim.com", "outlook.com", | |
| "icloud.com", "gmail.com", "hotmail.com", | |
| ] | |
| D_COLS_NORMALIZE = [ | |
| "D1", "D2", "D3", "D4", "D5", | |
| "D6", "D8", "D9", "D10", "D11", | |
| "D12", "D13", "D14", "D15", | |
| ] | |
| AGG_GROUP_COLS = ["card1", "card1_addr1"] | |
| AGG_TARGET_COL = "TransactionAmt" | |
| def fe_log_transform(df: pd.DataFrame) -> pd.DataFrame: | |
| if "TransactionAmt" not in df.columns: | |
| return df | |
| skew_before = df["TransactionAmt"].skew() | |
| log_vals = np.log1p(df["TransactionAmt"]) | |
| skew_after = log_vals.skew() | |
| df = pd.concat( | |
| [df, pd.DataFrame({"FE_amt_log": log_vals}, index=df.index)], | |
| axis=1 | |
| ) | |
| logger.info("FE: log1p(TransactionAmt)") | |
| logger.info(f" Skewness: {skew_before:.2f} → {skew_after:.2f}") | |
| return df | |
| def fe_temporal(df: pd.DataFrame) -> pd.DataFrame: | |
| if "TransactionDT" not in df.columns: | |
| return df | |
| dt = START_DATE + pd.to_timedelta(df["TransactionDT"], unit="s") | |
| new_cols = { | |
| "FE_hour" : dt.dt.hour.astype(np.int8), | |
| "FE_dayofweek" : dt.dt.dayofweek.astype(np.int8), | |
| "FE_day" : dt.dt.day.astype(np.int8), | |
| "FE_month" : dt.dt.month.astype(np.int8), | |
| "FE_is_night" : dt.dt.hour.isin(range(0, 6)).astype(np.int8), | |
| "FE_is_weekend" : dt.dt.dayofweek.isin([5, 6]).astype(np.int8), | |
| "FE_is_peak_fraud_hour": dt.dt.hour.isin(PEAK_FRAUD_HOURS).astype(np.int8), | |
| "_TransactionDT_days" : (df["TransactionDT"] / 86400).astype(np.float32), | |
| } | |
| df = pd.concat( | |
| [df, pd.DataFrame(new_cols, index=df.index)], axis=1 | |
| ) | |
| logger.info("FE: Temporal features from TransactionDT") | |
| logger.info(f" START_DATE : {START_DATE.date()}") | |
| logger.info(f" Date range : {dt.min()} → {dt.max()}") | |
| logger.info(f" PEAK_FRAUD_HOURS : {PEAK_FRAUD_HOURS}") | |
| logger.info( | |
| " Created: FE_hour, FE_dayofweek, FE_day, FE_month, " | |
| "FE_is_night, FE_is_weekend, FE_is_peak_fraud_hour, " | |
| "_TransactionDT_days" | |
| ) | |
| return df | |
| def fe_d_normalization(df: pd.DataFrame) -> pd.DataFrame: | |
| if "_TransactionDT_days" not in df.columns: | |
| logger.info("FE: D normalization skipped — _TransactionDT_days missing") | |
| return df | |
| dt_days = df["_TransactionDT_days"] | |
| new_cols = {} | |
| created = [] | |
| for col in D_COLS_NORMALIZE: | |
| if col in df.columns and pd.api.types.is_numeric_dtype(df[col]): | |
| norm_col = f"FE_{col}_normalized" | |
| new_cols[norm_col] = (df[col] - dt_days).astype(np.float32) | |
| created.append(norm_col) | |
| if new_cols: | |
| df = pd.concat( | |
| [df, pd.DataFrame(new_cols, index=df.index)], axis=1 | |
| ) | |
| logger.info("FE: D column normalization") | |
| logger.info(f" Formula : FE_D{{n}}_normalized = D{{n}} - TransactionDT_days") | |
| logger.info(f" Created : {len(created)} normalized D features") | |
| logger.info(f" Columns : {created}") | |
| return df | |
| def fe_card_combinations(df: pd.DataFrame) -> pd.DataFrame: | |
| new_cols = {} | |
| if "card1" in df.columns and "addr1" in df.columns: | |
| new_cols["card1_addr1"] = ( | |
| df["card1"].astype(str) + "_" + df["addr1"].astype(str) | |
| ) | |
| card_cols = [c for c in ["card1", "card2", "card3", "card5"] if c in df.columns] | |
| if card_cols: | |
| new_cols["card_full"] = ( | |
| df[card_cols].fillna("nan").astype(str).agg("_".join, axis=1) | |
| ) | |
| if new_cols: | |
| df = pd.concat( | |
| [df, pd.DataFrame(new_cols, index=df.index)], axis=1 | |
| ) | |
| logger.info("FE: Card combination fingerprints") | |
| logger.info(f" Created: {list(new_cols.keys())}") | |
| return df | |
| def fe_uid(df: pd.DataFrame) -> pd.DataFrame: | |
| new_cols = {} | |
| uid_parts = [] | |
| for col in ["card1", "card2", "P_emaildomain"]: | |
| if col in df.columns: | |
| uid_parts.append(df[col].fillna("nan").astype(str)) | |
| if len(uid_parts) >= 2: | |
| uid_series = uid_parts[0] | |
| for part in uid_parts[1:]: | |
| uid_series = uid_series + "_" + part | |
| new_cols["FE_uid"] = uid_series | |
| ext_parts = [] | |
| for col in ["card1", "addr1"]: | |
| if col in df.columns: | |
| ext_parts.append(df[col].fillna("nan").astype(str)) | |
| if "FE_D1_normalized" in df.columns: | |
| d1_binned = ( | |
| (df["FE_D1_normalized"] // 30).fillna(-999).astype(int).astype(str) | |
| ) | |
| ext_parts.append(d1_binned) | |
| if len(ext_parts) >= 2: | |
| uid_ext = ext_parts[0] | |
| for part in ext_parts[1:]: | |
| uid_ext = uid_ext + "_" + part | |
| new_cols["FE_uid_ext"] = uid_ext | |
| if new_cols: | |
| df = pd.concat( | |
| [df, pd.DataFrame(new_cols, index=df.index)], axis=1 | |
| ) | |
| logger.info("FE: UID features") | |
| logger.info(f" Created: {list(new_cols.keys())}") | |
| return df | |
| def fe_email_risk(df: pd.DataFrame) -> pd.DataFrame: | |
| new_cols = {} | |
| if "P_emaildomain" in df.columns: | |
| new_cols["FE_P_email_is_proton"] = df["P_emaildomain"].isin(PROTON_DOMAINS).astype(np.int8) | |
| new_cols["FE_P_email_high_risk"] = df["P_emaildomain"].isin(HIGH_RISK_EMAIL_DOMAINS).astype(np.int8) | |
| if "R_emaildomain" in df.columns: | |
| new_cols["FE_R_email_is_proton"] = df["R_emaildomain"].isin(PROTON_DOMAINS).astype(np.int8) | |
| new_cols["FE_R_email_high_risk"] = df["R_emaildomain"].isin(HIGH_RISK_EMAIL_DOMAINS).astype(np.int8) | |
| if "P_emaildomain" in df.columns and "R_emaildomain" in df.columns: | |
| new_cols["FE_email_match"] = ( | |
| df["P_emaildomain"] == df["R_emaildomain"] | |
| ).astype(np.int8) | |
| if new_cols: | |
| df = pd.concat( | |
| [df, pd.DataFrame(new_cols, index=df.index)], axis=1 | |
| ) | |
| logger.info("FE: Email domain risk flags") | |
| logger.info(f" Created: {list(new_cols.keys())}") | |
| return df | |
| def fe_browser_risk(df: pd.DataFrame) -> pd.DataFrame: | |
| if "id_31" not in df.columns: | |
| logger.info("FE: Browser risk flag skipped — id_31 not found") | |
| return df | |
| browser_lower = df["id_31"].astype(str).str.lower() | |
| risky_flag = browser_lower.str.contains( | |
| "|".join(RISKY_BROWSERS), na=False | |
| ).astype(np.int8) | |
| df = pd.concat( | |
| [df, pd.DataFrame({"FE_browser_is_risky": risky_flag}, index=df.index)], | |
| axis=1 | |
| ) | |
| risky_count = risky_flag.sum() | |
| logger.info("FE: Browser risk flag") | |
| logger.info(f" RISKY_BROWSERS : {RISKY_BROWSERS}") | |
| logger.info(f" id_31 dtype : {df['id_31'].dtype}") | |
| logger.info( | |
| f" Risky transactions : {risky_count:,} " | |
| f"({risky_count/len(df)*100:.2f}%)" | |
| ) | |
| logger.info(" Created: FE_browser_is_risky") | |
| return df | |
| def fe_device_type(df: pd.DataFrame) -> pd.DataFrame: | |
| if "DeviceType" not in df.columns: | |
| logger.info("FE: Device type skipped — DeviceType not found") | |
| return df | |
| mobile_flag = ( | |
| df["DeviceType"].astype(str).str.lower() == "mobile" | |
| ).astype(np.int8) | |
| df = pd.concat( | |
| [df, pd.DataFrame({"FE_device_is_mobile": mobile_flag}, index=df.index)], | |
| axis=1 | |
| ) | |
| mobile_count = mobile_flag.sum() | |
| logger.info("FE: Device type flag") | |
| logger.info(f" DeviceType dtype : {df['DeviceType'].dtype}") | |
| logger.info( | |
| f" Mobile transactions : {mobile_count:,} " | |
| f"({mobile_count/len(df)*100:.2f}%)" | |
| ) | |
| logger.info(" Created: FE_device_is_mobile") | |
| return df | |
| def fe_card_aggregations( | |
| df: pd.DataFrame, | |
| agg_maps: dict = None, | |
| ) -> Tuple[pd.DataFrame, dict]: | |
| """ | |
| Card-level TransactionAmt aggregations (mean, std, count). | |
| Groups: card1 and card1_addr1. | |
| __global__ key stores fallback value for unseen groups. | |
| __group_col__ removed — group column inferred from feature name. | |
| """ | |
| new_cols = {} | |
| if "TransactionAmt" not in df.columns: | |
| logger.info("FE: Card aggregations skipped — TransactionAmt missing") | |
| return df, agg_maps or {} | |
| if agg_maps is not None: | |
| # Test/val mode — infer group_col from feature name | |
| for feat, agg_map in agg_maps.items(): | |
| global_val = agg_map.get("__global__", 0) | |
| group_col = "card1_addr1" if "card1a1" in feat else "card1" | |
| if group_col in df.columns: | |
| new_cols[feat] = df[group_col].map(agg_map).fillna(global_val) | |
| if new_cols: | |
| df = pd.concat( | |
| [df, pd.DataFrame(new_cols, index=df.index)], axis=1 | |
| ) | |
| logger.info("FE: Card aggregations") | |
| logger.info(f" [TEST] Applied {len(agg_maps)} aggregation maps") | |
| return df, agg_maps | |
| # Train mode | |
| agg_maps = {} | |
| for group_col in AGG_GROUP_COLS: | |
| if group_col not in df.columns: | |
| continue | |
| grp = df.groupby(group_col)["TransactionAmt"] | |
| global_mean = df["TransactionAmt"].mean() | |
| global_std = df["TransactionAmt"].std() | |
| global_count = df.groupby(group_col).size().mean() | |
| prefix = "card1" if group_col == "card1" else "card1a1" | |
| mean_key = f"FE_{prefix}_amt_mean" | |
| std_key = f"FE_{prefix}_amt_std" | |
| cnt_key = f"FE_{prefix}_amt_count" | |
| mean_map = grp.mean().to_dict() | |
| std_map = grp.std().fillna(0).to_dict() | |
| count_map = df.groupby(group_col).size().to_dict() | |
| # Only __global__ stored — no __group_col__ sentinel | |
| mean_map["__global__"] = global_mean | |
| std_map["__global__"] = global_std | |
| count_map["__global__"] = global_count | |
| new_cols[mean_key] = df[group_col].map(mean_map).fillna(global_mean) | |
| new_cols[std_key] = df[group_col].map(std_map).fillna(global_std) | |
| new_cols[cnt_key] = df[group_col].map(count_map).fillna(global_count) | |
| agg_maps[mean_key] = mean_map | |
| agg_maps[std_key] = std_map | |
| agg_maps[cnt_key] = count_map | |
| if new_cols: | |
| df = pd.concat( | |
| [df, pd.DataFrame(new_cols, index=df.index)], axis=1 | |
| ) | |
| logger.info("FE: Card aggregations (TransactionAmt by card1 and card1_addr1)") | |
| logger.info(f" [TRAIN] Created: {list(new_cols.keys())}") | |
| return df, agg_maps | |
| def fe_frequency_encoding( | |
| df: pd.DataFrame, | |
| freq_maps: dict = None, | |
| ) -> Tuple[pd.DataFrame, dict]: | |
| base_cols = [ | |
| c for c in [ | |
| "card1", "card2", "addr1", | |
| "P_emaildomain", "R_emaildomain", | |
| "card1_addr1", "card_full", | |
| ] | |
| if c in df.columns | |
| ] | |
| fe_uid_cols = [c for c in ["FE_uid", "FE_uid_ext"] if c in df.columns] | |
| encode_cols = base_cols + fe_uid_cols | |
| if freq_maps is not None: | |
| new_cols = {} | |
| for col, freq_map in freq_maps.items(): | |
| if col not in df.columns: | |
| continue | |
| feat_name = ( | |
| f"{col}_freq" if col.startswith("FE_") | |
| else f"FE_{col}_freq" | |
| ) | |
| new_cols[feat_name] = df[col].map(freq_map).fillna(0) | |
| if new_cols: | |
| df = pd.concat( | |
| [df, pd.DataFrame(new_cols, index=df.index)], axis=1 | |
| ) | |
| logger.info("FE: Frequency encoding") | |
| logger.info(f" [TEST] Applied {len(freq_maps)} frequency maps") | |
| return df, freq_maps | |
| freq_maps = {} | |
| new_cols = {} | |
| for col in encode_cols: | |
| freq_map = df[col].value_counts(normalize=True).to_dict() | |
| freq_maps[col] = freq_map | |
| feat_name = ( | |
| f"{col}_freq" if col.startswith("FE_") | |
| else f"FE_{col}_freq" | |
| ) | |
| new_cols[feat_name] = df[col].map(freq_map).fillna(0) | |
| df = pd.concat( | |
| [df, pd.DataFrame(new_cols, index=df.index)], axis=1 | |
| ) | |
| logger.info("FE: Frequency encoding") | |
| logger.info(f" [TRAIN] Encoded {len(encode_cols)} columns: {encode_cols}") | |
| logger.info(f" Feature names: {list(new_cols.keys())}") | |
| return df, freq_maps | |
| def fe_card1_addr1_count( | |
| df: pd.DataFrame, | |
| count_map: dict = None, | |
| ) -> Tuple[pd.DataFrame, dict]: | |
| if "card1_addr1" not in df.columns: | |
| return df, count_map or {} | |
| if count_map is not None: | |
| df = pd.concat( | |
| [df, pd.DataFrame( | |
| {"FE_card1_addr1_count": df["card1_addr1"].map(count_map).fillna(0)}, | |
| index=df.index | |
| )], | |
| axis=1 | |
| ) | |
| logger.info("FE: card1_addr1 count — [TEST] Applied") | |
| return df, count_map | |
| count_map = df["card1_addr1"].value_counts().to_dict() | |
| df = pd.concat( | |
| [df, pd.DataFrame( | |
| {"FE_card1_addr1_count": df["card1_addr1"].map(count_map).fillna(0)}, | |
| index=df.index | |
| )], | |
| axis=1 | |
| ) | |
| logger.info("FE: card1_addr1 count — [TRAIN] Created") | |
| return df, count_map | |
| def fe_cleanup_temp(df: pd.DataFrame) -> pd.DataFrame: | |
| drop_cols = [c for c in ["_TransactionDT_days"] if c in df.columns] | |
| if drop_cols: | |
| df = df.drop(columns=drop_cols) | |
| logger.info(f"FE: Cleaned temp columns: {drop_cols}") | |
| return df | |
| def feature_engineer_train(df: pd.DataFrame) -> Tuple[pd.DataFrame, dict]: | |
| logger.info("=" * 50) | |
| logger.info("FEATURE ENGINEERING — TRAIN") | |
| logger.info(f"Input shape: {df.shape}") | |
| df = fe_log_transform(df) | |
| df = fe_temporal(df) | |
| df = fe_d_normalization(df) | |
| df = fe_card_combinations(df) | |
| df = fe_uid(df) | |
| df = fe_email_risk(df) | |
| df = fe_browser_risk(df) | |
| df = fe_device_type(df) | |
| df, agg_maps = fe_card_aggregations(df) | |
| df, freq_maps = fe_frequency_encoding(df) | |
| df, count_map = fe_card1_addr1_count(df) | |
| df = fe_cleanup_temp(df) | |
| fe_cols = [c for c in df.columns if c.startswith("FE_")] | |
| logger.info(f"Total FE features created : {len(fe_cols)}") | |
| logger.info(f"FE columns : {fe_cols}") | |
| logger.info(f"Output shape : {df.shape}") | |
| logger.info("FEATURE ENGINEERING TRAIN COMPLETE") | |
| logger.info("=" * 50) | |
| artifacts = { | |
| "freq_maps": freq_maps, | |
| "count_map": count_map, | |
| "agg_maps" : agg_maps, | |
| } | |
| return df, artifacts | |
| def feature_engineer_test( | |
| df: pd.DataFrame, | |
| artifacts: dict, | |
| ) -> pd.DataFrame: | |
| logger.info("=" * 50) | |
| logger.info("FEATURE ENGINEERING — TEST/VAL") | |
| logger.info(f"Input shape: {df.shape}") | |
| df = fe_log_transform(df) | |
| df = fe_temporal(df) | |
| df = fe_d_normalization(df) | |
| df = fe_card_combinations(df) | |
| df = fe_uid(df) | |
| df = fe_email_risk(df) | |
| df = fe_browser_risk(df) | |
| df = fe_device_type(df) | |
| df, _ = fe_card_aggregations(df, agg_maps=artifacts["agg_maps"]) | |
| df, _ = fe_frequency_encoding(df, freq_maps=artifacts["freq_maps"]) | |
| df, _ = fe_card1_addr1_count(df, count_map=artifacts["count_map"]) | |
| df = fe_cleanup_temp(df) | |
| fe_cols = [c for c in df.columns if c.startswith("FE_")] | |
| logger.info(f"Total FE features applied : {len(fe_cols)}") | |
| logger.info(f"Output shape : {df.shape}") | |
| logger.info("FEATURE ENGINEERING TEST/VAL COMPLETE") | |
| logger.info("=" * 50) | |
| return df |