Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

NumPy & SciPy for Scientific Computing  Module 2 – Indexing, reshaping and vectorised operations

Universal functions and vectorised arithmetic

How NumPy ufuncs run a compiled loop per dtype: division and rounding rules, safe division with out= and where=, reduce, accumulate and np.vectorize timed.

  • Beginner
  • 20 minutes
  • Examples run with Python 3.14.8, numpy 2.5.3, scipy 1.18.1 and Pyodide 314.0.7
  • By MySmartCoPilot
Free lesson, Pro answers: 20 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Predict the results of arithmetic, comparison and math ufuncs, including floor division, remainders and rounding half to even
  • Use the out=, where= and dtype= arguments of a ufunc to divide safely, reuse memory and avoid overflow
  • Replace loops with the reduce, accumulate, outer, reduceat and at methods
  • Explain why np.vectorize is a convenience rather than a speed-up, and measure the difference

Before you start

What you’ll learn · Before you start

What you will learn

  • Predict the results of arithmetic, comparison and math ufuncs, including floor division, remainders and rounding half to even
  • Use the out=, where= and dtype= arguments of a ufunc to divide safely, reuse memory and avoid overflow
  • Replace loops with the reduce, accumulate, outer, reduceat and at methods
  • Explain why np.vectorize is a convenience rather than a speed-up, and measure the difference

Before you start

On this page

A Python function that works out a delivery fee handles one order at a time. Wrapped in np.vectorize it accepts an array of 100,000 orders, yet it still took about 15 ms in this lesson’s run, while the same rule written with NumPy’s universal functions took under 0.2 ms.

You will see what a ufunc is and how it picks a compiled loop, predict integer division, remainders and rounding, divide safely with out= and where=, turn any two-input ufunc into a reduction, a running total or a table with its methods, and measure why np.vectorize is a convenience rather than a speed-up.

Every operator on arrays calls a ufunc, a set of compiled loops

A ufunc: A universal function: a NumPy function such as np.add or np.sqrt that works element by element on whole arrays, with one compiled loop for each combination of input types it supports. Operators such as + call ufuncs., or ufunc, is a NumPy function that, in the words of NumPy’s guide, “operates on ndarrays in an element-by-element fashion”, with broadcasting and type casting built in. Arithmetic, comparisons, np.exp, np.log, np.sin and their relatives are all ufuncs, and the operators call them: on arrays, a + b is np.add(a, b). Inside, a ufunc holds one compiled loop for each combination of types it supports:

np.add and its typed loops, comparisons and math functions Python · loops/ufunc_loops.py
"""Operators on arrays are ufuncs: each is a set of compiled loops, one for each combination of dtypes."""
import numpy as np

a = np.array([1, 2, 3], dtype=np.int64)
b = np.array([10, 20, 30], dtype=np.int64)
print("a + b equals np.add(a, b):", np.array_equal(a + b, np.add(a, b)))
print("np.add is a", type(np.add).__name__, "with", np.add.nin, "inputs,", np.add.nout, "output and", np.add.ntypes, "typed loops")
print("its first loops:", np.add.types[:6])
print("int64 + int64 ->", (a + b).dtype, "| int64 + float64 ->", (a + b.astype(np.float64)).dtype)
print("a > 1:", a > 1, "->", (a > 1).dtype)
print("np.sqrt(a):", np.sqrt(a).round(4), "| np.exp(0):", np.exp(0), "| np.log(np.e):", np.log(np.e))
print("np.sin(np.pi / 2):", np.sin(np.pi / 2), "| np.sin(np.pi):", np.sin(np.pi))
u = np.array([200, 100], dtype=np.uint8)
print("uint8 [200, 100] + itself:", u + u, "(it wraps at 256)")

Output

a + b equals np.add(a, b): True
np.add is a ufunc with 2 inputs, 1 output and 22 typed loops
its first loops: ['??->?', 'bb->b', 'BB->B', 'hh->h', 'HH->H', 'ii->i']
int64 + int64 -> int64 | int64 + float64 -> float64
a > 1: [False  True  True] -> bool
np.sqrt(a): [1.     1.4142 1.7321] | np.exp(0): 1.0 | np.log(np.e): 1.0
np.sin(np.pi / 2): 1.0 | np.sin(np.pi): 1.2246467991473532e-16
uint8 [200, 100] + itself: [144 200] (it wraps at 256)

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 ufunc_loops.py

np.add takes 2 inputs, gives 1 output and holds 22 typed loops; its list starts with ??->? for booleans and bb->b for int8. Two int64 arrays use the int64 loop and give int64, an int64 and a float64 array give float64, comparisons give booleans, and np.sqrt of integers gives floats. np.sin(np.pi) is 1.2e-16 rather than 0, because np.pi is only the float nearest to π. And the uint8 loop keeps its result in uint8, so 200 + 200 wrapped round to 144 without a warning. Step through what happens between a + b and its result:

