Spaces:
Sleeping
Sleeping
| import pandas as pd | |
| import numpy as np | |
| from sklearn.model_selection import train_test_split | |
| from sklearn.preprocessing import StandardScaler, OneHotEncoder | |
| from sklearn.impute import SimpleImputer | |
| from sklearn.compose import ColumnTransformer | |
| from sklearn.pipeline import Pipeline | |
| from sklearn.linear_model import LogisticRegression | |
| from imblearn.over_sampling import SMOTE | |
| from typing import Dict, Any, List, Tuple, Union | |
| # Importações para métricas de avaliação e plots | |
| from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score, roc_auc_score, confusion_matrix, classification_report, roc_curve, auc | |
| import matplotlib.pyplot as plt | |
| import seaborn as sns | |
| import os | |
| import tempfile # Para criar arquivos temporários para os plots | |
| import re # Para auxiliar na limpeza de markdown para LaTeX | |
| import shutil # Para copiar arquivos | |
| # Importações para LaTeX (pylatex) | |
| from pylatex import Document, Section, Command, LongTable, Tabular, Figure, NoEscape, Math, LineBreak | |
| from pylatex.utils import italic | |
| from pylatex.base_classes import Environment | |
| # --- DEFINIÇÃO DAS FEATURES PREDITIVAS E COLUNA ALVO PARA SEU data.csv --- | |
| ALL_PREDICTOR_FEATURES = [ | |
| 'CreditScore', 'Geography', 'Gender', 'Age', 'Tenure', | |
| 'Balance', 'NumOfProducts', 'HasCrCard', 'IsActiveMember', 'EstimatedSalary' | |
| ] | |
| TARGET_COLUMN = 'Exited' # Esta é a coluna que queremos prever, não um input. | |
| class ChurnModelPipeline: | |
| def __init__(self): | |
| self.model = None | |
| self.preprocessor = None | |
| self.feature_names_out = None # Nomes das features após o pré-processamento | |
| self.coefficients_df = pd.DataFrame() # Para armazenar coeficientes e Odds Ratios | |
| self.metrics_dict = {} # Para armazenar métricas de avaliação | |
| self.plot_paths = {} # Para armazenar caminhos para os plots gerados | |
| self.X_test_processed = None | |
| self.y_test = None | |
| self.X_test = None # Armazenar X_test para samplear para simulação | |
| self.df_raw_for_plots = None # Armazenar df_raw para gerar o heatmap de correlação | |
| self.training_details = {} # Para armazenar detalhes do treinamento | |
| def _build_preprocessor(self, X: pd.DataFrame) -> ColumnTransformer: | |
| numeric_features = X.select_dtypes(include=np.number).columns.tolist() | |
| categorical_features = X.select_dtypes(include='object').columns.tolist() | |
| numeric_transformer = Pipeline(steps=[ | |
| ('imputer', SimpleImputer(strategy='median')), | |
| ('scaler', StandardScaler()) | |
| ]) | |
| categorical_transformer = Pipeline(steps=[ | |
| ('imputer', SimpleImputer(strategy='most_frequent')), | |
| ('onehot', OneHotEncoder(handle_unknown='ignore')) | |
| ]) | |
| preprocessor = ColumnTransformer( | |
| transformers=[ | |
| ('num', numeric_transformer, numeric_features), | |
| ('cat', categorical_transformer, categorical_features) | |
| ], | |
| remainder='drop' | |
| ) | |
| return preprocessor | |
| def train(self, df: pd.DataFrame) -> None: | |
| print(f"Iniciando treinamento com {len(df)} linhas e features: {ALL_PREDICTOR_FEATURES}") | |
| self.training_details['dataset_rows'] = len(df) | |
| self.training_details['predictor_features'] = ALL_PREDICTOR_FEATURES | |
| self.training_details['target_column'] = TARGET_COLUMN | |
| missing_cols = [col for col in ALL_PREDICTOR_FEATURES + [TARGET_COLUMN] if col not in df.columns] | |
| if missing_cols: | |
| raise ValueError(f"Colunas ausentes no DataFrame: {missing_cols}. Verifique seu 'data.csv'.") | |
| self.df_raw_for_plots = df.copy() | |
| X = df[ALL_PREDICTOR_FEATURES] | |
| y = df[TARGET_COLUMN] | |
| X_train, self.X_test, y_train, self.y_test = train_test_split( | |
| X, y, test_size=0.2, random_state=42, stratify=y | |
| ) | |
| self.training_details['X_train_shape'] = X_train.shape | |
| self.training_details['y_train_value_counts_before_smote'] = y_train.value_counts().to_dict() | |
| self.preprocessor = self._build_preprocessor(X_train) | |
| X_train_processed = self.preprocessor.fit_transform(X_train) | |
| self.training_details['X_train_processed_shape'] = X_train_processed.shape | |
| numeric_f = X_train.select_dtypes(include=np.number).columns.tolist() | |
| categorical_f = X_train.select_dtypes(include='object').columns.tolist() | |
| ohe_feature_names = [] | |
| if 'cat' in self.preprocessor.named_transformers_ and isinstance(self.preprocessor.named_transformers_['cat'], Pipeline): | |
| if 'onehot' in self.preprocessor.named_transformers_['cat'].named_steps: | |
| ohe_feature_names = list(self.preprocessor.named_transformers_['cat'].named_steps['onehot'].get_feature_names_out(categorical_f)) | |
| self.feature_names_out = numeric_f + ohe_feature_names | |
| smote = SMOTE(random_state=42) | |
| X_train_resampled, y_train_resampled = smote.fit_resample(X_train_processed, y_train) | |
| self.training_details['y_train_resampled_value_counts_after_smote'] = y_train_resampled.value_counts().to_dict() | |
| self.model = LogisticRegression(random_state=42, solver='liblinear', C=0.1, max_iter=500) | |
| self.model.fit(X_train_resampled, y_train_resampled) | |
| self.training_details['model_trained_successfully'] = True | |
| self.X_test_processed = self.preprocessor.transform(self.X_test) | |
| self._evaluate_and_store_results(self.y_test, self.X_test_processed) | |
| self._generate_plots() | |
| def _evaluate_and_store_results(self, y_test: pd.Series, X_test_processed: np.ndarray) -> None: | |
| if self.model is None or self.preprocessor is None: | |
| raise RuntimeError("Modelo ou pré-processador não treinados para avaliação.") | |
| y_pred = self.model.predict(X_test_processed) | |
| y_proba = self.model.predict_proba(X_test_processed)[:, 1] | |
| self.metrics_dict = { | |
| "Acurácia": accuracy_score(y_test, y_pred), | |
| "AUC ROC": roc_auc_score(y_test, y_proba), | |
| "Precisão": precision_score(y_test, y_pred), | |
| "Recall (Sensibilidade)": recall_score(y_test, y_pred), | |
| "F1-Score": f1_score(y_test, y_pred) | |
| } | |
| if hasattr(self.model, 'coef_') and self.feature_names_out: | |
| coefs = self.model.coef_[0] if self.model.coef_.ndim > 1 else self.model.coef_ | |
| self.coefficients_df = pd.DataFrame({'Feature': self.feature_names_out, 'Coeficiente': coefs}) | |
| self.coefficients_df['Odds_Ratio'] = np.exp(self.coefficients_df['Coeficiente']) | |
| self.coefficients_df = self.coefficients_df.sort_values(by='Odds_Ratio', ascending=False).reset_index(drop=True) | |
| def _generate_plots(self) -> None: | |
| if self.model is None or self.preprocessor is None or self.X_test_processed is None or self.y_test is None: | |
| return | |
| plot_dir = tempfile.mkdtemp() | |
| self.plot_paths = {} | |
| dpi = 150 # Aumentado DPI para melhor qualidade em relatórios | |
| # --- 1. Correlation Heatmap --- | |
| if self.df_raw_for_plots is not None and not self.df_raw_for_plots.empty: | |
| plt.figure(figsize=(12, 10), dpi=dpi) | |
| numeric_cols_for_corr = self.df_raw_for_plots[ALL_PREDICTOR_FEATURES + [TARGET_COLUMN]].select_dtypes(include=np.number) | |
| corr_matrix = numeric_cols_for_corr.corr() | |
| sns.heatmap(corr_matrix, annot=True, cmap='coolwarm', fmt=".2f") | |
| plt.title('Mapa de Calor da Correlação entre as Variáveis Numéricas', fontsize=16) | |
| heatmap_path = os.path.join(plot_dir, 'correlation_heatmap.png') | |
| plt.tight_layout() | |
| plt.savefig(heatmap_path) | |
| plt.close() | |
| self.plot_paths['heatmap'] = heatmap_path | |
| # --- 2. Odds Ratios Chart --- | |
| if not self.coefficients_df.empty: | |
| plt.figure(figsize=(14, 8), dpi=dpi) | |
| plot_df = self.coefficients_df.copy() | |
| plot_df['Abs_Coef'] = np.abs(plot_df['Coeficiente']) | |
| plot_df = plot_df.sort_values(by='Abs_Coef', ascending=False).head(20) | |
| sns.barplot(x='Odds_Ratio', y='Feature', data=plot_df, palette='viridis', hue='Feature', legend=False) | |
| plt.axvline(1, color='red', linestyle='--', linewidth=0.8, label='Sem Efeito (Odds Ratio = 1)') | |
| plt.title('Odds Ratios das Features (Top 20 por impacto absoluto)', fontsize=16) | |
| plt.xlabel('Odds Ratio (escala logarítmica)', fontsize=12) | |
| plt.ylabel('Feature', fontsize=12) | |
| plt.xscale('log') | |
| plt.xticks(fontsize=10) | |
| plt.yticks(fontsize=10) | |
| plt.tight_layout() | |
| odds_ratio_path = os.path.join(plot_dir, 'odds_ratios.png') | |
| plt.savefig(odds_ratio_path) | |
| plt.close() | |
| self.plot_paths['odds_ratios'] = odds_ratio_path | |
| # --- 3. Confusion Matrix --- | |
| y_pred = self.model.predict(self.X_test_processed) | |
| cm = confusion_matrix(self.y_test, y_pred) | |
| plt.figure(figsize=(8, 6), dpi=dpi) | |
| sns.heatmap(cm, annot=True, fmt='d', cmap='Blues', | |
| xticklabels=['Previsto Não Churn', 'Previsto Churn'], | |
| yticklabels=['Real Não Churn', 'Real Churn'], | |
| annot_kws={"size": 14}) | |
| plt.title('Matriz de Confusão do Modelo (Conjunto de Teste)', fontsize=16) | |
| plt.xlabel('Rótulo Previsto', fontsize=12) | |
| plt.ylabel('Rótulo Verdadeiro', fontsize=12) | |
| plt.tight_layout() | |
| cm_path = os.path.join(plot_dir, 'confusion_matrix.png') | |
| plt.savefig(cm_path) | |
| plt.close() # Fechar a figura para liberar memória | |
| self.plot_paths['confusion_matrix'] = cm_path | |
| # --- 4. ROC Curve --- | |
| y_proba = self.model.predict_proba(self.X_test_processed)[:, 1] | |
| fpr, tpr, _ = roc_curve(self.y_test, y_proba) | |
| roc_auc = auc(fpr, tpr) | |
| plt.figure(figsize=(8, 6), dpi=dpi) | |
| plt.plot(fpr, tpr, color='darkorange', lw=2, label=f'Curva ROC (área = {roc_auc:.2f})') | |
| plt.plot([0, 1], [0, 1], color='navy', lw=2, linestyle='--') | |
| plt.xlim([0.0, 1.0]) | |
| plt.ylim([0.0, 1.05]) | |
| plt.xlabel('Taxa de Falso Positivo', fontsize=12) | |
| plt.ylabel('Taxa de Verdadeiro Positivo', fontsize=12) | |
| plt.title('Curva ROC do Modelo de Churn (Conjunto de Teste)', fontsize=16) | |
| plt.legend(loc="lower right", fontsize=10) | |
| plt.grid(True, linestyle='--', alpha=0.6) | |
| plt.tight_layout() | |
| roc_path = os.path.join(plot_dir, 'roc_curve.png') | |
| plt.savefig(roc_path) | |
| plt.close() # Fechar a figura para liberar memória | |
| self.plot_paths['roc_curve'] = roc_path | |
| def predict_churn(self, input_data: pd.DataFrame) -> Tuple[int, float, float]: # Adicionado float para logit_value | |
| if self.model is None or self.preprocessor is None: | |
| raise RuntimeError("Modelo ou pré-processador não treinados. Chame .train() primeiro.") | |
| if not all(col in input_data.columns for col in ALL_PREDICTOR_FEATURES): | |
| missing_cols = [col for col in ALL_PREDICTOR_FEATURES if col not in input_data.columns] | |
| raise ValueError(f"Dados de entrada brutos não contêm todas as features esperadas: {missing_cols}. Features esperadas: {ALL_PREDICTOR_FEATURES}") | |
| input_data_ordered = input_data[ALL_PREDICTOR_FEATURES] | |
| X_processed = self.preprocessor.transform(input_data_ordered) | |
| prediction = self.model.predict(X_processed)[0] | |
| probability_churn = self.model.predict_proba(X_processed)[0][1] | |
| logit_value = self.model.decision_function(X_processed)[0] # Obtém o valor do logit (L) | |
| return int(prediction), float(probability_churn), float(logit_value) # Retorna os três valores | |
| def _get_summary_latex_and_markdown_parts(self, last_interactive_input_df: pd.DataFrame = None) -> Tuple[str, List[Any], Dict[str, str]]: | |
| if self.model is None or self.preprocessor is None: | |
| return "Modelo ainda não treinado. Execute o treinamento primeiro.", [], {} | |
| markdown_story = [] | |
| latex_story = [] | |
| # --- Título Principal --- | |
| markdown_story.append("# Resumo Detalhado do Modelo de Churn") | |
| latex_story.append(Section(NoEscape(r'Resumo Detalhado do Modelo de Churn'))) | |
| markdown_story.append("Este relatório consolida as informações sobre o modelo de Regressão Logística treinado para prever o Churn de clientes bancários, conforme as demandas da Tarefa 5 de AEDI.\n") | |
| latex_story.append(NoEscape(r'Este relatório consolida as informações sobre o modelo de Regressão Logística treinado para prever o Churn de clientes bancários, conforme as demandas da Tarefa 5 de AEDI.\n\n')) | |
| # --- 1. Simulação Rápida (Última Previsão Interativa ou Exemplo) --- | |
| sample_customer_df = None | |
| logit_sample = 0.0 # Inicializar com valores padrão | |
| prob_sample = 0.0 | |
| if last_interactive_input_df is not None and not last_interactive_input_df.empty: | |
| sample_customer_df = last_interactive_input_df | |
| elif self.X_test is not None and not self.X_test.empty: | |
| sample_customer_df = self.X_test.sample(1, random_state=42) | |
| markdown_story.append("## 1. Simulação Rápida (Última Previsão Interativa ou Exemplo)") | |
| latex_story.append(Section(NoEscape(r'Simulação Rápida (Última Previsão Interativa ou Exemplo)'), False)) # False para não numerar subseção | |
| if sample_customer_df is not None: | |
| sample_display_df = sample_customer_df.copy() | |
| if 'HasCrCard' in sample_display_df.columns: | |
| sample_display_df['HasCrCard'] = sample_display_df['HasCrCard'].apply(lambda x: 'Sim' if x == 1 else 'Não') | |
| if 'IsActiveMember' in sample_display_df.columns: | |
| sample_display_df['IsActiveMember'] = sample_display_df['IsActiveMember'].apply(lambda x: 'Sim' if x == 1 else 'Não') | |
| pred_sample, prob_sample, logit_sample = self.predict_churn(sample_customer_df) | |
| churn_status_sample = "PROVAVELMENTE dará CHURN." if pred_sample == 1 else "PROVAVELMENTE NÃO dará CHURN." | |
| markdown_story.append("Esta seção mostra a previsão para o último conjunto de dados inserido na aba 'Previsão Interativa', ou um exemplo de cliente do conjunto de teste se nenhuma previsão interativa foi feita.\n") | |
| latex_story.append(NoEscape(r'Esta seção mostra a previsão para o último conjunto de dados inserido na aba `Previsão Interativa`, ou um exemplo de cliente do conjunto de teste se nenhuma previsão interativa foi feita.\n\n')) | |
| markdown_story.append("**Características do Cliente Simulado:**\n" + sample_display_df.to_markdown(index=False) + "\n") | |
| latex_story.append(NoEscape(r'\textbf{Características do Cliente Simulado:}\n')) | |
| latex_story.append(NoEscape(sample_display_df.to_latex(index=False, caption='Características do Cliente Simulado', label='tab:sim_customer', longtable=False))) | |
| markdown_story.append(f"**Resultado da Simulação:** O cliente **{churn_status_sample}** (Probabilidade de Churn: **{prob_sample:.2%}**)\n") | |
| latex_story.append(NoEscape(fr'\textbf{{Resultado da Simulação:}} O cliente \textbf{{{churn_status_sample}}} (Probabilidade de Churn: \textbf{{{prob_sample:.2f}\%}})\n\n')) | |
| else: | |
| markdown_story.append("Não foi possível realizar uma simulação pois o DataFrame de teste ou dados interativos não estão disponíveis.\n") | |
| latex_story.append(NoEscape(r'Não foi possível realizar uma simulação pois o DataFrame de teste ou dados interativos não estão disponíveis.\n\n')) | |
| # --- 2. Detalhes do Processo de Treinamento --- | |
| markdown_story.append("## 2. Detalhes do Processo de Treinamento") | |
| latex_story.append(Section(NoEscape(r'Detalhes do Processo de Treinamento'), False)) | |
| if self.training_details: | |
| training_details_markdown = "" | |
| training_details_latex = "" | |
| training_details_markdown += f"- **Dataset Carregado:** `{self.training_details.get('dataset_rows', 'N/A')} linhas`\n" | |
| training_details_markdown += f"- **Features Preditivas:** `{', '.join(self.training_details.get('predictor_features', ['N/A']))}`\n" | |
| training_details_markdown += f"- **Coluna Alvo:** `{self.training_details.get('target_column', 'N/A')}`\n" | |
| training_details_markdown += f"- **Shape X_train (antes pré-processamento):** `{self.training_details.get('X_train_shape', 'N/A')}`\n" | |
| y_train_before_smote = self.training_details.get('y_train_value_counts_before_smote', {}) | |
| training_details_markdown += f"- **Balanceamento `Exited` (antes SMOTE):** `Não Churn: {y_train_before_smote.get(0, 'N/A')}, Churn: {y_train_before_smote.get(1, 'N/A')}`\n" | |
| training_details_markdown += f"- **Shape X_train (após pré-processamento):** `{self.training_details.get('X_train_processed_shape', 'N/A')}`\n" | |
| y_train_after_smote = self.training_details.get('y_train_resampled_value_counts_after_smote', {}) | |
| training_details_markdown += f"- **Balanceamento `Exited` (após SMOTE):** `Não Churn: {y_train_after_smote.get(0, 'N/A')}, Churn: {y_train_after_smote.get(1, 'N/A')}`\n" | |
| training_details_markdown += f"- **Modelo Treinado:** `{'Sim' if self.training_details.get('model_trained_successfully', False) else 'Não'}`\n" | |
| # Formato LaTeX | |
| training_details_latex += r'\begin{itemize}' + '\n' | |
| training_details_latex += fr'\item \textbf{{Dataset Carregado:}} {self.training_details.get("dataset_rows", "N/A")} linhas' + '\n' | |
| training_details_latex += fr'\item \textbf{{Features Preditivas:}} \texttt{{{", ".join(self.training_details.get("predictor_features", ["N/A"]))}}}.' + '\n' | |
| training_details_latex += fr'\item \textbf{{Coluna Alvo:}} \texttt{{{self.training_details.get("target_column", "N/A")}}}.' + '\n' | |
| training_details_latex += fr'\item \textbf{{Shape $X_{{\text{{train}}}}$ (antes pré-processamento):}} {self.training_details.get("X_train_shape", "N/A")}.' + '\n' | |
| training_details_latex += fr'\item \textbf{{Balanceamento \texttt{{Exited}} (antes SMOTE):}} Não Churn: {y_train_before_smote.get(0, "N/A")}, Churn: {y_train_before_smote.get(1, "N/A")}.' + '\n' | |
| training_details_latex += fr'\item \textbf{{Shape $X_{{\text{{train}}}}$ (após pré-processamento):}} {self.training_details.get("X_train_processed_shape", "N/A")}.' + '\n' | |
| training_details_latex += fr'\item \textbf{{Balanceamento \texttt{{Exited}} (após SMOTE):}} Não Churn: {y_train_after_smote.get(0, "N/A")}, Churn: {y_train_after_smote.get(1, "N/A")}.' + '\n' | |
| training_details_latex += fr'\item \textbf{{Modelo Treinado:}} {"Sim" if self.training_details.get("model_trained_successfully", False) else "Não"}.' + '\n' | |
| training_details_latex += r'\end{itemize}' + '\n\n' | |
| markdown_story.append(training_details_markdown) | |
| latex_story.append(NoEscape(training_details_latex)) | |
| else: | |
| markdown_story.append("Nenhum detalhe de treinamento disponível.\n") | |
| latex_story.append(NoEscape(r'Nenhum detalhe de treinamento disponível.\n\n')) | |
| # --- 3. Descrição do Modelo e Metodologia --- | |
| markdown_story.append("## 3. Descrição do Modelo e Metodologia") | |
| latex_story.append(Section(NoEscape(r'Descrição do Modelo e Metodologia'), False)) | |
| markdown_story.append("O modelo utiliza **Regressão Logística** para classificar a probabilidade de um cliente sair (churn). Foram aplicadas as seguintes etapas para garantir robustez e tratar as características dos dados:\n") | |
| latex_story.append(NoEscape(r'O modelo utiliza \textbf{Regressão Logística} para classificar a probabilidade de um cliente sair (churn). Foram aplicadas as seguintes etapas para garantir robustez e tratar as características dos dados:\n\n')) | |
| markdown_story.append("- **Pré-processamento de Dados:**\n - **Features Numéricas:** Imputação de valores ausentes (mediana) e escalonamento (`StandardScaler`) para padronização.\n - **Features Categóricas:** Imputação de valores ausentes (moda) e codificação One-Hot (`OneHotEncoder`) para transformar categorias em formato numérico.\n") | |
| latex_story.append(NoEscape(r'\begin{itemize}' + '\n')) | |
| latex_story.append(NoEscape(r'\item \textbf{Pré-processamento de Dados:}' + '\n')) | |
| latex_story.append(NoEscape(r'\begin{itemize}' + '\n')) | |
| latex_story.append(NoEscape(r'\item \textbf{Features Numéricas:} Imputação de valores ausentes (mediana) e escalonamento (\texttt{StandardScaler}) para padronização.' + '\n')) | |
| latex_story.append(NoEscape(r'\item \textbf{Features Categóricas:} Imputação de valores ausentes (moda) e codificação One-Hot (\texttt{OneHotEncoder}) para transformar categorias em formato numérico.' + '\n')) | |
| latex_story.append(NoEscape(r'\end{itemize}' + '\n')) | |
| markdown_story.append("- **Balanceamento de Classes (SMOTE):** O conjunto de dados original apresentava desbalanceamento significativo na variável alvo (`Exited`). O algoritmo SMOTE (Synthetic Minority Over-sampling Technique) foi aplicado para gerar amostras sintéticas da classe minoritária (clientes que saem), garantindo que o modelo não seja viesado para a classe majoritária (clientes que permanecem).\n") | |
| latex_story.append(NoEscape(r'\item \textbf{Balanceamento de Classes (SMOTE):} O conjunto de dados original apresentava desbalanceamento significativo na variável alvo (\texttt{Exited}). O algoritmo SMOTE (Synthetic Minority Over-sampling Technique) foi aplicado para gerar amostras sintéticas da classe minoritária (clientes que saem), garantindo que o modelo não seja viesado para a classe majoritária (clientes que permanecem).' + '\n')) | |
| markdown_story.append("- **Regularização (L2):** A Regressão Logística foi configurada com um parâmetro `C=0.1` (inverso da força de regularização), que aplica regularização L2. Isso ajuda a prevenir o overfitting, penalizando coeficientes grandes e promovendo um modelo mais generalizável.\n") | |
| latex_story.append(NoEscape(r'\item \textbf{Regularização (L2):} A Regressão Logística foi configurada com um parâmetro \texttt{C=0.1} (inverso da força de regularização), que aplica regularização L2. Isso ajuda a prevenir o overfitting, penalizando coeficientes grandes e promovendo um modelo mais generalizável.' + '\n')) | |
| latex_story.append(NoEscape(r'\end{itemize}' + '\n\n')) | |
| # --- 4. Como a Probabilidade de Churn é Calculada --- | |
| markdown_story.append("## 4. Como a Probabilidade de Churn é Calculada") | |
| latex_story.append(Section(NoEscape(r'Como a Probabilidade de Churn é Calculada'), False)) | |
| markdown_story.append("A Regressão Logística é um modelo de classificação que estima a probabilidade de um evento (neste caso, o churn do cliente) ocorrer. Ao contrário da regressão linear, que prevê um valor contínuo, a regressão logística utiliza a **função sigmoide** para mapear qualquer valor real para um valor entre 0 e 1, que pode ser interpretado como probabilidade.\n") | |
| latex_story.append(NoEscape(r'A Regressão Logística é um modelo de classificação que estima a probabilidade de um evento (neste caso, o churn do cliente) ocorrer. Ao contrário da regressão linear, que prevê um valor contínuo, a regressão logística utiliza a \textbf{função sigmoide} para mapear qualquer valor real para um valor entre 0 e 1, que pode ser interpretado como probabilidade.\n\n')) | |
| markdown_story.append("A equação básica de um modelo linear (`L`) é:\n`L = β₀ + β₁X₁ + β₂X₂ + ... + βₙXₙ`\nOnde `β` são os coeficientes (pesos) das features (`X`).\n") | |
| latex_story.append(Math(data=[NoEscape(r'L = \beta_0 + \beta_1 X_1 + \beta_2 X_2 + \dots + \beta_n X_n')])) | |
| latex_story.append(NoEscape(r'\nOnde $\beta$ são os coeficientes (pesos) das features ($X$).\n\n')) | |
| markdown_story.append("A probabilidade (`P`) de churn é então calculada aplicando-se a função sigmoide (σ) a `L`:\n`P(Churn) = σ(L) = 1 / (1 + e⁻ᴸ)`\n") | |
| latex_story.append(Math(data=[NoEscape(r'P(\text{Churn}) = \sigma(L) = \frac{1}{1 + e^{-L}}')])) | |
| markdown_story.append("Esta função garante que a saída esteja sempre entre 0 e 1, representando a probabilidade de o cliente pertencer à classe 'Churn' (ou seja, `Exited = 1`). Se `P(Churn)` for maior que um determinado limiar (geralmente 0.5), o cliente é classificado como provável churn.\n") | |
| latex_story.append(NoEscape(r'\nEsta função garante que a saída esteja sempre entre 0 e 1, representando a probabilidade de o cliente pertencer à classe `Churn` (ou seja, \texttt{Exited = 1}). Se $P(\text{Churn})$ for maior que um determinado limiar (geralmente 0.5), o cliente é classificado como provável churn.\n\n')) | |
| # --- Subseção: Exemplo de Simulação Numérica (AGORA COM DADOS REAIS DA SIMULAÇÃO) --- | |
| markdown_story.append("### Exemplo de Simulação Numérica com Cliente Simulado") | |
| latex_story.append(Section(NoEscape(r'Exemplo de Simulação Numérica com Cliente Simulado'), False)) # Sub-subseção não numerada | |
| if sample_customer_df is not None: | |
| # Reutilizamos os valores de logit_sample e prob_sample calculados anteriormente para o cliente simulado | |
| markdown_story.append("Para ilustrar o cálculo, vamos usar as características do cliente simulado acima (ou o último cliente da Previsão Interativa) e os coeficientes do modelo treinado. Note que as características numéricas são **escalonadas** e as categóricas **one-hot encoded** antes de serem multiplicadas pelos coeficientes. \n") | |
| latex_story.append(NoEscape(r'Para ilustrar o cálculo, vamos usar as características do cliente simulado acima (ou o último cliente da Previsão Interativa) e os coeficientes do modelo treinado. Note que as características numéricas são \textbf{escalonadas} e as categóricas \textbf{one-hot encoded} antes de serem multiplicadas pelos coeficientes. \n\n')) | |
| markdown_story.append(f"**Características do Cliente 'Simulado':**\n" + sample_display_df.to_markdown(index=False) + "\n") | |
| latex_story.append(NoEscape(sample_display_df.to_latex(index=False, caption='Características do Cliente Simulado', label='tab:sim_customer_example', longtable=False))) | |
| # Pré-formatar os valores antes de inseri-los nas f-strings LaTeX | |
| logit_sample_formatted = f"{logit_sample:.4f}" | |
| prob_sample_formatted = f"{prob_sample:.4f}" | |
| prob_sample_percent_formatted = f"{prob_sample:.2%}" # Para exibição em percentual | |
| markdown_story.append("**Passos do Cálculo para o Cliente 'Simulado':**\n") | |
| markdown_story.append(f"1. **Calcular o Logit (L):** O Logit é a soma ponderada de todas as características do cliente (já processadas pelo pré-processador do modelo) multiplicadas por seus respectivos coeficientes, mais o intercepto do modelo. Para o cliente simulado, o modelo calculou um Logit de:\n`L = {logit_sample_formatted}`\n") | |
| # LaTeX for Logit calculation | |
| latex_story.append(NoEscape(r'\textbf{Passos do Cálculo para o Cliente "Simulado":}\n')) | |
| latex_story.append(NoEscape(r'\begin{enumerate}')) | |
| latex_story.append(NoEscape(fr'\item \textbf{{Calcular o Logit (L):}} O Logit é a soma ponderada de todas as características do cliente (já processadas pelo pré-processador do modelo) multiplicadas por seus respectivos coeficientes, mais o intercepto do modelo. Para o cliente simulado, o modelo calculou um Logit de:')) | |
| latex_story.append(Math(data=[NoEscape(r'L = ' + logit_sample_formatted)])) # Alteração aqui! | |
| markdown_story.append(f"2. **Calcular a Probabilidade de Churn (P) usando a função Sigmoide:** A probabilidade é obtida aplicando-se a função sigmoide ao valor de `L`:\n`P(Churn) = 1 / (1 + e^(-L))`\n`P(Churn) = 1 / (1 + e^(-({logit_sample_formatted})))`\n`P(Churn) = 1 / (1 + e^{{-{logit_sample_formatted}}})`\n`P(Churn) ≈ {prob_sample_formatted}`\n") | |
| # LaTeX for Probability calculation | |
| latex_story.append(NoEscape(r'\item \textbf{Calcular a Probabilidade de Churn (P) usando a função Sigmoide:} A probabilidade é obtida aplicando-se a função sigmoide ao valor de $L$:')) | |
| latex_story.append(Math(data=[NoEscape(r'P(\text{Churn}) = \frac{1}{1 + e^{-L}}')])) # Alteração aqui! | |
| latex_story.append(Math(data=[NoEscape(r'P(\text{Churn}) = \frac{1}{1 + e^{-(' + logit_sample_formatted + r')}}')])) # Alteração aqui! | |
| latex_story.append(Math(data=[NoEscape(r'P(\text{Churn}) = \frac{1}{1 + e^{-' + logit_sample_formatted + r'}}')])) # Alteração aqui! | |
| latex_story.append(Math(data=[NoEscape(r'P(\text{Churn}) \approx ' + prob_sample_formatted)])) # Alteração aqui! | |
| latex_story.append(NoEscape(r'\end{enumerate}\n')) | |
| markdown_story.append(f"**Resultado da Simulação para o Cliente 'Simulado':**\n") | |
| markdown_story.append(f"A probabilidade de Churn para este cliente específico é de **{prob_sample_formatted}**, ou seja, **{prob_sample_percent_formatted}**.\n") | |
| markdown_story.append(f"Este resultado indica que o cliente possui uma probabilidade de churn de {prob_sample_percent_formatted}, guiando a interpretação do risco.\n") | |
| latex_story.append(NoEscape(r'\textbf{Resultado da Simulação para o Cliente "Simulado":}\n')) | |
| latex_story.append(NoEscape(fr'A probabilidade de Churn para este cliente específico é de \textbf{{{prob_sample_formatted}}}, ou seja, \textbf{{{prob_sample_percent_formatted}}}. ')) | |
| latex_story.append(NoEscape(fr'Este resultado indica que o cliente possui uma probabilidade de churn de {prob_sample_percent_formatted}, guiando a interpretação do risco.\n\n')) | |
| else: | |
| markdown_story.append("Não foi possível gerar o exemplo de simulação numérica, pois nenhum cliente simulado foi fornecido.\n") | |
| latex_story.append(NoEscape(r'Não foi possível gerar o exemplo de simulação numérica, pois nenhum cliente simulado foi fornecido.\n\n')) | |
| # --- Fim da Subseção de Exemplo --- | |
| # --- 5. Importância das Variáveis (Coeficientes e Odds Ratio) --- | |
| markdown_story.append("## 5. Importância das Variáveis (Coeficientes e Odds Ratio)") | |
| latex_story.append(Section(NoEscape(r'Importância das Variáveis (Coeficientes e Odds Ratio)'), False)) | |
| markdown_story.append("A análise dos coeficientes do modelo de Regressão Logística, transformados em Odds Ratios, nos permite entender a influência de cada característica na probabilidade de Churn. Um Odds Ratio maior que 1 indica que o aumento daquela feature (ou pertencer àquela categoria) aumenta as chances de Churn, enquanto um valor menor que 1 diminui.\n") | |
| latex_story.append(NoEscape(r'A análise dos coeficientes do modelo de Regressão Logística, transformados em Odds Ratios, nos permite entender a influência de cada característica na probabilidade de Churn. Um Odds Ratio maior que 1 indica que o aumento daquela feature (ou pertencer àquela categoria) aumenta as chances de Churn, enquanto um valor menor que 1 diminui.\n\n')) | |
| if not self.coefficients_df.empty: | |
| markdown_story.append(self.coefficients_df.to_markdown(index=False) + "\n") | |
| latex_story.append(NoEscape(self.coefficients_df.to_latex(index=False, caption='Coeficientes e Odds Ratios das Variáveis', label='tab:coefficients', longtable=False))) | |
| # Início das correções para `SyntaxError: f-string: single '}' is not allowed` | |
| # Linha original de Precisão (equivalente a ~458) | |
| precision_value = self.metrics_dict.get('Precisão', 0) | |
| precision_text_latex = ( | |
| f'\item \textbf{{Precisão ({precision_value:.2f}\%):}} ' # Usando f-string para formatar valor e \% | |
| + r'Das previsões de churn (\texttt{1}), quantos realmente foram churn. ' | |
| r'É importante para o banco não abordar clientes que não iriam dar churn (reduzir falsos positivos). ' | |
| f'Um valor de {precision_value:.2f}\% significa que das vezes que o modelo previu churn, essa porcentagem estava correta.' | |
| ) | |
| latex_story.append(NoEscape(precision_text_latex + '\n')) | |
| # Linha original de Recall (equivalente a ~464) | |
| recall_value = self.metrics_dict.get('Recall (Sensibilidade)', 0) | |
| recall_text_latex = ( | |
| f'\item \textbf{{Recall (Sensibilidade) ({recall_value:.2f}\%):}} ' # Usando f-string para formatar valor e \% | |
| + r'Dos clientes que realmente deram churn (\texttt{1}), quantos o modelo identificou. ' | |
| r'É crucial para o banco identificar o máximo de clientes em risco (reduzir falsos negativos). ' | |
| f'Um valor de {recall_value:.2f}\% significa que essa porcentagem de clientes que de fato deram churn foi corretamente identificada pelo modelo.' | |
| ) | |
| latex_story.append(NoEscape(recall_text_latex + '\n')) | |
| # F1-Score - Ajustando para o novo padrão de concatenação se necessário, ou mantendo f-string se for simples | |
| f1_value = self.metrics_dict.get('F1-Score', 0) | |
| f1_text_latex = ( | |
| f'\item \textbf{{F1-Score ({f1_value:.4f})}}: ' | |
| r'É a média harmônica entre Precisão e Recall, útil quando há um desequilíbrio de classes e você precisa de um balanço entre identificar corretamente e não levantar falsos alarmes.' | |
| ) | |
| latex_story.append(NoEscape(f1_text_latex + '\n')) | |
| latex_story.append(NoEscape(r'\end{itemize}' + '\n\n')) | |
| else: | |
| markdown_story.append("Nenhum coeficiente disponível. O modelo pode não ter sido treinado ou não possui coeficientes acessíveis.\n") | |
| latex_story.append(NoEscape(r'Nenhum dado de avaliação disponível. O modelo pode não ter sido treinado ou avaliado.\n\n')) | |
| # --- 7. Conclusão e Próximos Passos --- | |
| markdown_story.append("## 7. Conclusão e Próximos Passos") | |
| latex_story.append(Section(NoEscape(r'Conclusão e Próximos Passos'), False)) | |
| markdown_story.append("O modelo de Regressão Logística provê uma base sólida para a previsão de churn. As variáveis identificadas como mais influentes (pelos Odds Ratios) devem ser o foco para o planejamento estratégico de retenção. Por exemplo, campanhas de marketing direcionadas a grupos de maior risco ou ofertas personalizadas podem ser desenvolvidas com base nas características que aumentam a probabilidade de churn.\nPara aprimoramento contínuo, sugere-se a exploração de outros modelos, engenharia de novas features, e reavaliação periódica do modelo com dados mais recentes.") | |
| latex_story.append(NoEscape(r'O modelo de Regressão Logística provê uma base sólida para a previsão de churn. As variáveis identificadas como mais influentes ( pelos Odds Ratios) devem ser o foco para o planejamento estratégico de retenção. Por exemplo, campanhas de marketing direcionadas a grupos de maior risco ou ofertas personalizadas podem ser desenvolvidas com base nas características que aumentam a probabilidade de churn.\n\nPara aprimoramento contínuo, sugere-se a exploração de outros modelos, engenharia de novas features, e reavaliação periódica do modelo com dados mais recentes.')) | |
| return "\n".join(markdown_story), latex_story, self.plot_paths | |
| def generate_latex_report(self, latex_content_parts: List[Any], header_info: Dict[str, str], plot_paths: Dict[str, str]) -> Union[str, None]: | |
| """Gera um arquivo LaTeX a partir do conteúdo e paths das imagens.""" | |
| try: | |
| doc = Document(documentclass='article', document_options=['12pt', 'a4paper']) | |
| # Pacotes LaTeX | |
| doc.packages.append(Command('usepackage', 'amsmath')) # Para equações avançadas | |
| doc.packages.append(Command('usepackage', 'graphicx')) # Para incluir imagens | |
| doc.packages.append(Command('usepackage', 'booktabs')) # Para tabelas com linhas mais bonitas | |
| doc.packages.append(Command('usepackage', 'geometry')) # Para configurar margens | |
| doc.packages.append(Command('usepackage', 'hyperref')) # Para links (útil para referências) | |
| doc.append(Command('geometry', 'margin=1in')) # Margens de 1 polegada | |
| doc.append(Command('graphicspath', NoEscape(r'{./}'))) # Para imagens no mesmo diretório que o .tex | |
| # --- Crie um diretório temporário ÚNICO para o .tex e as CÓPIAS das imagens --- | |
| latex_output_dir = tempfile.mkdtemp() | |
| output_filename_full_path = os.path.join(latex_output_dir, 'relatorio_churn.tex') | |
| # --- COPIE os arquivos de plot para este diretório temporário --- | |
| for key, original_path in plot_paths.items(): | |
| if os.path.exists(original_path): | |
| basename = os.path.basename(original_path) | |
| new_path_in_latex_dir = os.path.join(latex_output_dir, basename) | |
| shutil.copy2(original_path, new_path_in_latex_dir) # Copia o arquivo | |
| else: | |
| print(f"WARNING: Plot file not found at {original_path} for key {key}. It will not be included in LaTeX.") | |
| doc.append(NoEscape(r'\begin{titlepage}')) | |
| doc.append(Command('centering')) | |
| # Logo da UnB | |
| logo_filename = 'MARCADOR.png' # Nome do arquivo com case-sensitive | |
| logo_target_path = os.path.join(latex_output_dir, logo_filename) | |
| if os.path.exists(logo_filename): # Verifica se o original existe | |
| shutil.copy2(logo_filename, logo_target_path) # Copia o logo para o diretório temporário do LaTeX | |
| with doc.create(Figure(position='h!')) as logo_fig: | |
| # Referencia pelo nome do arquivo, pois está no mesmo diretório do .tex | |
| logo_fig.add_image(os.path.basename(logo_target_path), width='0.25\\textwidth') | |
| logo_fig.add_caption(NoEscape(r'\vspace{-0.5cm}')) | |
| else: | |
| doc.append(Command('textbf', 'AVISO: Logo da UnB não encontrado! Certifique-se de que "MARCADOR.png" esteja na raiz do seu Hugging Face Space.')) | |
| doc.append(Command('vspace', '0.5cm')) | |
| # Informações da Universidade | |
| doc.append(Command('large')) | |
| doc.append(Command('textbf', header_info["universidade"])) | |
| doc.append(LineBreak()) | |
| doc.append(header_info["departamento"]) | |
| doc.append(LineBreak()) | |
| doc.append(header_info["programa"]) | |
| doc.append(LineBreak()) | |
| doc.append(header_info["mestrado"]) | |
| doc.append(LineBreak()) | |
| doc.append(Command('vspace', '1.0cm')) | |
| # Título do Trabalho (do usuário, ajustado para LaTeX) | |
| latex_title_lines = [] | |
| current_line_parts = [] | |
| words = header_info["titulo_trabalho"].split() | |
| for word in words: | |
| if word == 'UTILIZANDO': | |
| if current_line_parts: | |
| latex_title_lines.append(" ".join(current_line_parts)) | |
| current_line_parts = [] | |
| latex_title_lines.append(r'\ \large UTILIZANDO') # LaTeX line break and large font size | |
| else: | |
| current_line_parts.append(word) | |
| if current_line_parts: | |
| latex_title_lines.append(" ".join(current_line_parts)) | |
| doc.append(Command('Huge')) | |
| if latex_title_lines: | |
| doc.append(Command('textbf', NoEscape(latex_title_lines[0]))) | |
| for line_idx in range(1, len(latex_title_lines)): | |
| doc.append(LineBreak()) | |
| if r'\large' in latex_title_lines[line_idx]: # Check for the large font command | |
| doc.append(NoEscape(latex_title_lines[line_idx])) | |
| else: | |
| doc.append(Command('textbf', NoEscape(latex_title_lines[line_idx]))) | |
| doc.append(Command('vspace', '1.0cm')) | |
| # Identificação AEDI | |
| doc.append(Command('large')) | |
| doc.append(header_info["identificacao_aedi"]) | |
| doc.append(LineBreak()) | |
| doc.append(Command('vspace', '0.5cm')) | |
| # Informações do Aluno e Professor | |
| doc.append(header_info["nome_aluno"]) | |
| doc.append(LineBreak()) | |
| doc.append(header_info["matricula_aluno"]) | |
| doc.append(LineBreak()) | |
| doc.append(header_info["nome_professor"]) | |
| doc.append(LineBreak()) | |
| doc.append(Command('normalsize')) | |
| doc.append(Command('vfill')) # Empurra o conteúdo para cima | |
| doc.append(Command('end{titlepage}')) | |
| doc.append(Command('clearpage')) | |
| doc.append(Command('tableofcontents')) # Sumário | |
| doc.append(Command('clearpage')) | |
| # Conteúdo do Resumo | |
| for item in latex_content_parts: | |
| doc.append(item) # pylatex objects (Section, Math, NoEscape) are directly appended | |
| # Adicionar imagens ao final do documento LaTeX | |
| doc.append(NoEscape(r'\clearpage')) | |
| doc.append(Section(NoEscape(r'Visualizações Gráficas do Modelo'))) | |
| for key, original_plot_path in plot_paths.items(): # Use original_plot_path para referenciar o caminho | |
| if os.path.exists(original_plot_path): | |
| with doc.create(Figure(position='htbp')) as plot_fig: | |
| # Referencia pelo nome do arquivo, pois está no mesmo diretório do .tex | |
| plot_fig.add_image(os.path.basename(original_plot_path), width='0.8\textwidth') | |
| plot_fig.add_caption(NoEscape(f'{key.replace("_", " ").title()}')) | |
| doc.append(Command('clearpage')) # Cada imagem em uma nova página | |
| else: | |
| print(f"WARNING: Plot file not found at {original_plot_path} for key {key} when adding to LaTeX. It may have been deleted prematurely.") | |
| # --- Usar doc.dumps() e salvar manualmente para maior robustez --- | |
| latex_content_str = doc.dumps() | |
| with open(output_filename_full_path, 'w', encoding='utf-8') as f: | |
| f.write(latex_content_str) | |
| # Verificar se o arquivo foi realmente criado e não está vazio | |
| if os.path.exists(output_filename_full_path) and os.path.getsize(output_filename_full_path) > 0: | |
| print(f"DEBUG: LaTeX file successfully created at {output_filename_full_path}") | |
| return output_filename_full_path | |
| else: | |
| print(f"CRITICAL ERROR: LaTeX file was NOT created or is empty by pylatex at {output_filename_full_path}.") | |
| return None | |
| except Exception as e: | |
| print(f"ERROR during LaTeX report generation: {e}") | |
| return None # Retorna None em caso de qualquer erro |