Let's call your original method f1:
def f1(df: pd.DataFrame, W: int, n: int) -> pd.DataFrame:
return df.rolling(W).mean().iloc[::n]
It's pretty efficient and more importantly, very easy to understand. However, if your ultimate objective is performance, you can implement your own rolling algorithm:
def f2(df: pd.DataFrame, W: int, n: int) -> pd.DataFrame:
# Convert to numpy array for speed
arr = df.to_numpy()
# Each row in `index` contains the indicies that
# participate in the window. The windows are preselected
# to those that we want in the final output.
index = np.arange(0, len(arr), n)
index = index[:, None] - range(W)
# Calculating the mean
result = arr[index].mean(axis=1)
# Any window with negative indexes are assigned NaN
result[(index < 0).any(axis=1)] = np.nan
return pd.DataFrame(result, columns=df.columns, index=df.index[::n])
Validation:
df = pd.DataFrame(np.random.randint(1, 1000, size=(100_000, 5)), columns=list("abcde"))
W, n = 5, 10
assert f1(df1, W, n).equals(f2(df, W, n))
Benchmark:
%timeit f1(df, W, n)
19.9 ms ± 177 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)
%timeit f2(df, W, n)
5.41 ms ± 251 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
Performance characteristics:
f1 runs in about constant time (~20ms on my computer), regardless of W and n. The performance of f2 varies based on the W / n ratio. The smaller this ratio, the faster it runs. However, if this ratio is sufficiently large, it may run slower than f1. The break-even point appears to be around 2.
Conclusion: performance is all about trade-offs. You get nothing for free. Go with f1 if it's fast enough for your use case as its simplicity and readability are hard to beat. Use f2 when W / n is small and performance is critical.