Interactive · stage by stage What NumPy does for a + b Step through the stages for a = [1, 2, 3] as int64 and b = [10.0, 20.0, 30.0] as float64.
What NumPy does for a + b, step 6 of 6. Showing a + b calls np.add, Read the input dtypes, Find a loop for them, Make the output array, Run the compiled loop. In focus: Run the compiled loop.cast safelya + b calls np.addthe operator is a ufuncRead the input dtypesint64 and float64Find a loop for them22 typed loops in np.addMake the output arraynew float64, or out=Run the compiled loopone pass over every element

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. An operator is a ufunc. For arrays, a + b calls np.add(a, b), a universal function. The run checked that both give the same array.
  2. The dtypes decide. np.add first looks at what it was given: an int64 array and a float64 array. The dtypes, not the values, decide every later step.
  3. A loop per dtype pair. np.add holds 22 compiled loops, one for each combination of types it supports, such as ll->l for int64 and dd->d for float64. No loop takes int64 and float64 at once.
  4. Cast, then pick. When no loop matches exactly, NumPy searches for one to which every input can be cast safely. int64 to float64 is such a cast, so the dd->d loop is chosen.
  5. Room for the result. The output is a new float64 array of the inputs' shape, unless out= names an array to write into, as the next section shows.
  6. One compiled pass. Finally the loop runs in compiled code over all the elements at once, with no Python object made per value: 11.0, 22.0 and 33.0.

Every later section builds on this: the dtypes choose the loop, the loop decides how results overflow, and out= decides where they are written.

Brightening a uint8 image with img + 60

On a uint8 image, img + 60 turns a pixel of 200 into 4 (260 − 256), dark where it should be bright, and nothing warns. Compute in a wider type and clip back: np.clip(img.astype(np.int16) + 60, 0, 255).astype(np.uint8).

Checkpoint

Check: Ufuncs and their typed loops

  1. Question 1 of 2 For two arrays, what does a * b call?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: np.multiply(a, b), a ufunc

    Every arithmetic and comparison operator on arrays is a ufunc call, which is why the same rules about dtypes, out= and where= apply to both.

  2. Question 2 of 2 u = np.array([200, 100], dtype=np.uint8). What is u + u?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: [144, 200]

    The input dtypes choose the loop, and the uint8 loop computes modulo 256. Pass dtype= or convert first when the result can be larger.

Integer division, remainders and rounding have rules of their own

Four operations surprise people coming from other languages or from school arithmetic. Here they are side by side:

Floor division, remainders, negative powers and rounding Python · integers/edge_cases.py
"""Integer division, remainders, powers and rounding follow rules of their own."""
import numpy as np

