Skip to content

Python and numpy: arrays, dtypes, broadcasting, views vs copies, uv projects

Modulelang.01 · practice · Python · Pass 0 · 3 to 4 h
You buildprimers/lang.01/: a uv project (pyproject.toml, uv.lock); worksheet.py with broadcast_shape, unbroadcast, as_c_float32; bigram.py with bigram_counts, row_normalize
Contractnone: a primer exercise, not part of the system. The signatures are in the stubs ol start lang.01 writes
Testscourse/tests/lang.01/, run by its check script (what they check: section 4)
Needsnothing: the course starts here. Python 3.11 or later and uv on your PATH (ol doctor)
Used byno code call site (a primer). It is the concept prerequisite of L0.0 (byte bigram from counts), optional C exercises such as M03.1 (row-major matmul), and L0.1 (broadcasting backward)
MilestoneMS-P0 (page: paths/course-p00-setup/milestone.md)
Optional depthnumpy user guide, “Broadcasting”, “Copies and views”, “Data type objects” (numpy.org/doc, free); Harris et al., “Array programming with NumPy”, Nature 585, 2020 (free); uv docs, “Working on projects” (docs.astral.sh/uv, free)
  • A numpy array is one flat buffer plus metadata (dtype, shape, strides). Transposes and slices change only the metadata, so they are views that share the buffer.
  • Broadcasting aligns shapes on the right, pads with 1s on the left, and stretches size-1 axes. You can predict any broadcast’s shape: the tests check your rule against numpy on all 1600 small pairs.
  • Every broadcast forward is a sum backward. unbroadcast is the adjoint of broadcasting, the identity L0.1’s autograd uses on every elementwise op.
  • The dtype is part of the value. A uint8 count wraps at 256; an int64 count does not.
  • A uv project pins your environment. pyproject.toml declares numpy, uv.lock pins the exact build, and uv run reproduces it on any machine, including CI.
Terminal window
ol start lang.01 # writes stubs: primers/lang.01/{worksheet,bigram}.py
uv init --bare primers/lang.01 # step 1: your uv project
uv add --project primers/lang.01 numpy # declares numpy and writes uv.lock
ol tests lang.01 # read the test catalog first
ol check lang.01 # exit code is the verdict

Your system is an empty repository. In Pass 1 you train its first model, L0.0’s byte bigram, and that training is nothing more than counting which byte follows which in a text file and turning the counts into probabilities. A Python loop over a 1 MB file does that count in about 0.12 s on a laptop; one numpy call does it in about 0.005 s, and the gap grows with every pass over the data. Next, rt.01 hands numpy arrays to C through a raw pointer, and if the array’s bytes are not laid out the way C reads them, C computes on the wrong numbers with no error at all. Then L0.1’s autograd has to undo broadcasting in every backward pass. All three rest on one picture of what an array is, and on knowing exactly which environment your Python runs in. This primer builds that picture with five small functions.

A numpy array is a block of memory (the buffer) plus four facts about how to read it.

SymbolMeaningType / shape
bbbytes per element (itemsize), fixed by the dtype: 1 for uint8, 4 for float32, 8 for int64 and float64integer
ddnumber of axes (ndim)integer
nkn_ksize of axis kk, for k=0,…,d−1k = 0, \dots, d-1; the tuple (n0,…,nd−1)(n_0, \dots, n_{d-1}) is the shapeinteger
iki_kan index on axis kk, with 0≤ik<nk0 \le i_k < n_kinteger
sks_kstride of axis kk: how many bytes to move to go from index iki_k to ik+1i_k + 1integer (bytes)
oobyte offset of element (0,…,0)(0, \dots, 0) inside the bufferinteger (bytes)

The element at index (i0,…,id−1)(i_0, \dots, i_{d-1}) lives at byte

addr(i)=o+∑k=0d−1ik sk.\text{addr}(i) = o + \sum_{k=0}^{d-1} i_k \, s_k .

An array is C-contiguous (row-major) when its last axis is packed and every earlier axis steps over a whole row of the next: sd−1=bs_{d-1} = b and sk=sk+1 nk+1s_k = s_{k+1}\, n_{k+1}. A float32 array of shape (3, 4) has strides (16, 4): one step along a row moves 4 bytes, one step down a column moves a whole row of 4 elements, 16 bytes. This layout is what a C function reading const float *a with explicit dimensions assumes.

