Skip to content
← Back to explainers

Python explainer

Fix the Algorithm Before You Reach for NumPy

About 4 min to read. Allow extra time to work through examples.

Speed up a moving average by reusing sums instead of recomputing them. Then see when NumPy helps, and when converting to it costs more than it saves.

This article uses Python. You can make it your practice mode to browse related content across the site.

A dashboard plots the average of the last 1,000 sensor readings at every point. The code sums each window from scratch:

import time


def moving_average(xs: list[float], window: int) -> list[float]:
    return [sum(xs[i : i + window]) / window for i in range(len(xs) - window + 1)]


readings = [float(i % 100) for i in range(100_000)]
for window in (10, 100, 1_000):
    start = time.perf_counter()
    moving_average(readings, window)
    print(f"window {window:>5}: {time.perf_counter() - start:.3f}s")

On a laptop with Python 3.15, it printed:

window    10: 0.009s
window   100: 0.031s
window  1000: 0.231s

The wider the window, the slower it gets, because each average adds up window values again. Most of them were in the previous window too. You might reach for NumPy at this point. Change the algorithm first.

Reuse the sums you already have

A prefix sum list holds the running total of the input, starting from zero. For xs = [3, 1, 4, 1, 5], the prefix sums are [0, 3, 4, 8, 9, 14]. Any window's sum is then one subtraction. The sum of xs[1:4], which is 1 + 4 + 1, is prefix[4] - prefix[1], or 9 - 3 = 6.

You build the prefix list once, and each average costs one subtraction and one division, whatever the window size. A prefix-sum version of the code above took about 4 milliseconds at all three sizes. You write it in the exercise.

When NumPy helps, and when it doesn't

A NumPy array stores its numbers side by side in one block of memory, and its operations loop over them in compiled code. A list stores references to separate float objects, and a Python loop handles them one at a time. So work on a whole array is fast, but only once the data is in an array:

import time

import numpy as np

readings = [float(i % 100) for i in range(1_000_000)]

start = time.perf_counter()
total = sum(readings)
print(f"sum of the list:  {time.perf_counter() - start:.4f}s")

start = time.perf_counter()
array = np.asarray(readings)
print(f"list to array:    {time.perf_counter() - start:.4f}s")

start = time.perf_counter()
array_total = float(array.sum())
print(f"sum of the array: {time.perf_counter() - start:.4f}s")

assert total == array_total

With NumPy 2.5 on Python 3.14, it printed:

sum of the list:  0.0018s
list to array:    0.0135s
sum of the array: 0.0002s

Summing the array is about ten times faster than summing the list. Converting the list costs about seven times more than summing it, though. If the data arrives as a list and you need one result, stay with the list. If you keep the array and run many operations on it, NumPy wins.

A Python for loop over an array is slower than one over a list. Each item the loop reads becomes a new Python object. On the same laptop, a for loop that adds up the array took between two and four times as long as the same loop over the list. Use NumPy's whole-array operations, or don't use NumPy.

Prefix sums can lose small values

A float holds about 16 significant digits. When the running total gets large, small values added to it can round away:

from itertools import accumulate

values = [1e16, 1.0, 1.0]
prefix = [0.0, *accumulate(values)]
recovered = [prefix[i + 1] - prefix[i] for i in range(len(values))]
print(recovered)  # [1e+16, 0.0, 0.0]

1e16 + 1.0 rounds back to 1e16, so both 1.0 values are lost before the subtraction. Readings of a similar size, like those in the exercise, lose almost nothing. If your values range over many orders of magnitude, test against math.fsum on each window, which avoids this loss.

Check your understanding You get 50 sensor readings, then later 50,000. Should you switch to NumPy? Show answer

Not because of the count alone. First check the algorithm. If each result repeats work, as the direct moving average does, prefix sums fix it in plain Python. Then measure at both sizes, including the conversion to an array. If the rest of your program already uses arrays, NumPy becomes the better choice, because you pay for the conversion once.

Going further

The standard library's array module stores numbers compactly without NumPy, but it has no whole-array arithmetic. For typed NumPy code, see NumPy's typing reference.

step 31 of 38

Memory, Copies and Slow Code

Up next Speed Up a Moving Average with Prefix Sums