churn / model_utils.py
252106862eder's picture
Update model_utils.py
6eaef4e verified
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