4.0 / src /streamlit_app.py
enzograndino's picture
Update src/streamlit_app.py
5d09271 verified
# 1. Importar Bibliotecas
import streamlit as st
import pandas as pd
import numpy as np
import kagglehub
from kagglehub import KaggleDatasetAdapter
import plotly.express as px
import plotly.graph_objects as go
from scipy.stats import shapiro, levene, kruskal
import statsmodels.api as sm
from statsmodels.formula.api import ols
from statsmodels.stats.stattools import durbin_watson
from statsmodels.stats.diagnostic import het_breuschpagan
from statsmodels.stats.outliers_influence import variance_inflation_factor
from statsmodels.stats.stattools import jarque_bera
from sklearn.metrics import mean_squared_error, mean_absolute_error
from sklearn.model_selection import cross_val_score, cross_val_predict, train_test_split
from sklearn.linear_model import LinearRegression
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import make_pipeline
import os
import warnings
warnings.filterwarnings("ignore")
# 2. Configuração da página e funções auxiliares
# Configuração da página do Streamlit
st.set_page_config(
page_title="Análise de Precificação Imobiliária ",
layout="wide",
initial_sidebar_state="expanded"
)
@st.cache_data
def load_data():
"""
Carrega o dataset do Kaggle diretamente para um DataFrame do Pandas
utilizando a API Kaggle Hub com o Pandas Adapter.
"""
try:
cache_path = "/tmp/kagglehub_cache"
os.makedirs(cache_path, exist_ok=True)
os.environ['KAGGLEHUB_CACHE'] = cache_path
df = kagglehub.load_dataset(
KaggleDatasetAdapter.PANDAS,
"prevek18/ames-housing-dataset",
"AmesHousing.csv",
)
# Limpeza e pré-processamento
if 'Order' in df.columns:
df.rename(columns={'Order': 'OrderID'}, inplace=True)
if 'PID' in df.columns:
df.rename(columns={'PID': 'PropertyID'}, inplace=True)
if 'MS SubClass' in df.columns:
df['MS SubClass'] = df['MS SubClass'].astype(str)
if 'Mo Sold' in df.columns:
df['Mo Sold'] = df['Mo Sold'].astype(str)
# Tratamento robusto de valores nulos
numeric_cols = df.select_dtypes(include=np.number).columns
df[numeric_cols] = df[numeric_cols].fillna(df[numeric_cols].median())
categorical_cols = df.select_dtypes(include=['object']).columns
df[categorical_cols] = df[categorical_cols].fillna('Missing')
return df
except Exception as e:
st.error(f"Erro ao baixar ou carregar os dados do Kaggle: {e}")
st.info("Verifique as credenciais do Kaggle (kaggle.json) nos Secrets do Space.")
return None
def safe_numeric_columns(df):
cols = df.select_dtypes(include=np.number).columns
exclude = [c for c in ['SalePrice', 'OrderID', 'PropertyID'] if c in cols]
return cols.drop(exclude).tolist()
def group_top_categories(df, col, top_n=10):
"""
Agrupa categorias menos frequentes em 'Other' para reduzir cardinalidade.
Retorna Série transformada.
"""
counts = df[col].value_counts(dropna=False)
top = counts.nlargest(top_n).index
return df[col].where(df[col].isin(top), other='Other')
def compute_vif(X_df):
"""
Computa VIF para DataFrame X_df (sem constante).
"""
vif_df = pd.DataFrame({
"feature": X_df.columns,
"VIF": [variance_inflation_factor(X_df.values, i) for i in range(X_df.shape[1])]
})
return vif_df
# Carrega os dados
data = load_data()
# 3. Layout do Dashboard (título e sidebar)
st.sidebar.title("Configurações da Análise")
st.sidebar.header("Tarefa 2 - SIEP")
st.sidebar.markdown("Esta aplicação interativa realiza uma análise de dados imobiliários com ANOVA e regressão linear.")
# Título Principal
st.title("Análise Preditiva de Preços de Imóveis")
st.markdown("Dashboard para a disciplina de Sistemas de Informação em Engenharia de Produção")
tab_intro, tab_eda, tab_anova, tab_regressao = st.tabs([
"Introdução", "Análise Exploratória (EDA)", "Análise de Variância (ANOVA)", "Modelo de Regressão Linear"
])
# 4. Conteudo das abas
if data is not None:
with tab_intro:
st.header("Contexto do Projeto")
st.write("""
Projeto: prever preço de venda (`SalePrice`) usando Regressão Linear Múltipla.
Requisitos principais da tarefa:
- Escolher 4 a 6 variáveis explicativas (pelo menos 1 contínua e 1 categórica).
- Ajustar modelo de regressão sem interações.
- Avaliar pressupostos (linearidade, normalidade, homocedasticidade, multicolinearidade).
- Aplicar transformações logarítmicas quando necessário e interpretar coeficientes.
- Avaliar desempenho (R², RMSE, MAE).
- Gerar recomendações e (bônus) disponibilizar interface interativa.
""")
with tab_eda:
st.header("Análise Exploratória de Dados (EDA)")
if st.checkbox("Mostrar uma amostra dos dados"):
st.dataframe(data.head())
st.write(f"O dataset contém **{data.shape[0]}** linhas e **{data.shape[1]}** colunas.")
st.subheader("Distribuição do Preço de Venda (SalePrice)")
col1, col2 = st.columns(2)
with col1:
fig_hist_price = px.histogram(data, x='SalePrice', nbins=100, title="Histograma de SalePrice")
st.plotly_chart(fig_hist_price, use_container_width=True)
with col2:
fig_box_price = px.box(data, y='SalePrice', title="Boxplot de SalePrice")
st.plotly_chart(fig_box_price, use_container_width=True)
st.markdown("**Interpretação:** `SalePrice` tende a ser assimétrico à direita — considere transformação logarítmica para modelagem.")
st.subheader("Correlação com SalePrice")
if st.checkbox("Calcular e mostrar correlações"):
numeric_cols = data.select_dtypes(include=np.number).columns.tolist()
corr_matrix = data[numeric_cols].corr()
corr_saleprice = corr_matrix['SalePrice'].sort_values(ascending=False).iloc[1:16]
fig_corr = px.bar(corr_saleprice, x=corr_saleprice.values, y=corr_saleprice.index, orientation='h',
title="Top 15 Variáveis Mais Correlacionadas com SalePrice",
labels={'x': 'Coeficiente de Correlação', 'y': 'Variável'})
st.plotly_chart(fig_corr, use_container_width=True)
st.markdown("**Interpretação:** `Overall Qual` e `Gr Liv Area` costumam ser fortes preditores.")
with tab_anova:
st.header("Análise de Variância (ANOVA)")
st.sidebar.header("Configurações da ANOVA")
suggested_cols_anova = ['Overall Qual', 'Neighborhood', 'Garage Cars', 'Full Bath', 'Kitchen Qual', 'Foundation']
valid_suggested_anova = [col for col in suggested_cols_anova if col in data.columns]
if not valid_suggested_anova:
st.warning("Nenhuma das colunas sugeridas para ANOVA foi encontrada no dataset.")
else:
anova_var = st.sidebar.selectbox("Selecione a variável categórica:", valid_suggested_anova)
alpha_anova = st.sidebar.slider("Nível de Significância (α) - ANOVA", 0.01, 0.10, 0.05, 0.01, key="alpha_anova")
st.subheader(f"Preço de Venda por '{anova_var}'")
fig_anova_box = px.box(data, x=anova_var, y='SalePrice', title=f"Boxplot de SalePrice por {anova_var}",
category_orders={anova_var: data.groupby(anova_var)['SalePrice'].median().sort_values().index})
st.plotly_chart(fig_anova_box, use_container_width=True)
if st.button("Executar Análise ANOVA"):
groups = [group["SalePrice"].dropna() for name, group in data.groupby(anova_var)]
stat_levene, p_levene = levene(*groups)
# Envolve a variável em Q("") para lidar com espaços no nome
formula = f'SalePrice ~ C(Q("{anova_var}"))'
model_ols = ols(formula, data=data).fit()
stat_shapiro, p_shapiro = shapiro(model_ols.resid)
st.subheader("Resultados dos Testes de Pressupostos")
st.markdown(f"**Levene (homocedasticidade):** p = {p_levene:.4f}")
st.markdown(f"**Shapiro-Wilk (normalidade resíduos):** p = {p_shapiro:.4f}")
st.info("Shapiro-Wilk pode rejeitar normalidade em grandes amostras — verifique QQ-plot e JB.")
if p_levene >= alpha_anova and p_shapiro >= alpha_anova:
st.info("Pressupostos atendidos — aplicando ANOVA (F).")
anova_result = sm.stats.anova_lm(model_ols, typ=2)
st.dataframe(anova_result)
p_value_main = anova_result['PR(>F)'][0]
test_used = "ANOVA"
else:
st.warning("Pressupostos não atendidos — aplicando Kruskal-Wallis (teste não paramétrico).")
stat_kruskal, p_kruskal = kruskal(*groups)
st.markdown(f"**Kruskal-Wallis:** estatística = {stat_kruskal:.3f}, p = {p_kruskal:.4f}")
p_value_main = p_kruskal
test_used = "Kruskal-Wallis"
if p_value_main < alpha_anova:
st.success(f"✅ Diferença significativa entre grupos detectada (p = {p_value_main:.4f} < {alpha_anova})")
else:
st.info(f"ℹ️ Nenhuma diferença estatisticamente significativa encontrada entre grupos (p = {p_value_main:.4f} >= {alpha_anova})")
st.subheader("📊 Interpretação Prática e Impacto para o Negócio")
group_stats = data.groupby(anova_var)['SalePrice'].agg([
('n', 'count'),
('média', 'mean'),
('mediana', 'median'),
('desvio_padrão', 'std'),
('mínimo', 'min'),
('máximo', 'max')
]).round(2)
group_stats = group_stats.sort_values('média', ascending=False)
st.markdown("**Estatísticas Descritivas por Categoria:**")
st.dataframe(group_stats.style.format({
'média': '${:,.2f}',
'mediana': '${:,.2f}',
'desvio_padrão': '${:,.2f}',
'mínimo': '${:,.2f}',
'máximo': '${:,.2f}'
}))
if p_value_main < alpha_anova:
st.markdown("---")
st.markdown("### 🎯 Insights Estratégicos para Tomada de Decisão")
categoria_mais_cara = group_stats.index[0]
preco_mais_caro = group_stats.iloc[0]['média']
categoria_mais_barata = group_stats.index[-1]
preco_mais_barato = group_stats.iloc[-1]['média']
diferenca_absoluta = preco_mais_caro - preco_mais_barato
diferenca_percentual = ((preco_mais_caro / preco_mais_barato) - 1) * 100
st.markdown(f"""
**1️⃣ Diferença de Valorização Identificada:**
- A categoria **'{categoria_mais_cara}'** apresenta o maior preço médio: **${preco_mais_caro:,.2f}**
- A categoria **'{categoria_mais_barata}'** apresenta o menor preço médio: **${preco_mais_barato:,.2f}**
- **Diferença:** ${diferenca_absoluta:,.2f} ({diferenca_percentual:.1f}% mais caro)
""")
if anova_var in ['Neighborhood', 'MS Zoning']:
st.markdown(f"""
**2️⃣ Recomendações para Investidores:**
- **Oportunidade de alto retorno:** Focar em imóveis na categoria '{categoria_mais_cara}' pode maximizar o valor de revenda.
- **Oportunidade de valorização:** Imóveis em '{categoria_mais_barata}' podem ser boas opções para reforma/revitalização se houver potencial de melhoria da região.
""")
elif anova_var in ['Overall Qual', 'Kitchen Qual', 'Exter Qual']:
st.markdown(f"""
**2️⃣ Recomendações para Proprietários:**
- **Investimento em melhorias:** Elevar a qualidade de '{categoria_mais_barata}' para '{categoria_mais_cara}' pode agregar até ${diferenca_absoluta:,.2f} ao valor do imóvel.
- **Priorização de reformas:** Foque em melhorias que elevem a classificação de qualidade, pois o impacto no preço é estatisticamente comprovado.
""")
elif anova_var in ['Garage Cars', 'Full Bath', 'Bedroom AbvGr']:
st.markdown(f"""
**2️⃣ Recomendações de Design e Reforma:**
- **Impacto quantitativo:** Aumentar de '{categoria_mais_barata}' para '{categoria_mais_cara}' está associado a um acréscimo médio de ${diferenca_absoluta:,.2f} no valor.
- **Reforma estratégica:** Se viável, considere adicionar unidades/capacidade nesta característica para valorizar o imóvel.
""")
else:
st.markdown("---")
st.markdown("### ℹ️ Interpretação")
st.markdown(f"""
Não foram encontradas diferenças estatisticamente significativas nos preços médios entre as categorias de **'{anova_var}'** (p = {p_value_main:.4f}).
**Implicações práticas:** Esta característica **não é um diferencial significativo** na precificação de imóveis quando analisada isoladamente.
""")
st.caption(f"💡 Fonte: Análise do Ames Housing Dataset usando {test_used} (α = {alpha_anova})")
with tab_regressao:
st.header("Modelo de Regressão Linear Múltipla")
st.sidebar.header("Configurações da Regressão")
numeric_cols_reg = safe_numeric_columns(data)
categorical_cols_reg = [c for c in ['Neighborhood', 'House Style', 'Overall Qual', 'Kitchen Qual', 'Foundation', 'Exter Qual'] if c in data.columns]
selected_numeric = st.sidebar.multiselect("Selecione variáveis numéricas (contínuas):", numeric_cols_reg,
default=['Gr Liv Area', 'Garage Area', 'Total Bsmt SF', '1st Flr SF'])
selected_categorical = st.sidebar.multiselect("Selecione variáveis categóricas:", categorical_cols_reg,
default=['Overall Qual', 'Neighborhood'])
st.sidebar.markdown("**Requisitos:** escolha entre 4 e 6 variáveis no total; pelo menos 1 contínua e 1 categórica.")
use_log_transform = st.sidebar.checkbox("Usar transformação log1p em y e em variáveis numéricas selecionadas", value=True)
alpha_reg = st.sidebar.slider("Nível de Significância (α) - Regressão", 0.01, 0.10, 0.05, 0.01, key="alpha_reg")
if st.button("Executar Modelo de Regressão"):
total_selected = len(selected_numeric) + len(selected_categorical)
if not (4 <= total_selected <= 6):
st.error("Seleção inválida: escolha entre 4 e 6 variáveis no total (numéricas + categóricas).")
st.stop()
if len(selected_numeric) < 1 or len(selected_categorical) < 1:
st.error("Seleção inválida: deve haver pelo menos 1 variável contínua e 1 categórica.")
st.stop()
cols_model = selected_numeric + selected_categorical + ['SalePrice']
df_model = data[cols_model].copy()
df_model[selected_numeric] = df_model[selected_numeric].fillna(df_model[selected_numeric].median())
for c in selected_categorical:
if c in df_model.columns:
df_model[c] = df_model[c].fillna('Missing')
high_card_cols = [c for c in selected_categorical if df_model[c].nunique() > 15]
for c in high_card_cols:
st.info(f"A coluna '{c}' tem alta cardinalidade ({df_model[c].nunique()} categorias). Agrupando menores frequências em 'Other' (top 10 mantidos).")
df_model[c] = group_top_categories(df_model, c, top_n=10)
if use_log_transform:
df_model['SalePrice'] = np.log1p(df_model['SalePrice'])
for col in selected_numeric:
if (df_model[col] >= 0).all():
df_model[col] = np.log1p(df_model[col])
else:
st.warning(f"A variável '{col}' contém valores negativos e permanecerá na escala original.")
X = pd.get_dummies(df_model.drop('SalePrice', axis=1), drop_first=True, dtype=float)
y = df_model['SalePrice']
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.20, random_state=42)
X_train_sm = sm.add_constant(X_train)
X_test_sm = sm.add_constant(X_test, has_constant='add')
model = sm.OLS(y_train, X_train_sm).fit()
st.subheader("Sumário do Modelo (Treino)")
st.text(model.summary().as_text())
pred_test = model.predict(X_test_sm)
y_test_orig = np.expm1(y_test) if use_log_transform else y_test
pred_test_orig = np.expm1(pred_test) if use_log_transform else pred_test
rmse_test = np.sqrt(mean_squared_error(y_test_orig, pred_test_orig))
mae_test = mean_absolute_error(y_test_orig, pred_test_orig)
st.subheader("Métricas de Desempenho (no conjunto de teste)")
col1, col2 = st.columns(2)
col1.metric("RMSE (teste)", f"${rmse_test:,.2f}")
col2.metric("MAE (teste)", f"${mae_test:,.2f}")
st.subheader("Diagnósticos dos Pressupostos (no conjunto de treino)")
resid = model.resid
_, p_shapiro = shapiro(resid)
jb_stat, jb_pvalue, _, _ = jarque_bera(resid)
st.markdown(f"- **Normalidade dos resíduos (Shapiro-Wilk):** p = {p_shapiro:.4f} {'✅' if p_shapiro >= alpha_reg else '❌'}")
st.markdown(f"- **Normalidade dos resíduos (Jarque-Bera):** p = {jb_pvalue:.4f} {'✅' if jb_pvalue >= alpha_reg else '❌'}")
fig_qq = sm.qqplot(resid, line='45', fit=True)
st.pyplot(fig_qq)
_, bp_lm_pvalue, _, _ = het_breuschpagan(resid, model.model.exog)
st.markdown(f"- **Homocedasticidade (Breusch-Pagan):** p = {bp_lm_pvalue:.4f} {'✅' if bp_lm_pvalue >= alpha_reg else '❌'}")
dw = durbin_watson(resid)
st.markdown(f"- **Autocorrelação de resíduos (Durbin-Watson):** {dw:.3f} (valores próximos de 2 são ideais)")
predictions_train = model.fittedvalues
fig_line = px.scatter(x=predictions_train, y=resid, labels={'x': 'Valores Preditos', 'y': 'Resíduos'}, title='Resíduos vs. Valores Preditos (Linearidade)')
fig_line.add_hline(y=0, line_dash="dash", line_color="red")
st.plotly_chart(fig_line, use_container_width=True)
X_vif = X_train_sm.drop(columns=['const'], errors='ignore')
if not X_vif.empty:
vif_df = compute_vif(X_vif)
st.subheader("VIF (Multicolinearidade)")
st.dataframe(vif_df.sort_values('VIF', ascending=False).reset_index(drop=True))
if (vif_df['VIF'] > 10).any():
st.warning("VIFs > 10 indicam multicolinearidade severa.")
if bp_lm_pvalue < alpha_reg:
st.warning("Heterocedasticidade detectada. Exibindo resultados com erros robustos (HC3).")
robust_res = model.get_robustcov_results(cov_type='HC3')
st.text(robust_res.summary().as_text())
st.subheader("🎯 Recomendações Práticas")
if bp_lm_pvalue < alpha_reg:
final_model_params = pd.Series(robust_res.params, index=model.params.index)
final_model_pvalues = pd.Series(robust_res.pvalues, index=model.params.index)
else:
final_model_params = model.params
final_model_pvalues = model.pvalues
for var in final_model_params.index:
if var == 'const': continue
if final_model_pvalues[var] < alpha_reg:
coef = final_model_params[var]
is_dummy = any(cat_col in var for cat_col in selected_categorical)
if use_log_transform:
if is_dummy:
impact = (np.exp(coef) - 1) * 100
st.write(f"🏷️ **{var}**: Associado a uma alteração de **{impact:+.2f}%** no preço.")
else: # contínua
impact = coef * 100
st.write(f"📈 **{var}**: Aumento de 1% está associado a **{impact:+.2f}%** no preço.")
else: # modelo linear
st.write(f"**{var}**: Aumento de 1 unidade está associado a **${coef:,.2f}** no preço.")
st.subheader("Validação Cruzada")
if st.checkbox("Mostrar Validação Cruzada (5 folds)"):
pipeline = make_pipeline(StandardScaler(), LinearRegression())
y_cv_pred = cross_val_predict(pipeline, X, y, cv=5)
y_orig = np.expm1(y) if use_log_transform else y
y_cv_pred_orig = np.expm1(y_cv_pred) if use_log_transform else y_cv_pred
rmse_cv = np.sqrt(mean_squared_error(y_orig, y_cv_pred_orig))
st.metric("RMSE Médio (CV 5-fold)", f"${rmse_cv:,.2f}")
else:
st.warning("Aguardando o carregamento dos dados... Verifique a conexão e as credenciais do Kaggle.")