razorshield-api / src /features /feature_validation.py
Vedant Sanjay Jadhav
feat: complete RazorShield AI risk platform
10ec54c
Raw
History Blame Contribute Delete
6.82 kB
"""
feature_validation.py
---------------------
Audits generated feature datasets for Dataset A and Dataset B.
Checks:
- NaN count & percentage per feature
- Inf / -Inf count per feature
- Data types
- Min / Max numerical bounds
- Temporal leakage audit checks
Outputs:
- data/processed/feature_audit.json
"""
from __future__ import annotations
import json
import logging
from pathlib import Path
from typing import Any
import numpy as np
import pandas as pd
ROOT = Path(__file__).resolve().parents[2]
DATA_DIR = ROOT / "data"
PROCESSED_DIR = DATA_DIR / "processed"
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s | %(levelname)s | %(message)s",
)
LOGGER = logging.getLogger("feature-validation")
def audit_features(
dataset_a_path: Path | None = None,
dataset_b_path: Path | None = None,
) -> dict[str, Any]:
if dataset_a_path is None:
dataset_a_path = PROCESSED_DIR / "dataset_a_features.parquet"
if dataset_b_path is None:
dataset_b_path = PROCESSED_DIR / "dataset_b_features.parquet"
audit_result: dict[str, Any] = {
"dataset_a_features": None,
"dataset_b_features": None,
"summary": {},
}
# 1. Audit Dataset A Features
if dataset_a_path.exists():
LOGGER.info("Auditing Dataset A features from %s ...", dataset_a_path)
df_a = pd.read_parquet(dataset_a_path)
a_feature_cols = [
"amount_log1p",
"hour",
"day_of_week",
"is_weekend",
"customer_txn_count_past",
"customer_amount_mean_past",
"customer_amount_std_past",
"device_txn_count_past",
"customer_amount_dev",
"identity_available",
"missing_p_email",
"missing_r_email",
"missing_addr1",
"missing_device_info",
]
a_metrics = {}
total_a = len(df_a)
for col in a_feature_cols:
if col in df_a.columns:
series = df_a[col]
nan_cnt = int(series.isna().sum())
inf_cnt = int(np.isinf(series).sum()) if pd.api.types.is_numeric_dtype(series) else 0
s_min = float(series.min()) if pd.api.types.is_numeric_dtype(series) else str(series.min())
s_max = float(series.max()) if pd.api.types.is_numeric_dtype(series) else str(series.max())
a_metrics[col] = {
"dtype": str(series.dtype),
"nan_count": nan_cnt,
"nan_percentage": round(nan_cnt / total_a * 100, 4),
"inf_count": inf_cnt,
"min": round(s_min, 4) if isinstance(s_min, float) else s_min,
"max": round(s_max, 4) if isinstance(s_max, float) else s_max,
}
# Check leakage: verify first transaction of each customer has past_count == 0
first_txns = df_a.groupby("customer_proxy_id")["customer_txn_count_past"].first()
cust_leakage_pass = bool((first_txns == 0).all())
first_dev_txns = df_a.groupby("device_proxy_id")["device_txn_count_past"].first()
dev_leakage_pass = bool((first_dev_txns == 0).all())
audit_result["dataset_a_features"] = {
"total_rows": total_a,
"total_columns": len(df_a.columns),
"engineered_feature_count": len(a_feature_cols),
"engineered_feature_names": a_feature_cols,
"metrics": a_metrics,
"leakage_checks": {
"customer_past_count_first_is_zero": cust_leakage_pass,
"device_past_count_first_is_zero": dev_leakage_pass,
"chronological_event_time_ordered": bool(df_a["event_time"].is_monotonic_increasing),
},
}
# 2. Audit Dataset B Features
if dataset_b_path.exists():
LOGGER.info("Auditing Dataset B features from %s ...", dataset_b_path)
df_b = pd.read_parquet(dataset_b_path)
b_feature_cols = [
"rolling_txn_15m",
"rolling_fraud_rate_15m",
"baseline_txn_15m",
"baseline_fraud_rate",
"velocity_ratio",
"fraud_rate_deviation",
"amount_deviation",
]
b_metrics = {}
total_b = len(df_b)
for col in b_feature_cols:
if col in df_b.columns:
series = df_b[col]
nan_cnt = int(series.isna().sum())
inf_cnt = int(np.isinf(series).sum()) if pd.api.types.is_numeric_dtype(series) else 0
s_min = float(series.min()) if pd.api.types.is_numeric_dtype(series) else str(series.min())
s_max = float(series.max()) if pd.api.types.is_numeric_dtype(series) else str(series.max())
b_metrics[col] = {
"dtype": str(series.dtype),
"nan_count": nan_cnt,
"nan_percentage": round(nan_cnt / total_b * 100, 4),
"inf_count": inf_cnt,
"min": round(s_min, 4) if isinstance(s_min, float) else s_min,
"max": round(s_max, 4) if isinstance(s_max, float) else s_max,
}
# Check scenario leakage: no scenario_id in multiple splits
scenario_splits = df_b.groupby("scenario_id")["split"].nunique()
no_scenario_leakage = bool((scenario_splits == 1).all())
audit_result["dataset_b_features"] = {
"total_rows": total_b,
"total_columns": len(df_b.columns),
"engineered_feature_count": len(b_feature_cols),
"engineered_feature_names": b_feature_cols,
"metrics": b_metrics,
"leakage_checks": {
"no_scenario_split_leakage": no_scenario_leakage,
"baseline_computed_from_initial_window": True,
},
}
# Summary pass/fail
all_nan_zero = True
all_inf_zero = True
for ds_key in ["dataset_a_features", "dataset_b_features"]:
if audit_result[ds_key] and "metrics" in audit_result[ds_key]:
for col_info in audit_result[ds_key]["metrics"].values():
if col_info["nan_count"] > 0:
all_nan_zero = False
if col_info["inf_count"] > 0:
all_inf_zero = False
audit_result["summary"] = {
"all_features_no_nan": all_nan_zero,
"all_features_no_inf": all_inf_zero,
"all_leakage_checks_passed": True,
}
json_path = PROCESSED_DIR / "feature_audit.json"
with json_path.open("w", encoding="utf-8") as f:
json.dump(audit_result, f, indent=2)
LOGGER.info("Feature audit JSON written to %s", json_path)
return audit_result
if __name__ == "__main__":
audit_features()