Capítulo 16, Intermediário
Dados ausentes, em profundidade
Preencher tudo com a mediana global é o primeiro reflexo, e quase sempre há uma resposta melhor. Aqui entram o preenchimento por grupo, a interpolação e os tipos que guardam o ausente sem converter nada.
O conjunto maior
Daqui até o fim do curso, o conjunto padrão é o funcionarios_100.csv: 104 linhas, mais sujas que as 15 do nível Básico (ids repetidos, mais ausentes, datas em dois formatos). Uma função reúne a limpeza dos capítulos 10 e 11, e o último cadastro de cada id vence, porque os repetidos do arquivo são atualizações:
import numpy as np
import pandas as pd
pd.set_option("display.width", 170)
pd.set_option("display.max_columns", 20)
CANONICO = {"engenharia": "Engenharia", "marketing": "Marketing", "finanças": "Finanças",
"ciência de dados": "Ciência de Dados", "rh": "RH"}
def carregar_100() -> pd.DataFrame:
df = pd.read_csv("dados/funcionarios_100.csv")
df["salario"] = pd.to_numeric(df["salario"].str.replace("R$ ", "", regex=False))
df["departamento"] = df["departamento"].str.strip().str.lower().map(CANONICO)
return df.drop_duplicates("id_funcionario", keep="last").reset_index(drop=True)
df = carregar_100()
print(df.shape)
print(df.isna().sum()[lambda s: s > 0].to_dict())
(100, 10)
{'departamento': 3, 'cidade': 5, 'idade': 4, 'salario': 7}
Preencher por grupo
A mediana global trata um engenheiro e uma pessoa do RH como iguais. Mas o salário depende do departamento, e a mediana do próprio grupo é uma estimativa muito melhor. O transform devolve, para cada linha, a mediana do departamento dela, e o fillna aceita essa Series inteira:
ausentes = df["salario"].isna()
global_ = df["salario"].fillna(df["salario"].median())
por_depto = df.groupby("departamento")["salario"].transform("median")
do_grupo = df["salario"].fillna(por_depto)
print(int(ausentes.sum()), round(float(global_[ausentes].mean())), round(float(do_grupo[ausentes].mean())))
print(int(do_grupo.isna().sum()))
7 9110 8911
0
As estimativas diferem: quem estava sem salário recebeu, em média, valores diferentes pelos dois métodos (a mediana global puxa todo mundo para o mesmo número). Neste conjunto, nenhuma linha sobrou ausente (o 0), mas isso é sorte do conjunto. Basta alguém sem salário e sem departamento para a conta falhar, porque ele não pertence a nenhum grupo (o groupby descarta a chave ausente, como vimos no capítulo 13):
pequeno = pd.DataFrame({"depto": ["A", "A", "B", None], "salario": [10.0, np.nan, 20.0, np.nan]})
por_grupo = pequeno.groupby("depto")["salario"].transform("median")
print(pequeno["salario"].fillna(por_grupo).tolist())
print(pequeno["salario"].fillna(por_grupo).fillna(pequeno["salario"].median()).tolist())
[10.0, 10.0, 20.0, nan]
[10.0, 10.0, 20.0, 15.0]
O primeiro resultado preencheu o A (com 10), mas deixou o último ausente. O segundo passo, com a mediana global como reserva, fecha o buraco. Por isso o padrão completo tem sempre dois passos: o grupo e, depois, a reserva.
Interpolar
Em dados com ordem (medições, preços ao longo dos dias), o interpolate estima o valor que falta traçando uma reta entre os vizinhos, o que é mais fiel do que repetir o valor anterior:
medicoes = pd.Series([10.0, np.nan, np.nan, 16.0, np.nan, 20.0])
print(medicoes.interpolate().tolist())
print(medicoes.ffill().tolist())
print(medicoes.interpolate(limit=1).tolist())
[10.0, 12.0, 14.0, 16.0, 18.0, 20.0]
[10.0, 10.0, 10.0, 16.0, 16.0, 20.0]
[10.0, 12.0, nan, 16.0, 18.0, 20.0]
O limit=1 impede que a estimativa atravesse um buraco longo: preencher dois dias seguidos sem dado é aceitável, e preencher duas semanas é inventar.
Descartar com critério
O dropna(thresh=n) mantém as linhas com pelo menos n valores preenchidos, o que é mais fino que "qualquer ausente" e que "todos ausentes":
print(len(df), len(df.dropna()), len(df.dropna(thresh=9)), len(df.dropna(thresh=10)))
completas = df.notna().all(axis=1).mean()
print(round(float(completas), 2))
100 81 100 81
0.81
Tipos que guardam o ausente
Como vimos no capítulo 3, o NaN obriga uma coluna de inteiros a virar decimal. Os tipos anuláveis (Int64, Float64, boolean, string) têm o seu próprio marcador, o pd.NA, e mantêm o tipo:
idade = df["idade"].astype("Int64")
print(idade.dtype, idade.isna().sum(), idade.head(3).tolist())
print(df[["idade", "salario"]].convert_dtypes().dtypes.to_dict())
Int64 4 [32, 37, 36]
{'idade': Int64Dtype(), 'salario': Int64Dtype()}
O convert_dtypes() escolhe o tipo anulável mais adequado para cada coluna. O pd.NA tem uma lógica própria, mais rigorosa que a do NaN:
print(pd.NA + 1, pd.NA == 1, True | pd.NA, False & pd.NA)
try:
bool(pd.NA)
except TypeError as erro:
print("TypeError:", erro)
<NA> <NA> True False
TypeError: boolean value of NA is ambiguous
Uma conta com pd.NA é NA. Uma comparação com ele é NA. Mas True | NA é verdadeiro, porque o resultado não depende do desconhecido, e False & NA é falso pelo mesmo motivo. E usar um NA como condição de um if é um erro explícito, em vez de decidir sozinho: o Pandas se recusa a adivinhar.
| Situação | O que eu faço |
|---|---|
| Número cujo ausente atrapalha a média | Mediana do grupo, depois a global como reserva |
| Série com ordem (tempo) | interpolate com limit, ou ffill |
| A ausência pode ser informação | Coluna indicadora *_informada antes de preencher |
| Inteiros com ausentes | Int64 (anulável), e não float64 |
| Linhas com poucos dados | dropna(thresh=...) |
Exercício 1
Preencher por grupo, com reserva
Escreva preencher_por_grupo(tabela, coluna, grupo), que preencha os ausentes de coluna com a mediana do grupo e, quando o grupo também faltar, com a mediana global. Confira que não sobra nenhum ausente e que a tabela original não mudou.