Machine learning para credit scoring

Desenvolvimento de modelo em Python

Bruno Carloto
17 min readMar 3, 2024

1. Introdução

Modelos de score baseados em machine learning, no mercado de crédito, auxiliam na tomada de decisão, seja para prospectar, conceder crédito, gerenciar a carteira e recuperar inadimplentes.

Esses modelos apresentam uma probabilidade referente aos consumidores do mercado de crédito. Tal probabilidade está atrelada justamente no objetivo do modelo, o qual é desenvolvido com foco em um dos ciclos de crédito.

Imagem pertencente a https://www.cdlmanaus.org.br/servicos/ciclo-de-negocios

Nesse artigo, faço uma demonstração sobre o desenvolvimento de um modelo focado em concessão de crédito. Além da demonstração, é possível experimentá-lo, clicando aqui.

A base trabalhada se encontra no kaggle, acessível clicando aqui.

Diversas análises foram realizadas ao longo do desenvolvimento do modelo, entretanto, para não tornar o conteúdo excessivamente longo, trouxe apenas as partes que podem transmitir a completude do estudo.

Conteúdo

  1. Introdução
  2. Análises e tratamentos primários
  3. Desenvolvimento do modelo
  4. Backtest
  5. Conclusão
  6. Modelo em produção

2. Análises e tratamentos primários

Iniciei o estudo com importação das biblotecas de uso do estudo

# Importando bibliotecas
import numpy as np # Manipulação matemática
import pandas as pd # Manipulação de dataframes e series
import re # Trabalho com expressões regulares
import matplotlib.pyplot as plt # Criação de gráficos
import seaborn as sns # Criação de gráficos
import sklearn # Aplicação de técnicas de pré-processamento e modelos de machine learning

Após importação das biblitecas, importo a base de desenvolvimento do modelo.

# Importando base
base_geral = pd.read_csv(r"C:\Users\nayar\OneDrive\Data Science\Projetos\projeto_score\credito\credit_risk_dataset.csv")

A base possui as seguintes colunas:

Ao todo, a base possui 32.581 linhas e 12 colunas.

# Dimensão da base
base_geral.shape

O tipo de dado de cada variável é predominante numérica, conforme indica o código abaixo:

# Observando informações da base treino
base_geral.info()

Olhando para a quantidade de dado em cada coluna, alguns apresentam ausência de valores.

Pela tabela acima, podemos identificar que duas variáveis estão com valores faltantes, a person_emp_length (tempo de emprego em anos) e a loan_int_rate (taxa de juros).

Porém, se houvesse muitíssimas colunas, ficaria inviável observá-las, então, construi o código abaixo para exibir quais apresentam valores faltantes.

# Evidenciando variáveis com ausência de valores
for col in base_geral.columns:
if base_geral[col].isnull().sum() > 0:
print('Variável: %s - UNFULL' %col)

As duas colunas retornadas pelo código são as mesmas já destacadas acima.

Sabendo disso, precisamos corrigir a ausência de valores, isso é, valores missings.

Temos quatro possibilidades que considerei: i) eliminar as colunas com ausência de informação ii) preencher as ausências com os valores médios, iii) preencher as ausências com os valores medianos ou iv) preencher as ausências com valores baseados nas similaridades entre os valores das demais colunas em relação à toda a base.

Diante da primeira opção, perdemos informações, já que eliminaríamos toda uma linha. A segunda opção pode apresentar uma média que não representa a realidade, caso não seja composta a partir de uma curva normal. A terceira opção apresenta mais segurança, porquanto não é afetada por outliers ou por distribuições não paramétricas. A última opção é mais sofisticada, entretanto, mais complexa e podendo resultar em um gasto de tempo que resulte numa solução suja.

Sendo assim, optei por trabalhar ou com a média ou com a mediana. A decisão dependeria da relação simétrica entre média, mediana e desvio padrão.

Iniciei com a análise da variável person_emp_length, referente ao tempo de emprego. Sequencialmente analisei a variável loan_int_rate, concernente à taxa de juros.

# Descrição estatística
base_geral.person_emp_length.describe()

