Machine Learning · Engenharia de Dados

Previsão de churn em escala: um pipeline de ML distribuído

Da pergunta de negócio ao score de risco por cliente: aprendizado supervisionado, comparação de três algoritmos de classificação e arquitetura de dados em camadas sobre Spark, Databricks e MLflow.


📄CSVTelco · 7.043 linhas
🥉Bronzedado bruto
🥈Pratalimpo & tipado
🥇Ourofeatures ML
🤖MLflow3 modelos
🎯Scorerisco/cliente
Fluxo de ponta a ponta: do CSV bruto à inferência em batch no Databricks.

Operadoras de telecom perdem clientes continuamente para o cancelamento — o churn — e adquirir um cliente novo custa várias vezes mais do que reter um existente. A pergunta de negócio do projeto é:

“Quais clientes têm maior probabilidade de cancelar nos próximos meses — e por quê?”

Responder exige mais do que treinar um modelo: exige um pipeline de ponta a ponta que lide com dado sujo, escala distribuída, rastreabilidade de experimentos e promoção governada. Este artigo percorre cada etapa — sobre Apache Spark, Databricks, Delta Lake, MLflow e Unity Catalog — com foco nos algoritmos e nas decisões que sustentam o resultado.

7.043
clientes no dataset
~26%
taxa de churn (classe rara)
3
algoritmos comparados
0.826
AUC-ROC do melhor modelo

Supervisionado ou não supervisionado?

A primeira decisão de modelagem é o tipo de aprendizado, porque ela determina os algoritmos disponíveis, as métricas válidas e o formato do dado.

No aprendizado não supervisionado, os dados não têm rótulos: o algoritmo descobre estrutura sozinho — agrupa clientes parecidos (clustering, como o k-means), reduz dimensionalidade (PCA) ou detecta anomalias. Não há rótulo a prever, apenas padrões a identificar.

No aprendizado supervisionado, cada exemplo vem com um rótulo, e o algoritmo aprende a função que mapeia as variáveis de entrada para esse rótulo. É o caso aqui: o dataset tem a coluna Churn indicando, para cada cliente histórico, se ele cancelou. Como o rótulo assume só dois valores — cancelou (1) ou permaneceu (0) — trata-se de classificação binária. O rótulo discreto distingue o problema de uma regressão (valor contínuo) e habilita métricas como AUC-ROC e F1.

Nota de método

A segmentação de clientes na análise exploratória (por contrato, por tempo de casa) é descritiva, guiada por regras de negócio — não são clusters aprendidos por um algoritmo. Uma evolução do projeto seria rodar um k-means para descobrir segmentos latentes de risco, como complemento ao classificador.

• • •

Arquitetura Medallion

A qualidade do modelo é limitada pela qualidade do dado, e dado de produção chega sujo. A arquitetura Medallion organiza o fluxo em três camadas sobre o Delta Lake, cada uma com uma responsabilidade definida — permitindo reprocessar, auditar e reutilizar o dado sem reescrever o pipeline inteiro.

🥉 Bronze

Dado bruto, intocado

O CSV como chegou, sem filtro nem transformação semântica. Garante rastreabilidade e permite reprocessar do zero.

🥈 Prata

Limpo e confiável

Nulos tratados, tipos corrigidos, target binarizado, regras de qualidade aplicadas. Pronto para análise e reutilizável por qualquer caso de uso.

🥇 Ouro

Features para ML

Variáveis derivadas e codificadas no formato que o modelo consome. É a única camada acoplada a este caso de uso.

Delta Lake em vez de Parquet puro pelas transações ACID, versionamento e time travel — consultar o estado da tabela em qualquer versão anterior. Em um pipeline reexecutado dezenas de vezes, isso garante consistência e reprodutibilidade.

• • •

ETAPA 01 — INGESTÃO

Bronze: ingestão sem transformação

A camada Bronze lê a tabela-fonte registrada no Unity Catalog e a persiste em Delta sem transformação, apenas com casting mínimo de tipos. Nada que altere o significado do dado entra aqui. Com o Bronze imutável, um bug na limpeza se corrige reprocessando, sem perda de dado.

