Fase 2 — Wrangling avançado | Pré-requisitos: Aulas 0 a 10 | Duração estimada: 90 minutos


Introdução

A Aula 10 ensinou a identificar e documentar problemas. Esta aula ensina a corrigi-los. Limpeza de dados é a execução das decisões tomadas na avaliação: corrigir tipos, tratar ausentes, remover duplicatas, padronizar categorias, lidar com outliers e validar que as correções funcionaram.

O princípio central desta aula é que limpeza deve ser programática, documentada e validada. Programática porque precisa ser reproduzível. Documentada porque cada decisão tem consequências analíticas. Validada porque é muito fácil introduzir novos problemas ao tentar corrigir os antigos.


Princípios antes do código

Antes de qualquer função pandas, três princípios que evitam erros sérios.

Nunca modifique o arquivo original. Trabalhe sempre com cópias em memória ou salve os dados limpos num arquivo separado. O dado bruto deve ser preservado intacto para que você possa reprocessar do zero se necessário.

Faça uma limpeza por vez, valide, depois avance. Aplicar dez transformações de uma vez dificulta identificar qual delas causou um problema inesperado.

Registre o impacto de cada limpeza. Antes e depois de cada operação, imprima quantas linhas e colunas existem. Isso detecta remoções acidentais imediatamente.

import pandas as pd
import numpy as np

# Carregar dados originais
df_original = pd.read_csv('dados_brutos.csv')

# Trabalhar com uma cópia
df = df_original.copy()

print(f"Início: {df.shape[0]:,} linhas, {df.shape[1]} colunas")

Corrigindo tipos de dados

Tipos incorretos são frequentemente o primeiro problema a corrigir porque afetam todas as operações subsequentes. Uma coluna de data como string impede cálculos de tempo. Uma coluna numérica como object impede cálculos matemáticos.

Convertendo para numérico

# Conversão simples
df['valor'] = pd.to_numeric(df['valor'])

# Com tratamento de erros: coerce converte valores não-numéricos para NaN
# em vez de lançar um erro
df['valor'] = pd.to_numeric(df['valor'], errors='coerce')

# Verificar quantos valores foram perdidos na conversão
n_perdidos = df['valor'].isnull().sum() - df_original['valor'].isnull().sum()
print(f"Valores convertidos para NaN: {n_perdidos}")

# Remover caracteres não numéricos antes de converter
df['preco'] = (df['preco']
    .str.replace('R\$', '', regex=True)
    .str.replace('.', '', regex=False)   # remover separador de milhar
    .str.replace(',', '.', regex=False)  # trocar decimal
    .str.strip()
)
df['preco'] = pd.to_numeric(df['preco'], errors='coerce')

Convertendo para datetime

# Conversão simples (pandas infere o formato)
df['data'] = pd.to_datetime(df['data'])

# Com formato explícito (mais rápido e mais seguro)
df['data'] = pd.to_datetime(df['data'], format='%d/%m/%Y')
df['data'] = pd.to_datetime(df['data'], format='%Y-%m-%d %H:%M:%S')

# Com tratamento de erros
df['data'] = pd.to_datetime(df['data'], errors='coerce')

# Formatos brasileiros comuns
# '%d/%m/%Y'     → 31/12/2024
# '%d/%m/%y'     → 31/12/24
# '%d-%m-%Y'     → 31-12-2024
# '%Y-%m-%d'     → 2024-12-31 (formato ISO, padrão internacional)
# '%d/%m/%Y %H:%M:%S' → 31/12/2024 14:30:00

# Verificar se há datas que não foram parseadas
n_datas_invalidas = df['data'].isnull().sum()
print(f"Datas não parseadas: {n_datas_invalidas}")

# Inspecionar os valores que falharam
mask_falha = df['data'].isnull() & df_original['data'].notnull()
print(df_original.loc[mask_falha, 'data'].value_counts().head(10))

Convertendo para category

# Colunas com poucos valores únicos economizam memória como category
for col in df.select_dtypes(include='object').columns:
    n_unicos = df[col].nunique()
    n_total = len(df)

    # Converter se a coluna tem menos de 5% de valores únicos
    if n_unicos / n_total < 0.05:
        df[col] = df[col].astype('category')
        print(f"'{col}' convertida para category ({n_unicos} categorias)")

# Economiza memória especialmente em datasets grandes
memoria_antes = df_original.memory_usage(deep=True).sum() / 1024**2
memoria_depois = df.memory_usage(deep=True).sum() / 1024**2
print(f"Memória: {memoria_antes:.1f} MB → {memoria_depois:.1f} MB")

Removendo duplicatas

