01_credit_risk_graph_curvature



Data Science Projects

Credit Risk with Graph Curvature using Home Credit Data

A Geometric Machine Learning Approach (PySpark)


Roberto SSoares - LfLngLrnng

in/roberto-dos-santos-soares
Portifólio: roberto-ssoares

" [+] Faturamento,
[-] Custo,
[+] Qualidade de vida "

"Mestre Bruno Jardim"

📌 Objetivo

  • Este projeto investiga se a geometria relacional entre clientes pode enriquecer a modelagem de risco de crédito.

  • Em vez de tratar clientes apenas como linhas independentes em uma tabela, construímos um grafo de similaridade entre clientes a partir do dataset Home Credit Default Risk.

  • Em seguida, calculamos medidas geométricas do grafo, como curvatura de Ricci, e incorporamos essas medidas como features para modelagem preditiva.


📌 Hipótese

  • Clientes estruturalmente semelhantes no espaço de atributos formam padrões relacionais que podem conter sinal preditivo adicional para inadimplência.

📚 Instalando e Carregando os Pacotes

  • Objetivo:
    • Importar bibliotecas necessárias para manipulação de dados, construção do grafo,
    • cálculo de curvatura e modelagem preditiva.
  • Ações realizadas:
    • Importação de bibliotecas de análise, ML e grafos
    • Configuração de warnings
  • Justificativa técnica:
    • Este projeto integra processamento tabular com modelagem em grafos.
    • Por isso, precisamos combinar bibliotecas clássicas de ciência de dados
    • com bibliotecas de análise de redes.
  • Resultados esperados:
    • Ambiente pronto para carga, preparação, construção do grafo e modelagem.

import warnings
warnings.filterwarnings("ignore")

import os
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.impute import SimpleImputer
from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer
from sklearn.metrics import ( roc_auc_score, classification_report, confusion_matrix )
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import RandomForestClassifier
from sklearn.neighbors import NearestNeighbors

from sklearn.preprocessing import StandardScaler, OneHotEncoder
import networkx as nx
from GraphRicciCurvature.OllivierRicci import OllivierRicci

✔️ 0. Configurações Iniciais

  • Objetivo:
    • Definir parâmetros globais do projeto.
  • Ações realizadas:
    • Definição de caminhos
    • Definição de colunas principais
    • Definição de hiperparâmetros iniciais
  • Justificativa técnica:
    • Centralizar parâmetros melhora reprodutibilidade e manutenção do notebook.
  • Resultados esperados:
    • Projeto parametrizado e fácil de ajustar.

RANDOM_STATE = 42
DIR_RAW = "../data/00-raw"
FILE = f"{DIR_RAW}/application_train.csv"

ID_COL = "SK_ID_CURR"
TARGET_COL = "TARGET"

SAMPLE_SIZE = 5000     # ajuste conforme memória da sua máquina
K_NEIGHBORS = 8        # número de vizinhos no grafo KNN

✔️ 1. Carga de Dados

  • Objetivo:
    • Carregar a base principal do Home Credit para análise.
  • Ações realizadas:
    • Leitura do CSV principal application_train.csv
  • Justificativa técnica:
    • application_train é a tabela central do problema de inadimplência e contém
    • os clientes rotulados com a variável-alvo TARGET.
  • Resultados esperados:
    • DataFrame bruto carregado em memória.

df_raw = pd.read_csv(FILE)
df_raw.head()
SK_ID_CURR TARGET NAME_CONTRACT_TYPE CODE_GENDER FLAG_OWN_CAR FLAG_OWN_REALTY CNT_CHILDREN AMT_INCOME_TOTAL AMT_CREDIT AMT_ANNUITY ... var_41 var_42 var_43 var_44 var_45 var_46 var_47 var_48 var_49 var_50
0 247330 0 Cash loans F N N 0 157500.0 706410.0 67072.5 ... 0.824762 0.333516 0.293260 0.564878 0.115058 0.655605 0.415562 0.092643 0.723331 0.796523
1 425716 1 Cash loans F Y Y 1 121500.0 545040.0 25407.0 ... 0.416260 0.404293 0.137944 0.457971 0.303691 0.215059 0.838892 0.608335 0.585643 0.298456
2 331625 0 Cash loans M Y Y 1 225000.0 942300.0 27679.5 ... 0.037711 0.124465 0.091840 0.364601 0.978220 0.520309 0.594523 0.559650 0.361873 0.254804
3 455397 0 Revolving loans F N Y 2 144000.0 180000.0 9000.0 ... 0.784630 0.831403 0.210872 0.049639 0.814219 0.830179 0.755163 0.216664 0.603002 0.429001
4 449114 0 Cash loans F N Y 0 112500.0 729792.0 37390.5 ... 0.265381 0.655344 0.668705 0.171391 0.335702 0.585494 0.619551 0.686738 0.540449 0.343632

5 rows × 172 columns

✔️ 2. Diagnóstico Inicial

  • Objetivo:
    • Entender dimensão, tipos e qualidade inicial da base.
  • Ações realizadas:
    • Inspeção de shape
    • Contagem de nulos
    • Distribuição da variável alvo
  • Justificativa técnica:
    • Esta etapa corresponde ao Data Understanding do CRISP-DM e orienta
    • decisões de amostragem, imputação e seleção de variáveis.
  • Resultados esperados:
    • Visão geral da base e do desbalanceamento da TARGET.

