Update src/streamlit_app.py
Browse files- src/streamlit_app.py +79 -114
src/streamlit_app.py
CHANGED
|
@@ -10,8 +10,7 @@ from sklearn.tree import DecisionTreeClassifier
|
|
| 10 |
from sklearn.ensemble import RandomForestClassifier, AdaBoostClassifier, GradientBoostingClassifier
|
| 11 |
from xgboost import XGBClassifier
|
| 12 |
from lightgbm import LGBMClassifier
|
| 13 |
-
from sklearn.metrics import roc_auc_score, roc_curve, accuracy_score, precision_score, recall_score, f1_score,
|
| 14 |
-
confusion_matrix, ConfusionMatrixDisplay
|
| 15 |
import matplotlib.pyplot as plt
|
| 16 |
import seaborn as sns
|
| 17 |
import numpy as np
|
|
@@ -19,13 +18,12 @@ import io
|
|
| 19 |
from sklearn.feature_selection import RFE
|
| 20 |
from sklearn.linear_model import LogisticRegression
|
| 21 |
|
|
|
|
| 22 |
# Configuração da página do Streamlit
|
| 23 |
st.set_page_config(layout="wide", page_title="Previsão de Reclamações de Clientes")
|
| 24 |
|
| 25 |
st.title("📊 Previsão de Reclamações de Clientes com Modelos Supervisionados")
|
| 26 |
-
st.markdown(
|
| 27 |
-
"Este dashboard tem como objetivo identificar clientes com maior probabilidade de terem feito uma reclamação nos últimos 2 anos, utilizando modelos de Machine Learning.")
|
| 28 |
-
|
| 29 |
|
| 30 |
# --- Carregamento e Pré-processamento dos Dados ---
|
| 31 |
@st.cache_data
|
|
@@ -38,7 +36,6 @@ def load_data():
|
|
| 38 |
st.stop()
|
| 39 |
return df
|
| 40 |
|
| 41 |
-
|
| 42 |
@st.cache_data
|
| 43 |
def preprocess_data(df):
|
| 44 |
df_processed = df.copy()
|
|
@@ -47,41 +44,34 @@ def preprocess_data(df):
|
|
| 47 |
df_processed['Dt_Customer'] = pd.to_datetime(df_processed['Dt_Customer'], format='%d-%m-%Y')
|
| 48 |
reference_date = df_processed['Dt_Customer'].min()
|
| 49 |
df_processed['Days_Since_Customer'] = (df_processed['Dt_Customer'] - reference_date).dt.days
|
| 50 |
-
df_processed = df_processed.drop('Dt_Customer', axis=1)
|
| 51 |
|
| 52 |
-
#
|
| 53 |
-
# Inclui colunas como Kidhome, Teenhome, AcceptedCmpX, Response que devem ser numéricas
|
| 54 |
cols_to_coerce_numeric = [
|
| 55 |
'Kidhome', 'Teenhome', 'Recency', 'MntWines', 'MntFruits', 'MntMeatProducts',
|
| 56 |
'MntFishProducts', 'MntSweetProducts', 'MntGoldProds', 'NumDealsPurchases',
|
| 57 |
'NumWebPurchases', 'NumCatalogPurchases', 'NumStorePurchases',
|
| 58 |
'NumWebVisitsMonth', 'AcceptedCmp1', 'AcceptedCmp2', 'AcceptedCmp3',
|
| 59 |
'AcceptedCmp4', 'AcceptedCmp5', 'Response', 'Days_Since_Customer', 'Income'
|
| 60 |
-
# Adicionado Income aqui para garantir
|
| 61 |
]
|
| 62 |
for col in cols_to_coerce_numeric:
|
| 63 |
if col in df_processed.columns:
|
| 64 |
df_processed[col] = pd.to_numeric(df_processed[col], errors='coerce')
|
| 65 |
-
df_processed[col] = df_processed[col].fillna(0)
|
| 66 |
-
|
| 67 |
-
# Lidar com valores ausentes: preencher 'Income' com a média (se ainda houver, após coerção)
|
| 68 |
-
# df_processed['Income'] = df_processed['Income'].fillna(df_processed['Income'].mean()) # Removido, já tratado acima
|
| 69 |
|
| 70 |
# Convertendo variáveis categóricas em numéricas (one-hot encoding)
|
| 71 |
df_processed = pd.get_dummies(df_processed, columns=['Education', 'Marital_Status'], drop_first=True)
|
| 72 |
|
| 73 |
# Excluir colunas irrelevantes e com variância zero
|
| 74 |
cols_to_drop = ['ID', 'Z_CostContact', 'Z_Revenue']
|
| 75 |
-
df_processed = df_processed.drop(columns=[col for col in cols_to_drop if col in df_processed.columns], axis=1,
|
| 76 |
-
|
| 77 |
-
|
| 78 |
# Remover colunas com variância zero (constantes) ou com muitos nulos após o pré-processamento
|
| 79 |
-
df_processed = df_processed.loc[:, df_processed.nunique() > 1]
|
| 80 |
-
df_processed = df_processed.dropna(axis=1, how='all')
|
| 81 |
|
| 82 |
return df_processed
|
| 83 |
|
| 84 |
-
|
| 85 |
# Função para treinar e avaliar modelos
|
| 86 |
@st.cache_data(show_spinner=False)
|
| 87 |
def train_and_evaluate_models(X_train_raw, X_test_raw, y_train, y_test, _scaler, model_selected=None):
|
|
@@ -98,36 +88,33 @@ def train_and_evaluate_models(X_train_raw, X_test_raw, y_train, y_test, _scaler,
|
|
| 98 |
|
| 99 |
results = {}
|
| 100 |
|
| 101 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
if len(np.unique(y_train)) < 2:
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
else:
|
| 106 |
-
st.error(
|
| 107 |
-
"Erro: O conjunto de treino contém apenas uma classe na variável alvo. Verifique o balanceamento ou a divisão dos dados.")
|
| 108 |
-
return {}
|
| 109 |
|
| 110 |
-
# Check if X_train_raw has enough samples
|
| 111 |
if X_train_raw.shape[0] == 0:
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
return {}
|
| 117 |
-
|
| 118 |
-
# Verificar se os dtypes são numéricos antes de treinar
|
| 119 |
for col in X_train_raw.columns:
|
| 120 |
if not pd.api.types.is_numeric_dtype(X_train_raw[col]):
|
| 121 |
-
st.error(
|
| 122 |
-
f"Erro: Coluna '{col}' no X_train_raw não é numérica. Tipo: {X_train_raw[col].dtype}. Verifique o pré-processamento.")
|
| 123 |
return {}
|
| 124 |
|
| 125 |
for col in X_test_raw.columns:
|
| 126 |
if not pd.api.types.is_numeric_dtype(X_test_raw[col]):
|
| 127 |
-
st.error(
|
| 128 |
-
f"Erro: Coluna '{col}' no X_test_raw não é numérica. Tipo: {X_test_raw[col].dtype}. Verifique o pré-processamento.")
|
| 129 |
return {}
|
| 130 |
|
|
|
|
| 131 |
for name, model in models.items():
|
| 132 |
if model_selected and name != model_selected:
|
| 133 |
continue
|
|
@@ -136,15 +123,15 @@ def train_and_evaluate_models(X_train_raw, X_test_raw, y_train, y_test, _scaler,
|
|
| 136 |
if name in ["K-Nearest Neighbors", "Support Vector Machine"]:
|
| 137 |
X_train_processed = _scaler.fit_transform(X_train_raw)
|
| 138 |
X_test_processed = _scaler.transform(X_test_raw)
|
| 139 |
-
else:
|
| 140 |
X_train_processed = X_train_raw
|
| 141 |
X_test_processed = X_test_raw
|
| 142 |
|
| 143 |
try:
|
| 144 |
model.fit(X_train_processed, y_train)
|
| 145 |
y_pred = model.predict(X_test_processed)
|
| 146 |
-
|
| 147 |
-
#
|
| 148 |
if hasattr(model, 'predict_proba'):
|
| 149 |
probas = model.predict_proba(X_test_processed)
|
| 150 |
if probas.shape[1] > 1:
|
|
@@ -152,7 +139,7 @@ def train_and_evaluate_models(X_train_raw, X_test_raw, y_train, y_test, _scaler,
|
|
| 152 |
else:
|
| 153 |
y_prob = probas[:, 0]
|
| 154 |
else:
|
| 155 |
-
y_prob = y_pred
|
| 156 |
|
| 157 |
# Calcular ROC AUC apenas se y_prob não for totalmente binário (0 ou 1)
|
| 158 |
if len(np.unique(y_prob)) > 1:
|
|
@@ -176,20 +163,16 @@ def train_and_evaluate_models(X_train_raw, X_test_raw, y_train, y_test, _scaler,
|
|
| 176 |
"TPR": tpr,
|
| 177 |
"y_prob": y_prob
|
| 178 |
}
|
| 179 |
-
except
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
f"Não foi possível treinar o modelo {name} devido a um erro: {e}. Provavelmente dados de teste/treino insuficientes ou de apenas uma classe.")
|
| 183 |
-
# Se for chamada inicial (dummy), não mostra nada no front
|
| 184 |
results[name] = {
|
| 185 |
"Model": None, "Accuracy": 0, "Precision": 0, "Recall": 0, "F1-score": 0,
|
| 186 |
-
"AUC": 0.5, "Confusion Matrix": np.array([[0,
|
| 187 |
-
"y_prob": np.zeros(len(y_test))
|
| 188 |
}
|
| 189 |
continue
|
| 190 |
return results
|
| 191 |
|
| 192 |
-
|
| 193 |
# --- Carregar e Pré-processar os dados ---
|
| 194 |
df = load_data()
|
| 195 |
df_processed = preprocess_data(df)
|
|
@@ -203,21 +186,17 @@ st.sidebar.header("⚙️ Configurações do Modelo")
|
|
| 203 |
# Balanceamento da Base
|
| 204 |
st.sidebar.subheader("Balanceamento de Dados (SMOTE)")
|
| 205 |
balance_data = st.sidebar.checkbox("Aplicar SMOTE", value=True)
|
| 206 |
-
st.sidebar.info(
|
| 207 |
-
"SMOTE cria amostras sintéticas da classe minoritária para balancear os dados, melhorando o desempenho em datasets desbalanceados.")
|
| 208 |
|
| 209 |
# Seleção de Variáveis
|
| 210 |
st.sidebar.subheader("Seleção de Variáveis")
|
| 211 |
use_rfe = st.sidebar.checkbox("Usar Seleção de Variáveis (RFE)", value=False)
|
| 212 |
if use_rfe:
|
| 213 |
-
# Garante que X tem colunas suficientes para o slider
|
| 214 |
max_features_rfe = X.shape[1] if X.shape[1] > 5 else 5
|
| 215 |
-
n_features_rfe = st.sidebar.slider("Número de Variáveis a Selecionar (RFE)", 5, max_features_rfe,
|
| 216 |
-
|
| 217 |
-
st.sidebar.info(
|
| 218 |
-
f"O RFE (Recursive Feature Elimination) seleciona as {n_features_rfe} melhores variáveis de forma iterativa.")
|
| 219 |
estimator_rfe = LogisticRegression(max_iter=1000, random_state=42)
|
| 220 |
-
|
| 221 |
if X.shape[0] > 0 and X.shape[1] >= n_features_rfe:
|
| 222 |
try:
|
| 223 |
selector_rfe = RFE(estimator_rfe, n_features_to_select=n_features_rfe, step=1)
|
|
@@ -229,20 +208,20 @@ if use_rfe:
|
|
| 229 |
st.sidebar.error(f"Erro ao aplicar RFE: {e}. RFE desabilitado.")
|
| 230 |
use_rfe = False
|
| 231 |
else:
|
| 232 |
-
st.sidebar.warning(
|
| 233 |
-
f"Não há dados suficientes ({X.shape[0]} amostras ou {X.shape[1]} colunas) para aplicar RFE com {n_features_rfe} features. RFE desabilitado.")
|
| 234 |
use_rfe = False
|
| 235 |
|
| 236 |
# Escolha do Modelo
|
| 237 |
st.sidebar.subheader("Seleção de Modelo para Treinamento")
|
| 238 |
|
| 239 |
-
# === CORREÇÃO:
|
| 240 |
st.session_state['is_initial_call'] = True
|
|
|
|
| 241 |
dummy_X_for_keys = pd.DataFrame(np.zeros((1, X.shape[1])), columns=X.columns)
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
st.session_state['is_initial_call'] = False
|
| 246 |
|
| 247 |
model_choice = st.sidebar.selectbox(
|
| 248 |
"Escolha o Modelo Principal para Análise Detalhada:",
|
|
@@ -254,11 +233,11 @@ st.sidebar.markdown("Desenvolvido por seu AI Assistant")
|
|
| 254 |
|
| 255 |
# --- Abas do Dashboard ---
|
| 256 |
tab1, tab2, tab3, tab4, tab5 = st.tabs([
|
| 257 |
-
"
|
| 258 |
-
"
|
| 259 |
-
"
|
| 260 |
-
"
|
| 261 |
-
"
|
| 262 |
])
|
| 263 |
|
| 264 |
with tab1:
|
|
@@ -287,8 +266,7 @@ with tab1:
|
|
| 287 |
|
| 288 |
with tab2:
|
| 289 |
st.header("2. Balanceamento de Dados com SMOTE")
|
| 290 |
-
st.write(
|
| 291 |
-
"A seguir, demonstramos o efeito do balanceamento da variável alvo 'Complain' utilizando a técnica **SMOTE**.")
|
| 292 |
|
| 293 |
X_display = X.copy()
|
| 294 |
y_display = y.copy()
|
|
@@ -312,8 +290,7 @@ with tab2:
|
|
| 312 |
ax.set_ylabel("Contagem")
|
| 313 |
st.pyplot(fig)
|
| 314 |
except Exception as e:
|
| 315 |
-
st.error(
|
| 316 |
-
f"Erro ao aplicar SMOTE: {e}. Isso pode acontecer se houver poucas amostras na classe minoritária ou muitas features.")
|
| 317 |
X_res, y_res = X_display, y_display
|
| 318 |
else:
|
| 319 |
st.info("SMOTE desabilitado. O balanceamento não será aplicado.")
|
|
@@ -326,23 +303,21 @@ with tab2:
|
|
| 326 |
st.subheader("Divisão dos Dados (Treino/Teste)")
|
| 327 |
test_size = st.slider("Tamanho do Conjunto de Teste", 0.1, 0.5, 0.3, 0.05)
|
| 328 |
if len(np.unique(y_res)) > 1:
|
| 329 |
-
X_train, X_test, y_train, y_test = train_test_split(X_res, y_res, test_size=test_size, random_state=42,
|
| 330 |
-
stratify=y_res)
|
| 331 |
else:
|
| 332 |
-
st.warning(
|
| 333 |
-
"Não foi possível usar `stratify` no `train_test_split` pois o alvo tem apenas uma classe após o processamento. Dividindo sem estratificação.")
|
| 334 |
X_train, X_test, y_train, y_test = train_test_split(X_res, y_res, test_size=test_size, random_state=42)
|
| 335 |
|
| 336 |
-
|
| 337 |
-
st.write("Shape
|
| 338 |
-
st.write("Shape
|
| 339 |
-
st.write("Shape
|
| 340 |
-
st.write("Shape
|
| 341 |
-
st.write("
|
| 342 |
-
st.
|
| 343 |
-
st.write("Primeiras 5 linhas (após pré-processamento)
|
| 344 |
-
st.
|
| 345 |
-
|
| 346 |
|
| 347 |
if X_train.empty or y_train.empty:
|
| 348 |
st.error("Os dados de treino estão vazios! Verifique o carregamento ou pré-processamento dos dados.")
|
|
@@ -352,8 +327,8 @@ with tab2:
|
|
| 352 |
st.stop()
|
| 353 |
|
| 354 |
st.subheader("Escalonamento de Dados")
|
| 355 |
-
st.write(
|
| 356 |
-
|
| 357 |
|
| 358 |
with tab3:
|
| 359 |
st.header("3. Comparação de Modelos Supervisionados")
|
|
@@ -395,8 +370,7 @@ with tab3:
|
|
| 395 |
st.markdown("""
|
| 396 |
Para problemas de previsão de reclamações, o **Recall** é frequentemente crucial, pois minimiza Falsos Negativos (clientes que reclamam mas não são previstos). No entanto, um bom **AUC** (Área sob a Curva ROC) indica a capacidade geral do modelo de distinguir entre as classes, e o **F1-score** oferece um equilíbrio entre Precisão e Recall.
|
| 397 |
""")
|
| 398 |
-
st.success(
|
| 399 |
-
f"**Recomendação:** O modelo com o maior **AUC** é geralmente um bom ponto de partida, pois indica a melhor capacidade discriminatória geral. Para este exemplo, o modelo principal para análise detalhada será o selecionado na sidebar: **{model_choice}**.")
|
| 400 |
|
| 401 |
with tab4:
|
| 402 |
st.header("4. Análise Detalhada do Modelo Selecionado")
|
|
@@ -404,11 +378,10 @@ with tab4:
|
|
| 404 |
|
| 405 |
if st.button(f"Analisar {model_choice}"):
|
| 406 |
with st.spinner(f"Analisando {model_choice}..."):
|
| 407 |
-
selected_model_results = train_and_evaluate_models(X_train, X_test, y_train, y_test, StandardScaler(),
|
| 408 |
-
|
| 409 |
-
|
| 410 |
if model_choice not in selected_model_results or selected_model_results[model_choice]['Model'] is None:
|
| 411 |
-
st.error(f"Não foi possível analisar o modelo {model_choice}. Ele pode ter falhado no treinamento.")
|
| 412 |
else:
|
| 413 |
metrics = selected_model_results[model_choice]
|
| 414 |
|
|
@@ -421,8 +394,7 @@ with tab4:
|
|
| 421 |
|
| 422 |
st.subheader(f"Matriz de Confusão para {model_choice}")
|
| 423 |
fig_cm, ax_cm = plt.subplots(figsize=(7, 6))
|
| 424 |
-
disp = ConfusionMatrixDisplay(confusion_matrix=metrics['Confusion Matrix'],
|
| 425 |
-
display_labels=['Não Reclamou (0)', 'Reclamou (1)'])
|
| 426 |
disp.plot(cmap=plt.cm.Blues, ax=ax_cm)
|
| 427 |
ax_cm.set_title(f'Matriz de Confusão para {model_choice}')
|
| 428 |
st.pyplot(fig_cm)
|
|
@@ -437,8 +409,7 @@ with tab4:
|
|
| 437 |
|
| 438 |
st.subheader(f"Curva ROC para {model_choice}")
|
| 439 |
fig_roc_single, ax_roc_single = plt.subplots(figsize=(8, 6))
|
| 440 |
-
ax_roc_single.plot(metrics['FPR'], metrics['TPR'], color='darkorange', lw=2,
|
| 441 |
-
label=f'Curva ROC (AUC = {metrics["AUC"]:.2f})')
|
| 442 |
ax_roc_single.plot([0, 1], [0, 1], color='navy', lw=2, linestyle='--', label='Classificador Aleatório')
|
| 443 |
ax_roc_single.set_xlabel('Taxa de Falsos Positivos (FPR)')
|
| 444 |
ax_roc_single.set_ylabel('Taxa de Verdadeiros Positivos (TPR)')
|
|
@@ -446,8 +417,7 @@ with tab4:
|
|
| 446 |
ax_roc_single.legend(loc='lower right')
|
| 447 |
ax_roc_single.grid(True)
|
| 448 |
st.pyplot(fig_roc_single)
|
| 449 |
-
st.write(
|
| 450 |
-
f"O **AUC** de {metrics['AUC']:.2f} indica a capacidade discriminatória do modelo: quanto mais próximo de 1, melhor o modelo distingue entre as classes.")
|
| 451 |
|
| 452 |
st.subheader("Sensibilidade aos Hiperparâmetros")
|
| 453 |
if model_choice == "K-Nearest Neighbors":
|
|
@@ -467,8 +437,7 @@ with tab4:
|
|
| 467 |
Modelos de Boosting como XGBoost e LightGBM são influenciados por `n_estimators` (número de estimadores), `learning_rate` (taxa de aprendizado) e `max_depth`. Uma `learning_rate` menor com mais estimadores pode melhorar o desempenho, mas requer mais tempo de treinamento. `Max_depth` controla a complexidade de cada árvore.
|
| 468 |
""")
|
| 469 |
else:
|
| 470 |
-
st.markdown(
|
| 471 |
-
"Este modelo também possui hiperparâmetros que podem ser ajustados para otimizar o desempenho (ex: `max_depth` para Decision Tree, `n_estimators` para AdaBoosting/Gradient Boosting).")
|
| 472 |
|
| 473 |
with tab5:
|
| 474 |
st.header("5. Tomada de Decisão e Aplicação Gerencial")
|
|
@@ -476,12 +445,10 @@ with tab5:
|
|
| 476 |
|
| 477 |
if st.button("Gerar Análise Gerencial"):
|
| 478 |
with st.spinner("Gerando insights gerenciais..."):
|
| 479 |
-
selected_model_results = train_and_evaluate_models(X_train, X_test, y_train, y_test, StandardScaler(),
|
| 480 |
-
|
| 481 |
-
|
| 482 |
if model_choice not in selected_model_results or selected_model_results[model_choice]['Model'] is None:
|
| 483 |
-
st.error(
|
| 484 |
-
f"Não foi possível gerar a análise gerencial para o modelo {model_choice}. Ele pode ter falhado no treinamento.")
|
| 485 |
else:
|
| 486 |
model_instance = selected_model_results[model_choice]["Model"]
|
| 487 |
|
|
@@ -490,8 +457,7 @@ with tab5:
|
|
| 490 |
if hasattr(model_instance, 'feature_importances_'):
|
| 491 |
feature_importances = model_instance.feature_importances_
|
| 492 |
feature_names = X.columns.tolist()
|
| 493 |
-
importance_df = pd.DataFrame(
|
| 494 |
-
{'Variável': feature_names, 'Importância Relativa': feature_importances})
|
| 495 |
importance_df = importance_df.sort_values(by='Importância Relativa', ascending=False)
|
| 496 |
st.dataframe(importance_df.head(10).set_index('Variável'))
|
| 497 |
|
|
@@ -503,8 +469,7 @@ with tab5:
|
|
| 503 |
elif hasattr(model_instance, 'coef_'):
|
| 504 |
st.info("Para modelos lineares, os coeficientes podem ser interpretados como importância.")
|
| 505 |
else:
|
| 506 |
-
st.info(
|
| 507 |
-
"Não foi possível extrair a importância das variáveis para este tipo de modelo de forma direta.")
|
| 508 |
|
| 509 |
st.subheader("Análise e Recomendações Gerenciais")
|
| 510 |
|
|
|
|
| 10 |
from sklearn.ensemble import RandomForestClassifier, AdaBoostClassifier, GradientBoostingClassifier
|
| 11 |
from xgboost import XGBClassifier
|
| 12 |
from lightgbm import LGBMClassifier
|
| 13 |
+
from sklearn.metrics import roc_auc_score, roc_curve, accuracy_score, precision_score, recall_score, f1_score, confusion_matrix, ConfusionMatrixDisplay
|
|
|
|
| 14 |
import matplotlib.pyplot as plt
|
| 15 |
import seaborn as sns
|
| 16 |
import numpy as np
|
|
|
|
| 18 |
from sklearn.feature_selection import RFE
|
| 19 |
from sklearn.linear_model import LogisticRegression
|
| 20 |
|
| 21 |
+
|
| 22 |
# Configuração da página do Streamlit
|
| 23 |
st.set_page_config(layout="wide", page_title="Previsão de Reclamações de Clientes")
|
| 24 |
|
| 25 |
st.title("📊 Previsão de Reclamações de Clientes com Modelos Supervisionados")
|
| 26 |
+
st.markdown("Este dashboard tem como objetivo identificar clientes com maior probabilidade de terem feito uma reclamação nos últimos 2 anos, utilizando modelos de Machine Learning.")
|
|
|
|
|
|
|
| 27 |
|
| 28 |
# --- Carregamento e Pré-processamento dos Dados ---
|
| 29 |
@st.cache_data
|
|
|
|
| 36 |
st.stop()
|
| 37 |
return df
|
| 38 |
|
|
|
|
| 39 |
@st.cache_data
|
| 40 |
def preprocess_data(df):
|
| 41 |
df_processed = df.copy()
|
|
|
|
| 44 |
df_processed['Dt_Customer'] = pd.to_datetime(df_processed['Dt_Customer'], format='%d-%m-%Y')
|
| 45 |
reference_date = df_processed['Dt_Customer'].min()
|
| 46 |
df_processed['Days_Since_Customer'] = (df_processed['Dt_Customer'] - reference_date).dt.days
|
| 47 |
+
df_processed = df_processed.drop('Dt_Customer', axis=1) # Remove coluna original de data
|
| 48 |
|
| 49 |
+
# Coerção explícita para numérico para colunas que podem vir como 'object'
|
|
|
|
| 50 |
cols_to_coerce_numeric = [
|
| 51 |
'Kidhome', 'Teenhome', 'Recency', 'MntWines', 'MntFruits', 'MntMeatProducts',
|
| 52 |
'MntFishProducts', 'MntSweetProducts', 'MntGoldProds', 'NumDealsPurchases',
|
| 53 |
'NumWebPurchases', 'NumCatalogPurchases', 'NumStorePurchases',
|
| 54 |
'NumWebVisitsMonth', 'AcceptedCmp1', 'AcceptedCmp2', 'AcceptedCmp3',
|
| 55 |
'AcceptedCmp4', 'AcceptedCmp5', 'Response', 'Days_Since_Customer', 'Income'
|
|
|
|
| 56 |
]
|
| 57 |
for col in cols_to_coerce_numeric:
|
| 58 |
if col in df_processed.columns:
|
| 59 |
df_processed[col] = pd.to_numeric(df_processed[col], errors='coerce')
|
| 60 |
+
df_processed[col] = df_processed[col].fillna(0) # Preenche NaN com 0 após coerção, se houver
|
|
|
|
|
|
|
|
|
|
| 61 |
|
| 62 |
# Convertendo variáveis categóricas em numéricas (one-hot encoding)
|
| 63 |
df_processed = pd.get_dummies(df_processed, columns=['Education', 'Marital_Status'], drop_first=True)
|
| 64 |
|
| 65 |
# Excluir colunas irrelevantes e com variância zero
|
| 66 |
cols_to_drop = ['ID', 'Z_CostContact', 'Z_Revenue']
|
| 67 |
+
df_processed = df_processed.drop(columns=[col for col in cols_to_drop if col in df_processed.columns], axis=1, errors='ignore')
|
| 68 |
+
|
|
|
|
| 69 |
# Remover colunas com variância zero (constantes) ou com muitos nulos após o pré-processamento
|
| 70 |
+
df_processed = df_processed.loc[:, df_processed.nunique() > 1] # Remove colunas com apenas 1 valor único
|
| 71 |
+
df_processed = df_processed.dropna(axis=1, how='all') # Remove colunas totalmente nulas
|
| 72 |
|
| 73 |
return df_processed
|
| 74 |
|
|
|
|
| 75 |
# Função para treinar e avaliar modelos
|
| 76 |
@st.cache_data(show_spinner=False)
|
| 77 |
def train_and_evaluate_models(X_train_raw, X_test_raw, y_train, y_test, _scaler, model_selected=None):
|
|
|
|
| 88 |
|
| 89 |
results = {}
|
| 90 |
|
| 91 |
+
# --- NOVO: Se for uma chamada inicial, apenas retorna as chaves sem tentar treinar ---
|
| 92 |
+
if st.session_state.get('is_initial_call', False):
|
| 93 |
+
return {name: {} for name in models.keys()}
|
| 94 |
+
# --- FIM NOVO ---
|
| 95 |
+
|
| 96 |
+
# Check if y_train has at least two classes before attempting to train (real training calls)
|
| 97 |
if len(np.unique(y_train)) < 2:
|
| 98 |
+
st.error("Erro: O conjunto de treino contém apenas uma classe na variável alvo. Verifique o balanceamento ou a divisão dos dados.")
|
| 99 |
+
return {}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
|
| 101 |
+
# Check if X_train_raw has enough samples (real training calls)
|
| 102 |
if X_train_raw.shape[0] == 0:
|
| 103 |
+
st.error("Erro: Dados de treino com 0 amostras. Não é possível treinar modelos.")
|
| 104 |
+
return {}
|
| 105 |
+
|
| 106 |
+
# Verificar se os dtypes são numéricos antes de treinar (real training calls)
|
|
|
|
|
|
|
|
|
|
| 107 |
for col in X_train_raw.columns:
|
| 108 |
if not pd.api.types.is_numeric_dtype(X_train_raw[col]):
|
| 109 |
+
st.error(f"Erro: Coluna '{col}' no X_train_raw não é numérica. Tipo: {X_train_raw[col].dtype}. Verifique o pré-processamento.")
|
|
|
|
| 110 |
return {}
|
| 111 |
|
| 112 |
for col in X_test_raw.columns:
|
| 113 |
if not pd.api.types.is_numeric_dtype(X_test_raw[col]):
|
| 114 |
+
st.error(f"Erro: Coluna '{col}' no X_test_raw não é numérica. Tipo: {X_test_raw[col].dtype}. Verifique o pré-processamento.")
|
|
|
|
| 115 |
return {}
|
| 116 |
|
| 117 |
+
|
| 118 |
for name, model in models.items():
|
| 119 |
if model_selected and name != model_selected:
|
| 120 |
continue
|
|
|
|
| 123 |
if name in ["K-Nearest Neighbors", "Support Vector Machine"]:
|
| 124 |
X_train_processed = _scaler.fit_transform(X_train_raw)
|
| 125 |
X_test_processed = _scaler.transform(X_test_raw)
|
| 126 |
+
else: # Para outros modelos, usamos os dados crus (não escalados)
|
| 127 |
X_train_processed = X_train_raw
|
| 128 |
X_test_processed = X_test_raw
|
| 129 |
|
| 130 |
try:
|
| 131 |
model.fit(X_train_processed, y_train)
|
| 132 |
y_pred = model.predict(X_test_processed)
|
| 133 |
+
|
| 134 |
+
# Correção para IndexError no predict_proba
|
| 135 |
if hasattr(model, 'predict_proba'):
|
| 136 |
probas = model.predict_proba(X_test_processed)
|
| 137 |
if probas.shape[1] > 1:
|
|
|
|
| 139 |
else:
|
| 140 |
y_prob = probas[:, 0]
|
| 141 |
else:
|
| 142 |
+
y_prob = y_pred # fallback, não ideal para AUC
|
| 143 |
|
| 144 |
# Calcular ROC AUC apenas se y_prob não for totalmente binário (0 ou 1)
|
| 145 |
if len(np.unique(y_prob)) > 1:
|
|
|
|
| 163 |
"TPR": tpr,
|
| 164 |
"y_prob": y_prob
|
| 165 |
}
|
| 166 |
+
except Exception as e:
|
| 167 |
+
# Captura erros de treinamento, mas não os exibe para o usuário final durante a seleção inicial
|
| 168 |
+
# O warning será exibido APENAS se o treinamento real for solicitado (nos botões "Treinar" ou "Analisar")
|
|
|
|
|
|
|
| 169 |
results[name] = {
|
| 170 |
"Model": None, "Accuracy": 0, "Precision": 0, "Recall": 0, "F1-score": 0,
|
| 171 |
+
"AUC": 0.5, "Confusion Matrix": np.array([[0,0],[0,0]]), "FPR": [0,1], "TPR": [0,1], "y_prob": np.zeros(len(y_test)), "Error": str(e)
|
|
|
|
| 172 |
}
|
| 173 |
continue
|
| 174 |
return results
|
| 175 |
|
|
|
|
| 176 |
# --- Carregar e Pré-processar os dados ---
|
| 177 |
df = load_data()
|
| 178 |
df_processed = preprocess_data(df)
|
|
|
|
| 186 |
# Balanceamento da Base
|
| 187 |
st.sidebar.subheader("Balanceamento de Dados (SMOTE)")
|
| 188 |
balance_data = st.sidebar.checkbox("Aplicar SMOTE", value=True)
|
| 189 |
+
st.sidebar.info("SMOTE cria amostras sintéticas da classe minoritária para balancear os dados, melhorando o desempenho em datasets desbalanceados.")
|
|
|
|
| 190 |
|
| 191 |
# Seleção de Variáveis
|
| 192 |
st.sidebar.subheader("Seleção de Variáveis")
|
| 193 |
use_rfe = st.sidebar.checkbox("Usar Seleção de Variáveis (RFE)", value=False)
|
| 194 |
if use_rfe:
|
|
|
|
| 195 |
max_features_rfe = X.shape[1] if X.shape[1] > 5 else 5
|
| 196 |
+
n_features_rfe = st.sidebar.slider("Número de Variáveis a Selecionar (RFE)", 5, max_features_rfe, min(10, max_features_rfe))
|
| 197 |
+
st.sidebar.info(f"O RFE (Recursive Feature Elimination) seleciona as {n_features_rfe} melhores variáveis de forma iterativa.")
|
|
|
|
|
|
|
| 198 |
estimator_rfe = LogisticRegression(max_iter=1000, random_state=42)
|
| 199 |
+
|
| 200 |
if X.shape[0] > 0 and X.shape[1] >= n_features_rfe:
|
| 201 |
try:
|
| 202 |
selector_rfe = RFE(estimator_rfe, n_features_to_select=n_features_rfe, step=1)
|
|
|
|
| 208 |
st.sidebar.error(f"Erro ao aplicar RFE: {e}. RFE desabilitado.")
|
| 209 |
use_rfe = False
|
| 210 |
else:
|
| 211 |
+
st.sidebar.warning(f"Não há dados suficientes ({X.shape[0]} amostras ou {X.shape[1]} colunas) para aplicar RFE com {n_features_rfe} features. RFE desabilitado.")
|
|
|
|
| 212 |
use_rfe = False
|
| 213 |
|
| 214 |
# Escolha do Modelo
|
| 215 |
st.sidebar.subheader("Seleção de Modelo para Treinamento")
|
| 216 |
|
| 217 |
+
# === CORREÇÃO: Usar st.session_state para sinalizar a chamada inicial ===
|
| 218 |
st.session_state['is_initial_call'] = True
|
| 219 |
+
# Criar dados dummy com 1 linha de zeros e todas as colunas de X para ter o shape correto
|
| 220 |
dummy_X_for_keys = pd.DataFrame(np.zeros((1, X.shape[1])), columns=X.columns)
|
| 221 |
+
# y_dummy deve ter pelo menos 2 classes para a função não reclamar
|
| 222 |
+
dummy_y_for_keys = pd.Series([0, 1])
|
| 223 |
+
model_keys = train_and_evaluate_models(dummy_X_for_keys, dummy_X_for_keys, dummy_y_for_keys, dummy_y_for_keys, StandardScaler()).keys()
|
| 224 |
+
st.session_state['is_initial_call'] = False # Reseta a flag após a chamada inicial
|
| 225 |
|
| 226 |
model_choice = st.sidebar.selectbox(
|
| 227 |
"Escolha o Modelo Principal para Análise Detalhada:",
|
|
|
|
| 233 |
|
| 234 |
# --- Abas do Dashboard ---
|
| 235 |
tab1, tab2, tab3, tab4, tab5 = st.tabs([
|
| 236 |
+
"Visão Geral dos Dados",
|
| 237 |
+
"Balanceamento de Dados",
|
| 238 |
+
"Comparação de Modelos",
|
| 239 |
+
"Análise do Melhor Modelo",
|
| 240 |
+
"Aplicação Gerencial"
|
| 241 |
])
|
| 242 |
|
| 243 |
with tab1:
|
|
|
|
| 266 |
|
| 267 |
with tab2:
|
| 268 |
st.header("2. Balanceamento de Dados com SMOTE")
|
| 269 |
+
st.write("A seguir, demonstramos o efeito do balanceamento da variável alvo 'Complain' utilizando a técnica **SMOTE**.")
|
|
|
|
| 270 |
|
| 271 |
X_display = X.copy()
|
| 272 |
y_display = y.copy()
|
|
|
|
| 290 |
ax.set_ylabel("Contagem")
|
| 291 |
st.pyplot(fig)
|
| 292 |
except Exception as e:
|
| 293 |
+
st.error(f"Erro ao aplicar SMOTE: {e}. Isso pode acontecer se houver poucas amostras na classe minoritária ou muitas features.")
|
|
|
|
| 294 |
X_res, y_res = X_display, y_display
|
| 295 |
else:
|
| 296 |
st.info("SMOTE desabilitado. O balanceamento não será aplicado.")
|
|
|
|
| 303 |
st.subheader("Divisão dos Dados (Treino/Teste)")
|
| 304 |
test_size = st.slider("Tamanho do Conjunto de Teste", 0.1, 0.5, 0.3, 0.05)
|
| 305 |
if len(np.unique(y_res)) > 1:
|
| 306 |
+
X_train, X_test, y_train, y_test = train_test_split(X_res, y_res, test_size=test_size, random_state=42, stratify=y_res)
|
|
|
|
| 307 |
else:
|
| 308 |
+
st.warning("Não foi possível usar `stratify` no `train_test_split` pois o alvo tem apenas uma classe após o processamento. Dividindo sem estratificação.")
|
|
|
|
| 309 |
X_train, X_test, y_train, y_test = train_test_split(X_res, y_res, test_size=test_size, random_state=42)
|
| 310 |
|
| 311 |
+
st.write(f"**Shape dos dados de treino:** {X_train.shape}")
|
| 312 |
+
st.write(f"**Shape dos dados de teste:** {X_test.shape}")
|
| 313 |
+
st.write(f"**Shape y_train:** {y_train.shape}")
|
| 314 |
+
st.write(f"**Shape y_test:** {y_test.shape}")
|
| 315 |
+
st.write(f"**Shape do DataFrame (após pré-processamento):** {df_processed.shape}")
|
| 316 |
+
st.write(f"**Tipos das colunas (após pré-processamento):**")
|
| 317 |
+
st.dataframe(df_processed.dtypes.astype(str).reset_index().rename(columns={'index': 'Coluna', 0: 'Tipo de Dado'}))
|
| 318 |
+
st.write(f"**Primeiras 5 linhas (após pré-processamento):**")
|
| 319 |
+
st.dataframe(df_processed.head())
|
| 320 |
+
st.write(f"**Classes em y_train:** {np.unique(y_train)}")
|
| 321 |
|
| 322 |
if X_train.empty or y_train.empty:
|
| 323 |
st.error("Os dados de treino estão vazios! Verifique o carregamento ou pré-processamento dos dados.")
|
|
|
|
| 327 |
st.stop()
|
| 328 |
|
| 329 |
st.subheader("Escalonamento de Dados")
|
| 330 |
+
st.write("Para modelos sensíveis à escala (como KNN e SVM), os dados serão automaticamente escalonados (`StandardScaler`) antes do treinamento e da previsão.")
|
| 331 |
+
|
| 332 |
|
| 333 |
with tab3:
|
| 334 |
st.header("3. Comparação de Modelos Supervisionados")
|
|
|
|
| 370 |
st.markdown("""
|
| 371 |
Para problemas de previsão de reclamações, o **Recall** é frequentemente crucial, pois minimiza Falsos Negativos (clientes que reclamam mas não são previstos). No entanto, um bom **AUC** (Área sob a Curva ROC) indica a capacidade geral do modelo de distinguir entre as classes, e o **F1-score** oferece um equilíbrio entre Precisão e Recall.
|
| 372 |
""")
|
| 373 |
+
st.success(f"**Recomendação:** O modelo com o maior **AUC** é geralmente um bom ponto de partida, pois indica a melhor capacidade discriminatória geral. Para este exemplo, o modelo principal para análise detalhada será o selecionado na sidebar: **{model_choice}**.")
|
|
|
|
| 374 |
|
| 375 |
with tab4:
|
| 376 |
st.header("4. Análise Detalhada do Modelo Selecionado")
|
|
|
|
| 378 |
|
| 379 |
if st.button(f"Analisar {model_choice}"):
|
| 380 |
with st.spinner(f"Analisando {model_choice}..."):
|
| 381 |
+
selected_model_results = train_and_evaluate_models(X_train, X_test, y_train, y_test, StandardScaler(), model_selected=model_choice)
|
| 382 |
+
|
|
|
|
| 383 |
if model_choice not in selected_model_results or selected_model_results[model_choice]['Model'] is None:
|
| 384 |
+
st.error(f"Não foi possível analisar o modelo {model_choice}. Ele pode ter falhado no treinamento. Erro: {selected_model_results.get(model_choice, {}).get('Error', 'Desconhecido')}")
|
| 385 |
else:
|
| 386 |
metrics = selected_model_results[model_choice]
|
| 387 |
|
|
|
|
| 394 |
|
| 395 |
st.subheader(f"Matriz de Confusão para {model_choice}")
|
| 396 |
fig_cm, ax_cm = plt.subplots(figsize=(7, 6))
|
| 397 |
+
disp = ConfusionMatrixDisplay(confusion_matrix=metrics['Confusion Matrix'], display_labels=['Não Reclamou (0)', 'Reclamou (1)'])
|
|
|
|
| 398 |
disp.plot(cmap=plt.cm.Blues, ax=ax_cm)
|
| 399 |
ax_cm.set_title(f'Matriz de Confusão para {model_choice}')
|
| 400 |
st.pyplot(fig_cm)
|
|
|
|
| 409 |
|
| 410 |
st.subheader(f"Curva ROC para {model_choice}")
|
| 411 |
fig_roc_single, ax_roc_single = plt.subplots(figsize=(8, 6))
|
| 412 |
+
ax_roc_single.plot(metrics['FPR'], metrics['TPR'], color='darkorange', lw=2, label=f'Curva ROC (AUC = {metrics["AUC"]:.2f})')
|
|
|
|
| 413 |
ax_roc_single.plot([0, 1], [0, 1], color='navy', lw=2, linestyle='--', label='Classificador Aleatório')
|
| 414 |
ax_roc_single.set_xlabel('Taxa de Falsos Positivos (FPR)')
|
| 415 |
ax_roc_single.set_ylabel('Taxa de Verdadeiros Positivos (TPR)')
|
|
|
|
| 417 |
ax_roc_single.legend(loc='lower right')
|
| 418 |
ax_roc_single.grid(True)
|
| 419 |
st.pyplot(fig_roc_single)
|
| 420 |
+
st.write(f"O **AUC** de {metrics['AUC']:.2f} indica a capacidade discriminatória do modelo: quanto mais próximo de 1, melhor o modelo distingue entre as classes.")
|
|
|
|
| 421 |
|
| 422 |
st.subheader("Sensibilidade aos Hiperparâmetros")
|
| 423 |
if model_choice == "K-Nearest Neighbors":
|
|
|
|
| 437 |
Modelos de Boosting como XGBoost e LightGBM são influenciados por `n_estimators` (número de estimadores), `learning_rate` (taxa de aprendizado) e `max_depth`. Uma `learning_rate` menor com mais estimadores pode melhorar o desempenho, mas requer mais tempo de treinamento. `Max_depth` controla a complexidade de cada árvore.
|
| 438 |
""")
|
| 439 |
else:
|
| 440 |
+
st.markdown("Este modelo também possui hiperparâmetros que podem ser ajustados para otimizar o desempenho (ex: `max_depth` para Decision Tree, `n_estimators` para AdaBoosting/Gradient Boosting).")
|
|
|
|
| 441 |
|
| 442 |
with tab5:
|
| 443 |
st.header("5. Tomada de Decisão e Aplicação Gerencial")
|
|
|
|
| 445 |
|
| 446 |
if st.button("Gerar Análise Gerencial"):
|
| 447 |
with st.spinner("Gerando insights gerenciais..."):
|
| 448 |
+
selected_model_results = train_and_evaluate_models(X_train, X_test, y_train, y_test, StandardScaler(), model_selected=model_choice)
|
| 449 |
+
|
|
|
|
| 450 |
if model_choice not in selected_model_results or selected_model_results[model_choice]['Model'] is None:
|
| 451 |
+
st.error(f"Não foi possível gerar a análise gerencial para o modelo {model_choice}. Ele pode ter falhado no treinamento. Erro: {selected_model_results.get(model_choice, {}).get('Error', 'Desconhecido')}")
|
|
|
|
| 452 |
else:
|
| 453 |
model_instance = selected_model_results[model_choice]["Model"]
|
| 454 |
|
|
|
|
| 457 |
if hasattr(model_instance, 'feature_importances_'):
|
| 458 |
feature_importances = model_instance.feature_importances_
|
| 459 |
feature_names = X.columns.tolist()
|
| 460 |
+
importance_df = pd.DataFrame({'Variável': feature_names, 'Importância Relativa': feature_importances})
|
|
|
|
| 461 |
importance_df = importance_df.sort_values(by='Importância Relativa', ascending=False)
|
| 462 |
st.dataframe(importance_df.head(10).set_index('Variável'))
|
| 463 |
|
|
|
|
| 469 |
elif hasattr(model_instance, 'coef_'):
|
| 470 |
st.info("Para modelos lineares, os coeficientes podem ser interpretados como importância.")
|
| 471 |
else:
|
| 472 |
+
st.info("Não foi possível extrair a importância das variáveis para este tipo de modelo de forma direta.")
|
|
|
|
| 473 |
|
| 474 |
st.subheader("Análise e Recomendações Gerenciais")
|
| 475 |
|