# Ver o tamanho antes
print(f"Antes: {len(df):,} linhas")

# Remover duplicatas exatas
df = df.drop_duplicates()
print(f"Após remover duplicatas exatas: {len(df):,} linhas")

# Remover duplicatas por subconjunto de colunas
# keep='first' mantém a primeira ocorrência (padrão)
# keep='last' mantém a última
# keep=False remove todas as ocorrências
df = df.drop_duplicates(subset=['id_pedido'], keep='first')
print(f"Após remover duplicatas de id_pedido: {len(df):,} linhas")

Para duplicatas de entidade onde os registros têm valores diferentes e você precisa decidir qual manter, a lógica é mais elaborada:

# Exemplo: cliente aparece múltiplas vezes com datas diferentes
# Manter apenas o registro mais recente por cliente
df_clientes_limpo = (df_clientes
    .sort_values('data_atualizacao', ascending=False)
    .drop_duplicates(subset=['id_cliente'], keep='first')
    .reset_index(drop=True)
)

print(f"Clientes únicos antes: {df_clientes['id_cliente'].nunique()}")
print(f"Clientes únicos depois: {len(df_clientes_limpo)}")

Tratando valores ausentes

Esta é a parte mais delicada da limpeza porque a decisão errada pode introduzir viés sistemático. Há quatro estratégias principais: remover, imputar com estatística simples, imputar com lógica de negócio e imputar com modelo.

Remoção

# Remover linhas onde colunas específicas têm ausentes
df_limpo = df.dropna(subset=['id_pedido', 'valor'])

# Remover linhas onde TODAS as colunas são ausentes
df_limpo = df.dropna(how='all')

# Remover colunas com mais de X% de ausentes
threshold = 0.5  # remover colunas com mais de 50% ausentes
df_limpo = df.dropna(axis=1, thresh=int(len(df) * (1 - threshold)))

# Documentar impacto
print(f"Linhas removidas: {len(df) - len(df_limpo):,}")
print(f"Porcentagem removida: {(len(df) - len(df_limpo))/len(df)*100:.1f}%")

Remoção é adequada quando: a ausência é aleatória, a fração de ausentes é pequena (geralmente menos de 5%), e as linhas removidas não têm perfil sistematicamente diferente do restante.

Imputação com estatística simples

# Imputar com a mediana (preferível à média para distribuições assimétricas)
mediana_valor = df['valor'].median()
df['valor'] = df['valor'].fillna(mediana_valor)

# Imputar com a média
df['valor'] = df['valor'].fillna(df['valor'].mean())

# Imputar com a moda (para colunas categóricas)
moda_categoria = df['categoria'].mode()[0]
df['categoria'] = df['categoria'].fillna(moda_categoria)

# Imputar com um valor constante
df['desconto'] = df['desconto'].fillna(0)
df['pais'] = df['pais'].fillna('Brasil')

Imputação por grupo

Imputar com a estatística do grupo ao qual a observação pertence é muito mais preciso do que imputar com a estatística global:

# Imputar salário ausente com a mediana do departamento
df['salario'] = df.groupby('departamento')['salario'].transform(
    lambda x: x.fillna(x.median())
)

# Se ainda houver ausentes após imputação por grupo
# (departamentos onde todos têm salário ausente), imputar com a mediana global
df['salario'] = df['salario'].fillna(df['salario'].median())

# Verificar resultado
print(f"Ausentes após imputação: {df['salario'].isnull().sum()}")

Imputação com lógica de negócio

Frequentemente a melhor estratégia é usar o conhecimento do domínio:

# Desconto ausente para pedidos sem promoção deve ser 0
df.loc[df['codigo_promocao'].isnull(), 'desconto'] = 0

# Data de entrega ausente para pedidos cancelados não precisa ser imputada
# — ausência é informativa e esperada
mask_cancelado = df['status'] == 'cancelado'
# Não imputar data_entrega para cancelados — deixar como NaN é correto

# Categoria ausente para um tipo específico de produto
df.loc[(df['tipo'] == 'servico') & df['categoria'].isnull(), 'categoria'] = 'servicos'

Forward fill e backward fill para séries temporais

# Ordenar por data antes de ffill/bfill
df = df.sort_values('data')

# Forward fill: propagar o último valor conhecido para o próximo ausente
# Útil para séries temporais onde o valor "permanece" até mudar
df['preco'] = df.groupby('id_produto')['preco'].ffill()

# Backward fill: propagar o próximo valor conhecido para trás
df['preco'] = df['preco'].bfill()

# Limitar quantas posições o fill propaga
df['preco'] = df['preco'].ffill(limit=3)  # preenche no máximo 3 posições consecutivas