print("Shape da base:", df_raw.shape)
print(" ")
print("\nDistribuição da TARGET:")
print(df_raw[TARGET_COL].value_counts(dropna=False))
print(" ")
print("\nProporção da TARGET:")
print(df_raw[TARGET_COL].value_counts(normalize=True).round(4))
print(" ")
print("\nProporção de Nulos:")
null_pct = (df_raw.isna().mean() * 100).sort_values(ascending=False)
print(null_pct.head(20))
Shape da base: (215257, 172)
 

Distribuição da TARGET:
TARGET
0    197845
1     17412
Name: count, dtype: int64
 

Proporção da TARGET:
TARGET
0    0.9191
1    0.0809
Name: proportion, dtype: float64
 

Proporção de Nulos:
COMMONAREA_MODE             69.859284
COMMONAREA_MEDI             69.859284
COMMONAREA_AVG              69.859284
NONLIVINGAPARTMENTS_MODE    69.408660
NONLIVINGAPARTMENTS_AVG     69.408660
NONLIVINGAPARTMENTS_MEDI    69.408660
FONDKAPREMONT_MODE          68.375477
LIVINGAPARTMENTS_AVG        68.327162
LIVINGAPARTMENTS_MODE       68.327162
LIVINGAPARTMENTS_MEDI       68.327162
FLOORSMIN_MEDI              67.824043
FLOORSMIN_MODE              67.824043
FLOORSMIN_AVG               67.824043
YEARS_BUILD_MODE            66.496792
YEARS_BUILD_AVG             66.496792
YEARS_BUILD_MEDI            66.496792
OWN_CAR_AGE                 65.891469
LANDAREA_MEDI               59.374143
LANDAREA_MODE               59.374143
LANDAREA_AVG                59.374143
dtype: float64
df_raw.describe().transpose()
count mean std min 25% 50% 75% max
SK_ID_CURR 215257.0 278236.387137 102885.029589 100003.000000 189025.000000 278215.000000 367388.000000 4.562550e+05
TARGET 215257.0 0.080889 0.272666 0.000000 0.000000 0.000000 0.000000 1.000000e+00
CNT_CHILDREN 215257.0 0.416637 0.719695 0.000000 0.000000 0.000000 1.000000 1.900000e+01
AMT_INCOME_TOTAL 215257.0 168556.848346 105855.718537 25650.000000 112500.000000 144000.000000 202500.000000 1.350000e+07
AMT_CREDIT 215257.0 599495.998425 402898.914406 45000.000000 270000.000000 514867.500000 808650.000000 4.050000e+06
... ... ... ... ... ... ... ... ...
var_46 215257.0 0.500602 0.288052 0.000004 0.251720 0.500931 0.750341 9.999807e-01
var_47 215257.0 0.499781 0.288411 0.000008 0.249971 0.499138 0.749475 9.999971e-01
var_48 215257.0 0.500618 0.288286 0.000010 0.250336 0.502229 0.749817 9.999943e-01
var_49 215257.0 0.499476 0.288685 0.000017 0.248878 0.499258 0.749368 9.999751e-01
var_50 215257.0 0.499538 0.288505 0.000006 0.249740 0.499035 0.748715 9.999967e-01

156 rows × 8 columns

✔️ 3. Amostragem Controlada

  • Objetivo:
    • Reduzir o volume de dados para viabilizar a construção do grafo e o cálculo de curvatura.
  • Ações realizadas:
    • Amostragem estratificada simples por TARGET
  • Justificativa técnica:
    • O cálculo de curvatura em grafos pode ser computacionalmente caro.
    • Uma amostra controlada mantém representatividade e viabilidade computacional.
    • O uso de groupby().sample() evita problemas de estrutura causados por groupby().apply().
  • Resultados esperados:
    • DataFrame amostrado, menor e mais adequado ao experimento inicial.

sample_frac = SAMPLE_SIZE / len(df_raw)

df1_amostra = (
    df_raw
    .groupby(TARGET_COL, group_keys=False)
    .sample(frac=sample_frac, random_state=RANDOM_STATE)
    .reset_index(drop=True)
)

print("Shape da amostra:", df1_amostra.shape)
print()
print(df1_amostra[TARGET_COL].value_counts(normalize=True).round(4))
Shape da amostra: (5000, 172)

TARGET
0    0.9192
1    0.0808
Name: proportion, dtype: float64
print("TARGET existe em df?", TARGET_COL in df1_amostra.columns)
print(" ")
print(df1_amostra.columns[:10].tolist())
TARGET existe em df? True
 
['SK_ID_CURR', 'TARGET', 'NAME_CONTRACT_TYPE', 'CODE_GENDER', 'FLAG_OWN_CAR', 'FLAG_OWN_REALTY', 'CNT_CHILDREN', 'AMT_INCOME_TOTAL', 'AMT_CREDIT', 'AMT_ANNUITY']

✔️ 3.1. Alternativa ainda mais explícita

  • Se você quiser controlar por classe de forma totalmente transparente:
  • Mas, para neste caso, ficaremos com a primeira versão usando groupby().sample().

n_total = SAMPLE_SIZE

class_counts = df_raw[TARGET_COL].value_counts()
class_props = class_counts / len(df_raw)

sample_parts = []

for cls, prop in class_props.items():
    n_cls = max(1, int(n_total * prop))
    part = df_raw[df_raw[TARGET_COL] == cls].sample(
        n=min(n_cls, len(df_raw[df_raw[TARGET_COL] == cls])),
        random_state=RANDOM_STATE
    )
    sample_parts.append(part)