Because the formula is the whole story, many operations need no copying at all. They make a view: a new array object with new metadata over the same buffer.

Operation on x (float32, shape (3, 4), strides (16, 4))Result shapeResult stridesShares the buffer?
x.T (transpose)(4, 3)(4, 16)yes, a view
x[:, ::2] (every other column)(3, 2)(16, 8)yes, a view
x.reshape(12)(12,)(4,)yes, a view (x is contiguous)
x.astype(np.float64)(3, 4)(32, 8)no, a copy
np.broadcast_to(np.ones(4, np.float32), (3, 4))(3, 4)(0, 4)yes: stride 0 repeats a row without copying it

A copy has its own buffer; writing to it never changes the original. np.shares_memory(a, b) answers whether two arrays overlap. y.flags.c_contiguous answers whether y is laid out row-major.

The dtype says how to interpret each run of bb bytes. uint8 holds the integers 0 to 255, and arithmetic on it is done modulo 256: 200 + 100 stored in uint8 is 44, silently. int64 holds about ±9.2×1018\pm 9.2 \times 10^{18}, enough for any count in this course. float32 keeps 24 significant bits, so np.float32(0.1) is really 0.100000001490116…, the nearest value it can hold. numpy 2 raises OverflowError when you mix a uint8 array with a Python integer that does not fit (x * 256), but uint8 arithmetic between arrays, or with a constant that fits (x * 255 + y), still wraps without a word.

An elementwise operation such as x + y needs both operands to have the same shape. Broadcasting is numpy’s rule for when it may pretend they do, by stretching size-1 axes with stride 0 (2.1) instead of copying. For shapes aa (with pp axes) and bb (with qq axes):

  1. Let n=max⁡(p,q)n = \max(p, q). Pad the shorter shape with 1s on the left until both have nn axes. Equivalently, align the shapes on their last axis.
  2. For each axis kk: if ak=bka_k = b_k, the result has aka_k. If one of them is 1, the result has the other. Otherwise the shapes do not broadcast, and numpy raises ValueError.

So a bias vector of shape (3,) added to a batch of shape (2, 3) acts as (1, 3), stretched to (2, 3): the same 3 numbers are added to every row. The rule never creates data; it only reuses elements.

Write BB for the operation “broadcast xx from shape ss to shape TT”. Every element xjx_j appears at several positions of BxB x. If some later number LL depends on y=Bxy = Bx, the chain rule says the sensitivity of LL to xjx_j is the sum of its sensitivities to every copy of xjx_j:

SymbolMeaningType / shape
xxthe operand before broadcastingshape ss
y=Bxy = Bxthe broadcast resultshape TT
ggthe gradient of LL with respect to yy, one number per element of yyshape TT
⟨u,v⟩\langle u, v \rangle∑mumvm\sum_m u_m v_m, the sum of elementwise products of two same-shaped arraysscalar
B⊤gB^\top gunbroadcast(g, s): gg summed over every position that holds a copy of the same xjx_jshape ss

∂L∂xj=∑positions m that copy xjgm,equivalently⟨Bx,g⟩=⟨x,B⊤g⟩.\frac{\partial L}{\partial x_j} = \sum_{\text{positions } m \text{ that copy } x_j} g_m , \qquad\text{equivalently}\qquad \langle Bx, g \rangle = \langle x, B^\top g \rangle .

Concretely, B⊤B^\top sums gg over the leading axes broadcasting added, then over every axis where ss has size 1, keeping those axes so the result has shape exactly ss. The right-hand identity is the test: it must hold for every xx and gg.

A Python loop runs the interpreter once per element. A numpy call runs one compiled loop over the whole buffer. To count byte pairs without a Python loop, turn each adjacent pair (a,b)(a, b) into one integer

k=256 a+b,0≤k<65536,k = 256\,a + b, \qquad 0 \le k < 65536 ,

count how many times each kk occurs with np.bincount(k, minlength=65536), and reshape the 65536 counts to (256, 256). The reshape is free and exact because 256a+b256a + b is precisely the row-major position of (a,b)(a, b) in a 256 by 256 table (2.1 with s=(256,1)s = (256, 1) in elements). Compute kk in int64: in uint8, 256a256a does not fit.

Why not C[a, b] += 1 with index arrays? Indexing with arrays is buffered: numpy reads C[a, b] for all pairs, adds 1, then writes back, so a pair that occurs 3 times is written 3 times with the same value and counts once. np.add.at(C, (a, b), 1) is the unbuffered version; bincount is the fast one.

