Modelo para Churn Prediction em Empresa de Telecomunicações
- Vinicius Goia
- 6 de set. de 2023
- 10 min de leitura
Atualizado: 13 de set. de 2023
Se você está com pressa!!!
O objetivo deste estudo é explorar dados de churn para geração de insights e criação de um modelo de previsão. Vários algoritmos foram utilizados para verificação de métricas além de técnicas de Feature Scaling, Balanceamento e Otimização de Hiperparâmetros. Ao final, o algoritmo XGBClassifier obteve uma das maiores pontuações e foi escolhido para tuning, possuindo um resultado satisfatório na geração do modelo de predição.
Contextualizando...
Churn nada mais é do que o número de desistência de clientes que já fazem parte de uma cartela de consumidores. Quando se fala em planos ou assinaturas, as grandes empresas envolvendo telefonia e televisão são as primeiras que vem em mente, porém, atualmente, com o advento da internet, diversos concorrentes de streaming disputam o mesmo terreno, o que faz do churn uma métrica essencial para marketing e retenção. Afinal, segundo Kim e Lee (2021), reter clientes chega a ser seis vezes mais barato que conseguir novos consumidores.
Segundo Sulikowski e Zdziebko (2021), as razões dos clientes desistirem de uma assinatura podem ser divididas em duas partes: compulsórias e voluntárias. O primeiro tipo é tipicamente causado por circunstâncias que fogem do controle da empresa, como problemas financeiros, relocações ou uma proposta mais atrativa feita por uma concorrente. Já com as razões voluntárias, a empresa tem total responsabilidade sobre elas. Os motivos incluem má qualidade dos serviços ofertados, não cumprimento de ações, falta de maleabilidade de acertos, etc.
A partir do momento que há a possibilidade de previsão de pessoas que deixarão de assinar algum serviço, as equipes de marketing podem entrar em ação e disparar novos anúncios e promoções para tentar reter esses clientes, diminuindo assim a taxa de churn.
Obs: As saídas dos comandos em Python referentes à textos e tabelas não serão exibidos. Apenas gráficos, vídeos e outras imagens serão mostradas.
Sobre os Dados
Os dados utilizados neste projeto foram originalmente disponibilizados na plataforma de ensino da IBM Developer, e tratam de um problema típico de uma companhia de telecomunicações. O dataset completo pode ser encontrado neste link.
Neste momento, iremos importar as bibliotecas necessárias e transformar o dataset em questão em um Dataframe, salvando-o em uma variável. Logo após, verificaremos as primeiras entradas e a dimensão de nossos dados.
# Importar as bibliotecas necessárias
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
# Configuração do estilo de plotagem
sns.set_style('dark')
# Leitura do arquivo .csv em um dataframe
df = pd.read_csv("https://raw.githubusercontent.com/carlosfab/dsnp2/master/datasets/WA_Fn-UseC_-Telco-Customer-Churn.csv")
# Visualização das primeiras entradas
df.head()
# Visualização do tamanho do dataset
print("O Dataset possui {} linhas e {} colunas.".format(df.shape[0], df.shape[1]))
Dicionário de Variáveis
Sendo assim, as variáveis disponíveis são:
customerID: sequência individual para identificação de cliente;
gender: gênero do cliente;
SeniorCitizen: clientes com 65 anos ou mais;
Partner: clientes com companheiros;
Dependents: clientes com dependentes;
tenure: posses de clientes;
PhoneService: serviços de telefone contratados;
MultipleLines: clientes com mais de uma linha;
InternetService: tipo de serviço de internet contratado;
OnlineSecurity: clientes com serviço segurança online contratado;
OnlineBackup: clientes com serviços de backup contratados;
DeviceProtection: clientes com serviços de proteção de aparelho contratado;
TechSupport: clientes com serviços de tecnologia contratado;
StreamingTV: clientes com serviços de streaming de TV contratado;
StreamingMovies: clientes com serviços de streaming de Filmes contratado;
Contract: tipo de contrato;
PaperlessBilling: clientes que utilizam serviços de e-mail para recebimento de contas, notícias, promoçoes;
PaymentMethod: método de pagamento;
MonthlyCharges: encargos mensais;
TotalCharges: total de encargos;
Churn: clientes que cancelaram a assinatura.
Análise Exploratória
O primeiro contato
Antes de começar qualquer análise, iremos utilizar alguns métodos para verificação de informações básicas do dataset, como valores médios, valores ausentes, e os tipos de variáveis presentes.
# Descrição geral do dataset
df.describe()
# Configurações para plotagem de boxplot
fig, ax = plt.subplots(nrows=3, ncols=1, figsize=(6,6))
sns.boxplot(df.SeniorCitizen,ax=ax[0],showmeans=True)
sns.boxplot(df.tenure,ax=ax[1], showmeans=True)
sns.boxplot(df.MonthlyCharges,ax=ax[2], showmeans=True)
plt.tight_layout()
plt.show()

