Universal Functions (ufuncs)
ufuncs
Core Principle: Universal functions (
ufuncs) are compiled, vectorized C routines that execute elementwise computations with built-in broadcasting and type resolution.
Every +, *, np.log2, np.exp and np.maximum you write is a ufunc. Understanding their shared machinery gives you several free capabilities most people never use.
What counts as a ufunc
(<ufunc 'floor'>, <ufunc 'ceil'>, <function round at 0x10623af30>, <function clip at 0x1062397f0>)
Operators dispatch to them: a + b is np.add(a, b).
np.maximum vs np.max is a classic confusion. np.maximum(a, b) compares two arrays elementwise; np.max(a) reduces one array to its largest value. Same for minimum/min.
The shared keyword arguments
Every ufunc accepts these, and they are underused:
np.add(a, b, out=result) # write into an existing buffer, no allocation
np.log2(x, where=x > 0) # only compute where the condition holds
np.divide(1, x, out=np.zeros_like(x), where=x != 0) # safe reciprocal
np.add(a, b, dtype=np.float64) # force accumulation precision
The out= + where= combination is the correct way to avoid divide-by-zero and log-of-zero warnings. The common alternative, np.where(x > 0, np.log2(x), 0), still computes log2(0) and emits the warning, because both branches are evaluated eagerly.
out= also matters in memory-tight loops:
np.log2(big, out=big) # transform in place, no second 8 GB allocation
big += 1 # likewise
The four ufunc methods
Binary ufuncs carry four extra methods that give you reductions for free:
np.add.reduce(a) # ≡ a.sum()
np.add.accumulate(a) # ≡ a.cumsum()
np.add.reduceat(a, indices) # segmented reduction across contiguous interval boundaries
np.add.outer(a, b) # outer product across pairwise combinations
np.add.at(a, idx, values) # unbuffered in-place scatter: handles duplicates
np.multiply.reduce(a) # ≡ a.prod()
np.maximum.accumulate(a) # running maximum
np.logical_or.reduce(masks) # OR together a stack of masks
np.add.reduceat is a genuine grouped-sum primitive:
# sum coverage within exons, given exon start indices in a sorted array
starts = np.array([0, 150, 400])
np.add.reduceat(coverage, starts) # one total per segment
np.maximum.accumulate is how you compute a running maximum in one pass, useful for step-down multiple-testing procedures.
Type promotion and casting
array([2.], dtype=float32)
The NumPy 2 promotion change (NEP 50) means Python scalars no longer upcast arrays. See NumPy dtypes.
Speed, concretely
array([-0.6507215 , -1.89010879, -4.60916421, ..., -2.91210759,
-2.06869819, -1.03919183], shape=(1000000,))
Roughly 30× here; for simpler operations the gap widens to 100×+. The reason is not that C is faster than Python per operation: it is that the loop, the type dispatch and the boxing all happen once instead of a million times. See Vectorization.
Writing your own
If you have a scalar function you cannot express in NumPy operations:
@np.vectorize # ← convenience, NOT speed. It is a Python loop.
def f(x): ...
from numba import vectorize
@vectorize(["float64(float64)"], nopython=True)
def f(x): ... # ← this one is genuinely compiled
np.vectorize is not a performance tool. Its docstring says so. It exists to give a scalar function ufunc-like broadcasting semantics. If you need speed, use numba, or restructure the problem into existing ufuncs.
Bioinformatics examples
# Phred quality ↔ error probability
p_err = 10 ** (-quals / 10)
quals = -10 * np.log10(p_err)
# log transform with a pseudocount (the standard RNA-seq move)
np.log2(counts + 1)
# safe log with an explicit floor
np.log2(counts, out=np.full_like(counts, -np.inf), where=counts > 0)
# clip outliers before plotting
np.clip(log2fc, -5, 5)
# elementwise max against a floor
np.maximum(pvalues, 1e-300) # avoid log10(0) = -inf in a volcano plot
# GC content across a one-hot matrix
onehot[:, [1, 2]].sum() / onehot.sum()
Common mistakes
np.maxwhen you meantnp.maximum(or vice versa). One reduces, one pairs.- Expecting
np.whereto short-circuit. Both branches are always computed. Usewhere=on the ufunc. np.vectorizefor speed. It is a loop.- Ignoring RuntimeWarnings for
invalid value/divide by zero. They mean NaN or inf entered your data. Usenp.errstateto handle deliberately, notwarnings.filterwarningsto hide:with np.errstate(divide="ignore", invalid="ignore"): ratio = a / b out=with an incompatible dtype. It silently casts, which can truncate.- Forgetting integer overflow in
np.add.reduceonint32counts. Passdtype=np.int64.
See also
Vectorization · Broadcasting · Axes and Reductions · NumPy dtypes · Missing Data in NumPy
Exercise
Write log_ratios(a, b), which computes log2(a / b) elementwise but only
where both operands are strictly positive. Use the out and where keywords of
np.divide and np.log2 so the undefined positions are never evaluated at all;
an implementation that divides first and masks afterwards raises warnings and
produces infinities.
Return five lines. The first is ratios=<values joined by commas>, each defined
value rounded to three decimals and each undefined position written as NA. The
second is defined=<n>/<total>. The third is sum=<value> and the fourth
product=<value>, computed over the defined values with np.add.reduce and
np.multiply.reduce and rounded to three decimals; when nothing is defined
these must be the identities of the two operations, which are not the same
number. The fifth is running=<list>, the cumulative totals from
np.add.accumulate over the defined values, rounded to three decimals, and the
empty list when there are none.
Test cases · 4
| # | via | input | expected stdout |
|---|---|---|---|
| 1 | stdin | 8 4 0 16 5 2 8 4 0 5 | ratios=2.0,-1.0,NA,NA,0.0 defined=3/5 sum=1.0 product=0.0 running=[2.0, 1.0, 1.0] |
| 2 | stdin | 1 2 4 8 1 1 1 1 | ratios=0.0,1.0,2.0,3.0 defined=4/4 sum=6.0 product=0.0 running=[0.0, 1.0, 3.0, 6.0] |
| 3 | stdin | 3 6 0 0 | ratios=NA,NA defined=0/2 sum=0.0 product=1.0 running=[] |
| 4 | stdin | -1 0 1 1 | ratios=NA,NA defined=0/2 sum=0.0 product=1.0 running=[] |
Hints · 2
01Hint
np.full(a.shape, np.nan) gives you an output buffer whose untouched positions stay NaN.
02Hint
np.add.reduce of an empty array is 0.0 and np.multiply.reduce of an empty array is 1.0 — every ufunc carries its own identity.