Skip to content

Math Operations & Universal Functions

NumPy's superpower: element-wise math without any loops.

Element-wise arithmetic

import numpy as np

a = np.array([1, 2, 3, 4])
b = np.array([10, 20, 30, 40])

print(a + b)          # element-wise addition
print(a - b)
print(a * b)          # NOT matrix multiply!
print(b / a)
print(a ** 2)         # square each element
print(b % a)          # modulo

Math with a scalar

NumPy applies the scalar to every element:

import numpy as np

a = np.arange(1, 6)

print(a + 10)       # [11 12 13 14 15]
print(a * 2)        # [ 2  4  6  8 10]
print(a / 2)        # [0.5 1.0 1.5 2.0 2.5]
print(a > 3)        # [False False False True True]

Comparison operators — return boolean arrays

import numpy as np

a = np.array([1, 5, 3, 9, 2])
print(a > 3)        # [False True False True False]
print(a == 5)       # [False True False False False]
print(a != 5)       # [True False True True True]

Universal Functions (ufuncs)

NumPy has fast vectorized versions of common math:

import numpy as np

a = np.array([1, 4, 9, 16, 25])

print(np.sqrt(a))       # square root
print(np.exp(a))         # e^x
print(np.log(a))         # natural log
print(np.log2(a))        # log base 2
print(np.log10(a))       # log base 10
print(np.abs([-3, -2, 1, 4]))   # absolute value

Trigonometry:

import numpy as np

angles = np.array([0, 30, 45, 60, 90]) * np.pi / 180   # convert to radians

print("sin:", np.round(np.sin(angles), 3))
print("cos:", np.round(np.cos(angles), 3))
print("tan:", np.round(np.tan(angles), 3))

Rounding:

import numpy as np

a = np.array([1.234, 5.678, -3.49, 0.5, 1.5, 2.5])

print(np.round(a, 1))    # [ 1.2  5.7 -3.5  0.   2.   2. ]  ← banker's rounding!
print(np.floor(a))       # round DOWN
print(np.ceil(a))         # round UP
print(np.trunc(a))        # drop the decimal (toward zero)

Heads up: np.round(0.5) → 0, np.round(1.5) → 2. This is "banker's rounding" — rounds to even. Python's round() does the same.

Why ufuncs are fast

import numpy as np
import time

N = 5_000_000

# Pure Python
import math
py_list = list(range(N))
t = time.time()
result = [math.sqrt(x) for x in py_list]
py_time = time.time() - t

# NumPy
np_array = np.arange(N)
t = time.time()
result = np.sqrt(np_array)
np_time = time.time() - t

print(f"Python: {py_time*1000:.0f} ms")
print(f"NumPy : {np_time*1000:.0f} ms")
print(f"Speedup: {py_time/np_time:.0f}x")

Same work, ~50× faster.

In-place operations

a += 5 works but creates a new array. To modify in place (saves memory):

import numpy as np

a = np.arange(5).astype(float)
print("before:", a)

# In-place
a += 10
print("after +=:", a)

# Or use the `out=` argument
np.sqrt(a, out=a)
print("after sqrt:", a)

For huge arrays, in-place ops avoid memory allocation — important for performance.

min, max, sum, mean

These are "reductions" — they collapse the array to a single number (or a vector if you specify an axis).

import numpy as np

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

print("min :", a.min())
print("max :", a.max())
print("sum :", a.sum())
print("mean:", a.mean())
print("std :", a.std())
print("argmin:", a.argmin())  # index of the min
print("argmax:", a.argmax())  # index of the max

We'll cover axis-wise reductions in chapter 8.

np.where() — vectorized if/else

np.where(condition, x, y) — for each element: pick from x if condition is True, else from y.

import numpy as np

scores = np.array([85, 42, 77, 95, 33, 60, 88])

result = np.where(scores >= 50, "pass", "fail")
print(result)

# Clip negatives — replace negative values with 0
data = np.array([-3, 5, -1, 4, -7, 2])
clipped = np.where(data < 0, 0, data)
print(clipped)

Just give the condition (no x/y) → indices where True:

import numpy as np

a = np.array([1, 5, 3, 9, 2, 8])
indices = np.where(a > 4)
print(indices)            # (array([1, 3, 5]),)
print(a[indices])         # [5 9 8]

