loanguard / src /features /behavioral.py
adwitiyashukla's picture
update repo
44cd54e
Raw
History Blame Contribute Delete
3.58 kB
"""Behavioural features derived from application fields.
These are the features that consistently move the needle in
production fraud models - ratios, credit-history age, payment-to-
income, etc.
"""
from __future__ import annotations
import numpy as np
import pandas as pd
def _safe_qcut(series: pd.Series, q: int = 10) -> pd.Series:
"""Quantile-bucket a series, robust to small / single-row inputs.
``pd.qcut`` raises when there aren't enough distinct values to form
``q`` bins (e.g. a single-row scoring request). We fall back to a
single bucket (0) in that case - the bucket is only used as a graph
link key, never as a direct model feature, so a constant value at
inference time is harmless.
"""
s = pd.to_numeric(series, errors="coerce")
try:
binned = pd.qcut(s, q=q, labels=False, duplicates="drop")
return binned.astype("Int64")
except (ValueError, IndexError):
return pd.Series([pd.NA] * len(s), index=s.index, dtype="Int64")
def build_behavioral_features(df: pd.DataFrame, today: pd.Timestamp | None = None) -> pd.DataFrame:
"""Add ~15 behavioural / derived features. Pure function - no side effects."""
df = df.copy()
today = today or df.get("issue_d", pd.Series([pd.Timestamp.today()])).max()
# Income / loan ratios
if {"loan_amnt", "annual_inc"}.issubset(df.columns):
df["loan_to_income"] = df["loan_amnt"] / df["annual_inc"].replace(0, np.nan)
if {"installment", "annual_inc"}.issubset(df.columns):
df["installment_to_income"] = (df["installment"] * 12) / df["annual_inc"].replace(0, np.nan)
if {"revol_bal", "annual_inc"}.issubset(df.columns):
df["revol_bal_to_income"] = df["revol_bal"] / df["annual_inc"].replace(0, np.nan)
# Credit history length (years between earliest_cr_line and issue_d)
if {"earliest_cr_line", "issue_d"}.issubset(df.columns):
df["credit_history_years"] = (
(df["issue_d"] - df["earliest_cr_line"]).dt.days / 365.25
).clip(lower=0)
# Account utilisation features
if {"open_acc", "total_acc"}.issubset(df.columns):
df["pct_open_acc"] = df["open_acc"] / df["total_acc"].replace(0, np.nan)
# Negative-event composite
neg_cols = [c for c in ("delinq_2yrs", "pub_rec", "pub_rec_bankruptcies") if c in df.columns]
if neg_cols:
df["n_negative_events"] = df[neg_cols].fillna(0).sum(axis=1)
# High-DTI and zero-emp flags
if "dti" in df.columns:
df["flag_high_dti"] = (df["dti"] > 35).astype(int)
if "emp_length" in df.columns:
df["flag_no_employment"] = df["emp_length"].fillna(0).eq(0).astype(int)
# Income discretisation (used for matching / graph features later)
if "annual_inc" in df.columns:
df["income_bucket"] = _safe_qcut(df["annual_inc"], q=10)
# Loan-amount discretisation
if "loan_amnt" in df.columns:
df["loan_bucket"] = _safe_qcut(df["loan_amnt"], q=10)
# Application month / quarter (seasonality)
if "issue_d" in df.columns:
df["app_month"] = df["issue_d"].dt.month
df["app_quarter"] = df["issue_d"].dt.quarter
df["app_year"] = df["issue_d"].dt.year
# Title / emp_title length and digit-ratio (proxy for application-form quality)
for col in ("title", "emp_title"):
if col in df.columns:
s = df[col].fillna("").astype(str)
df[f"{col}_len"] = s.str.len()
df[f"{col}_digit_ratio"] = s.apply(
lambda x: sum(c.isdigit() for c in x) / max(len(x), 1)
)
return df