Grande parte da base é de consumidores com até 7 anos de emprego. Há também um provável outlier ou sujeira referente a 123 anos de emprego. Não é algo aparentemente real e pode ser uma informação necessitada de descarte.

Observei que a média a respeito do tempo empregado é próxima à mediana. Temos um desvio padrão de quatro para cima e para baixo.

Optei por preencher as ausências de valores da coluna de tempo de emprego com o valor da mediana, por observá-la mais sólida.

# Preenchendo missing values
base_geral.person_emp_length.fillna(4, inplace = True)

Realizei a análise da coluna taxa de juros.

# Descrição estatística
base_geral.loan_int_rate.describe()

Na base, grande parte da base tem taxa de juros de até aproximadamente 13%. A média e a mediana estão próximas com um desvio padrão baixo.

Poderia optar pela média, todavia, sigo a mediana, já aplicada na variável anterior.

# Preenchendo missing values
base_geral.loan_int_rate.fillna(10, inplace = True)

Após preenchimento, temos todas as colunas devidamente preenchidas. Reaplico o código já aplicado inicialmente.

# Evidenciando variáveis sem duplicidade
for col in base_geral.columns:
if base_geral[col].isnull().sum() > 0:
print('Variável: %s - UNFULL' %col)

Nenhuma resposta foi retornada, indicando que todas as colunas estão preenchidas com valores.

Para melhor compreensão das variáveis, plotei histogramas para cada variável numérica.

# Observando distribuição dos dados numéricos
numericos = ['person_age', 'person_income', 'person_emp_length', 'loan_amnt',
'loan_int_rate', 'loan_status', 'loan_percent_income',
'cb_person_cred_hist_length']

for col in numericos:
plt.figure(figsize = (10, 4))
sns.histplot(base_geral[col].round(2))
plt.ticklabel_format(style='plain', axis='x')

De modo geral, temos uma proporção maior de mais jovens na base; mais pessoas recebem as menores rendas; mais pessoas possuem menos tempo de emprego; mais pessoas possuem os menores empréstimos; a taxa de juro em torno de 10% se mostra como a mais praticada; há mais adimplentes do que inadimplentes; o comprometimento de renda mais comum está abaixo de 20% e a maior parte dos consumidores possuem os menores anos de histórico no mercado de crédito.

Todas essas observações podem ser resumidas numa frase simples: os mais jovens têm sido os mais frequentes na tomada de crédito, apresentando o menor tempo de emprego e o menor relacionamento creditício.

Mas essa é uma característica dessa base ou amostra.

Observei a descritiva estatística de cada uma dessas variáveis através do código a seguir:

# Descrição estatística dos numéricos
base_geral[numericos].describe().round(2)

75% dos consumidores, presentes na base, possuem até 30 anos, o que confirma a jovialidade destacada anteriormente.

A renda anual desses mesmo 75% é de até aproximadamente R$ 79 mil. Isso equivale a um salário mensal em torno de R$ 6 mil.

Considerando a média, temos uma renda mensal em torno de R$ 5 mil.

Em média, esses consumidores realizam empréstimos em torno de R$ 9 mil.

Além das variáveis numéricas, temos as categóricas.

# Observando distribuição dos dados categóricos
categorias = ['person_home_ownership', 'loan_intent', 'loan_grade', 'cb_person_default_on_file']

for col in categorias:
plt.figure(figsize = (15, 4))
sns.countplot(base_geral[col])

Em relação ao tipo de moradia, a maior parte reside em imóveis alugados, seguidamente por imóveis hipotecados.

O destino do empréstimo é principalmente para educação.

Os principais graus de empréstimo são A e B, que mais à frente destaco algumas análises sobre essa variável.

Por fim, a maior parte não apresenta histórico de inadimplência.

Antes de desenvolver o modelo, procurei entender algumas relacões de variáveis referentes à inadimplência.

A primeira que destaco é uma análise acerca da renda anual por adimplente e inadimplente.

# Separando adimplentes e inadimplentes
adimplentes = base_analitica[base_analitica.loan_status == 0]
inadimplentes = base_analitica[base_analitica.loan_status == 1]

