|
|
import streamlit as st |
|
|
import pandas as pd |
|
|
import numpy as np |
|
|
import plotly.express as px |
|
|
import plotly.graph_objects as go |
|
|
from sklearn.model_selection import train_test_split |
|
|
from sklearn.preprocessing import StandardScaler |
|
|
from sklearn.metrics import ( |
|
|
classification_report, |
|
|
confusion_matrix, |
|
|
roc_curve, |
|
|
roc_auc_score, |
|
|
precision_recall_fscore_support, |
|
|
) |
|
|
from sklearn.linear_model import LogisticRegression |
|
|
from sklearn.neighbors import KNeighborsClassifier |
|
|
from sklearn.svm import SVC |
|
|
from imblearn.over_sampling import SMOTE |
|
|
import time |
|
|
import warnings |
|
|
|
|
|
warnings.filterwarnings("ignore") |
|
|
|
|
|
|
|
|
st.set_page_config( |
|
|
page_title="Dashboard de Previsão de Cancelamento", |
|
|
page_icon="🏨", |
|
|
layout="wide", |
|
|
) |
|
|
|
|
|
|
|
|
st.title("🏨 Dashboard de Previsão de Cancelamento de Reservas") |
|
|
|
|
|
|
|
|
|
|
|
@st.cache_data |
|
|
def load_data(file_path): |
|
|
"""Carrega o dataset principal. O cache evita recarregar a cada interação.""" |
|
|
try: |
|
|
df = pd.read_csv(file_path) |
|
|
return df |
|
|
except FileNotFoundError: |
|
|
st.error( |
|
|
f"Erro: Arquivo '{file_path}' não encontrado. Faça o upload do arquivo para o seu Hugging Face Space." |
|
|
) |
|
|
return None |
|
|
|
|
|
|
|
|
@st.cache_data |
|
|
def preprocess_data(df): |
|
|
"""Aplica o pré-processamento seguindo as diretrizes da Tarefa 3.""" |
|
|
df_proc = df.copy() |
|
|
|
|
|
|
|
|
df_proc["country"].fillna(df_proc["country"].mode()[0], inplace=True) |
|
|
df_proc["agent"].fillna(0, inplace=True) |
|
|
df_proc["company"].fillna(0, inplace=True) |
|
|
df_proc["children"].fillna(0, inplace=True) |
|
|
|
|
|
|
|
|
df_proc = df_proc[(df_proc["adr"] >= 0) & (df_proc["adr"] < 5000)] |
|
|
|
|
|
|
|
|
df_proc["total_stay"] = ( |
|
|
df_proc["stays_in_weekend_nights"] + df_proc["stays_in_week_nights"] |
|
|
) |
|
|
df_proc["total_guests"] = ( |
|
|
df_proc["adults"] + df_proc["children"] + df_proc["babies"] |
|
|
) |
|
|
df_proc = df_proc[df_proc["total_guests"] > 0] |
|
|
|
|
|
|
|
|
|
|
|
y = df_proc["is_canceled"] |
|
|
|
|
|
numeric_features = [ |
|
|
"lead_time", |
|
|
"total_stay", |
|
|
"total_guests", |
|
|
"adr", |
|
|
"previous_cancellations", |
|
|
"previous_bookings_not_canceled", |
|
|
"booking_changes", |
|
|
"days_in_waiting_list", |
|
|
"total_of_special_requests", |
|
|
] |
|
|
|
|
|
categorical_features = [ |
|
|
"hotel", |
|
|
"market_segment", |
|
|
"distribution_channel", |
|
|
"deposit_type", |
|
|
"customer_type", |
|
|
"is_repeated_guest", |
|
|
] |
|
|
|
|
|
all_features = numeric_features + categorical_features |
|
|
df_features = df_proc[all_features] |
|
|
|
|
|
|
|
|
X = pd.get_dummies(df_features, columns=categorical_features, drop_first=True) |
|
|
|
|
|
return X, y |
|
|
|
|
|
|
|
|
|
|
|
def get_model(algorithm, params): |
|
|
"""Instancia o modelo com base nos parâmetros do usuário.""" |
|
|
if algorithm == "Regressão Logística": |
|
|
model = LogisticRegression( |
|
|
C=params["C_rl"], |
|
|
solver="liblinear", |
|
|
random_state=42, |
|
|
max_iter=1000, |
|
|
) |
|
|
elif algorithm == "KNN": |
|
|
model = KNeighborsClassifier( |
|
|
n_neighbors=params["k"], metric=params["distance_metric"] |
|
|
) |
|
|
elif algorithm == "SVM": |
|
|
model = SVC( |
|
|
C=params["C_svm"], |
|
|
kernel=params["kernel"], |
|
|
gamma=params["gamma"] if params["kernel"] == "rbf" else "auto", |
|
|
probability=True, |
|
|
random_state=42, |
|
|
) |
|
|
return model |
|
|
|
|
|
|
|
|
|
|
|
def plot_roc_curve(y_test, y_proba, auc): |
|
|
"""Plota a curva ROC usando Plotly.""" |
|
|
fpr, tpr, _ = roc_curve(y_test, y_proba) |
|
|
fig = px.area( |
|
|
x=fpr, |
|
|
y=tpr, |
|
|
title=f"Curva ROC (AUC = {auc:.4f})", |
|
|
labels=dict(x="Taxa de Falsos Positivos", y="Taxa de Verdadeiros Positivos"), |
|
|
width=700, |
|
|
height=500, |
|
|
) |
|
|
fig.add_shape(type="line", line=dict(dash="dash"), x0=0, x1=1, y0=0, y1=1) |
|
|
fig.update_layout( |
|
|
yaxis_title="Taxa de Verdadeiros Positivos (Sensibilidade)", |
|
|
xaxis_title="Taxa de Falsos Positivos (1 - Especificidade)", |
|
|
) |
|
|
return fig |
|
|
|
|
|
|
|
|
def plot_confusion_matrix(y_test, y_pred): |
|
|
"""Plota a Matriz de Confusão usando Plotly.""" |
|
|
cm = confusion_matrix(y_test, y_pred) |
|
|
|
|
|
fig = px.imshow( |
|
|
cm, |
|
|
labels=dict( |
|
|
x="Previsão do Modelo", y="Valor Real", color="Contagem" |
|
|
), |
|
|
x=["Não Cancelou (0)", "Cancelou (1)"], |
|
|
y=["Não Cancelou (0)", "Cancelou (1)"], |
|
|
color_continuous_scale="Blues", |
|
|
text_auto=True, |
|
|
) |
|
|
|
|
|
fig.update_layout( |
|
|
title="Matriz de Confusão", |
|
|
xaxis_title="Previsão do Modelo", |
|
|
yaxis_title="Valor Real", |
|
|
width=600, |
|
|
height=500, |
|
|
) |
|
|
return fig |
|
|
|
|
|
|
|
|
|
|
|
st.sidebar.header("⚙️ Painel de Controle do Analista") |
|
|
|
|
|
df_original = load_data("hotel_bookings.csv") |
|
|
|
|
|
if df_original is not None: |
|
|
|
|
|
st.sidebar.subheader("1. Configuração dos Dados") |
|
|
sample_size = st.sidebar.slider( |
|
|
"Tamanho da Amostra para Treinamento", |
|
|
min_value=1000, |
|
|
max_value=20000, |
|
|
value=3000, |
|
|
step=500, |
|
|
help="Use uma amostra menor para velocidade ou maior para precisão. O dataset completo tem >100k linhas.", |
|
|
) |
|
|
test_split_pct = st.sidebar.slider( |
|
|
"Percentual de Dados para Teste", |
|
|
min_value=0.1, |
|
|
max_value=0.5, |
|
|
value=0.3, |
|
|
step=0.05, |
|
|
) |
|
|
use_smote = st.sidebar.checkbox( |
|
|
"Aplicar SMOTE (Corrigir Desbalanceamento)", |
|
|
value=False, |
|
|
help="Pode melhorar o 'Recall', mas aumenta o tempo de treino.", |
|
|
) |
|
|
|
|
|
|
|
|
st.sidebar.subheader("2. Seleção do Algoritmo") |
|
|
algorithm = st.sidebar.selectbox( |
|
|
"Escolha o Algoritmo", |
|
|
("Regressão Logística", "KNN", "SVM"), |
|
|
) |
|
|
|
|
|
|
|
|
st.sidebar.subheader(f"3. Ajuste de Parâmetros ({algorithm})") |
|
|
params = {} |
|
|
|
|
|
if algorithm == "Regressão Logística": |
|
|
params["C_rl"] = st.sidebar.select_slider( |
|
|
"C (Força da Regularização)", |
|
|
options=[0.01, 0.1, 1.0, 10.0, 100.0], |
|
|
value=1.0, |
|
|
help="Valores menores = mais regularização (modelo mais simples).", |
|
|
) |
|
|
|
|
|
elif algorithm == "KNN": |
|
|
params["k"] = st.sidebar.slider( |
|
|
"k (Número de Vizinhos)", min_value=3, max_value=21, value=5, step=2 |
|
|
) |
|
|
params["distance_metric"] = st.sidebar.selectbox( |
|
|
"Métrica de Distância", ("euclidean", "manhattan") |
|
|
) |
|
|
|
|
|
elif algorithm == "SVM": |
|
|
params["kernel"] = st.sidebar.selectbox("Kernel", ("linear", "rbf")) |
|
|
params["C_svm"] = st.sidebar.select_slider( |
|
|
"C (Regularização)", |
|
|
options=[0.1, 1.0, 10.0, 50.0], |
|
|
value=1.0, |
|
|
help="Controla o trade-off entre erro de treino e margem.", |
|
|
) |
|
|
if params["kernel"] == "rbf": |
|
|
params["gamma"] = st.sidebar.select_slider( |
|
|
"Gamma (Influência do Ponto)", |
|
|
options=[0.001, 0.01, 0.1, 1.0], |
|
|
value=0.1, |
|
|
) |
|
|
else: |
|
|
params["gamma"] = "auto" |
|
|
|
|
|
|
|
|
st.sidebar.markdown("---") |
|
|
run_button = st.sidebar.button("Executar Análise", type="primary") |
|
|
|
|
|
|
|
|
if run_button: |
|
|
with st.spinner( |
|
|
f"Executando pipeline para {algorithm} com {sample_size} amostras..." |
|
|
): |
|
|
start_time = time.time() |
|
|
|
|
|
|
|
|
df_sample = df_original.sample(n=sample_size, random_state=42) |
|
|
|
|
|
|
|
|
X, y = preprocess_data(df_sample) |
|
|
|
|
|
|
|
|
feature_names = X.columns.tolist() |
|
|
|
|
|
|
|
|
X_train, X_test, y_train, y_test = train_test_split( |
|
|
X, y, test_size=test_split_pct, random_state=42, stratify=y |
|
|
) |
|
|
|
|
|
|
|
|
scaler = StandardScaler() |
|
|
X_train_scaled = scaler.fit_transform(X_train) |
|
|
X_test_scaled = scaler.transform(X_test) |
|
|
|
|
|
|
|
|
if use_smote: |
|
|
smote = SMOTE(random_state=42) |
|
|
X_train_scaled, y_train = smote.fit_resample(X_train_scaled, y_train) |
|
|
|
|
|
|
|
|
model = get_model(algorithm, params) |
|
|
model.fit(X_train_scaled, y_train) |
|
|
|
|
|
|
|
|
y_pred = model.predict(X_test_scaled) |
|
|
y_proba = model.predict_proba(X_test_scaled)[:, 1] |
|
|
auc = roc_auc_score(y_test, y_proba) |
|
|
report = classification_report(y_test, y_pred, output_dict=True) |
|
|
report_df = pd.DataFrame(report).transpose() |
|
|
|
|
|
( |
|
|
precision, |
|
|
recall, |
|
|
f1_score, |
|
|
_, |
|
|
) = precision_recall_fscore_support(y_test, y_pred, average="binary") |
|
|
|
|
|
|
|
|
end_time = time.time() |
|
|
training_time = end_time - start_time |
|
|
|
|
|
|
|
|
st.header(f"Resultados para: {algorithm}") |
|
|
|
|
|
|
|
|
st.subheader("Visão Geral das Métricas (Classe 1: 'Cancelou')") |
|
|
col1, col2, col3, col4 = st.columns(4) |
|
|
col1.metric("AUC (Area Under Curve)", f"{auc:.3f}") |
|
|
col2.metric("F1-Score", f"{f1_score:.3f}") |
|
|
col3.metric("Precisão (Precision)", f"{precision:.3f}") |
|
|
col4.metric("Recall (Sensibilidade)", f"{recall:.3f}") |
|
|
|
|
|
st.markdown(f"**Tempo de Treinamento e Avaliação:** {training_time:.2f} segundos") |
|
|
|
|
|
|
|
|
st.subheader("Visualização das Métricas") |
|
|
fig_roc = plot_roc_curve(y_test, y_proba, auc) |
|
|
fig_cm = plot_confusion_matrix(y_test, y_pred) |
|
|
|
|
|
col_graph1, col_graph2 = st.columns(2) |
|
|
with col_graph1: |
|
|
st.plotly_chart(fig_roc, use_container_width=True) |
|
|
with col_graph2: |
|
|
st.plotly_chart(fig_cm, use_container_width=True) |
|
|
|
|
|
st.subheader("Relatório de Classificação Detalhado") |
|
|
st.dataframe(report_df.style.format("{:.3f}")) |
|
|
|
|
|
|
|
|
if algorithm == "Regressão Logística": |
|
|
st.subheader("Análise de Coeficientes (Interpretabilidade)") |
|
|
|
|
|
coefs = model.coef_[0] |
|
|
odds_ratios = np.exp(coefs) |
|
|
|
|
|
df_coef = pd.DataFrame({ |
|
|
'Variável': feature_names, |
|
|
'Coeficiente (Log-Odds)': coefs, |
|
|
'Odds Ratio (Razão de Chances)': odds_ratios |
|
|
}) |
|
|
|
|
|
|
|
|
|
|
|
df_coef = df_coef.sort_values(by="Odds Ratio (Razão de Chances)", ascending=False) |
|
|
|
|
|
st.dataframe(df_coef.style.format({ |
|
|
'Coeficiente (Log-Odds)': '{:.4f}', |
|
|
'Odds Ratio (Razão de Chances)': '{:.3f}' |
|
|
}).background_gradient( |
|
|
cmap='RdBu_r', |
|
|
subset=['Odds Ratio (Razão de Chances)', 'Coeficiente (Log-Odds)']) |
|
|
) |
|
|
|
|
|
st.markdown(""" |
|
|
**Como interpretar esta tabela:** |
|
|
* **Odds Ratio > 1 (Azul):** Aumenta a chance de cancelamento. |
|
|
* *Exemplo: Se `lead_time` tem Odds Ratio de 1.02, cada dia extra de antecedência aumenta a chance de cancelar em 2%.* |
|
|
* **Odds Ratio < 1 (Vermelho):** Diminui a chance de cancelamento (fator de proteção). |
|
|
* *Exemplo: Se `deposit_type_Non Refund` tem Odds Ratio de 0.20, ter um depósito não-reembolsável reduz a chance de cancelar em 80%.* |
|
|
* **Odds Ratio = 1:** Não tem efeito. |
|
|
""") |
|
|
|
|
|
|
|
|
st.header("💡 Interpretação Gerencial e Recomendações") |
|
|
|
|
|
st.subheader(f"Análise Gerencial do Modelo: {algorithm}") |
|
|
|
|
|
if algorithm == "Regressão Logística": |
|
|
st.markdown(""" |
|
|
**O que é?** Um modelo estatístico que calcula a *probabilidade* de cancelamento. É o modelo mais fácil de interpretar. |
|
|
**Ponto Forte (Interpretabilidade):** Como visto na tabela acima, podemos ver exatamente quais fatores (como `lead_time` ou `deposit_type`) mais aumentam ou diminuem as chances de cancelamento. |
|
|
**Ponto Fraco:** Pode não capturar relações complexas entre as variáveis. |
|
|
""") |
|
|
elif algorithm == "KNN": |
|
|
st.markdown(""" |
|
|
**O que é?** Um modelo que classifica uma nova reserva com base nas reservas mais *parecidas* (vizinhas) que já temos no histórico. |
|
|
**Ponto Forte (Intuitivo):** Fácil de entender. "Diga-me quem são seus vizinhos e eu direi quem você é". Bom para capturar padrões locais. |
|
|
**Ponto Fraco (Performance):** Lento para prever em datasets muito grandes e muito sensível ao escalonamento dos dados e a features irrelevantes. |
|
|
""") |
|
|
elif algorithm == "SVM": |
|
|
st.markdown(""" |
|
|
**O que é?** Um modelo que tenta encontrar a *melhor fronteira* ou "linha" que separa os cancelamentos dos não-cancelamentos, maximizando a distância entre os dois grupos. |
|
|
**Ponto Forte (Poder Preditivo):** Especialmente com o kernel 'RBF', pode encontrar relações não-lineares complexas que outros modelos não veem. Geralmente tem alta acurácia. |
|
|
**Ponto Fraco (Caixa Preta):** É muito difícil de explicar *por que* o modelo tomou uma decisão específica. |
|
|
""") |
|
|
|
|
|
st.subheader("Tradução das Métricas para o Negócio Hoteleiro") |
|
|
st.markdown(f""" |
|
|
* **Precisão (Precision) = {precision:.2f}:** Das reservas que o modelo *disse* que iriam cancelar, **{precision*100:.1f}%** realmente cancelariam. |
|
|
* *Impacto:* Uma Precisão alta evita que a equipe de retenção perca tempo com clientes que não iriam cancelar. |
|
|
|
|
|
* **Recall (Sensibilidade) = {recall:.2f}:** Das reservas que *realmente* foram canceladas, o modelo conseguiu identificar **{recall*100:.1f}%** delas. |
|
|
* *Impacto:* Este é o custo de "deixar passar". Um Recall baixo significa que muitos cancelamentos estão ocorrendo sem aviso prévio. |
|
|
|
|
|
* **AUC = {auc:.2f}:** Mede a capacidade *geral* do modelo de distinguir entre um cancelamento e uma não-cancelamento. Um valor de 0.5 é um chute; 1.0 é a perfeição. **{auc*100:.1f}%** é um indicador de quão robusto é o modelo. |
|
|
""") |
|
|
|
|
|
st.subheader("Ranking e Recomendações (Visão Geral)") |
|
|
st.markdown(""" |
|
|
A "melhor" escolha depende da estratégia da rede hoteleira: |
|
|
|
|
|
1. **Para Interpretabilidade (Entender o *Porquê*):** |
|
|
* **Vencedor:** **Regressão Logística**. |
|
|
* **Ação:** Use este modelo para entender os *drivers* do cancelamento. Se `lead_time` alto é um fator de risco, a equipe de marketing pode criar ações de engajamento para reservas feitas com muita antecedência. |
|
|
|
|
|
2. **Para Ação Preventiva (Maximizar o *Recall*):** |
|
|
* **Vencedor:** Geralmente **SVM** ou **KNN** (com SMOTE) podem ser ajustados para um Recall mais alto. |
|
|
* **Ação:** Se a estratégia é "não deixar nenhum cancelamento passar despercebido" (mesmo que isso gere alguns falsos positivos), priorizamos o **Recall**. Podemos enviar um e-mail de confirmação ou uma pequena oferta para *todas* as reservas de alto risco sinalizadas pelo modelo. |
|
|
|
|
|
3. **Para Eficiência Operacional (Maximizar a *Precisão*):** |
|
|
* **Vencedor:** Geralmente **Regressão Logística** ou **SVM (linear)**. |
|
|
* **Ação:** Se temos uma equipe de retenção pequena e cara (ex: ligações telefônicas), queremos ter certeza de que cada reserva sinalizada é *realmente* de alto risco. Priorizamos a **Precisão**. |
|
|
""") |
|
|
|
|
|
else: |
|
|
st.warning("O arquivo 'hotel_bookings.csv' não foi carregado. O dashboard não pode continuar.") |