Modelo de Predição de Turnover
Modelo de ML que antecipa risco de saída voluntária com 87% de acurácia, impactando a retenção de 2.400 colaboradores.
Demonstração do projeto
O projeto nasceu da necessidade de reduzir o custo de rotatividade no Banco BV, que representava um impacto financeiro de R$12M/ano. Construímos um pipeline completo de machine learning — da coleta e engenharia de features até a inferência em produção — que classifica colaboradores em faixas de risco (baixo, médio, alto) com base em dados comportamentais, estruturais e de carreira, permitindo ações preventivas de retenção antes que a intenção de saída se consolide.
Impacto & Métricas
87%
Acurácia
+12pp vs baseline
0.91
AUC-ROC
30%
Redução de turnover
em 12 meses
2.400
Colaboradores impactados
R$ 3.6M
ROI estimado
Contexto e Problema
O custo de reposição de um colaborador pode chegar a 1,5x o salário anual do profissional.
Com uma taxa de turnover voluntário de 18% ao ano e um quadro de 2.400 pessoas,
o banco enfrentava uma perda estimada de R$ 12M/ano em custos diretos e indiretos.
A solução convencional de RH — ação reativa pós-pedido de demissão — mostrava-se ineficaz.
Precisávamos de um modelo preditivo e acionável.
Arquitetura da Solução
O pipeline foi estruturado em 4 camadas:
1. Ingestão de dados: coleta de 7 sistemas de RH (HRIS, LMS, pesquisa de clima,
folha de pagamento, ponto eletrônico, avaliação de desempenho, recrutamento interno)
2. Feature Engineering: 42 variáveis derivadas, incluindo scores compostos de
engajamento, velocidade de progressão de carreira e sinais comportamentais
3. Modelagem: Gradient Boosting com validação cruzada estratificada (5-fold),
otimização via Optuna para hiperparâmetros
4. Inferência e ação: score diário por colaborador com classificação em faixas de
risco, disponibilizado via dashboard para gestores
Decisões Técnicas
Por que GBM e não Random Forest?
GBM apresentou AUC-ROC 4pp superior no conjunto de validação.
A natureza sequencial do boosting captura melhor as interações entre
variáveis de carreira (ex: ausência de promoção × tempo de empresa × score de clima).
Tratamento de desbalanceamento de classes
A taxa de turnover de 18% gera desbalanceamento. Usamos SMOTE na camada de treino
combinado com ajuste do `class_weight` no GBM, obtendo F1-score de 0.82 na classe positiva.
Interpretabilidade com SHAP
Para cada colaborador em risco, o sistema gera os 5 principais fatores que
contribuíram para a classificação, permitindo ao gestor entender o contexto antes da conversa.
import pandas as pd
import numpy as np
from sklearn.preprocessing import LabelEncoder
def build_features(df: pd.DataFrame) -> pd.DataFrame:
"""
Engenharia de features para predição de turnover.
Gera variáveis comportamentais, de carreira e estruturais.
"""
features = df.copy()
# Tempo na empresa (dias → anos)
features["tenure_years"] = (
pd.to_datetime("today") - pd.to_datetime(features["hire_date"])
).dt.days / 365.25
# Velocidade de progressão de carreira
features["promo_rate"] = (
features["promotions_count"] / features["tenure_years"].clip(lower=0.5)
)
# Distância da última avaliação de desempenho (meses)
features["months_since_review"] = (
pd.to_datetime("today") - pd.to_datetime(features["last_review_date"])
).dt.days / 30
# Score de engajamento composto
features["engagement_score"] = (
features["survey_score"] * 0.4
+ features["training_hours_ytd"].clip(upper=40) / 40 * 0.3
+ (1 - features["absence_rate_6m"]) * 0.3
)
# Sinais de desengajamento recente
features["declining_performance"] = (
(features["perf_score_current"] < features["perf_score_previous"]).astype(int)
)
return features
def encode_categoricals(df: pd.DataFrame, cat_cols: list[str]) -> pd.DataFrame:
"""Label encoding para variáveis categóricas."""
enc = LabelEncoder()
for col in cat_cols:
df[col] = enc.fit_transform(df[col].astype(str))
return dfimport pandas as pd
import numpy as np
from sklearn.preprocessing import LabelEncoder
def build_features(df: pd.DataFrame) -> pd.DataFrame:
"""
Engenharia de features para predição de turnover.
Gera variáveis comportamentais, de carreira e estruturais.
"""
features = df.copy()
# Tempo na empresa (dias → anos)
features["tenure_years"] = (
pd.to_datetime("today") - pd.to_datetime(features["hire_date"])
).dt.days / 365.25
# Velocidade de progressão de carreira
features["promo_rate"] = (
features["promotions_count"] / features["tenure_years"].clip(lower=0.5)
)
# Distância da última avaliação de desempenho (meses)
features["months_since_review"] = (
pd.to_datetime("today") - pd.to_datetime(features["last_review_date"])
).dt.days / 30
# Score de engajamento composto
features["engagement_score"] = (
features["survey_score"] * 0.4
+ features["training_hours_ytd"].clip(upper=40) / 40 * 0.3
+ (1 - features["absence_rate_6m"]) * 0.3
)
# Sinais de desengajamento recente
features["declining_performance"] = (
(features["perf_score_current"] < features["perf_score_previous"]).astype(int)
)
return features
def encode_categoricals(df: pd.DataFrame, cat_cols: list[str]) -> pd.DataFrame:
"""Label encoding para variáveis categóricas."""
enc = LabelEncoder()
for col in cat_cols:
df[col] = enc.fit_transform(df[col].astype(str))
return dffrom sklearn.ensemble import GradientBoostingClassifier
from sklearn.model_selection import StratifiedKFold, cross_val_score
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
import numpy as np
def train_turnover_model(X_train, y_train):
"""
Treina pipeline de GBM para predição de turnover.
Usa StratifiedKFold para preservar proporção de classes.
"""
pipeline = Pipeline([
("scaler", StandardScaler()),
("clf", GradientBoostingClassifier(
n_estimators=300,
learning_rate=0.05,
max_depth=4,
min_samples_leaf=20,
subsample=0.8,
random_state=42,
)),
])
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
scores = cross_val_score(pipeline, X_train, y_train, cv=cv, scoring="roc_auc")
print(f"AUC-ROC CV: {scores.mean():.3f} ± {scores.std():.3f}")
pipeline.fit(X_train, y_train)
return pipelinefrom sklearn.ensemble import GradientBoostingClassifier
from sklearn.model_selection import StratifiedKFold, cross_val_score
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
import numpy as np
def train_turnover_model(X_train, y_train):
"""
Treina pipeline de GBM para predição de turnover.
Usa StratifiedKFold para preservar proporção de classes.
"""
pipeline = Pipeline([
("scaler", StandardScaler()),
("clf", GradientBoostingClassifier(
n_estimators=300,
learning_rate=0.05,
max_depth=4,
min_samples_leaf=20,
subsample=0.8,
random_state=42,
)),
])
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
scores = cross_val_score(pipeline, X_train, y_train, cv=cv, scoring="roc_auc")
print(f"AUC-ROC CV: {scores.mean():.3f} ± {scores.std():.3f}")
pipeline.fit(X_train, y_train)
return pipeline-- Classificação de risco por colaborador
-- Atualizado diariamente via job agendado
WITH predictions AS (
SELECT
employee_id,
prediction_score,
prediction_date,
CASE
WHEN prediction_score >= 0.70 THEN 'Alto'
WHEN prediction_score >= 0.40 THEN 'Médio'
ELSE 'Baixo'
END AS risk_level
FROM ml_predictions.turnover_scores
WHERE prediction_date = CURRENT_DATE
),
employee_context AS (
SELECT
e.employee_id,
e.full_name,
e.department,
e.manager_id,
e.tenure_years,
e.last_salary_review_date
FROM hr.employees e
WHERE e.active = TRUE
)
SELECT
ec.*,
p.prediction_score,
p.risk_level,
DATEDIFF(CURRENT_DATE, ec.last_salary_review_date) AS days_since_salary_review
FROM employee_context ec
INNER JOIN predictions p ON ec.employee_id = p.employee_id
ORDER BY p.prediction_score DESC;-- Classificação de risco por colaborador
-- Atualizado diariamente via job agendado
WITH predictions AS (
SELECT
employee_id,
prediction_score,
prediction_date,
CASE
WHEN prediction_score >= 0.70 THEN 'Alto'
WHEN prediction_score >= 0.40 THEN 'Médio'
ELSE 'Baixo'
END AS risk_level
FROM ml_predictions.turnover_scores
WHERE prediction_date = CURRENT_DATE
),
employee_context AS (
SELECT
e.employee_id,
e.full_name,
e.department,
e.manager_id,
e.tenure_years,
e.last_salary_review_date
FROM hr.employees e
WHERE e.active = TRUE
)
SELECT
ec.*,
p.prediction_score,
p.risk_level,
DATEDIFF(CURRENT_DATE, ec.last_salary_review_date) AS days_since_salary_review
FROM employee_context ec
INNER JOIN predictions p ON ec.employee_id = p.employee_id
ORDER BY p.prediction_score DESC;