# Gerando gráfico
plt.figure(figsize = (10, 4))
sns.kdeplot(adimplentes.person_income, shade = True, color = 'blue', legend = 'adimplente')
sns.kdeplot(inadimplentes.person_income, shade = True, color = 'red', legend = 'inadimplente')

plt.title('Densidade da renda por adimplente e inadimplente')
plt.ylabel('Densidade')
plt.legend(['adimplente', 'inadimplente']);

Os inadimplentes tendem a ter rendas anuais menores do que os adimplentes. Podemos dizer, então, em termos gerais, que quanto maior a renda, menor a probabilidade de inadimplência.

# Gerando gráfico
plt.figure(figsize = (10, 4))
sns.kdeplot(adimplentes.loan_amnt, shade = True, color = 'blue', legend = 'adimplente')
sns.kdeplot(inadimplentes.loan_amnt, shade = True, color = 'red', legend = 'inadimplente')

plt.title('Densidade do empréstimo por adimplente e inadimplente')
plt.ylabel('Densidade')
plt.legend(['adimplente', 'inadimplente']);

Os maiores valores de empréstimo tendem a apresentar maior proporção de inadimplentes, de modo que podemos entender que quanto maior o valor do empréstimo, maior a probabilidade de inadimplência.

Os adimplentes tendem a se concentrar mais nos menores empréstimos do que os inadimplentes.

# Gerando gráfico
plt.figure(figsize = (10, 4))
sns.kdeplot(adimplentes.loan_int_rate, shade = True, color = 'blue')
sns.kdeplot(inadimplentes.loan_int_rate, shade = True, color = 'red')

plt.title('Densidade da taxa de juro por adimplente e inadimplente')
plt.ylabel('Densidade')
plt.legend(['adimplente', 'inadimplente']);

Observando o gráfico acima, observamos que os adimplentes se tendem a se concentrar nas menores taxas de juro enquanto os inadimplentes, nas maiores. Dessa forma, podemos dizer que quanto maior a taxa de juro, maior a probabilidade de inadimplência.

Entretanto, a taxa de juro pode ser pré-definida de acordo com o risco do cliente.

Esse gráfico pode estar apenas apontando algo já esperado e não consequente.

# Gerando gráfico
plt.figure(figsize = (10, 4))
sns.kdeplot(adimplentes.loan_percent_income, shade = True, color = 'blue')
sns.kdeplot(inadimplentes.loan_percent_income, shade = True, color = 'red')


plt.title('Densidade do % de comprometimento da renda por adimplente e inadimplente')
plt.ylabel('Densidade')
plt.legend(['adimplente', 'inadimplente']);

Por fim, observamos acima que o público inadimplente tende a se concentrar nos maiores percentuais de compromentimento da renda, enquanto os adimplentes, nas menores.

Sintetizando todos esses gráficos analisados imediatamente acima, podemos entender que a combinação entre menores rendas, maiores empréstimos, maiores taxas de juro e maiores comprometimentos da renda resultam em um provável inadimplente.

Quatro variáveis da base são categóricas. Para análisá-las, busquei enxergar as inadimplências atreladas aos seus valores únicos. Ficará mais claro a seguir.

Primeiramente, busquei enxergar a taxa de inadimplência pelo tipo de imóvel.

# Agrupando bads e totais por person_home_ownership
inad_home = base_analitica.groupby('person_home_ownership').sum()['loan_status'].reset_index()
qtd_home = base_analitica.groupby('person_home_ownership').count()['loan_status'].reset_index()

# Criando tabela única entre os agrupamentos
groupby_inad_home = pd.merge(
inad_home, qtd_home, how = 'left', on = 'person_home_ownership')

# Renomeando variáveis
groupby_inad_home.rename(columns = {'loan_status_x':'bad', 'loan_status_y':'QTD'}, inplace = True)

# Cálculo da inadimplência
groupby_inad_home['inad'] = round(groupby_inad_home.bad/groupby_inad_home.QTD, 4)

# Resultado
groupby_inad_home.sort_values('inad', ascending = False)