Criando uma variável indicadora de ausência

Quando a ausência em si é informativa, é melhor criar uma variável binária do que simplesmente imputar:

# Criar coluna indicadora antes de imputar
df['salario_informado'] = df['salario'].notnull().astype(int)

# Agora imputar (sem perder a informação de que estava ausente)
df['salario'] = df['salario'].fillna(df['salario'].median())

Padronizando texto e categorias

Normalização básica

# Maiúsculas/minúsculas e espaços — sempre o primeiro passo
df['cidade'] = df['cidade'].str.lower().str.strip()

# Remover múltiplos espaços internos
df['nome'] = df['nome'].str.replace(r'\s+', ' ', regex=True).str.strip()

# Remover acentos (útil quando há inconsistência de acentuação)
import unicodedata

def remover_acentos(texto):
    if pd.isna(texto):
        return texto
    return unicodedata.normalize('NFKD', str(texto)).encode('ascii', 'ignore').decode('ascii')

df['cidade'] = df['cidade'].apply(remover_acentos)

Mapeamento de categorias inconsistentes

Quando há variações que precisam ser padronizadas, um dicionário de mapeamento é a solução mais explícita e auditável:

# Mapeamento explícito de variações para o valor canônico
mapa_estados = {
    'sp': 'SP', 'são paulo': 'SP', 'sao paulo': 'SP', 's.paulo': 'SP',
    'rj': 'RJ', 'rio de janeiro': 'RJ', 'rio': 'RJ',
    'mg': 'MG', 'minas gerais': 'MG', 'minas': 'MG',
    'rs': 'RS', 'rio grande do sul': 'RS',
}

df['estado'] = df['estado'].str.lower().str.strip().map(mapa_estados)

# Verificar valores que não foram mapeados
nao_mapeados = df[df['estado'].isnull() & df_original['estado'].notnull()]['estado']
if len(nao_mapeados) > 0:
    print("Valores não mapeados:")
    print(nao_mapeados.value_counts())

# Mapeamento de categorias de produto
mapa_categorias = {
    'cama_mesa_banho': 'cama_mesa_banho',
    'cama mesa banho': 'cama_mesa_banho',
    'cama-mesa-banho': 'cama_mesa_banho',
    'beleza_saude': 'beleza_saude',
    'beleza saude': 'beleza_saude',
    'beleza e saude': 'beleza_saude',
}

df['categoria'] = df['categoria'].str.lower().str.strip().map(mapa_categorias)

Extração de informação de campos de texto livre

# Extrair número de um campo de texto
df['numero_nf'] = df['nota_fiscal'].str.extract(r'NF[- ]?(\d+)', expand=False)

# Extrair CEP de um campo de endereço
df['cep'] = df['endereco'].str.extract(r'(\d{5}-?\d{3})', expand=False)

# Extrair domínio de e-mail
df['dominio_email'] = df['email'].str.extract(r'@(.+)$', expand=False)

# Separar nome completo em primeiro e último nome
df[['primeiro_nome', 'ultimo_nome']] = df['nome_completo'].str.split(' ', n=1, expand=True)

Tratando outliers

Outliers não devem ser removidos automaticamente. A decisão depende da natureza do outlier e das perguntas analíticas.

Identificar e inspecionar antes de decidir

def inspecionar_outliers(df, col, fator_iqr=1.5):
    q25 = df[col].quantile(0.25)
    q75 = df[col].quantile(0.75)
    iqr = q75 - q25
    lim_inf = q25 - fator_iqr * iqr
    lim_sup = q75 + fator_iqr * iqr

    outliers = df[(df[col] < lim_inf) | (df[col] > lim_sup)]

    print(f"Coluna: {col}")
    print(f"Limites IQR ({fator_iqr}x): [{lim_inf:.2f}, {lim_sup:.2f}]")
    print(f"Outliers: {len(outliers)} ({len(outliers)/len(df)*100:.2f}%)")
    print(f"Valores extremos: min={df[col].min():.2f}, max={df[col].max():.2f}")

    return outliers

outliers_valor = inspecionar_outliers(df, 'valor')

Opções de tratamento

# Opção 1: Remover outliers
df_sem_outliers = df[(df['valor'] >= lim_inf) & (df['valor'] <= lim_sup)]

# Opção 2: Winsorização — substituir por um percentil extremo
# (mantém o número de linhas mas limita os extremos)
from scipy.stats import mstats

df['valor_winsorizado'] = mstats.winsorize(df['valor'], limits=[0.01, 0.01])
# Substitui os 1% menores pelo valor do percentil 1
# e os 1% maiores pelo valor do percentil 99