df2_amostra = pd.concat(sample_parts, axis=0).sample(frac=1, random_state=RANDOM_STATE).reset_index(drop=True)

print("Shape da amostra:", df2_amostra.shape)
print()
print(df2_amostra[TARGET_COL].value_counts(normalize=True).round(4))
Shape da amostra: (4999, 172)

TARGET
0    0.9192
1    0.0808
Name: proportion, dtype: float64

✔️ 4. Seleção inicial de features

  • Objetivo:
    • Selecionar um conjunto inicial de features mais estável e interpretável para o grafo e para o baseline.
  • Ações realizadas:
    • Separação de colunas numéricas e categóricas
    • Remoção de colunas com excesso de nulos
  • Justificativa técnica:
    • Para o experimento inicial, vamos privilegiar um pipeline robusto.
    • Variáveis com excesso de missing podem gerar ruído desnecessário neste primeiro ciclo.
  • Resultados esperados:
    • Lista de colunas numéricas e categóricas elegíveis para o modelo.

#remove colunas com mais de 40% de nulos
missing_ratio = df1_amostra.isna().mean()
keep_cols = missing_ratio[missing_ratio <= 0.40].index.tolist()

#garantir colunas principais
keep_cols = list(set(keep_cols + [ID_COL, TARGET_COL]))

df1_features = df1_amostra[keep_cols].copy()

num_cols = df1_features.select_dtypes(include=["number"]).columns.tolist()
cat_cols = df1_features.select_dtypes(include=["object"]).columns.tolist()

# remover ID e target das listas numéricas
num_cols = [c for c in num_cols if c not in [ID_COL, TARGET_COL]]

print("Qtde colunas numéricas:", len(num_cols))
print("Qtde colunas categóricas:", len(cat_cols))
print("Shape após filtro de missing:", df1_features.shape)
Qtde colunas numéricas: 109
Qtde colunas categóricas: 12
Shape após filtro de missing: (5000, 123)

✔️ 5. Seleção de features para o grafo

  • Objetivo:
    • Definir quais variáveis numéricas serão usadas para medir similaridade entre clientes.
  • Ações realizadas:
    • Seleção de um subconjunto de variáveis numéricas relevantes
    • Fallback automático caso alguma coluna não exista
  • Justificativa técnica:
    • O grafo KNN será construído a partir de distância no espaço de atributos.
    • Usar variáveis numéricas facilita padronização e cálculo de vizinhança.
  • Resultados esperados:
    • Lista final de colunas numéricas para construção do grafo.

preferred_graph_cols = [
    "AMT_INCOME_TOTAL",
    "AMT_CREDIT",
    "AMT_ANNUITY",
    "AMT_GOODS_PRICE",
    "DAYS_BIRTH",
    "DAYS_EMPLOYED",
    "CNT_CHILDREN",
    "CNT_FAM_MEMBERS",
    "REGION_POPULATION_RELATIVE",
    "EXT_SOURCE_1",
    "EXT_SOURCE_2",
    "EXT_SOURCE_3"
]

graph_num_cols = [c for c in preferred_graph_cols if c in df1_features.columns]

# fallback para garantir experimento mesmo se alguma coluna estiver ausente
if len(graph_num_cols) < 5:
    graph_num_cols = num_cols[:10]

print("Colunas usadas no grafo:")
print(graph_num_cols)
Colunas usadas no grafo:
['AMT_INCOME_TOTAL', 'AMT_CREDIT', 'AMT_ANNUITY', 'AMT_GOODS_PRICE', 'DAYS_BIRTH', 'DAYS_EMPLOYED', 'CNT_CHILDREN', 'CNT_FAM_MEMBERS', 'REGION_POPULATION_RELATIVE', 'EXT_SOURCE_2', 'EXT_SOURCE_3']

✔️ 6. Preparação da matriz numérica para KNN

  • Objetivo:
    • Preparar a matriz numérica que será usada para encontrar vizinhos mais próximos.
  • Ações realizadas:
    • Imputação de missing numérico
    • Padronização das variáveis
  • Justificativa técnica:
    • KNN é sensível à escala. A padronização evita que variáveis com magnitude maior
    • dominem o cálculo de distância.
  • Resultados esperados:
    • Matriz numérica padronizada pronta para o cálculo de vizinhança.

imputer_graph = SimpleImputer(strategy="median")
scaler_graph = StandardScaler()

X_graph_num = imputer_graph.fit_transform(df1_features[graph_num_cols])
X_graph_scaled = scaler_graph.fit_transform(X_graph_num)

print("Shape da matriz do grafo:", X_graph_scaled.shape)
Shape da matriz do grafo: (5000, 11)

✔️ 7. Construção do grafo KNN

  • Objetivo:
    • Construir um grafo de similaridade entre clientes usando k-vizinhos mais próximos.
  • Ações realizadas:
    • Ajuste do algoritmo NearestNeighbors
    • Criação de arestas entre clientes similares
    • Registro da distância entre clientes
  • Justificativa técnica:
    • Como não há arestas explícitas entre clientes na base, modelamos relações
    • por similaridade de perfil. Isso cria uma estrutura relacional útil para
    • extração de sinais geométricos.
  • Resultados esperados:
    • Grafo não direcionado com nós = clientes e arestas = similaridade.

nbrs = NearestNeighbors(
    n_neighbors=K_NEIGHBORS + 1,   # +1 porque o primeiro vizinho é ele mesmo
    metric="euclidean"
)
nbrs.fit(X_graph_scaled)