01_ingestao_bronze.py
# Lê a fonte pelo nome no Unity Catalog e persiste em Delta, sem tocar no conteúdo
df_raw = spark.table("portfolio.default.wa_fn_use_c_telco_customer_churn")
 
(df_raw.write
   .format("delta")
   .mode("overwrite")
   .option("overwriteSchema", "true")
   .saveAsTable("portfolio.default.telco_bronze"))   # 7.043 linhas preservadas

ETAPA 02 — LIMPEZA

Prata: limpeza e padronização

A camada Prata define o teto de qualidade do modelo. O dataset Telco tem um problema conhecido: a coluna TotalCharges chega como texto, e clientes com tenure = 0 (recém-chegados, sem cobrança acumulada) trazem um espaço em branco " " no lugar do número. Um cast direto produziria nulo silencioso. O tratamento é explícito: espaço vazio vira 0.0, o valor semanticamente correto.

Três decisões definem esta camada:

1. Binarizar o alvo. Churn “Yes”/“No” vira 1/0, formato numérico exigido pela labelCol dos classificadores do Spark ML.

2. Remover o customerID. Identificadores únicos não têm poder preditivo e abrem porta para data leakage: o modelo decoraria clientes em vez de aprender padrões generalizáveis.

3. Normalizar categorias. Um trim() nas strings evita que "Yes" e "Yes " virem categorias distintas, o que corromperia o encoding adiante.

A binarização revela que cerca de 26% dos clientes deram churn — um desbalanceamento de classes, com a classe-alvo na minoria. Esse número determina a escolha da métrica de avaliação mais adiante.

02_limpeza_prata.py
# Espaço em branco em TotalCharges → 0.0 (clientes com tenure = 0 sem cobrança)
df_silver = (df_bronze
  .withColumn("TotalCharges",
      F.when(F.trim(F.col("TotalCharges")) == "", F.lit(0.0))
       .otherwise(F.col("TotalCharges").cast("double")))
  .withColumn("Churn",
      (F.col("Churn") == F.lit("Yes")).cast("int"))   # "Yes"/"No" → 1/0
  .drop("customerID"))                               # sem poder preditivo
 
(df_silver.write
   .format("delta").mode("overwrite")
   .option("overwriteSchema", "true")
   .saveAsTable("portfolio.default.telco_silver"))
• • •

ETAPA 03 — EXPLORAÇÃO

Análise exploratória (EDA com Spark SQL)

A análise exploratória foi escrita em Spark SQL sobre a camada Prata — linguagem declarativa rodando sobre um motor que escala para terabytes. Cada consulta testou uma hipótese de negócio, e os padrões resultantes definiram as features posteriores.

42,7%
de churn em contratos mês a mês, contra 2,8% nos de dois anos. Sem fidelidade, o custo de cancelar é baixo.
47,7%
de churn nos primeiros 12 meses. A relação é mais frágil no início; clientes consolidados tendem a permanecer.
41,9%
de churn entre clientes de internet fibra — serviço premium, com alta concorrência e expectativa de qualidade.
45,3%
de churn no pagamento por cheque eletrônico, indicador de baixo engajamento e pouco lock-in.
+ serviços
Mais serviços, mais retenção: quem contrata vários produtos tem maior custo de troca e mais valor percebido.
R$74 vs 61
quem cancela paga, em média, mensalidade mais alta — indício de sensibilidade a preço.

Cada insight vira uma feature em potencial — tempo e tipo de contrato, serviço de internet, método de pagamento, número de serviços e cobrança mensal — base da engenharia de features adiante.

03_eda_spark_sql.sql
-- Taxa de churn por tipo de contrato — hipótese de fidelização
SELECT  Contract,
        COUNT(*)                          AS total,
        SUM(Churn)                         AS churned,
        ROUND(AVG(Churn) * 100, 1)        AS churn_pct
