NumPy: Working with Numbers
1 · The lesson
readA Python list of a million numbers is flexible but slow. A NumPy array of a million numbers does maths 10 to 100 times faster, uses a fraction of the memory, and reads more like the formula you'd write on paper. NumPy is the foundation everything else in the DS stack sits on — pandas, scikit-learn, PyTorch — they all return or accept NumPy arrays.
This lesson gives you the working subset. The full NumPy track goes deeper.
1. Why NumPy Exists
import numpy as np import time n = 1_000_000 # Pure Python py = list(range(n)) t0 = time.perf_counter() py_doubled = [x * 2 for x in py] py_time = time.perf_counter() - t0 # NumPy arr = np.arange(n) t0 = time.perf_counter() arr_doubled = arr * 2 np_time = time.perf_counter() - t0 print(f"Python list: {py_time*1000:.1f} ms") print(f"NumPy array: {np_time*1000:.1f} ms") print(f"Speedup: {py_time/np_time:.0f}x")
The speedup comes from two places: NumPy stores numbers in a contiguous typed buffer (no Python object overhead per element), and operations like arr * 2 run in compiled C. You write Python; the loops happen below your code.
The convention is import numpy as np. Always. Every example online assumes it.
2. Creating Arrays
import numpy as np # From a list a = np.array([1, 2, 3, 4, 5]) print(a) # [1 2 3 4 5] # Constructors zeros = np.zeros(5) # [0. 0. 0. 0. 0.] floats by default ones = np.ones((2, 3)) # 2x3 matrix of 1.0 empty = np.empty(3) # uninitialised — values are garbage, fast full = np.full(4, 7.5) # [7.5 7.5 7.5 7.5] # Ranges r1 = np.arange(0, 10) # [0 1 2 3 4 5 6 7 8 9] like range r2 = np.arange(0, 1, 0.25) # [0. 0.25 0.5 0.75] r3 = np.linspace(0, 1, 5) # [0. 0.25 0.5 0.75 1. ] N points incl. ends # Random rng = np.random.default_rng(42) # seedable generator (modern API) print(rng.random(3)) # 3 floats in [0, 1) print(rng.integers(1, 7, size=10)) # 10 dice rolls
arange excludes the stop; linspace includes both ends and takes a count. Use linspace for plot axes; arange for indices.
3. dtype, shape, ndim, size
Four attributes describe any array — memorise these four and you can read most NumPy code.
a = np.array([[1, 2, 3], [4, 5, 6]]) print(a.dtype) # int64 — every cell same type print(a.shape) # (2, 3) — rows, columns print(a.ndim) # 2 — number of dimensions print(a.size) # 6 — total cells print(a.itemsize) # 8 — bytes per cell
setup added so this can run · defines np
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) np = _AutoMock('np')
Common dtypes: int64, float64, bool, int32, float32. Force a dtype when you need to save memory or match an API:
b = np.array([1, 2, 3], dtype=np.float32) print(b.dtype) # float32
setup added so this can run · defines np
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) np = _AutoMock('np')
A mixed-type list silently produces an object array — slow and useless for maths. Watch for it:
bad = np.array([1, "two", 3.0]) print(bad.dtype) # <U21 — string array, not numbers
setup added so this can run · defines np
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) np = _AutoMock('np')
4. Vectorised Operations — No For Loops
Every arithmetic operator works elementwise. So do the comparisons. So do the maths functions.
a = np.array([1, 2, 3, 4, 5]) b = np.array([10, 20, 30, 40, 50]) print(a + b) # [11 22 33 44 55] print(a * 2) # [ 2 4 6 8 10] print(b / a) # [10. 10. 10. 10. 10.] print(a ** 2) # [ 1 4 9 16 25] print(np.sqrt(a)) # [1. 1.414 1.732 2. 2.236] print(np.log(a)) # natural log of each print(np.exp(a)) # e^x of each # Comparisons return boolean arrays print(a > 2) # [False False True True True] print((a > 1) & (a < 5)) # element-wise AND — note &, not `and`
setup added so this can run · defines np
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) np = _AutoMock('np')
The rule: if you find yourself writing a for-loop over a NumPy array, you're probably doing it wrong. Express the operation on the whole array.
5. Aggregations
Reduce an array to one number — or to one number per row / column.
a = np.array([[1, 2, 3], [4, 5, 6]]) print(a.sum()) # 21 print(a.mean()) # 3.5 print(a.max()) # 6 print(a.min()) # 1 print(a.std()) # 1.707... population stddev print(a.var()) # 2.916... # axis=0 collapses rows → result per column print(a.sum(axis=0)) # [5 7 9] # axis=1 collapses columns → result per row print(a.sum(axis=1)) # [ 6 15]
setup added so this can run · defines np
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) np = _AutoMock('np')
axis=0 is "down the rows". axis=1 is "across the columns". The collapsed dimension disappears from the output shape.
6. Indexing and Slicing
For 1D arrays, indexing matches Python lists.
a = np.arange(10, 60, 10) # [10 20 30 40 50] print(a[0]) # 10 print(a[-1]) # 50 print(a[1:4]) # [20 30 40] print(a[::2]) # [10 30 50] every other element
setup added so this can run · defines np
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) np = _AutoMock('np')
For 2D, give two indices — row then column:
m = np.array([[1, 2, 3], [4, 5, 6], [7, 8, 9]]) print(m[0, 0]) # 1 print(m[1, 2]) # 6 print(m[:, 0]) # [1 4 7] first column print(m[1, :]) # [4 5 6] second row print(m[0:2, 1:3]) # [[2 3] # [5 6]]
setup added so this can run · defines np
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) np = _AutoMock('np')
m[1, :] is the row at index 1. m[:, 0] is the column at index 0. The colon means "all of this axis".
7. Boolean Masking
Pick elements that satisfy a condition. This is the single most useful indexing trick.
a = np.array([12, 5, 9, 20, 3, 17]) mask = a > 10 print(mask) # [ True False False True False True] print(a[mask]) # [12 20 17] # In one line — common pattern print(a[a > 10]) # [12 20 17] # Combine conditions with & and | (parentheses required) print(a[(a > 5) & (a < 18)]) # [12 9 17] # Assign through a mask — clip values in place a[a < 5] = 0 print(a) # [12 5 9 20 0 17]
setup added so this can run · defines np
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) np = _AutoMock('np')
Boolean masking is how you filter a column. In pandas it looks identical — df[df["age"] > 30].
8. Reshaping
Same data, different shape. The total number of elements must match.
a = np.arange(12) print(a.shape) # (12,) b = a.reshape(3, 4) print(b) # [[ 0 1 2 3] # [ 4 5 6 7] # [ 8 9 10 11]] c = a.reshape(2, 2, 3) # 3D — 2 layers of 2x3 print(c.shape) # (2, 2, 3) # -1 means "figure this dimension out" d = a.reshape(4, -1) # 4 rows, NumPy fills in 3 cols print(d.shape) # (4, 3)
setup added so this can run · defines np
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) np = _AutoMock('np')
reshape returns a view where possible — no data is copied. Cheap.
9. A Teaser: Broadcasting
What happens when shapes don't match?
prices = np.array([[10, 20, 30], [40, 50, 60]]) tax_rate = 0.2 # Scalar + array print(prices * (1 + tax_rate)) # [[12. 24. 36.] # [48. 60. 72.]] # Row vector + 2D — broadcasts down the rows discount = np.array([0.9, 0.95, 1.0]) print(prices * discount) # [[ 9. 19. 30. ] # [36. 47.5 60. ]]
setup added so this can run · defines np
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) np = _AutoMock('np')
NumPy stretches the smaller shape to match the larger one without copying. That's broadcasting, and it's the deep magic behind concise array code. Full treatment in the advanced NumPy lesson.
Common Mistakes
- Pythonic for-loops over arrays.
for x in arr: arr[i] = x * 2instead ofarr * 2. Defeats every speed advantage NumPy offers. - Mixed-type arrays become
objectdtype.np.array([1, "two", 3])gives you a string array. Coerce types before constructing. - Integer arrays surprise on division.
np.array([1, 2, 3]) / 2is fine (returns float).np.array([1, 2, 3]) // 2truncates. Mind the difference. - Using
and/oron arrays. They raiseValueError. Use&and|with parenthesised operands. - Forgetting
axis=.a.sum()collapses the whole array.a.sum(axis=0)returns one value per column. - Confusing
np.arangewithnp.linspace.arangetakes a step,linspacetakes a count. Uselinspacewhen you care about the endpoints.
🎯 Your Turn — normalize()
Implement z-score normalisation: for each value subtract the mean and divide by the standard deviation. A normalised array has mean 0 and stdev 1 — a preprocessing step you'll do constantly before ML.
Skeleton:
import numpy as np def normalize(arr): """Return (arr - mean) / std. Handle std == 0 sensibly.""" arr = np.asarray(arr, dtype=float) # TODO 1: compute mean and std # TODO 2: subtract and divide — vectorised, no loops ... # Test data = np.array([10, 20, 30, 40, 50]) z = normalize(data) print(z) # roughly [-1.41 -0.71 0. 0.71 1.41] print(z.mean(), z.std()) # ~0.0, 1.0
Hint 1 — Two numbers, then one expression
Computem = arr.mean() and s = arr.std() first. Then return (arr - m) / s. NumPy broadcasts the scalars across the whole array — no loop needed.
Hint 2 — Constant arrays
If every element is the same,std is 0 and you'd divide by zero. Decide what to do: return zeros, raise ValueError, or replace std with 1. Returning zeros is the kindest default.
Show full solution
import numpy as np def normalize(arr): arr = np.asarray(arr, dtype=float) mean = arr.mean() std = arr.std() if std == 0: return np.zeros_like(arr) # all values identical — no spread to scale return (arr - mean) / std data = np.array([10, 20, 30, 40, 50]) z = normalize(data) print(z) # [-1.41 -0.71 0. 0.71 1.41] print(round(z.mean(), 10), round(z.std(), 10)) # 0.0, 1.0 # Edge case print(normalize([7, 7, 7])) # [0. 0. 0.]
Three lines of real work — np.asarray, two scalar reductions, one broadcast subtraction-and-divide. Scikit-learn's StandardScaler is the same calculation with a fit/transform interface around it.
What You Learned
- NumPy arrays are contiguous, typed, and fast — 10-100x quicker than Python lists for numeric work.
- Four attributes describe an array:
dtype,shape,ndim,size. - Vectorised ops replace for-loops —
a + b,a * 2,np.sqrt(a). - Aggregations collapse arrays;
axis=controls which dimension disappears. - Boolean masking filters by condition:
a[a > 5]. - Reshape restructures data without copying.
- Broadcasting lets differently-shaped operands combine — the dark art of concise array code.
Next: Pandas: Working with Tables — NumPy with column names, indexes, and the I/O you actually need.
Practice this
on practicepython.inShort exercises that run in your browser and tell you what your code actually did, not just whether a test passed.