NumPy in Python | Python Course | Dataplexa

NumPy — Fast Numerical Computing

NumPy is the foundation of the entire Python data science stack. pandas, scikit-learn, TensorFlow, PyTorch, and SciPy all build on top of it. At its core, NumPy provides one thing that Python's built-in lists cannot: a fast, typed, multi-dimensional array where mathematical operations run at C speed across every element simultaneously.

This lesson covers creating and inspecting arrays, vectorised operations, broadcasting, indexing and slicing, reshaping, linear algebra, and random number generation.

Why NumPy Exists — Lists vs Arrays

Python lists are flexible but slow for numerical work. NumPy arrays fix this: all elements share the same type, data sits in a contiguous block of memory, and operations run in compiled C rather than interpreted Python.

import numpy as np
import time

# Speed comparison — sum of 10 million numbers
data = list(range(10_000_000))
arr  = np.array(data)

start = time.perf_counter()
total_list = sum(data)
t_list = time.perf_counter() - start

start = time.perf_counter()
total_arr = arr.sum()
t_arr = time.perf_counter() - start

print(f"List sum : {total_list:,} — {t_list:.4f}s")
print(f"NumPy sum: {total_arr:,} — {t_arr:.4f}s")
print(f"Speedup  : {t_list / t_arr:.0f}x")

# Memory — each Python int is ~28 bytes; NumPy int64 is exactly 8 bytes
import sys
py_list = list(range(1000))
np_arr  = np.arange(1000, dtype=np.int64)
print(f"\nPython list 1000 ints : {sys.getsizeof(py_list):,} bytes")
print(f"NumPy array 1000 int64: {np_arr.nbytes:,} bytes")
List sum : 49,999,995,000,000 — 0.2841s NumPy sum: 49,999,995,000,000 — 0.0062s Speedup : 46x Python list 1000 ints : 8,056 bytes NumPy array 1000 int64: 8,000 bytes

Creating Arrays

import numpy as np

# From Python sequences
a1 = np.array([1, 2, 3, 4, 5])
a2 = np.array([[1, 2, 3], [4, 5, 6]])        # 2-D array (matrix)
a3 = np.array([1.0, 2, 3], dtype=np.float32) # explicit dtype

print("1-D:", a1, "| dtype:", a1.dtype, "| shape:", a1.shape)
print("2-D:\n", a2, "| shape:", a2.shape)

# Factory functions
print(np.zeros((3, 4)))           # 3x4 matrix of zeros (float64)
print(np.ones((2, 3), dtype=int)) # 2x3 matrix of ones (int)
print(np.eye(3))                  # 3x3 identity matrix
print(np.full((2, 2), 7))         # 2x2 filled with 7

# Ranges and sequences
print(np.arange(0, 10, 2))        # [0 2 4 6 8]
print(np.linspace(0, 1, 5))       # 5 evenly spaced values 0..1

# Array metadata
print("\nShape:", a2.shape)       # (2, 3)
print("Ndim :", a2.ndim)          # 2
print("Size :", a2.size)          # 6 (total elements)
print("Dtype:", a2.dtype)         # int64
1-D: [1 2 3 4 5] | dtype: int64 | shape: (5,) 2-D: [[1 2 3] [4 5 6]] | shape: (2, 3) [[0. 0. 0. 0.] [0. 0. 0. 0.] [0. 0. 0. 0.]] [[1 1 1] [1 1 1]] [[1. 0. 0.] [0. 1. 0.] [0. 0. 1.]] [[7 7] [7 7]] [0 2 4 6 8] [0. 0.25 0.5 0.75 1. ] Shape: (2, 3) Ndim : 2 Size : 6 Dtype: int64
  • shape — tuple of dimension sizes; ndim — number of dimensions; size — total element count; dtype — element type.
  • np.arange — like Python range but returns an array; np.linspace — N evenly spaced values between start and stop (inclusive).
  • All elements in an array share the same dtype — assigning a float to an int array truncates it.

Vectorised Operations

Operations on NumPy arrays apply element-wise without any explicit loop. This is called vectorisation — the loop runs in C, not Python.

import numpy as np

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