distances, indices = nbrs.kneighbors(X_graph_scaled)

G = nx.Graph()

# adiciona nós
for customer_id in df1_features[ID_COL].tolist():
    G.add_node(customer_id)

# adiciona arestas
customer_ids = df1_features[ID_COL].tolist()

for i in range(len(customer_ids)):
    source_id = customer_ids[i]
    
    for j in range(1, K_NEIGHBORS + 1):
        neighbor_idx = indices[i, j]
        target_id = customer_ids[neighbor_idx]
        dist = distances[i, j]
        
        if source_id != target_id:
            G.add_edge(
                source_id,
                target_id,
                weight=float(1 / (1 + dist)),
                distance=float(dist)
            )

print("Número de nós:", G.number_of_nodes())
print("Número de arestas:", G.number_of_edges())
Número de nós: 5000
Número de arestas: 27804

✔️ 8. Inspeção estrutural do grafo

  • Objetivo:
    • Observar propriedades básicas do grafo construído.
  • Ações realizadas:
    • Cálculo de grau médio
    • Contagem de componentes conectados
  • Justificativa técnica:
    • Esta leitura ajuda a entender se o grafo ficou excessivamente esparso,
    • excessivamente denso ou fragmentado.
  • Resultados esperados:
    • Diagnóstico estrutural inicial do grafo.

degrees = [deg for _, deg in G.degree()]
print("Grau médio:", round(np.mean(degrees), 2))
print("Grau mínimo:", np.min(degrees))
print("Grau máximo:", np.max(degrees))
print("Número de componentes conectados:", nx.number_connected_components(G))
Grau médio: 11.12
Grau mínimo: 8
Grau máximo: 26
Número de componentes conectados: 1

✔️ 9. Visualização de subgrafo

  • Objetivo:
    • Visualizar uma pequena amostra do grafo para inspeção qualitativa.
  • Ações realizadas:
    • Seleção de subconjunto de nós
    • Plot simples com spring layout
  • Justificativa técnica:
    • Visualização exploratória auxilia na interpretação do padrão de conectividade.
  • Resultados esperados:
    • Representação visual de um subgrafo da rede de clientes.

plt.figure(figsize=(10, 8))

sub_nodes = list(G.nodes())[:150]
subgraph = G.subgraph(sub_nodes)

pos = nx.spring_layout(subgraph, seed=RANDOM_STATE)
nx.draw(
    subgraph,
    pos,
    node_size=25,
    with_labels=False
)

plt.title("Subgrafo amostral de clientes similares")
plt.show()
Graph

✔️ 10. Cálculo da curvatura de Ollivier-Ricci

  • Objetivo:
    • Calcular curvatura de Ricci nas arestas do grafo.
  • Ações realizadas:
    • Execução do algoritmo OllivierRicci
  • Justificativa técnica:
    • A curvatura resume propriedades geométricas locais do grafo.
    • Em termos intuitivos, ela ajuda a identificar regiões densas,
    • gargalos e padrões estruturais relevantes.
  • Resultados esperados:
    • Grafo enriquecido com atributo ricciCurvature nas arestas.

orc = OllivierRicci(G, alpha=0.5, verbose="INFO")
orc.compute_ricci_curvature()

✔️ 10.1. Opção - Forçar execução sem multiprocessing

  • Dependendo da versão da biblioteca, pode funcionar passar:

#orc = OllivierRicci(G, alpha=0.5, verbose="INFO", proc=1)
#orc.compute_ricci_curvature()

✔️ 11. Extração das curvaturas de aresta

  • Objetivo:
    • Organizar os resultados de curvatura em formato tabular.
  • Ações realizadas:
    • Iteração sobre arestas
    • Extração de curvatura e atributos auxiliares
  • Justificativa técnica:
    • Isso facilita estatística descritiva, visualização e interpretação.
  • Resultados esperados:
    • DataFrame de arestas com curvatura de Ricci.

G_curv = orc.G

edge_rows = []

for u, v, data in G_curv.edges(data=True):
    edge_rows.append({
        "source": u,
        "target": v,
        "distance": data.get("distance", np.nan),
        "weight": data.get("weight", np.nan),
        "ricci_curvature": data.get("ricciCurvature", np.nan)
    })

df_edges = pd.DataFrame(edge_rows)
df_edges.head()
source target distance weight ricci_curvature
0 204040 254299 0.419204 0.704620 0.383597
1 204040 356853 1.067864 0.483591 0.040278
2 204040 333990 1.150261 0.465060 0.040947
3 204040 432673 1.212241 0.452030 -0.009316
4 204040 196367 1.263886 0.441718 -0.099400

✔️ 12. Estatística descritiva da curvatura

  • Objetivo:
    • Descrever a distribuição da curvatura nas arestas do grafo.
  • Ações realizadas:
    • Estatística descritiva
    • Histograma
  • Justificativa técnica:
    • A distribuição da curvatura ajuda a entender se a rede apresenta
    • predominância de regiões densas, neutras ou gargalos.
  • Resultados esperados:
    • Visão estatística e gráfica da curvatura.

display(df_edges["ricci_curvature"].describe())

plt.figure(figsize=(8, 5))
df_edges["ricci_curvature"].dropna().hist(bins=30)
plt.title("Distribuição da Curvatura de Ricci")
plt.xlabel("Curvatura")
plt.ylabel("Frequência")
plt.show()
count    27804.000000
mean        -0.145710
std          0.223203
min         -1.644944
25%         -0.290087
50%         -0.140232
75%          0.005637
max          0.685194
Name: ricci_curvature, dtype: float64
Graph

