Data Science Projects
Credit Risk with Graph Curvature using Home Credit Data
A Geometric Machine Learning Approach (PySpark)
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
- 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
- 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
- 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
- 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
- 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']
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
- 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)
- 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']
- 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)
- 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
- 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
- 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()
- 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()
#orc = OllivierRicci(G, alpha=0.5, verbose="INFO", proc=1)
#orc.compute_ricci_curvature()
- 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 |
- 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
- 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 |
- 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
- 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']
- 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)
- Objetivo:
- Construir dois conjuntos de features:
- baseline tabular
- 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
- 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. ['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_CLIENT', 'var_44', 'var_20', 'CNT_FAM_MEMBERS', 'var_28', 'REG_REGION_NOT_WORK_REGION', 'AMT_GOODS_PRICE', 'var_42', 'AMT_REQ_CREDIT_BUREAU_WEEK', 'AMT_REQ_CREDIT_BUREAU_DAY', 'DAYS_REGISTRATION', 'FLAG_DOCUMENT_17', 'var_13', 'FLAG_CONT_MOBILE', 'LIVE_REGION_NOT_WORK_REGION', 'LIVE_CITY_NOT_WORK_CITY', 'CNT_CHILDREN', 'var_37', 'var_15', 'var_2', 'AMT_INCOME_TOTAL', 'var_22', 'FLAG_WORK_PHONE', 'DAYS_LAST_PHONE_CHANGE', 'AMT_REQ_CREDIT_BUREAU_MON', 'var_39', 'FLAG_DOCUMENT_5', 'DAYS_BIRTH', 'var_29', 'var_48', 'DEF_60_CNT_SOCIAL_CIRCLE', 'var_49', 'var_14', 'var_30', 'var_33', 'var_10', 'var_36', 'FLAG_DOCUMENT_6', 'REG_REGION_NOT_LIVE_REGION', 'EXT_SOURCE_2', 'var_7', 'var_18', 'var_6', 'var_35', 'var_21', 'FLAG_DOCUMENT_11', 'var_45', 'AMT_ANNUITY', 'DEF_30_CNT_SOCIAL_CIRCLE', 'REG_CITY_NOT_LIVE_CITY', 'var_25', 'FLAG_DOCUMENT_15', 'FLAG_DOCUMENT_10', 'REGION_POPULATION_RELATIVE', 'FLAG_DOCUMENT_18', 'var_12', 'EXT_SOURCE_3', 'var_9', 'var_32', 'var_4', 'var_16', 'FLAG_DOCUMENT_16', 'REGION_RATING_CLIENT_W_CITY', 'var_34', 'AMT_CREDIT', 'var_40', 'FLAG_DOCUMENT_21', 'FLAG_DOCUMENT_14', 'REG_CITY_NOT_WORK_CITY', 'var_43', 'var_46', 'AMT_REQ_CREDIT_BUREAU_YEAR', 'FLAG_MOBIL', 'var_19', 'var_17', 'var_27', 'FLAG_DOCUMENT_13', 'var_31', 'FLAG_DOCUMENT_12', 'FLAG_DOCUMENT_8', 'FLAG_DOCUMENT_19', 'var_11', 'var_3', 'HOUR_APPR_PROCESS_START', 'var_41', 'var_47', 'FLAG_DOCUMENT_20', 'FLAG_DOCUMENT_4', 'AMT_REQ_CREDIT_BUREAU_HOUR', 'var_50', 'DAYS_ID_PUBLISH', 'FLAG_EMAIL', 'FLAG_DOCUMENT_9', 'FLAG_DOCUMENT_7', 'var_1', 'AMT_REQ_CREDIT_BUREAU_QRT', 'FLAG_EMP_PHONE']
['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']
- 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]]
- 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. ['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_CLIENT', 'var_44', 'var_20', 'CNT_FAM_MEMBERS', 'var_28', 'REG_REGION_NOT_WORK_REGION', 'AMT_GOODS_PRICE', 'var_42', 'AMT_REQ_CREDIT_BUREAU_WEEK', 'AMT_REQ_CREDIT_BUREAU_DAY', 'DAYS_REGISTRATION', 'FLAG_DOCUMENT_17', 'var_13', 'FLAG_CONT_MOBILE', 'LIVE_REGION_NOT_WORK_REGION', 'LIVE_CITY_NOT_WORK_CITY', 'CNT_CHILDREN', 'var_37', 'var_15', 'var_2', 'AMT_INCOME_TOTAL', 'var_22', 'FLAG_WORK_PHONE', 'DAYS_LAST_PHONE_CHANGE', 'AMT_REQ_CREDIT_BUREAU_MON', 'var_39', 'FLAG_DOCUMENT_5', 'DAYS_BIRTH', 'var_29', 'var_48', 'DEF_60_CNT_SOCIAL_CIRCLE', 'var_49', 'var_14', 'var_30', 'var_33', 'var_10', 'var_36', 'FLAG_DOCUMENT_6', 'REG_REGION_NOT_LIVE_REGION', 'EXT_SOURCE_2', 'var_7', 'var_18', 'var_6', 'var_35', 'var_21', 'FLAG_DOCUMENT_11', 'var_45', 'AMT_ANNUITY', 'DEF_30_CNT_SOCIAL_CIRCLE', 'REG_CITY_NOT_LIVE_CITY', 'var_25', 'FLAG_DOCUMENT_15', 'FLAG_DOCUMENT_10', 'REGION_POPULATION_RELATIVE', 'FLAG_DOCUMENT_18', 'var_12', 'EXT_SOURCE_3', 'var_9', 'var_32', 'var_4', 'var_16', 'FLAG_DOCUMENT_16', 'REGION_RATING_CLIENT_W_CITY', 'var_34', 'AMT_CREDIT', 'var_40', 'FLAG_DOCUMENT_21', 'FLAG_DOCUMENT_14', 'REG_CITY_NOT_WORK_CITY', 'var_43', 'var_46', 'AMT_REQ_CREDIT_BUREAU_YEAR', 'FLAG_MOBIL', 'var_19', 'var_17', 'var_27', 'FLAG_DOCUMENT_13', 'var_31', 'FLAG_DOCUMENT_12', 'FLAG_DOCUMENT_8', 'FLAG_DOCUMENT_19', 'var_11', 'var_3', 'HOUR_APPR_PROCESS_START', 'var_41', 'var_47', 'FLAG_DOCUMENT_20', 'FLAG_DOCUMENT_4', 'AMT_REQ_CREDIT_BUREAU_HOUR', 'var_50', 'DAYS_ID_PUBLISH', 'FLAG_EMAIL', 'FLAG_DOCUMENT_9', 'FLAG_DOCUMENT_7', 'var_1', 'AMT_REQ_CREDIT_BUREAU_QRT', 'FLAG_EMP_PHONE', 'graph_degree', 'graph_degree_weighted', 'curvature_mean', 'curvature_min', 'curvature_max', 'curvature_std', 'local_clustering']
['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']
- 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]]
- 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
- 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))
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
- 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
- Interpretação técnica dos resultados
- Neste experimento, comparamos duas abordagens:
- Baseline tabular: clientes tratados como registros independentes
- 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
- 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