Capítulo 10, Básico
Duplicatas, tipos e nomes
Os problemas deste capítulo não dão erro: o Pandas lê tudo sem reclamar, e as contas rodam. Limpar é descobrir o que está silenciosamente errado e corrigir com o mínimo de perda.
Duplicatas
Há duas perguntas diferentes: existem linhas inteiramente repetidas, e existem identificadores repetidos? O duplicated() compara todas as colunas por padrão, e o subset restringe a comparação:
import numpy as np
import pandas as pd
pd.set_option("display.width", 170)
pd.set_option("display.max_columns", 20)
df = pd.read_csv("dados/funcionarios_15.csv")
print(int(df.duplicated().sum()), int(df.duplicated(subset=["id_funcionario"]).sum()))
colunas = ["id_funcionario", "nome", "departamento", "experiencia", "data_admissao"]
print(df.loc[df.duplicated("id_funcionario", keep=False), colunas])
0 1
id_funcionario nome departamento experiencia data_admissao
0 101 Ana Souza Engenharia 1 2021-01-10
14 101 Ana Souza RH 7 2023-06-24
Nenhuma linha é inteiramente repetida (0), mas um identificador aparece duas vezes (1). A mesma funcionária foi cadastrada duas vezes, e com campos atualizados na segunda: o departamento mudou, e a experiência também. Por isso a comparação de linhas inteiras não achou nada.
O drop_duplicates precisa saber qual manter, e a escolha importa:
primeiro = df.drop_duplicates(subset=["id_funcionario"], keep="first")
ultimo = df.drop_duplicates(subset=["id_funcionario"], keep="last")
print(len(primeiro), len(ultimo))
print(primeiro.loc[primeiro["id_funcionario"] == 101, "departamento"].item())
print(ultimo.loc[ultimo["id_funcionario"] == 101, "departamento"].item())
14 14
Engenharia
RH
O keep="first" fica com o primeiro cadastro (Engenharia), e o keep="last" fica com o mais recente (RH). Se o segundo registro é uma atualização, o certo é guardar o mais novo, e não o primeiro que apareceu. O curso original mantém o primeiro, e eu sigo isso nos próximos capítulos para os números baterem com o curso, mas em dados reais eu ordeno por data antes de remover, e fico com o último.
Tipos
A coluna salario é str, porque duas linhas têm R$ 4650 e R$ 5550. Há dois caminhos para convertê-la em número. O to_numeric(errors="coerce") converte o que consegue e transforma o resto em NaN, e isso destrói os dois salários escritos com R$:
direto = pd.to_numeric(df["salario"], errors="coerce")
print(int(df["salario"].isna().sum()), int(direto.isna().sum()))
sem_simbolo = df["salario"].str.replace("R$ ", "", regex=False)
certo = pd.to_numeric(sem_simbolo)
print(int(certo.isna().sum()), certo.dtype)
2 4
2 float64
Os ausentes de verdade eram 2. Com o coerce direto, viraram 4: perdemos 2 salários que existiam, só que escritos de um jeito diferente. Primeiro tirar o símbolo e depois converter (sem coerce) preserva tudo. E sem o coerce, um valor que realmente não é número para a execução com uma mensagem clara:
try:
pd.to_numeric(pd.Series(["10", "abc"]))
except ValueError as erro:
print("ValueError:", erro)
ValueError: Unable to parse string "abc" at position 1
O errors="coerce" é útil, mas é uma decisão de descartar dados. Eu o uso depois de conferir quantos valores ele vai transformar em NaN, e não como primeira tentativa.
O `astype(int)` e o ausente
Converter uma coluna com ausentes para inteiro falha, porque o NaN não é inteiro. O tipo anulável Int64 resolve:
try:
df["idade"].astype(int)
except ValueError as erro:
print(type(erro).__name__)
print(df["idade"].astype("Int64").tolist()[4:8])
IntCastingNaNError
[28, 29, <NA>, 31]
Datas
Uma data guardada como texto não serve para nada: não ordena por tempo, não subtrai, não extrai o ano. O to_datetime a transforma em um tipo de data de verdade, e o acessor .dt dá as peças:
datas = pd.to_datetime(df["data_admissao"])
print(datas.dtype, datas.dt.year.tolist()[:5])
print(datas.min().date(), datas.max().date())
datetime64[us] [2021, 2022, 2023, 2024, 2021]
2021-01-10 2024-08-17
Nomes de colunas
O rename devolve uma tabela nova e deixa a original intacta (não precisa de inplace). Para limpar todos os nomes de uma vez, encadeie os métodos de texto. Em português, tirar os acentos evita muita dor de cabeça com nomes de colunas, e o normalize faz isso:
renomeada = df.rename(columns={"nome": "nome_completo"})
print(renomeada.columns.tolist()[:3], df.columns.tolist()[:3])
sujas = pd.Series([" Nome Completo ", "Salário (R$)", "Data de Admissão"])
limpas = (
sujas.str.strip()
.str.lower()
.str.normalize("NFKD")
.str.encode("ascii", errors="ignore")
.str.decode("ascii")
.str.replace(r"[^a-z0-9]+", "_", regex=True)
.str.strip("_")
)
print(limpas.tolist())
['id_funcionario', 'nome_completo', 'departamento'] ['id_funcionario', 'nome', 'departamento']
['nome_completo', 'salario_r', 'data_de_admissao']
O normalize("NFKD") separa cada letra acentuada em letra mais acento, e o encode("ascii", errors="ignore") descarta tudo o que não é ASCII (os acentos soltos), de modo que Salário vira Salario. Eu escolhi esse caminho de propósito, em vez de uma expressão regular que apague os acentos: com o pyarrow instalado, o tipo str do Pandas 3 usa o motor de expressões regulares do Arrow, que aceita \p{M} mas rejeita \u0300, e o módulo re do Python faz o contrário. O mesmo padrão pode funcionar em uma máquina e falhar em outra. O capítulo 17 volta a isso.
Texto inconsistente
O departamento tem engenharia e MARKETING ao lado de Engenharia e Marketing. A solução comum é strip().str.title(), mas o title() estraga siglas e preposições:
print(df["departamento"].str.strip().str.title().dropna().unique().tolist())
['Engenharia', 'Marketing', 'Ciência De Dados', 'Rh', 'Finanças']
Repare em Rh (a sigla perdeu a forma) e em Ciência De Dados (o De ganhou maiúscula). Em colunas de categorias conhecidas, o caminho mais seguro é um dicionário com os nomes corretos, aplicado com o map. E ele traz um bônus: o que não está no dicionário vira NaN, o que permite detectar valores inesperados:
CANONICO = {
"engenharia": "Engenharia",
"marketing": "Marketing",
"finanças": "Finanças",
"ciência de dados": "Ciência de Dados",
"rh": "RH",
}
limpo = df["departamento"].str.strip().str.lower().map(CANONICO)
inesperados = df.loc[limpo.isna() & df["departamento"].notna(), "departamento"]
print(limpo.value_counts(dropna=False).to_dict())
print(len(inesperados))
{'Marketing': 4, 'Engenharia': 3, 'RH': 3, 'Ciência de Dados': 2, 'Finanças': 2, nan: 1}
0
Agora são 5 departamentos (mais 1 ausente), e nenhum valor inesperado: o que não estava no dicionário seria mostrado em inesperados. Se amanhã chegar Tecnologia em um arquivo novo, ele vira NaN e você é avisado, em vez de ganhar um sexto departamento em silêncio.
Exercício 1
Limpar o salário
Escreva limpar_salario(serie), que receba uma Series de texto com valores como "R$ 4650", "4800" e ausentes, e devolva uma Series de números (float64), sem perder nenhum valor que exista.