Lendo e Tipando um Arquivo Real

Autor

Douglas Braga

A seção anterior fechou avisando que estados não tinha um valor faltante sequer, e prometeu trocar esse arquivo limpo por um que chega direto de uma fonte real. É o que este arquivo faz: um conjunto de anúncios de aluguel de imóveis em cinco cidades brasileiras, lido sem nenhuma limpeza prévia — exatamente como ele chegaria de um raspador ou de uma exportação de planilha.

Uma primeira leitura

alugueis = pd.read_csv("dados/alugueis.csv")
alugueis.shape
(10692, 13)

10.692 imóveis, 13 colunas. head() mostra o que cada linha descreve:

alugueis.head()
cidade area_m2 quartos banheiros vagas andar aceita_animal mobiliado condominio aluguel iptu seguro_incendio total
0 São Paulo 70 2 1 1 7 sim sim 2065 3300 211 42 5618
1 São Paulo 320 4 4 0 20 sim não 1200 4960 1750 63 7973
2 Porto Alegre 80 1 1 1 6 sim não 1000 2800 0 41 3841
3 Porto Alegre 51 2 1 0 2 sim não 270 1112 22 17 1421
4 São Paulo 25 1 1 0 1 não não 0 800 25 11 836

Cada linha é um anúncio: a cidade, a área em metros quadrados, quantos quartos, banheiros e vagas de garagem, o andar, se aceita animal, se vem mobiliado, e cinco valores em reais — condomínio, aluguel, IPTU, seguro-incêndio e o total.

Uma coluna fora do padrão

Antes de qualquer conta, dtypes — o mesmo primeiro passo da seção 6.1:

alugueis.dtypes
cidade             object
area_m2             int64
quartos             int64
banheiros           int64
vagas               int64
andar              object
aceita_animal      object
mobiliado          object
condominio          int64
aluguel             int64
iptu                int64
seguro_incendio     int64
total               int64
dtype: object

Doze colunas vieram como o esperado: nove numéricas (de area_m2 a vagas e de condominio a total, todas contagens ou valores em reais) e três como object porque são texto de verdade — cidade é um nome de cidade, aceita_animal e mobiliado são “sim” ou “não”. andar é a única que destoa: representa um número — o andar do imóvel —, e mesmo assim veio como object, junto dos textos.

O estrago, antes da causa

Um número de andar deveria admitir média. Tentar calcular uma mostra o problema na prática, não em teoria:

try:
    alugueis["andar"].mean()
except TypeError as erro:
    print("TypeError:", str(erro)[:90], "...")
    print("caracteres na mensagem completa:", len(str(erro)))
TypeError: Could not convert string '720621-743221011224-198--1718-710511-56311108111--620-7511013212 ...
caracteres na mensagem completa: 12722

mean() nem chega a rodar: object é o tipo genérico que o pandas usa para texto, e texto não tem média. A mensagem completa, se fosse impressa inteira, traria a coluna inteira concatenada num único bloco de 12.722 caracteres — por isso o truncamento acima, e não por economia de espaço.

Onde está o problema

unique() mostra os primeiros valores distintos da coluna:

alugueis["andar"].unique()[:10]
array(['7', '20', '6', '2', '1', '-', '4', '3', '10', '11'], dtype=object)

Entre '7', '20', '6' e outros números escritos como texto, aparece um '-'. Contando quantas linhas trazem exatamente esse valor:

tracos = (alugueis["andar"] == "-").sum()
tracos, round(100 * tracos / len(alugueis))
(np.int64(2461), 23)

2.461 das 10.692 linhas — 23% do arquivo — têm "-" em vez de um número de andar. Basta uma linha assim para que a coluna inteira vire object: o pandas decide o tipo de uma coluna olhando todos os valores dela, e uma coluna não pode ser meio int64, meio texto.

Por que o pandas não pegou sozinho

O read_csv já reconhece um conjunto de marcadores de valor ausente — NaN, NA, null, n/a, campo vazio, entre outros — e os converte direto em nulo, sem que ninguém peça. "-" não está nessa lista. Para o pandas, "-" é um caractere como outro qualquer, e uma coluna com um único valor não numérico entre milhares de números vira object inteira, sem aviso.

