Projeto de engenharia de Machine Learning end-to-end: do dado bruto ao modelo rastreado com MLflow, com pipeline reprodutível e boas práticas de engenharia de software.
Tech Challenge — PosTech | Autores: Patrick Covre · Beatriz Ferrante · Cecilia Rocha
Uma operadora de telecomunicações precisa identificar clientes com risco de cancelamento (churn). Este projeto treina e compara modelos de ML — incluindo uma rede neural MLP com PyTorch — e registra todos os experimentos no MLflow para rastreabilidade.
- Python 3.10+
- Poetry instalado
# Clone o repositório
git clone <url-do-repo>
cd tech-challenge-step-1
# Instala todas as dependências via Poetry
poetry installAbra o VSCode, selecione o kernel do Poetry (tech-challenge-...py3.14) e execute as células em ordem:
src/notebooks/eda.ipynb— análise exploratóriasrc/notebooks/etapa1_baselines.ipynb— baselines no MLflowsrc/notebooks/etapa2_experimentos.ipynb— MLP PyTorch + comparação
Após rodar os notebooks, abra o PowerShell em qualquer diretório e rode:
# Substitua <usuario> pelo seu nome de usuário do Windows
& $PY314 -m mlflow ui --backend-store-uri "sqlite:///C:/Users/<usuario>/Tech-challenge-step-1/Tech-challenge-step-1/mlruns.db" --port 5000Importante: use o caminho absoluto com barras
/— o caminho relativosqlite:///mlruns.dbsó funciona se o terminal estiver na raiz do projeto.
Acesse http://localhost:5000.
tech-challenge/
│
├── src/
│ ├── config.py ← Configurações centrais
│ ├── pipeline.py ← Orquestrador do pipeline completo
│ │
│ ├── data/
│ │ ├── churn.csv ← Dataset (Telco Customer Churn — IBM)
│ │ └── loader.py ← Carregamento e pré-processamento
│ │
│ ├── models/
│ │ ├── mlp.py ← Arquitetura da rede neural (PyTorch)
│ │ ├── mlp_trainer.py ← Loop de treino com early stopping
│ │ ├── registry.py ← Catálogo de modelos disponíveis
│ │ └── trainer.py ← Treino/avaliação de modelos sklearn
│ │
│ ├── service/
│ │ └── mlflow_service.py ← Registro padronizado no MLflow
│ │
│ ├── notebooks/
│ │ ├── eda.ipynb ← Etapa 1: análise exploratória
│ │ ├── etapa1_baselines.ipynb ← Etapa 1: baselines registrados
│ │ └── etapa2_experimentos.ipynb ← Etapa 2: MLP + comparação de modelos
│ │
│ ├── docs/ ← Gráficos gerados pelos notebooks
│ └── tests/ ← Testes automatizados (pytest)
│
├── pyproject.toml ← Dependências, linting, pytest
├── Makefile ← Atalhos de linha de comando
└── mlruns.db ← Banco de dados do MLflow (gerado ao rodar)
O que faz: Define três dataclasses de configuração:
DataConfig— caminho do dataset, nome da coluna alvo, tamanho do splitMLPConfig— arquitetura da rede neural, hiperparâmetros de treinoMLflowConfig— URI do banco e nome do experimentosetup_logging()— configura logging estruturado (semprint())
Por que existe: Centralizar todas as configurações em um único lugar. Quando você quiser trocar o dataset ou ajustar hiperparâmetros, mexe só aqui — sem precisar caçar valores espalhados pelo código.
# Exemplo: mudar o experimento sem tocar em outros arquivos
mlflow_cfg = MLflowConfig(experiment_name="meu-experimento-v2")O que faz: A classe ChurnDataLoader:
- Lê qualquer CSV e faz limpeza específica do dataset Telco (converte
TotalChargesde string para float, remove nulos) - Codifica a coluna alvo (Yes/No → 1/0)
- Divide em treino/teste com estratificação (preserva a proporção de churn)
- Aplica
StandardScalernas features numéricas eOneHotEncodernas categóricas - Retorna os arrays prontos para sklearn e PyTorch
Por que existe: Sem esse módulo, cada notebook repetiria o mesmo código de limpeza. Se o dataset mudar ou você descobrir um bug no pré-processamento, corrige em um lugar só e todos os notebooks se beneficiam.
loader = ChurnDataLoader(path="src/data/churn.csv", target="Churn")
df = loader.load()
X_train, X_test, y_train, y_test, preprocessor = loader.get_splits(df)O que faz: Define a classe ChurnMLP (herda de nn.Module). A arquitetura é configurável via listas:
hidden_sizes— número de neurônios por camada ocultadropout_rates— taxa de dropout por camada (0.0 = sem BatchNorm nem Dropout)
Padrão: [128, 64, 32] com dropouts [0.3, 0.2, 0.0].
Por que existe: Separar a definição da arquitetura do loop de treino é uma boa prática em PyTorch. Você pode instanciar o modelo em qualquer lugar, salvar seus pesos e trocar a arquitetura sem reescrever o treino.
# Testar uma arquitetura maior sem mudar o trainer
model = ChurnMLP(input_dim=46, hidden_sizes=[256, 128, 64], dropout_rates=[0.4, 0.3, 0.0])O que faz: Define dois dicionários centrais:
MODEL_REGISTRY— mapeia uma chave para modelo instanciado, hiperparâmetros e (opcionalmente) configuração do MLflow Model RegistryPYTORCH_REGISTRY— mesmo padrão para modelos PyTorch, que têm ciclo de treino próprio
Cada entrada pode ter uma chave "mlflow" com a configuração completa de registro:
MODEL_REGISTRY["logistic_regression"] = {
"model": LogisticRegression(...),
"params": {"C": 1.0, ...},
"mlflow": {
"model_description": "Regressão Logística — baseline linear...",
"version_description": "LogisticRegression com C=1.0 treinada na Etapa 1.",
"version_tags": {"stage": "etapa1", "framework": "sklearn"},
"version_alias": "baseline",
},
}Os notebooks desempacotam **entry["mlflow"] direto na chamada do MLflowService, sem duplicar nenhum valor.
Por que existe: Fonte única de verdade para tudo que define um modelo — arquitetura, hiperparâmetros e metadados do Registry. Para adicionar um novo modelo ao experimento (incluindo descrição e alias no MLflow), você mexe apenas aqui.
# Adicionar um novo modelo com config completa de Registry
MODEL_REGISTRY["svm"] = {
"model": SVC(C=1.0, kernel="rbf", probability=True),
"params": {"C": 1.0, "kernel": "rbf"},
"mlflow": {
"model_description": "SVM RBF para o dataset Telco.",
"version_description": "SVM com C=1.0 e kernel RBF.",
"version_tags": {"stage": "etapa2", "framework": "sklearn"},
"version_alias": "challenger",
},
}O que faz: Dois componentes:
METRICS_REGISTRY— dicionário de métricas disponíveis (accuracy, f1_macro, precision, recall, roc_auc)SklearnTrainer.fit_evaluate()— treina qualquer modelo sklearn e retorna as métricas calculadas
Por que existe: Padroniza o cálculo de métricas. Sem isso, cada modelo calcularia métricas de forma diferente e você poderia usar average="binary" em um lugar e average="macro" em outro — tornando a comparação entre modelos inválida.
# Adicionar uma nova métrica para todos os modelos de uma vez
from sklearn.metrics import average_precision_score
METRICS_REGISTRY["pr_auc"] = lambda yt, _yp, prob: average_precision_score(yt, prob)O que faz: A classe PyTorchMLPTrainer:
- Separa 10% do treino para validação interna (early stopping)
- Executa o loop de treino com mini-batches
- Aplica
StepLRpara decaimento da taxa de aprendizado - Para automaticamente quando a loss de validação para de melhorar (
EarlyStopping) - Avalia no conjunto de teste e retorna métricas + lista de losses por época
Por que existe: O loop de treino PyTorch é verboso. Isolado aqui, o notebook fica limpo — só chama fit_evaluate() e recebe os resultados. O early stopping é fundamental para evitar overfitting sem precisar definir o número exato de épocas manualmente.
O que faz: A classe MLflowService padroniza como os experimentos são logados:
log_sklearn_run()— loga modelo sklearn, métricas, parâmetros e info do datasetlog_pytorch_run()— idem para PyTorch, mais a curva de loss por época
Quando register=True, ambos os métodos aceitam parâmetros opcionais para configurar a versão no Model Registry:
| Parâmetro | O que configura no Registry |
|---|---|
model_description |
Descrição geral do modelo (aparece na página do modelo) |
version_description |
Descrição da versão específica |
version_tags |
Tags chave-valor na versão (ex: stage, framework) |
version_alias |
Alias da versão (ex: "champion", "challenger") |
Internamente o método _configure_registered_version() usa o MlflowClient para aplicar todas essas configurações logo após o registro.
Os valores dessas configurações não ficam nos notebooks — ficam centralizados em
src/models/registry.py(chave"mlflow"de cada entrada). Os notebooks desempacotam com**entry["mlflow"].
Por que existe: Sem esse serviço, cada notebook chamaria mlflow.log_metric(), mlflow.log_params() etc. de forma diferente. Experimentos logados de forma inconsistente são difíceis de comparar na UI do MLflow. Centralizando aqui, todos os runs seguem o mesmo padrão — incluindo descrições e aliases no Registry.
O que faz: A classe ChurnPipeline conecta todos os módulos em sequência:
ChurnDataLoader → SklearnTrainer (para cada modelo do registry) → PyTorchMLPTrainer → MLflowService
Por que existe: Permite rodar o experimento completo com uma linha de Python, sem abrir nenhum notebook. Também serve de base para automação futura (CI/CD, agendamento de retreino).
make run
# equivalente a: python -c "from src.pipeline import ChurnPipeline; ChurnPipeline().run()"Os notebooks são a interface narrativa do projeto. Eles não contêm lógica de negócio — importam dos módulos src/ e apresentam a análise de forma legível.
| Notebook | Etapa | O que entrega |
|---|---|---|
eda.ipynb |
1 | Análise exploratória + justificativa das métricas escolhidas |
etapa1_baselines.ipynb |
1 | DummyClassifier e Logistic Regression registrados no MLflow |
etapa2_experimentos.ipynb |
2 | MLP PyTorch + comparação de 6+ modelos + análise de custo FP/FN |
Por que separar notebooks de módulos Python?
Notebooks são ótimos para exploração e apresentação, mas péssimos para reuso e teste. Código em .py pode ser importado, versionado, testado com pytest e executado em CI. A combinação dos dois é o padrão da indústria.
O que faz: Define tudo sobre o projeto em um arquivo só:
- Dependências de produção (pandas, torch, mlflow, sklearn...)
- Dependências de desenvolvimento (ruff, pytest)
- Configuração do linter (ruff) e do pytest
Por que existe: Sem isso, cada desenvolvedor instalaria versões diferentes das bibliotecas e os resultados seriam irreproduíveis. O poetry.lock garante que todos usem exatamente as mesmas versões.
O que faz: Agrupa os comandos mais usados:
make install # instala dependências
make lint # verifica estilo com ruff
make lint-fix # corrige automaticamente
make test # roda pytest
make run # executa o pipeline completo
make mlflow # sobe a UI do MLflow na porta 5000Por que existe: Padroniza como qualquer pessoa da equipe (ou um CI) executa as tarefas, sem precisar decorar comandos longos ou consultar documentação.
eda.ipynb
└── ChurnDataLoader ←── src/data/loader.py
etapa1_baselines.ipynb
├── ChurnDataLoader
├── SklearnTrainer ←── src/models/trainer.py
└── MLflowService ←── src/service/mlflow_service.py
etapa2_experimentos.ipynb
├── ChurnDataLoader
├── MODEL_REGISTRY ←── src/models/registry.py
├── SklearnTrainer
├── PyTorchMLPTrainer ←── src/models/mlp_trainer.py
│ └── ChurnMLP ←── src/models/mlp.py
└── MLflowService
pipeline.py (roda tudo acima via linha de comando)
Edite apenas src/models/registry.py:
# 1. Importe o modelo no topo do arquivo
from sklearn.svm import SVC
# 2. Adicione a entrada no dicionário MODEL_REGISTRY
MODEL_REGISTRY["svm"] = {
"model": SVC(C=1.0, kernel="rbf", probability=True, random_state=42),
"params": {"C": 1.0, "kernel": "rbf"},
}Pronto. Na próxima execução dos notebooks o SVM aparece automaticamente na tabela comparativa e é registrado no MLflow.
Se quiser mudar camadas/dropout sem criar novo arquivo, edite src/config.py:
mlp_cfg = MLPConfig(
hidden_sizes=[256, 128, 64, 32], # arquitetura mais profunda
dropout_rates=[0.4, 0.3, 0.2, 0.0],
learning_rate=5e-4,
epochs=150,
early_stopping_patience=15,
)Se quiser uma arquitetura completamente diferente (ex: com skip connections), crie um novo arquivo src/models/minha_rede.py herdando de nn.Module:
# src/models/minha_rede.py
import torch.nn as nn
class MinhaRede(nn.Module):
def __init__(self, input_dim):
super().__init__()
# defina as camadas aqui
...
def forward(self, x):
...
return x.squeeze(1) # retorna um logit por amostraDepois instancie essa rede dentro de PyTorchMLPTrainer (ou crie um trainer dedicado seguindo o mesmo padrão de mlp_trainer.py).
Passo 1 — Coloque o CSV em src/data/
src/data/
├── churn.csv ← dataset original
└── novo_dataset.csv ← novo arquivo
Passo 2 — Atualize DataConfig em src/config.py
data_cfg = DataConfig(
path=Path("src/data/novo_dataset.csv"),
target="nome_da_coluna_alvo", # coluna binária (0/1 ou Yes/No)
test_size=0.2,
)Passo 3 — Verifique se o pré-processamento se aplica
Abra src/data/loader.py e revise o método _clean():
def _clean(self, df):
# Esta linha é específica do dataset Telco (TotalCharges vem como string)
# Se o seu dataset não tiver esse problema, pode remover ou adaptar
if "TotalCharges" in df.columns:
df["TotalCharges"] = pd.to_numeric(df["TotalCharges"], errors="coerce")
# Esta linha funciona para qualquer dataset com target Yes/No
# Se o seu alvo já for 0/1, o (== "Yes") retornará False para 0 e True para 1
# Nesse caso substitua por: df[self.target] = df[self.target].astype(int)
if df[self.target].dtype == object:
df[self.target] = (df[self.target] == "Yes").astype(int)Passo 4 — Atualize o MLflowConfig para separar os experimentos
mlflow_cfg = MLflowConfig(experiment_name="novo-dataset-experimento-v1")Passo 5 — Rode os notebooks normalmente
O ChurnDataLoader detecta automaticamente quais colunas são numéricas e quais são categóricas e aplica o pré-processamento correto. Nenhuma outra mudança é necessária.
Combine os passos acima. A ordem recomendada é:
1. Adiciona o CSV em src/data/
2. Atualiza DataConfig (path + target)
3. Verifica _clean() em loader.py
4. Adiciona o modelo em registry.py
5. Atualiza experiment_name no MLflowConfig
6. Roda os notebooks
7. Compara no MLflow UI (http://localhost:5000)
| Prática | Onde |
|---|---|
| Seeds fixados para reprodutibilidade | config.py (RANDOM_STATE = 42) |
Logging estruturado (sem print()) |
config.py + setup_logging() |
| Validação cruzada estratificada | etapa2_experimentos.ipynb seção 10 |
| Versão do dataset registrada (SHA-256) | loader.py + mlflow_service.py |
| Early stopping | mlp_trainer.py |
| Linting com ruff | pyproject.toml + Makefile |
| Testes automatizados | src/tests/ |
| Model Registry com descrição, tags e alias | mlflow_service.py + notebooks |