FROM    portfolio.default.telco_silver
GROUP   BY Contract
ORDER   BY churn_pct DESC
 
-- Resultado: Month-to-month 42,7% · One year 11,3% · Two year 2,8%
• • •

ETAPA 04 — ENGENHARIA DE FEATURES

Ouro: engenharia de features

Algoritmos operam sobre números, não sobre categorias como “contrato mês a mês” ou “fibra ótica”. A camada Ouro transforma o dado limpo em um vetor numérico denso via um Spark ML Pipeline de três estágios. O Pipeline garante reprodutibilidade: fit() aprende os parâmetros (o vocabulário de categorias, por exemplo) e transform() os aplica; o mesmo objeto serializado é recarregado na inferência.

Os três estágios de codificação

StringIndexer — converte cada categoria em um índice inteiro, ordenado por frequência (o valor mais comum recebe 0). handleInvalid="keep" reserva um índice para categorias não vistas no treino, evitando que o pipeline quebre em produção diante de um valor novo.

OneHotEncoder — o índice inteiro introduz uma ordem falsa (2 > 1 > 0, inexistente entre “cartão”, “boleto” e “cheque”). O one-hot remove essa ordem, transformando cada índice em um vetor binário esparso. dropLast=True descarta uma categoria por variável para evitar a dummy variable trap — a multicolinearidade que desestabiliza modelos lineares.

VectorAssembler — reúne as categóricas codificadas e as numéricas em uma única coluna features, o vetor consumido pelos algoritmos.

Três features de domínio

Além das 21 variáveis originais, três features derivadas da EDA traduzem conhecimento de negócio em colunas:

FeatureO que captura
num_servicesContagem de serviços adicionais (0–8). Correlaciona negativamente com churn — mais produtos, mais retenção.
charges_per_tenureTotalCharges / tenure: custo médio mensal acumulado, proxy do valor percebido ao longo do tempo.
is_new_customerFlag para os primeiros 12 meses — o período com ~47,7% de churn identificado na EDA.
Training-serving skew

Essas três features são criadas fora do pipeline Spark ML — ele as consome, mas não as gera. Por isso precisam ser recriadas antes de qualquer inferência em dados novos. Omitir essa etapa causa training-serving skew: o modelo recebe um vetor com formato diferente do treino e produz predições inválidas sem erro aparente.

04_feature_engineering.py
# Features de domínio — derivadas dos padrões encontrados na EDA
service_cols = ["PhoneService", "MultipleLines", "OnlineSecurity",
                "OnlineBackup", "DeviceProtection", "TechSupport",
                "StreamingTV", "StreamingMovies"]
 
df_gold = (df_silver
  .withColumn("num_services",
      sum(F.when(F.col(c) == "Yes", 1).otherwise(0) for c in service_cols))
  .withColumn("charges_per_tenure",
      F.col("TotalCharges") / F.greatest(F.col("tenure"), F.lit(1)))
  .withColumn("is_new_customer",
      (F.col("tenure") <= 12).cast("int")))
 
# Pipeline: StringIndexer → OneHotEncoder → VectorAssembler
indexers  = [StringIndexer(inputCol=c, outputCol=c+"_idx", handleInvalid="keep") for c in cat_cols]
encoders  = [OneHotEncoder(inputCol=c+"_idx", outputCol=c+"_ohe", dropLast=True)  for c in cat_cols]
assembler = VectorAssembler(inputCols=num_cols + [c+"_ohe" for c in cat_cols], outputCol="features")
 
df_features = Pipeline(stages=indexers + encoders + [assembler]).fit(df_gold).transform(df_gold)
• • •

ETAPA 05 — TREINAMENTO

Os três classificadores

Com o dataset Ouro pronto, o split é 80% treino / 20% teste (seed=42), preservando a proporção de churn em cada parte. São treinados três classificadores supervisionados, cada um com uma abordagem distinta para separar “permanece” de “cancela”, todos rodando distribuídos via Spark MLlib — não em scikit-learn de nó único.

1 · Regressão Logística

linear · interpretável