a = np.array([-7, 7, -7, 7])
b = np.array([2, 2, -2, -2])
print("a // b:", a // b, "| a % b:", a % b, "| np.fmod(a, b):", np.fmod(a, b))
print("Python: -7 // 2 =", -7 // 2, "and -7 % 2 =", -7 % 2)
try:
    np.array([2, 3]) ** -1
except ValueError as error:
    print("integer array ** -1: ValueError:", error)
print("np.array([2, 3]) ** -1.0:", np.array([2, 3]) ** -1.0)
print("np.round([0.5, 1.5, 2.5, 3.5, -0.5, -1.5]):", np.round([0.5, 1.5, 2.5, 3.5, -0.5, -1.5]))
print("Python round(2.5) and round(0.5):", round(2.5), round(0.5))
print("np.round(2.675, 2):", np.round(2.675, 2), "| round(2.675, 2):", round(2.675, 2))
print("np.floor, np.ceil and np.trunc of -2.5:", np.floor(-2.5), np.ceil(-2.5), np.trunc(-2.5))

Output

a // b: [-4  3  3 -4] | a % b: [ 1  1 -1 -1] | np.fmod(a, b): [-1  1 -1  1]
Python: -7 // 2 = -4 and -7 % 2 = 1
integer array ** -1: ValueError: Integers to negative integer powers are not allowed.
np.array([2, 3]) ** -1.0: [0.5        0.33333333]
np.round([0.5, 1.5, 2.5, 3.5, -0.5, -1.5]): [ 0.  2.  2.  4. -0. -2.]
Python round(2.5) and round(0.5): 2 0
np.round(2.675, 2): 2.68 | round(2.675, 2): 2.67
np.floor, np.ceil and np.trunc of -2.5: -3.0 -2.0 -2.0

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 edge_cases.py

// is floor division: Division that rounds the quotient down, towards minus infinity: -7 // 2 is -4. Its remainder, -7 % 2, is 1, which has the sign of the divisor, so the quotient times the divisor plus the remainder gives -7 back., as in Python: -7 // 2 rounds −3.5 down to −4, and the remainder makes up the rest, -7 % 2 is 1, because NumPy’s reference says the remainder “has the same sign as the divisor”. np.fmod truncates instead, as C and MATLAB’s rem do, so its remainders take the dividend’s sign. An integer array raised to a negative integer power raises a ValueError, since the answer would not be an integer; a float exponent, ** -1.0, gives 0.5 and 0.333….

np.round uses rounding half to even: A rounding rule that sends a value exactly halfway between two candidates to the even one: 0.5 and -0.5 to 0, 1.5 and 2.5 to 2. np.round and Python's round both use it.: 0.5 and −0.5 go to 0, 1.5 and 2.5 to 2, 3.5 to 4, and Python’s own round does the same. The last line shows a different trap. np.round(2.675, 2) gave 2.68 while Python gave 2.67: 2.675 is stored as 2.67499999…, which Python rounds correctly down, but NumPy’s reference describes its method as “fast but sometimes inexact”: it multiplies by 100, and 2.675 × 100 rounds to exactly 267.5 in floating point, which goes to the even 268.

Round money in whole units

For amounts that must round the way a bill does, keep them as whole paise or cents in integers, or use Python’s decimal module, rather than np.round on floats.

Checkpoint

Check: Division, remainders and rounding

  1. Question 1 of 2 What are np.array([-7]) // 2 and np.array([-7]) % 2?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: -4 and 1

    NumPy's // and % follow Python's rules: q = floor(a / b) and r = a - q × b, so the remainder has the sign of b.

  2. Question 2 of 2 What does np.round([0.5, 1.5, 2.5]) give?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: [0., 2., 2.]

    Rounding half to even keeps the average of many rounded values from drifting upwards.

out= reuses memory and where= skips positions

Four shops report their revenue and their number of orders, and two of them had no orders. Dividing one array by the other gives inf and nan for those two, with a warning for each. A ufunc can instead compute only where a condition holds and write into an array you supply. Step through np.divide with both:

Interactive · step by step Dividing only where the divisor is not zero Step through the call. The left column holds the inputs and the condition; the right column shows out as each position is decided.
Dividing only where the divisor is not zero, step 6 of 6. Showing sales, orders, where = orders != 0 (T F F T), out (1.5 -1.0 -1.0 0.5), the result (1.5 -1.0 -1.0 0.5). In focus: the result.sales3.0 5.0 0.0 4.0orders2.0 0.0 0.0 8.0where = orders != 0T F F Tout1.5 -1.0 -1.0 0.5the result1.5 -1.0 -1.0 0.5

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. Four shops. Four shops with their revenue and their number of orders. Two shops had no orders, so dividing there would give inf and nan, with two warnings.
  2. The fill comes first. out = np.full(4, -1.0) is made before the call. Whatever the call does not compute stays -1.0, the value chosen to mean no orders.
  3. The condition. orders != 0 is True for shops 0 and 3 and False for shops 1 and 2. It is broadcast with the inputs and decides each position.
  4. Computed where True. np.divide computes only where the condition is True: 3.0 / 2.0 = 1.5 and 4.0 / 8.0 = 0.5. No division by zero ever happens.
  5. Left alone where False. Positions 1 and 2 are left as they were in out, -1.0. Without out=, those positions would hold whatever the new memory contained, which is why NumPy warns.
  6. The result is out. The call returns out itself, so result is out is True, and the four answers are 1.5, -1.0, -1.0 and 0.5.

The run divides the plain way first, then with out= and where=, and then shows two more uses of the arguments:

Division with and without out= and where=, and dtype= against overflow Python · outwhere/safe_divide.py
"""Divide without dividing by zero: where= chooses the positions, out= holds what the others keep."""
import warnings

import numpy as np

sales = np.array([3.0, 5.0, 0.0, 4.0])  # revenue of four shops
orders = np.array([2.0, 0.0, 0.0, 8.0])  # their numbers of orders
with warnings.catch_warnings(record=True) as caught:
    warnings.simplefilter("always")
    plain = sales / orders
print("sales / orders:", plain)
for w in caught:
    print("  ", f"{w.category.__name__}: {w.message}")
out = np.full(4, -1.0)  # what a skipped position keeps
result = np.divide(sales, orders, out=out, where=orders != 0)
print("np.divide(sales, orders, out=out, where=orders != 0):", result, "| result is out:", result is out)
with warnings.catch_warnings(record=True) as caught:
    warnings.simplefilter("always")
    np.divide(sales, orders, where=orders != 0)
print("where= without out=:", f"{caught[0].category.__name__}: {caught[0].message}")
with np.errstate(divide="ignore", invalid="ignore"):
    quiet = sales / orders
print("inside np.errstate:", quiet, "| np.isfinite:", np.isfinite(quiet))
total = np.zeros(4)
np.add(total, sales, out=total)  # the result goes into total: no new array
print("np.add(total, sales, out=total):", total)
small = np.array([200, 100], dtype=np.uint8)
print("np.add(small, small):", np.add(small, small), "| with dtype=np.int16:", np.add(small, small, dtype=np.int16))

Output

sales / orders: [1.5 inf nan 0.5]
   RuntimeWarning: divide by zero encountered in divide
   RuntimeWarning: invalid value encountered in divide
np.divide(sales, orders, out=out, where=orders != 0): [ 1.5 -1.  -1.   0.5] | result is out: True
where= without out=: UserWarning: 'where' used without 'out', expect uninitialized memory in output. If this is intentional, use out=None.
inside np.errstate: [1.5 inf nan 0.5] | np.isfinite: [ True False False  True]
np.add(total, sales, out=total): [3. 5. 0. 4.]
np.add(small, small): [144 200] | with dtype=np.int16: [400 200]

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 safe_divide.py

The plain division printed [1.5 inf nan 0.5] and two RuntimeWarnings. With out=np.full(4, -1.0) and where=orders != 0, nothing was divided by zero and the two shops without orders kept −1.0: NumPy’s ufunc reference says a False position means “leave the value in the output alone”. Without out=, those positions would hold whatever the new memory contained, which is why NumPy has warned since 2.4 when where= comes without out=. np.errstate only silences the warnings; the inf and nan stay in the data, which np.isfinite shows.

out= is useful without where= as well: np.add(total, sales, out=total) writes into total and makes no new array, which is what total += sales does. dtype= sets the type the loop computes in: the uint8 values 200 and 100 doubled gave 144 and 200, and with dtype=np.int16 they gave 400 and 200.

Key idea

where= decides which positions a ufunc computes and out= holds every other position, so give out= an array that already contains what a skipped position should show.

Checkpoint

Check: out=, where= and dtype=

  1. Question 1 of 2 out = np.full(3, 9.0), then np.sqrt([4.0, -1.0, 9.0], out=out, where=[True, False, True]). What is out?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: [2., 9., 3.]

    With where=, out is both the place the results go and the source of the values that are skipped.

  2. Question 2 of 2 Why does NumPy 2.5 warn about np.divide(a, b, where=b != 0) without out=?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The skipped positions are left with whatever the new memory held

    Give out= an array that already holds the values you want at the skipped positions, as np.full(n, -1.0) did.

Methods turn a ufunc into a reduction, a running total or a table

Every ufunc that takes two inputs and gives one output has five methods. NumPy’s guide lists them as four that work like reductions, reduce, accumulate, reduceat and outer, and one for in-place work, at. The run tries each on numbers small enough to check by hand:

reduce, accumulate, outer, reduceat and at, and a deposit that grows Python · methods/growth.py
"""The ufunc methods reduce, accumulate, outer, reduceat and at, on numbers small enough to check."""
import numpy as np

print("np.add.reduce([1, 2, 3, 4]):", np.add.reduce([1, 2, 3, 4]), "| np.add.accumulate:", np.add.accumulate([1, 2, 3, 4]))
print("np.multiply.accumulate(np.arange(1, 6)):", np.multiply.accumulate(np.arange(1, 6)))
print("np.maximum.accumulate([1, 3, 2, 5, 4]):", np.maximum.accumulate([1, 3, 2, 5, 4]))
print("np.multiply.outer([1, 2, 3], [10, 20]):")
print(np.multiply.outer([1, 2, 3], [10, 20]))
print("np.add.reduceat(np.arange(8), [0, 4, 6]):", np.add.reduceat(np.arange(8), [0, 4, 6]))
v = np.zeros(4, dtype=np.int64)
np.add.at(v, [0, 0, 2], 5)
print("np.add.at(v, [0, 0, 2], 5):", v)

monthly = np.full(120, 0.08 / 12)  # 8 % a year, added monthly, for 10 years
balance = 100_000 * np.multiply.accumulate(1 + monthly)
print(f"100,000 after 1 year: {balance[11]:,.2f} | after 10 years: {balance[-1]:,.2f}")
print(f"closed form, 100,000 * (1 + 0.08 / 12) ** 120: {100_000 * (1 + 0.08 / 12) ** 120:,.2f}")
monthly[60:] = 0.06 / 12  # the rate falls to 6 % after five years
balance = 100_000 * np.multiply.accumulate(1 + monthly)
print(f"with 6 % from year 6: {balance[-1]:,.2f}")

Output

np.add.reduce([1, 2, 3, 4]): 10 | np.add.accumulate: [ 1  3  6 10]
np.multiply.accumulate(np.arange(1, 6)): [  1   2   6  24 120]
np.maximum.accumulate([1, 3, 2, 5, 4]): [1 3 3 5 5]
np.multiply.outer([1, 2, 3], [10, 20]):
[[10 20]
 [20 40]
 [30 60]]
np.add.reduceat(np.arange(8), [0, 4, 6]): [ 6  9 13]
np.add.at(v, [0, 0, 2], 5): [10  0  5  0]
100,000 after 1 year: 108,299.95 | after 10 years: 221,964.02
closed form, 100,000 * (1 + 0.08 / 12) ** 120: 221,964.02
with 6 % from year 6: 200,957.86

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 growth.py

reduce combines all the values with the ufunc, so np.add.reduce is a sum, 10. accumulate keeps every intermediate result: the running sum [1 3 6 10], the running product [1 2 6 24 120] and the running maximum [1 3 3 5 5]. outer applies the ufunc to every pair, a 3 by 2 table. reduceat reduces the segments that start at the positions you give, so [0, 4, 6] sums 0 to 3, 4 to 5 and 6 to 7, [6 9 13]. at is the unbuffered operation that the lesson on boolean masks and fancy indexing used for repeated indices, and here it added 5 twice at position 0. A ufunc with one input has none of this: np.sin.reduce raises a ValueError.

accumulate is a growth model in one line. A deposit of 100,000 at 8 % a year, with interest added monthly, grows by a factor of 1 + 0.08 / 12 each month, and the running product of 120 factors gives every month’s balance: 108,299.95 after one year and 221,964.02 after ten, equal to the closed form. Because each month has a factor of its own, a rate that falls to 6 % after five years needs only changed factors, and gives 200,957.86. The model below starts at the run’s values; change the rate or the years and watch the balance:

Interactive · change it and see A deposit of 100,000 at 8 % a year, interest added monthly Set a starting amount, a yearly rate, how often interest is added and an amount added at the end of every month.
Balance and money paid in, year by year: amount against years.0246810050k1 L1.5 L2 L2.5 Lyearsamountbalancepaid in

100,000 at 8 % a year grows to 221,964 in 10 years; 121,964 of it is interest.

Balance after 10 years 221,964
Paid in 100,000
Interest earned 121,964
Effective yearly rate 8.30 %
The numbers as a table
Balance at the end of each year
YearPaid inBalance
1100,000108,300
2100,000117,289
3100,000127,024
4100,000137,567
5100,000148,985
6100,000161,350
7100,000174,742
8100,000189,246
9100,000204,953
10100,000221,964

Its balance after 10 years, 221,964, is the program’s last value rounded to a whole number.

Compound Interest Calculator Enter 100,000 at 8 % for 10 years with monthly compounding, and compare the final balance with the run's 221,964.02.

Checkpoint

Check: reduce, accumulate, outer, reduceat and at

  1. Question 1 of 2 What is np.multiply.accumulate([1, 2, 3, 4])?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: [1, 2, 6, 24]

    reduce gives one result; accumulate gives the result after each element, which is how the balances grew month by month.

  2. Question 2 of 2 What does np.add.reduceat(np.arange(8), [0, 4, 6]) give?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: [6, 9, 13]

    reduceat reduces between consecutive start positions, which sums irregular groups, such as days into weeks of different length, in one call.

np.vectorize is a Python loop with an array interface

np.vectorize turns a Python function of one number into one that accepts arrays. The run times it against a list comprehension and against the same fee rule written with ufuncs, on 100,000 seeded distances:

A delivery fee for 100,000 orders, three ways Python · vectorize/timing.py
"""np.vectorize is a Python loop behind an array interface: timed against a ufunc expression."""
import platform
import timeit

import numpy as np

rng = np.random.default_rng(7)  # seeded: every run prices the same 100,000 orders
km = rng.uniform(0, 10, 100_000)  # delivery distances in km


def fee(d):
    """The delivery fee of one order: 40 up to 2 km, then 12 a km more."""
    return 40.0 if d <= 2 else 40.0 + 12.0 * (d - 2)


vfee = np.vectorize(fee)
ways = [
    ("list comprehension", lambda: np.array([fee(d) for d in km.tolist()])),
    ("np.vectorize(fee)", lambda: vfee(km)),
    ("np.where(...)", lambda: np.where(km <= 2, 40.0, 40.0 + 12.0 * (km - 2))),
]
results = {}
for name, fn in ways:
    results[name] = fn()
    best = min(timeit.repeat(fn, number=1, repeat=7))
    print(f"{name:<19} {best * 1000:8.3f} ms")
same = all(np.array_equal(results["np.where(...)"], r) for r in results.values())
print("the three give the same fees:", same, "| total", round(float(results["np.where(...)"].sum()), 2))
print("np.vectorize(fee) is a ufunc:", isinstance(vfee, np.ufunc))
print(f"CPython {platform.python_version()}, NumPy {np.__version__}, {platform.machine()}")

Output

list comprehension    13.348 ms
np.vectorize(fee)     15.161 ms
np.where(...)          0.176 ms
the three give the same fees: True | total 7844441.05
np.vectorize(fee) is a ufunc: False
CPython 3.14.8, NumPy 2.5.3, arm64

This output changes from run to run: the times depend on the computer and on what else it is doing, so every run prints different milliseconds; the fees and their total are the same in every run

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 timing.py

The list comprehension and np.vectorize both call fee once per order, 100,000 Python calls, and took about 13 and 15 ms; the np.where expression computed both branches for every order with ufuncs and kept one per element, in about 0.18 ms, about 86 times faster on this run. All three gave the same fees. NumPy’s reference is plain about it: vectorize is provided “primarily for convenience, not for performance”, and its implementation “is essentially a for loop”.

It has a second trap: it learns the output dtype from the first result.

The first result decides the dtype of every result Python · otypes/first_result.py
"""np.vectorize takes its output dtype from the first result, unless otypes says otherwise."""
import numpy as np


def discount(price):
    """10 % off above 100, nothing off up to 100."""
    return 0 if price <= 100 else price * 0.1


prices = np.array([80.0, 155.0, 99.5, 129.0])
print("np.vectorize(discount):                ", np.vectorize(discount)(prices))
print("np.vectorize(discount, otypes=[float]):", np.vectorize(discount, otypes=[float])(prices))
print("np.where(prices > 100, prices * 0.1, 0.0):", np.where(prices > 100, prices * 0.1, 0.0))

Output

np.vectorize(discount):                 [ 0 15  0 12]
np.vectorize(discount, otypes=[float]): [ 0.  15.5  0.  12.9]
np.where(prices > 100, prices * 0.1, 0.0): [ 0.  15.5  0.  12.9]

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 first_result.py

discount(80.0) returned the integer 0, so every later result was cast to an integer and 15.5 became 15. Passing otypes=[float] fixes it, and the np.where version never had the problem.

Expecting np.vectorize to make a loop fast

Wrapping a Python function in np.vectorize lets it accept arrays but keeps one Python call per element. Write the rule with ufuncs instead: comparisons with np.where for an if and else, np.select for several branches.

Checkpoint

Check: np.vectorize and real vectorisation

  1. Question 1 of 2 Why was np.vectorize(fee) no faster than a list comprehension in the run?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It still calls the Python function once for every element

    Real vectorisation means writing the rule with ufuncs, as the np.where expression did.

  2. Question 2 of 2 discount returns 0 for prices up to 100 and price * 0.1 above. What does np.vectorize(discount)([80.0, 155.0]) give?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: [0, 15], integers

    NumPy's reference: the output dtype comes from calling the function on the first element, unless otypes is given.

Practise: divide safely and keep running maxima

Two small functions in plain Python reproduce what the ufunc arguments and methods of this lesson do: a division that skips zero divisors, as out= with where= does, and running maxima that either spread NaN or skip it. The sample tests run here:

Code lab · 2 steps

Divide safely and keep running maxima

Divide two lists the way np.divide with out= and where= does, then compute running maxima as np.maximum.accumulate and np.fmax.accumulate do.

  1. Divide two lists the way out= and where= do To do
  2. Running maxima, with NaN kept and with NaN skipped To do

Exercise · Easy · Python

Divide two lists the way out= and where= do

Write safe_ratio(a, b, fill) in ratio.py, with Python's own lists. It returns what np.divide(a, b, out=np.full(len(a), float(fill)), where=b != 0) returns, as a list of floats:

  • a[i] / b[i] wherever b[i] != 0;
  • float(fill) wherever b[i] == 0, so nothing is ever divided by zero;
  • every value a float, also when the inputs are integers.

The comparison decides each position, as where= does: -0.0 != 0 is False, so a negative zero gets the fill, and nan != 0 is True, so a NaN divisor gives NaN. Lists of different lengths raise ValueError. The lesson's run gives the first check: safe_ratio([3.0, 5.0, 0.0, 4.0], [2.0, 0.0, 0.0, 8.0], -1.0) is [1.5, -1.0, -1.0, 0.5].

Starter code · ratio.py

def safe_ratio(a, b, fill):
    """Return a[i] / b[i] where b[i] != 0 and float(fill) elsewhere, as floats (np.divide with out= and where=)."""
    return [x / y if y > 0 else fill for x, y in zip(a, b)]
The sample tests · test_ratio.py
import math

from ratio import safe_ratio


def test_the_lessons_shops():
    """gives the ratios the lesson's run printed, with the fill where there were no orders"""
    assert safe_ratio([3.0, 5.0, 0.0, 4.0], [2.0, 0.0, 0.0, 8.0], -1.0) == [1.5, -1.0, -1.0, 0.5]


def test_floats_everywhere():
    """returns floats, also for integer inputs and an integer fill"""
    result = safe_ratio([7, 9, 1], [2, 3, 0], 0)
    assert result == [3.5, 3.0, 0.0]
    assert all(isinstance(x, float) for x in result)


def test_negative_divisors_and_negative_zero():
    """divides by negative numbers, and treats -0.0 as zero"""
    assert safe_ratio([6.0, 1.0], [-3.0, -0.0], 9.0) == [-2.0, 9.0]


def test_nan_divisor():
    """divides by NaN, because NaN != 0, and so gives NaN"""
    assert math.isnan(safe_ratio([1.0], [math.nan], 0.0)[0])


def test_lengths_must_match():
    """refuses lists of different lengths"""
    try:
        safe_ratio([1.0, 2.0], [1.0], 0.0)
    except ValueError:
        return
    assert False, "lists of different lengths must raise ValueError"
A hint

Walk over zip(a, b) and test y != 0 before dividing: Python raises ZeroDivisionError for 1 / 0.0, where NumPy only warns, so the test has to come first. Write float(fill) and x / y (true division, which always gives a float) into the result.

The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Exercise · Medium · Python

Running maxima, with NaN kept and with NaN skipped

Write two functions in runmax.py, with Python's own lists. Each returns the largest value so far at every position, as the accumulate method of a ufunc does, and they differ only in what a NaN does:

  • running_max(x) matches np.maximum.accumulate(x): np.maximum gives NaN whenever either input is NaN, so from the first NaN on, every value is NaN.
  • running_fmax(x) matches np.fmax.accumulate(x): np.fmax takes the other input when one is NaN, so NaN is skipped. Before the first number there is nothing to compare with, so a leading NaN stays NaN.

An empty list gives an empty list. The lesson's run gives the first check: running_max([1, 3, 2, 5, 4]) is [1, 3, 3, 5, 5]. With NaN, NumPy turns [2.0, nan, 1.0, 5.0] into [2.0, nan, nan, nan] with np.maximum.accumulate and into [2.0, 2.0, 2.0, 5.0] with np.fmax.accumulate.

Starter code · runmax.py

def running_max(x):
    """Return the running maximum of x, as np.maximum.accumulate gives it (NaN spreads)."""
    return [max(x[: i + 1]) for i in range(len(x))]


def running_fmax(x):
    """Return the running maximum of x, as np.fmax.accumulate gives it (NaN is skipped)."""
    return [max(x[: i + 1]) for i in range(len(x))]
The sample tests · test_runmax.py
import math

from runmax import running_fmax, running_max

nan = math.nan


def same(got, expected):
    """True when the lists agree position by position, counting NaN as equal to NaN."""
    return len(got) == len(expected) and all((math.isnan(g) and math.isnan(e)) or g == e for g, e in zip(got, expected))


def test_the_lessons_maxima():
    """gives the running maximum the lesson's run printed"""
    assert running_max([1, 3, 2, 5, 4]) == [1, 3, 3, 5, 5]
    assert running_fmax([1, 3, 2, 5, 4]) == [1, 3, 3, 5, 5]
    assert running_max([-3, -1, -2]) == [-3, -1, -1]


def test_nan_spreads_in_running_max():
    """makes every value NaN from the first NaN on, as np.maximum does"""
    assert same(running_max([2.0, nan, 1.0, 5.0]), [2.0, nan, nan, nan])
    assert same(running_max([nan, 1.0]), [nan, nan])


def test_nan_is_skipped_in_running_fmax():
    """skips NaN when there is a number to keep, as np.fmax does"""
    assert same(running_fmax([2.0, nan, 1.0, 5.0]), [2.0, 2.0, 2.0, 5.0])
    assert same(running_fmax([nan, 1.0, nan, 3.0]), [nan, 1.0, 1.0, 3.0])


def test_empty():
    """gives an empty list for no values"""
    assert running_max([]) == []
    assert running_fmax([]) == []
A hint

Keep the best value so far in a variable and append it at each step. math.isnan(v) tells a NaN apart (a NaN is not even equal to itself). For running_max, once the best value is NaN it stays NaN; for running_fmax, ignore a NaN whenever there is a number to keep instead.

The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

The questions that follow cover all five ideas, and the first of them comes from the idea your answers so far show as weakest:

Practice that adapts to you

Practise this lesson

15 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 15 How many typed loops did np.add.ntypes report on NumPy 2.5.3?

    Type a number.

    Show the answer: Question 1 of 15

    Answer: 22 loops

    np.add has 22 loops, one for each supported combination of input types, from ??->? for booleans to OO->O for Python objects.

  2. Question 2 of 15 An int64 array plus a float64 array: which loop runs, and what dtype is the result?

    Choose one answer.

    Show the answer: Question 2 of 15

    Answer: The dd->d loop, after the int64 values are cast to float64; the result is float64

    The input dtypes pick the loop; a mix is resolved by a safe cast to a common type before the compiled loop runs.

  3. Question 3 of 15 np.sin(np.pi) printed 1.2246467991473532e-16, not 0. How should a test check that a value is zero here?

    Choose one answer.

    Show the answer: Question 3 of 15

    Answer: np.isclose(np.sin(np.pi), 0), with a tolerance

    Ufuncs compute in floating point, so exact comparisons of results that should be equal mathematically need a tolerance.

  4. Question 4 of 15 Which call gives a remainder with the sign of the dividend, as C's % does?

    Choose one answer.

    Show the answer: Question 4 of 15

    Answer: np.fmod(a, b)

    np.remainder and // go together (floor), np.fmod and np.trunc together (truncation).

  5. Question 5 of 15 What does np.array([2, 3]) ** -1 do?

    Choose one answer.

    Show the answer: Question 5 of 15

    Answer: It raises ValueError: Integers to negative integer powers are not allowed

    NumPy's reference: an integer type raised to a negative integer power raises a ValueError.

  6. Question 6 of 15 np.round(2.675, 2) gave 2.68 but Python's round(2.675, 2) gave 2.67. Why?

    Choose one answer.

    Show the answer: Question 6 of 15

    Answer: np.round multiplies by 100, rounds and divides, and the multiplication rounds 267.4999… up to 267.5

    For money and other decimal rules, round with Python's decimal module or in whole units; np.round is fast, not exact.

  7. Question 7 of 15 What does np.add(total, sales, out=total) do?

    Choose one answer.

    Show the answer: Question 7 of 15

    Answer: It adds sales into total in place and returns total itself

    out= reuses memory, which matters inside loops over large arrays: total += sales is the same call.

  8. Question 8 of 15 small = np.array([200, 100], dtype=np.uint8). Which call gives [400, 200]?

    Choose one answer.

    Show the answer: Question 8 of 15

    Answer: np.add(small, small, dtype=np.int16)

    dtype= sets the type the loop computes in, so it can prevent overflow without converting the inputs first.

  9. Question 9 of 15 Inside with np.errstate(divide="ignore", invalid="ignore"):, what does sales / orders give?

    Choose one answer.

    Show the answer: Question 9 of 15

    Answer: The same [1.5, inf, nan, 0.5], without the warnings

    Silencing a warning keeps inf and nan in the data, so check np.isfinite or use where= when they must not appear.

  10. Question 10 of 15 What shape does np.multiply.outer([1, 2, 3], [10, 20]) have?

    Choose one answer.

    Show the answer: Question 10 of 15

    Answer: (3, 2)

    outer is the table of every pair: a multiplication table, or with np.subtract.outer all pairwise differences.

  11. Question 11 of 15 In the run, 100,000 grew at 8 % a year, added monthly, with np.multiply.accumulate(1 + monthly). What was the balance after 10 years, to the nearest whole number?

    Type a number.

    Show the answer: Question 11 of 15

    Answer: 221964 in the deposit's currency (anything from 221963 to 221965 counts)

    The last running product is (1 + 0.08 / 12) to the power 120, about 2.2196, so the balance is 221,964.02, the same as the closed form the run printed.

  12. Question 12 of 15 Why does the run use np.multiply.accumulate instead of (1 + r) ** n for the case where the rate falls to 6 % after five years?

    Choose one answer.

    Show the answer: Question 12 of 15

    Answer: accumulate multiplies whatever factor each month has, so a rate that changes needs no new formula

    A running product of factors is the general tool; the closed form is a special case for a constant factor.

  13. Question 13 of 15 What does np.vectorize(f) give you?

    Choose one answer.

    Show the answer: Question 13 of 15

    Answer: A function that accepts arrays and calls f once per element, in Python

    Use vectorize to make a scalar function accept arrays when speed does not matter, and ufunc expressions when it does.

  14. Question 14 of 15 The run measured np.vectorize(fee) at about 15.2 ms and the np.where expression at about 0.176 ms. About how many times faster was np.where?

    Type a number.

    Show the answer: Question 14 of 15

    Answer: 86 times (anything from 76 to 96 counts)

    15.2 / 0.176 is about 86. Timings vary from run to run, but the gap of about two orders of magnitude is the point: one Python call per element against one compiled pass.

  15. Question 15 of 15 Which expression computes the delivery fee, 40 up to 2 km and 40 + 12 a km beyond, for an array km without a Python loop?

    Choose one answer.

    Show the answer: Question 15 of 15

    Answer: np.where(km <= 2, 40.0, 40.0 + 12.0 * (km - 2))

    An if/else on each element becomes np.where on whole arrays; more branches become np.select.

Interview questions

Warm-up (fresher): What is a ufunc, and why is np.add(a, b) faster than a Python loop over the elements?

A ufunc, or universal function, is a NumPy function that works element by element on whole arrays, with broadcasting and type casting built in; np.add, np.sqrt and np.maximum are ufuncs, and operators such as + call them. Each ufunc holds compiled loops, one for each combination of input types it supports, and picks one from the dtypes of its inputs, for example the float64 loop for two float64 arrays. That loop then runs over every element in compiled code. A Python loop instead creates and inspects a Python object for every value and runs the interpreter for every addition, which is why, in the lesson’s timed run, a delivery fee written with ufuncs ran about 86 times faster than one Python call per order. Ufuncs also take out= to reuse memory and where= to skip positions, and have methods such as reduce and accumulate.

Two interview questions apply these rules to real data, a conversion report that divides by zero and image arithmetic that wraps round; their first steps are on the module’s question page:

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Fresher to mid

    A daily report computes conversion = purchases / visits for 50,000 pages. Pages with no visits show inf or NaN on the dashboard, and the job's log fills with RuntimeWarnings. Make pages without visits show "no data", stop the warnings for the right reason, and say how you would test the fix.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Fresher to mid

    Brightening a uint8 image with img + 60 turns some bright pixels nearly black, and averaging two frames with (a + b) / 2 gives values that are far too small in bright areas. Explain both, and fix them without float copies of every frame where they are not needed.

    Model answer, Pro. First step and follow-ups

Key takeaways

  • Operators on arrays call ufuncs, and a ufunc picks a compiled loop from its inputs’ dtypes, so the dtypes decide the result type and whether a result wraps round, as uint8 200 + 200 = 144 did.
  • // and % follow floor rules with the remainder’s sign from the divisor, integers to negative integer powers raise, and np.round rounds halves to even with a fast method that can differ from Python’s round.
  • where= computes only the positions you choose and out= keeps every other one, which divides safely by zero; np.errstate only silences warnings.
  • reduce, accumulate, outer, reduceat and at turn a two-input ufunc into a total, a running result, a table, segment totals or an unbuffered update.
  • np.vectorize calls the Python function once per element and takes its dtype from the first result, so rewrite the rule with ufuncs and np.where when speed matters.

Terms in this lesson

floor division
Division that rounds the quotient down, towards minus infinity: -7 // 2 is -4. Its remainder, -7 % 2, is 1, which has the sign of the divisor, so the quotient times the divisor plus the remainder gives -7 back.
rounding half to even
A rounding rule that sends a value exactly halfway between two candidates to the even one: 0.5 and -0.5 to 0, 1.5 and 2.5 to 2. np.round and Python's round both use it.
ufunc
A universal function: a NumPy function such as np.add or np.sqrt that works element by element on whole arrays, with one compiled loop for each combination of input types it supports. Operators such as + call ufuncs.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.