Skip to content

    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):

    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()))
    
    Output
    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:

    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())
    
    Output
    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":

    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()))
    
    Output
    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:

    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:])))
    
    Output
    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:

    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))
    
    Output
    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:

    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))
    
    Output
    ['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:

    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()))
    
    Output
    [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.