Da Tabela para o Modelo

Autor

Douglas Braga

A seção anterior fechou alugueis com mais colunas do que abriu — cidade ganhou estado e população, e o aluguel ganhou uma versão por metro quadrado, sem que nenhum merge multiplicasse uma linha sequer. Nenhuma dessas colunas resolve a pergunta que fecha o capítulo, porque a pergunta não pede mais colunas: pede uma resposta. “Quanto deveria custar o aluguel deste imóvel?” é o que decide, de toda a tabela, o que vira o que um modelo tenta prever e o que vira o que ele recebe para prever.

Como cada .qmd roda no seu próprio kernel, esta seção lê dados/alugueis.csv de novo e leva adiante só as duas decisões tomadas antes e que ficam: "-" vira nulo em andar (seção 6.3), e esse nulo vira 0 (seção 6.4). As colunas que os dois merge da seção 6.5 acrescentaram não entram nesta escolha — nenhuma pergunta abaixo depende de estado ou de população.

alugueis = pd.read_csv("dados/alugueis.csv", na_values=["-"])
alugueis["andar"] = alugueis["andar"].fillna(0)
alugueis.shape
(10692, 13)

A pergunta decide a tabela

O que a pergunta pede é aluguel: essa coluna sai da tabela e vira y, o alvo. O resto — o que se sabe do imóvel antes de saber o aluguel — vira X, os preditores. Por ora, só os numéricos que já chegam prontos: área, quartos, banheiros, vagas.

A seção 6.2 já mostrou que colchete simples devolve uma Series e colchete duplo devolve um DataFrame. É a mesma sintaxe que separa y de X aqui — só que agora X tem quatro colunas, não uma:

y = alugueis["aluguel"]
X = alugueis[["area_m2", "quartos", "banheiros", "vagas"]]
print(type(y))
print(type(X))
<class 'pandas.core.series.Series'>
<class 'pandas.core.frame.DataFrame'>
print(y.shape)
print(X.shape)
(10692,)
(10692, 4)

y é uma Series de formato (10692,); X é um DataFrame de formato (10692, 4). Cada linha das duas tabelas ainda descreve o mesmo imóvel — é essa correspondência linha a linha que faz X e y andarem juntos daqui em diante.

andar, resolvido nas seções 6.3 e 6.4, fica fora desta primeira versão de X: o 0 do fillna representa “sem andar” (uma casa), não “andar térreo” de um apartamento, e tratar os dois como o mesmo número é uma escolha de modelagem que esta seção não chega a examinar. aceita_animal e mobiliado ficam fora pelo mesmo motivo que cidade ficaria se não fosse traduzida a seguir: ainda são texto — “sim” ou “não” —, não número.

O modelo não sabe o que é “cidade”

cidade é o que a seção 6.1 chamaria de categórico nominal: cinco rótulos, sem ordem nenhuma entre eles. Um modelo, porém, só sabe multiplicar e somar número — e a tradução mais óbvia de texto para número, trocar cada cidade por um inteiro (São Paulo = 1, Rio de Janeiro = 2, Campinas = 3, e assim por diante), inventaria exatamente o que cidade não tem: uma ordem entre as cinco, e uma distância entre elas — o modelo passaria a “ler” que Campinas está duas vezes mais longe de São Paulo do que o Rio está, uma afirmação sem sentido nenhum sobre nomes de cidade.

pd.get_dummies evita essa invenção: em vez de uma coluna com números inventados, cria uma coluna por categoria, cada uma com 0 ou 1.

dummies_cidade = pd.get_dummies(alugueis["cidade"])
dummies_cidade.head(3)
Belo Horizonte Campinas Porto Alegre Rio de Janeiro São Paulo
0 False False False False True
1 False False False False True
2 False False True False False
list(dummies_cidade.columns)
['Belo Horizonte', 'Campinas', 'Porto Alegre', 'Rio de Janeiro', 'São Paulo']

Cinco colunas, uma por cidade, em ordem alfabética. Nenhuma delas carrega mais peso que outra — e cada linha marca exatamente uma cidade como verdadeira:

dummies_cidade.sum(axis=1).unique()
array([1])

Toda linha soma 1: nenhum imóvel fica sem cidade, e nenhum marca duas ao mesmo tempo. Essas cinco colunas entram em X no lugar do texto:

X = pd.concat([X, dummies_cidade], axis=1)
X.shape
(10692, 9)

X passa de quatro para nove colunas — as quatro numéricas mais as cinco 0/1 que substituem cidade, sem nenhuma ordem que a coluna original não tinha.

Uma coluna que não pode entrar: o vazamento

A tabela ainda traz total, que devia ser a soma de aluguel, condominio, iptu e seguro_incendio. Conferindo:

soma = alugueis["aluguel"] + alugueis["condominio"] + alugueis["iptu"] + alugueis["seguro_incendio"]
diferenca = soma - alugueis["total"]
(diferenca == 0).sum(), len(alugueis)
(np.int64(9347), 10692)

Em 9.347 das 10.692 linhas, a soma bate com total exatamente. Nas outras:

divergentes = diferenca[diferenca != 0]
len(divergentes), divergentes.median(), divergentes.quantile(0.75), divergentes.min()
(1345, np.float64(-1.0), np.float64(2.0), np.int64(-379))

1.345 linhas divergem, e por pouco: a mediana da diferença é -1, o percentil 75 é 2, e o pior caso chega a -379 — a tabela não traz o que causou essas 1.345 exceções, só que a maioria bate exatamente e as demais divergem por um valor pequeno perto de zero, não por uma fração do total.

