Gabriel Rocha
Projetos

Análise de Churn de Clientes

Estudo de churn de clientes de um banco, da análise exploratória à modelagem e a um preditor interativo de evasão.

2024Repositório

Estudo de churn de clientes de um banco, feito em dupla com meu amigo Juan Braz sobre a base pública “Bank Customer Churn” do Kaggle (10.000 clientes e 18 colunas). O trabalho virou uma série de três artigos no Medium, em inglês: eu fiquei com a análise exploratória (Parte 1, no meu perfil skicklel) e o Juan com a modelagem e validação (Parte 2) e com o preditor interativo (Parte 3). O objetivo era entender quais características separam quem sai de quem fica e, na sequência, prever a evasão.

O que foi investigado

Na parte exploratória eu comparei cada variável com o alvo Exited (se o cliente saiu ou ficou). A base não tinha valores nulos nem duplicados, então a limpeza foi conferir tipos e partir para as visualizações.

Os padrões mais claros:

Na matriz de correlação, as variáveis com correlação mais forte com Exited foram Age, Balance e IsActiveMember. Essa matriz usou só colunas numéricas, por isso o Complain, que é o grande vazamento da base, não apareceu aí.

Pra ler cada categoria, eu plotava a contagem dividida por quem saiu e quem ficou e anotava o percentual de cada grupo sobre a barra:

for coluna in ['Card Type', 'HasCrCard']:
    plt.figure(figsize=(10, 6))
    ax = sns.countplot(x=df_bank[coluna], hue=df_bank['Exited'],
                       palette="viridis", dodge=True)
    for container in ax.containers:
        total = sum(p.get_height() for p in container)
        for p in container:
            height = p.get_height()
            percentage = f'{100 * height / total:.1f}%' if total > 0 else '0.0%'
            ax.annotate(percentage, (p.get_x() + p.get_width() / 2., height),
                        ha='center', va='bottom', fontsize=10, color='black')
    plt.title(f'Distribuição de Churn por {coluna}')

Modelagem e validação

Na Parte 2 o Juan seguiu com o pipeline clássico: one-hot encoding nas categóricas, StandardScaler nas numéricas, split estratificado de 80/20 e três modelos (Regressão Logística, Random Forest e XGBoost), comparados por acurácia, AUC, precisão, recall e F1.

O split e o encoding do notebook:

X = df_bank.drop('Exited', axis=1)   # features
y = df_bank['Exited']                # alvo

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

X_train_encoded = pd.get_dummies(X_train)
X_test_encoded = pd.get_dummies(X_test)
X_train_encoded, X_test_encoded = X_train_encoded.align(
    X_test_encoded, join='left', axis=1, fill_value=0)

Os três modelos ficaram com acurácia acima de 99,7% no teste, AUC entre 0,997 e 0,9996 e F1 acima de 0,99, e a validação cruzada de 5 folds repetiu o padrão (sempre perto de 0,998). Numa base de churn com só ~20% de saída, um resultado tão perfeito deveria ter sido o primeiro sinal de que havia algo errado nos dados, mas na época tratamos como dado “bem preparado” e até concluímos que não havia vazamento. A Parte 3 fecha com um script que coleta os dados de um cliente via input() e devolve a probabilidade de saída de cada modelo.

Particularidades e pontos que revisaria

  1. Vazamento de informação embutido na base. As colunas Complain e Satisfaction Score são registradas depois da reclamação e do atendimento, então não existem antes do cliente decidir sair, e a primeira é praticamente o gabarito do alvo: Complain tem média 0,2044 enquanto Exited tem 0,2038, quase a mesma contagem. Com isso os modelos “aprendem” a responder pela coluna vazada e batem ~99,8%. Hoje eu revisitaria a conclusão dos artigos: era o próprio dado do Kaggle vazando, não mérito do pipeline.

  2. Preditor interativo herda o vazamento. A ferramenta da Parte 3 pede Complain, Satisfaction Score e Points Earned como entrada. Num cenário real essas informações só existem depois do evento que se quer prever, então o “preditor em tempo real” replica o mesmo problema da base.

  3. Dois pipelines de pré-processamento no notebook. Uma célula monta um df_encoded (one-hot com drop_first=True + StandardScaler) e anuncia que os dados estão prontos para modelagem, mas a modelagem logo depois ignora esse dataframe, refaz o encoding já sobre X_train/X_test com align. Sobrou código morto e duas formas diferentes de preparar os mesmos dados.

  4. Colunas de identificação entram como features. O X é montado só com df_bank.drop('Exited'), então RowNumber, CustomerId e Surname seguem no modelo. Pior: Surname é object, e o pd.get_dummies(X_train) sem filtrar transforma isso em milhares de colunas dummy inúteis.

  5. “Validação externa” sobre dados sintéticos aleatórios. A base externa foi gerada com np.random.choice, sorteando Exited e Complain de forma independente; numa base assim as features não têm relação com o alvo, então reportar AUC perto de 1 nela é implausível. Essa etapa eu revalidaria, porque o resultado não confere com o que uma base puramente aleatória deveria produzir.

  6. Repositório pesado e sem .gitignore. O CSV de 837 KB e o main.ipynb com todos os outputs e imagens em base64 (uns 1,4 MB) foram commitados; um notebook limpo ficaria bem menor e mais fácil de revisar.

Pontos de melhoria