# Opção 3: Transformação logarítmica
# (reduz o efeito de outliers em distribuições com cauda longa)
df['valor_log'] = np.log1p(df['valor'])  # log(1+x) para lidar com zeros

# Opção 4: Criar flag e manter
df['outlier_valor'] = ((df['valor'] < lim_inf) | (df['valor'] > lim_sup)).astype(int)
# Analisar com e sem outliers, comparar resultados

# Opção 5: Substituir por NaN e imputar
df.loc[(df['valor'] < lim_inf) | (df['valor'] > lim_sup), 'valor'] = np.nan
df['valor'] = df['valor'].fillna(df['valor'].median())

A winsorização é frequentemente a melhor opção quando os outliers são provavelmente reais mas extremos demais para não distorcer estatísticas. Ela preserva a informação de que o valor era extremo, apenas limitando o quanto ele afeta os cálculos.


Corrigindo inconsistências entre colunas

# Remover linhas com inconsistência lógica impossível
df = df[df['data_entrega'] >= df['data_pedido']]

# Corrigir inconsistência que tem lógica clara
# Se status é 'entregue' mas data_entrega está ausente,
# usar a data máxima conhecida para esse período como proxy
mask_inconsistente = (df['status'] == 'entregue') & df['data_entrega'].isnull()
print(f"Linhas com status=entregue sem data_entrega: {mask_inconsistente.sum()}")
# Decisão: remover essas linhas pois não é possível calcular tempo de entrega
df = df[~mask_inconsistente]

# Derivar uma coluna consistente a partir de outras
df['valor_final'] = np.where(
    df['valor_final'] > df['valor_original'],
    df['valor_original'],  # se valor_final > original, usar original (provavelmente erro)
    df['valor_final']
)

Renomeando colunas

Nomes de colunas limpos tornam o código mais legível e evitam erros de digitação:

# Renomear colunas específicas
df = df.rename(columns={
    'Valor Total (R$)': 'valor_total',
    'Data de Compra': 'data_compra',
    'Nome do Cliente': 'nome_cliente',
    'ID': 'id_pedido'
})

# Padronizar todos os nomes de uma vez
df.columns = (df.columns
    .str.lower()
    .str.strip()
    .str.replace(' ', '_', regex=False)
    .str.replace('[^a-z0-9_]', '', regex=True)  # remover caracteres especiais
)

print("Colunas após limpeza:")
print(df.columns.tolist())

Validação da limpeza

Validar significa verificar que as correções funcionaram e não introduziram novos problemas. É a etapa mais esquecida e uma das mais importantes.

Validações básicas

print("=== Validação pós-limpeza ===\n")

# 1. Dimensões
print(f"Linhas: {len(df):,} (original: {len(df_original):,}, "
      f"removidas: {len(df_original) - len(df):,})")
print(f"Colunas: {df.shape[1]} (original: {df_original.shape[1]})")

# 2. Nenhum ausente nas colunas críticas
colunas_criticas = ['id_pedido', 'data_compra', 'valor', 'status']
for col in colunas_criticas:
    n_ausentes = df[col].isnull().sum()
    status = "OK" if n_ausentes == 0 else f"FALHA — {n_ausentes} ausentes"
    print(f"  {col}: {status}")

# 3. Tipos corretos
assert df['data_compra'].dtype == 'datetime64[ns]', "data_compra deve ser datetime"
assert df['valor'].dtype in ['float64', 'float32'], "valor deve ser float"
assert df['id_pedido'].dtype in ['int64', 'int32'], "id_pedido deve ser int"

# 4. Sem duplicatas
n_dup = df.duplicated(subset=['id_pedido']).sum()
assert n_dup == 0, f"Ainda há {n_dup} id_pedido duplicados"

# 5. Valores dentro dos domínios esperados
assert df['valor'].min() >= 0, "Há valores negativos em 'valor'"
assert df['status'].isin(['entregue', 'cancelado', 'em_transito']).all(), \
    "Há valores inválidos em 'status'"

# 6. Consistência entre colunas
inconsistentes = (df['data_entrega'] < df['data_compra']).sum()
assert inconsistentes == 0, f"Ainda há {inconsistentes} entregas antes da compra"

print("\nTodas as validações passaram.")

Validação de distribuição

Após limpeza, compare as distribuições das variáveis principais com o dataset original para garantir que a limpeza não introduziu viés:

import matplotlib.pyplot as plt

colunas_numericas = ['valor', 'tempo_entrega_dias']

fig, axes = plt.subplots(len(colunas_numericas), 2, figsize=(12, 4 * len(colunas_numericas)))