Uma coluna que é, para a imensa maioria das linhas, a soma que contém aluguel como parcela entrega a resposta a quem for prever aluguel — é essa a definição de vazamento, e é por isso que total não pode entrar em X. O instinto para confirmar um vazamento é medir a correlação entre a coluna suspeita e o alvo:

round(alugueis["total"].corr(alugueis["aluguel"]), 4)
np.float64(0.2645)

0,2645. Uma correlação fraca — para uma coluna que, na prática, contém aluguel dentro de si. Colocando total ao lado dos outros números da tabela, inclusive condominio, que compõe total do mesmo jeito:

colunas_numericas = ["area_m2", "quartos", "banheiros", "vagas", "condominio", "total", "aluguel"]
correlacoes = alugueis[colunas_numericas].corr()
correlacoes.round(2)
area_m2 quartos banheiros vagas condominio total aluguel
area_m2 1.00 0.19 0.23 0.19 0.01 0.05 0.18
quartos 0.19 1.00 0.73 0.62 0.01 0.13 0.54
banheiros 0.23 0.73 1.00 0.70 0.05 0.21 0.67
vagas 0.19 0.62 0.70 1.00 0.01 0.15 0.58
condominio 0.01 0.01 0.05 0.01 1.00 0.96 0.04
total 0.05 0.13 0.21 0.15 0.96 1.00 0.26
aluguel 0.18 0.54 0.67 0.58 0.04 0.26 1.00
fig, ax = plt.subplots()
im = ax.imshow(correlacoes, cmap="Blues", vmin=0, vmax=1)
ax.set_xticks(range(len(colunas_numericas)))
ax.set_xticklabels(colunas_numericas, rotation=45, ha="right")
ax.set_yticks(range(len(colunas_numericas)))
ax.set_yticklabels(colunas_numericas)
ax.grid(False)
for i in range(len(colunas_numericas)):
    for j in range(len(colunas_numericas)):
        valor = correlacoes.iloc[i, j]
        cor_texto = "white" if valor > 0.55 else "#373A3C"
        ax.text(j, i, f"{valor:.2f}", ha="center", va="center", color=cor_texto, fontsize=8)
fig.colorbar(im, ax=ax, label="correlação")
plt.tight_layout()
plt.show()
Figura 33.1: Correlação entre os preditores numéricos, condominio, total e o alvo aluguel: banheiros se aproxima mais de aluguel do que total, mesmo total sendo, na prática, a soma que inclui aluguel

banheiros correlaciona 0,67 com aluguel, vagas 0,58, quartos 0,54 — todas mais altas que os 0,2645 de total, e nenhuma delas tem uma fórmula que inclua aluguel. Se a regra para achar vazamento fosse “procure a coluna de correlação mais alta com o alvo”, total passaria despercebida, e banheiros chamaria a atenção por um motivo que não tem nada a ver com vazar resposta nenhuma.

O que explica os 0,2645 está na própria matriz: total correlaciona 0,96 com condominio, não com aluguel. condominio é a mesma coluna cujo outlier a seção 6.4 nomeou — as duas linhas de condomínio R$ 1.117.000 — e manteve dentro de alugueis. Removendo só essas duas linhas:

mascara_outlier = alugueis["condominio"] == alugueis["condominio"].max()
sem_duas_linhas = alugueis[~mascara_outlier]
mascara_outlier.sum(), round(sem_duas_linhas["total"].corr(sem_duas_linhas["aluguel"]), 4)
(np.int64(2), np.float64(0.706))

Duas linhas, de 10.692, e a correlação entre total e aluguel sobe de 0,2645 para 0,706. Alargando o corte para o 0,1% de maior condominio — que inclui essas duas linhas mais outros imóveis de condomínio alto que a seção 6.4 não chegou a nomear:

limite = alugueis["condominio"].quantile(0.999)
sem_0_1_porcento = alugueis[alugueis["condominio"] < limite]
outros = alugueis[(alugueis["condominio"] >= limite) & ~mascara_outlier]
len(alugueis) - len(sem_0_1_porcento), len(outros), outros["condominio"].min(), outros["condominio"].max(), round(sem_0_1_porcento["total"].corr(sem_0_1_porcento["aluguel"]), 4)
(11, 9, np.int64(10000), np.int64(220000), np.float64(0.793))

11 imóveis ao todo — os dois já removidos mais nove outros, com condomínio entre R$ 10.000 e R$ 220.000, bem abaixo do valor duplicado —, e a correlação sobe mais uma vez, para 0,793. Nenhuma fórmula mudou nos dois cortes: excluir mais imóveis de condomínio alto desloca ainda mais a correlação entre total e aluguel.

Vazamento não é uma propriedade da correlação — é uma propriedade de como a coluna foi construída. total vaza a resposta porque a fórmula que a gera inclui aluguel como parcela em 9.347 das 10.692 linhas, e isso continua verdade com correlação de 0,2645, de 0,706 ou de 0,793, com o outlier de condominio dentro da tabela ou fora dela. Medir a correlação com o alvo é um jeito de procurar vazamento — não o único, e este caso mostra como ele pode falhar: a coluna mais perigosa da tabela nem aparece entre as mais correlacionadas com o que se quer prever.

"total" in X.columns
False

total segue fora de X — não porque a correlação avise, mas porque a definição da coluna é o motivo, e essa definição não muda de uma linha para outra. O que fazer com vazamento além de excluir a coluna volta mais adiante no material.

X e y, prontos

print(X.shape)
print(y.shape)
(10692, 9)
(10692,)

X chega com 10.692 linhas e nove colunas — quatro números que já vinham prontos, cinco que vieram de uma tradução de texto sem ordem inventada. y chega com as mesmas 10.692 linhas, numa coluna só: o que se quer prever. É a tabela que um modelo espera receber. O que falta agora é o que um modelo é, e como saber se ele presta.