Spaces:
Running
Running
File size: 2,973 Bytes
42029e4 44cd54e 42029e4 44cd54e 42029e4 | 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 | """Graph / ring-fraud features.
Builds an applicant similarity graph where edges connect applicants
sharing key identifiers (zip, employer, income bucket). The
community-level fraud rate and degree centrality of each node are
strong fraud signals - they catch organised rings that any single
feature would miss.
For very large datasets the graph would be built with GraphTool or
Neo4j; networkx is plenty for up to ~500k applications.
"""
from __future__ import annotations
import numpy as np
import pandas as pd
import networkx as nx
from ..utils.logging import get_logger
log = get_logger(__name__)
def build_graph_features(
df: pd.DataFrame,
link_keys: tuple[str, ...] = ("zip_code", "emp_title", "income_bucket"),
min_link_keys: int = 2,
max_component_size: int = 5_000,
) -> pd.DataFrame:
"""Attach graph-derived features to each row.
Added columns:
- graph_component_size : size of the connected component
- graph_degree : node degree
- graph_default_rate : (only set during training) fraud rate of component
"""
df = df.copy().reset_index(drop=True)
log.info(
f"Building applicant graph from {len(df):,} nodes, "
f"link_keys={link_keys}, min_link_keys={min_link_keys}"
)
G = nx.Graph()
G.add_nodes_from(df.index.tolist())
# For each link key, group rows and connect pairs that share it.
# We avoid all-pairs O(N^2) by capping per-group connections.
for key in link_keys:
if key not in df.columns:
continue
groups = df.groupby(key, observed=True).indices
for value, idxs in groups.items():
if pd.isna(value) or len(idxs) < 2 or len(idxs) > 200:
# ignore huge groups (e.g. NaN zip) - they're too generic
continue
for i in range(len(idxs)):
for j in range(i + 1, len(idxs)):
a, b = int(idxs[i]), int(idxs[j])
if G.has_edge(a, b):
G[a][b]["weight"] = G[a][b].get("weight", 1) + 1
else:
G.add_edge(a, b, weight=1)
# Keep only edges with weight >= min_link_keys (i.e. share >= K keys)
weak_edges = [(u, v) for u, v, d in G.edges(data=True) if d["weight"] < min_link_keys]
G.remove_edges_from(weak_edges)
log.info(f"Graph: {G.number_of_nodes()} nodes, {G.number_of_edges():,} edges")
# Compute per-node features
component_sizes = np.ones(len(df), dtype=int)
degrees = np.zeros(len(df), dtype=int)
for comp in nx.connected_components(G):
size = len(comp)
if size > max_component_size:
continue
for node in comp:
component_sizes[node] = size
for node, deg in G.degree():
degrees[node] = deg
df["graph_component_size"] = component_sizes
df["graph_degree"] = degrees
df["graph_isolated"] = (degrees == 0).astype(int)
return df
|