Part 7 · Scientific Computing & Data Science · Module 48

NumPy Fundamentals

NumPy's ndarray is the foundation of the entire scientific Python stack: pandas, SciPy, scikit-learn, matplotlib, and every deep-learning framework speak it. Learn how it is laid out in memory and how to think in whole arrays, and your numerical code becomes shorter, clearer, and often a hundred times faster.

⏱ ~3 hoursPrerequisites: M47, M20

The ndarray: what makes it different from a list

In M47 you represented vectors as Python lists, and saw that a pure-Python matrix multiply spends nearly all its time on interpreter overhead. A list stores pointers to separate Python objects scattered across memory, and each object carries its own type, reference count, and value. That flexibility (any mix of types, any size) is exactly what makes lists slow for arithmetic.

A NumPy ndarray ("n-dimensional array") makes three promises instead:

Python list [1.0, 2.0, 3.0, 4.0] ptrptrptrptr float 1.0float 2.0float 3.0float 4.0 each: type ptr + refcount + value = 24 bytes np.array([1.0, 2.0, 3.0, 4.0]) dtype=float64, shape=(4,), strides=(8,) 1.02.03.04.0 32 bytes, side by side
A list is an array of pointers to boxed objects; an ndarray is one buffer of raw values plus metadata.

Because the type is known once for the whole array and the values are contiguous, NumPy can run its loops in compiled C, use SIMD instructions, and stay friendly to the CPU cache. The price is that every element must be the same type and the size is fixed at creation.

NumPy upcasts to a single type that can hold everything: integers plus a float become floats; anything with a string becomes strings (and arithmetic on that array then fails). Mixed data is a sign you want a pandas DataFrame (M51), which stores one array per column.

Remember: ndarray = one dtype + one buffer + shape + strides. Operators work element-wise.

Creating arrays

You rarely type arrays out by hand. These constructors cover most needs:

CallResultNotes
np.array(seq)array from (nested) listsinfers dtype; pass dtype= to choose
np.zeros((2, 3)), np.ones(5), np.full((2, 2), 7)filled arraysdefault dtype float64; shape as a tuple
np.arange(0, 10, 2)[0 2 4 6 8]like range; stop excluded
np.linspace(0, 1, 5)[0. .25 .5 .75 1.]count of points; stop included. Prefer for floats
np.eye(3)3 × 3 identityfrom M47
np.empty((1000,))uninitializedfast, contains garbage; fill it before reading
np.zeros_like(a)same shape and dtype as aalso ones_like, full_like
rng = np.random.default_rng(42)a random Generatorrng.random, rng.integers, rng.normal

Remember: shapes are tuples; linspace for float ranges; default_rng(seed) for random numbers.

dtypes, overflow, and precision

A dtype fixes how many bytes each element uses and how those bits are read. Common ones:

Integers

int8 (-128…127), int16, int32 (about ±2.1 billion), int64 (about ±9.2 × 10¹⁸); unsigned uint8 (0…255, used for image pixels).

Floats

float64 (default, about 15–16 significant digits), float32 (about 7 digits, half the memory; common in ML and GPUs), float16.

Others

bool, complex128, fixed-width strings like <U5, datetime64, and object (pointers to Python objects, which throws away the speed).

Python's own int never overflows: it grows as needed. NumPy integers are fixed-width hardware integers, so they wrap around silently when an array operation exceeds their range, exactly like a car odometer rolling over. Floats don't wrap; they lose precision, and they overflow to inf.

Remember: NumPy ints wrap silently on overflow; floats round. Pick the dtype on purpose and convert with astype before arithmetic that might exceed the range.

Shape, reshape, and axis

The shape is a tuple with one entry per dimension: (5,) is a 1-D array of 5 elements, (3, 4) is 3 rows by 4 columns, (10, 28, 28) could be ten 28 × 28 grayscale images. ndim is the length of the shape. reshape reinterprets the same elements in a new shape, as long as the total count is unchanged; pass -1 for one dimension and NumPy computes it.

