ricardoadriano commited on
Commit
57e0985
·
1 Parent(s): 10b0012
Dados/marketing_campaign.csv ADDED
The diff for this file is too large to render. See raw diff
 
requirements.txt CHANGED
@@ -1,3 +1,6 @@
1
- altair
2
- pandas
3
- streamlit
 
 
 
 
1
+ streamlit==1.39.0
2
+ pandas==2.2.3
3
+ numpy==1.26.4
4
+ scikit-learn==1.5.2
5
+ altair==5.4.1
6
+ statsmodels==0.14.4
src/streamlit_app.py CHANGED
@@ -1,40 +1,382 @@
1
- import altair as alt
 
 
 
 
 
 
2
  import numpy as np
3
  import pandas as pd
4
  import streamlit as st
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
 
6
- """
7
- # Welcome to Streamlit!
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
+ # -*- coding: utf-8 -*-
2
+ # ------------------------------------------------------------
3
+ # App: Análise de Reclamações de Consumidores
4
+ # Dataset esperado: Dados/marketing_campaign.csv (Kaggle - Customer Personality Analysis)
5
+ # Objetivo: prever probabilidade de "Complain" e explicar fatores (itens c e e da Tarefa)
6
+ # ------------------------------------------------------------
7
+ import os
8
  import numpy as np
9
  import pandas as pd
10
  import streamlit as st
11
+ import altair as alt
12
+
13
+ from typing import List, Tuple
14
+
15
+ # Sci-kit / stats
16
+ from sklearn.model_selection import train_test_split
17
+ from sklearn.compose import ColumnTransformer
18
+ from sklearn.preprocessing import OneHotEncoder, StandardScaler
19
+ from sklearn.pipeline import Pipeline
20
+ from sklearn.impute import SimpleImputer
21
+ from sklearn.metrics import roc_auc_score, accuracy_score, confusion_matrix, RocCurveDisplay
22
+ from sklearn.inspection import permutation_importance
23
+
24
+ from sklearn.linear_model import LogisticRegression
25
+ from sklearn.ensemble import RandomForestClassifier
26
+
27
+ import statsmodels.api as sm
28
+
29
+ # -----------------------------
30
+ # Configurações gerais
31
+ # -----------------------------
32
+ st.set_page_config(
33
+ page_title="Reclamações de Consumidores — PPCA/UnB",
34
+ layout="wide",
35
+ )
36
+
37
+ st.title("📈 Reclamações de Consumidores — Predição & Explicação")
38
+ st.caption("Com base no conjunto **Customer Personality Analysis** (marketing_campaign.csv)")
39
+
40
+ DATA_PATH = "Dados/marketing_campaign.csv"
41
+
42
+ # -----------------------------
43
+ # Utilitários
44
+ # -----------------------------
45
+ @st.cache_data(show_spinner=False)
46
+ def load_data(path: str) -> pd.DataFrame:
47
+ df = pd.read_csv(path, sep=",", encoding="utf-8")
48
+ # Alguns CSVs deste dataset vêm com separador ';'. Se falhar, tenta novamente:
49
+ if df.shape[1] == 1:
50
+ df = pd.read_csv(path, sep=";", encoding="utf-8")
51
+ return df
52
+
53
+ def infer_target_column(df: pd.DataFrame) -> str:
54
+ # No dataset da Kaggle, a variável é "Complain" (0/1).
55
+ # Se não existir, tenta nomes comuns.
56
+ candidates = ["Complain", "complain", "Complaint", "has_complaint", "has_complain"]
57
+ for c in candidates:
58
+ if c in df.columns:
59
+ return c
60
+ # fallback: se não achou, cria guiagem
61
+ return None
62
+
63
+ def split_features(df: pd.DataFrame, y_col: str) -> Tuple[List[str], List[str]]:
64
+ cat_cols = [c for c in df.columns if (df[c].dtype == "object" or df[c].dtype.name == "category") and c != y_col]
65
+ num_cols = [c for c in df.columns if (np.issubdtype(df[c].dtype, np.number)) and c != y_col]
66
+ return num_cols, cat_cols
67
+
68
+ def build_preprocessor(num_cols: List[str], cat_cols: List[str]) -> ColumnTransformer:
69
+ num_pipe = Pipeline([
70
+ ("imputer", SimpleImputer(strategy="median")),
71
+ ("scaler", StandardScaler())
72
+ ])
73
+ cat_pipe = Pipeline([
74
+ ("imputer", SimpleImputer(strategy="most_frequent")),
75
+ ("onehot", OneHotEncoder(handle_unknown="ignore", sparse_output=False))
76
+ ])
77
+ pre = ColumnTransformer([
78
+ ("num", num_pipe, num_cols),
79
+ ("cat", cat_pipe, cat_cols)
80
+ ])
81
+ return pre
82
+
83
+ def get_model(name: str):
84
+ if name == "Regressão Logística":
85
+ return LogisticRegression(max_iter=200, n_jobs=None) # n_jobs só no liblinear/saga; usamos solver padrão (lbfgs)
86
+ elif name == "Random Forest":
87
+ return RandomForestClassifier(
88
+ n_estimators=300,
89
+ max_depth=None,
90
+ random_state=42,
91
+ n_jobs=-1
92
+ )
93
+ else:
94
+ raise ValueError("Modelo desconhecido.")
95
+
96
+ def coefficient_table_for_logit(statsmodels_result, feature_names):
97
+ # Retorna DataFrame com OR e IC 95%
98
+ params = statsmodels_result.params
99
+ conf = statsmodels_result.conf_int(alpha=0.05)
100
+ df_coef = pd.DataFrame({
101
+ "feature": ["Intercept"] + feature_names,
102
+ "coef": params.values
103
+ })
104
+ conf = pd.DataFrame(conf.values, columns=["ci_low", "ci_high"])
105
+ df_coef["ci_low"] = conf["ci_low"].values
106
+ df_coef["ci_high"] = conf["ci_high"].values
107
+
108
+ # Odds ratios
109
+ df_coef["odds_ratio"] = np.exp(df_coef["coef"])
110
+ df_coef["or_ci_low"] = np.exp(df_coef["ci_low"])
111
+ df_coef["or_ci_high"] = np.exp(df_coef["ci_high"])
112
+ return df_coef
113
+
114
+ def make_recommendations(imp_df: pd.DataFrame, top_k: int = 5) -> List[str]:
115
+ """
116
+ Gera recomendações de alto nível com base nas variáveis mais importantes.
117
+ imp_df precisa ter colunas: feature, importance, sign (para regressão logística; senão assume neutro).
118
+ """
119
+ recs = []
120
+ # Pega top_k
121
+ core = imp_df.sort_values("importance", ascending=False).head(top_k)
122
+ for _, row in core.iterrows():
123
+ feat = row["feature"]
124
+ sign = row.get("sign", 0)
125
+ if sign > 0:
126
+ recs.append(
127
+ f"🔧 **Reduzir a exposição associada a `{feat}`**, pois aumento nessa variável eleva a probabilidade de reclamação. "
128
+ f"Considere políticas específicas (p.ex., comunicação proativa, revisão de políticas de entrega/atendimento, "
129
+ f"ou benefícios segmentados para o grupo impactado por `{feat}`)."
130
+ )
131
+ elif sign < 0:
132
+ recs.append(
133
+ f"✅ **Ampliar ações relacionadas a `{feat}`**, já que maior valor nessa variável tende a reduzir reclamações. "
134
+ f"Ex.: expandir programas de fidelidade ou incentivos que reforcem o comportamento ligado a `{feat}`."
135
+ )
136
+ else:
137
+ recs.append(
138
+ f"📌 **Monitorar `{feat}`** de perto: é importante, ainda que a direção do efeito varie entre segmentos. "
139
+ f"Teste intervenções com experimentos A/B e avalie impacto nas métricas de reclamação."
140
+ )
141
+ # Recomendações genéricas de processo:
142
+ recs.append("🧪 **Implantar testes A/B** para validar intervenções nas variáveis-chave e medir impacto em taxa de reclamação.")
143
+ recs.append("📞 **Aprimorar o 1º contato (FCR)**: reduzir transferência/recontato; scripts e treinamentos focados nas causas top-1/2.")
144
+ recs.append("🔁 **Feedback loop**: alimentar o time de Produto/Qualidade com causas de reclamação mais preditivas para correções upstream.")
145
+ return recs
146
+
147
+ # -----------------------------
148
+ # Carregamento
149
+ # -----------------------------
150
+ with st.sidebar:
151
+ st.header("📂 Dados")
152
+ st.write("Esperado: `Dados/marketing_campaign.csv`")
153
+ if not os.path.exists(DATA_PATH):
154
+ st.error(f"Arquivo não encontrado em `{DATA_PATH}`. Suba o CSV na pasta `Dados/` do Space.")
155
+ else:
156
+ st.success("Arquivo encontrado ✅")
157
+
158
+ try:
159
+ df_raw = load_data(DATA_PATH)
160
+ except Exception as e:
161
+ st.stop()
162
+
163
+ target_col = infer_target_column(df_raw)
164
+ if target_col is None:
165
+ st.error("Não encontrei a coluna alvo (ex.: `Complain`). Confirme o nome no CSV.")
166
+ st.dataframe(df_raw.head())
167
+ st.stop()
168
+
169
+ # -----------------------------
170
+ # Sidebar — Configuração
171
+ # -----------------------------
172
+ with st.sidebar:
173
+ st.header("⚙️ Configuração do Modelo")
174
+ st.caption("**Item (c)** — Definição & Seleção de Modelos")
175
+ model_name = st.selectbox("Modelo preditivo", ["Regressão Logística", "Random Forest"], index=0)
176
+
177
+ # Seleção de variáveis explicativas
178
+ st.subheader("Variáveis explicativas")
179
+ num_cols, cat_cols = split_features(df_raw, target_col)
180
+ all_feats = num_cols + cat_cols
181
+ default_feats = [c for c in all_feats if c != target_col]
182
+ selected_feats = st.multiselect(
183
+ "Selecione as variáveis de entrada",
184
+ options=default_feats,
185
+ default=default_feats[: min(12, len(default_feats))]
186
+ )
187
+
188
+ test_size = st.slider("Proporção de teste", 0.1, 0.4, 0.2, 0.05)
189
+ random_state = st.number_input("Random seed", value=42, step=1)
190
+
191
+ st.markdown("### 🔍 Visão geral dos dados")
192
+ st.write(f"Linhas: **{df_raw.shape[0]}**, Colunas: **{df_raw.shape[1]}**")
193
+ st.dataframe(df_raw[[c for c in [target_col] + selected_feats if c in df_raw.columns]].head(10))
194
+
195
+ # -----------------------------
196
+ # Preparação
197
+ # -----------------------------
198
+ df = df_raw.dropna(subset=[target_col]).copy()
199
+ y = df[target_col].astype(int)
200
+ X = df[selected_feats].copy()
201
+
202
+ # Tipos
203
+ sel_num = [c for c in selected_feats if c in X.columns and np.issubdtype(X[c].dtype, np.number)]
204
+ sel_cat = [c for c in selected_feats if c in X.columns and (X[c].dtype == "object" or X[c].dtype.name == "category")]
205
+
206
+ pre = build_preprocessor(sel_num, sel_cat)
207
+ model = get_model(model_name)
208
+
209
+ pipe = Pipeline([
210
+ ("pre", pre),
211
+ ("clf", model)
212
+ ])
213
+
214
+ X_train, X_test, y_train, y_test = train_test_split(
215
+ X, y, test_size=test_size, random_state=random_state, stratify=y
216
+ )
217
+
218
+ # -----------------------------
219
+ # Treinamento
220
+ # -----------------------------
221
+ with st.spinner("Treinando modelo..."):
222
+ pipe.fit(X_train, y_train)
223
+
224
+ # Predições e métricas
225
+ proba_test = pipe.predict_proba(X_test)[:, 1]
226
+ pred_test = (proba_test >= 0.5).astype(int)
227
+ auc = roc_auc_score(y_test, proba_test)
228
+ acc = accuracy_score(y_test, pred_test)
229
+ cm = confusion_matrix(y_test, pred_test)
230
+
231
+ met1, met2 = st.columns(2)
232
+ with met1:
233
+ st.metric("AUC (ROC)", f"{auc:.3f}")
234
+ with met2:
235
+ st.metric("Acurácia", f"{acc:.3f}")
236
+
237
+ st.markdown("#### Matriz de confusão")
238
+ cm_df = pd.DataFrame(cm, index=["Real 0", "Real 1"], columns=["Pred 0", "Pred 1"])
239
+ st.dataframe(cm_df)
240
+
241
+ # Curva ROC (usando altair simples)
242
+ roc_points = []
243
+ fpr_list = np.linspace(0, 1, 101)
244
+ # Calcular TPR para limiares aproximados
245
+ from sklearn.metrics import roc_curve
246
+ fpr, tpr, thr = roc_curve(y_test, proba_test)
247
+ roc_data = pd.DataFrame({"fpr": fpr, "tpr": tpr})
248
+ roc_chart = alt.Chart(roc_data).mark_line().encode(x="fpr:Q", y="tpr:Q").properties(
249
+ height=250, width=380
250
+ )
251
+ diag = alt.Chart(pd.DataFrame({"x":[0,1],"y":[0,1]})).mark_line(strokeDash=[4,4]).encode(x="x", y="y")
252
+ st.altair_chart(roc_chart + diag, use_container_width=True)
253
+
254
+ # -----------------------------
255
+ # Importância das variáveis
256
+ # -----------------------------
257
+ st.markdown("### 🌟 Importância das variáveis")
258
+ with st.spinner("Calculando importância (permutation importance)..."):
259
+ perm = permutation_importance(pipe, X_test, y_test, n_repeats=10, random_state=42, scoring="roc_auc")
260
+ # Nomear features após o preprocessamento:
261
+ # Recupera nomes one-hot para categoricas
262
+ ohe = pipe.named_steps["pre"].named_transformers_.get("cat")
263
+ ohe_feat_names = []
264
+ if ohe is not None and hasattr(ohe, "named_steps"):
265
+ onehot = ohe.named_steps["onehot"]
266
+ if hasattr(onehot, "get_feature_names_out"):
267
+ ohe_feat_names = list(onehot.get_feature_names_out(sel_cat))
268
+ # Nomes finais
269
+ feat_names = sel_num + ohe_feat_names
270
+ imp_df = pd.DataFrame({
271
+ "feature": feat_names,
272
+ "importance": perm.importances_mean[:len(feat_names)]
273
+ }).sort_values("importance", ascending=False)
274
+
275
+ # Para regressão logística, calcular sinal aproximado por coeficientes
276
+ sign_map = {}
277
+ if model_name == "Regressão Logística":
278
+ # Reconstruir coeficientes no espaço expandido:
279
+ # Ajusta novamente em X_train pretransformado para extrair coef
280
+ X_train_proc = pipe.named_steps["pre"].fit_transform(X_train)
281
+ clf = LogisticRegression(max_iter=200)
282
+ clf.fit(X_train_proc, y_train)
283
+ coefs = clf.coef_.ravel()
284
+ # Alinha tamanho; pode haver diferença por features descartadas
285
+ k = min(len(coefs), len(feat_names))
286
+ for i in range(k):
287
+ sign_map[feat_names[i]] = np.sign(coefs[i])
288
+
289
+ imp_df["sign"] = imp_df["feature"].map(lambda f: sign_map.get(f, 0))
290
+ st.dataframe(imp_df.head(15))
291
+
292
+ # Chart
293
+ bar = alt.Chart(imp_df.head(20)).mark_bar().encode(
294
+ x=alt.X("importance:Q", title="Perm. importance (AUC)"),
295
+ y=alt.Y("feature:N", sort='-x', title="Feature"),
296
+ color=alt.value("#3165d4")
297
+ ).properties(height=450)
298
+ st.altair_chart(bar, use_container_width=True)
299
+
300
+ # -----------------------------
301
+ # Predição interativa
302
+ # -----------------------------
303
+ st.markdown("## 🔮 Predição interativa (probabilidade de reclamação)")
304
+ st.caption("Ajuste os valores no painel e veja a probabilidade prevista pelo modelo.")
305
+
306
+ # Constrói um dicionário de entradas
307
+ with st.form("pred_form"):
308
+ cols = st.columns(3)
309
+ inputs = {}
310
+ for idx, col in enumerate(selected_feats):
311
+ col_container = cols[idx % 3]
312
+ with col_container:
313
+ if col in sel_num:
314
+ # Usa faixa baseada nos quantis do treino
315
+ q1, q5, q95, q99 = X_train[col].quantile([0.01, 0.05, 0.95, 0.99])
316
+ val = st.number_input(
317
+ f"{col}",
318
+ value=float(np.nan_to_num(X_train[col].median(), nan=0.0)),
319
+ help=f"Faixa típica ~ {q5:.2f}–{q95:.2f} (1–99%: {q1:.2f}–{q99:.2f})"
320
+ )
321
+ inputs[col] = val
322
+ else:
323
+ opts = sorted([str(x) for x in X_train[col].dropna().unique().tolist()])[:30]
324
+ default = opts[0] if opts else ""
325
+ val = st.selectbox(f"{col}", options=opts if opts else [""], index=0 if opts else 0)
326
+ inputs[col] = val
327
+ submitted = st.form_submit_button("Calcular probabilidade")
328
+
329
+ if submitted:
330
+ x_new = pd.DataFrame([inputs])
331
+ prob = pipe.predict_proba(x_new)[0, 1]
332
+ st.success(f"Probabilidade de registrar reclamação (Complain=1): **{prob:.2%}**")
333
+
334
+ # -----------------------------
335
+ # Inferência estatística (Logística)
336
+ # -----------------------------
337
+ st.markdown("## 📚 Inferência estatística (para mitigação)")
338
+ st.caption("Quando o modelo selecionado é Regressão Logística, mostramos *odds ratios* com IC 95% (explicabilidade estatística).")
339
+
340
+ if model_name == "Regressão Logística":
341
+ try:
342
+ # Reconstruir design matrix com OHE + padronização (para statsmodels, manter padronização ajuda numérica)
343
+ pre_fit = pipe.named_steps["pre"].fit(X_train, y_train)
344
+ X_train_proc = pre_fit.transform(X_train)
345
+ feature_names = sel_num + (
346
+ list(pre_fit.named_transformers_["cat"].named_steps["onehot"].get_feature_names_out(sel_cat))
347
+ if sel_cat else []
348
+ )
349
+ X_sm = sm.add_constant(pd.DataFrame(X_train_proc, columns=feature_names))
350
+ y_sm = y_train.values
351
+ logit = sm.Logit(y_sm, X_sm).fit(disp=False)
352
+ or_table = coefficient_table_for_logit(logit, feature_names)
353
+ st.dataframe(or_table[["feature", "odds_ratio", "or_ci_low", "or_ci_high"]].round(3))
354
+
355
+ st.info(
356
+ "Interpretação: valores de *odds ratio* > 1 aumentam a chance de reclamação; "
357
+ "< 1 reduzem. Use os IC para priorizar intervenções mais robustas."
358
+ )
359
+ except Exception as e:
360
+ st.warning(f"Não foi possível calcular os intervalos de confiança: {e}")
361
+
362
+ # -----------------------------
363
+ # Item (c): Definição & Seleção de Modelos
364
+ # -----------------------------
365
+ st.markdown("## 🧠 Item (c) — Definição & Seleção de Modelos")
366
+ st.write("""
367
+ **Regressão Logística** foi escolhida por sua interpretabilidade (odds ratios) e por modelar diretamente a probabilidade de `Complain=1`.
368
+ Em paralelo, **Random Forest** foi incluída como baseline não linear robusto a interações e efeitos não lineares. A escolha final pode ser
369
+ guiada por **AUC/ROC**, **acurácia** e capacidade de **explicação** necessária ao negócio. Para variáveis mistas (numéricas/categóricas),
370
+ aplicamos *imputação*, *padronização* (numéricas) e *one-hot* (categóricas) para garantir comparabilidade e estabilidade do treinamento.
371
+ """)
372
+
373
+ # -----------------------------
374
+ # Item (e): Recomendações estratégicas
375
+ # -----------------------------
376
+ st.markdown("## 🧭 Item (e) — Recomendações para a Tomada de Decisão")
377
+ recs = make_recommendations(imp_df, top_k=5)
378
+ for r in recs:
379
+ st.markdown("- " + r)
380
 
381
+ st.markdown("---")
382
+ st.caption("PPCA/UnB Tarefa 6 — Modelos Supervisionados • App em Streamlit para Hugging Face Spaces")