# Arithmetic — all element-wise
print(a + b)       # [11 22 33 44 55]
print(a * b)       # [10 40 90 160 250]
print(b / a)       # [10. 10. 10. 10. 10.]
print(a ** 2)      # [ 1  4  9 16 25]

# Scalar operations broadcast automatically
print(a * 10)      # [10 20 30 40 50]
print(a + 100)     # [101 102 103 104 105]

# Universal functions (ufuncs) — vectorised math
print(np.sqrt(a))           # [1.   1.41 1.73 2.   2.24]
print(np.exp(np.array([0, 1, 2])))    # [1.    2.718 7.389]
print(np.log(np.array([1, np.e, np.e**2])))  # [0. 1. 2.]
print(np.abs(np.array([-3, -1, 0, 2, 4])))   # [3 1 0 2 4]

# Aggregations
prices = np.array([4.99, 12.50, 89.99, 1.50, 24.99])
print(f"\nMin: {prices.min():.2f} | Max: {prices.max():.2f}")
print(f"Mean: {prices.mean():.2f} | Std: {prices.std():.2f}")
print(f"Sum: {prices.sum():.2f} | Cumsum: {np.cumsum(prices)}")
[11 22 33 44 55] [ 10 40 90 160 250] [10. 10. 10. 10. 10.] [ 1 4 9 16 25] [10 20 30 40 50] [101 102 103 104 105] [1. 1.41421356 1.73205081 2. 2.23606798] [1. 2.71828183 7.38905610] [0. 1. 2.] [3 1 0 2 4] Min: 1.50 | Max: 89.99 Mean: 26.79 | Std: 31.73 Sum: 133.97 | Cumsum: [ 4.99 17.49 107.48 108.98 133.97]
  • Every arithmetic operator applies element-wise — no loops needed.
  • NumPy ufuncs like np.sqrt, np.exp, np.log are vectorised versions of math functions.
  • .mean(), .std(), .sum(), .min(), .max(), .cumsum() — all work on the whole array or along an axis.

Indexing, Slicing, and Boolean Masking

import numpy as np

arr = np.array([10, 20, 30, 40, 50, 60, 70, 80])

# Basic indexing and slicing — same syntax as lists
print(arr[0])        # 10
print(arr[-1])       # 80
print(arr[2:5])      # [30 40 50]
print(arr[::2])      # [10 30 50 70]  every other element
print(arr[::-1])     # [80 70 60 50 40 30 20 10]  reversed

# 2-D indexing — [row, col]
m = np.array([[1,2,3],[4,5,6],[7,8,9]])
print(m[0, 2])       # 3  — row 0, col 2
print(m[1, :])       # [4 5 6]  — entire row 1
print(m[:, 1])       # [2 5 8]  — entire column 1
print(m[0:2, 1:3])   # [[2 3] [5 6]]  — submatrix

# Boolean masking — select elements meeting a condition
prices = np.array([4.99, 12.50, 89.99, 1.50, 24.99])
mask = prices > 10
print(mask)           # [False  True  True False  True]
print(prices[mask])   # [12.5  89.99 24.99]  — only matching values

# Fancy indexing — select by list of indices
idx = np.array([0, 2, 4])
print(prices[idx])    # [ 4.99 89.99 24.99]

# NumPy slices are VIEWS not copies — modifying affects the original
view = arr[2:5]
view[0] = 999
print(arr)            # [10 20 999 40 50 60 70 80]
copy = arr[2:5].copy()  # .copy() to avoid this
10 80 [30 40 50] [10 30 50 70] [80 70 60 50 40 30 20 10] 3 [4 5 6] [2 5 8] [[2 3] [5 6]] [False True True False True] [12.5 89.99 24.99] [ 4.99 89.99 24.99] [ 10 20 999 40 50 60 70 80]
  • NumPy slices return views — they share memory with the original. Use .copy() to get an independent copy.
  • Boolean masking is the idiomatic way to filter arrays — equivalent to a list comprehension but far faster.
  • Fancy indexing with an array of indices returns a copy, not a view.

Reshaping and Stacking

import numpy as np

a = np.arange(12)
print("Original:", a)

# reshape — same data, new shape (total elements must match)
m = a.reshape(3, 4)
print("3x4:\n", m)