O baseline. Modela a probabilidade de churn como combinação linear das features passada por uma sigmoide, que comprime o resultado para [0, 1]. Aprende um peso por feature — interpretável: dá para ler quanto cada variável contribui. Regularização L2 (Ridge) leve contém overfitting sem zerar coeficientes.

maxIter=100 · regParam=0.01 · elasticNetParam=0.0 (100% L2)

2 · Random Forest

ensemble · bagging

Cem árvores de decisão treinadas em paralelo, cada uma sobre uma amostra aleatória dos dados e das features (bootstrap aggregating, ou bagging). A predição é o voto do conjunto. Árvores isoladas têm alta variância; agregar muitas a reduz sem aumentar o viés. Captura interações não-lineares e é robusta a outliers.

numTrees=100 · maxDepth=8 · seed=42

3 · Gradient Boosted Trees

ensemble · boosting

Também um conjunto de árvores, mas com estratégia oposta. O boosting é sequencial: cada árvore corrige os erros residuais da anterior, descendo o gradiente da função de perda. Ataca o viés, não a variância; costuma ser o mais preciso, ao custo de mais treino e maior risco de overfitting se mal calibrado. O stepSize (taxa de aprendizado) controla esse equilíbrio.

maxIter=50 · maxDepth=5 · stepSize=0.1 · seed=42
Bagging × Boosting

Bagging (Random Forest) treina árvores independentes e agrega — reduz a variância. Boosting (GBT) treina árvores em sequência, cada uma corrigindo a anterior — reduz o viés. Abordagens opostas para o mesmo fim: um conjunto melhor que qualquer árvore isolada.

Rastreamento com MLflow

Cada treino roda dentro de um mlflow.start_run(): parâmetros, métricas e o modelo serializado são logados, e toda execução vira um run comparável na interface. É a base do MLOps — rastreabilidade, comparação entre runs e nenhum modelo sem proveniência.

05_treinamento_mlflow.py
def treinar_e_logar(modelo, params, nome_run):
    with mlflow.start_run(run_name=nome_run):
        m     = modelo.fit(df_train)
        preds = m.transform(df_test)
 
        auc = evaluator_auc.evaluate(preds)   # métrica de seleção
        f1  = evaluator_f1.evaluate(preds)
        mlflow.log_params(params)
        mlflow.log_metric("auc_roc", auc)
        mlflow.spark.log_model(m, "model")   # modelo versionado
        return m, preds
• • •

Métrica de avaliação

O desbalanceamento determina a métrica. Com acurácia, um modelo que sempre previsse “não cancela” acertaria ~74% — inútil, pois erraria justamente os clientes relevantes. A métrica principal é a AUC-ROC: mede a capacidade de ordenar clientes por risco, independe do limiar de decisão e é robusta a classes desbalanceadas (0,5 = aleatório; 1,0 = separação perfeita). F1 e acurácia entram como secundárias.

A matriz de confusão traduz a métrica para o negócio: um falso negativo é um cliente que vai cancelar e não foi detectado — perda de receita; um falso positivo é um cliente fiel marcado como risco — custo de uma ação de retenção desnecessária. O peso entre os dois calibra o limiar.

Resultados

Treinados e avaliados no mesmo conjunto de teste, os três ficaram em empate técnico, todos na faixa de AUC 0,82. O melhor resultado, por margem mínima, foi do modelo mais simples:

ModeloTipoAUC-ROCF1AcuráciaStatus
Regressão Logística Linear 0,8259 0,7915 0,8006 Selecionado
Random Forest Bagging 0,8253 0,7852 0,7984
Gradient Boosted Trees Boosting 0,8220 0,7882 0,7963

Por que o modelo linear teve o melhor desempenho

Quando um modelo linear empata com Random Forest e GBT, a fronteira entre churn e retenção neste dataset é majoritariamente linear. Após o one-hot encoding, as variáveis mais preditivas (tipo de contrato, tenure, método de pagamento) separam bem as classes sem interações complexas, e os ensembles não encontraram estrutura não-linear relevante para explorar.

