Skillversitas

Universal Functions (ufuncs)

A universal function - a ufunc - is a single operation applied elementwise over arrays, implemented as a tight C loop with the full feature set of the API: multiple dtypes, in-place writes into a named output, and per-operation error control. The arithmetic operators (+, *) are ufuncs in disguise, and the library's hundreds of elementwise operations - sqrt, log, sin, clip, where - all follow the same calling convention. Learning the ufunc interface once makes every one of them usable at full speed.

What a ufunc Is

import numpy as np

a = np.array([1.0, 4.0, 9.0])
print(np.sqrt(a))         # [1. 2. 3.] - the operation, elementwise
print(np.add(1, 2))       # 3 - ufuncs accept scalars too
print(np.add.types)       # the supported type signatures ('dd->d', ...)

# every operator is a ufunc call in disguise
x = np.array([1, 2, 3])
print(x + 1)              # np.add(x, 1)
print(x * x)              # np.multiply(x, x)
print(-x)                 # np.negative(x)

Each ufunc exposes its supported signatures (np.add.types), and each signature is a compiled loop for a specific dtype combination - that is the mechanism behind dtype dispatch: calling np.sqrt on a float32 array runs the float32 loop, with no Python-side type checking per element. The common family is large: math (sqrt, exp, log, log2, log10, pow), trig (sin, cos, tan, and the hyperbolic set), rounding (floor, ceil, round, trunc), comparisons (equal, greater, ...), and the two-argument selection set (maximum, minimum, clip, where).

out and where: The Full Calling Convention

a = np.array([1.0, -2.0, 3.0, -4.0])
b = np.array([10.0, 20.0, 30.0, 40.0])

# named output buffer - zero allocation
out = np.empty(4)
np.add(a, b, out=out)
print(out)

# where: apply only where the mask is True, leave the rest untouched
base = np.zeros(4)
np.add(a, b, out=base, where=(a > 0))
print(base)                 # [11. 0. 33. 0.]

# np.where: the ternary form - pick elementwise between two arrays
print(np.where(a > 0, a, 0))       # [1. 0. 3. 0.]
print(np.where(a > 0, "pos", "neg"))

The keyword interface is where ufuncs earn their keep. out= names the destination (no temporary is allocated - the hot-loop form). where=mask restricts the operation to the True positions of a boolean mask, leaving other elements of the output untouched - conditional computation without a branch per element. And np.where(cond, x, y) is the elementwise ternary: the vectorized replacement for [xi if c else yi for ...] that scales to millions of elements.

reduce and accumulate

a = np.array([1, 2, 3, 4, 5])

print(np.add.reduce(a))          # 15 - the same as a.sum()
print(np.multiply.reduce(a))     # 120 - the same as a.prod()
print(np.maximum.reduce(a))      # 5 - the same as a.max()

print(np.add.accumulate(a))      # [ 1  3  6 10 15] - running sums
print(np.multiply.accumulate(a)) # [  1   2   6  24 120] - running products

# custom: reduce with a different binary function
import operator
print(np.frompyfunc(operator.truediv, 2, 1).reduce(a))  # less common, but the pattern

Every ufunc is a binary operation, and two of its derived forms are general: reduce folds the operation across an axis (that is what sum, prod, max are), and accumulate returns the running result - the vectorized prefix sum, the standard tool for cumulative totals, running averages, and scan-style computations. If you find yourself writing a loop that carries a running state, check whether a ufunc's accumulate is the operation.

Controlling Floating-Point Warnings

a = np.array([1.0, 0.0, -1.0])

print(np.log(a))
# [0.         -inf  nan]  (with a RuntimeWarning: divide by zero / invalid value)

# silence known, intended cases
with np.errstate(divide="ignore", invalid="ignore"):
    safe = np.log(a)
print(safe)

# make a specific case a hard error
with np.errstate(all="raise"):
    try:
        np.sqrt(np.array([-1.0]))
    except FloatingPointError:
        print("raised")

np.errstate is a context manager that sets floating-point error behavior - ignore, warn, raise, or call - per condition (divide, over, under, invalid). The pattern: wrap computations where -inf or nan are expected and handled (log of zero before a mask) so the warning stream stays clean, and set all="raise" in tests to catch the cases that are not.

Where ufuncs Stop

  • Ufuncs are elementwise (or reduce/accumulate along axes) - operations that need a neighbor (convolution, sorting) or a whole matrix (eigenvalues) are separate functions, not ufuncs.
  • np.at is the exotic exception: unbuffered elementwise accumulation for duplicate indices (np.add.at(b, [0, 0, 1], [1, 2, 3]) adds both into index 0) - the fix for the "sum into buckets with repeated indices" problem.

Summary

Treat elementwise operations as ufunc calls: the operators are shorthand, and the full interface adds out= (zero allocation), where=mask (conditional application), and np.where (elementwise ternary). Use reduce/accumulate for the fold and running-state patterns, keep the warning stream honest with np.errstate, and reach for np.add.at when accumulation targets repeat. The ufunc convention is one calling style that spans hundreds of operations - learn it once, and the entire elementwise library is at full speed.