File size: 13,655 Bytes
9f22e36
19f6a77
5189ebb
 
 
 
 
 
 
 
 
 
 
 
 
 
19f6a77
5189ebb
 
6a8a483
19f6a77
 
5189ebb
 
 
 
19f6a77
5189ebb
 
 
6a8a483
 
 
 
 
5189ebb
 
 
19d987f
 
 
5189ebb
6a8a483
5189ebb
8804675
5189ebb
6a8a483
 
 
 
 
 
 
 
 
 
19d987f
 
6a8a483
 
 
 
 
 
 
 
8804675
6a8a483
 
 
 
 
 
 
 
 
 
 
8804675
6a8a483
 
 
 
 
 
 
 
 
 
 
19d987f
6a8a483
19d987f
 
6a8a483
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8804675
6a8a483
 
 
5189ebb
 
 
 
6a8a483
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19d987f
6a8a483
 
 
 
 
 
 
 
 
 
 
 
 
 
19d987f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6a8a483
19d987f
 
 
8804675
5189ebb
 
 
 
 
 
 
 
 
6a8a483
 
 
 
 
 
 
5189ebb
 
 
6a8a483
 
 
 
 
 
 
 
 
 
5189ebb
 
 
 
 
 
 
8804675
 
5189ebb
8804675
5189ebb
6a8a483
5189ebb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6a8a483
 
 
 
5189ebb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6a8a483
 
 
5189ebb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6a8a483
 
 
5189ebb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6a8a483
5189ebb
6a8a483
 
 
 
5189ebb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6a8a483
 
 
5189ebb
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
#!/usr/bin/env python
# coding: utf-8
"""
Streamlit – Churn (Logistic Regression) for Hugging Face Spaces
---------------------------------------------------------------
- Loads "Dados/Churn_Modelling.csv"
- Lets the user choose features (numeric / categorical)
- Trains Logistic Regression
- Shows coefficients, odds ratios, and quick interpretations
- Provides an interactive control panel to simulate a customer's probability of churn

Obs.: Esta versão atende ao item (a) da Tarefa 5: modelagem com Regressão Logística
e interpretação dos coeficientes/odds ratio.
"""

import os
import numpy as np
import pandas as pd
import streamlit as st
from pathlib import Path

from sklearn.model_selection import train_test_split
from sklearn.preprocessing import OneHotEncoder, StandardScaler
from sklearn.compose import ColumnTransformer
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import Pipeline

# -----------------------------
# Page config
# -----------------------------
st.set_page_config(
    page_title="Churn – Regressão Logística (PPCA/UnB)",
    layout="wide",
    initial_sidebar_state="expanded"
)
st.title("Churn – Regressão Logística (PPCA/UnB)")
st.caption("Item (a) – Modelagem da Retenção de Clientes e interpretação de coeficientes/odds ratio.")

# Botão para limpar cache do loader e revarrer o repositório
st.sidebar.button("🔄 Reescanear CSV", on_click=lambda: st.cache_data.clear())