Pelo princípio da parcimônia, entre modelos de desempenho equivalente escolhe-se o mais simples. A Regressão Logística treina em segundos, é simples de servir e interpretável: a equipe de retenção lê por que um cliente foi marcado como risco. Para um caso de uso que orienta ações humanas, a interpretabilidade pesa mais do que 0,3% de AUC. Essa foi a lógica de seleção; o melhor AUC apenas confirmou a decisão.

• • •

ETAPA 06 — GOVERNANÇA & INFERÊNCIA

Governança e inferência

Um bom run no MLflow não equivale a aptidão para produção. O modelo selecionado é registrado no Unity Catalog Model Registry — com uma signature que define o contrato de entrada e saída — como portfolio.default.telco-churn-predictor, versão 1. O registro separa a experimentação livre (qualquer run) da promoção governada (modelo versionado, auditável, com rollback por alias).

Com o modelo promovido, a inferência em batch pontua a base de clientes, extrai a probabilidade de churn da classe positiva e a traduz em faixas acionáveis pela equipe de CRM:

Faixa de riscoProbabilidade de churnAção recomendada
🔴 ALTO≥ 0,70Retenção imediata — contato proativo, oferta dirigida
🟡 MÉDIO0,40 – 0,70Monitoramento ativo, campanhas de relacionamento
🟢 BAIXO< 0,40Sem intervenção necessária

O resultado é uma tabela Delta — telco_predictions — com um score e uma faixa de risco por cliente, pronta para dashboards e sistemas downstream. Os limiares são parâmetros de negócio, ajustáveis conforme o custo relativo entre perder um cliente e executar uma ação de retenção desnecessária.

06_avaliacao_batch_inference.py
# Carrega o modelo promovido no Unity Catalog e pontua a base inteira
model    = mlflow.spark.load_model("models:/portfolio.default.telco-churn-predictor/1")
df_preds = model.transform(df_gold_inference)
 
# Extrai p(churn) do vetor de probabilidades e classifica por faixa
extract_prob = F.udf(lambda v: float(v[1]), DoubleType())
 
df_scores = (df_preds
  .withColumn("churn_prob", extract_prob(F.col("probability")))
  .withColumn("risco",
      F.when(F.col("churn_prob") >= 0.70, "ALTO")
       .when(F.col("churn_prob") >= 0.40, "MÉDIO")
       .otherwise("BAIXO")))
 
df_scores.write.format("delta").mode("overwrite").saveAsTable("portfolio.default.telco_predictions")
• • •

Considerações finais

O modelo selecionado tem AUC 0,826. Mas o resultado principal não é o modelo, e sim o sistema em torno dele: a arquitetura Medallion que mantém o dado confiável, o MLflow que torna cada experimento reproduzível, o Unity Catalog que governa a promoção e a inferência em batch que converte probabilidade em ação. Trocar o algoritmo é uma alteração pontual; a fundação é o que sustenta o pipeline.

Em resumo

  • Aprendizado supervisionado — há rótulos (Churn), logo é classificação binária, não clustering.
  • Três algoritmos comparados — Regressão Logística (linear), Random Forest (bagging) e GBT (boosting), todos distribuídos no Spark MLlib.
  • Modelo selecionado — a Regressão Logística (AUC 0,8259) igualou os ensembles; parcimônia e interpretabilidade definiram a escolha.
  • AUC-ROC como métrica — robusta ao desbalanceamento de ~26% de churn, ao contrário da acurácia.
  • Engenharia de features decisiva — StringIndexer → OneHotEncoder → VectorAssembler, mais três features de domínio guiadas pela EDA.
  • MLOps completo — Medallion + MLflow + Unity Catalog: dado confiável, experimentos rastreados, promoção governada, score por cliente.

A
Athos Roque
Em transição de suporte técnico para Engenharia e Ciência de Dados. Construindo um portfólio reproduzível de projetos em SQL avançado, séries temporais, ML em escala e MLOps, tudo rodando em homelab próprio.