Study note
Learning NumPy with rougier/numpy-100
A practical summary of the NumPy concepts, patterns, and pitfalls I learned from the first 74 exercises in rougier/numpy-100.
A Practical Study Note from the First 74 Exercises
I have completed exercises 1–74 in rougier/numpy-100. This note is not an answer key and does not reproduce 74 solutions. Instead, I am extracting the patterns I expect to reuse in data work, numerical experiments, and future Python projects.
I want to thank Nicolas P. Rougier, the project's creator and maintainer, as well as everyone who has contributed. The collection draws from the NumPy mailing list, Stack Overflow, the NumPy documentation, and problems Rougier created himself. The repository started in 2014 and is still maintained more than a decade later, which is remarkable for a learning resource.
The mental model that made NumPy click
An ndarray is easiest for me to understand as data + shape + dtype. The data supplies the values, the shape says how those values are arranged, and the dtype says how each value is represented in memory. An axis is one direction through that shape. For a table-shaped array, axis 0 runs down the rows and axis 1 runs across the columns, although the meaning of each axis depends on the problem.
Vectorization means expressing an operation over arrays instead of manually visiting every element in Python. Broadcasting extends this idea to differently shaped arrays. NumPy compares dimensions from the trailing side; two dimensions are compatible when they are equal or when either dimension is 1. That rule lets a column of row means interact with an entire matrix without manually repeating the means.
The final part of the model is ownership. Some operations produce views that share data, while others produce independent copies or temporary arrays. Basic slicing usually returns a view, but advanced indexing returns a copy. Knowing which one I have is not a minor optimization detail. It determines whether a later mutation changes the original array.
1. Creating, inspecting, and reshaping arrays
I now inspect an unfamiliar array before transforming it. shape describes its axes, size counts elements, and dtype identifies their representation. itemsize is the bytes per element, while nbytes is the storage used by the array elements. reshape changes the organization, not the number of values, and can reuse the same buffer when the memory layout permits.
import numpy as np
values = np.arange(12, dtype=np.int32).reshape(3, 4)
print(values)
print("shape:", values.shape)
print("size:", values.size)
print("dtype:", values.dtype)
print("itemsize:", values.itemsize)
print("nbytes:", values.nbytes)
Constructors communicate intent. I use np.zeros for initialized storage, np.eye for an identity matrix, and np.arange for regular integer sequences. For random data, an explicit generator such as np.random.default_rng(42) makes reproducibility a deliberate choice. Before reshaping, I check that the resolved dimensions multiply to size; NumPy can infer one dimension when I use -1.
2. Slicing, masks, and advanced indexing
Selection has several meanings. A basic slice describes a rectangular view into existing data. A Boolean mask selects values that satisfy a condition. An integer index array can gather or reorder rows. Masks and integer arrays use advanced indexing, so the resulting array is a copy even though an assignment such as data[mask] = 0 still writes into data through indexed assignment.
import numpy as np
data = np.arange(20).reshape(5, 4)
middle_view = data[1:4, 1:3]
mask = data % 3 == 0
multiples_of_three = data[mask]
row_order = np.array([4, 0, 2])
reordered_rows = data[row_order]
print(middle_view)
print(multiples_of_three)
print(reordered_rows)
print(np.nonzero(mask))
The mask has the same shape as data, so each True position selects one element and the result is one-dimensional. np.nonzero(mask) preserves positional information by returning one coordinate array per axis. The row-order array targets axis 0, producing a new array in the requested order. This is concise, but I should not mistake it for a shared view.
3. Broadcasting and vectorized computation
Broadcasting becomes predictable when I write down shapes. To center every row, a matrix with shape (2, 3) needs means with shape (2, 1). keepdims=True leaves the reduced axis at length 1, so the subtraction is compatible from the trailing dimensions. No manually repeated mean matrix is needed.
import numpy as np
samples = np.array([[1.0, 3.0, 5.0], [2.0, 4.0, 8.0]])
row_means = samples.mean(axis=1, keepdims=True)
centered = samples - row_means
weights = np.array([[1.0, 0.5], [-1.0, 2.0], [0.25, 1.0]])
projected = centered @ weights
print("means shape:", row_means.shape)
print(centered)
print("projected shape:", projected.shape)
Vectorization states the operation at the level of rows and matrices, which is usually easier to audit than a nested loop. The @ operator then performs matrix multiplication: the inner dimensions, both 3 here, must agree. This is fundamentally different from *, which multiplies element by element and follows broadcasting rules.
4. Reductions: think about the axis
A reduction removes information along selected axes. The useful question is not only "sum or mean?" but "which dimension am I summarizing?" On an array shaped (2, 3, 4), reducing axis 1 combines the three entries in that direction and leaves a (2, 4) result. A tuple of axes performs a multidimensional reduction in one operation.
import numpy as np
cube = np.arange(24).reshape(2, 3, 4)
sum_over_axis_1 = cube.sum(axis=1)
mean_over_two_axes = cube.mean(axis=(0, 1))
per_block_max = cube.max(axis=(1, 2), keepdims=True)
positions = cube.argmin(axis=2)
print(sum_over_axis_1.shape)
print(mean_over_two_axes)
print(per_block_max.shape)
print(positions)
keepdims retains reduced axes as length 1, which is useful when the result must broadcast back against the source. Functions such as argmin and argmax return positions rather than values; with an axis supplied, those positions are relative to that axis. I now predict the output shape before running a reduction.
5. Dtypes, memory, views, and mutation
A dtype is both a numerical choice and a memory contract. Structured dtypes extend that contract to records with named fields, while ordinary numerical dtypes determine precision, range, and casting behavior. The example below also separates a shared basic-slice view from an advanced-index copy.
import numpy as np
record_dtype = np.dtype([("name", "U8"), ("score", "f4")])
records = np.array([("Ada", 91.5), ("Lin", 88.0)], dtype=record_dtype)
base = np.arange(6, dtype=np.int32)
shared = base[1:5]
independent = base[[1, 2, 3, 4]]
shared[0] = 99
independent[1] = -1
read_only = base.view()
read_only.flags.writeable = False
safe_float_copy = base.astype(np.float64, copy=True)
safe_float_copy *= 0.5
print(records["score"])
print(base, independent)
print(np.shares_memory(base, shared))
print(read_only.flags.writeable, safe_float_copy.dtype)
Changing shared changes base; changing independent does not. A read-only view is a useful local guard, although the original owner may still be writeable. In-place operations and out= can reduce temporaries, but they also couple the result to the destination dtype. For example, multiplying an integer array in place by 0.5 cannot safely store the fractional result under normal casting rules.
I distinguish conversion from reinterpretation. astype converts values to a new dtype. Viewing the same bytes with another dtype changes how those bytes are interpreted and can alter shape or produce meaningless values if the layout is misunderstood. I prefer a clear copy at ownership boundaries and only use aggressive in-place or dtype techniques when their invariants are explicit.
6. Sorting, searching, and grouped accumulation
Sometimes I need the order of values rather than a sorted copy. For a one-dimensional array like this one, argsort returns indices that can reorder the array and any aligned one-dimensional arrays consistently. A nearest-value search follows another reusable pattern: compute absolute differences, then take the index of the minimum.
import numpy as np
values = np.array([4.2, 1.5, 3.8, 1.6])
order = np.argsort(values)
target = 3.6
nearest_index = np.abs(values - target).argmin()
labels = np.array([0, 1, 0, 2, 1, 0])
weights = np.array([1.0, 2.5, 3.0, 4.0, 1.5, 2.0])
grouped_with_at = np.zeros(3)
np.add.at(grouped_with_at, labels, weights)
grouped_with_bincount = np.bincount(labels, weights=weights, minlength=3)
print(values[order])
print(nearest_index, values[nearest_index])
print(grouped_with_at)
print(grouped_with_bincount)
Grouped accumulation is where indexing semantics matter. np.add.at performs unbuffered updates, so every repeated label contributes. Weighted np.bincount expresses the same grouped-sum idea efficiently when labels are a one-dimensional array of nonnegative integers. minlength makes the expected number of groups visible. For more general keys, I would sort, canonicalize, or pre-aggregate rather than force them into this representation.
7. Reusable multidimensional patterns
Two patterns from these exercises feel broadly useful. First, coordinate grids often need only a column-shaped coordinate and a row-shaped coordinate. Broadcasting combines them into every grid position. Second, when an object has multiple equivalent representations, canonicalize it before asking for uniqueness.
import numpy as np
rows = np.arange(3)[:, None]
columns = np.arange(4)[None, :]
distance_squared = (rows - 1) ** 2 + (columns - 2) ** 2
edges = np.array([[3, 1], [2, 4], [1, 3], [4, 2], [2, 2]])
canonical_edges = np.sort(edges, axis=1)
unique_edges = np.unique(canonical_edges, axis=0)
print(distance_squared)
print(unique_edges)
The coordinate arrays have shapes (3, 1) and (1, 4), so both broadcast to a (3, 4) result. For undirected edges, (3, 1) and (1, 3) mean the same connection. Sorting each pair establishes a canonical endpoint order; np.unique(..., axis=0) can then remove duplicate rows. The same normalize-then-compare idea applies to intervals, pairs, and other unordered records.
Mistakes I want to remember
The first mistake is confusing * with @. The former is element-wise and may broadcast; the latter contracts matrix dimensions. A result with a plausible shape can still encode the wrong mathematics, so I should name the intended operation before choosing the operator.
Floating-point comparison needs an equally explicit choice. Decimal calculations can differ by tiny rounding errors, and NaN is not equal to itself. I use np.isnan to locate missing floating values. np.array_equal asks whether shapes and values match exactly, with an equal_nan=True option when that is the intended policy. np.allclose asks whether numerical values are within chosen tolerances, also with optional equal_nan=True. These functions answer different questions; tolerance should come from the problem, not habit.
Advanced indexing has two related traps. A selection made with integer arrays or Boolean masks is a copy, so mutating the returned selection will not mutate the source. Meanwhile, code such as totals[labels] += weights uses buffered advanced-index updates. When labels repeats, contributions can be lost because updates do not accumulate one by one. np.add.at provides the unbuffered behavior needed for that case.
Broadcasting mistakes often survive because NumPy finds a compatible shape that was not the one I meant. Before a nontrivial operation, I now inspect or assert shapes and identify what each axis represents. Adding a singleton axis with None or preserving one with keepdims should express a reason, not serve as random trial and error.
Finally, memory-saving tricks have a clarity cost. An in-place update can destroy a value needed later, fail because of casting, or silently couple two aliases. Dtype reinterpretation can turn valid bytes into nonsense while looking technically legal. I want to optimize allocation only after ownership, dtype, and writeability are understood.
What comes next
The first 74 exercises gave me a connected mental model rather than a bag of isolated tricks. My next goal is to complete exercises 75–100, then revisit this note and refine the patterns with what I learn. I remain grateful to Nicolas P. Rougier and all contributors for making NumPy easier to study, teach, and discuss.
Support
Support HappyFan with ETH on Base
Tips go directly to HappyFan's wallet. The site never touches your private keys, and every transfer must be confirmed in your wallet.
Base address
0xAc1Dd15430a8078D8A3Dfa8E8293fA9479912d54
Send only ETH or Base-compatible assets on the Base network to this EVM address. Short form: 0xAc1D...2d54
Comments
No comments yet.