✔️ 13. Features geométricas por cliente

  • Objetivo:
    • Agregar propriedades geométricas do grafo ao nível do cliente.
  • Ações realizadas:
    • Cálculo de estatísticas de curvatura por nó
    • Cálculo de grau e clustering coefficient
  • Justificativa técnica:
    • Modelos tabulares precisam de features por linha.
    • Aqui transformamos estrutura relacional em atributos individuais.
  • Resultados esperados:
    • DataFrame com features geométricas por cliente.

node_rows = []

for node in G.nodes():
    incident_curvatures = []
    incident_weights = []
    
    for neighbor in G.neighbors(node):
        data = G.get_edge_data(node, neighbor)
        incident_curvatures.append(data.get("ricciCurvature", np.nan))
        incident_weights.append(data.get("weight", np.nan))
    
    node_rows.append({
        ID_COL: node,
        "graph_degree": G.degree(node),
        "graph_degree_weighted": G.degree(node, weight="weight"),
        "curvature_mean": np.nanmean(incident_curvatures) if len(incident_curvatures) > 0 else np.nan,
        "curvature_min": np.nanmin(incident_curvatures) if len(incident_curvatures) > 0 else np.nan,
        "curvature_max": np.nanmax(incident_curvatures) if len(incident_curvatures) > 0 else np.nan,
        "curvature_std": np.nanstd(incident_curvatures) if len(incident_curvatures) > 0 else np.nan,
        "local_clustering": nx.clustering(G, node, weight=None)
    })

df_graph_features = pd.DataFrame(node_rows)
df_graph_features.head()
SK_ID_CURR graph_degree graph_degree_weighted curvature_mean curvature_min curvature_max curvature_std local_clustering
0 204040 9 4.208832 NaN NaN NaN NaN 0.333333
1 151722 12 5.906183 NaN NaN NaN NaN 0.242424
2 402164 12 3.879631 NaN NaN NaN NaN 0.333333
3 281589 13 4.604832 NaN NaN NaN NaN 0.192308
4 213088 15 7.487532 NaN NaN NaN NaN 0.285714

✔️ 14. Merge com a base principal

  • Objetivo:
    • Incorporar as features geométricas ao dataset de modelagem.
  • Ações realizadas:
    • Merge entre base de clientes e atributos do grafo
  • Justificativa técnica:
    • Esse passo unifica informação tabular e relacional em uma única base analítica.
  • Resultados esperados:
    • Base consolidada para modelagem.

df_model = df1_features.merge(df_graph_features, on=ID_COL, how="left")
print("Shape da base modelagem:", df_model.shape)
df_model.head()
Shape da base modelagem: (5000, 130)
var_24 FLAG_DOCUMENT_2 var_23 FLAG_PHONE var_8 FLAG_DOCUMENT_3 var_26 var_38 var_5 OBS_30_CNT_SOCIAL_CIRCLE ... AMT_REQ_CREDIT_BUREAU_QRT NAME_HOUSING_TYPE FLAG_EMP_PHONE graph_degree graph_degree_weighted curvature_mean curvature_min curvature_max curvature_std local_clustering
0 0.283401 0 0.422637 0 0.957378 1 0.577099 0.018221 0.817941 0.0 ... NaN House / apartment 1 9 4.208832 NaN NaN NaN NaN 0.333333
1 0.463920 0 0.609124 0 0.971677 0 0.955273 0.572177 0.465824 3.0 ... 0.0 House / apartment 1 12 5.906183 NaN NaN NaN NaN 0.242424
2 0.533850 0 0.794818 0 0.612048 0 0.379137 0.834554 0.839051 1.0 ... NaN House / apartment 0 12 3.879631 NaN NaN NaN NaN 0.333333
3 0.754027 0 0.085205 0 0.113551 0 0.502462 0.366798 0.554650 0.0 ... 1.0 House / apartment 1 13 4.604832 NaN NaN NaN NaN 0.192308
4 0.808076 0 0.546754 1 0.934963 1 0.911366 0.727425 0.916967 2.0 ... 3.0 House / apartment 0 15 7.487532 NaN NaN NaN NaN 0.285714

5 rows × 130 columns

✔️ 15. Definição das colunas geométricas

  • Objetivo:
    • Identificar explicitamente as features geométricas do experimento.
  • Ações realizadas:
    • Lista de colunas derivadas do grafo
  • Justificativa técnica:
    • Essa separação será usada para comparar baseline tabular com modelo enriquecido.
  • Resultados esperados:
    • Lista formal das features geométricas.

graph_cols = [
    "graph_degree",
    "graph_degree_weighted",
    "curvature_mean",
    "curvature_min",
    "curvature_max",
    "curvature_std",
    "local_clustering"
]

graph_cols = [c for c in graph_cols if c in df_model.columns]
graph_cols
['graph_degree',
 'graph_degree_weighted',
 'curvature_mean',
 'curvature_min',
 'curvature_max',
 'curvature_std',
 'local_clustering']

✔️ 16. Separação treino/teste antes do pré-processamento

  • Objetivo:
    • Separar treino e teste preservando a proporção da variável-alvo.
  • Ações realizadas:
    • Train/test split estratificado
  • Justificativa técnica:
    • A separação antes do pipeline evita vazamento de informação.
  • Resultados esperados:
    • Conjuntos de treino e teste bem definidos.