A maior inadimplência é para o grupo de pessoas que não possui nem imóvel alugado, nem imóvel hipotecado, nem imóvel próprio. Esses são os classificados como “other”. O grupo que possui imóvel próprio apresentou a menor taxa de inadimplência.

Em seguida, observei a inadimplência pelo motivo de empréstimo.

# Agrupando bads e totais por intent
inad_intent = base_analitica.groupby('loan_intent').sum()['loan_status'].reset_index()
qtd_intent = base_analitica.groupby('loan_intent').count()['loan_status'].reset_index()

# Criando tabela única entre os agrupamentos
groupby_inad_intent = pd.merge(
inad_intent, qtd_intent, how = 'left', on = 'loan_intent')

# Renomeando variáveis
groupby_inad_intent.rename(columns = {'loan_status_x':'bad', 'loan_status_y':'QTD'}, inplace = True)

# Cálculo da inadimplência
groupby_inad_intent['inad'] = round(groupby_inad_intent.bad/groupby_inad_intent.QTD, 4)

# Resultado
groupby_inad_intent.sort_values('inad', ascending = False

O grupo com maior inadimplência é o que adquiriu empréstimo para consolidação de débito. O grupo com menor inadimplência é o que destina o empréstimo para investimento.

Seguidamente analisei o grau de risco do empréstimo.

# Agrupando bads e totais por loan_grade
inad_loan_grade = base_analitica.groupby('loan_grade').sum()['loan_status'].reset_index()
qtd_loan_grade = base_analitica.groupby('loan_grade').count()['loan_status'].reset_index()

# Criando tabela única entre os agrupamentos
groupby_inad_loan_grade = pd.merge(
inad_loan_grade, qtd_loan_grade, how = 'left', on = 'loan_grade')

# Renomeando variáveis
groupby_inad_loan_grade.rename(columns = {'loan_status_x':'bad', 'loan_status_y':'QTD'}, inplace = True)

# Cálculo da inadimplência
groupby_inad_loan_grade['inad'] = round(groupby_inad_loan_grade.bad/groupby_inad_loan_grade.QTD, 4)

# Resultado
groupby_inad_loan_grade.sort_values('inad', ascending = False)

O grau de risco do empréstimo está em ordem alfabética, isso é, considerando de A a G, quanto maior próximo de G, maior a inadimplência.

Por fim, observando quem tem histórico de inadimplência:

# Agrupando bads e totais por intent
inad_cb_person_default_on_file = base_analitica.groupby('cb_person_default_on_file').sum()['loan_status'].reset_index()
qtd_cb_person_default_on_file = base_analitica.groupby('cb_person_default_on_file').count()['loan_status'].reset_index()

# Criando tabela única entre os agrupamentos
groupby_inad_cb_person_default_on_file = pd.merge(
inad_cb_person_default_on_file, qtd_cb_person_default_on_file, how = 'left', on = 'cb_person_default_on_file')

# Renomeando variáveis
groupby_inad_cb_person_default_on_file.rename(columns = {'loan_status_x':'bad', 'loan_status_y':'QTD'}, inplace = True)

# Cálculo da inadimplência
groupby_inad_cb_person_default_on_file['inad'] = round(groupby_inad_cb_person_default_on_file.bad/groupby_inad_cb_person_default_on_file.QTD, 4)

# Resultado
groupby_inad_cb_person_default_on_file.sort_values('inad', ascending = False)

O grupo que possui histórico de inadimplência anterior ao empréstimo apresenta maior inadimplência do que o grupo que não possui.

Sendo assim, para análise, transformei as variáveis categóricas em numéricas, de modo que cada número representou um peso.

Isso foi feito para, caso essas variáveis entrem no input do modelo, apresentem peso.

3. Pré-processamento e seleção de variáveis preditoras

Abaixo está uma documentação que evidencia a correspondência entre os valores únicos das variáveis e seus respectivos pesos numéricos.

Em seguida, fiz a transformação.

# Criando mapeamento encoding
mapeamento_person_home_ownership = {'OWN':1, 'MORTGAGE':2, 'RENT':3, 'OTHER':4}
mapeameto_loan_intent = {'VENTURE':1, 'EDUCATION':2, 'PERSONAL':3, 'MEDICAL': 4, 'HOMEIMPROVEMENT':5, 'DEBTCONSOLIDATION':6}
mapeamento_loan_grade = {'A':1, 'B':2, 'C':3, 'D':4, 'E':5, 'F':6, 'G':7}
mapeamento_cb_person_default_on_file = {'N':1, 'Y':2}

# Encoder
base_analitica['person_home_ownership_Encoded'] = base_analitica['person_home_ownership'].map(mapeamento_person_home_ownership)
base_analitica['loan_intent_Encoded'] = base_analitica['loan_intent'].map(mapeameto_loan_intent)
base_analitica['loan_grade_Encoded'] = base_analitica['loan_grade'].map(mapeamento_loan_grade)
base_analitica['cb_person_default_on_file_Encoded'] = base_analitica['cb_person_default_on_file'].map(mapeamento_cb_person_default_on_file)

Abaixo está um trecho de como ficou essa transformação:

Em seguida, fiz análise de correlação.

# Criando correlação entre variáveis
corr = base_analitica.corr()

# Criando gráfico
plt.figure(figsize = (10,5))
sns.heatmap(corr, annot = True)

plt.title('Correlação entre as variáveis')

plt.show()

A variável dependente ou resposta é loan status, isso é, status de pagamento do empréstimo, se adimplente ou inadimplente.

Nenhuma variável apresenta forte correlação com o status de pagamento do empréstimo. As mais fortes, porém não passando de 40%, são loan percent income e loan grade encoded, a qual criei a partir da variável categórica loan grade.

Para seleção das variáveis de maior peso, utilizei Random Forest.

Para isso realizei algumas importações.

# Importando bibliotecas para estudo de importância de variável
from sklearn.preprocessing import MinMaxScaler
from sklearn.model_selection import train_test_split
from sklearn.ensemble import RandomForestClassifier

Inicialmente separei em torno de 60% da base para desenvolvimento do modelo, ou seja, em torno de 16 mil registros. Os 40% restantes serviram para uma segunda validação do modelo, como backtest.

Após separação dos 60%, reparti esses 60% em treinamento e teste.

Para melhor compreensão, observe o fluxo a seguir:

O código a seguir separa os 60% em treinamento e teste, sendo 80% para treinamento e 20% para teste.

# Separando target
X = numericos_df.iloc[:16152,:] # Separando variáveis independentes
y = base_analitica['loan_status'].iloc[:16152] # Variável independente

# Separando em treinamento e teste
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size = 0.2, random_state = 101)

