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