Chapter 24, Intermediate
Windows: rolling, expanding and ewm
A moving average smooths out the noise of a series so you can see the trend. Window functions do this (and the accumulated, and the average that weighs more on the recent) without a loop.
The daily series
The orders become a series per day: the quantity ordered each day of the year. The resample("D") ensures a value for each day, including those without orders (which become zero in the sum):
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`: the sliding window
The rolling(7) looks at the 7 last values at each position, and the aggregation (mean, sum, max...) calculates over them. The first 6 days do not have 7 values to look at, and remain NaN, unless you accept incomplete windows with 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]
With min_periods=1, the average of the first days uses what exists (the average of the 1st day is the value itself). It's a choice: it leaves fewer gaps, and the first averages are less reliable (fewer data behind).
The time window
With a date index, the window can be a period, and not a number of rows. This is what you want when there are missing days, because 7D always means "the last 7 consecutive days":
por_tempo = diaria.rolling("7D").sum()
print(int(por_tempo.isna().sum()), int(por_tempo.iloc[6]) == int(diaria.iloc[:7].sum()))
0 True
The same calculation in NumPy
The moving average in Pandas is exactly the sliding window you saw in chapter 28 of the NumPy course. You can check that both give the same result:
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
The advantage of Pandas is what comes with it: the date labels, the handling of gaps (min_periods), and the window by period.
`expanding` and `ewm`
The expanding calculates over everything up to this point: a cumulative, an average since the beginning. The ewm (exponential average) gives more weight to recent values, and therefore reacts more quickly to a change than the 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
The last two numbers are similar but different: both smooth, but the exponential gives more weight to recent days.
Window by group
Applying the window within each group returns a result with a double index (group and date), which does not align with the original table. The reset_index(level=0, drop=True) removes the group level:
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
Compare with the past
The shift(7) brings the value from the same day of the previous week, which eliminates the effect of the day of the week (the first 7 days have no comparison). And the pct_change of the weekly series gives the variation from one week to another:
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
Exercise 1
The moving sum of 30 days
Write soma_movel_30d(serie_diaria), with a time window, and check that, on the 30th day, the value is equal to the sum of the first 30 days.