# Resultados
X_train.shape[0], X_test.shape[0], y_train.shape[0], y_test.shape[0]

Aproximadamente 13 mil foram utilizados para treinamento e 3 mil para teste.

Apesar de algoritmos de árvore e floresta serem bons em detectar dados não normalizados, optei por normalizá-los.

# Criar uma instância do MinMaxScaler
scaler = MinMaxScaler()

# Ajustar a escala nos dados de treinamento e aplicar a normalização
X_train_normalized = scaler.fit_transform(X_train)

# Aplicar a mesma normalização nos dados de teste
X_test_normalized = scaler.transform(X_test)

Em seguida treinei o algoritmo Random Forest para avaliação do peso das variáveis. Pelo peso, decidi quais variáveis entrariam no modelo.

Após treinamento criei uma variável contendo as features e seus respectivos pesos.

# Criando objeto
rf_model = RandomForestClassifier(random_state = 101, max_depth = 100)

# Fitando modelo
rf_model.fit(X_train_normalized, y_train)

# Separando importância das variáveis
importancias = rf_model.feature_importances_

# Relacionando importância e variáveis
feature_importancia = pd.DataFrame({'feature':X.columns, 'importancia':importancias})

# Criando base com index solto para merging
merging_apoio = feature_importancia.reset_index()

# Definindo limiar de seleção
limiar_cumulativo = 0.86

# Calculo de cumulativo
importancia_cumulativa = pd.DataFrame(
feature_importancia[['importancia']]
).sort_values('importancia', ascending = False).cumsum().reset_index()