Um dado importante que se observa acima é que, além do dataset não possuir outliers, apenas 3 atributos são considerados numéricos. Vamos verificar melhor essa informação com o método abaixo.
# Verificação dos tipos de variáveis presentes no dataset
df.info()
Confirma-se então que apenas 3 atributos são considerados numéricos e que não há nenhum valor ausente. Porém a coluna TotalCharges tem a mesma natureza da MonthlyCharges e está sendo considerada como string. Vamos converter essa coluna. Mas antes, criaremos uma cópia do dataset original.
# Cópia do dataset original
df_churn = df.copy()
# Tentativa de conversão da coluna para float
df_churn.TotalCharges = df.TotalCharges.astype(float)
Observa-se aqui que, apesar de não haver dados ausentes, temos informações preenchidas como se fossem dados ausentes. Conseguimos observar isso através do value_counts. Dessa maneira, iremos criar uma função para substituir essas informações por NAN e, depois, substituí-las pelo valor mediano da coluna.
# Verificação dos dados inseridos incorretamente
df_churn.TotalCharges.value_counts()
# Função para conversão de variável
def convert (entrada):
### Essa função tenta transformar as variáveis string em float e, caso não consiga devido aos valores ausentes, transforma ela em nan.###
try:
return float(entrada)
except ValueError:
return np.nan
# Aplicação da função
df_churn['TotalCharges'] = df_churn['TotalCharges'].apply(convert)
# Substituição dos valores nan pela mediana da coluna
df_churn['TotalCharges'].fillna(df_churn['TotalCharges'].median(),inplace=True)
#Verificação da transformação dos valores floats
df_churn.info()
# Descrição do dataset com a nova coluna transformada
df_churn.describe()
# Configurações para plotagem de boxplot
fig, ax = plt.subplots(nrows=2, ncols=1, figsize=(6,6))
sns.boxplot(df_churn.MonthlyCharges,ax=ax[0],showmeans=True)
sns.boxplot(df_churn.TotalCharges,ax=ax[1], showmeans=True)
plt.tight_layout()
plt.show()

Como temos poucas variáveis numéricas, vamos verificar a variação de valores de nosso dataset, evidenciando quais colunas são categóricas.
#Verificação de colunas categóricas
df_churn.nunique()
Observa-se que temos 17 colunas com características categóricas e variações de 2 a 4 elementos.
O atributo customerID possui um valor específico para cada cliente e essa informação, por enquanto, não agregará em nosso processo. Portanto, a coluna será excluída.
# Exclusão da coluna customerID
df_churn.drop('customerID', axis=1, inplace=True)
Vamos verificar agora se a nossa variável alvo encontra-se desbalanceada no dataset.
# Plot para verificação de balanceamento
sns.countplot(df_churn['Churn'])

Como observado, os dados estão desbalanceados, o que nos exigirá futuramente um procedimento para equalizar essa proporção.
Iremos agora realizar algumas explorações em cima dos clientes os quais o churn foi positivo. Para isso, criaremos um dataset específico apenas com essas seleções.
# Dataset apenas com clientes que desistiram das assinaturas
df_churn_positive = df_churn.loc[df_churn['Churn']=='Yes']
# Visualização das primeiras entradas
df_churn_positive.head()
print("o Dataset possui {} linhas e {} colunas.".format(df_churn_positive.shape[0],df_churn_positive.shape[1]))
# Configurações para plotagem de histograma para cada coluna do dataset
fig, ax = plt.subplots(nrows=5, ncols=4, figsize=(30,30))
cont_row=0
cont_col=0
for i in df_churn_positive.columns:
sns.countplot(df_churn_positive[i], ax=ax[cont_row][cont_col])
cont_col += 1
if cont_col== 3 and cont_row == 4:
break;
if cont_col == 4:
cont_col = 0
cont_row += 1
if cont_row == 5:
break;