For a count table CC with rows aa, the probability that bb follows aa is the row-normalized count Pab=Cab/∑b′Cab′P_{ab} = C_{ab} / \sum_{b'} C_{ab'}. In numpy that is C / C.sum(axis=1, keepdims=True): the row sums keep shape (256, 1), so broadcasting divides each row by its own total. A row with no counts has total 0, and 0/00/0 is NaN; this exercise keeps such rows at 0 (L0.0 adds smoothing so that no row is empty).

A uv project is a directory with a pyproject.toml. Its [project].dependencies list says what the code needs (numpy>=2.0). uv add edits that list and writes uv.lock, which pins the exact version and file hash of every package. uv run <cmd> first makes the project’s own environment (.venv/ in the project directory) match the lock, then runs <cmd> inside it. So uv run on your laptop and in CI runs the same numpy. Commit pyproject.toml and uv.lock; never commit .venv/ (the .gitignore that ol course init wrote already ignores it). Installing numpy by hand into some other Python is invisible to uv run.

These numbers are the first test of each function in section 4.

Broadcast shapes. Pad on the left, then compare axis by axis.

aba, b paddedper axisresult
(3, 1)(1, 4)(3, 1), (1, 4)3 vs 1 gives 3; 1 vs 4 gives 4(3, 4)
(2, 3)(3,)(2, 3), (1, 3)2 vs 1 gives 2; 3 vs 3 gives 3(2, 3)
(8, 1, 6, 1)(7, 1, 5)(8, 1, 6, 1), (1, 7, 1, 5)8; 7; 6; 5(8, 7, 6, 5)
()(5,)(1,), (5,)1 vs 5 gives 5(5,)
(4, 1)(0,)(4, 1), (1, 0)4; 1 vs 0 gives 0(4, 0)
(3,)(4,)(3,), (4,)3 vs 4: neither is 1ValueError

Unbroadcast. Take gg = a (2, 3) array of ones. Each result entry counts how many copies its element had.

target shapewhat to sumresult
(3,)the added leading axis 0[2, 2, 2]
(2, 1)axis 1, kept[[3], [3]]
(1, 3)axis 0, kept[[2, 2, 2]]
()both axes6
(2, 3)nothingg itself

Layout. x = np.arange(12, dtype=np.float32).reshape(3, 4) has strides (16, 4). Its transpose x.T has shape (4, 3) and strides (4, 16), so x.T[1, 0] sits at byte 1⋅4+0⋅16=41 \cdot 4 + 0 \cdot 16 = 4, which is x[0, 1]: correct as an array. But a C function given x.T’s buffer pointer walks bytes 0, 4, 8, … and reads 0, 1, 2, 3, …, the elements of x in row order, while the row-major (4, 3) array it was promised starts 0, 4, 8. as_c_float32(x.T) must therefore copy into a fresh buffer that reads 0, 4, 8, 1, 5, 9, 2, 6, 10, 3, 7, 11. And as_c_float32 of the float64 array [[0.1, 0.2, 0.3], [1, 2, 3]] (strides (24, 8)) is a float32 copy with strides (12, 4) whose first element is 0.100000001490116.

Bigram counts of “banana”. The bytes are 98 97 110 97 110 97 (b a n a n a). The 5 adjacent pairs and their codes k=256a+bk = 256a + b:

paira, bk
ba98, 9725088 + 97 = 25185
an97, 11024832 + 110 = 24942
na110, 9728160 + 97 = 28257
an97, 11024942
na110, 9728257

bincount gives 1 at 25185, 2 at 24942, 2 at 28257. Reshaped: C98,97=1C_{98,97} = 1, C97,110=2C_{97,110} = 2, C110,97=2C_{110,97} = 2, every other entry 0, total 5 = 6 - 1.

Row normalization. For C=[[1,3],[1,1]]C = [[1, 3], [1, 1]] the row sums are 4 and 2, so P=[[1/4,3/4],[1/2,1/2]]P = [[1/4, 3/4], [1/2, 1/2]]. The wrong version C / C.sum(axis=1) divides by [4, 2] laid along the columns: [[1/4,3/2],[1/4,1/2]][[1/4, 3/2], [1/4, 1/2]], whose first row sums to 1.75. No error is raised: the table is square, so the shapes broadcast.