for i, col in enumerate(colunas_numericas):
    # Antes
    df_original[col].dropna().hist(bins=30, ax=axes[i, 0], color='coral', alpha=0.7)
    axes[i, 0].set_title(f'{col} — ANTES (n={df_original[col].notnull().sum():,})')
    axes[i, 0].axvline(df_original[col].median(), color='black', linestyle='--', label='mediana')
    axes[i, 0].legend()

    # Depois
    df[col].dropna().hist(bins=30, ax=axes[i, 1], color='steelblue', alpha=0.7)
    axes[i, 1].set_title(f'{col} — DEPOIS (n={df[col].notnull().sum():,})')
    axes[i, 1].axvline(df[col].median(), color='black', linestyle='--', label='mediana')
    axes[i, 1].legend()

plt.tight_layout()
plt.show()

# Comparar estatísticas
print("Comparação de estatísticas antes/depois:")
for col in colunas_numericas:
    med_antes = df_original[col].median()
    med_depois = df[col].median()
    variacao = (med_depois - med_antes) / med_antes * 100
    print(f"  {col}: mediana {med_antes:.2f} → {med_depois:.2f} ({variacao:+.1f}%)")

Salvando os dados limpos

# Salvar em CSV
df.to_csv('dados_limpos.csv', index=False, encoding='utf-8')

# Salvar em parquet (mais eficiente para datasets grandes)
df.to_parquet('dados_limpos.parquet', index=False)

# Salvar em Excel
df.to_excel('dados_limpos.xlsx', index=False, sheet_name='dados')

# Para análises futuras: salvar no SQLite local
from sqlalchemy import create_engine
engine = create_engine('sqlite:///analise.db')
df.to_sql('dados_limpos', engine, if_exists='replace', index=False)

print(f"Dados limpos salvos: {len(df):,} linhas, {df.shape[1]} colunas")

Pipeline completo de limpeza

Juntando tudo num pipeline sequencial e documentado:

import pandas as pd
import numpy as np

def limpar_dados_vendas(caminho_arquivo):
    """
    Pipeline completo de limpeza do dataset de vendas.
    Retorna o DataFrame limpo e um dicionário com métricas do processo.
    """
    metricas = {}

    # Carregar
    df = pd.read_csv(caminho_arquivo, encoding='latin-1', sep=';')
    metricas['linhas_originais'] = len(df)
    print(f"Carregado: {len(df):,} linhas")

    # 1. Padronizar nomes de colunas
    df.columns = (df.columns.str.lower().str.strip()
                  .str.replace(' ', '_').str.replace('[^a-z0-9_]', '', regex=True))

    # 2. Corrigir tipos
    df['data_pedido'] = pd.to_datetime(df['data_pedido'], format='%d/%m/%Y', errors='coerce')
    df['data_entrega'] = pd.to_datetime(df['data_entrega'], format='%d/%m/%Y', errors='coerce')
    df['valor'] = pd.to_numeric(
        df['valor'].str.replace('R\$', '', regex=True)
                   .str.replace('.', '', regex=False)
                   .str.replace(',', '.'),
        errors='coerce'
    )

    # 3. Remover duplicatas exatas
    antes = len(df)
    df = df.drop_duplicates()
    metricas['duplicatas_removidas'] = antes - len(df)
    print(f"Duplicatas removidas: {metricas['duplicatas_removidas']}")

    # 4. Remover inconsistências lógicas
    antes = len(df)
    df = df[~(df['data_entrega'] < df['data_pedido'])]
    metricas['inconsistencias_removidas'] = antes - len(df)
    print(f"Inconsistências removidas: {metricas['inconsistencias_removidas']}")

    # 5. Tratar ausentes
    df['categoria'] = df['categoria'].fillna('sem_categoria')
    df['desconto'] = df['desconto'].fillna(0)
    antes = len(df)
    df = df.dropna(subset=['id_pedido', 'valor', 'data_pedido'])
    metricas['ausentes_criticos_removidos'] = antes - len(df)
    print(f"Linhas com ausentes críticos removidas: {metricas['ausentes_criticos_removidos']}")

    # 6. Padronizar categorias
    mapa_categorias = {
        'cama mesa banho': 'cama_mesa_banho',
        'cama-mesa-banho': 'cama_mesa_banho',
        'beleza saude': 'beleza_saude',
        'beleza e saude': 'beleza_saude',
    }
    df['categoria'] = (df['categoria'].str.lower().str.strip()
                       .replace(mapa_categorias))

    # 7. Tratar outliers de valor (winsorização)
    q01 = df['valor'].quantile(0.01)
    q99 = df['valor'].quantile(0.99)
    df['valor'] = df['valor'].clip(lower=q01, upper=q99)

    # Métricas finais
    metricas['linhas_finais'] = len(df)
    metricas['pct_retida'] = len(df) / metricas['linhas_originais'] * 100

    print(f"\nResumo:")
    print(f"  Original: {metricas['linhas_originais']:,} linhas")
    print(f"  Final: {metricas['linhas_finais']:,} linhas")
    print(f"  Retida: {metricas['pct_retida']:.1f}%")

    return df, metricas