m2 = a.reshape(2, 6)
print("2x6:\n", m2)

# -1 means "infer this dimension"
print(a.reshape(4, -1))     # NumPy figures out columns = 3
print(a.reshape(-1, 1))     # column vector (12, 1)

# flatten — always returns a copy; ravel — returns a view when possible
print("flat:", m.flatten())
print("ravel:", m.ravel())

# Transpose
print("Transpose of 3x4:\n", m.T)   # shape becomes (4, 3)

# Stacking arrays
x = np.array([1, 2, 3])
y = np.array([4, 5, 6])
print("hstack:", np.hstack([x, y]))        # [1 2 3 4 5 6]
print("vstack:\n", np.vstack([x, y]))      # 2x3 matrix
print("stack axis=1:\n", np.stack([x, y], axis=1))  # 3x2
Original: [ 0 1 2 3 4 5 6 7 8 9 10 11] 3x4: [[ 0 1 2 3] [ 4 5 6 7] [ 8 9 10 11]] 2x6: [[ 0 1 2 3 4 5] [ 6 7 8 9 10 11]] [[ 0 1 2] [ 3 4 5] [ 6 7 8] [ 9 10 11]] [[ 0] [ 1] ... [11]] flat: [ 0 1 2 3 4 5 6 7 8 9 10 11] ravel: [ 0 1 2 3 4 5 6 7 8 9 10 11] Transpose of 3x4: [[ 0 4 8] [ 1 5 9] [ 2 6 10] [ 3 7 11]] hstack: [1 2 3 4 5 6] vstack: [[1 2 3] [4 5 6]] stack axis=1: [[1 4] [2 5] [3 6]]
  • reshape(-1, n) — use -1 to let NumPy infer one dimension automatically.
  • flatten() always returns a copy; ravel() returns a view when the array is contiguous.
  • arr.T transposes — swaps rows and columns.
  • hstack joins along columns; vstack along rows; stack(axis=n) gives full control.

Broadcasting

Broadcasting lets NumPy operate on arrays of different shapes by automatically expanding the smaller array along the appropriate dimensions — without copying data.

import numpy as np

# Scalar broadcasts over entire array
a = np.array([1, 2, 3, 4])
print(a + 10)     # [11 12 13 14]

# 1-D array broadcasts over rows of 2-D array
m = np.array([[1, 2, 3],
              [4, 5, 6],
              [7, 8, 9]])
row = np.array([10, 20, 30])   # shape (3,)
print(m + row)                 # row added to EACH row of m

# Column vector broadcasts over columns
col = np.array([[100], [200], [300]])  # shape (3, 1)
print(m + col)                 # col added to EACH column of m

# Real-world: normalise each feature column (zero mean, unit std)
data = np.array([[1.0, 200, 0.5],
                 [2.0, 400, 1.5],
                 [3.0, 600, 2.5]])
mean = data.mean(axis=0)   # mean of each column
std  = data.std(axis=0)
normalised = (data - mean) / std   # broadcasting handles shape automatically
print("\nNormalised:\n", normalised.round(4))
[11 12 13 14] [[11 22 33] [14 25 36] [17 28 39]] [[101 102 103] [204 205 206] [307 308 309]] Normalised: [[-1.2247 0. 0. ] <- actually [-1.2247 -1.2247 -1.2247] [ 0. 0. 0. ] [ 1.2247 1.2247 1.2247]]
  • Broadcasting rule: dimensions are compared from the right; a dimension of 1 stretches to match the other.
  • axis=0 aggregates across rows (giving one value per column); axis=1 aggregates across columns.
  • Feature normalisation — subtract mean, divide by std — is a one-liner in NumPy thanks to broadcasting.

Linear Algebra

import numpy as np

A = np.array([[1, 2], [3, 4]])
B = np.array([[5, 6], [7, 8]])

# Matrix multiplication — use @ operator (Python 3.5+)
print("A @ B:\n", A @ B)
print("np.dot:\n", np.dot(A, B))   # equivalent

# Element-wise multiply (NOT matrix multiply)
print("A * B:\n", A * B)

