Pular para o conteúdo

    Capítulo 24, Intermediário

    Janelas: rolling, expanding e ewm

    Uma média móvel suaviza o ruído de uma série para você ver a tendência. As funções de janela fazem isso (e o acumulado, e a média que pesa mais o recente) sem um laço.

    A série diária

    Os pedidos viram uma série por dia: a quantidade pedida a cada dia do ano. O resample("D") garante um valor para cada dia, inclusive os sem pedido (que viram zero na soma):

    intermediario/cap24_janelas.pylinhas 10 a 18
    import numpy as np
    import pandas as pd
    
    pd.set_option("display.width", 170)
    pd.set_option("display.max_columns", 20)
    
    pedidos = pd.read_csv("dados/pedidos.csv", parse_dates=["data_pedido"])
    diaria = pedidos.set_index("data_pedido")["quantidade"].resample("D").sum()
    print(len(diaria), int(diaria.sum()))
    
    Saída
    365 4365
    

    `rolling`: a janela deslizante

    O rolling(7) olha os 7 últimos valores a cada posição, e a agregação (mean, sum, max...) calcula sobre eles. Os primeiros 6 dias não têm 7 valores para olhar, e ficam NaN, a não ser que você aceite janelas incompletas com min_periods:

    intermediario/cap24_janelas.pylinhas 23 a 25
    media7 = diaria.rolling(7).mean()
    print(int(media7.isna().sum()), int(diaria.rolling(7, min_periods=1).mean().isna().sum()))
    print(media7.iloc[6:9].round(2).tolist())
    
    Saída
    6 0
    [10.57, 9.0, 7.71]
    

    Com min_periods=1, a média dos primeiros dias usa o que existe (a média do 1º dia é o próprio valor). É uma escolha: dá menos buracos, e as primeiras médias são menos confiáveis (menos dados por trás).

    A janela por tempo

    Com um índice de datas, a janela pode ser um período, e não um número de linhas. É o que você quer quando há dias faltando, porque 7D significa sempre "os últimos 7 dias corridos":

    intermediario/cap24_janelas.pylinhas 30 a 31
    por_tempo = diaria.rolling("7D").sum()
    print(int(por_tempo.isna().sum()), int(por_tempo.iloc[6]) == int(diaria.iloc[:7].sum()))
    
    Saída
    0 True
    

    A mesma conta no NumPy

    A média móvel do Pandas é exatamente a janela deslizante que você viu no capítulo 28 do curso de NumPy. Dá para conferir que os dois dão o mesmo resultado:

    intermediario/cap24_janelas.pylinhas 36 a 40
    from numpy.lib.stride_tricks import sliding_window_view
    
    valores = diaria.to_numpy(dtype=float)
    manual = sliding_window_view(valores, 7).mean(axis=1)
    print(bool(np.allclose(manual, media7.to_numpy()[6:])))
    
    Saída
    True
    

    A vantagem do Pandas é o que vem junto: os rótulos de data, o tratamento dos buracos (min_periods) e a janela por período.

    `expanding` e `ewm`

    O expanding calcula sobre tudo até aqui: um acumulado, uma média desde o início. O ewm (média exponencial) dá mais peso aos valores recentes, e por isso reage mais depressa a uma mudança do que o rolling:

    intermediario/cap24_janelas.pylinhas 45 a 49
    acumulado = diaria.expanding().sum()
    print(bool(acumulado.iloc[-1] == diaria.sum()), bool(acumulado.is_monotonic_increasing))
    
    media_exp = diaria.ewm(span=7).mean()
    print(round(float(media_exp.iloc[-1]), 2), round(float(media7.iloc[-1]), 2))
    
    Saída
    True True
    9.93 11.0
    

    Os dois últimos números são parecidos mas diferentes: ambos suavizam, mas a exponencial dá mais peso aos dias recentes.

    Janela por grupo

    Aplicar a janela dentro de cada grupo devolve um resultado com índice duplo (grupo e data), que não alinha com a tabela original. O reset_index(level=0, drop=True) tira o nível do grupo:

    intermediario/cap24_janelas.pylinhas 54 a 61
    por_canal = (
        pedidos.set_index("data_pedido")
        .groupby("canal")["quantidade"]
        .rolling("7D").sum()
    )
    print(por_canal.index.names)
    por_canal = por_canal.reset_index(level=0, drop=True)
    print(por_canal.index.names, len(por_canal) == len(pedidos))
    
    Saída
    ['canal', 'data_pedido']
    ['data_pedido'] True
    

    Comparar com o passado

    O shift(7) traz o valor do mesmo dia da semana anterior, o que elimina o efeito do dia da semana (os 7 primeiros dias ficam sem comparação). E o pct_change da série semanal dá a variação de uma semana para a outra:

    intermediario/cap24_janelas.pylinhas 66 a 68
    semana = diaria.resample("W").sum()
    print(semana.pct_change().round(3).iloc[1:4].tolist())
    print(int((diaria - diaria.shift(7)).isna().sum()))
    
    Saída
    [0.133, -0.044, 0.538]
    7
    

    Exercício 1

    A soma móvel de 30 dias

    Escreva soma_movel_30d(serie_diaria), com uma janela por tempo, e confira que, no 30º dia, o valor é igual à soma dos 30 primeiros dias.