Many functions take an axis argument. The rule that never fails: axis=k is the dimension that disappears. For a 2-D array, sum(axis=0) collapses the rows (axis 0), going down each column and leaving one result per column; sum(axis=1) collapses the columns, going across each row and leaving one result per row.

123 456 579 a.sum(axis=0) → shape (3,) axis 0 runs down 123 456 615 a.sum(axis=1) → shape (2,) axis 1 runs across
The axis you name is the one that gets collapsed. For shape (2, 3): axis=0 leaves (3,), axis=1 leaves (2,).

Remember: axis=k is the dimension that vanishes. keepdims=True keeps it as size 1, which is handy for broadcasting the result back.

Indexing: basic, slicing, boolean masks, fancy

NumPy indexing extends list indexing to many dimensions, separated by commas inside one pair of brackets: m[i, j] rather than m[i][j].

Remember: m[i, j], slices per axis, masks for "where condition", integer arrays for "these positions".

Views, copies, and strides

A view is a new array object that shares the same buffer as the original, with different shape or strides. Basic slicing, reshape (when possible), .T, and ravel (when possible) return views: no data is copied, so they are instant even for huge arrays, but writing to a view changes the original. Boolean and fancy indexing always return copies, because the selected elements generally can't be described by a regular stride pattern.

This is the opposite of lists, where lst[1:3] makes a copy. It surprises everyone once.

Strides explained

Strides are the byte steps along each axis. For a (3, 4) array of 8-byte floats in row-major order, moving one column right is 8 bytes and moving one row down is 4 × 8 = 32 bytes: strides (32, 8). The transpose just swaps them to (8, 32), which is why .T is free. Taking every other column, m[:, ::2], doubles the column stride to 16. The element at [i, j] lives at byte offset i·stride₀ + j·stride₁: exactly the row-major formula from M47, generalized.

Remember: slices and transposes are views (shared, free); masks and fancy indexing are copies. Strides say how to walk the buffer.

Vectorization, ufuncs, and aggregation

Vectorization means expressing a computation on whole arrays instead of writing a Python loop over elements. The loop still happens, but inside compiled code. NumPy's element-wise functions are called ufuncs (universal functions): arithmetic operators, np.sqrt, np.exp, np.log, np.sin, np.maximum, comparisons, and many more. They work on any shape and return an array of the same shape.

Aggregations (reductions) collapse values: sum, mean, std, min, max, argmin, argmax, any, all, all with an optional axis. NaN-aware versions (np.nanmean, np.nansum) skip missing values.

Remember: write the formula once for the whole array. If you find yourself writing for i in range(len(a)) over NumPy data, look for a ufunc, a reduction, or broadcasting.

Broadcasting, step by step

