NumPy · Module 4 of 13 · 5 concepts · about 6 min

Vectorized Computation

The compiled per-element operations every arithmetic expression is built from, and the arguments that stop them allocating.

At work: Doing maths on millions of numbers at once: prices, scores, signals, instead of one at a time in a slow loop.

What a ufunc is

What it is

A universal function: a compiled operation applied element-wise over broadcast inputs. +, np.exp, np.maximum and np.isnan are all ufuncs, and they all share the same machinery.

Why it matters

It is the unit of vectorisation. If an expression is built from ufuncs it runs entirely in compiled code; if anything in it is a Python function called per element, it does not.

In the real world: Applying GST to 10 million order amounts in one line, running at the speed of compiled code rather than Python.

x = np.array([1.0, 4.0, 9.0])
np.sqrt(x)                  # array([1., 2., 3.])
np.maximum(x, 2.0)          # element-wise max against a scalar
x > 2                        # a comparison is a ufunc too
np.exp(x).dtype              # float64 — exp always leaves the integers

Using Python's math module on an array. math.exp takes a scalar, so it either raises or (worse, inside a comprehension) works one element at a time at Python speed.

out= and in-place operators

What it is

Every ufunc takes out=, writing its result into an existing array instead of allocating one. x += 1 and np.add(x, 1, out=x) are the same thing.

Why it matters

On large arrays the allocations dominate: a chained expression can allocate several full-size temporaries, and out= removes them. It is the cheapest optimisation in NumPy.

In the real world: Updating a huge array of account balances with today's interest without allocating a second copy, keeping memory use flat.

buf = np.empty_like(x)
np.exp(x, out=buf)            # no allocation
np.multiply(buf, 2.0, out=buf)  # still none
x /= x.sum()                  # in place: no temporary copy of x

In-place operators do not upcast. int_array /= 2 raises, and int_array += 0.5 raises rather than silently promoting, which is the good case; int8_counts += 200 wraps instead.

where= on a ufunc

What it is

where= computes the operation only at the positions the mask is True, leaving the rest of out untouched. Combined with out=, it is the standard guarded-arithmetic idiom.

Why it matters

It is the only way to avoid evaluating an operation where it is invalid (dividing by zero, taking the log of a non-positive number) rather than computing a nan and discarding it.

In the real world: Computing profit margin only where revenue isn't zero, so new products with no sales don't produce errors.

sums = np.array([30.0, 0.0, 5.0])
counts = np.array([2.0, 0.0, 1.0])
means = np.divide(sums, counts, out=np.zeros_like(sums), where=counts > 0)
means        # array([15.,  0.,  5.]) — no warning, no nan

Passing where= without out=. The skipped positions then come from an uninitialised buffer, so they hold arbitrary values rather than zeros. out= is not optional in practice.

Type promotion and overflow

What it is

A binary ufunc promotes its inputs to a common dtype: int with float gives float, float32 with float64 gives float64. Integer arithmetic does not promote on overflow: it wraps.

Why it matters

It explains why a float32 pipeline quietly becomes float64 after one multiplication by a Python float, doubling its memory, and why an integer sum of a large count column can come out negative.

In the real world: Summing large order values stored as small integers can wrap around to negative numbers, a silent revenue bug.

(np.ones(3, dtype=np.float32) * 2.0).dtype      # float32 — a Python scalar adapts
(np.ones(3, dtype=np.float32) + np.ones(3)).dtype  # float64 — the array wins
np.array([2**31], dtype=np.int32) * 2           # overflows, wraps negative
np.arange(10, dtype=np.int8).sum().dtype        # int64: sum accumulates wider

Summing a bool or small-int array and assuming the accumulator is wide enough. sum does widen, but np.add.reduce(x, dtype=x.dtype) and in-place += do not.

Practise it: Softmax That Does Not Overflow

reduce, accumulate and reduceat

What it is

Each ufunc carries methods: np.add.reduce is sum, np.add.accumulate is cumsum, np.maximum.accumulate is a running maximum, and np.add.reduceat sums between given boundaries.

Why it matters

They generalise the named functions to *any* ufunc, which is where np.maximum.accumulate (the trick under a vectorised forward fill) comes from.

In the real world: A running total of daily sales, or the highest account balance reached so far, each in one call.

x = np.array([3, 1, 4, 1, 5])
np.add.reduce(x)                 # 14, i.e. x.sum()
np.maximum.accumulate(x)         # array([3, 3, 4, 4, 5])
np.logical_or.reduce([True, False])   # True
np.add.reduceat(x, [0, 2])       # sums of x[0:2] and x[2:]

Expecting np.minimum.accumulate on an array containing nan to skip it. nan propagates through every comparison, so one nan poisons the rest of the running result.

Practise it: Fill the Gaps in Every Row