The Concept
NumPy Arrays: The Core Data Structure
A NumPy array is a contiguous block of memory holding elements of the same type. Unlike Python lists (which store pointers to objects), NumPy arrays store raw numbers. This enables:
- Cache-friendly memory layout — CPU can prefetch data efficiently
- SIMD vectorization — CPU processes multiple elements per instruction
- BLAS/LAPACK integration — optimized linear algebra routines
Why is np.sum(arr) 1000x faster than sum(arr) for a large array?
Python loops process one element at a time with type-checking overhead per item. NumPy arrays store raw numbers in contiguous memory, so np.sum runs as a single C/SIMD operation over the whole array — no per-element Python overhead.
import numpy as np
# Creating arrays
a = np.array([1, 2, 3, 4, 5]) # 1D array (shape: (5,))
b = np.array([[1, 2], [3, 4]]) # 2D array (shape: (2, 2))
c = np.zeros(100) # 100 zeros
d = np.random.randn(1000, 768) # 1000x768 random matrix (like embeddings)
# Key attributes
print(a.shape) # (5,)
print(a.dtype) # int64
print(b.ndim) # 2
print(d.nbytes) # 6,144,000 bytes (~6MB)
Vectorized Operations
Every arithmetic operation on arrays is element-wise and vectorized:
x = np.array([1.0, 2.0, 3.0, 4.0])
y = np.array([10.0, 20.0, 30.0, 40.0])
# Element-wise operations
x + y # [11., 22., 33., 44.]
x * y # [10., 40., 90., 160.]
x ** 2 # [1., 4., 9., 16.]
np.sqrt(x) # [1., 1.414, 1.732, 2.]
# Reductions
x.sum() # 10.0
x.mean() # 2.5
x.std() # 1.118
x.max() # 4.0
# Linear algebra
x @ y # dot product: 300.0
np.dot(x, y) # same thing: 300.0
Broadcasting: The Shape Rules
Broadcasting lets NumPy operate on arrays of different shapes by "stretching" the smaller one. The rules:
- Compare shapes element-wise from right to left
- Dimensions are compatible if they're equal OR one of them is 1
- If compatible, the smaller array is "stretched" to match
# Shape (3,) + scalar → broadcasts scalar to (3,)
np.array([1, 2, 3]) + 10 # [11, 12, 13]
# Shape (3, 4) + (4,) → broadcasts (4,) across rows
matrix = np.ones((3, 4))
row = np.array([1, 2, 3, 4])
matrix + row # adds row to every row of matrix
# Shape (3, 1) + (1, 4) → broadcasts to (3, 4)
col = np.array([[1], [2], [3]]) # shape (3, 1)
row = np.array([10, 20, 30, 40]) # shape (4,)
col + row # shape (3, 4): [[11,21,31,41], [12,22,32,42], [13,23,33,43]]
# Common gotcha: (3,) is NOT the same as (3, 1)
a = np.array([1, 2, 3]) # shape (3,) — 1D
b = np.array([[1], [2], [3]]) # shape (3, 1) — 2D column
# a + b works (broadcasts to (3, 3))
# But a.T is still (3,) — 1D arrays don't transpose!
Indexing and Slicing
arr = np.arange(12).reshape(3, 4)
# [[ 0, 1, 2, 3],
# [ 4, 5, 6, 7],
# [ 8, 9, 10, 11]]
arr[0] # first row: [0, 1, 2, 3]
arr[:, 1] # second column: [1, 5, 9]
arr[1:, 2:] # bottom-right 2x2: [[6, 7], [10, 11]]
arr[arr > 5] # boolean mask: [6, 7, 8, 9, 10, 11]
arr[[0, 2]] # fancy indexing: rows 0 and 2