Broadcasting is how NumPy combines arrays of different shapes without copying data. It is why a * 2 works (the scalar "stretches" to a's shape) and why you can subtract a row of column means from every row of a matrix in one expression. The rules, applied to the two shapes:

  1. Align from the right. Write the shapes one above the other, right-aligned. If one has fewer dimensions, pad it on the left with 1s.
  2. Compare each dimension. Two sizes are compatible if they are equal, or if one of them is 1.
  3. Stretch the 1s. A size-1 dimension is (virtually) repeated to match the other. The result shape is the maximum in each position. If any pair is incompatible, NumPy raises ValueError: operands could not be broadcast together.
A.shape = (3, 1) B.shape = (4,) → pad → (1, 4) result = (3, 4) 01020 + 0123 = 0123 10111213 20212223 column stretched across 4 columns, row stretched down 3 rows
A column (3, 1) plus a row (4,) makes a (3, 4) table: every pairwise sum, with no loops and no copies of the inputs.

Remember: right-align, equal-or-1, stretch the 1s. Use [:, None] or keepdims=True to turn a per-row result into a column that broadcasts back.

The everyday toolbox: @, where, clip, cumsum, sort, unique

A handful of functions show up in nearly every NumPy program:

Remember: @ is matrix product, * is element-wise. argsort gives positions, which you then use for fancy indexing.

Random generators, seeding, and saving arrays

Random numbers the modern way

Create a Generator with rng = np.random.default_rng(seed) and call methods on it: rng.random(n) (uniform in [0, 1)), rng.integers(lo, hi, size), rng.normal(mean, sd, size), rng.choice(a, size, replace=False), rng.permutation(a), rng.shuffle(a). A seed makes the sequence reproducible: the same seed gives the same numbers, which you need for tests, debugging, and reproducible science. Pass the generator into functions instead of using global state. The older np.random.seed() / np.random.rand() API uses one hidden global generator; avoid it in new code.

Saving and loading

np.save("a.npy", a) writes one array in NumPy's binary format (dtype and shape included, exact values, fast). np.savez("data.npz", x=x, y=y) bundles several named arrays (savez_compressed zips them). np.load reads both. For human-readable text, there's np.savetxt/np.loadtxt, which is slower and can lose precision.

Remember: one default_rng(seed) per program (or test), passed around explicitly; .npy/.npz for exact, fast storage.

Performance and common pitfalls

Growing arrays in a loop

np.append copies the whole array every time: O(n²). Collect into a list and convert once, or preallocate with np.empty(n).

Element-wise Python loops

Indexing a[i] in a loop creates a NumPy scalar object each time, often slower than a plain list. Vectorize.

Unintended views

Modifying a slice modifies the source. Copy when ownership matters.

Silent integer overflow

Especially uint8 images: convert to a wider type before arithmetic.

Float equality

0.1 + 0.2 == 0.3 is False. Use np.isclose / np.allclose.

Shape (n,) vs (n, 1)

A 1-D array is neither a row nor a column. Mixing (n,) and (n, 1) broadcasts to (n, n), a classic silent bug.

Remember: preallocate or build lists then convert; check shapes when results look "too big"; compare floats with tolerance.

Practice

Use NumPy and no Python loops over elements unless the exercise allows it.

Write checkerboard(n) returning an n × n integer array with 0 at [0, 0] and alternating 0/1 (like a chessboard). Then write normalize_rows(X) returning a float array where each row of X is divided by that row's sum (so each row sums to 1). Do not modify X.

Hint

For the board, (i + j) % 2 where i is a column of row indices and j a row of column indices: broadcasting builds the grid. For rows, X.sum(axis=1, keepdims=True).

Show solution
import numpy as np

def checkerboard(n):
    i = np.arange(n)
    return (i[:, None] + i[None, :]) % 2

def normalize_rows(X):
    X = np.asarray(X, dtype=float)
    return X / X.sum(axis=1, keepdims=True)

Write zscore(X) that standardizes each column of a 2-D array: subtract the column mean and divide by the column standard deviation (population std, ddof=0). Columns whose std is 0 should become all zeros instead of NaN. Return a new float array.

Hint

mu = X.mean(axis=0) and sd = X.std(axis=0) both have shape (cols,) and broadcast against X. Replace zero stds with 1 using np.where before dividing.

Show solution
import numpy as np

def zscore(X):
    X = np.asarray(X, dtype=float)
    mu = X.mean(axis=0)
    sd = X.std(axis=0)
    sd = np.where(sd == 0, 1.0, sd)
    return (X - mu) / sd

Write one_hot(labels, n_classes): given an integer array of class labels, return an (len(labels), n_classes) integer array with a single 1 per row at the label's column. Then write top_k(scores, k) returning the indices of the k largest scores, largest first.

Hint

Start with np.zeros((n, n_classes), dtype=int) and set out[np.arange(n), labels] = 1. For top-k, np.argsort(scores)[::-1][:k].

Show solution
import numpy as np

def one_hot(labels, n_classes):
    labels = np.asarray(labels)
    out = np.zeros((labels.size, n_classes), dtype=int)
    out[np.arange(labels.size), labels] = 1
    return out

def top_k(scores, k):
    return np.argsort(scores)[::-1][:k]

Write pairwise_distances(A, B): A has shape (m, d) and B has shape (n, d). Return the (m, n) array whose [i, j] entry is the Euclidean distance between A[i] and B[j]. No Python loops. Then write nearest(A, B) returning, for each row of A, the index of the closest row of B.

Hint

A[:, None, :] has shape (m, 1, d) and B[None, :, :] has shape (1, n, d). Their difference is (m, n, d); square, sum over the last axis, square root.

Show solution
import numpy as np

def pairwise_distances(A, B):
    diff = A[:, None, :] - B[None, :, :]
    return np.sqrt((diff ** 2).sum(axis=-1))

def nearest(A, B):
    return pairwise_distances(A, B).argmin(axis=1)

Write moving_average(x, w) returning the averages of every window of w consecutive values (the result has len(x) - w + 1 elements). Use np.cumsum so the work is O(n) regardless of w. Raise ValueError if w is less than 1 or greater than len(x).

Hint

Prepend a 0: c = np.concatenate(([0.0], np.cumsum(x))). Then the sum of x[i:i+w] is c[i + w] - c[i], so all window sums are c[w:] - c[:-w].

Show solution
import numpy as np

def moving_average(x, w):
    x = np.asarray(x, dtype=float)
    if w < 1 or w > x.size:
        raise ValueError(f"window must be in 1..{x.size}, got {w}")
    c = np.concatenate(([0.0], np.cumsum(x)))
    return (c[w:] - c[:-w]) / w

Mini-project: image manipulation with plain arrays

A grayscale image is just a 2-D array of brightness values; a color image is a 3-D array of shape (height, width, 3) holding red, green, and blue. Everything you learned applies: slicing flips and crops, broadcasting weights channels, and a blur is a convolution, where each output pixel is the average of a small neighborhood. The cell below builds a synthetic picture so it runs offline, then processes it.

Mini-projectimgtools: a tiny NumPy image library

Build imgtools.py that works on any image array, plus demo.py that loads a real photo and saves a comparison figure.

pixi init imgtools && cd imgtools
pixi add python=3.14 numpy matplotlib pytest
pixi run python demo.py photo.png      # writes compare.png
pixi run pytest -q

Spec

  • to_float(img) converts uint8 (0–255) to float64 in [0, 1] and leaves float images alone; to_uint8 clips and converts back.
  • grayscale(img) uses the weights 0.299, 0.587, 0.114 with @.
  • flip(img, axis), crop(img, top, left, height, width), and rotate90(img) (hint: transpose plus a flip) using views where possible.
  • convolve(gray, kernel) applies any odd-sized 2-D kernel with edge padding, looping only over kernel offsets. Provide box_kernel(k) and a sharpen kernel [[0, -1, 0], [-1, 5, -1], [0, -1, 0]].
  • brighten(img, amount) must not overflow on uint8 input.
  • demo.py reads an image with matplotlib.pyplot.imread and saves a 2 × 3 grid with imshow.
  • grayscale returns shape (h, w) for an (h, w, 3) input
  • A test shows flip returns a view (np.shares_memory) and crop has the right shape
  • Convolving with a 1 × 1 kernel [[1]] returns the input unchanged (tested)
  • A box blur preserves the mean brightness of a constant image (tested)
  • brighten on a uint8 pixel of 250 by 20 gives 255, not 14 (tested)
  • demo.py writes compare.png; pixi run pytest passes

Stretch goals

  • Edge detection with Sobel kernels: combine horizontal and vertical gradients with np.hypot.
  • A histogram equalizer using np.unique or np.bincount and cumsum.
  • Compare your convolve with scipy.signal.convolve2d from M50.

Quiz

Recap

Going further