# Unificando feature e peso cumulativo
importancia_cumulativa = pd.merge(importancia_cumulativa, merging_apoio[['index', 'feature']], how = 'left', on = 'index')

# Apagando index
importancia_cumulativa = importancia_cumulativa[['feature', 'importancia']]

# Resultado
importancia_cumulativa

# Selecionando feature
features_selecionadas = importancia_cumulativa[importancia_cumulativa['importancia'] <= limiar_cumulativo][['feature', 'importancia']]

# Resultado
features_selecionadas

Acima estão as principais variáveis que, juntas, representam 85% do peso de importância.

Também realizei o cálculo de distância de importância entre as variáveis.

# Calculando distância entre as importâncias
abs(features_selecionadas['importancia'] - features_selecionadas['importancia'].shift(-1)) * 100

A distância cai cada vez menos.

Em seguida, selecionei as colunas de peso.

# Armazenando colunas selecionadas
colunas_selecionadas = []
for i in range(len(features_selecionadas)):
colunas_selecionadas.append(features_selecionadas.values[i][0])

# Resultado
colunas_selecionadas

4. Desenvolvimento do modelo

Após isso, importei algoritmos concorrentes para seleção do algoritmo que melhor fita nos dados.

# Validação de modelos com features de peso e desbalanceamento

# Importando cross validation
from sklearn.model_selection import cross_val_score

# Importando principais modelos de classificação
from sklearn.linear_model import LogisticRegression
from sklearn.tree import DecisionTreeClassifier
from sklearn.svm import SVC

Criei um código de treinamento e validação cruzada.

# Criando listas concernentes aos resultados de cada algoritmo
list_score_random_forest_classifier = []
list_score_logistic_regression = []
list_score_decision_tree_classifier = []
list_score_svc = []

for i in range(5):

Kfold = 5

X_train, X_test, y_train, y_test = train_test_split(X, y, test_size = 0.2, random_state = i)

# Criar uma instância do MinMaxScaler
scaler = MinMaxScaler()

# Ajustar a escala nos dados de treinamento e aplicar a normalização
X_train_normalized = scaler.fit_transform(X_train)

# Aplicar a mesma normalização nos dados de teste
X_test_normalized = scaler.transform(X_test)

### --------------- Treinamento e desempenho dos modelos ----------------

modelo_random_forest_classifier = RandomForestClassifier()
scores_random_forest_classifier = cross_val_score(modelo_random_forest_classifier, X_train_normalized, y_train, cv = Kfold) # cv é o número de dobras (folds) da validação cruzada
list_score_random_forest_classifier.append(np.mean(scores_random_forest_classifier))

modelo_logistic_regression = LogisticRegression()
scores_logistic_regression = cross_val_score(modelo_logistic_regression, X_train_normalized, y_train, cv = Kfold) # cv é o número de dobras (folds) da validação cruzada
list_score_logistic_regression.append(np.mean(scores_logistic_regression))

modelo_decision_tree_classifier = DecisionTreeClassifier()
scores_decision_tree_classifier = cross_val_score(modelo_decision_tree_classifier, X_train_normalized, y_train, cv = Kfold) # cv é o número de dobras (folds) da validação cruzada
list_score_decision_tree_classifier.append(np.mean(scores_decision_tree_classifier))

modelo_svc = SVC()
scores_svc = cross_val_score(modelo_svc, X_train_normalized, y_train, cv = Kfold) # cv é o número de dobras (folds) da validação cruzada
list_score_svc.append(np.mean(scores_svc))


print('RandomForesClassifier: ', round(np.mean(list_score_random_forest_classifier), 4))
print('LogisticRegression: ', round(np.mean(list_score_logistic_regression), 4))
print('DecisionTreeClassifier: ', round(np.mean(list_score_decision_tree_classifier), 4))
print('SVC: ', round(np.mean(list_score_svc), 4))

O modelo desenvolvido a partir do Random Forest apresentou a melhor pontuação entre os quatro modelos.

Para melhoramento do modelo, fiz análise de balanceamento.

