Pular para o conteúdo

    Capítulo 21, Intermediário

    Combinar tabelas: merge e join

    Os pedidos têm o código do cliente, e os dados do cliente estão em outra tabela. O `merge` encontra as linhas que combinam, e o tipo de junção decide o que acontece com as que não combinam: é onde mais se perdem e se multiplicam linhas sem querer.

    As três tabelas

    O modelo de vendas tem três tabelas: clientes (60), produtos (20) e pedidos (800), ligadas pelos códigos id_cliente e id_produto. Os pedidos têm uma peculiaridade escondida: alguns pedidos citam um cliente que não existe na tabela de clientes.

    intermediario/cap21_merge_join.pylinhas 10 a 19
    import numpy as np
    import pandas as pd
    
    pd.set_option("display.width", 170)
    pd.set_option("display.max_columns", 20)
    
    clientes = pd.read_csv("dados/clientes.csv")
    produtos = pd.read_csv("dados/produtos.csv")
    pedidos = pd.read_csv("dados/pedidos.csv", parse_dates=["data_pedido"])
    print(clientes.shape, produtos.shape, pedidos.shape)
    
    Saída
    (60, 5) (20, 4) (800, 7)
    

    Os tipos de junção

    Cada tipo decide o que fazer com as linhas sem par:

    how=Mantém
    inner (padrão)Só as linhas com par nos dois lados
    leftTodas as da esquerda, com NaN onde não há par
    rightTodas as da direita
    outerTodas, dos dois lados
    intermediario/cap21_merge_join.pylinhas 24 a 26
    interna = pedidos.merge(clientes, on="id_cliente")
    esquerda = pedidos.merge(clientes, on="id_cliente", how="left")
    print(len(pedidos), len(interna), len(esquerda))
    
    Saída
    800 788 800
    

    A junção interna devolveu 788 pedidos, e não 800: os 12 pedidos de clientes inexistentes sumiram em silêncio. Em um relatório de vendas, isso é dinheiro faltando. A junção left mantém os 800. O parâmetro indicator=True diz de onde veio cada linha, e permite achar os órfãos:

    intermediario/cap21_merge_join.pylinhas 28 a 31
    conferencia = pedidos.merge(clientes, on="id_cliente", how="left", indicator=True)
    print(conferencia["_merge"].value_counts().to_dict())
    orfaos = conferencia.loc[conferencia["_merge"] == "left_only", "id_cliente"].unique()
    print(sorted(int(x) for x in orfaos))
    
    Saída
    {'both': 788, 'left_only': 12, 'right_only': 0}
    [61, 62, 63, 64, 65]
    

    Aqui, 12 pedidos apontam para os códigos 61 a 65, que não existem. A regra que eu sigo: depois de qualquer junção, conferir o número de linhas (e, de preferência, descobrir o porquê de cada diferença).

    O perigo maior: a chave duplicada multiplica linhas

    Se a chave da tabela da direita não for única, cada linha da esquerda é copiada para cada par. As linhas se multiplicam, e os totais ficam inflados, sem nenhum erro:

    intermediario/cap21_merge_join.pylinhas 36 a 38
    clientes_com_repeticao = pd.concat([clientes, clientes.head(3)])
    multiplicada = pedidos.merge(clientes_com_repeticao, on="id_cliente")
    print(len(interna), len(multiplicada))
    
    Saída
    788 821
    

    Três clientes repetidos transformaram 788 linhas em 821. O parâmetro validate verifica a relação esperada e falha se ela não valer. Aqui, a relação é "muitos pedidos para um cliente" (m:1):

    intermediario/cap21_merge_join.pylinhas 40 a 46
    from pandas.errors import MergeError
    
    try:
        pedidos.merge(clientes_com_repeticao, on="id_cliente", validate="m:1")
    except MergeError as erro:
        print("MergeError:", str(erro).splitlines()[0])
    print(len(pedidos.merge(clientes, on="id_cliente", validate="m:1")))
    
    Saída
    MergeError: Merge keys are not unique in right dataset; not a many-to-one merge
    788
    

    Escrever o validate em toda junção é o hábito que mais evita o erro de totais inflados.

    Chaves com nomes ou tipos diferentes

    Quando a chave tem nomes diferentes nas duas tabelas, use left_on e right_on. Quando as colunas não chave têm o mesmo nome, o Pandas as distingue com sufixos:

    intermediario/cap21_merge_join.pylinhas 51 a 53
    a = pd.DataFrame({"id": [1, 2], "valor": [10, 20]})
    b = pd.DataFrame({"codigo": [1, 2], "valor": [1, 2]})
    print(a.merge(b, left_on="id", right_on="codigo", suffixes=("_a", "_b")))
    
    Saída
       id  valor_a  codigo  valor_b
    0   1       10       1        1
    1   2       20       2        2
    

    E uma chave com tipos diferentes (um inteiro de um lado, um texto do outro) é recusada, em vez de resultar em uma junção vazia. Essa recusa é uma proteção:

    intermediario/cap21_merge_join.pylinhas 55 a 58
    try:
        pd.DataFrame({"k": [1, 2]}).merge(pd.DataFrame({"k": ["1", "2"]}), on="k")
    except ValueError as erro:
        print("ValueError:", str(erro)[:50])
    
    Saída
    ValueError: You are trying to merge on int64 and str columns f
    

    Calcular a receita: duas junções em sequência

    Para saber quanto cada pedido valeu, juntamos os produtos (para ter o preço). O desconto ausente significa sem desconto:

    intermediario/cap21_merge_join.pylinhas 63 a 66
    vendas = pedidos.merge(produtos, on="id_produto", how="left", validate="m:1")
    vendas["receita"] = vendas["quantidade"] * vendas["preco_unitario"] * (1 - vendas["desconto"].fillna(0))
    print(round(float(vendas["receita"].sum()), 2))
    print(vendas.groupby("categoria")["receita"].sum().round(2).sort_values(ascending=False).head(3))
    
    Saída
    970851.1
    categoria
    Esporte        270915.28
    Informática    247345.06
    Casa           237620.67
    Name: receita, dtype: float64
    

    E para a receita por segmento de cliente, uma junção left mantém os pedidos dos clientes que não existem, que aparecem como um grupo próprio, em vez de sumirem:

    intermediario/cap21_merge_join.pylinhas 68 a 72
    completo = vendas.merge(clientes[["id_cliente", "segmento"]], on="id_cliente", how="left", validate="m:1")
    completo["segmento"] = completo["segmento"].fillna("Sem cadastro")
    por_segmento = completo.groupby("segmento")["receita"].sum().round(2)
    print(por_segmento.to_dict())
    print(round(float(por_segmento.sum()), 2) == round(float(vendas["receita"].sum()), 2))
    
    Saída
    {'Atacado': 159214.69, 'Online': 382015.17, 'Sem cadastro': 17060.07, 'Varejo': 412561.17}
    True
    

    A soma por segmento fecha com a receita total (o True), porque nenhum pedido foi descartado. Esse é o teste que importa: o total depois da junção é igual ao total antes.

    `join`, `map` e o mesmo resultado

    O join junta pelo índice, e é a forma curta quando a chave já é o índice. Para trazer uma só coluna de uma tabela de consulta, o map com uma Series faz o mesmo que uma junção, em uma linha:

    intermediario/cap21_merge_join.pylinhas 77 a 79
    por_indice = pedidos.join(produtos.set_index("id_produto")["preco_unitario"], on="id_produto")
    por_map = pedidos["id_produto"].map(produtos.set_index("id_produto")["preco_unitario"])
    print(bool(np.allclose(por_indice["preco_unitario"], por_map)))
    
    Saída
    True
    

    As três perguntas depois de um merge

    Antes de confiar em uma junção, eu respondo: quantas linhas eu tinha, e quantas tenho? Se mudou, por quê? A chave é única onde eu esperava (validate)? Sobrou algum órfão (indicator=True)? Um merge que "funcionou" sem erro e deu um número diferente do esperado é o erro mais caro desta ferramenta.

    Exercício 1

    O localizador de órfãos

    Escreva pedidos_orfaos(pedidos, clientes), que devolva os id_pedido dos pedidos cujo cliente não existe, sem usar um merge (dica: isin), e confira que o resultado bate com o do indicator=True.