# -----------------------------
# Data loader (cache) – robusto para HF Spaces
# -----------------------------
@st.cache_data
def load_data():
    from pathlib import Path
    import pandas as _pd

    # Candidate roots a varrer
    roots = []
    try:
        roots.append(Path(__file__).parent)
    except Exception:
        pass
    roots += [Path.cwd(), Path("."), Path("/home/user/app")]
    # ADIÇÃO: raízes comuns em Spaces
    roots += [Path("/app"), Path("/app/src")]

    # Caminhos explícitos rápidos
    fast_candidates = [
        Path("Dados/Churn_Modelling.csv"),
        Path("./Dados/Churn_Modelling.csv"),
        Path("/mnt/data/Dados/Churn_Modelling.csv"),
        Path("Churn_Modelling.csv"),
        Path("./Churn_Modelling.csv"),
    ]

    # Função simples de "sniff" de delimitador
    def _detect_sep(sample_lines):
        if any(";" in line for line in sample_lines):
            return ";"
        if any("\t" in line for line in sample_lines):
            return "\t"
        return ","

    # 1) Tentar candidatos explícitos
    for pth in fast_candidates:
        try:
            if pth.exists():
                text = pth.read_text(encoding="utf-8", errors="ignore")
                sample = text.splitlines()[:5]
                sep = _detect_sep(sample)
                df_ = _pd.read_csv(pth, sep=sep)
                return df_, str(pth)
        except Exception:
            pass

    # 2) Busca recursiva case-insensitive pelo nome
    targets = []
    seen_dirs = set()
    for root in roots:
        if root.exists() and str(root) not in seen_dirs:
            seen_dirs.add(str(root))
            for p in root.rglob("*"):
                try:
                    if p.is_file() and p.name.lower() == "churn_modelling.csv":
                        targets.append(p)
                except Exception:
                    continue

    # Preferir caminho dentro de 'Dados/'
    targets.sort(
        key=lambda p: (
            0 if ("Dados" in str(p.parent) or "dados" in str(p.parent)) else 1,
            len(str(p))
        )
    )

    for pth in targets:
        try:
            text = pth.read_text(encoding="utf-8", errors="ignore")
            sample = text.splitlines()[:5]
            sep = _detect_sep(sample)
            df_ = _pd.read_csv(pth, sep=sep)
            return df_, str(pth)
        except Exception:
            continue

    # Não achou
    return _pd.DataFrame(), "caminhos não encontrados"

df, data_info = load_data()

if df.empty:
    st.error("Não foi possível carregar **Churn_Modelling.csv** nos caminhos padrão.")
    with st.expander("Diagnóstico rápido", expanded=True):
        st.write("**Caminho de trabalho atual (cwd):**", os.getcwd())
        try:
            st.write("**Arquivos na raiz:**", os.listdir("."))
        except Exception as e:
            st.write("Falha ao listar raiz:", e)
        dados_dir = Path("Dados")
        if dados_dir.exists():
            try:
                st.write("**Arquivos em `Dados/`:**", os.listdir(dados_dir))
            except Exception as e:
                st.write("Falha ao listar `Dados/`:", e)
        st.caption(
            "Se `Dados/Churn_Modelling.csv` não aparecer acima, suba o CSV para o repositório do Space "
            "com exatamente esse caminho e nome (case-sensitive)."
        )

    # Alternativa 1: upload local (pode falhar em Spaces públicos com 403)
    st.info("**Alternativa:** faça upload do CSV abaixo para testar agora (não persiste no repositório).")
    up = st.file_uploader("Envie Churn_Modelling.csv", type=["csv"])
    if up is not None:
        # Tentar separar por vírgula, depois ponto-e-vírgula e tab, se necessário
        try:
            df = pd.read_csv(up)
        except Exception:
            up.seek(0)
            try:
                df = pd.read_csv(up, sep=";")
            except Exception:
                up.seek(0)
                df = pd.read_csv(up, sep="\t")
        data_info = "via upload do usuário"
    # Alternativa 2: URL direta
    st.info("**Alternativa 2 (URL):** informe um link direto (RAW) para o CSV (ex.: GitHub Raw ou Hugging Face Datasets) e clique em **Carregar via URL**.")
    url_csv = st.text_input("URL direta do CSV (https://...)")
    load_url = st.button("Carregar via URL")
    if load_url and url_csv:
        try:
            df = pd.read_csv(url_csv)
            data_info = f"via URL: {url_csv}"
        except Exception:
            try:
                df = pd.read_csv(url_csv, sep=";")
                data_info = f"via URL (sep=';'): {url_csv}"
            except Exception:
                try:
                    df = pd.read_csv(url_csv, sep="\t")
                    data_info = f"via URL (sep='\\t'): {url_csv}"
                except Exception as e2:
                    st.error(f"Falha ao carregar via URL: {e2}")
                    st.stop()
    else:
        # Se nem upload nem URL foram usados e df continua vazio, parar aqui
        if df.empty:
            st.stop()