df_limpo, metricas = limpar_dados_vendas('dados_brutos.csv')

Resumo

Limpeza executa as decisões da avaliação de forma programática, documentada e validada. Nunca modifique o arquivo original — trabalhe com cópias. Corrija tipos com pd.to_numeric() e pd.to_datetime() usando errors='coerce' para não perder dados por valores inesperados. Remova duplicatas com drop_duplicates(). Trate ausentes escolhendo entre remoção, imputação simples, imputação por grupo ou lógica de negócio conforme o padrão de ausência. Padronize texto com .str.lower().str.strip() e dicionários de mapeamento explícito. Trate outliers escolhendo entre remoção, winsorização, transformação ou flag, conforme a natureza do problema. Valide com assert para colunas críticas e compare distribuições antes e depois. Salve os dados limpos num arquivo separado.

Exercícios

  1. Por que usar pd.to_numeric(col, errors='coerce') em vez de pd.to_numeric(col) ao converter colunas? Qual o risco de cada abordagem?

    ✓ Resposta:

    pd.to_numeric(col) com o comportamento padrão (errors='raise') lança uma exceção se encontrar qualquer valor que não pode ser convertido para número. Isso para a execução e exige que você trate cada valor problemático antes de prosseguir. É útil quando você quer ser alertado imediatamente sobre qualquer valor inesperado, mas em datasets grandes com muitas exceções torna o processo muito trabalhoso.

    pd.to_numeric(col, errors='coerce') converte silenciosamente os valores não-numéricos para NaN em vez de lançar exceção. Isso permite que a conversão prossiga e que você inspecione depois quantos valores foram afetados.

    O risco de errors='raise' é bloquear o processamento por valores que poderiam ser tratados como ausentes. O risco de errors='coerce' é mascarar problemas: se você não verificar quantos valores foram convertidos para NaN, pode não perceber que uma coluna inteira está sendo descartada silenciosamente por um problema de formato.

    A boa prática é usar errors='coerce' seguido de uma verificação explícita:

    n_antes = df['valor'].isnull().sum()
    df['valor'] = pd.to_numeric(df['valor'], errors='coerce')
    n_depois = df['valor'].isnull().sum()
    n_convertidos = n_depois - n_antes
    if n_convertidos > 0:
        print(f"Atenção: {n_convertidos} valores convertidos para NaN")
    
  2. Qual a diferença entre fillna() e imputação por grupo com groupby().transform()? Quando cada uma é mais adequada?

    ✓ Resposta:

    fillna() substitui ausentes por um valor único para toda a coluna — a mediana global, um valor constante, ou o valor anterior/seguinte na série. É rápida e simples, mas usa a mesma referência para todas as linhas independentemente do contexto.

    Imputação por grupo com groupby().transform() substitui ausentes pelo valor calculado dentro do grupo ao qual cada linha pertence. Por exemplo, substitui o salário ausente pela mediana do departamento da linha, não pela mediana global de todos os salários.

    A imputação por grupo é mais adequada quando a variável tem distribuições muito diferentes entre grupos. Se a mediana de salário é R$ 3.000 no departamento operacional e R$ 12.000 no departamento executivo, imputar tudo com a mediana global de R$ 4.500 seria incorreto para ambos os grupos. Imputar pela mediana do departamento preserva a estrutura real dos dados.

    # Imputação global — inadequada quando há grupos com perfis muito diferentes
    df['salario'] = df['salario'].fillna(df['salario'].median())
    
    # Imputação por grupo — preserva a estrutura entre grupos
    df['salario'] = df.groupby('departamento')['salario'].transform(
        lambda x: x.fillna(x.median())
    )
    

    Use fillna() quando os grupos têm distribuições similares ou quando você está imputando um valor constante com lógica de negócio (desconto ausente = 0). Use imputação por grupo quando há segmentos com perfis claramente diferentes.

  3. O que é winsorização e em que situação ela é preferível à remoção de outliers?

    ✓ Resposta:

    Winsorização é a substituição de valores além de um percentil extremo pelo valor exato daquele percentil. Por exemplo, winsorizar ao nível de 1% substitui todos os valores abaixo do percentil 1 pelo valor do percentil 1, e todos os valores acima do percentil 99 pelo valor do percentil 99. O número de linhas não muda — os valores extremos são "limitados", não removidos.

    A winsorização é preferível à remoção quando:

    Os outliers são provavelmente reais — não erros de digitação ou inconsistências — mas extremos demais para não distorcer estatísticas como média e variância. Um valor de venda de R$ 500.000 num dataset onde 99% das vendas são abaixo de R$ 10.000 pode ser legítimo, mas vai inflar a média de forma que não representa o comportamento típico.

    Você precisa manter o número de observações. Análises que dependem do tamanho da amostra — testes estatísticos, modelos de machine learning — se beneficiam de manter todas as linhas.

    Você quer preservar a informação relativa de que o valor era extremo, apenas limitando seu impacto numérico. Com remoção, você perde o registro completamente. Com winsorização, o valor continua presente mas com magnitude controlada.

    # Winsorização com clip() — simples e eficiente
    q01 = df['valor'].quantile(0.01)
    q99 = df['valor'].quantile(0.99)
    df['valor_winsorizado'] = df['valor'].clip(lower=q01, upper=q99)
    
  4. Por que é importante comparar as distribuições antes e depois da limpeza? O que você está procurando nessa comparação?

    ✓ Resposta:

    A comparação de distribuições antes e depois é a validação mais importante do processo de limpeza porque detecta se as transformações introduziram viés sistemático.

    O que você está procurando:

    Mudanças grandes na mediana ou média. Se a mediana do valor de vendas era R$ 450 antes e ficou R$ 380 depois da limpeza, alguma decisão removeu seletivamente linhas de valores mais altos. Isso pode ser intencional (remover outliers) ou acidental (um filtro muito agressivo).

    Mudanças na forma da distribuição. Se a distribuição era assimétrica à direita antes e ficou simétrica depois, você pode ter removido exatamente a cauda que era o fenômeno de interesse.

    Redução excessiva de observações. Remover mais de 10% a 15% das linhas sem uma justificativa muito clara é um sinal de alerta. Cada linha removida é informação descartada.

    Aparecimento de picos artificiais. Imputação com a mediana cria um pico artificial no valor da mediana. Se você imputou 30% de uma coluna com o mesmo valor, o histograma vai ter um pico óbvio que não reflete a realidade dos dados.

    # Verificação rápida de mudanças nas estatísticas principais
    for col in ['valor', 'tempo_entrega_dias']:
        print(f"\n{col}:")
        print(f"  N: {df_original[col].notnull().sum():,} → {df[col].notnull().sum():,}")
        print(f"  Mediana: {df_original[col].median():.2f} → {df[col].median():.2f}")
        print(f"  Média: {df_original[col].mean():.2f} → {df[col].mean():.2f}")
        print(f"  Std: {df_original[col].std():.2f} → {df[col].std():.2f}")
    
  5. Escreva o código para um pipeline de limpeza de uma tabela de clientes com as seguintes colunas: id_cliente, nome, email, data_nascimento, cidade, estado, renda_mensal. O pipeline deve: padronizar nomes de colunas, corrigir tipos, remover duplicatas por id_cliente mantendo o registro mais recente, tratar ausentes em renda_mensal com a mediana do estado, padronizar estado para sigla em maiúsculas de dois caracteres, e validar o resultado.

    ✓ Resposta:
    import pandas as pd
    import numpy as np
    
    def limpar_clientes(df_input):
        df = df_input.copy()
        print(f"Início: {len(df):,} linhas\n")
    
        # 1. Padronizar nomes de colunas
        df.columns = (df.columns.str.lower().str.strip()
                      .str.replace(' ', '_').str.replace('[^a-z0-9_]', '', regex=True))
        print(f"Colunas: {df.columns.tolist()}")
    
        # 2. Corrigir tipos
        df['data_nascimento'] = pd.to_datetime(df['data_nascimento'], errors='coerce')
        df['renda_mensal'] = pd.to_numeric(
            df['renda_mensal'].astype(str)
                .str.replace('R\$', '', regex=True)
                .str.replace('.', '', regex=False)
                .str.replace(',', '.')
                .str.strip(),
            errors='coerce'
        )
        print(f"Tipos corrigidos")
    
        # 3. Remover duplicatas por id_cliente, mantendo o mais recente
        # Assumindo que há uma coluna de data de atualização; se não houver, usar posição
        if 'data_atualizacao' in df.columns:
            df = (df.sort_values('data_atualizacao', ascending=False)
                    .drop_duplicates(subset=['id_cliente'], keep='first'))
        else:
            df = df.drop_duplicates(subset=['id_cliente'], keep='last')
        print(f"Após deduplicação: {len(df):,} linhas")
    
        # 4. Padronizar estado para sigla maiúscula de 2 caracteres
        mapa_estados = {
            'são paulo': 'SP', 'sao paulo': 'SP', 'sp': 'SP',
            'rio de janeiro': 'RJ', 'rio': 'RJ', 'rj': 'RJ',
            'minas gerais': 'MG', 'minas': 'MG', 'mg': 'MG',
            'bahia': 'BA', 'ba': 'BA',
            'paraná': 'PR', 'parana': 'PR', 'pr': 'PR',
            'rio grande do sul': 'RS', 'rs': 'RS',
            'pernambuco': 'PE', 'pe': 'PE',
            'ceará': 'CE', 'ceara': 'CE', 'ce': 'CE',
            'goiás': 'GO', 'goias': 'GO', 'go': 'GO',
            'santa catarina': 'SC', 'sc': 'SC',
        }
        df['estado_normalizado'] = df['estado'].str.lower().str.strip()
        df['estado'] = df['estado_normalizado'].map(mapa_estados)
    
        nao_mapeados = df['estado'].isnull().sum()
        if nao_mapeados > 0:
            print(f"Estados não mapeados: {nao_mapeados}")
            print(df[df['estado'].isnull()]['estado_normalizado'].value_counts())
        df = df.drop(columns=['estado_normalizado'])
    
        # 5. Tratar ausentes em renda_mensal com mediana do estado
        df['renda_mensal'] = df.groupby('estado')['renda_mensal'].transform(
            lambda x: x.fillna(x.median())
        )
        # Ausentes restantes (estados onde todos têm renda ausente): mediana global
        df['renda_mensal'] = df['renda_mensal'].fillna(df['renda_mensal'].median())
    
        print(f"Ausentes em renda_mensal após imputação: {df['renda_mensal'].isnull().sum()}")
    
        # 6. Validação
        print("\n=== Validação ===")
    
        assert df['id_cliente'].duplicated().sum() == 0, \
            "Ainda há id_cliente duplicados"
        print("OK — sem duplicatas de id_cliente")
    
        assert df['renda_mensal'].isnull().sum() == 0, \
            "Ainda há ausentes em renda_mensal"
        print("OK — sem ausentes em renda_mensal")
    
        assert df['renda_mensal'].min() >= 0, \
            "Há rendas negativas"
        print("OK — sem rendas negativas")
    
        estados_invalidos = ~df['estado'].isin([
            'AC','AL','AP','AM','BA','CE','DF','ES','GO','MA','MT','MS',
            'MG','PA','PB','PR','PE','PI','RJ','RN','RS','RO','RR','SC',
            'SP','SE','TO'
        ]) & df['estado'].notnull()
        assert estados_invalidos.sum() == 0, \
            f"Há {estados_invalidos.sum()} estados com sigla inválida"
        print("OK — todos os estados são siglas válidas")
    
        print(f"\nFinal: {len(df):,} linhas, {df.shape[1]} colunas")
        return df
    
    # Uso
    df_clientes_limpo = limpar_clientes(df_clientes_bruto)
    df_clientes_limpo.to_csv('clientes_limpos.csv', index=False, encoding='utf-8')
    

Referências

  • McKinney, Wes. Python for Data Analysis, 3ª edição. O'Reilly, 2022. Capítulos 7 e 8 cobrem limpeza de dados com pandas em profundidade, incluindo tratamento de ausentes, duplicatas e operações de string.
  • Documentação do pandas — Working with text data: pandas.pydata.org/docs/user_guide/text.html Referência completa do acessor `.str` com todos os métodos disponíveis.
  • Documentação do pandas — Working with missing data: pandas.pydata.org/docs/user_guide/missing_data.html Cobre todos os métodos de detecção e preenchimento de ausentes.
  • Van Buuren, Stef. Flexible Imputation of Missing Data, 2ª edição. Chapman & Hall, 2018. O livro de referência sobre imputação múltipla e estratégias avançadas para dados ausentes. Disponível gratuitamente em stefvanbuuren.name/fimd.
  • Tukey, John W. Exploratory Data Analysis. Addison-Wesley, 1977. O livro que introduziu a winsorização como técnica formal de tratamento de outliers e o boxplot como ferramenta de detecção.
  • Scipy.stats documentation — mstats.winsorize: docs.scipy.org/doc/scipy/reference/generated/scipy.stats.mstats.winsorize.html Referência da função de winsorização da biblioteca scipy.