Através desses Plots conseguimos extrair os seguintes insights:
A distribuição de gênero para churn está completamente balanceada, ou seja, praticamente o mesmo número de homens e mulheres desistem de planos;
A maioria do churn acontece em uma faixa etária abaixo dos 65 anos;
Quase o dobro das desistências acontece em pessoas solteiras e sem dependentes, comparadas a quem possui companheiros e dependentes;
A maioria das desistências acontece em pessoas que tem serviços contratados de telefonia e fibra óptica;
A maioria do churn acontece em clientes que não possuem serviços de segurança online, backup, proteção de aparelho e suporte técnico contratados;
As desistências são um pouco mais frequentes em pessoas que possuem serviços de streaming contratados;
A maioria dos cancelamentos acontece em contas pagas mensalmente por meios eletrônicos e recebidas online.
De maneira geral, percebe-se que a taxa de churn é mais presente em clientes mais jovens, com poucos serviços contratados. Presume-se que pessoas mais velhas com mais serviços contratados possuem certa fidelidade a empresa. Um ponto que podemos ressaltar também é que o uso de tecnologias está mais presente em gerações mais novas, o que facilita, de certa forma, a busca por outros planos mais compatíveis com o seu consumo.
Preparando o Terreno
Os algoritmos de Machine Learning, apesar de entenderem variáveis categóricas, precisam receber essa informação em forma númérica, o que otimiza e muito o processo. Nossa tabela possui várias colunas categóricas e com diferentes características, o que nos exigirá a aplicação de duas técnicas.
Feature Scaling com Label Encoder
O Label Encoder deriva da biblioteca ScikitLearn e basicamente assume valores para cada variável categórica. Iremos aplicar ele nas colunas onde as variações não ultrapassam de 2. Dessa maneira, não criamos hierarquia de valores, o que poderia interferir no nosso modelo.
# Importação de biblioteca necessária
from sklearn.preprocessing import LabelEncoder
# Instanciamento
LE = LabelEncoder()
# Seleção das colunas com 2 tipos de variáveis
col_LE = df_churn.nunique().index[df_churn.nunique()==2]
# Iteração nas colunas selecionadas
for i in col_LE:
df_churn[i] = LE.fit_transform(df_churn[i])
Feature Scaling com Get Dummies
O Get Dummies deriva da biblioteca Pandas e basicamente cria uma nova coluna com o nome da variável categórica e assume valores binários para afirmar a presença da característica. Dessa maneira, nossas variações serão 0 e 1, sem criação de hierarquia.
# Seleção das colunas com mais de 2 tipos de variáveis
col_du = df_churn.nunique().index[df_churn.nunique()<5].drop(col_LE)
# Aplicação do método no dataset
df_churn = pd.get_dummies(df_churn, columns=col_du)
# Visualização das primeiras entradas do dataset depois da aplicação dos métodos
df_churn.head()
Divisão dos dados de treino e validação
Como nossos modelos de Machine Learning possuirão características de sistemas supervisionados, iremos dividir os dados em conjunto de features e conjunto de target. Logo após, dividiremos esses dados em treino e teste.
# Importação de biblioteca necessária
from sklearn.model_selection import train_test_split
# Divisão de dados em Features e Target
X = df_churn.drop("Churn", axis=1)
y = df_churn["Churn"]
# Divisão dos dados de treino e teste
X_train, X_test, y_train, y_test = train_test_split(X, y)
Modelos de Machine Learning
Para esse estudo, iremos praticar um fluxo de trabalho diferente do que já vínhamos utilizando. Não iremos apenas injetar os nossos dados em um algoritmo e verificar sua eficácia. Iremos comparar diversos algoritmos através de uma baseline, escolher a melhor performance e realizar o tuning de hiperparâmetros para otimizar o resultado. Dessa maneira, garantimos o melhor modelo para aplicação.
# Importação de bibliotecas necessárias
from sklearn.pipeline import make_pipeline
from sklearn.model_selection import cross_val_score
from sklearn.preprocessing import StandardScaler
Primeiramente, criaremos uma função que iniciará uma pipeline onde aplicaremos uma padronização e o algoritmo escolhido, retornando a métrica de recall para análise. A segunda função é similar a primeira, porém sem a padronização. Isso porque aplicaremos uma padronização e um balanceamento posteriormente aos dados.
O objetivo da primeira função é apenas nos informar um valor base para nossas comparações.
# Função para criação de pipeline com padronização
def val_model1 (X, y, clf, quite=False):
X = np.array(X)
y = np.array(y)
model = make_pipeline(StandardScaler(),clf)
scores = cross_val_score (model, X, y, scoring='recall')
if quite == False:
print("Recall: {:.2f} (+/- {:.2f})".format(scores.mean(), scores.std()))
return scores.mean()
# Função para criação de pipeline sem padronização
def val_model2 (X, y, clf, quite=False):
X = np.array(X)
y = np.array(y)
model = make_pipeline(clf)
scores = cross_val_score (model, X, y, scoring='recall')
if quite == False:
print("Recall: {:.2f} (+/- {:.2f})".format(scores.mean(), scores.std()))
return scores.mean()
Com as funções definidas, aplicaremos o algoritmo Random Forest diretamente nos nossos dados de treino.
# Importação de biblioteca necessária
from sklearn.ensemble import RandomForestClassifier
# Instanciamento e aplicação de algoritmo através de função
rf = RandomForestClassifier()
score_baseline = val_model1(X_train, y_train, rf)
Assim, obtemos um Recall de 0,48. Essa será nossa base de comparação para outros algoritmos. Lembrando que os dados utilizados também não foram balanceados, procedimento que faremos a seguir.
# Padronização dos dados
scaler = StandardScaler().fit(X_train)
X_train = scaler.transform(X_train)
# Importação de bibliotecas necessárias
from imblearn.under_sampling import RandomUnderSampler
# Usar técnica under-sampling para balanceamento
rus = RandomUnderSampler()
X_rus, y_rus = rus.fit_resample(X_train, y_train)
# Ver o balanceamento das classes
print(pd.Series(y_rus).value_counts())
# Plotar a nova distribuição de classes
fig, ax = plt.subplots()
sns.countplot(y_rus, ax=ax)
ax.set_title ("Distribuição das Classes")
plt.show()

