Update src/streamlit_app.py
Browse files- src/streamlit_app.py +526 -38
src/streamlit_app.py
CHANGED
|
@@ -1,40 +1,528 @@
|
|
| 1 |
-
import altair as alt
|
| 2 |
-
import numpy as np
|
| 3 |
-
import pandas as pd
|
| 4 |
import streamlit as st
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
Edit `/streamlit_app.py` to customize this app to your heart's desire :heart:.
|
| 10 |
-
If you have any questions, checkout our [documentation](https://docs.streamlit.io) and [community
|
| 11 |
-
forums](https://discuss.streamlit.io).
|
| 12 |
-
|
| 13 |
-
In the meantime, below is an example of what you can do with just a few lines of code:
|
| 14 |
-
"""
|
| 15 |
-
|
| 16 |
-
num_points = st.slider("Number of points in spiral", 1, 10000, 1100)
|
| 17 |
-
num_turns = st.slider("Number of turns in spiral", 1, 300, 31)
|
| 18 |
-
|
| 19 |
-
indices = np.linspace(0, 1, num_points)
|
| 20 |
-
theta = 2 * np.pi * num_turns * indices
|
| 21 |
-
radius = indices
|
| 22 |
-
|
| 23 |
-
x = radius * np.cos(theta)
|
| 24 |
-
y = radius * np.sin(theta)
|
| 25 |
-
|
| 26 |
-
df = pd.DataFrame({
|
| 27 |
-
"x": x,
|
| 28 |
-
"y": y,
|
| 29 |
-
"idx": indices,
|
| 30 |
-
"rand": np.random.randn(num_points),
|
| 31 |
-
})
|
| 32 |
-
|
| 33 |
-
st.altair_chart(alt.Chart(df, height=700, width=700)
|
| 34 |
-
.mark_point(filled=True)
|
| 35 |
-
.encode(
|
| 36 |
-
x=alt.X("x", axis=None),
|
| 37 |
-
y=alt.Y("y", axis=None),
|
| 38 |
-
color=alt.Color("idx", legend=None, scale=alt.Scale()),
|
| 39 |
-
size=alt.Size("rand", legend=None, scale=alt.Scale(range=[1, 150])),
|
| 40 |
-
))
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import streamlit as st
|
| 2 |
+
import pandas as pd
|
| 3 |
+
from collections import Counter
|
| 4 |
+
from imblearn.over_sampling import SMOTE
|
| 5 |
+
from sklearn.model_selection import train_test_split
|
| 6 |
+
from sklearn.preprocessing import StandardScaler
|
| 7 |
+
from sklearn.neighbors import KNeighborsClassifier
|
| 8 |
+
from sklearn.svm import SVC
|
| 9 |
+
from sklearn.tree import DecisionTreeClassifier
|
| 10 |
+
from sklearn.ensemble import RandomForestClassifier, AdaBoostClassifier, GradientBoostingClassifier
|
| 11 |
+
from xgboost import XGBClassifier
|
| 12 |
+
from lightgbm import LGBMClassifier
|
| 13 |
+
from sklearn.metrics import roc_auc_score, roc_curve, accuracy_score, precision_score, recall_score, f1_score, \
|
| 14 |
+
confusion_matrix, ConfusionMatrixDisplay
|
| 15 |
+
import matplotlib.pyplot as plt
|
| 16 |
+
import seaborn as sns
|
| 17 |
+
import numpy as np
|
| 18 |
+
import io
|
| 19 |
+
from sklearn.feature_selection import RFE
|
| 20 |
+
from sklearn.linear_model import LogisticRegression
|
| 21 |
+
|
| 22 |
+
# Configuração da página do Streamlit
|
| 23 |
+
st.set_page_config(layout="wide", page_title="Previsão de Reclamações de Clientes")
|
| 24 |
+
|
| 25 |
+
st.title("📊 Previsão de Reclamações de Clientes com Modelos Supervisionados")
|
| 26 |
+
st.markdown(
|
| 27 |
+
"Este dashboard tem como objetivo identificar clientes com maior probabilidade de terem feito uma reclamação nos últimos 2 anos, utilizando modelos de Machine Learning.")
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
# --- Carregamento e Pré-processamento dos Dados ---
|
| 31 |
+
@st.cache_data
|
| 32 |
+
def load_data():
|
| 33 |
+
github_url = "https://raw.githubusercontent.com/Abdulraqib20/Customer-Personality-Analysis/refs/heads/main/marketing_campaign.csv"
|
| 34 |
+
try:
|
| 35 |
+
df = pd.read_csv(github_url, sep='\t')
|
| 36 |
+
except Exception as e:
|
| 37 |
+
st.error(f"Erro ao carregar o arquivo do GitHub: {e}")
|
| 38 |
+
st.stop()
|
| 39 |
+
return df
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
@st.cache_data
|
| 43 |
+
def preprocess_data(df):
|
| 44 |
+
df_processed = df.copy()
|
| 45 |
+
|
| 46 |
+
# Handle 'Dt_Customer' column
|
| 47 |
+
df_processed['Dt_Customer'] = pd.to_datetime(df_processed['Dt_Customer'], format='%d-%m-%Y')
|
| 48 |
+
reference_date = df_processed['Dt_Customer'].min()
|
| 49 |
+
df_processed['Days_Since_Customer'] = (df_processed['Dt_Customer'] - reference_date).dt.days
|
| 50 |
+
df_processed = df_processed.drop('Dt_Customer', axis=1) # Remove coluna original de data
|
| 51 |
+
|
| 52 |
+
# --- Coerção explícita para numérico para colunas que podem vir como 'object' ---
|
| 53 |
+
# Inclui colunas como Kidhome, Teenhome, AcceptedCmpX, Response que devem ser numéricas
|
| 54 |
+
cols_to_coerce_numeric = [
|
| 55 |
+
'Kidhome', 'Teenhome', 'Recency', 'MntWines', 'MntFruits', 'MntMeatProducts',
|
| 56 |
+
'MntFishProducts', 'MntSweetProducts', 'MntGoldProds', 'NumDealsPurchases',
|
| 57 |
+
'NumWebPurchases', 'NumCatalogPurchases', 'NumStorePurchases',
|
| 58 |
+
'NumWebVisitsMonth', 'AcceptedCmp1', 'AcceptedCmp2', 'AcceptedCmp3',
|
| 59 |
+
'AcceptedCmp4', 'AcceptedCmp5', 'Response', 'Days_Since_Customer', 'Income'
|
| 60 |
+
# Adicionado Income aqui para garantir
|
| 61 |
+
]
|
| 62 |
+
for col in cols_to_coerce_numeric:
|
| 63 |
+
if col in df_processed.columns:
|
| 64 |
+
df_processed[col] = pd.to_numeric(df_processed[col], errors='coerce')
|
| 65 |
+
df_processed[col] = df_processed[col].fillna(0) # Preenche NaN com 0 após coerção, se houver
|
| 66 |
+
|
| 67 |
+
# Lidar com valores ausentes: preencher 'Income' com a média (se ainda houver, após coerção)
|
| 68 |
+
# df_processed['Income'] = df_processed['Income'].fillna(df_processed['Income'].mean()) # Removido, já tratado acima
|
| 69 |
+
|
| 70 |
+
# Convertendo variáveis categóricas em numéricas (one-hot encoding)
|
| 71 |
+
df_processed = pd.get_dummies(df_processed, columns=['Education', 'Marital_Status'], drop_first=True)
|
| 72 |
+
|
| 73 |
+
# Excluir colunas irrelevantes e com variância zero
|
| 74 |
+
cols_to_drop = ['ID', 'Z_CostContact', 'Z_Revenue']
|
| 75 |
+
df_processed = df_processed.drop(columns=[col for col in cols_to_drop if col in df_processed.columns], axis=1,
|
| 76 |
+
errors='ignore')
|
| 77 |
+
|
| 78 |
+
# Remover colunas com variância zero (constantes) ou com muitos nulos após o pré-processamento
|
| 79 |
+
df_processed = df_processed.loc[:, df_processed.nunique() > 1] # Remove colunas com apenas 1 valor único
|
| 80 |
+
df_processed = df_processed.dropna(axis=1, how='all') # Remove colunas totalmente nulas
|
| 81 |
+
|
| 82 |
+
return df_processed
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
# Função para treinar e avaliar modelos
|
| 86 |
+
@st.cache_data(show_spinner=False)
|
| 87 |
+
def train_and_evaluate_models(X_train_raw, X_test_raw, y_train, y_test, _scaler, model_selected=None):
|
| 88 |
+
models = {
|
| 89 |
+
"K-Nearest Neighbors": KNeighborsClassifier(),
|
| 90 |
+
"Support Vector Machine": SVC(probability=True, random_state=42),
|
| 91 |
+
"Decision Tree": DecisionTreeClassifier(random_state=42),
|
| 92 |
+
"Random Forest": RandomForestClassifier(random_state=42),
|
| 93 |
+
"AdaBoosting": AdaBoostClassifier(random_state=42),
|
| 94 |
+
"Gradient Boosting": GradientBoostingClassifier(random_state=42),
|
| 95 |
+
"XGBoosting": XGBClassifier(use_label_encoder=False, eval_metric='logloss', random_state=42),
|
| 96 |
+
"LightGBM": LGBMClassifier(random_state=42)
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
results = {}
|
| 100 |
+
|
| 101 |
+
# Check if y_train has at least two classes before attempting to train
|
| 102 |
+
if len(np.unique(y_train)) < 2:
|
| 103 |
+
if st.session_state.get('is_initial_call', False):
|
| 104 |
+
return {name: {} for name in models.keys()}
|
| 105 |
+
else:
|
| 106 |
+
st.error(
|
| 107 |
+
"Erro: O conjunto de treino contém apenas uma classe na variável alvo. Verifique o balanceamento ou a divisão dos dados.")
|
| 108 |
+
return {}
|
| 109 |
+
|
| 110 |
+
# Check if X_train_raw has enough samples
|
| 111 |
+
if X_train_raw.shape[0] == 0:
|
| 112 |
+
if st.session_state.get('is_initial_call', False):
|
| 113 |
+
return {name: {} for name in models.keys()}
|
| 114 |
+
else:
|
| 115 |
+
st.error("Erro: Dados de treino com 0 amostras. Não é possível treinar modelos.")
|
| 116 |
+
return {}
|
| 117 |
+
|
| 118 |
+
# Verificar se os dtypes são numéricos antes de treinar
|
| 119 |
+
for col in X_train_raw.columns:
|
| 120 |
+
if not pd.api.types.is_numeric_dtype(X_train_raw[col]):
|
| 121 |
+
st.error(
|
| 122 |
+
f"Erro: Coluna '{col}' no X_train_raw não é numérica. Tipo: {X_train_raw[col].dtype}. Verifique o pré-processamento.")
|
| 123 |
+
return {}
|
| 124 |
+
|
| 125 |
+
for col in X_test_raw.columns:
|
| 126 |
+
if not pd.api.types.is_numeric_dtype(X_test_raw[col]):
|
| 127 |
+
st.error(
|
| 128 |
+
f"Erro: Coluna '{col}' no X_test_raw não é numérica. Tipo: {X_test_raw[col].dtype}. Verifique o pré-processamento.")
|
| 129 |
+
return {}
|
| 130 |
+
|
| 131 |
+
for name, model in models.items():
|
| 132 |
+
if model_selected and name != model_selected:
|
| 133 |
+
continue
|
| 134 |
+
|
| 135 |
+
# Aplicar escalonamento apenas para os dados de treino e teste
|
| 136 |
+
if name in ["K-Nearest Neighbors", "Support Vector Machine"]:
|
| 137 |
+
X_train_processed = _scaler.fit_transform(X_train_raw)
|
| 138 |
+
X_test_processed = _scaler.transform(X_test_raw)
|
| 139 |
+
else: # Para outros modelos, usamos os dados crus (não escalados)
|
| 140 |
+
X_train_processed = X_train_raw
|
| 141 |
+
X_test_processed = X_test_raw
|
| 142 |
+
|
| 143 |
+
try:
|
| 144 |
+
model.fit(X_train_processed, y_train)
|
| 145 |
+
y_pred = model.predict(X_test_processed)
|
| 146 |
+
|
| 147 |
+
# === CORREÇÃO PARA IndexError no predict_proba ===
|
| 148 |
+
if hasattr(model, 'predict_proba'):
|
| 149 |
+
probas = model.predict_proba(X_test_processed)
|
| 150 |
+
if probas.shape[1] > 1:
|
| 151 |
+
y_prob = probas[:, 1]
|
| 152 |
+
else:
|
| 153 |
+
y_prob = probas[:, 0]
|
| 154 |
+
else:
|
| 155 |
+
y_prob = y_pred # fallback, não ideal para AUC
|
| 156 |
+
|
| 157 |
+
# Calcular ROC AUC apenas se y_prob não for totalmente binário (0 ou 1)
|
| 158 |
+
if len(np.unique(y_prob)) > 1:
|
| 159 |
+
roc_auc = roc_auc_score(y_test, y_prob)
|
| 160 |
+
fpr, tpr, _ = roc_curve(y_test, y_prob)
|
| 161 |
+
else:
|
| 162 |
+
roc_auc = 0.5
|
| 163 |
+
fpr, tpr = [0, 1], [0, 1]
|
| 164 |
+
|
| 165 |
+
conf_matrix = confusion_matrix(y_test, y_pred)
|
| 166 |
+
|
| 167 |
+
results[name] = {
|
| 168 |
+
"Model": model,
|
| 169 |
+
"Accuracy": accuracy_score(y_test, y_pred),
|
| 170 |
+
"Precision": precision_score(y_test, y_pred, zero_division=0),
|
| 171 |
+
"Recall": recall_score(y_test, y_pred, zero_division=0),
|
| 172 |
+
"F1-score": f1_score(y_test, y_pred, zero_division=0),
|
| 173 |
+
"AUC": roc_auc,
|
| 174 |
+
"Confusion Matrix": conf_matrix,
|
| 175 |
+
"FPR": fpr,
|
| 176 |
+
"TPR": tpr,
|
| 177 |
+
"y_prob": y_prob
|
| 178 |
+
}
|
| 179 |
+
except ValueError as e:
|
| 180 |
+
if not st.session_state.get('is_initial_call', False):
|
| 181 |
+
st.warning(
|
| 182 |
+
f"Não foi possível treinar o modelo {name} devido a um erro: {e}. Provavelmente dados de teste/treino insuficientes ou de apenas uma classe.")
|
| 183 |
+
# Se for chamada inicial (dummy), não mostra nada no front
|
| 184 |
+
results[name] = {
|
| 185 |
+
"Model": None, "Accuracy": 0, "Precision": 0, "Recall": 0, "F1-score": 0,
|
| 186 |
+
"AUC": 0.5, "Confusion Matrix": np.array([[0, 0], [0, 0]]), "FPR": [0, 1], "TPR": [0, 1],
|
| 187 |
+
"y_prob": np.zeros(len(y_test))
|
| 188 |
+
}
|
| 189 |
+
continue
|
| 190 |
+
return results
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
# --- Carregar e Pré-processar os dados ---
|
| 194 |
+
df = load_data()
|
| 195 |
+
df_processed = preprocess_data(df)
|
| 196 |
+
|
| 197 |
+
X = df_processed.drop('Complain', axis=1)
|
| 198 |
+
y = df_processed['Complain']
|
| 199 |
+
|
| 200 |
+
# --- Sidebar para controle ---
|
| 201 |
+
st.sidebar.header("⚙️ Configurações do Modelo")
|
| 202 |
+
|
| 203 |
+
# Balanceamento da Base
|
| 204 |
+
st.sidebar.subheader("Balanceamento de Dados (SMOTE)")
|
| 205 |
+
balance_data = st.sidebar.checkbox("Aplicar SMOTE", value=True)
|
| 206 |
+
st.sidebar.info(
|
| 207 |
+
"SMOTE cria amostras sintéticas da classe minoritária para balancear os dados, melhorando o desempenho em datasets desbalanceados.")
|
| 208 |
+
|
| 209 |
+
# Seleção de Variáveis
|
| 210 |
+
st.sidebar.subheader("Seleção de Variáveis")
|
| 211 |
+
use_rfe = st.sidebar.checkbox("Usar Seleção de Variáveis (RFE)", value=False)
|
| 212 |
+
if use_rfe:
|
| 213 |
+
# Garante que X tem colunas suficientes para o slider
|
| 214 |
+
max_features_rfe = X.shape[1] if X.shape[1] > 5 else 5
|
| 215 |
+
n_features_rfe = st.sidebar.slider("Número de Variáveis a Selecionar (RFE)", 5, max_features_rfe,
|
| 216 |
+
min(10, max_features_rfe))
|
| 217 |
+
st.sidebar.info(
|
| 218 |
+
f"O RFE (Recursive Feature Elimination) seleciona as {n_features_rfe} melhores variáveis de forma iterativa.")
|
| 219 |
+
estimator_rfe = LogisticRegression(max_iter=1000, random_state=42)
|
| 220 |
+
|
| 221 |
+
if X.shape[0] > 0 and X.shape[1] >= n_features_rfe:
|
| 222 |
+
try:
|
| 223 |
+
selector_rfe = RFE(estimator_rfe, n_features_to_select=n_features_rfe, step=1)
|
| 224 |
+
selector_rfe = selector_rfe.fit(X, y)
|
| 225 |
+
rfe_selected_features_indices = selector_rfe.support_
|
| 226 |
+
X = X.loc[:, rfe_selected_features_indices]
|
| 227 |
+
st.sidebar.success(f"RFE aplicado. Selecionadas {X.shape[1]} features.")
|
| 228 |
+
except Exception as e:
|
| 229 |
+
st.sidebar.error(f"Erro ao aplicar RFE: {e}. RFE desabilitado.")
|
| 230 |
+
use_rfe = False
|
| 231 |
+
else:
|
| 232 |
+
st.sidebar.warning(
|
| 233 |
+
f"Não há dados suficientes ({X.shape[0]} amostras ou {X.shape[1]} colunas) para aplicar RFE com {n_features_rfe} features. RFE desabilitado.")
|
| 234 |
+
use_rfe = False
|
| 235 |
+
|
| 236 |
+
# Escolha do Modelo
|
| 237 |
+
st.sidebar.subheader("Seleção de Modelo para Treinamento")
|
| 238 |
+
|
| 239 |
+
# === CORREÇÃO: Passar dados dummy robustos para a chamada inicial do selectbox ===
|
| 240 |
+
st.session_state['is_initial_call'] = True
|
| 241 |
+
dummy_X_for_keys = pd.DataFrame(np.zeros((1, X.shape[1])), columns=X.columns)
|
| 242 |
+
dummy_y_for_keys = pd.Series([0, 1])
|
| 243 |
+
model_keys = train_and_evaluate_models(dummy_X_for_keys, dummy_X_for_keys, dummy_y_for_keys, dummy_y_for_keys,
|
| 244 |
+
StandardScaler()).keys()
|
| 245 |
+
st.session_state['is_initial_call'] = False
|
| 246 |
+
|
| 247 |
+
model_choice = st.sidebar.selectbox(
|
| 248 |
+
"Escolha o Modelo Principal para Análise Detalhada:",
|
| 249 |
+
list(model_keys)
|
| 250 |
+
)
|
| 251 |
+
|
| 252 |
+
st.sidebar.markdown("---")
|
| 253 |
+
st.sidebar.markdown("Desenvolvido por seu AI Assistant")
|
| 254 |
+
|
| 255 |
+
# --- Abas do Dashboard ---
|
| 256 |
+
tab1, tab2, tab3, tab4, tab5 = st.tabs([
|
| 257 |
+
"1. Visão Geral dos Dados",
|
| 258 |
+
"2. Balanceamento de Dados",
|
| 259 |
+
"3. Comparação de Modelos",
|
| 260 |
+
"4. Análise do Melhor Modelo",
|
| 261 |
+
"5. Aplicação Gerencial"
|
| 262 |
+
])
|
| 263 |
+
|
| 264 |
+
with tab1:
|
| 265 |
+
st.header("1. Visão Geral dos Dados")
|
| 266 |
+
st.subheader("Primeiras 5 Linhas do Dataset")
|
| 267 |
+
st.dataframe(df.head())
|
| 268 |
+
|
| 269 |
+
st.subheader("Estatísticas Descritivas")
|
| 270 |
+
st.dataframe(df.describe())
|
| 271 |
+
|
| 272 |
+
st.subheader("Informações sobre as Colunas")
|
| 273 |
+
buffer = io.StringIO()
|
| 274 |
+
df.info(buf=buffer)
|
| 275 |
+
s = buffer.getvalue()
|
| 276 |
+
st.text(s)
|
| 277 |
+
|
| 278 |
+
st.subheader("Distribuição da Variável Alvo ('Complain') Original")
|
| 279 |
+
fig, ax = plt.subplots(figsize=(6, 4))
|
| 280 |
+
sns.countplot(x=y, ax=ax)
|
| 281 |
+
ax.set_title("Distribuição Original da Variável 'Complain'")
|
| 282 |
+
ax.set_xlabel("Reclamou (0: Não, 1: Sim)")
|
| 283 |
+
ax.set_ylabel("Contagem")
|
| 284 |
+
st.pyplot(fig)
|
| 285 |
+
st.write(f"Distribuição da variável 'Complain' original: {Counter(y)}")
|
| 286 |
+
st.warning("Observe o desbalanceamento da classe 'Complain' (poucas reclamações).")
|
| 287 |
+
|
| 288 |
+
with tab2:
|
| 289 |
+
st.header("2. Balanceamento de Dados com SMOTE")
|
| 290 |
+
st.write(
|
| 291 |
+
"A seguir, demonstramos o efeito do balanceamento da variável alvo 'Complain' utilizando a técnica **SMOTE**.")
|
| 292 |
+
|
| 293 |
+
X_display = X.copy()
|
| 294 |
+
y_display = y.copy()
|
| 295 |
+
|
| 296 |
+
if balance_data:
|
| 297 |
+
st.subheader("Resultados do SMOTE")
|
| 298 |
+
smote = SMOTE(random_state=42)
|
| 299 |
+
try:
|
| 300 |
+
if len(np.unique(y_display)) < 2:
|
| 301 |
+
st.error("SMOTE não pode ser aplicado: A variável alvo contém apenas uma classe.")
|
| 302 |
+
X_res, y_res = X_display, y_display
|
| 303 |
+
else:
|
| 304 |
+
X_res, y_res = smote.fit_resample(X_display, y_display)
|
| 305 |
+
st.success("Dados balanceados com sucesso!")
|
| 306 |
+
st.write(f"'Complain' variable distribution after SMOTE balancing: {Counter(y_res)}")
|
| 307 |
+
|
| 308 |
+
fig, ax = plt.subplots(figsize=(6, 4))
|
| 309 |
+
sns.countplot(x=y_res, ax=ax)
|
| 310 |
+
ax.set_title("Distribuição da Variável 'Complain' Após SMOTE")
|
| 311 |
+
ax.set_xlabel("Reclamou (0: Não, 1: Sim)")
|
| 312 |
+
ax.set_ylabel("Contagem")
|
| 313 |
+
st.pyplot(fig)
|
| 314 |
+
except Exception as e:
|
| 315 |
+
st.error(
|
| 316 |
+
f"Erro ao aplicar SMOTE: {e}. Isso pode acontecer se houver poucas amostras na classe minoritária ou muitas features.")
|
| 317 |
+
X_res, y_res = X_display, y_display
|
| 318 |
+
else:
|
| 319 |
+
st.info("SMOTE desabilitado. O balanceamento não será aplicado.")
|
| 320 |
+
X_res, y_res = X_display, y_display
|
| 321 |
+
|
| 322 |
+
if X_res.empty or y_res.empty:
|
| 323 |
+
st.error("Erro: Os dados pós-balanceamento estão vazios. Verifique o dataset original e o pré-processamento.")
|
| 324 |
+
X_train, X_test, y_train, y_test = pd.DataFrame(), pd.DataFrame(), pd.Series(), pd.Series()
|
| 325 |
+
else:
|
| 326 |
+
st.subheader("Divisão dos Dados (Treino/Teste)")
|
| 327 |
+
test_size = st.slider("Tamanho do Conjunto de Teste", 0.1, 0.5, 0.3, 0.05)
|
| 328 |
+
if len(np.unique(y_res)) > 1:
|
| 329 |
+
X_train, X_test, y_train, y_test = train_test_split(X_res, y_res, test_size=test_size, random_state=42,
|
| 330 |
+
stratify=y_res)
|
| 331 |
+
else:
|
| 332 |
+
st.warning(
|
| 333 |
+
"Não foi possível usar `stratify` no `train_test_split` pois o alvo tem apenas uma classe após o processamento. Dividindo sem estratificação.")
|
| 334 |
+
X_train, X_test, y_train, y_test = train_test_split(X_res, y_res, test_size=test_size, random_state=42)
|
| 335 |
+
|
| 336 |
+
# --- Mensagens de depuração movidas para cá, se necessário ---
|
| 337 |
+
st.write("Shape X_train:", X_train.shape)
|
| 338 |
+
st.write("Shape X_test:", X_test.shape)
|
| 339 |
+
st.write("Shape y_train:", y_train.shape)
|
| 340 |
+
st.write("Shape y_test:", y_test.shape)
|
| 341 |
+
st.write("Shape do DataFrame (após pré-processamento):", df_processed.shape)
|
| 342 |
+
st.write("Tipos das colunas (após pré-processamento):", df_processed.dtypes)
|
| 343 |
+
st.write("Primeiras 5 linhas (após pré-processamento):", df_processed.head())
|
| 344 |
+
st.write("Classes em y_train:", np.unique(y_train))
|
| 345 |
+
# --- FIM NOVO ---
|
| 346 |
+
|
| 347 |
+
if X_train.empty or y_train.empty:
|
| 348 |
+
st.error("Os dados de treino estão vazios! Verifique o carregamento ou pré-processamento dos dados.")
|
| 349 |
+
st.stop()
|
| 350 |
+
if X_test.empty or y_test.empty:
|
| 351 |
+
st.error("Os dados de teste estão vazios! Verifique o carregamento ou pré-processamento dos dados.")
|
| 352 |
+
st.stop()
|
| 353 |
+
|
| 354 |
+
st.subheader("Escalonamento de Dados")
|
| 355 |
+
st.write(
|
| 356 |
+
"Para modelos sensíveis à escala (como KNN e SVM), os dados serão automaticamente escalonados (`StandardScaler`) antes do treinamento e da previsão.")
|
| 357 |
+
|
| 358 |
+
with tab3:
|
| 359 |
+
st.header("3. Comparação de Modelos Supervisionados")
|
| 360 |
+
st.write("Avalie o desempenho de diferentes grupos de modelos supervisionados utilizando métricas chave.")
|
| 361 |
+
|
| 362 |
+
if st.button("Treinar e Comparar Todos os Modelos"):
|
| 363 |
+
with st.spinner("Treinando e avaliando modelos..."):
|
| 364 |
+
all_results = train_and_evaluate_models(X_train, X_test, y_train, y_test, StandardScaler())
|
| 365 |
+
|
| 366 |
+
valid_results = {k: v for k, v in all_results.items() if v['Model'] is not None}
|
| 367 |
+
|
| 368 |
+
if not valid_results:
|
| 369 |
+
st.warning("Nenhum modelo pôde ser treinado com sucesso. Verifique seus dados e configurações.")
|
| 370 |
+
else:
|
| 371 |
+
st.subheader("Métricas de Desempenho dos Modelos")
|
| 372 |
+
metrics_df = pd.DataFrame({
|
| 373 |
+
"Modelo": list(valid_results.keys()),
|
| 374 |
+
"Accuracy": [res["Accuracy"] for res in valid_results.values()],
|
| 375 |
+
"Precision": [res["Precision"] for res in valid_results.values()],
|
| 376 |
+
"Recall": [res["Recall"] for res in valid_results.values()],
|
| 377 |
+
"F1-score": [res["F1-score"] for res in valid_results.values()],
|
| 378 |
+
"AUC": [res["AUC"] for res in valid_results.values()]
|
| 379 |
+
})
|
| 380 |
+
st.dataframe(metrics_df.set_index("Modelo").sort_values(by="AUC", ascending=False))
|
| 381 |
+
|
| 382 |
+
st.subheader("Curvas ROC de Todos os Modelos")
|
| 383 |
+
fig_roc_all, ax_roc_all = plt.subplots(figsize=(10, 8))
|
| 384 |
+
for name, metrics in valid_results.items():
|
| 385 |
+
ax_roc_all.plot(metrics['FPR'], metrics['TPR'], label=f'{name} (AUC = {metrics["AUC"]:.2f})')
|
| 386 |
+
ax_roc_all.plot([0, 1], [0, 1], 'k--', label='Aleatório (AUC = 0.50)')
|
| 387 |
+
ax_roc_all.set_xlabel('Taxa de Falsos Positivos (FPR)')
|
| 388 |
+
ax_roc_all.set_ylabel('Taxa de Verdadeiros Positivos (TPR)')
|
| 389 |
+
ax_roc_all.set_title('Curva ROC para Diferentes Modelos')
|
| 390 |
+
ax_roc_all.legend()
|
| 391 |
+
ax_roc_all.grid(True)
|
| 392 |
+
st.pyplot(fig_roc_all)
|
| 393 |
+
|
| 394 |
+
st.subheader("Discussão sobre a Escolha do Melhor Modelo")
|
| 395 |
+
st.markdown("""
|
| 396 |
+
Para problemas de previsão de reclamações, o **Recall** é frequentemente crucial, pois minimiza Falsos Negativos (clientes que reclamam mas não são previstos). No entanto, um bom **AUC** (Área sob a Curva ROC) indica a capacidade geral do modelo de distinguir entre as classes, e o **F1-score** oferece um equilíbrio entre Precisão e Recall.
|
| 397 |
+
""")
|
| 398 |
+
st.success(
|
| 399 |
+
f"**Recomendação:** O modelo com o maior **AUC** é geralmente um bom ponto de partida, pois indica a melhor capacidade discriminatória geral. Para este exemplo, o modelo principal para análise detalhada será o selecionado na sidebar: **{model_choice}**.")
|
| 400 |
+
|
| 401 |
+
with tab4:
|
| 402 |
+
st.header("4. Análise Detalhada do Modelo Selecionado")
|
| 403 |
+
st.write(f"Foco na análise detalhada do modelo: **{model_choice}**.")
|
| 404 |
+
|
| 405 |
+
if st.button(f"Analisar {model_choice}"):
|
| 406 |
+
with st.spinner(f"Analisando {model_choice}..."):
|
| 407 |
+
selected_model_results = train_and_evaluate_models(X_train, X_test, y_train, y_test, StandardScaler(),
|
| 408 |
+
model_selected=model_choice)
|
| 409 |
+
|
| 410 |
+
if model_choice not in selected_model_results or selected_model_results[model_choice]['Model'] is None:
|
| 411 |
+
st.error(f"Não foi possível analisar o modelo {model_choice}. Ele pode ter falhado no treinamento.")
|
| 412 |
+
else:
|
| 413 |
+
metrics = selected_model_results[model_choice]
|
| 414 |
+
|
| 415 |
+
st.subheader(f"Métricas de Desempenho para {model_choice}")
|
| 416 |
+
st.write(f"**Accuracy:** {metrics['Accuracy']:.4f}")
|
| 417 |
+
st.write(f"**Precision:** {metrics['Precision']:.4f}")
|
| 418 |
+
st.write(f"**Recall:** {metrics['Recall']:.4f}")
|
| 419 |
+
st.write(f"**F1-score:** {metrics['F1-score']:.4f}")
|
| 420 |
+
st.write(f"**AUC:** {metrics['AUC']:.4f}")
|
| 421 |
+
|
| 422 |
+
st.subheader(f"Matriz de Confusão para {model_choice}")
|
| 423 |
+
fig_cm, ax_cm = plt.subplots(figsize=(7, 6))
|
| 424 |
+
disp = ConfusionMatrixDisplay(confusion_matrix=metrics['Confusion Matrix'],
|
| 425 |
+
display_labels=['Não Reclamou (0)', 'Reclamou (1)'])
|
| 426 |
+
disp.plot(cmap=plt.cm.Blues, ax=ax_cm)
|
| 427 |
+
ax_cm.set_title(f'Matriz de Confusão para {model_choice}')
|
| 428 |
+
st.pyplot(fig_cm)
|
| 429 |
+
|
| 430 |
+
st.markdown("""
|
| 431 |
+
**Interpretação da Matriz de Confusão:**
|
| 432 |
+
- **Verdadeiros Negativos (TN):** Clientes que não reclamaram e foram previstos corretamente.
|
| 433 |
+
- **Falsos Positivos (FP):** Clientes que não reclamaram, mas foram erroneamente previstos como reclamantes (custo de intervenção desnecessária).
|
| 434 |
+
- **Falsos Negativos (FN):** Clientes que reclamaram, mas foram erroneamente previstos como não reclamantes (custo de perda de oportunidade de intervenção, insatisfação).
|
| 435 |
+
- **Verdadeiros Positivos (TP):** Clientes que reclamaram e foram previstos corretamente.
|
| 436 |
+
""")
|
| 437 |
+
|
| 438 |
+
st.subheader(f"Curva ROC para {model_choice}")
|
| 439 |
+
fig_roc_single, ax_roc_single = plt.subplots(figsize=(8, 6))
|
| 440 |
+
ax_roc_single.plot(metrics['FPR'], metrics['TPR'], color='darkorange', lw=2,
|
| 441 |
+
label=f'Curva ROC (AUC = {metrics["AUC"]:.2f})')
|
| 442 |
+
ax_roc_single.plot([0, 1], [0, 1], color='navy', lw=2, linestyle='--', label='Classificador Aleatório')
|
| 443 |
+
ax_roc_single.set_xlabel('Taxa de Falsos Positivos (FPR)')
|
| 444 |
+
ax_roc_single.set_ylabel('Taxa de Verdadeiros Positivos (TPR)')
|
| 445 |
+
ax_roc_single.set_title(f'Curva ROC para {model_choice}')
|
| 446 |
+
ax_roc_single.legend(loc='lower right')
|
| 447 |
+
ax_roc_single.grid(True)
|
| 448 |
+
st.pyplot(fig_roc_single)
|
| 449 |
+
st.write(
|
| 450 |
+
f"O **AUC** de {metrics['AUC']:.2f} indica a capacidade discriminatória do modelo: quanto mais próximo de 1, melhor o modelo distingue entre as classes.")
|
| 451 |
+
|
| 452 |
+
st.subheader("Sensibilidade aos Hiperparâmetros")
|
| 453 |
+
if model_choice == "K-Nearest Neighbors":
|
| 454 |
+
st.markdown("""
|
| 455 |
+
O KNN é altamente sensível ao `n_neighbors` (número de vizinhos). Pequenos valores podem causar overfitting, enquanto valores grandes podem levar a underfitting. A métrica de distância e a escala dos dados também são cruciais.
|
| 456 |
+
""")
|
| 457 |
+
elif model_choice == "Random Forest":
|
| 458 |
+
st.markdown("""
|
| 459 |
+
O Random Forest é impactado por `n_estimators` (número de árvores), `max_depth` (profundidade máxima) e `min_samples_leaf`. Mais árvores geralmente melhoram o desempenho, mas `max_depth` e `min_samples_leaf` controlam a complexidade e evitam o overfitting das árvores individuais.
|
| 460 |
+
""")
|
| 461 |
+
elif model_choice == "Support Vector Machine":
|
| 462 |
+
st.markdown("""
|
| 463 |
+
O SVM é sensível ao `C` (parâmetro de regularização) e `kernel` (função de kernel). `C` controla a penalidade por erros de classificação, e o `kernel` define a forma do limite de decisão (linear, RBF, etc.). A escala dos dados é fundamental para o SVM.
|
| 464 |
+
""")
|
| 465 |
+
elif model_choice == "XGBoosting" or model_choice == "LightGBM":
|
| 466 |
+
st.markdown("""
|
| 467 |
+
Modelos de Boosting como XGBoost e LightGBM são influenciados por `n_estimators` (número de estimadores), `learning_rate` (taxa de aprendizado) e `max_depth`. Uma `learning_rate` menor com mais estimadores pode melhorar o desempenho, mas requer mais tempo de treinamento. `Max_depth` controla a complexidade de cada árvore.
|
| 468 |
+
""")
|
| 469 |
+
else:
|
| 470 |
+
st.markdown(
|
| 471 |
+
"Este modelo também possui hiperparâmetros que podem ser ajustados para otimizar o desempenho (ex: `max_depth` para Decision Tree, `n_estimators` para AdaBoosting/Gradient Boosting).")
|
| 472 |
+
|
| 473 |
+
with tab5:
|
| 474 |
+
st.header("5. Tomada de Decisão e Aplicação Gerencial")
|
| 475 |
+
st.write("Análise dos fatores que mais influenciam a ocorrência de reclamações e recomendações práticas.")
|
| 476 |
+
|
| 477 |
+
if st.button("Gerar Análise Gerencial"):
|
| 478 |
+
with st.spinner("Gerando insights gerenciais..."):
|
| 479 |
+
selected_model_results = train_and_evaluate_models(X_train, X_test, y_train, y_test, StandardScaler(),
|
| 480 |
+
model_selected=model_choice)
|
| 481 |
+
|
| 482 |
+
if model_choice not in selected_model_results or selected_model_results[model_choice]['Model'] is None:
|
| 483 |
+
st.error(
|
| 484 |
+
f"Não foi possível gerar a análise gerencial para o modelo {model_choice}. Ele pode ter falhado no treinamento.")
|
| 485 |
+
else:
|
| 486 |
+
model_instance = selected_model_results[model_choice]["Model"]
|
| 487 |
+
|
| 488 |
+
st.subheader("Importância das Variáveis")
|
| 489 |
+
|
| 490 |
+
if hasattr(model_instance, 'feature_importances_'):
|
| 491 |
+
feature_importances = model_instance.feature_importances_
|
| 492 |
+
feature_names = X.columns.tolist()
|
| 493 |
+
importance_df = pd.DataFrame(
|
| 494 |
+
{'Variável': feature_names, 'Importância Relativa': feature_importances})
|
| 495 |
+
importance_df = importance_df.sort_values(by='Importância Relativa', ascending=False)
|
| 496 |
+
st.dataframe(importance_df.head(10).set_index('Variável'))
|
| 497 |
+
|
| 498 |
+
fig_imp, ax_imp = plt.subplots(figsize=(10, 6))
|
| 499 |
+
sns.barplot(x='Importância Relativa', y='Variável', data=importance_df.head(10), ax=ax_imp)
|
| 500 |
+
ax_imp.set_title('Top 10 Variáveis Mais Importantes')
|
| 501 |
+
st.pyplot(fig_imp)
|
| 502 |
+
|
| 503 |
+
elif hasattr(model_instance, 'coef_'):
|
| 504 |
+
st.info("Para modelos lineares, os coeficientes podem ser interpretados como importância.")
|
| 505 |
+
else:
|
| 506 |
+
st.info(
|
| 507 |
+
"Não foi possível extrair a importância das variáveis para este tipo de modelo de forma direta.")
|
| 508 |
+
|
| 509 |
+
st.subheader("Análise e Recomendações Gerenciais")
|
| 510 |
+
|
| 511 |
+
st.markdown("""
|
| 512 |
+
Com base nas variáveis mais importantes, podemos formular estratégias proativas:
|
| 513 |
+
|
| 514 |
+
**Exemplo de Cenário e Recomendação (Ajuste com base nos resultados reais das suas variáveis importantes):**
|
| 515 |
+
|
| 516 |
+
Se, por exemplo, 'MntWines' (gasto com vinho), 'NumWebVisitsMonth' (visitas ao site) e 'Dt_Customer' (dias desde a última compra) forem as variáveis mais importantes:
|
| 517 |
+
|
| 518 |
+
* **Clientes com alto gasto em vinho (`MntWines`)** que apresentam **alta frequência de visitas ao site (`NumWebVisitsMonth`) mas baixo engajamento recente (`Dt_Customer` elevado)** podem estar enfrentando dificuldades para encontrar produtos, informações ou ter problemas não resolvidos.
|
| 519 |
+
|
| 520 |
+
**Recomendação Gerencial:**
|
| 521 |
+
|
| 522 |
+
Priorize esses clientes com **ações proativas de atendimento e retenção**. Por exemplo:
|
| 523 |
+
1. **Suporte Proativo:** Monitore clientes com alto `NumWebVisitsMonth` que não resultam em compra ou que têm histórico de altos gastos e ofereça ajuda via chat ou contato telefônico personalizado.
|
| 524 |
+
2. **Campanhas de Reengajamento:** Crie campanhas segmentadas para clientes com `Dt_Customer` elevado, oferecendo descontos em seus produtos preferidos (ex: vinhos) ou convidando-os a fornecer feedback sobre a experiência recente.
|
| 525 |
+
3. **Melhoria na Experiência Online:** Analise as páginas mais visitadas por esses clientes com `NumWebVisitsMonth` alto para identificar gargalos ou informações ausentes que possam estar gerando frustração.
|
| 526 |
|
| 527 |
+
Ao antecipar e resolver proativamente as insatisfações, a empresa pode **melhorar a experiência do consumidor, reduzir as taxas de reclamação e aumentar a lealdade do cliente.**
|
| 528 |
+
""")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|