Como se pode ver, a classe inadimplente é minoritária.

Para essa análise, importei alguns algoritmos de balanceamento.

# Importando bibliotecas de balanceamento
# Oversampling
from imblearn.over_sampling import SMOTE
from imblearn.over_sampling import ADASYN

# Undersampling
from imblearn.under_sampling import TomekLinks
from imblearn.under_sampling import RandomUnderSampler

# Combinado
from imblearn.combine import SMOTEENN
from imblearn.combine import SMOTETomek

Realizei o fit entre a base de treinamento e os balanceadores.

# Balanceamento com os algoritmos

smote = SMOTE(random_state = 101)
X_smote, y_smote = smote.fit_resample(X.values, y)

adasyn = ADASYN(random_state = 101)
X_adasyn, y_adasyn = adasyn.fit_resample(X.values, y)

tomek_links = TomekLinks()
X_tomek_links, y_tomek_links = tomek_links.fit_resample(X.values, y)

random_under_sampler = RandomUnderSampler(random_state = 101)
X_random_under_sampler, y_random_under_sampler = random_under_sampler.fit_resample(X.values, y)

smoteenn = SMOTEENN(random_state = 101)
X_smoteenn, y_smoteenn = smoteenn.fit_resample(X.values, y)

smotetomek = SMOTETomek(random_state = 101)
X_smotetomek, y_smotetomek = smotetomek.fit_resample(X.values, y)

Treinei o algoritmo Random Forest primeiramente com balanceamento oversampling.

list_score_random_forest_classifier_smote = []

for i in range(5):

Kfold = 5

X_train, X_test, y_train, y_test = train_test_split(X_smote, y_smote, test_size = 0.2, random_state = i)

# Criar uma instância do MinMaxScaler
scaler = MinMaxScaler()

# Ajustar a escala nos dados de treinamento e aplicar a normalização
X_train_normalized = scaler.fit_transform(X_train)

# Aplicar a mesma normalização nos dados de teste
X_test_normalized = scaler.transform(X_test)

modelo_random_forest_classifier_smote = RandomForestClassifier()
scores_random_forest_classifier_smote = cross_val_score(modelo_random_forest_classifier_smote, X_train_normalized, y_train, cv = Kfold, error_score='raise') # cv é o número de dobras (folds) da validação c
list_score_random_forest_classifier_smote.append(np.mean(scores_random_forest_classifier_smote))

print('RandomForesClassifier: ', round(np.mean(list_score_random_forest_classifier), 4))
print('RandomForesClassifier Smoted: ', round(np.mean(list_score_random_forest_classifier_smote), 4))

O modelo com balanceamento a partir do algorimo Smote superou o modelo desbalanceado.

Em seguida, treinei com o balanceamento undersampling.

list_score_random_forest_classifier_tomek_links = []

for i in range(5):

Kfold = 3

X_train, X_test, y_train, y_test = train_test_split(X_tomek_links, y_tomek_links, test_size = 0.2, random_state = i)

# Criar uma instância do MinMaxScaler
scaler = MinMaxScaler()

# Ajustar a escala nos dados de treinamento e aplicar a normalização
X_train_normalized = scaler.fit_transform(X_train)

# Aplicar a mesma normalização nos dados de teste
X_test_normalized = scaler.transform(X_test)

modelo_random_forest_classifier_tomek_links = RandomForestClassifier()
scores_random_forest_classifier_tomek_links = cross_val_score(modelo_random_forest_classifier_tomek_links, X_train_normalized, y_train, cv = Kfold, error_score='raise') # cv é o número de dobras (folds) da validação c
list_score_random_forest_classifier_tomek_links.append(np.mean(scores_random_forest_classifier_tomek_links))

print('RandomForesClassifier: ', round(np.mean(list_score_random_forest_classifier), 4))
print('RandomForesClassifier Smoted: ', round(np.mean(list_score_random_forest_classifier_smote), 4))
print('RandomForesClassifier Tomeked: ', round(np.mean(list_score_random_forest_classifier_tomek_links), 4))

Esse balanceamento reduziu um pouco a qualidade geral do modelo em relação ao modelo desbalanceado.