np.clip() — bound the values

Faster than np.where for clipping:

import numpy as np

a = np.array([-3, 5, 100, 200, 50, -10])
print(np.clip(a, 0, 100))    # everything < 0 → 0, > 100 → 100

Two-array math — same shape required

import numpy as np

a = np.array([1, 2, 3, 4])
b = np.array([10, 20, 30, 40])

print(a + b)         # works

# Different lengths → ValueError
# c = np.array([1, 2])
# print(a + c)

For shape mismatches, NumPy may still let it work — see Broadcasting.

Mini-project — Celsius to Fahrenheit

import numpy as np

celsius = np.array([0, 10, 20, 25, 30, 100])
fahrenheit = celsius * 9 / 5 + 32

for c, f in zip(celsius, fahrenheit):
    print(f"{c:5.1f}°C  =  {f:5.1f}°F")

Cheatsheet

Operation NumPy
Add, sub, mul, div a + b, a - b, a * b, a / b
Power a ** 2
Modulo a % 2
Sqrt, exp, log np.sqrt, np.exp, np.log
Trig np.sin, np.cos, np.tan
Round np.round, np.floor, np.ceil, np.trunc
Abs np.abs
Sum, min, max .sum(), .min(), .max()
Index of min/max .argmin(), .argmax()
Condition → 1/0 (a > 5).astype(int)
If/else vectorized np.where(cond, x, y)
Clip to range np.clip(a, low, high)

Common pitfalls

  • * is element-wise, not matrix multiplication — for matrix multiply use @ or np.matmul.
  • np.log(0) returns -inf + a warning — clip first or use np.log1p(x) (which is log(1+x)).
  • np.round uses banker's roundinground(0.5)==0. Surprising.
  • Integer divisionnp.array([10]) / 3 is 3.333, but np.array([10]) // 3 is 3.
  • Operations on arrays of different shapes — may raise an error OR succeed via broadcasting. Read the next chapter.

Practice

What does this print?

Expected: [1. 2. 3.]

import numpy as np
print(np.sqrt(np.array([1, 4, 9])))

Compute the matrix product of A and B (not element-wise)

Expected: [[19 22] [43 50]]

import numpy as np
A = np.array([[1, 2], [3, 4]])
B = np.array([[5, 6], [7, 8]])
print(A * B)         # bug: * is element-wise — use @ for matrix product

Quiz — Quick check

What you remember

Q1. In NumPy, a * b for two arrays of the same shape does…

  • Element-wise multiplication
  • Matrix multiplication
  • The dot product
  • Throws an error

Why: * is always element-wise in NumPy. For matrix multiplication use @ (Python 3.5+) or np.matmul(a, b).

Q2. np.where(cond, x, y) is…

  • A loop helper
  • A vectorized if/else — picks from x where cond is True, else from y
  • A boolean array constructor
  • Deprecated in favor of np.if

Why: np.where(a > 0, a, 0) clips negatives to zero. Same as a list comprehension but executes in compiled C — much faster.

Q3. What does np.round(0.5) return?

  • 0 (banker's rounding to nearest even)
  • 1
  • 0.5
  • Raises an error

Why: NumPy uses banker's rounding (round-half-to-even). So 0.5 → 0, 1.5 → 2, 2.5 → 2. Same as Python's built-in round(). Surprising the first time.

Common doubts

What's the difference between * and @?

* is element-wise multiplication: [[1,2],[3,4]] * [[1,2],[3,4]][[1,4],[9,16]]. @ is matrix multiplication (dot product): the standard linear-algebra operation. They're completely different — using the wrong one is a common bug.

Why does np.log(0) not raise an error?

NumPy returns -inf and emits a runtime warning by default. To get a clean result, clip first: np.log(np.clip(x, 1e-10, None)). Or use np.log1p(x) which computes log(1+x) accurately for small x.

When should I use in-place ops like a += 5 vs a = a + 5?

Use in-place (+=, *=, etc.) for large arrays to avoid allocating a new buffer — saves memory and time. For small arrays it doesn't matter. Caveat: in-place ops change a directly, which can surprise other code holding a reference.

What's next

Broadcasting