Telecom Customer Churn

Éverton Bin
Julho, 2020

1- Introdução

É cada vez mais comum encontrarmos empresas que se baseiam no modelo de negócio chamado de Saas ou Software as a Service que, incialmente, era o termo usado para definir, por exemplo, programas que eram vendidos no formato de licensa temporária, mas que, com o advento de serviços de assinatura nos mais diversos segmentos, passou a ser utilizado num sentido mais amplo. Como se trata de uma espécia de aluguel de um produto ou serviço, a saúde da empresa estará diretamente ligada ao chamado Customer Churn, conceito este que poderia ser explicado como sendo a taxa de cancelamento por parte dos clientes associada a um determinado período de tempo.

Neste estudo, vamos utilizar o dataset referente a uma empresa do ramo de Telecom fornecido pela Data Science Academy dentro do programa "Formação Cientista de Dados", como sendo um dos projetos propostos para serem desenvolvidos pelos alunos da plataforma ao longo da formação. O objetivo é criar um modelo de aprendizado de máquina capaz de prever ser um cliente pode ou não cancelar o seu plano e qual é a probabilidade relativa a ambas as possibilidades.

O conjunto de dados fornecido apresenta 20 variáveis, sendo elas:

  • state: estado de residência do cliente;
  • account_length: duração da conta;
  • area_code: código de área;
  • international_plan: se o cliente possui ou não um plano internacional;
  • voice_mail_plan: se o cliente possui ou não um plano de "voice mail";
  • number_vmail_messages: número de mensagens do tipo "voice mail";
  • total_day_minutes: minutos totais em horário comercial;
  • total_day_calls: total de chamadas em horário comercial;
  • total_day_charge: gasto total em horário comercial;
  • total_eve_minutes: minutos totais pós horário comercial;
  • total_eve_calls: total de chamadas pós horário comercial;
  • total_eve_charge: gasto total pós horário comercial;
  • total_night_minutes: minutos totais durante a noite;
  • total_night_calls: total de chamadas durante a noite;
  • total_night_charge: gasto total durante a noite;
  • total_intl_minutes: minutos totais de chamadas internacionais;
  • total_intl_calls: total de chamadas internacionais;
  • total_intl_charge: gasto total com chamadas internacionais;
  • number_customer_service_calls: número de ligações para serviço de atendimento ao cliente;
  • churn: se o cliente cancelou ou não o seu plano.
  • Baseado nos dados fornecidos, também tentaremos responder a alguns questionamentos que podem nos trazer insights interessantes sobre a situação dos clientes desta empresa de Telecom:

  • Quais são os estados com maior número de cancelamentos?
  • Quais são os estados com mais clientes fiéis?
  • Qual é a diferença de gasto médio total entre clientes que cancelaram e os que não cancelaram seus planos?
  • Proporcionalmente, clientes que cancelam ligam mais vezes para os serviços de atendimento do que os demais clientes?
  • 2- Carregando os Dados

    In [1]:
    # Pacotes utilizados:
    import pandas as pd
    import matplotlib.pyplot as plt
    import seaborn as sns
    %matplotlib inline
    
    import warnings
    warnings.filterwarnings('ignore')
    
    from sklearn.preprocessing import MinMaxScaler
    from imblearn.over_sampling import SMOTE
    
    from sklearn.model_selection import train_test_split
    from sklearn.metrics import accuracy_score, confusion_matrix, plot_confusion_matrix
    from sklearn.linear_model import LogisticRegression
    from xgboost import XGBClassifier
    from sklearn.svm import SVC
    from sklearn.model_selection import GridSearchCV
    
    In [2]:
    # Leitura do arquivo:
    cust_churn = pd.read_csv('projeto4_telecom_treino.csv')
    
    In [3]:
    cust_churn.head()
    
    Out[3]:
    Unnamed: 0 state account_length area_code international_plan voice_mail_plan number_vmail_messages total_day_minutes total_day_calls total_day_charge ... total_eve_calls total_eve_charge total_night_minutes total_night_calls total_night_charge total_intl_minutes total_intl_calls total_intl_charge number_customer_service_calls churn
    0 1 KS 128 area_code_415 no yes 25 265.1 110 45.07 ... 99 16.78 244.7 91 11.01 10.0 3 2.70 1 no
    1 2 OH 107 area_code_415 no yes 26 161.6 123 27.47 ... 103 16.62 254.4 103 11.45 13.7 3 3.70 1 no
    2 3 NJ 137 area_code_415 no no 0 243.4 114 41.38 ... 110 10.30 162.6 104 7.32 12.2 5 3.29 0 no
    3 4 OH 84 area_code_408 yes no 0 299.4 71 50.90 ... 88 5.26 196.9 89 8.86 6.6 7 1.78 2 no
    4 5 OK 75 area_code_415 yes no 0 166.7 113 28.34 ... 122 12.61 186.9 121 8.41 10.1 3 2.73 3 no

    5 rows × 21 columns

    In [4]:
    cust_churn.dtypes
    
    Out[4]:
    Unnamed: 0                         int64
    state                             object
    account_length                     int64
    area_code                         object
    international_plan                object
    voice_mail_plan                   object
    number_vmail_messages              int64
    total_day_minutes                float64
    total_day_calls                    int64
    total_day_charge                 float64
    total_eve_minutes                float64
    total_eve_calls                    int64
    total_eve_charge                 float64
    total_night_minutes              float64
    total_night_calls                  int64
    total_night_charge               float64
    total_intl_minutes               float64
    total_intl_calls                   int64
    total_intl_charge                float64
    number_customer_service_calls      int64
    churn                             object
    dtype: object

    Vamos excluir a primeira variável (sem nome), pois ela não será necessária:

    In [5]:
    # Excluindo a variável "unnamed":
    cust_churn = cust_churn.drop(cust_churn.iloc[:, [0]], axis = 1)
    

    3- Análise Exploratória

    Com os dados carregados, daremos início ao processo de análise exploratória com intuito de extrair informações do conjunto de dados fornecidos. Vamos focar em responder as questões apresentadas na introdução deste estudo.

    Antes disso, faremos um resumo estatístico das variáveis para termos uma ideia da variação destes atributos:

    In [6]:
    cust_churn.iloc[:, 0:12].describe()
    
    Out[6]:
    account_length number_vmail_messages total_day_minutes total_day_calls total_day_charge total_eve_minutes total_eve_calls total_eve_charge
    count 3333.000000 3333.000000 3333.000000 3333.000000 3333.000000 3333.000000 3333.000000 3333.000000
    mean 101.064806 8.099010 179.775098 100.435644 30.562307 200.980348 100.114311 17.083540
    std 39.822106 13.688365 54.467389 20.069084 9.259435 50.713844 19.922625 4.310668
    min 1.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000
    25% 74.000000 0.000000 143.700000 87.000000 24.430000 166.600000 87.000000 14.160000
    50% 101.000000 0.000000 179.400000 101.000000 30.500000 201.400000 100.000000 17.120000
    75% 127.000000 20.000000 216.400000 114.000000 36.790000 235.300000 114.000000 20.000000
    max 243.000000 51.000000 350.800000 165.000000 59.640000 363.700000 170.000000 30.910000
    In [7]:
    cust_churn.iloc[:, 12:].describe()
    
    Out[7]:
    total_night_minutes total_night_calls total_night_charge total_intl_minutes total_intl_calls total_intl_charge number_customer_service_calls
    count 3333.000000 3333.000000 3333.000000 3333.000000 3333.000000 3333.000000 3333.000000
    mean 200.872037 100.107711 9.039325 10.237294 4.479448 2.764581 1.562856
    std 50.573847 19.568609 2.275873 2.791840 2.461214 0.753773 1.315491
    min 23.200000 33.000000 1.040000 0.000000 0.000000 0.000000 0.000000
    25% 167.000000 87.000000 7.520000 8.500000 3.000000 2.300000 1.000000
    50% 201.200000 100.000000 9.050000 10.300000 4.000000 2.780000 1.000000
    75% 235.300000 113.000000 10.590000 12.100000 6.000000 3.270000 2.000000
    max 395.000000 175.000000 17.770000 20.000000 20.000000 5.400000 9.000000

    Vamos criar subsets, dividindo o conjunto de dados original entre as observações de cancelamento e não cancelamento para, então, criarmos um gráfico de contagem do número de ocorrências de cancelamento ou não cancelamento por estado:

    In [8]:
    # Criando o subset de observações de cancelamento:
    churn_y = cust_churn[cust_churn['churn'] == 'yes']
    
    # Criando o gráfico de contagem de cancelamentos por estado:
    fig, ax = plt.subplots(nrows = 1, ncols = 1, figsize = (16, 6))
    plt.yticks([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20])
    sns.countplot(x = 'state', order = churn_y['state'].value_counts().index, data = churn_y, palette = 'Reds_r').set(
        xlabel = 'Estado',
        ylabel = 'Número de Cancelamentos',
        title = 'Contagem de Cancelamentos por Estado')
    fig.show()
    

    Podemos observar que, em números absolutos, os estados do Texas e New Jersey empatam com o maior número de ocorrências de cancelamento neste conjunto de dados. Maryland, Michigan e, empatados, Minnesota e New York completam o ranking de estados com maior número absoluto de clientes que cancelaram seus planos com a empresa.

    In [9]:
    # Criando o subset de observações de não cancelamento:
    churn_n = cust_churn[cust_churn['churn'] == 'no']
    
    # Criando o gráfico de contagem de não cancelamentos por estado:
    fig, ax = plt.subplots(nrows = 1, ncols = 1, figsize = (16, 6))
    plt.yticks([5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70, 75, 80, 85, 90, 95, 100])
    sns.countplot(x = 'state', order = churn_n['state'].value_counts().index, data = churn_n, palette = 'Blues_r').set(
        xlabel = 'Estado',
        ylabel = 'Número de Clientes Mantidos',
        title = 'Contagem de Clientes Mantidos por Estado')
    fig.show()
    

    Entre os clientes que foram mantidos no período de abrangência do conjunto de dados, o estado de West Virginia se destaca muito em relação aos demais estados. O ranking positivo se completa com os estados de Virginia, Alabama, Wisconsin e Minnesota, sendo que estes estados apresentam números muito semelhantes.

    Agora vamos avaliar a relação do gasto dos clientes com o cancelamento da conta:

    In [10]:
    # Selecionando as colunas que indicam tempo de contrato, gasto do cliente e se o cancelamento ocorreu:
    cust_charge = cust_churn[['account_length', 'total_day_charge', 'total_eve_charge', 'total_night_charge', \
                              'total_intl_charge', 'churn']]
    
    # Transformando as variáveis para obtermos o gasto médio diário dos clientes:
    cust_charge['avg_day_charge'] = cust_charge['total_day_charge'] / cust_charge['account_length']
    cust_charge['avg_eve_charge'] = cust_charge['total_eve_charge'] / cust_charge['account_length']
    cust_charge['avg_night_charge'] = cust_charge['total_night_charge'] / cust_charge['account_length']
    cust_charge['avg_intl_charge'] = cust_charge['total_intl_charge'] / cust_charge['account_length']
    
    # Selecionando as novas variáveis e agrupando-as pelo "churn" e calculando os gastos médios dos clientes:
    cust_charge_mean = cust_charge[['churn', 'avg_day_charge', 'avg_eve_charge', 'avg_night_charge', \
                                    'avg_intl_charge']].groupby(['churn'], as_index = False).mean()
    cust_charge_mean['avg_total'] = cust_charge_mean['avg_day_charge'] + cust_charge_mean['avg_eve_charge'] + \
                                    cust_charge_mean['avg_night_charge'] + cust_charge_mean['avg_intl_charge']
    
    # Visualizando o dataset tranformado:
    cust_charge_mean
    
    Out[10]:
    churn avg_day_charge avg_eve_charge avg_night_charge avg_intl_charge avg_total
    0 no 0.470234 0.271583 0.142873 0.042243 0.926933
    1 yes 0.517043 0.255093 0.136683 0.043310 0.952130

    Para melhor visualização, vamos criar gráficos que mostram o gasto médio diário discriminado por clientes que cancelaram e que não cancelaram suas contas com a empresa. Vamos visualizar o gasto por horário do dia bem como o gasto diário total:

    In [11]:
    # Criando o gráfico:
    fig, ax = plt.subplots(nrows = 1, ncols = 2, figsize = (15, 5))
    
    sns.barplot(x = 'churn', y = 'avg_day_charge', data = cust_charge_mean, ci = None,
                palette = 'autumn_r', ax = ax[0]).set_title('Gasto Médio Horário Comercial')
    ax[0].set_xlabel('cancelamento')
    ax[0].set_ylabel('gasto médio diário')
    
    sns.barplot(x = 'churn', y = 'avg_eve_charge', data = cust_charge_mean, ci = None,
                palette = 'autumn_r', ax = ax[1]).set_title('Gasto Médio Pós-Horário Comercial')
    ax[1].set_xlabel('cancelamento')
    ax[1].set_ylabel('gasto médio diário')
    
    fig.show()
    
    In [12]:
    # Criando o gráfico:
    fig, ax = plt.subplots(nrows = 1, ncols = 2, figsize = (15, 5))
    
    sns.barplot(x = 'churn', y = 'avg_night_charge', data = cust_charge_mean, ci = None,
                palette = 'autumn_r', ax = ax[0]).set_title('Gasto Médio Noturno')
    ax[0].set_xlabel('cancelamento')
    ax[0].set_ylabel('gasto médio diário')
    
    sns.barplot(x = 'churn', y = 'avg_intl_charge', data = cust_charge_mean, ci = None,
                palette = 'autumn_r', ax = ax[1]).set_title('Gasto Médio Internacional')
    ax[1].set_xlabel('cancelamento')
    ax[1].set_ylabel('gasto médio diário')
    
    fig.show()
    
    In [13]:
    # Criando o gráfico:
    fig, ax = plt.subplots(nrows = 1, ncols = 1, figsize = (7, 5))
    
    sns.barplot(x = 'churn', y = 'avg_total', data = cust_charge_mean, ci = None,
                palette = 'autumn_r').set_title('Gasto Médio Total')
    ax.set_xlabel('cancelamento')
    ax.set_ylabel('gasto médio diário')
    
    fig.show()
    

    Podemos observar que os clientes que cancelaram têm um gasto médio mais elevado em horário comercial e com ligações internacionais. O gasto médio diário é levemente menor nos demais horários. Somando os gastos relativos a todos os períodos, pode-se observar que os clientes que solicitaram o cancelamento dos serviços têm um gasto médio maior que os clientes que permaneceram.

    Embora não se possa afirmar que existe uma relação de causalidade entre gasto médio e cancelamento, é possível ter uma ideia do perfil dos clientes com potencial para cancelar a relação com a empresa, sendo que este perfil tem a tendência de ser caracterizado pelo maior gasto com ligações em horário comercial e com ligações internacionais.

    O próximo passo é analisar as ligações para o SAC - Serviço de Atendimento ao Consumidor:

    In [14]:
    # Selecionando as colunas que indicam se o cancelamento ocorreu, o tempo de contrato, e o número de ligações ao SAC:
    cust_serv_calls = cust_churn[['churn', 'account_length', 'number_customer_service_calls']]
    
    # Criando uma variável que indica o número de ligação para um período de 100 dias:
    cust_serv_calls['calls_per_100_days'] = (cust_serv_calls['number_customer_service_calls'] / \
                                             cust_serv_calls['account_length'])*100
    
    # Agrupando o subset pelo "churn" e retornando a média geral de ligações ao SAC e a média de ligações ao SAC por 100 dias:
    cust_serv_calls = cust_serv_calls[['churn', 'number_customer_service_calls', \
                                       'calls_per_100_days']].groupby(['churn'], as_index = False).mean()
    
    # Arredondando valores:
    cust_serv_calls['number_customer_service_calls'] = cust_serv_calls['number_customer_service_calls'].round(decimals = 2)
    cust_serv_calls['calls_per_100_days'] = cust_serv_calls['calls_per_100_days'].round(decimals = 2)
    
    # Visualizando o subset transformando:
    cust_serv_calls
    
    Out[14]:
    churn number_customer_service_calls calls_per_100_days
    0 no 1.45 2.21
    1 yes 2.23 3.86
    In [15]:
    # Criando um gráfico para diferença entre o número de ligações para clientes que cancelaram e não cancelaram seus planos:
    fig, ax = plt.subplots(nrows = 1, ncols = 2, figsize = (15, 5))
    
    sns.barplot(x = 'churn', y = 'number_customer_service_calls', data = cust_serv_calls, ci = None,
                palette = 'autumn_r', ax = ax[0]).set_title('Número Médio de Ligações para o SAC')
    ax[0].set_xlabel('cancelamento')
    ax[0].set_ylabel('nº médio de ligações')
    
    sns.barplot(x = 'churn', y = 'calls_per_100_days', data = cust_serv_calls, ci = None,
                palette = 'autumn_r', ax = ax[1]).set_title('Número Médio de Ligações para o SAC por 100 Dias')
    ax[1].set_xlabel('cancelamento')
    ax[1].set_ylabel('nº médio de ligações a cada 100 dias')
    
    fig.show()
    

    Através desta análise, fica claro que os clientes que cancelaram seus planos ligaram mais vezes para o Serviço de Atendimento ao Consumidor em comparação com os clientes que não fizeram o cancelamento. Na comparação de número de ligações ao SAC por 100 dias, pode-se observar que a proporção de ligações praticamente dobra para o caso dos clientes que efetuaram o cancelamento.

    Este fato pode dar um indicativo à empresa de Telecom que o Serviço de Atendimento ao Consumidor pode ser peça chave para evitar que um cliente, de fato, efetue o cancelamento do seu plano, seja melhorando este serviço de atendimento ou fazendo uso deste contato para propor vantagens que possam deixar o cliente mais satisfeito.

    4- Pré-Processamento de Dados

    A partir deste momento, vamos aplicar transformações nos dados com o intuito de melhorar a sua qualidade para que possamos entregar dados mais limpos e expressivos para o algoritmo de machine learning. A ideia é avaliar como os dados se relacionam entre si e com a variável alvo, para que possamos selecionar atributos mais significativos.

    Como o conjunto de dados apresenta clientes que permaneceram por períodos distintos, a primeira transformação a ser feita será a "equalização" dos dados, transformando as variáveis que indicam valores totais (ao longo de todo período) para variáveis que indicam valores unitários (por dia). Assim teremos um ponto de partida comum:

    4.1- Equalização de Variáveis

    In [16]:
    # Criano uma função para equalizar os dados do dataset:
    def equaliza_data(df):
        '''Equaliza as variáveis numéricas e retorna o dataset transformado.
        
        Args:
        df: dataset a ser transformado.
        '''
        # Criando uma lista das colunas que não serão transformadas:
        no_equaliz = ['state', 'account_length', 'area_code', 'international_plan', 'voice_mail_plan', 'churn']
    
        # Criano um loop for para transformar as colunas de interesse:
        for i in range(0, len(df.columns)):
            if df.columns[i] not in no_equaliz:
                df[df.columns[i]] = df[df.columns[i]] / df['account_length']
    
        return df
    
    In [17]:
    # Aplicando a função ao dataset:
    cust_churn_trnsf = equaliza_data(cust_churn)
    

    4.2- Exclusão de Variáveis

    Como a informação da variável account_length foi incorporada indiretamente às demais variáveis no item anterior, vamos excluir a variável do conjunto de dados. Da mesma forma, por termos um grande números de estados e códigos de área (classes) em relação ao número de observações, resolvemos excluir também as variáveis state e area_code:

    In [18]:
    cust_churn_trnsf = cust_churn_trnsf.drop(['account_length', 'state', 'area_code'], axis = 1)
    

    4.3- Tratamento de Variáveis do Tipo Classe

    Visto que as variáveis international_plan e voice_mail_plan indicam se o cliente contratou ou não um determinado plano oferecido pela empresa, vamos transformá-las em variáveis numéricas, utilizando 0 para indicar que o cliente não possui o plano e 1 para indicar que o cliente adquiriu o plano oferecido.

    Vamos utilizar esta mesma lógica para tratar a variável churn, ou seja, 0 indicará que o cliente não cancelou, enquanto 1 indicará que o cancelamento foi efetivado.

    In [19]:
    # Selecionando as colunas que serão transformadas:
    classes = ['international_plan', 'voice_mail_plan', 'churn']
    
    # Aplicando a transformação às colunas:
    for column in classes:
        cust_churn_trnsf[column] = cust_churn_trnsf[column].apply(lambda x: 0 if x == "no" else 1)
    

    4.4- Normalização dos Dados

    Ainda que nem todos os algoritmos de aprendizado de máquina necessitem receber os dados normalizados, vamos aplicar o processo de normalização para deixar os dados padronizados em uma mesma escala e poder apresentar os mesmos dados a diferentes algoritmos:

    In [20]:
    # Criando o objeto scaler:
    scaler = MinMaxScaler(feature_range = (0,1))
    
    # Aplicando a padronização aos dados:
    cust_churn_padrao = scaler.fit_transform(cust_churn_trnsf)
    
    # Transformando o array criado para dataframe:
    cust_churn_padrao = pd.DataFrame(cust_churn_padrao, columns = cust_churn_trnsf.columns)
    

    4.5- Feature Selection - Seleção de Variáveis

    Nesta etapa, vamos avaliar a correlação entre as variáveis para que possamos fazer uma pré-seleção daquelas que possam ser mais relevantes para o processo de construção do modelo de aprendizado:

    In [21]:
    # Calculando a correlação entre as variáveis:
    cust_churn_corr = cust_churn_padrao.corr()
    
    # Criando um gráfico da matriz de correlação:
    fig, ax = plt.subplots(nrows = 1, ncols = 1, figsize = (17, 15))
    
    sns.heatmap(cust_churn_corr, annot = True, fmt = '.3g', vmin = -1, vmax = 1, center = 0,
                            cmap = 'RdYlBu', square = True)
    ax.set_title('Matriz de Correlação entre Variáveis')
    
    fig.show()
    

    Como já esperado, pode-se observar pela matriz acima que muitas variáveis apresentam uma correlação muito elevada entre si. Em outras palavras, podemos dizer que elas representam a mesma informação, como no caso, por exemplo, das variáveis total_day_minutes, total_day_calls e total_day_charge. Ou seja, as variáveis não são independentes entre si, pois se aumentássemos o total de minutos, automaticamente estaríamos aumentando o gasto e, provavelmente, teríamos um número maior de ligações.

    Sendo assim, entre estas variáveis que são dependentes entre si, vamos selecionar apenas as que indicam o gasto:

    In [22]:
    # Variáveis a serem descartadas:
    var_dependentes = ['total_day_minutes', 'total_day_calls', 'total_night_minutes', 'total_night_calls',
                      'total_intl_minutes', 'total_intl_calls']
    
    # Descartando as variáveis:
    cust_churn_padrao = cust_churn_padrao.drop(var_dependentes, axis = 1)
    
    # Salvando a lista final de colunas selecionadas:
    cols_final = cust_churn_padrao.columns
    

    4.6- Balanceamento dos Dados - SMOTE

    Como queremos que o modelo a ser criado aprenda igualmente sobre duas classes distintas (clientes que cancelam o plano e clientes que não o cancelam), precisamos garantir o balanceamento dos dados, ou seja, precisamos ter um número de observações similares para ambas as classes.

    Primeiramente, vamos verificar se o conjunto de dados está desbalanceado:

    In [23]:
    # Agrupando por classe e contabilizando o número de observações:
    cust_churn_padrao.groupby(['churn']).size()
    
    Out[23]:
    churn
    0.0    2850
    1.0     483
    dtype: int64

    Claramente, temos muito mais informações sobre clientes que não cancelaram do que sobre os clientes que efetivamente cancelaram o plano. Sendo assim, vamos aplicar o SMOTE para que a proporção de dados de cada classe seja mais proporcional:

    In [24]:
    # Dividindo os dados em variáveis preditoras e variável target:
    X = cust_churn_padrao.iloc[:, :-1]
    Y = cust_churn_padrao.iloc[:, -1]
    
    # Aplicando SMOTE aos dados de treino:
    sm = SMOTE(random_state = 101)
    X_treino, Y_treino = sm.fit_sample(X, Y)
    

    5- Modelos Preditivos

    Nesta etapa, vamos fazer uso do algoritmo de aprendizado chamado Logistic Regression. Primeiramente, o modelo será criado para, então, alimentá-lo com os dados de treino. Criaremos outro modelo, utilizando o algoritmo XGBoost Classifier, para então compararmos o desempenho dos dois. Ainda uma terceira alternativa será criada, no caso, fazendo uso do algoritmo SVC.

    Com os modelos treinados, será possível fazer as previsões a partir do conjunto de dados de teste para que, finalmente, possamos avaliar o desempenho de cada um dos modelos na predição das classes desejadas. Sendo assim, vamos carregar os dados de teste que foram fornecidos:

    In [25]:
    # Carregado o arquivo com o conjunto de dados para teste:
    cust_churn_teste = pd.read_csv("projeto4_telecom_teste.csv")
    

    Com o arquivo carregado, faremos as mesmas transformações realizadas no conjunto de dados de treino:

    In [26]:
    # Equalizando os dados:
    cust_churn_teste_trnsf = equaliza_data(cust_churn_teste)
    
    # Exclusão de variáveis:
    cust_churn_teste_trnsf = cust_churn_teste_trnsf.drop(['account_length', 'state', 'area_code'], axis = 1)
    
    # Tratamento de variáveis do tipo classe:
    for column in classes:
        cust_churn_teste_trnsf[column] = cust_churn_teste_trnsf[column].apply(lambda x: 0 if x == "no" else 1)
    
    # Normalização dos dados de teste:
    cust_churn_teste_padrao = scaler.fit_transform(cust_churn_teste_trnsf)
    
    # Transformando o array criado para dataframe:
    cust_churn_teste_padrao = pd.DataFrame(cust_churn_teste_padrao, columns = cust_churn_teste_trnsf.columns)
    
    # Feature Selection:
    cust_churn_teste_padrao = cust_churn_teste_padrao[cols_final]
    
    # Dividindo os dados de teste em variáveis preditoras e variável target:
    X_teste = cust_churn_teste_padrao.iloc[:, :-1]
    Y_teste = cust_churn_teste_padrao.iloc[:, -1]
    

    5.1- Criando e Treinando os Modelos Preditivos

    Criando o modelo de Regressão Logística:

    In [27]:
    # Criando o modelo de Regressão Logística:
    model = LogisticRegression(random_state = 101)
    
    # Treinando o modelo com os dados de treino:
    model.fit(X_treino, Y_treino)
    
    Out[27]:
    LogisticRegression(random_state=101)

    Com o modelo treinado, vamos fazer as previsões para os dados de teste:

    In [28]:
    # Aplicando o modelo aos dados de teste:
    Y_pred = model.predict(X_teste)
    

    Vamos criar um segundo modelo preditivo, desta vez utilizando o XGBoost Classifier:

    In [29]:
    # Criando o modelo XGBoost Classifier:
    model_xgb = XGBClassifier(random_state = 101)
    
    # Treinando o modelo com os dados de treino:
    model_xgb.fit(X_treino, Y_treino)
    
    Out[29]:
    XGBClassifier(base_score=0.5, booster='gbtree', colsample_bylevel=1,
                  colsample_bynode=1, colsample_bytree=1, gamma=0, gpu_id=-1,
                  importance_type='gain', interaction_constraints='',
                  learning_rate=0.300000012, max_delta_step=0, max_depth=6,
                  min_child_weight=1, missing=nan, monotone_constraints='()',
                  n_estimators=100, n_jobs=0, num_parallel_tree=1, random_state=101,
                  reg_alpha=0, reg_lambda=1, scale_pos_weight=1, subsample=1,
                  tree_method='exact', validate_parameters=1, verbosity=None)

    Da mesma forma que anteriormente, faremos as previsões dos dados de teste, utilizando o novo modelo criado:

    In [30]:
    # Aplicando o novo modelo aos dados de teste:
    Y_pred_xgb = model_xgb.predict(X_teste)
    

    Criando nosso terceiro modelo preditivo, desta vez o Support Vector Classifier:

    In [31]:
    # Criando o modelo SVC:
    model_svc = SVC(random_state = 101)
    
    # Treinando o modelo com os dados de treino:
    model_svc.fit(X_treino, Y_treino)
    
    Out[31]:
    SVC(random_state=101)

    Fazendo as novas previsões com o modelo SVC:

    In [32]:
    # Aplicando o terceiro modelo aos dados de teste:
    Y_pred_svc = model_svc.predict(X_teste)
    

    5.2- Avaliando os Modelos Preditivos

    Para fazermos a avaliação dos modelos treinados, precisamos comparar as previsões feitas por ele nos dados de teste com os resultados reais do conjunto de dados. Neste estudo, a métrica de avaliação utilizada será a acurácia:

    In [33]:
    # Calculando a acurácia do modelo de Regressão Logística:
    accuracy = accuracy_score(Y_teste, Y_pred)
    print('A acurácia do modelo de Regressão Logística para os dados de teste é de', round(accuracy*100), '%.')
    
    A acurácia do modelo de Regressão Logística para os dados de teste é de 85.0 %.
    
    In [34]:
    # Calculando a acurácia do modelo XGBoost Classifier:
    accuracy_xgb = accuracy_score(Y_teste, Y_pred_xgb)
    print('A acurácia do modelo de classificação XGBoost para os dados de teste é de', round(accuracy_xgb*100), '%.')
    
    A acurácia do modelo de classificação XGBoost para os dados de teste é de 75.0 %.
    
    In [35]:
    # Calculando a acurácia do SVM:
    accuracy_svc = accuracy_score(Y_teste, Y_pred_svc)
    print('A acurácia do modelo Support Vector Classifier para os dados de teste é de', round(accuracy_svc*100), '%.')
    
    A acurácia do modelo Support Vector Classifier para os dados de teste é de 85.0 %.
    

    Vamos criar uma matriz de confusão para cada um dos modelos criados para entendermos o desempenho do modelo na previsão de cada classe:

    In [36]:
    # Plotando a matriz de confusão 'normalizada' com o Seaborn:
    fig, ax = plt.subplots(nrows = 1, ncols = 3, figsize = (15, 5))
    
    class_names = ['Não', 'Sim']
    
    plot_confusion_matrix(model, X_teste, Y_teste, display_labels = class_names, cmap=plt.cm.PuBu,
                          normalize = 'true', ax = ax[0])
    ax[0].set_title('Matriz de Confusão Logistic Reg.')
    
    plot_confusion_matrix(model_xgb, X_teste, Y_teste, display_labels = class_names, cmap=plt.cm.PuBu,
                          normalize = 'true', ax = ax[1])
    ax[1].set_title('Matriz de Confusão XGBoost')
    
    plot_confusion_matrix(model_svc, X_teste, Y_teste, display_labels = class_names, cmap=plt.cm.PuBu,
                          normalize = 'true', ax = ax[2])
    ax[2].set_title('Matriz de Confusão SVC')
    
    fig.show()
    

    Através da matriz de confusão, é possível perceber que o modelo XGBoost, apesar de ter apresentado uma acurácia menor em relação aos demais modelos criados, é o modelo que aprendeu de maneira mais equilibrada sobre ambas as classes, ainda que sua acurácia para a classe que representa os clientes que cancelaram o plano seja muito baixa - perto dos 50%.

    Os modelos de Regressão Logística e SVC apresentaram um desempenho geral muito superior, no entanto, quando fazemos a análise por classe, percebemos que o modelo aprendeu muito sobre clientes que não cancelam - acurácia superior a 90% - e, no entanto, não conseguiu identificar a classe que cancela o plano - abaixo de 30% de acurácia.

    Vamos, portanto, tentar melhorar o modelo XGBoost Classifier, fazendo uso do Grid Search CV:

    In [37]:
    # Criando o modelo inicial:
    estimator = XGBClassifier(objective = 'binary:logistic',
                                     nthread = 4,
                                     seed = 101)
    
    # Selecionando parâmetros a serem analisados pelo Grid Search:
    params = {"learning_rate": [0.05, 0.10, 0.15, 0.20, 0.25, 0.30],
              "max_depth": [3, 4, 5, 6, 8, 10, 12, 15],
              "min_child_weight": [1, 3, 5, 7],
              "gamma": [0.0, 0.1, 0.2 , 0.3, 0.4],
              "colsample_bytree": [0.3, 0.4, 0.5 , 0.7]}
    
    # Criando o Grid Search:
    grid_search = GridSearchCV(estimator = estimator,
                              param_grid = params,
                              scoring = 'roc_auc',
                              n_jobs = 4,
                              cv = 4,
                              verbose = True)
    
    # Aplicando o Grid Search aos dados de treino:
    grid_search.fit(X_treino, Y_treino)
    
    # Retornando os melhores hiperparâmetros:
    grid_search.best_estimator_
    
    Fitting 4 folds for each of 3840 candidates, totalling 15360 fits
    
    [Parallel(n_jobs=4)]: Using backend LokyBackend with 4 concurrent workers.
    [Parallel(n_jobs=4)]: Done  42 tasks      | elapsed:    6.0s
    [Parallel(n_jobs=4)]: Done 192 tasks      | elapsed:   22.4s
    [Parallel(n_jobs=4)]: Done 442 tasks      | elapsed:   52.6s
    [Parallel(n_jobs=4)]: Done 792 tasks      | elapsed:  1.6min
    [Parallel(n_jobs=4)]: Done 1242 tasks      | elapsed:  2.6min
    [Parallel(n_jobs=4)]: Done 1792 tasks      | elapsed:  3.9min
    [Parallel(n_jobs=4)]: Done 2442 tasks      | elapsed:  5.4min
    [Parallel(n_jobs=4)]: Done 3192 tasks      | elapsed:  7.2min
    [Parallel(n_jobs=4)]: Done 4042 tasks      | elapsed:  9.3min
    [Parallel(n_jobs=4)]: Done 4992 tasks      | elapsed: 12.1min
    [Parallel(n_jobs=4)]: Done 6042 tasks      | elapsed: 15.1min
    [Parallel(n_jobs=4)]: Done 7192 tasks      | elapsed: 18.3min
    [Parallel(n_jobs=4)]: Done 8442 tasks      | elapsed: 22.2min
    [Parallel(n_jobs=4)]: Done 9792 tasks      | elapsed: 26.5min
    [Parallel(n_jobs=4)]: Done 11242 tasks      | elapsed: 31.3min
    [Parallel(n_jobs=4)]: Done 12792 tasks      | elapsed: 37.4min
    [Parallel(n_jobs=4)]: Done 14442 tasks      | elapsed: 44.0min
    [Parallel(n_jobs=4)]: Done 15360 out of 15360 | elapsed: 47.9min finished
    
    Out[37]:
    XGBClassifier(base_score=0.5, booster='gbtree', colsample_bylevel=1,
                  colsample_bynode=1, colsample_bytree=0.7, gamma=0.0, gpu_id=-1,
                  importance_type='gain', interaction_constraints='',
                  learning_rate=0.1, max_delta_step=0, max_depth=15,
                  min_child_weight=1, missing=nan, monotone_constraints='()',
                  n_estimators=100, n_jobs=4, nthread=4, num_parallel_tree=1,
                  random_state=101, reg_alpha=0, reg_lambda=1, scale_pos_weight=1,
                  seed=101, subsample=1, tree_method='exact', validate_parameters=1,
                  verbosity=None)

    Fazendo uso dos hiperparâmetros indicados pelo Grid Search, vamos treinar um novo modelo XGBoost Classifier, treiná-lo e testá-lo com os mesmos dados de treino para, então, verificarmos a sua acurácia:

    In [38]:
    # Criando um novo modelo XGB Classifier com os parâmetros indicados pelo Grid Search:
    model_xgb_tuned = XGBClassifier(base_score=0.5, booster='gbtree', colsample_bylevel=1,
                                    colsample_bynode=1, colsample_bytree=0.7, gamma=0.1, gpu_id=-1,
                                    importance_type='gain', interaction_constraints='',
                                    learning_rate=0.1, max_delta_step=0, max_depth=15,
                                    min_child_weight=1,
                                    n_estimators=100, n_jobs=4, nthread=4, num_parallel_tree=1,
                                    random_state=101, reg_alpha=0, reg_lambda=1, scale_pos_weight=1,
                                    seed=101, subsample=1, tree_method='exact', validate_parameters=1)
    
    # Treinando o novo modelo:
    model_xgb_tuned.fit(X_treino, Y_treino)
    
    # Aplicando o novo modelo aos dados de teste:
    Y_pred_xgb_tuned = model_xgb_tuned.predict(X_teste)
    
    # Calculando a acurácia do modelo XGBoost Classifier com novos hiperparâmetros:
    accuracy_xgb_tuned = accuracy_score(Y_teste, Y_pred_xgb_tuned)
    print('A acurácia do modelo de classificação XGBoost ajustado para os dados de teste é de', round(accuracy_xgb_tuned*100), '%.')
    
    A acurácia do modelo de classificação XGBoost ajustado para os dados de teste é de 75.0 %.
    

    A acurácia não teve alteração em relação ao primeiro modelo criado, permanecendo em 75%. Vamos avaliar mais minuciosamente a acurácia através da matriz de confusão:

    In [39]:
    # Criando uma matriz de confusão:
    disp = plot_confusion_matrix(model_xgb_tuned, X_teste, Y_teste,
                                 display_labels = class_names,
                                 cmap=plt.cm.PuBu,
                                 normalize = 'true')
    disp.ax_.set_title('Matriz de Confusão XGBoost Tuned')
    plt.show()
    

    Podemos perceber uma leve melhora na previsão da classe que indica os clientes que cancelaram o plano, embora esta acurácia ainda esteja muito baixa.

    Vamos ainda retornar as previsões no formato de probabilidade, ou seja, qual é a probabilidade da observação pertencer a cada uma das classes:

    In [40]:
    # Retornando os resultados do modelo em forma de probabilidade:
    Y_pred_xgb_prob = model_xgb_tuned.predict_proba(X_teste)
    

    Para melhor observação, vamos transformar estas previsões e um dataframe e incluir também as previsões finais do modelo:

    In [41]:
    # Criando um dataframe com as probabilidades e incluindo as predições:
    Y_pred_xgb_prob = pd.DataFrame(Y_pred_xgb_prob, columns = ['Prob_Não', 'Prob_Sim'])
    Y_pred_xgb_prob['Pred'] = Y_pred_xgb_tuned
    

    Visualizando o dataframe criado com as probabilidades e previsões finais:

    In [42]:
    Y_pred_xgb_prob
    
    Out[42]:
    Prob_Não Prob_Sim Pred
    0 0.250963 0.749037 1.0
    1 0.367571 0.632429 1.0
    2 0.991744 0.008256 0.0
    3 0.962969 0.037031 0.0
    4 0.904881 0.095119 0.0
    ... ... ... ...
    1662 0.920921 0.079079 0.0
    1663 0.496044 0.503956 1.0
    1664 0.979796 0.020204 0.0
    1665 0.906696 0.093304 0.0
    1666 0.939517 0.060483 0.0

    1667 rows × 3 columns

    6- Conclusão

    Criamos diferentes modelos que apresentaram problemas distintos: alguns apresentaram uma acurácia geral mais elevada, mas, ao observarmos mais atentamente, foi possível perceber que houve uma espécie de overfitting para uma das classes, enquanto outro apresentou uma acurácia geral mais baixa, porém conseguiu equilibrar melhor o entendimento das duas classes.

    Ao escolhermos o modelo XGBoost Classifier para aplicar o Grid Search CV com o intuito de procurar pelos melhores hiperparâmetros, não obtivemos uma melhora significativa na acurácia do modelo. Outras possibilidades que ainda poderiam ser desenvolvidas seriam a transformação de algumas variáveis (por exemplo, criar uma única variável que representasse o gasto total) e a aplicação de alguma técnica de redução de dimensionalidade - como o PCA - transformando as variáveis em um número menor de componentes.

    Fim.