Step 1, the uv project. In your repo:

Terminal window
uv init --bare primers/lang.01 # writes primers/lang.01/pyproject.toml
uv add --project primers/lang.01 numpy # adds numpy to [project].dependencies, writes uv.lock
git add primers/lang.01/pyproject.toml primers/lang.01/uv.lock

Step 2, the stubs. ol start lang.01 writes primers/lang.01/worksheet.py and primers/lang.01/bigram.py. Every function body is raise NotImplementedError("lang.01"); you replace the bodies, never the signatures.

Step 3, the functions.

primers/lang.01/worksheet.py
def broadcast_shape(a: tuple[int, ...], b: tuple[int, ...]) -> tuple[int, ...]: ...
# the shape of x + y for x.shape == a, y.shape == b; ValueError when they do not broadcast.
# Implement the rule of 2.3 yourself; do not call np.broadcast_shapes.
def unbroadcast(grad: np.ndarray, shape: tuple[int, ...]) -> np.ndarray: ...
# grad summed back down to `shape` (2.4); ValueError when `shape` could not broadcast to grad.shape
def as_c_float32(x) -> np.ndarray: ...
# x as a C-contiguous float32 array; shares memory with x when x already is one
# primers/lang.01/bigram.py
def bigram_counts(data: bytes) -> np.ndarray: ...
# int64 (256, 256): C[a, b] = number of i with data[i] == a and data[i + 1] == b; no Python loop
def row_normalize(counts: np.ndarray) -> np.ndarray: ...
# float64, each nonzero row divided by its sum; zero rows stay zero

Step 4, the check. ol check lang.01 runs course/tests/lang.01/check in your repo. It refuses early, with the reason, if a file is missing, then runs uv run --project primers/lang.01 --with pytest pytest course/tests/lang.01 with primers/lang.01 on PYTHONPATH. Your project provides numpy; the check adds only pytest.

