| 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.") |