st.success(f"Dataset carregado de: `{data_info}`")

# Normalizar nomes de colunas
df.columns = [c.strip() for c in df.columns]

# -----------------------------
# Target and candidate features (dataset padrão do Kaggle)
# -----------------------------
TARGET = "Exited"  # 1 = saiu, 0 = permaneceu
candidates_num = [
    c for c in [
        "CreditScore", "Age", "Tenure", "Balance", "NumOfProducts",
        "HasCrCard", "IsActiveMember", "EstimatedSalary"
    ] if c in df.columns
]
candidates_cat = [c for c in ["Geography", "Gender"] if c in df.columns]

# Sidebar: feature selection & model hyperparams
st.sidebar.header("Configuração do Modelo")
use_num = st.sidebar.multiselect(
    "Variáveis numéricas",
    options=candidates_num,
    default=[c for c in ["Age", "Balance", "NumOfProducts", "IsActiveMember"] if c in candidates_num]
)
use_cat = st.sidebar.multiselect(
    "Variáveis categóricas",
    options=candidates_cat,
    default=[c for c in ["Geography", "Gender"] if c in candidates_cat]
)

test_size = st.sidebar.slider("Proporção de teste", 0.1, 0.4, 0.2, 0.05)
reg_strength = st.sidebar.slider("Força de regularização (C)", 0.05, 5.0, 1.0, 0.05)
class_balanced = st.sidebar.checkbox("Class weight = 'balanced' (útil se desbalanceado)", value=True)
max_iter = st.sidebar.slider("Max iter", 200, 2000, 1000, 100)

train_btn = st.sidebar.button("Treinar modelo")

# -----------------------------
# Quick EDA block (compact)
# -----------------------------
st.subheader("Visão rápida do conjunto de dados")
col_a, col_b = st.columns([2, 1])
with col_a:
    st.dataframe(df.sample(min(10, len(df))), use_container_width=True)
with col_b:
    if TARGET in df.columns:
        n1 = int(df[TARGET].sum())
        n0 = int((1 - df[TARGET]).sum())
        st.metric("Clientes que saíram (1)", n1)
        st.metric("Clientes que ficaram (0)", n0)

# -----------------------------
# Training
# -----------------------------
def build_pipeline(num_cols, cat_cols, C=1.0, class_weight=None, max_iter=1000):
    preprocess = ColumnTransformer(
        transformers=[
            ("num", StandardScaler(with_mean=True, with_std=True), num_cols),
            ("cat", OneHotEncoder(drop="first", handle_unknown="ignore"), cat_cols),
        ],
        remainder="drop"
    )
    lr = LogisticRegression(
        C=C, penalty="l2", solver="lbfgs",
        max_iter=max_iter, class_weight=class_weight, n_jobs=None
    )
    pipe = Pipeline(steps=[("prep", preprocess), ("clf", lr)])
    return pipe

def get_feature_names(preprocess, num_cols, cat_cols):
    names = []
    if num_cols:
        names.extend(num_cols)
    if cat_cols:
        ohe = preprocess.named_transformers_["cat"]
        cat_names = ohe.get_feature_names_out(cat_cols).tolist()
        names.extend(cat_names)
    return names

