top of page

Modelo para Churn Prediction em Empresa de Telecomunicações

  • Foto do escritor: Vinicius Goia
    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


Comments


©2023 by Natural Engines. Flowing Knowledge.

bottom of page