train_df, test_df = train_test_split(
    df_model,
    test_size=0.30,
    random_state=RANDOM_STATE,
    stratify=df_model[TARGET_COL]
)

print("Train shape:", train_df.shape)
print("Test shape:", test_df.shape)
Train shape: (3500, 130)
Test shape: (1500, 130)

✔️ 17. Definição das features baseline e geometric

  • Objetivo:
    • Construir dois conjuntos de features:
        1. baseline tabular
        1. tabular + geométricas
  • Ações realizadas:
    • Identificação de colunas categóricas e numéricas
    • Remoção de colunas que não entram no modelo
  • Justificativa técnica:
    • Isso permitirá uma comparação justa entre as duas abordagens.
  • Resultados esperados:
    • Listas de colunas para cada experimento.

exclude_cols = [ID_COL, TARGET_COL]

all_feature_cols = [c for c in df_model.columns if c not in exclude_cols]
baseline_feature_cols = [c for c in all_feature_cols if c not in graph_cols]
geometric_feature_cols = all_feature_cols.copy()

baseline_num_cols = train_df[baseline_feature_cols].select_dtypes(include=["number"]).columns.tolist()
baseline_cat_cols = train_df[baseline_feature_cols].select_dtypes(include=["object"]).columns.tolist()

geo_num_cols = train_df[geometric_feature_cols].select_dtypes(include=["number"]).columns.tolist()
geo_cat_cols = train_df[geometric_feature_cols].select_dtypes(include=["object"]).columns.tolist()

print("Qtde features baseline:", len(baseline_feature_cols))
print("Qtde features geometric:", len(geometric_feature_cols))
Qtde features baseline: 121
Qtde features geometric: 128

✔️ 18. Pipeline do baseline tabular

  • Objetivo:
    • Treinar um baseline puramente tabular.
  • Ações realizadas:
    • Construção de pipeline com imputação, escala e one-hot encoding
    • Treinamento de regressão logística
  • Justificativa técnica:
    • O baseline é essencial para avaliar se as features geométricas realmente agregam valor.
  • Resultados esperados:
    • Modelo tabular treinado e pronto para avaliação.

# Como validar antes de treinar
# Você pode checar quantas colunas categóricas existem:
print("Qtde baseline_cat_cols:", len(baseline_cat_cols))
print(baseline_cat_cols[:20])
Qtde baseline_cat_cols: 12
['OCCUPATION_TYPE', 'FLAG_OWN_CAR', 'CODE_GENDER', 'NAME_EDUCATION_TYPE', 'ORGANIZATION_TYPE', 'NAME_INCOME_TYPE', 'WEEKDAY_APPR_PROCESS_START', 'NAME_FAMILY_STATUS', 'NAME_TYPE_SUITE', 'NAME_CONTRACT_TYPE', 'FLAG_OWN_REALTY', 'NAME_HOUSING_TYPE']
baseline_preprocessor = ColumnTransformer(
    transformers=[
        ("num", Pipeline([
            ("imputer", SimpleImputer(strategy="median")),
            ("scaler", StandardScaler())
        ]), baseline_num_cols),
        
        ("cat", Pipeline([
            ("imputer", SimpleImputer(strategy="most_frequent")),
            ("onehot", OneHotEncoder(handle_unknown="ignore"))
        ]), baseline_cat_cols)
    ],
    remainder="drop",
    verbose_feature_names_out=False
)

baseline_model = Pipeline([
    ("preprocessor", baseline_preprocessor),
    ("clf", LogisticRegression(max_iter=1000, random_state=RANDOM_STATE))
])

X_train_base = train_df[baseline_feature_cols]
y_train = train_df[TARGET_COL]

X_test_base = test_df[baseline_feature_cols]
y_test = test_df[TARGET_COL]

baseline_model.fit(X_train_base, y_train)
Pipeline(steps=[('preprocessor',
                 ColumnTransformer(transformers=[('num',
                                                  Pipeline(steps=[('imputer',
                                                                   SimpleImputer(strategy='median')),
                                                                  ('scaler',
                                                                   StandardScaler())]),
                                                  ['var_24', 'FLAG_DOCUMENT_2',
                                                   'var_23', 'FLAG_PHONE',
                                                   'var_8', 'FLAG_DOCUMENT_3',
                                                   'var_26', 'var_38', 'var_5',
                                                   'OBS_30_CNT_SOCIAL_CIRCLE',
                                                   'DAYS_EMPLOYED',
                                                   'OBS_60_CNT_SOCIAL_CIRCLE',
                                                   'REGION_RATING_C...
                                                                   OneHotEncoder(handle_unknown='ignore'))]),
                                                  ['OCCUPATION_TYPE',
                                                   'FLAG_OWN_CAR',
                                                   'CODE_GENDER',
                                                   'NAME_EDUCATION_TYPE',
                                                   'ORGANIZATION_TYPE',
                                                   'NAME_INCOME_TYPE',
                                                   'WEEKDAY_APPR_PROCESS_START',
                                                   'NAME_FAMILY_STATUS',
                                                   'NAME_TYPE_SUITE',
                                                   'NAME_CONTRACT_TYPE',
                                                   'FLAG_OWN_REALTY',
                                                   'NAME_HOUSING_TYPE'])],
                                   verbose_feature_names_out=False)),
                ('clf', LogisticRegression(max_iter=1000, random_state=42))])
In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.

✔️ 19. Avaliação do baseline

  • Objetivo:
    • Avaliar o desempenho do baseline tabular.
  • Ações realizadas:
    • Predição
    • Cálculo de ROC AUC
    • Relatório de classificação
  • Justificativa técnica:
    • Este resultado será o referencial mínimo do projeto.
  • Resultados esperados:
    • Métricas do baseline.

