Pular para o conteúdo

    Capítulo 31, Avançado

    Alinhamento e junções por tempo

    O Pandas alinha por **rótulo** em quase tudo, e isso é uma qualidade e uma armadilha. Aqui estão os lugares onde o alinhamento age sem avisar, e a junção por tempo, que resolve um problema que o `merge` comum não resolve.

    Atribuir uma coluna alinha pelo índice

    Atribuir uma Series a uma coluna não copia na ordem: o Pandas procura, para cada linha, o valor com o mesmo rótulo. Se a Series chega em outra ordem, os valores vão para as linhas certas pelo rótulo, e o que parece um erro é o comportamento correto:

    avancado/cap31_alinhamento_asof.pylinhas 10 a 20
    import numpy as np
    import pandas as pd
    
    pd.set_option("display.width", 170)
    pd.set_option("display.max_columns", 20)
    
    base = pd.DataFrame({"nome": ["Ana", "Bruno", "Carla"]}, index=[0, 1, 2])
    notas = pd.Series([9, 7, 8], index=[2, 0, 1])
    base["nota_por_rotulo"] = notas
    base["nota_por_posicao"] = notas.to_numpy()
    print(base)
    
    Saída
        nome  nota_por_rotulo  nota_por_posicao
    0    Ana                7                 9
    1  Bruno                8                 7
    2  Carla                9                 8
    

    Na coluna nota_por_rotulo, a Ana (índice 0) recebeu 7, a nota que tinha o rótulo 0. Na coluna nota_por_posicao, ela recebeu 9, a primeira da lista. As duas estão "certas" para o seu modelo, e a confusão entre os dois é a causa clássica de notas trocadas.

    E um rótulo que não existe vira NaN, sem erro:

    avancado/cap31_alinhamento_asof.pylinhas 22 a 24
    parcial = pd.Series([1, 2], index=[0, 5])
    base["parcial"] = parcial
    print(base["parcial"].tolist())
    
    Saída
    [1.0, nan, nan]
    

    Rótulos repetidos quebram o alinhamento

    O alinhamento só faz sentido se cada rótulo for único. Com repetição, o Pandas se recusa a adivinhar:

    avancado/cap31_alinhamento_asof.pylinhas 29 a 34
    com_repeticao = pd.Series([1, 2, 3], index=[0, 0, 1])
    try:
        com_repeticao.reindex([0, 1])
    except ValueError as erro:
        print("ValueError:", erro)
    print(base.index.is_unique, com_repeticao.index.is_unique)
    
    Saída
    ValueError: cannot reindex on an axis with duplicate labels
    True False
    

    Antes de uma operação que alinha, index.is_unique é uma conferência barata.

    `reindex`, `align` e `combine_first`

    O reindex impõe um novo conjunto de rótulos, e o que falta vira NaN (ou o fill_value). O combine_first preenche os buracos de uma tabela com os valores de outra:

    avancado/cap31_alinhamento_asof.pylinhas 39 a 42
    principal = pd.Series([10.0, np.nan, 30.0], index=["a", "b", "c"])
    reserva = pd.Series([1.0, 2.0, 3.0, 4.0], index=["a", "b", "c", "d"])
    print(principal.reindex(["a", "b", "c", "d"]).tolist())
    print(principal.combine_first(reserva).to_dict())
    
    Saída
    [10.0, nan, 30.0, nan]
    {'a': 10.0, 'b': 2.0, 'c': 30.0, 'd': 4.0}
    

    `merge_asof`: o valor mais recente

    Um problema comum: cada pedido precisa da taxa de câmbio vigente na data dele, mas a tabela de câmbio só tem uma linha por trimestre. Um merge comum só casa datas idênticas. O merge_asof casa cada linha com a mais recente anterior (direction="backward"):

    avancado/cap31_alinhamento_asof.pylinhas 47 a 56
    pedidos = pd.read_csv("dados/pedidos.csv", parse_dates=["data_pedido"]).sort_values("data_pedido")
    cambio = pd.DataFrame(
        {
            "data": pd.to_datetime(["2025-01-01", "2025-04-01", "2025-07-01", "2025-10-01"]),
            "usd": [6.1, 5.8, 5.5, 5.9],
        }
    )
    com_cambio = pd.merge_asof(pedidos, cambio, left_on="data_pedido", right_on="data", direction="backward")
    print(com_cambio.groupby("usd").size().to_dict())
    print(com_cambio.loc[com_cambio["data_pedido"] == "2025-03-31", "usd"].unique().tolist())
    
    Saída
    {5.5: 209, 5.8: 215, 5.9: 201, 6.1: 175}
    [6.1]
    

    Um pedido de 31 de março recebe a taxa de 1º de janeiro (6,1), porque a de abril ainda não existia. Com o tolerance, você diz até quanto tempo para trás uma taxa ainda vale, e pedidos mais distantes ficam sem par:

    avancado/cap31_alinhamento_asof.pylinhas 58 a 62
    com_prazo = pd.merge_asof(
        pedidos, cambio, left_on="data_pedido", right_on="data",
        direction="backward", tolerance=pd.Timedelta("30D"),
    )
    print(int(com_prazo["usd"].isna().sum()) > 0, int(com_cambio["usd"].isna().sum()))
    
    Saída
    True 0
    

    As duas tabelas precisam estar ordenadas pela chave. Sem isso, o Pandas se recusa:

    avancado/cap31_alinhamento_asof.pylinhas 64 a 67
    try:
        pd.merge_asof(pedidos.sort_values("quantidade"), cambio, left_on="data_pedido", right_on="data")
    except ValueError as erro:
        print("ValueError:", erro)
    
    Saída
    ValueError: left keys must be sorted
    

    `explode`: uma célula com vários valores

    Uma coluna com listas (ou textos separados por vírgula) vira uma linha por valor com o explode, o que permite contar, agrupar e juntar:

    avancado/cap31_alinhamento_asof.pylinhas 72 a 74
    produtos = pd.DataFrame({"produto": ["A", "B", "C"], "tags": ["novo,oferta", "oferta", "novo,oferta,verao"]})
    em_linhas = produtos.assign(tag=produtos["tags"].str.split(",")).explode("tag")
    print(len(em_linhas), em_linhas["tag"].value_counts().to_dict())
    
    Saída
    6 {'oferta': 3, 'novo': 2, 'verao': 1}
    

    Exercício 1

    O desconto vigente

    Dada uma tabela tabela_descontos com data e percentual, e os pedidos, use o merge_asof para dar a cada pedido o desconto vigente na sua data, e confira que nenhum pedido anterior à primeira data da tabela ganha desconto.