if train_btn:
    if not use_num and not use_cat:
        st.warning("Selecione pelo menos **uma** variável explicativa (numérica ou categórica).")
        st.stop()

    cols_needed = [TARGET] + use_num + use_cat
    df_model = df[cols_needed].dropna().copy()

    X = df_model[use_num + use_cat]
    y = df_model[TARGET]

    cw = "balanced" if class_balanced else None
    pipe = build_pipeline(use_num, use_cat, C=reg_strength, class_weight=cw, max_iter=max_iter)

    X_train, X_test, y_train, y_test = train_test_split(
        X, y, test_size=test_size, random_state=42, stratify=y
    )
    pipe.fit(X_train, y_train)

    # -------------------------
    # Coefficients & Odds Ratios
    # -------------------------
    lr = pipe.named_steps["clf"]
    preprocess = pipe.named_steps["prep"]

    feat_names = get_feature_names(preprocess, use_num, use_cat)
    coefs = lr.coef_.ravel()
    odds = np.exp(coefs)

    coef_table = pd.DataFrame({
        "Variável": feat_names,
        "Coeficiente (β)": coefs,
        "Odds Ratio (e^β)": odds
    }).sort_values(by="Odds Ratio (e^β)", ascending=False)

    st.subheader("Coeficientes e Odds Ratio")
    st.write(
        "Interpretação: mantendo as demais variáveis constantes, um aumento de uma unidade na variável "
        "(ou mudança para a categoria indicada) multiplica as *odds* de churn por `e^β`. "
        "Se `e^β > 1`, o risco de churn aumenta; se `< 1`, diminui."
    )
    st.dataframe(coef_table, use_container_width=True, height=380)

    # Acurácia simples (para referência rápida no item a)
    acc = pipe.score(X_test, y_test)
    st.info(f"**Acurácia (holdout)**: {acc:.3f}  |  Amostras de treino: {len(X_train)}  |  Amostras de teste: {len(X_test)}")

    # -------------------------
    # Interactive prediction
    # -------------------------
    st.subheader("Simulação: probabilidade de churn para um perfil de cliente")
    with st.expander("Abrir painel de controle do cliente", expanded=True):
        inputs = {}
        cols = st.columns(2)

        # Numeric controls
        for i, col in enumerate(use_num):
            with cols[i % 2]:
                vmin = float(np.nanmin(df[col])) if np.isfinite(df[col]).all() else 0.0
                vmax = float(np.nanmax(df[col])) if np.isfinite(df[col]).all() else 1.0
                vmean = float(np.nanmean(df[col])) if np.isfinite(df[col]).all() else (vmin + vmax) / 2.0
                step = (vmax - vmin) / 100.0 if vmax > vmin else 1.0
                inputs[col] = st.number_input(
                    f"{col}", value=round(vmean, 2), step=step,
                    min_value=vmin, max_value=vmax, format="%.2f"
                )

        # Categorical controls
        for i, col in enumerate(use_cat):
            with cols[i % 2]:
                opts = sorted([o for o in df[col].dropna().unique().tolist()])
                default_idx = 0 if opts else None
                inputs[col] = st.selectbox(f"{col}", options=opts, index=default_idx if default_idx is not None else 0)

        # Compose a single-row DataFrame
        if inputs:
            row = pd.DataFrame([inputs])
            proba = float(pipe.predict_proba(row)[0, 1])
            st.metric("Probabilidade de churn (sair do banco)", f"{proba:.1%}")
            st.caption("Dica: ajuste os controles e observe como a probabilidade muda.")

    # -------------------------
    # Textual help / interpretation
    # -------------------------
    st.subheader("Como interpretar os coeficientes")
    st.markdown("""
- **Sinal de β**: positivo ⇒ aumenta as *odds* de churn; negativo ⇒ reduz.
- **Magnitude**: valores maiores em módulo indicam maior impacto, dado o mesmo escalonamento.
- **Odds Ratio `e^β`**: fator multiplicativo nas *odds*. Ex.: `e^β = 1,30` ⇒ as *odds* aumentam **30%**.
- Em variáveis **categóricas**, o β refere-se à **categoria de referência vs. a categoria exibida**
  (depois do one-hot com `drop='first'`).
    """)

else:
    st.info("Selecione as variáveis na barra lateral e clique em **Treinar modelo** para começar.")

# -----------------------------
# Footer
# -----------------------------
st.markdown("---")
st.caption("PPCA/UnB • Tarefa 5 – Item (a) • Regressão Logística + Odds Ratio • Feito para rodar em Hugging Face Spaces (Streamlit).")