pred_base = baseline_model.predict(X_test_base)
proba_base = baseline_model.predict_proba(X_test_base)[:, 1]

print("ROC AUC - Baseline Tabular:", round(roc_auc_score(y_test, proba_base), 4))
print("\nClassification Report - Baseline")
print(classification_report(y_test, pred_base))
print("\nConfusion Matrix - Baseline")
print(confusion_matrix(y_test, pred_base))
ROC AUC - Baseline Tabular: 0.6873

Classification Report - Baseline
              precision    recall  f1-score   support

           0       0.92      0.99      0.95      1379
           1       0.20      0.03      0.06       121

    accuracy                           0.91      1500
   macro avg       0.56      0.51      0.51      1500
weighted avg       0.86      0.91      0.88      1500


Confusion Matrix - Baseline
[[1363   16]
 [ 117    4]]

✔️ 20. Pipeline com features geométricas

  • Objetivo:
  • Treinar um modelo com enriquecimento geométrico.
  • Ações realizadas:
    • Construção de pipeline com as mesmas etapas do baseline
    • Inclusão explícita das features derivadas do grafo
  • Justificativa técnica:
    • Assim isolamos o efeito da geometria na performance do modelo.
  • Resultados esperados:
    • Modelo tabular + geométrico treinado.

geo_preprocessor = ColumnTransformer(
    transformers=[
        ("num", Pipeline([
            ("imputer", SimpleImputer(strategy="median")),
            ("scaler", StandardScaler())
        ]), geo_num_cols),
        ("cat", Pipeline([
            ("imputer", SimpleImputer(strategy="most_frequent")),
             ("onehot", OneHotEncoder(handle_unknown="ignore"))
        ]), geo_cat_cols)
    ],
    remainder="drop",
    verbose_feature_names_out=False
)

geo_model = Pipeline([
    ("preprocessor", geo_preprocessor),
    ("clf", LogisticRegression(max_iter=1000, random_state=RANDOM_STATE))
])

X_train_geo = train_df[geometric_feature_cols]
X_test_geo = test_df[geometric_feature_cols]

geo_model.fit(X_train_geo, y_train)
Pipeline(steps=[('preprocessor',
                 ColumnTransformer(transformers=[('num',
                                                  Pipeline(steps=[('imputer',
                                                                   SimpleImputer(strategy='median')),
                                                                  ('scaler',
                                                                   StandardScaler())]),
                                                  ['var_24', 'FLAG_DOCUMENT_2',
                                                   'var_23', 'FLAG_PHONE',
                                                   'var_8', 'FLAG_DOCUMENT_3',
                                                   'var_26', 'var_38', 'var_5',
                                                   'OBS_30_CNT_SOCIAL_CIRCLE',
                                                   'DAYS_EMPLOYED',
                                                   'OBS_60_CNT_SOCIAL_CIRCLE',
                                                   'REGION_RATING_C...
                                                                   OneHotEncoder(handle_unknown='ignore'))]),
                                                  ['OCCUPATION_TYPE',
                                                   'FLAG_OWN_CAR',
                                                   'CODE_GENDER',
                                                   'NAME_EDUCATION_TYPE',
                                                   'ORGANIZATION_TYPE',
                                                   'NAME_INCOME_TYPE',
                                                   'WEEKDAY_APPR_PROCESS_START',
                                                   'NAME_FAMILY_STATUS',
                                                   'NAME_TYPE_SUITE',
                                                   'NAME_CONTRACT_TYPE',
                                                   'FLAG_OWN_REALTY',
                                                   'NAME_HOUSING_TYPE'])],
                                   verbose_feature_names_out=False)),
                ('clf', LogisticRegression(max_iter=1000, random_state=42))])
In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.

✔️ 21. Avaliação do modelo geométrico

  • Objetivo:
    • Avaliar o efeito das features geométricas no desempenho do modelo.
  • Ações realizadas:
    • Predição
    • Cálculo de ROC AUC
    • Relatório de classificação
  • Justificativa técnica:
    • Esta é a comparação central do experimento.
  • Resultados esperados:
    • Métricas do modelo enriquecido com geometria.

pred_geo = geo_model.predict(X_test_geo)
proba_geo = geo_model.predict_proba(X_test_geo)[:, 1]

print("ROC AUC - Tabular + Geometria:", round(roc_auc_score(y_test, proba_geo), 4))
print("\nClassification Report - Geometric")
print(classification_report(y_test, pred_geo))
print("\nConfusion Matrix - Geometric")
print(confusion_matrix(y_test, pred_geo))
ROC AUC - Tabular + Geometria: 0.6908

Classification Report - Geometric
              precision    recall  f1-score   support

           0       0.92      0.99      0.95      1379
           1       0.28      0.04      0.07       121

    accuracy                           0.91      1500
   macro avg       0.60      0.52      0.51      1500
weighted avg       0.87      0.91      0.88      1500


Confusion Matrix - Geometric
[[1366   13]
 [ 116    5]]

✔️ 22. Random Forest complementar

  • Objetivo:
    • Testar um modelo não linear com a base enriquecida.
  • Ações realizadas:
    • Pré-processamento manual
    • Treinamento de Random Forest
  • Justificativa técnica:
    • Alguns sinais estruturais podem ser melhor capturados por modelos não lineares.
  • Resultados esperados:
    • Métricas complementares e comparação adicional.