Com dos dados balanceados e padronizados, iremos utilizar a segunda função para aplicação dos seguintes algoritmos:
Random Forest
Decision Tree
Stochastic Gradient Descent
SVC
Regressão Logística
LightGBM
# Importação dos pacotes necessários
from sklearn.svm import SVC
from lightgbm import LGBMClassifier
from sklearn.linear_model import SGDClassifier
from sklearn.tree import DecisionTreeClassifier
from sklearn.linear_model import LogisticRegression
from xgboost import XGBClassifier
# Instanciamento de modelos
dt = DecisionTreeClassifier()
sgdc = SGDClassifier()
svc = SVC()
lr = LogisticRegression()
xgb = XGBClassifier()
lgbm = LGBMClassifier()
model = []
recall = []
# Avaliação de desempenho (recall)
for clf in (rf, dt, sgdc, svc, lr, xgb, lgbm):
model.append(clf.__class__.__name__)
recall.append(val_model2(X_rus, y_rus, clf, quite=True))
# Montagem de tabela comparativa
pd.DataFrame(data=recall, index=model, columns=['Recall'])

Observa-se claramente o impacto do balanceamento da coluna Target na performance do algoritmo. Para o Random Forest, tivemos um salto para 0,75 de Recall. Porém, na comparação geral, a Regressão Logística saiu na frente, contudo, iremos utilizar o XGBClassifier para o tuning pois possui uma gama maior de parâmetros para testes.
# Importação de bibliotecas necessárias
from sklearn.model_selection import StratifiedKFold
from sklearn.model_selection import GridSearchCV
Iremos encontrar os melhores parâmetros através do GridSearchCV. Para início, testaremos algums valores de n_estimators. Encontrando esse valor, testaremos valores para max_depth, min_child_weight, gamma e learning_rate, sempre incluindo-os nas novas buscas.
# Melhor parâmetro para n_estimator
# Instanciamento de modelo
xgb = XGBClassifier(learning_rate=0.1)
# Parâmetros para teste
param_grid = {
'n_estimators':range(0,1000,50),
}
# Identificação de melhor parâmetro
kfold = StratifiedKFold(n_splits=10, shuffle=True)
grid_search = GridSearchCV(xgb, param_grid, scoring="recall", n_jobs=-1, cv=kfold)
grid_result = grid_search.fit(X_rus, y_rus)
# Impressão de resultados
print("Melhor: {} para {}".format(grid_result.best_score_, grid_result.best_params_))
# Melhor parâmetro para max_depth e min_child_weight
# Instanciamento de modelo
xgb = XGBClassifier(learning_rate=0.1, n_estimators=50)
# Parâmetros para teste
param_grid = {
'max_depth':range(1,8,1),
'min_child_weight':range(1,5,1)
}
# Identificação de melhor parâmetro
kfold = StratifiedKFold(n_splits=10, shuffle=True)
grid_search = GridSearchCV(xgb, param_grid, scoring="recall", n_jobs=-1, cv=kfold)
grid_result = grid_search.fit(X_rus, y_rus)
# Impressão de resultados
print("Melhor: {} para {}".format(grid_result.best_score_, grid_result.best_params_))
# Melhor parâmetro para gamma
# Instanciamento de modelo
xgb = XGBClassifier(learning_rate=0.1, n_estimators=50, max_depth=1, min_child_weight=1)
# Parâmetros para teste
param_grid = {
'gamma':[i/10.0 for i in range(0,5)]
}
# Identificação de melhor parâmetro
kfold = StratifiedKFold(n_splits=10, shuffle=True)
grid_search = GridSearchCV(xgb, param_grid, scoring="recall", n_jobs=-1, cv=kfold)
grid_result = grid_search.fit(X_rus, y_rus)
# Impressão de resultados
print("Melhor: {} para {}".format(grid_result.best_score_, grid_result.best_params_))
# Melhor parâmetro para learning_rate
# Instanciamento de modelo
xgb = XGBClassifier(n_estimators=50, max_depth=1, min_child_weight=1, gamma=0.0)
#Parâmetros para teste
param_grid = {
'learning_rate':[0.001, 0.01, 0.1, 1]
}
# Identificação de melhor parâmetro
kfold = StratifiedKFold(n_splits=10, shuffle=True)
grid_search = GridSearchCV(xgb, param_grid, scoring="recall", n_jobs=-1, cv=kfold)
grid_result = grid_search.fit(X_rus, y_rus)
# Impressão de resultados
print("Melhor: {} para {}".format(grid_result.best_score_, grid_result.best_params_))
Portanto, nossos melhores parâmetros foram:
learning_rate: 0.001
n_estimator: 50
max_depth: 1
min_child_weight: 1
gamma: 0.0
Finalmente, aplicaremos nosso modelo otimizado em dados de teste e verificaremos sua performance.
# Instalação de bibliotecas necessárias
pip install scikit-plot
from sklearn.metrics import roc_auc_score, accuracy_score, f1_score, confusion_matrix, classification_report
from scikitplot.metrics import plot_confusion_matrix, plot_roc
# Modelo Final
xgb = XGBClassifier(learning_rate=0.001 , n_estimators=50, max_depth=1, min_child_weight=1, gamma=0.0)
xgb.fit(X_rus, y_rus)
# Realização das previsões
X_test = scaler.transform(X_test)
y_pred = xgb.predict(X_test)
# Classification Report
print(classification_report(y_test, y_pred))
# Impressão da área sob a curva
print("AUC: {:.4f}\n".format(roc_auc_score(y_test, y_pred)))
# Plot matriz de confusão
plot_confusion_matrix(y_test, y_pred, normalize=True)
plt.show()

Conclusão
Nesse estudo ficou evidente a importância de métricas para tomada de decisão. Na seção Análise Exploratória, observamos que entender perfis de usuários que tendem a desistir de alguma assinatura pode gerar movimentações mais acertivas da empresa para a retenção desse cliente, uma vez que essas ações são mais baratas se comparadas as novas pessoas. Também entendemos a importância do Feature Scaling e do Balanceamento no uso de algoritmos, bem como possuir uma métrica base para comparação.
Cada algoritmo performa de uma maneira diferente e compará-los para a escolha do melhor é uma boa prática para a otimização de sua aplicação. Utilizar ferramentas automatizadas que nos indicam os melhores parâmetros também nos ajudam a tirar o máximo de cada modelo.
Referências
Kim, S., & Lee, H. (2021). Customer Churn Prediction in Influencer Commerce: An Application of Decision Trees. Procedia Computer Science, 199, 1332–1339. https://doi.org/10.1016/j.procs.2022.01.169
Sulikowski, P., & Zdziebko, T. (2021). Churn factors identification from real-world data in the telecommunications industry: Case study. Procedia Computer Science, 192, 4800–4809. https://doi.org/10.1016/j.procs.2021.09.258
Comments