# Linear algebra operations
print("\nDeterminant:", np.linalg.det(A))
print("Inverse:\n", np.linalg.inv(A).round(4))
print("Eigenvalues:", np.linalg.eigvals(A).round(4))

# Solve Ax = b — finds x
b = np.array([5, 11])
x = np.linalg.solve(A, b)
print("Solution x:", x)   # A @ x should equal b
print("Verify A @ x =", A @ x)

# Norms and trace
print("Frobenius norm:", np.linalg.norm(A))
print("Trace (sum of diagonal):", np.trace(A))
A @ B: [[19 22] [43 50]] np.dot: [[19 22] [43 50]] A * B: [[ 5 12] [21 32]] Determinant: -2.0 Inverse: [[-2. 1. ] [ 1.5 -0.5 ]] Eigenvalues: [-0.3723 5.3723] Solution x: [1. 2.] Verify A @ x = [ 5. 11.] Frobenius norm: 5.477225575051661 Trace (sum of diagonal): 5
  • Use @ for matrix multiplication — never * which is element-wise.
  • np.linalg.solve(A, b) — more numerically stable than computing the inverse and multiplying.
  • np.trace(A) — sum of the diagonal elements.

Random Number Generation

import numpy as np

rng = np.random.default_rng(seed=42)   # reproducible generator (modern API)

# Uniform floats [0, 1)
print(rng.random((2, 3)))

# Integers in range
print(rng.integers(1, 7, size=10))   # 10 dice rolls

# Normal distribution — mean=0, std=1
samples = rng.standard_normal(10_000)
print(f"Mean: {samples.mean():.4f} | Std: {samples.std():.4f}")

# Normal with custom mean and std
heights = rng.normal(loc=170, scale=10, size=5)  # cm
print("Heights:", heights.round(1))

# Shuffle and choice
arr = np.arange(10)
rng.shuffle(arr)
print("Shuffled:", arr)

picks = rng.choice(arr, size=4, replace=False)   # no replacement
print("Picked:", picks)

# Old API for reference (still common in tutorials)
# np.random.seed(42); np.random.randn(3)
[[0.77395605 0.43887844 0.85859792] [0.69736803 0.09417735 0.97562235]] [1 5 2 3 1 4 6 3 2 5] Mean: -0.0042 | Std: 0.9999 Heights: [161.2 175.9 168.3 182.7 170.4] Shuffled: [7 6 4 3 9 0 1 8 2 5] Picked: [4 9 3 1]
  • np.random.default_rng(seed) — the modern, reproducible API. Always prefer it over np.random.seed().
  • rng.integers(low, high, size) — high is exclusive (like Python range).
  • rng.normal(loc, scale, size) — normal distribution with specified mean and standard deviation.
  • rng.choice(arr, size, replace=False) — sample without replacement.

Quick Reference Table

OperationNumPy CodeNotes
Create from listnp.array([1,2,3])Specify dtype= optionally
Zeros / Ones / Identitynp.zeros(shape) / np.ones / np.eye(n)shape is a tuple
Range / Linspacenp.arange(start, stop, step) / np.linspacearange excludes stop; linspace includes it
Reshapearr.reshape(rows, -1)-1 infers the missing dimension
Boolean maskarr[arr > 10]Returns only matching elements
Matrix multiplyA @ BNever use * for matrix multiply
Axis aggregationarr.mean(axis=0)axis=0 across rows, axis=1 across cols

Practice

Why are NumPy arrays faster than Python lists for numerical operations?



What is the key difference between np.arange and np.linspace regarding the stop value?



What does a NumPy slice return — a copy or a view?



Which Python operator performs matrix multiplication in NumPy?



What is the difference between arr.mean(axis=0) and arr.mean(axis=1)?



What is the modern NumPy API for creating a reproducible random number generator?



Quick Quiz

Which four attributes describe the structure of a NumPy array?





What does prices[prices > 10] return?





Which reshape call turns a 1-D array of 12 elements into a column vector of shape (12, 1)?





What is the recommended NumPy way to solve a linear system Ax = b?





How does NumPy broadcasting work?





What is the difference between flatten() and ravel()?





NEXT UP
ML with Python
Training and evaluating machine learning models with scikit-learn — regression, classification, pipelines, and model evaluation metrics.