Spaces:
Sleeping
Sleeping
Update src/streamlit_app.py
Browse files- src/streamlit_app.py +233 -378
src/streamlit_app.py
CHANGED
|
@@ -1,391 +1,246 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
import os
|
| 4 |
import numpy as np
|
| 5 |
import pandas as pd
|
| 6 |
-
import streamlit as st
|
| 7 |
import altair as alt
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
-
# ---------- Utilidades ----------
|
| 29 |
@st.cache_data(show_spinner=False)
|
| 30 |
-
def
|
| 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 |
-
def engineer_features(df: pd.DataFrame) -> pd.DataFrame:
|
| 59 |
-
"""Engenharia minimalista para o dataset padrão do Kaggle."""
|
| 60 |
-
out = df.copy()
|
| 61 |
-
|
| 62 |
-
# Tenure (dias desde Dt_Customer)
|
| 63 |
-
if "Dt_Customer" in out.columns:
|
| 64 |
-
out["Dt_Customer"] = pd.to_datetime(out["Dt_Customer"], errors="coerce", dayfirst=True)
|
| 65 |
-
out["TenureDays"] = (pd.Timestamp("today").normalize() - out["Dt_Customer"]).dt.days
|
| 66 |
-
|
| 67 |
-
# Total gasto (Mnt*)
|
| 68 |
-
mnt_cols = [c for c in out.columns if c.startswith("Mnt")]
|
| 69 |
-
if mnt_cols:
|
| 70 |
-
out["TotalMnt"] = out[mnt_cols].sum(axis=1)
|
| 71 |
-
|
| 72 |
-
# Compras totais e participações
|
| 73 |
-
buy_cols = [c for c in ["NumWebPurchases", "NumCatalogPurchases", "NumStorePurchases"] if c in out.columns]
|
| 74 |
-
if buy_cols:
|
| 75 |
-
out["TotalPurchases"] = out[buy_cols].sum(axis=1)
|
| 76 |
-
if "NumWebPurchases" in out.columns:
|
| 77 |
-
out["OnlineShare"] = out["NumWebPurchases"] / out["TotalPurchases"].replace(0, np.nan)
|
| 78 |
-
if "NumDealsPurchases" in out.columns:
|
| 79 |
-
out["PromoShare"] = out["NumDealsPurchases"] / out["TotalPurchases"].replace(0, np.nan)
|
| 80 |
-
|
| 81 |
-
# Ticket médio
|
| 82 |
-
if "TotalMnt" in out.columns and "TotalPurchases" in out.columns:
|
| 83 |
-
out["AvgTicket"] = out["TotalMnt"] / out["TotalPurchases"].replace(0, np.nan)
|
| 84 |
-
|
| 85 |
-
# Diversidade de cesta (quantos tipos Mnt*>0)
|
| 86 |
-
if mnt_cols:
|
| 87 |
-
out["BasketDiversity"] = (out[mnt_cols] > 0).sum(axis=1)
|
| 88 |
-
|
| 89 |
-
return out
|
| 90 |
-
|
| 91 |
-
def build_preprocessor(num_cols: List[str], cat_cols: List[str]) -> ColumnTransformer:
|
| 92 |
-
"""Imputação + padronização (num) e OHE drop='first' (cat) para evitar colinearidade."""
|
| 93 |
-
num_pipe = Pipeline([
|
| 94 |
-
("imp", SimpleImputer(strategy="median")),
|
| 95 |
-
("scaler", StandardScaler())
|
| 96 |
-
])
|
| 97 |
-
cat_pipe = Pipeline([
|
| 98 |
-
("imp", SimpleImputer(strategy="most_frequent")),
|
| 99 |
-
("ohe", OneHotEncoder(handle_unknown="ignore", drop="first", sparse_output=False))
|
| 100 |
-
])
|
| 101 |
-
return ColumnTransformer([
|
| 102 |
-
("num", num_pipe, num_cols),
|
| 103 |
-
("cat", cat_pipe, cat_cols)
|
| 104 |
-
])
|
| 105 |
-
|
| 106 |
-
def get_feature_names(pre: ColumnTransformer, num_cols: List[str], cat_cols: List[str]) -> List[str]:
|
| 107 |
-
names = list(num_cols)
|
| 108 |
-
if cat_cols:
|
| 109 |
-
ohe = pre.named_transformers_["cat"].named_steps["ohe"]
|
| 110 |
-
names.extend(list(ohe.get_feature_names_out(cat_cols)))
|
| 111 |
-
return names
|
| 112 |
-
|
| 113 |
-
def fit_inference(model_type: str, X_design: pd.DataFrame, y: pd.Series):
|
| 114 |
-
"""Ajusta a inferência (statsmodels): Logit p/ binário; OLS p/ contínuo."""
|
| 115 |
-
X_sm = sm.add_constant(X_design, has_constant="add")
|
| 116 |
-
if model_type == "logit":
|
| 117 |
-
res = sm.Logit(y.values, X_sm).fit(disp=False)
|
| 118 |
-
or_vals = np.exp(res.params)
|
| 119 |
-
or_ci = np.exp(res.conf_int())
|
| 120 |
-
tbl = pd.DataFrame({
|
| 121 |
-
"feature": res.params.index,
|
| 122 |
-
"coef": res.params.values,
|
| 123 |
-
"std_err": res.bse.values,
|
| 124 |
-
"z/t": res.tvalues.values if hasattr(res, "tvalues") else res.tvalues,
|
| 125 |
-
"p_value": res.pvalues.values,
|
| 126 |
-
"ci_low": res.conf_int()[0].values,
|
| 127 |
-
"ci_high": res.conf_int()[1].values,
|
| 128 |
-
"odds_ratio": or_vals.values,
|
| 129 |
-
"or_ci_low": or_ci[0].values,
|
| 130 |
-
"or_ci_high": or_ci[1].values
|
| 131 |
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
else:
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
for _, r in core.iterrows():
|
| 153 |
-
feat = r["feature"]
|
| 154 |
-
sign = np.sign(r["coef"])
|
| 155 |
-
if model_type == "logit":
|
| 156 |
-
or_txt = f"(OR≈{r['odds_ratio']:.2f}, IC95% {r['or_ci_low']:.2f}–{r['or_ci_high']:.2f}, p={r['p_value']:.3g})"
|
| 157 |
-
if sign > 0:
|
| 158 |
-
out.append(f" **Reduzir exposição associada a `{feat}`** {or_txt}, pois aumento nessa variável eleva a probabilidade do alvo.")
|
| 159 |
-
else:
|
| 160 |
-
out.append(f" **Fortalecer fatores ligados a `{feat}`** {or_txt}, pois valores maiores reduzem a probabilidade do alvo.")
|
| 161 |
-
else:
|
| 162 |
-
eff = f"(β≈{r['coef']:.3g}, IC95% {r['ci_low']:.2g}–{r['ci_high']:.2g}, p={r['p_value']:.3g})"
|
| 163 |
-
if sign > 0:
|
| 164 |
-
out.append(f" **Mitigar o crescimento de `{feat}`** {eff}, pois contribui positivamente para o aumento do alvo.")
|
| 165 |
-
else:
|
| 166 |
-
out.append(f" **Aumentar `{feat}`** {eff}, pois está associado à redução do alvo.")
|
| 167 |
-
# trilhas transversais
|
| 168 |
-
out.append(" **Testes A/B** nas variáveis mais significativas para validar impacto causal.")
|
| 169 |
-
out.append(" **Melhorar FCR/primeiro contato** nas causas evidenciadas pelos top fatores.")
|
| 170 |
-
out.append(" **Feedback a Produto/Qualidade** guiado pelos efeitos com evidência estatística robusta.")
|
| 171 |
-
return out[:k+3]
|
| 172 |
-
|
| 173 |
-
# ---------- Sidebar (lado esquerdo) ----------
|
| 174 |
-
with st.sidebar:
|
| 175 |
-
st.header("Configuração")
|
| 176 |
-
if not os.path.exists(DATA_PATH):
|
| 177 |
-
st.error(f"Arquivo não encontrado: `{DATA_PATH}`. Suba o CSV em `Dados/`.")
|
| 178 |
-
st.stop()
|
| 179 |
-
|
| 180 |
-
df_raw = load_csv_try(DATA_PATH)
|
| 181 |
-
df_eng = engineer_features(df_raw)
|
| 182 |
-
|
| 183 |
-
with st.sidebar:
|
| 184 |
-
st.markdown("**Alvo (variável dependente):**")
|
| 185 |
-
all_cols = df_eng.columns.tolist()
|
| 186 |
-
# Alvo padrão fixo: Response (se existir). Caso contrário, mesma lógica de fallback.
|
| 187 |
-
if "Response" in all_cols:
|
| 188 |
-
default_target = "Response"
|
| 189 |
-
else:
|
| 190 |
-
default_target = None
|
| 191 |
-
for c in all_cols:
|
| 192 |
-
if is_binary_series(df_eng[c]): default_target = c; break
|
| 193 |
-
if default_target is None:
|
| 194 |
-
for c in all_cols:
|
| 195 |
-
if np.issubdtype(df_eng[c].dtype, np.number): default_target = c; break
|
| 196 |
-
target_col = st.selectbox("Alvo (y)", options=all_cols, index=all_cols.index(default_target) if default_target in all_cols else 0)
|
| 197 |
-
|
| 198 |
-
# Variáveis explicativas
|
| 199 |
-
exclude = [target_col]
|
| 200 |
-
num_cols_all, cat_cols_all = split_num_cat(df_eng, exclude=exclude)
|
| 201 |
-
|
| 202 |
-
# X padrão alinhado ao Colab (só incluir se existir na base)
|
| 203 |
-
preferred_defaults = [
|
| 204 |
-
"Income", "Recency", "Education", "Marital_Status",
|
| 205 |
-
"TenureDays", "TotalMnt", "TotalPurchases",
|
| 206 |
-
"OnlineShare", "PromoShare", "AvgTicket", "BasketDiversity",
|
| 207 |
-
"NumWebVisitsMonth"
|
| 208 |
-
]
|
| 209 |
-
default_X = [c for c in preferred_defaults if c in (num_cols_all + cat_cols_all)]
|
| 210 |
-
|
| 211 |
-
with st.sidebar:
|
| 212 |
-
st.markdown("**Variáveis explicativas (X):**")
|
| 213 |
-
# Se nada dos preferidos existir, cai no fallback antigo (algumas num + categ)
|
| 214 |
-
if not default_X:
|
| 215 |
-
engineered_first = [c for c in ["TenureDays","TotalMnt","TotalPurchases","OnlineShare","PromoShare","AvgTicket","BasketDiversity"] if c in num_cols_all]
|
| 216 |
-
default_X = engineered_first + [c for c in num_cols_all if c not in engineered_first][:5] + cat_cols_all[:3]
|
| 217 |
-
|
| 218 |
-
selected_feats = st.multiselect("Selecione X", options=(num_cols_all + cat_cols_all), default=default_X)
|
| 219 |
-
|
| 220 |
-
test_size = st.slider("Proporção de teste", 0.1, 0.4, 0.2, 0.05)
|
| 221 |
-
random_state = st.number_input("Random seed", value=42, step=1)
|
| 222 |
-
|
| 223 |
-
if len(selected_feats) == 0:
|
| 224 |
-
st.warning("Selecione pelo menos uma variável explicativa.")
|
| 225 |
st.stop()
|
| 226 |
|
| 227 |
-
|
| 228 |
-
st.
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
y_numeric_try = coerce_numeric_series(y_raw) if not is_bin else None
|
| 239 |
-
is_numeric_ok = False
|
| 240 |
-
if not is_bin and y_numeric_try is not None:
|
| 241 |
-
conv_rate = y_numeric_try.notna().mean()
|
| 242 |
-
is_numeric_ok = conv_rate >= 0.8
|
| 243 |
-
|
| 244 |
-
# 3) se não binário e não numérico, vira categórico multi-classe → one-vs-rest
|
| 245 |
-
with st.sidebar:
|
| 246 |
-
positive_class = None
|
| 247 |
-
if not is_bin and not is_numeric_ok:
|
| 248 |
-
uniq_vals = sorted(pd.unique(y_raw.dropna()).tolist(), key=lambda x: str(x))
|
| 249 |
-
st.markdown("**Alvo categórico com múltiplas classes**")
|
| 250 |
-
positive_class = st.selectbox("Classe 'positiva' (one-vs-rest)", options=uniq_vals, index=0)
|
| 251 |
-
st.caption("O modelo fará Logit para a classe escolhida vs. as demais.")
|
| 252 |
-
|
| 253 |
-
# ---------- Montagem de y conforme os casos ----------
|
| 254 |
-
if is_bin:
|
| 255 |
-
if not np.issubdtype(y_raw.dtype, np.number):
|
| 256 |
-
uniq = sorted(pd.unique(y_raw.dropna()).tolist(), key=lambda x: str(x))
|
| 257 |
-
y = y_raw.replace({uniq[0]: 0, uniq[1]: 1}).astype(int)
|
| 258 |
-
else:
|
| 259 |
-
y = y_raw.astype(int)
|
| 260 |
-
model_type = "logit"
|
| 261 |
-
elif is_numeric_ok:
|
| 262 |
-
y = y_numeric_try.astype(float)
|
| 263 |
-
model_type = "ols"
|
| 264 |
-
else:
|
| 265 |
-
y = (y_raw == positive_class).astype(int)
|
| 266 |
-
model_type = "logit"
|
| 267 |
-
|
| 268 |
-
# Alinha df aos y válidos
|
| 269 |
-
mask_valid = y.notna()
|
| 270 |
-
df_model = df_eng.loc[mask_valid].copy()
|
| 271 |
-
y = y.loc[mask_valid]
|
| 272 |
-
X = df_model[selected_feats].copy()
|
| 273 |
-
|
| 274 |
-
# ---------- Pré-processamento e treino ----------
|
| 275 |
-
sel_num = [c for c in selected_feats if np.issubdtype(X[c].dtype, np.number)]
|
| 276 |
-
sel_cat = [c for c in selected_feats if (X[c].dtype == "object" or X[c].dtype.name == "category")]
|
| 277 |
-
|
| 278 |
-
pre = build_preprocessor(sel_num, sel_cat)
|
| 279 |
-
quick_est = LogisticRegression(max_iter=200) if model_type == "logit" else LinearRegression()
|
| 280 |
-
pipe = Pipeline([("pre", pre), ("est", quick_est)])
|
| 281 |
-
|
| 282 |
-
X_train, X_test, y_train, y_test = train_test_split(
|
| 283 |
-
X, y, test_size=test_size, random_state=random_state,
|
| 284 |
-
stratify=y if model_type == "logit" else None
|
| 285 |
)
|
| 286 |
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
with st.spinner("Ajustando modelo de inferência (statsmodels)..."):
|
| 301 |
-
res, infer_tbl = fit_inference(model_type, X_train_df, y_train)
|
| 302 |
-
|
| 303 |
-
if model_type == "logit":
|
| 304 |
-
if positive_class is not None:
|
| 305 |
-
st.caption(f"Modelo: **Logit** (one-vs-rest). Classe positiva: **{positive_class}**.")
|
| 306 |
-
else:
|
| 307 |
-
st.caption("Modelo: **Logit** (alvo binário). Coeficientes em log-odds; exibimos **odds ratios** e IC 95%.")
|
| 308 |
-
cols_show = ["feature","coef","std_err","z/t","p_value","ci_low","ci_high","odds_ratio","or_ci_low","or_ci_high"]
|
| 309 |
-
else:
|
| 310 |
-
st.caption("Modelo: **OLS** (alvo contínuo). Coeficientes, erros-padrão, estatística t e IC 95%.")
|
| 311 |
-
cols_show = ["feature","coef","std_err","z/t","p_value","ci_low","ci_high"]
|
| 312 |
-
|
| 313 |
-
st.dataframe(infer_tbl[cols_show].round(4), use_container_width=True)
|
| 314 |
-
|
| 315 |
-
# ---------- Métricas ----------
|
| 316 |
-
st.markdown("### Desempenho do modelo")
|
| 317 |
-
if model_type == "logit":
|
| 318 |
-
y_proba = pipe.predict_proba(X_test)[:, 1] if hasattr(pipe.named_steps["est"], "predict_proba") else pipe.predict(X_test)
|
| 319 |
-
y_pred = (y_proba >= 0.5).astype(int)
|
| 320 |
-
auc = roc_auc_score(y_test, y_proba)
|
| 321 |
-
acc = accuracy_score(y_test, y_pred)
|
| 322 |
-
c1, c2 = st.columns(2)
|
| 323 |
-
with c1: st.metric("AUC (ROC)", f"{auc:.3f}")
|
| 324 |
-
with c2: st.metric("Acurácia (0.5)", f"{acc:.3f}")
|
| 325 |
-
|
| 326 |
-
cm = confusion_matrix(y_test, y_pred)
|
| 327 |
-
st.markdown("**Matriz de confusão (teste)**")
|
| 328 |
-
st.dataframe(pd.DataFrame(cm, index=["Real 0","Real 1"], columns=["Pred 0","Pred 1"]), use_container_width=True)
|
| 329 |
-
|
| 330 |
-
fpr, tpr, _ = roc_curve(y_test, y_proba)
|
| 331 |
-
roc_data = pd.DataFrame({"fpr": fpr, "tpr": tpr})
|
| 332 |
-
roc_chart = alt.Chart(roc_data).mark_line().encode(x="fpr:Q", y="tpr:Q").properties(height=250, width=380)
|
| 333 |
-
diag = alt.Chart(pd.DataFrame({"x":[0,1],"y":[0,1]})).mark_line(strokeDash=[4,4]).encode(x="x", y="y")
|
| 334 |
-
st.altair_chart(roc_chart + diag, use_container_width=True)
|
| 335 |
-
else:
|
| 336 |
-
y_pred = pipe.predict(X_test)
|
| 337 |
-
r2 = r2_score(y_test, y_pred)
|
| 338 |
-
rmse = mean_squared_error(y_test, y_pred, squared=False)
|
| 339 |
-
c1, c2 = st.columns(2)
|
| 340 |
-
with c1: st.metric("R² (teste)", f"{r2:.3f}")
|
| 341 |
-
with c2: st.metric("RMSE (teste)", f"{rmse:.3f}")
|
| 342 |
-
|
| 343 |
-
# ---------- Força dos efeitos ----------
|
| 344 |
-
st.markdown("### Força dos efeitos (|t/z|)")
|
| 345 |
-
eff_df = infer_tbl[infer_tbl["feature"] != "const"].copy()
|
| 346 |
-
eff_df["effect_strength"] = eff_df["z/t"].abs()
|
| 347 |
-
eff_chart = alt.Chart(eff_df.sort_values("effect_strength", ascending=False).head(20)).mark_bar().encode(
|
| 348 |
-
x=alt.X("effect_strength:Q", title="|estatística t/z|"),
|
| 349 |
-
y=alt.Y("feature:N", sort='-x', title="Variável")
|
| 350 |
-
).properties(height=420)
|
| 351 |
-
st.altair_chart(eff_chart, use_container_width=True)
|
| 352 |
-
|
| 353 |
-
# ---------- Predição interativa ----------
|
| 354 |
-
st.markdown("## Predição interativa")
|
| 355 |
-
st.caption("Ajuste valores para X e veja a probabilidade (Logit) ou valor previsto (OLS).")
|
| 356 |
-
|
| 357 |
-
with st.form("pred_form"):
|
| 358 |
-
cols = st.columns(3)
|
| 359 |
-
user_inputs = {}
|
| 360 |
-
for i, col in enumerate(selected_feats):
|
| 361 |
-
with cols[i % 3]:
|
| 362 |
-
if col in [c for c in selected_feats if np.issubdtype(X[c].dtype, np.number)]:
|
| 363 |
-
q1, q5, q95, q99 = X_train[col].quantile([0.01,0.05,0.95,0.99])
|
| 364 |
-
default_val = float(np.nan_to_num(X_train[col].median(), nan=0.0))
|
| 365 |
-
user_inputs[col] = st.number_input(
|
| 366 |
-
f"{col}", value=default_val,
|
| 367 |
-
help=f"Faixa típica ~ {q5:.2f}–{q95:.2f} (1–99%: {q1:.2f}–{q99:.2f})"
|
| 368 |
-
)
|
| 369 |
-
else:
|
| 370 |
-
opts = sorted([str(x) for x in X_train[col].dropna().unique().tolist()])[:50]
|
| 371 |
-
user_inputs[col] = st.selectbox(f"{col}", options=opts if opts else [""], index=0 if opts else 0)
|
| 372 |
-
submitted = st.form_submit_button("Calcular")
|
| 373 |
-
|
| 374 |
-
if submitted:
|
| 375 |
-
x_new = pd.DataFrame([user_inputs])
|
| 376 |
-
x_new_proc = pre_fit.transform(x_new)
|
| 377 |
-
x_new_df = pd.DataFrame(x_new_proc, columns=feat_names)
|
| 378 |
-
X_sm_new = sm.add_constant(x_new_df, has_constant="add")
|
| 379 |
-
y_hat = float(res.predict(X_sm_new)[0])
|
| 380 |
-
if model_type == "logit":
|
| 381 |
-
st.success(f"Probabilidade prevista do alvo: **{y_hat:.2%}**")
|
| 382 |
-
else:
|
| 383 |
-
st.success(f"Valor previsto do alvo: **{y_hat:.4g}**")
|
| 384 |
-
|
| 385 |
-
# ---------- Recomendações (item e) ----------
|
| 386 |
-
st.markdown("## Recomendações estratégicas (Item e)")
|
| 387 |
-
for r in recs_from_inference(infer_tbl, model_type=model_type, k=5):
|
| 388 |
-
st.markdown("- " + r)
|
| 389 |
|
| 390 |
-
st.
|
| 391 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
|
|
|
|
|
|
| 2 |
import numpy as np
|
| 3 |
import pandas as pd
|
|
|
|
| 4 |
import altair as alt
|
| 5 |
+
import streamlit as st
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
st.set_page_config(page_title="Simulação Monte Carlo (Dirichlet–Multinomial)", layout="wide")
|
| 9 |
+
|
| 10 |
+
# ===================== Sidebar: parâmetros =====================
|
| 11 |
+
st.sidebar.title("Parâmetros da Simulação")
|
| 12 |
+
N_SIM = st.sidebar.number_input("Número de simulações", min_value=1000, max_value=200_000, value=10_000, step=1000)
|
| 13 |
+
META_APROV = st.sidebar.slider("Meta de aprovação (≥)", 0.50, 0.95, 0.80, 0.01)
|
| 14 |
+
MAX_EVASAO = st.sidebar.slider("Limite de evasão (≤)", 0.00, 0.40, 0.15, 0.01)
|
| 15 |
+
ADD_K = st.sidebar.select_slider("Suavização add-k", options=[0.5, 1.0, 2.0], value=1.0)
|
| 16 |
+
N_MULT = st.sidebar.select_slider("Cenário do tamanho da turma (n ×)", options=[0.9, 1.0, 1.1], value=1.0)
|
| 17 |
+
SEED = st.sidebar.number_input("Semente aleatória", min_value=0, value=42, step=1)
|
| 18 |
+
|
| 19 |
+
# ===================== Helpers =====================
|
| 20 |
+
def _norm_cols(cols):
|
| 21 |
+
return [re.sub(r"\s+", " ", str(c)).strip().replace("%", "pct") for c in cols]
|
| 22 |
+
|
| 23 |
+
def _pick(col, pats):
|
| 24 |
+
return any(re.search(p, col, re.I) for p in pats)
|
| 25 |
+
|
| 26 |
+
def _to_num(s):
|
| 27 |
+
return pd.to_numeric(
|
| 28 |
+
s.astype(str)
|
| 29 |
+
.str.replace("%", "", regex=False)
|
| 30 |
+
.str.replace(",", ".", regex=False)
|
| 31 |
+
.str.strip(),
|
| 32 |
+
errors="coerce"
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
def _try_read_csv(path: Path):
|
| 36 |
+
"""Lê Dados/levantamentoTurmas.csv tentando separadores e encodings comuns."""
|
| 37 |
+
if not path.exists():
|
| 38 |
+
return None, f"Arquivo esperado não encontrado: {path}"
|
| 39 |
+
last_err = None
|
| 40 |
+
for enc in ("utf-8-sig", "utf-8", "latin1"):
|
| 41 |
+
for sep in (None, ",", ";", "\t"): # None = autodetect
|
| 42 |
+
try:
|
| 43 |
+
df = pd.read_csv(path, sep=sep, engine="python", encoding=enc)
|
| 44 |
+
if df.shape[1] == 1 and sep is None:
|
| 45 |
+
df = pd.read_csv(path, sep=";", engine="python", encoding=enc)
|
| 46 |
+
return df, {"source": str(path), "sep": sep if sep is not None else "auto", "encoding": enc}
|
| 47 |
+
except Exception as e:
|
| 48 |
+
last_err = e
|
| 49 |
+
continue
|
| 50 |
+
return None, f"Falha ao ler {path}: {last_err}"
|
| 51 |
|
| 52 |
+
@st.cache_data(show_spinner=False)
|
| 53 |
+
def load_dataframe_from_dados():
|
| 54 |
+
csv_path = Path("Dados/levantamentoTurmas.csv")
|
| 55 |
+
df, meta = _try_read_csv(csv_path)
|
| 56 |
+
if df is None:
|
| 57 |
+
return None, meta # mensagem de erro
|
| 58 |
+
|
| 59 |
+
# Normalização de cabeçalhos
|
| 60 |
+
df.columns = _norm_cols(df.columns)
|
| 61 |
+
|
| 62 |
+
# Renomeação inteligente
|
| 63 |
+
ren = {}
|
| 64 |
+
for c in df.columns:
|
| 65 |
+
lc = c.lower()
|
| 66 |
+
if _pick(c, [r"^turma"]): ren[c] = "Turma"
|
| 67 |
+
elif _pick(c, [r"matriculado"]): ren[c] = "Matriculados"
|
| 68 |
+
elif _pick(c, [r"\baprov"]): ren[c] = "Aprovados" if "pct" not in lc else "pct_Aprov"
|
| 69 |
+
elif _pick(c, [r"reprov"]): ren[c] = "Reprovados" if "pct" not in lc else "pct_Reprov"
|
| 70 |
+
elif _pick(c, [r"desistent|evas"]): ren[c] = "Desistentes" if "pct" not in lc else "pct_Desist"
|
| 71 |
+
df = df.rename(columns=ren)
|
| 72 |
+
|
| 73 |
+
# Converte números/percentuais
|
| 74 |
+
for c in ["Matriculados","Aprovados","Reprovados","Desistentes","pct_Aprov","pct_Reprov","pct_Desist"]:
|
| 75 |
+
if c in df.columns:
|
| 76 |
+
df[c] = _to_num(df[c])
|
| 77 |
+
|
| 78 |
+
# Reconstrói contagens quando vierem apenas em %
|
| 79 |
+
if "Aprovados" not in df.columns and "pct_Aprov" in df.columns:
|
| 80 |
+
df["Aprovados"] = (df["pct_Aprov"]/100 * df["Matriculados"]).round()
|
| 81 |
+
if "Reprovados" not in df.columns and "pct_Reprov" in df.columns:
|
| 82 |
+
df["Reprovados"] = (df["pct_Reprov"]/100 * df["Matriculados"]).round()
|
| 83 |
+
if "Desistentes" not in df.columns and "pct_Desist" in df.columns:
|
| 84 |
+
df["Desistentes"] = (df["pct_Desist"]/100 * df["Matriculados"]).round()
|
| 85 |
+
|
| 86 |
+
need = ["Turma","Matriculados","Aprovados","Reprovados","Desistentes"]
|
| 87 |
+
miss = [c for c in need if c not in df.columns]
|
| 88 |
+
if miss:
|
| 89 |
+
return None, f"Colunas ausentes no CSV ({csv_path}): {miss}"
|
| 90 |
+
|
| 91 |
+
base = df[need].copy()
|
| 92 |
+
for c in need[1:]:
|
| 93 |
+
base[c] = pd.to_numeric(base[c], errors="coerce").fillna(0).astype(int)
|
| 94 |
+
|
| 95 |
+
base = base[base["Matriculados"] > 0].copy()
|
| 96 |
+
base["Turma"] = base["Turma"].astype(str).str.strip()
|
| 97 |
+
|
| 98 |
+
# Ajuste de soma
|
| 99 |
+
soma = base[["Aprovados","Reprovados","Desistentes"]].sum(axis=1)
|
| 100 |
+
diff = soma != base["Matriculados"]
|
| 101 |
+
base.loc[diff, "Aprovados"] = (
|
| 102 |
+
base.loc[diff, "Matriculados"] - base.loc[diff, ["Reprovados","Desistentes"]].sum(axis=1)
|
| 103 |
+
).clip(lower=0)
|
| 104 |
+
|
| 105 |
+
if len(base) == 0:
|
| 106 |
+
return None, "Após limpeza, não restaram turmas válidas."
|
| 107 |
+
|
| 108 |
+
return base.reset_index(drop=True), None
|
| 109 |
|
|
|
|
| 110 |
@st.cache_data(show_spinner=False)
|
| 111 |
+
def simulate_dirichlet_multinomial(base: pd.DataFrame, n_sim: int, meta_aprov: float, max_evasao: float, add_k: float, n_mult: float, seed: int):
|
| 112 |
+
rng = np.random.default_rng(seed)
|
| 113 |
+
rows = []
|
| 114 |
+
for _, r in base.iterrows():
|
| 115 |
+
turma = r["Turma"]
|
| 116 |
+
n0 = int(r["Matriculados"])
|
| 117 |
+
n = max(1, int(round(n0 * n_mult)))
|
| 118 |
+
a, rp, dz = int(r["Aprovados"]), int(r["Reprovados"]), int(r["Desistentes"])
|
| 119 |
+
alpha = np.array([a + add_k, rp + add_k, dz + add_k], dtype=float)
|
| 120 |
+
|
| 121 |
+
P = rng.dirichlet(alpha, size=n_sim)
|
| 122 |
+
counts = np.vstack([rng.multinomial(n, p) for p in P])
|
| 123 |
+
t_ap = counts[:, 0] / n
|
| 124 |
+
t_dz = counts[:, 2] / n
|
| 125 |
+
|
| 126 |
+
rows.append({
|
| 127 |
+
"Turma": turma,
|
| 128 |
+
"Matriculados": n,
|
| 129 |
+
"Média_Aprov": t_ap.mean(),
|
| 130 |
+
"P5_Aprov": np.percentile(t_ap, 5),
|
| 131 |
+
"P50_Aprov": np.percentile(t_ap, 50),
|
| 132 |
+
"P95_Aprov": np.percentile(t_ap, 95),
|
| 133 |
+
"Média_Desist": t_dz.mean(),
|
| 134 |
+
"P5_Desist": np.percentile(t_dz, 5),
|
| 135 |
+
"P50_Desist": np.percentile(t_dz, 50),
|
| 136 |
+
"P95_Desist": np.percentile(t_dz, 95),
|
| 137 |
+
"Prob_Meta": ((t_ap >= meta_aprov) & (t_dz <= max_evasao)).mean()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
})
|
| 139 |
+
return pd.DataFrame(rows).sort_values("Prob_Meta", ascending=False).reset_index(drop=True)
|
| 140 |
+
|
| 141 |
+
@st.cache_data(show_spinner=False)
|
| 142 |
+
def sample_turma(base: pd.DataFrame, turma_label: str, n_sim: int, add_k: float, n_mult: float, seed: int):
|
| 143 |
+
turma_label = str(turma_label).strip()
|
| 144 |
+
m = base["Turma"] == turma_label
|
| 145 |
+
if not m.any():
|
| 146 |
+
mc = base["Turma"].str.contains(re.escape(turma_label), case=False, na=False)
|
| 147 |
+
if not mc.any():
|
| 148 |
+
return None, None
|
| 149 |
+
idx = base.index[mc][0]
|
| 150 |
else:
|
| 151 |
+
idx = base.index[m][0]
|
| 152 |
+
|
| 153 |
+
r = base.loc[idx]
|
| 154 |
+
n0 = int(r["Matriculados"])
|
| 155 |
+
n = max(1, int(round(n0 * n_mult)))
|
| 156 |
+
a, rp, dz = int(r["Aprovados"]), int(r["Reprovados"]), int(r["Desistentes"])
|
| 157 |
+
alpha = np.array([a + add_k, rp + add_k, dz + add_k], dtype=float)
|
| 158 |
+
rng = np.random.default_rng(seed)
|
| 159 |
+
P = rng.dirichlet(alpha, size=n_sim)
|
| 160 |
+
C = np.vstack([rng.multinomial(n, p) for p in P])
|
| 161 |
+
return C[:, 0] / n, C[:, 2] / n
|
| 162 |
+
|
| 163 |
+
# ===================== App =====================
|
| 164 |
+
st.title("Simulação de Monte Carlo — Dirichlet–Multinomial")
|
| 165 |
+
st.caption("O app lê **Dados/levantamentoTurmas.csv**. Ajuste os parâmetros na lateral e simule.")
|
| 166 |
+
|
| 167 |
+
base, err = load_dataframe_from_dados()
|
| 168 |
+
if err:
|
| 169 |
+
st.error(err)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 170 |
st.stop()
|
| 171 |
|
| 172 |
+
with st.expander("Ver dados utilizados (base limpa)", expanded=False):
|
| 173 |
+
st.dataframe(base)
|
| 174 |
+
|
| 175 |
+
sim_df = simulate_dirichlet_multinomial(
|
| 176 |
+
base=base,
|
| 177 |
+
n_sim=int(N_SIM),
|
| 178 |
+
meta_aprov=float(META_APROV),
|
| 179 |
+
max_evasao=float(MAX_EVASAO),
|
| 180 |
+
add_k=float(ADD_K),
|
| 181 |
+
n_mult=float(N_MULT),
|
| 182 |
+
seed=int(SEED)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
)
|
| 184 |
|
| 185 |
+
st.subheader("Resultados por turma")
|
| 186 |
+
st.dataframe(sim_df.style.format({
|
| 187 |
+
"Média_Aprov": "{:.3f}", "P5_Aprov": "{:.3f}", "P50_Aprov": "{:.3f}", "P95_Aprov": "{:.3f}",
|
| 188 |
+
"Média_Desist": "{:.3f}", "P5_Desist": "{:.3f}", "P50_Desist": "{:.3f}", "P95_Desist": "{:.3f}",
|
| 189 |
+
"Prob_Meta": "{:.3f}"
|
| 190 |
+
}))
|
| 191 |
+
|
| 192 |
+
st.download_button(
|
| 193 |
+
label="Baixar resultados (CSV)",
|
| 194 |
+
data=sim_df.to_csv(index=False).encode("utf-8"),
|
| 195 |
+
file_name="resultados_simulacao.csv",
|
| 196 |
+
mime="text/csv"
|
| 197 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
|
| 199 |
+
st.subheader("Probabilidade de bater a meta (ordenado)")
|
| 200 |
+
chart_prob = (
|
| 201 |
+
alt.Chart(sim_df.sort_values("Prob_Meta", ascending=True))
|
| 202 |
+
.mark_bar()
|
| 203 |
+
.encode(
|
| 204 |
+
x=alt.X("Prob_Meta:Q", title=f"Prob. (aprovação ≥ {META_APROV:.0%} & evasão ≤ {MAX_EVASAO:.0%})"),
|
| 205 |
+
y=alt.Y("Turma:N", sort="-x", title="Turma"),
|
| 206 |
+
tooltip=[
|
| 207 |
+
alt.Tooltip("Turma:N"),
|
| 208 |
+
alt.Tooltip("Prob_Meta:Q", format=".3f"),
|
| 209 |
+
alt.Tooltip("Média_Aprov:Q", format=".3f"),
|
| 210 |
+
alt.Tooltip("Média_Desist:Q", format=".3f"),
|
| 211 |
+
],
|
| 212 |
+
).properties(height=400)
|
| 213 |
+
)
|
| 214 |
+
st.altair_chart(chart_prob, use_container_width=True)
|
| 215 |
+
|
| 216 |
+
st.subheader("Distribuições simuladas (detalhe por turma)")
|
| 217 |
+
col1, col2 = st.columns(2)
|
| 218 |
+
with col1:
|
| 219 |
+
turma_sel = st.selectbox("Escolha uma turma", options=sim_df["Turma"].tolist(), index=0)
|
| 220 |
+
with col2:
|
| 221 |
+
st.write(f"Meta de aprovação ≥ **{META_APROV:.0%}** | Evasão ≤ **{MAX_EVASAO:.0%}**")
|
| 222 |
+
st.write(f"add-k = **{ADD_K}** · n × = **{N_MULT}** · simulações = **{N_SIM}**")
|
| 223 |
+
|
| 224 |
+
t_ap, t_dz = sample_turma(base, turma_sel, int(N_SIM), float(ADD_K), float(N_MULT), int(SEED))
|
| 225 |
+
if t_ap is None:
|
| 226 |
+
st.warning("Turma não encontrada após normalização.")
|
| 227 |
+
else:
|
| 228 |
+
h_ap = (
|
| 229 |
+
alt.Chart(pd.DataFrame({"taxa_aprov": t_ap}))
|
| 230 |
+
.mark_bar()
|
| 231 |
+
.encode(x=alt.X("taxa_aprov:Q", bin=alt.Bin(maxbins=30), title="Taxa de aprovação"),
|
| 232 |
+
y=alt.Y("count()", title="Frequência"))
|
| 233 |
+
.properties(height=300)
|
| 234 |
+
)
|
| 235 |
+
linha_meta = alt.Chart(pd.DataFrame({"x": [META_APROV]})).mark_rule(strokeDash=[6,4]).encode(x="x:Q")
|
| 236 |
+
st.altair_chart(h_ap + linha_meta, use_container_width=True)
|
| 237 |
+
|
| 238 |
+
h_dz = (
|
| 239 |
+
alt.Chart(pd.DataFrame({"taxa_evasao": t_dz}))
|
| 240 |
+
.mark_bar()
|
| 241 |
+
.encode(x=alt.X("taxa_evasao:Q", bin=alt.Bin(maxbins=30), title="Taxa de evasão"),
|
| 242 |
+
y=alt.Y("count()", title="Frequência"))
|
| 243 |
+
.properties(height=300)
|
| 244 |
+
)
|
| 245 |
+
linha_lim = alt.Chart(pd.DataFrame({"x": [MAX_EVASAO]})).mark_rule(strokeDash=[6,4]).encode(x="x:Q")
|
| 246 |
+
st.altair_chart(h_dz + linha_lim, use_container_width=True)
|