Spaces:
Running
Running
| """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 | |