# transforma treino e teste
X_train_geo_transformed = geo_preprocessor.fit_transform(X_train_geo)
X_test_geo_transformed = geo_preprocessor.transform(X_test_geo)

rf_model = RandomForestClassifier(
    n_estimators=250,
    max_depth=10,
    min_samples_split=10,
    min_samples_leaf=5,
    random_state=RANDOM_STATE,
    n_jobs=-1
)

rf_model.fit(X_train_geo_transformed, y_train)

pred_rf = rf_model.predict(X_test_geo_transformed)
proba_rf = rf_model.predict_proba(X_test_geo_transformed)[:, 1]

print("ROC AUC - Random Forest + Geometria:", round(roc_auc_score(y_test, proba_rf), 4))
print("\nClassification Report - RF Geometric")
print(classification_report(y_test, pred_rf))
ROC AUC - Random Forest + Geometria: 0.693

Classification Report - RF Geometric
              precision    recall  f1-score   support

           0       0.92      1.00      0.96      1379
           1       0.00      0.00      0.00       121

    accuracy                           0.92      1500
   macro avg       0.46      0.50      0.48      1500
weighted avg       0.85      0.92      0.88      1500

✔️ 23. Importância das features

  • Objetivo:
    • Identificar quais atributos mais contribuíram para o Random Forest.
  • Ações realizadas:
    • Extração das importâncias
    • Plot das top features
  • Justificativa técnica:
    • Essa leitura ajuda a verificar se as features geométricas entraram entre os
    • sinais mais relevantes do modelo.
  • Resultados esperados:
    • Ranking visual das features mais importantes.

feature_names = geo_preprocessor.get_feature_names_out()
importances = pd.Series(rf_model.feature_importances_, index=feature_names).sort_values(ascending=False)

top_n = 20
plt.figure(figsize=(10, 6))
importances.head(top_n).sort_values().plot(kind="barh")
plt.title(f"Top {top_n} Features Mais Importantes - RF")
plt.xlabel("Importance")
plt.show()

display(importances.head(top_n))
Graph
EXT_SOURCE_3             0.079706
EXT_SOURCE_2             0.036946
DAYS_ID_PUBLISH          0.022009
var_13                   0.021651
var_8                    0.019988
var_36                   0.017757
DAYS_BIRTH               0.016998
var_2                    0.016885
var_29                   0.015534
var_35                   0.015483
var_10                   0.015478
var_50                   0.015325
graph_degree_weighted    0.014911
DAYS_EMPLOYED            0.014649
var_7                    0.014519
var_12                   0.014331
local_clustering         0.014062
var_28                   0.014062
var_39                   0.013877
var_27                   0.013778
dtype: float64

✔️ 24. Verificação específica das features geométricas

  • Objetivo:
    • Avaliar explicitamente a relevância das features geométricas.
  • Ações realizadas:
    • Filtragem das importâncias para atributos geométricos
  • Justificativa técnica:
    • Esta célula facilita a narrativa do projeto: mostrar se a geometria entrou
    • ou não como sinal relevante.
  • Resultados esperados:
    • Ranking de features geométricas no modelo.

geom_importances = importances[importances.index.str.contains(
    "graph_degree|curvature|local_clustering",
    regex=True
)]

display(geom_importances.sort_values(ascending=False))
graph_degree_weighted    0.014911
local_clustering         0.014062
graph_degree             0.005535
dtype: float64

✔️ 25. Leitura técnica dos resultados

  • Interpretação técnica dos resultados
    • Neste experimento, comparamos duas abordagens:
      1. Baseline tabular: clientes tratados como registros independentes
      2. Tabular + geometria: clientes enriquecidos com atributos derivados de um grafo de similaridade
  • Questões centrais
    • As features geométricas agregaram sinal preditivo?
    • A curvatura ajudou a resumir estrutura local relevante?
    • O modelo passou a capturar relações invisíveis em uma abordagem puramente tabular?
  • Possíveis leituras
    • Se o ROC AUC melhorar, há evidência de que a estrutura relacional entre clientes contém informação útil
    • Se as features geométricas aparecerem entre as mais importantes, a geometria do grafo passou a atuar como sinal de risco
    • Se não houver ganho, ainda assim o experimento tem valor técnico, pois valida ou refuta uma hipótese de modelagem de forma explícita

✔️ 26. Conclusão do notebook

  • Conclusão
    • Este notebook apresentou uma abordagem de Machine Learning Geométrico aplicada ao problema de risco de crédito com o dataset Home Credit Default Risk.
    • A partir de um grafo de similaridade entre clientes, calculamos curvatura de Ricci e outras estatísticas estruturais, transformando a geometria do grafo em features utilizáveis por modelos supervisionados.
  • Principais aprendizados
    • Clientes podem ser modelados como uma estrutura relacional, não apenas como linhas independentes
    • Curvatura de Ricci oferece um resumo geométrico local da rede
    • Features geométricas podem enriquecer a modelagem de crédito, especialmente em contextos onde relações estruturais importam
  • Próximos passos
    • Incorporar tabelas auxiliares do Home Credit, como bureau e previous_application
    • Testar Graph Neural Networks
    • Comparar KNN graph com regras de conexão por contexto ocupacional e regional
    • Publicar dashboard interativo e página HTML para portfólio

Fim

#!uv pip install nbconvert -U -q
!jupyter nbconvert 01_credit_risk_graph_curvature.ipynb --to html --template my-template-html-v07.tpl