TestKINDChecksWhy it matters downstream
test_broadcast_shape_hand_examplesunitthe section 3 tableyou and the test agree on the rule
test_broadcast_shape_is_your_own_ruleunitpasses with numpy’s broadcasting helpers switched offthe exercise is the rule
test_broadcast_shape_aligns_on_the_rightboundary(3,) with (3, 1) gives (3, 3)the bias-add shape in every layer
test_broadcast_shape_rejects_incompatible_shapesboundary(3,) with (4,) raisesa shape bug must fail loudly
test_broadcast_shape_matches_numpy_on_every_small_pairdifferentialall 1600 pairs of shapes with up to 3 axes of sizes 1 to 3you can predict any broadcast
test_unbroadcast_hand_exampleunitthe section 3 tablethe L0.1 backward of an add
test_unbroadcast_keeps_size_one_axesboundarytarget (2, 1) stays (2, 1)a bias gradient keeps the bias’s shape
test_unbroadcast_sums_leading_axesboundary(4, 2, 3) to (3,) and (1, 3)batch axes are summed away
test_unbroadcast_rejects_a_shape_that_did_not_broadcastboundary(2,) is not a source of (2, 3)no invented gradients
test_unbroadcast_is_the_adjoint_of_broadcastingproperty⟨Bx,g⟩=⟨x,B⊤g⟩\langle Bx, g\rangle = \langle x, B^\top g\rangle on every small pair, seeded PCG32 valuesthe law autograd relies on
test_as_c_float32_hand_exampleunitfloat64 to float32, row-major bytesrt.01’s argument conversion
test_as_c_float32_does_not_copy_when_it_need_notunitan already-right array is sharedno copy per C call
test_as_c_float32_copies_a_transposed_viewboundaryx.T becomes a fresh row-major copyC reads the right elements
test_as_c_float32_copies_a_strided_sliceboundaryx[:, ::2] becomes denseC has no stride argument for this
test_as_c_float32_keeps_a_scalar_0dboundarya 0-d input stays shape ()scalar tensors in L0.0’s safetensors files
test_bigram_hand_exampleunit“banana” from section 3the L0.0 training count
test_bigram_counts_every_repeated_pairboundary“aaaa” counts aa three timesreal text repeats pairs constantly
test_bigram_counts_do_not_wrap_at_256boundary999 repeats read as 999, dtype int64counts over megabytes
test_bigram_counts_of_short_inputs_are_all_zeroboundaryempty and one-byte inputsedge of every corpus shard
test_bigram_counts_use_all_256_byte_valuesboundarybytes 128 and 255 get their own rowsUTF-8 text uses high bytes (D32)
test_bigram_row_sums_count_each_leading_bytepropertyrow aa sums to the count of aa in data[:-1]a law, not one example
test_bigram_counts_match_a_python_loopdifferentialequal to the obvious loop on random bytesthe loop is the definition
test_bigram_counts_has_no_python_loopunitno for, while, or comprehension in bigram_counts or any helper in bigram.py it callsthe exercise is vectorizing
test_row_normalize_hand_exampleunit[[1, 3], [1, 1]] from section 3the bigram’s probabilities
test_row_normalize_divides_rows_not_columnsboundaryrows sum to 1 on an uneven tablethe silent square-table bug
test_row_normalize_leaves_empty_rows_zeroboundaryno NaN for unseen bytesone NaN poisons every sum
test_project_declares_numpyunit[project].dependencies lists numpythe environment is reproducible
PitfallSymptomCaught by
1. Aligning shapes on the left (padding on the right)(3,) with (3, 1) gives (3, 1) instead of (3, 3)test_broadcast_shape_aligns_on_the_right
2. Taking the larger size when two sizes differ and neither is 1(3,) with (4,) “broadcasts” to (4,)test_broadcast_shape_rejects_incompatible_shapes
3. Summing without keepdims=True in unbroadcasta (2, 1) bias gets a (2,) gradient; the update b -= lr * g then broadcasts b up to (2, 2)test_unbroadcast_keeps_size_one_axes
4. np.array(x, dtype=np.float32) or x.astype(np.float32)correct values, but a full copy on every call even when x is already righttest_as_c_float32_does_not_copy_when_it_need_not
5. np.asarray(x, dtype=np.float32)a transposed view passes through unchanged; C reads x’s rows insteadtest_as_c_float32_copies_a_transposed_view
6. np.ascontiguousarray(x, dtype=np.float32)right for every array of 1 or more dimensions, but it returns at least 1-D: a 0-d scalar comes back with shape (1,)test_as_c_float32_keeps_a_scalar_0d
7. C[a, b] += 1 with index arrayseach distinct pair counts once (“aaaa” gives 1, not 3)test_bigram_counts_every_repeated_pair
8. A uint8 count table, or uint8 pair codes999 repeats read as 231; codes a * 255 + b wraptest_bigram_counts_do_not_wrap_at_256
9. C / C.sum(axis=1) without keepdimsno error on a square table, rows do not sum to 1test_row_normalize_divides_rows_not_columns
10. Dividing an empty row by its zero totalNaN rows that spread into every later sumtest_row_normalize_leaves_empty_rows_zero
11. pip install numpy into a global Python instead of the projectworks on your laptop, fails in CI and for uv runtest_project_declares_numpy
DirectionModuleHow it uses this
ForwardL0.0the byte bigram’s training is bigram_counts over a corpus, then row normalization with add-one smoothing
ForwardM03.1row-major layout and strides help explain the C exercise’s matrix buffers; Python and C are checked separately through fixture files
ForwardM03.1tl_matmul_f32 reads row-major float32 with an explicit leading dimension: the strides of 2.1, counted in elements
ForwardL0.1the backward of every broadcasting op is unbroadcast, and its gradient test is the adjoint identity of 2.4
Forwardlang.02the next primer: the shell, exit codes, signals, and make

A primer has no code call site, so no module’s ol check blocks on it. MS-P0 does: it requires a fresh pass of lang.01.

Your pieceProduction equivalentWhat it addsWhere to look
unbroadcastPyTorch Tensor.sum_to_size, ATen at::sum_tothe same reduction, run on every broadcasting op’s gradientaten/src/ATen/ExpandUtils.h
unbroadcastHIPS autograd unbroadcastthe same idea in a numpy-only autograd, close to what you build in L0.1autograd/numpy/numpy_vjps.py
as_c_float32PyTorch Tensor.contiguous(), DLPackzero-copy exchange of strided buffers between frameworksdmlc/dlpack include/dlpack/dlpack.h
bigram_countsnp.unique(..., return_counts=True), collections.Countercounting arbitrary keys, not just dense integer codesnumpy docs, numpy.bincount
primers/lang.01/uv.lockuv workspacesone lock for several packages (your python/ project in Pass 1)docs.astral.sh/uv, “Workspaces”