A inferência de tipo do pandas é uma heurística sobre os caracteres do arquivo, não um contrato sobre o que a coluna significa. Ela acerta quando todo valor é uma forma reconhecível de número, texto ou marcador de nulo já catalogado — e erra silenciosamente quando um valor foge desse catálogo, mesmo sendo óbvio para quem lê a coluna que "-" ali significa “sem informação”. Conferir dtypes logo depois de ler o arquivo é o que transforma esse silêncio em alguma coisa visível antes de qualquer conta.

Dois consertos, para duas situações

Quando se sabe qual é o marcador, na_values ensina o read_csv a reconhecê-lo já na leitura:

alugueis_tipado = pd.read_csv("dados/alugueis.csv", na_values=["-"])
alugueis_tipado["andar"].dtype, alugueis_tipado["andar"].isna().sum(), round(alugueis_tipado["andar"].mean(), 2)
(dtype('float64'), np.int64(2461), np.float64(6.58))

Com "-" na lista de nulos, andar vira float64 — os 2.461 traços viram NaN, e a média dos demais 8.231 valores sai 6,58.

Quando não se sabe de antemão qual marcador vai aparecer, pd.to_numeric com errors="coerce" faz o trabalho na direção contrária: converte o que consegue e transforma tudo o que não é número em nulo, seja "-", seja qualquer outro caractere estranho que o arquivo trouxer.

andar_coerced = pd.to_numeric(alugueis["andar"], errors="coerce")
andar_coerced.dtype, andar_coerced.isna().sum()
(dtype('float64'), np.int64(2461))

O resultado bate com o de na_values neste arquivo — os mesmos 2.461 nulos —, porque "-" é o único valor fora do padrão numérico. Mas to_numeric é mais forte e mais perigoso do que parece: ele converte em silêncio qualquer valor que não reconheça, inclusive um erro de digitação que na_values=["-"] deixaria passar direto como texto visível, pronto para ser notado. Usar na_values é dizer “eu sei o que este marcador significa”; usar to_numeric(errors="coerce") é dizer “eu não sei o que vou encontrar, e prefiro perder o que não for número a travar a conversão inteira”.

Uma coluna em que category compensa

A seção 6.1 converteu sigla em category e mediu que o consumo de memória piorava — 27 valores, todos distintos, sem repetição para amortizar. cidade pode ser o caso oposto, se poucos valores se repetirem por muitas linhas. Antes de converter, vale medir o consumo de memória como está:

antes_cidade = alugueis["cidade"].memory_usage(deep=True)
antes_cidade
730981

730.981 bytes para uma coluna de texto. Quantos valores distintos existem por trás desse número:

alugueis["cidade"].nunique()
5

Só cinco cidades, repetidas pelas 10.692 linhas — a proporção entre valores distintos e linhas é o inverso exato do caso de sigla. Convertendo e medindo de novo:

depois_cidade = alugueis["cidade"].astype("category").memory_usage(deep=True)
antes_cidade, depois_cidade, round(depois_cidade / antes_cidade * 100)
(730981, 11325, 2)

De 730.981 bytes como object para 11.325 bytes como category — 2% do tamanho original. Aqui a repetição é o que faz o category compensar: em vez de 10.692 ponteiros de texto, a coluna guarda cinco categorias e um array de códigos pequenos apontando para elas. É o mesmo mecanismo da 6.1, com o resultado invertido porque a proporção entre valores distintos e linhas também está invertida.

A cauda que o aluguel esconde

Resolvido o problema de andar, vale olhar o que a distribuição do próprio aluguel revela:

fig, ax = plt.subplots()
ax.hist(alugueis["aluguel"], bins=60)
ax.set_xlabel("aluguel (R$)")
ax.set_ylabel("imóveis")
plt.tight_layout()
plt.show()
Figura 30.1: Distribuição do aluguel: a maioria dos imóveis se concentra abaixo de 10 mil reais, mas a cauda longa chega a 45 mil
alugueis["aluguel"].median(), alugueis["aluguel"].max()
(np.float64(2661.0), np.int64(45000))

A mediana fica em R$ 2.661, e a barra mais alta do histograma mora perto dela — mas a cauda à direita se estica até R$ 45.000, um único imóvel bem separado do resto. Uma coluna corretamente tipada não garante uma distribuição bem comportada: o aluguel já é int64 desde a primeira leitura, e ainda assim traz um outlier — um valor tão distante do resto que se destaca da distribuição — que qualquer estatística resumida esconderia. É esse tipo de problema — não mais o tipo da coluna, mas o formato dos valores dentro dela — que a próxima seção enfrenta.