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'sround()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@ornp.matmul. - ❗
np.log(0)returns-inf+ a warning — clip first or usenp.log1p(x)(which islog(1+x)). - ❗
np.rounduses banker's rounding —round(0.5)==0. Surprising. - ❗ Integer division —
np.array([10]) / 3is3.333, butnp.array([10]) // 3is3. - ❗ Operations on arrays of different shapes — may raise an error OR succeed via broadcasting. Read the next chapter.
Practice¶
Compute the matrix product of A and B (not element-wise)
Expected: [[19 22] [43 50]]
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+) ornp.matmul(a, b).
Q2. np.where(cond, x, y) is…
- A loop helper
- A vectorized if/else — picks from
xwherecondis True, else fromy - 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-inround(). 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.