Ao fim, treinei com todos os algoritmos de balanceamento importados. Os resultados foram:

print('RandomForesClassifier: ', round(np.mean(list_score_random_forest_classifier), 4))
print('RandomForesClassifier Smoted: ', round(np.mean(list_score_random_forest_classifier_smote), 4))
print('RandomForesClassifier Tomeked: ', round(np.mean(list_score_random_forest_classifier_tomek_links), 4))
print('RandomForesClassifier Adasyn: ', round(np.mean(list_score_random_forest_classifier_adasyn), 4))
print('RandomForesClassifier Under Sampler: ', round(np.mean(list_score_random_forest_classifier_random_under_sampler), 4))

O melhor balanceamento foi o smote e o pior foi o Random Under Sampler.

Para validação do modelo final, importei algumas métricas.

# Importando métricas
from sklearn.metrics import accuracy_score, classification_report, roc_curve, roc_auc_score, auc, confusion_matrix

Realizei a separação em treinamento e teste.

# Separando dados
X = base_analitica[colunas_selecionadas].iloc[:16152,:]

X_tomek_links, y_tomek_links = TomekLinks().fit_resample(X.values, y)

X_train, X_test, y_train, y_test = train_test_split(X_tomek_links, y_tomek_links, test_size = 0.2, random_state = 101)

Após, treinei o modelo e realizei avaliação.

# Criando objetivo
modelo_RFC = RandomForestClassifier(random_state = 101)

# Treinando modelo
modelo_RFC.fit(X_train, y_train)

# Predição
#X_test_normalizar = scaler.transform(X_test)
y_pred = modelo_RFC.predict(X_test)

# Métrica
print(classification_report(y_pred, y_test))

Pensando em inadimplência, quero reduzir o falso negativo, isso é, afirmativa que alguém não irá inadimplir quando na verdade irá.

A métrica que trabalha com falso negativo em seu cálculo é o recall.

Analisei o modelo através da curva ROC.

# Predizendo com probabilidade
y_prob = modelo_RFC.predict_proba(X_test)[:, 1]

# Cálculo da área
roc_auc = roc_auc_score(y_test, y_prob)

fpr, tpr, thresholds = roc_curve(y_test, y_prob)

# Calcular a área sob a curva ROC (AUC-ROC)
roc_auc = roc_auc_score(y_test, y_prob)

# Gerar o gráfico da curva ROC
plt.figure(figsize=(8, 6))
plt.plot(fpr, tpr, color='darkorange', lw=2, label=f'ROC curve (area = {roc_auc:.2f})')
plt.plot([0, 1], [0, 1], color='navy', lw=2, linestyle='--')
plt.xlim([0.0, 1.0])
plt.ylim([0.0, 1.05])
plt.xlabel('Taxa de Falso Positivo')
plt.ylabel('Taxa de Verdadeiro Positivo')
plt.title('Curva ROC')
plt.legend(loc='lower right')
plt.show()

Vemos que a área do modelo é de 94%, isso é, o modelo desempenhou bom papel em treinamento.

5. Backtest

Além da validação com as bases de treinamento e teste, realizei a validação em backtest, mencionado anteriormente.

Temos os seguintes resultados:

6. Conclusão

Esse modelo foi selecionado para implantação. O mesmo mostrou ordenação da inadimplência pelas faixas de score, isso é, quanto maior o score, menor a inadimplência. Em outras palavras, o modelo discrimina bem bons e maus pagadores.

O modelo corrobora para a definição de que o perfil menos arriscado é aquele que possui a maior probabilidade de pagar suas obrigações.

Pela base utilizada, não trabalhei com safras, o que poderia validar se o modelo mantém o KS estável através de safras.

7. Modelo em produção

O modelo se encontram em produção. É possível testá-lo clicando aqui.

Você pode me encontrar no Linkedin, clicando aqui.

--

--

Bruno Carloto

Bem-vindo ao Deep Analytics, um blog que aborda de forma técnica o mundo Analytics | LinkedIn: www.linkedin.com/in/bruno-rodrigues-carloto