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):
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()))
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:
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())
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":
por_tempo = diaria.rolling("7D").sum()
print(int(por_tempo.isna().sum()), int(por_tempo.iloc[6]) == int(diaria.iloc[:7].sum()))
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:
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:])))
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:
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))
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:
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))
['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:
semana = diaria.resample("W").sum()
print(semana.pct_change().round(3).iloc[1:4].tolist())
print(int((diaria - diaria.shift(7)).isna().sum()))
[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.