SiddharthAll posts
The Zipper, the Mask, and the Lost Write: How AI Picks the Right Numbers
Kimbho Thoughts|How AI works

The Zipper, the Mask, and the Lost Write: How AI Picks the Right Numbers

What you’ll learn
  • PyTorch indexing follows three consistent rules—indexes become coordinates that broadcast together, masks become flattened lists of True positions, and each bracket starts fresh axis numbering—which explain why results like `x[[0,2],[1,3]]` return pairs instead of blocks and why `x[rmask][:, cmask] = 0` silently writes to a discarded copy.
  • The most important takeaway is that chained indexing after an advanced index produces a copy, so writes vanish without error; use one bracket on the tensor you mean to change, and use `torch.where` or `index_put_(accumulate=True)` when shape must survive or indices repeat.

How AI works · A plain-English guide

The Zipper, the Mask, and the Lost Write: How AI Picks the Right Numbers

A plain-English guide to one of the most common operations in machine learning — and the silent mistakes hiding in it.

By Siddharth Tiwari · AI strategy & analytics · 23 min read

Every time a streaming service suggests a film, a phone recognises its owner’s face, or a chatbot answers a question, a computer is doing something surprisingly humble: picking numbers out of grids. Underneath it all, modern AI is a vast collection of numbers in tables, and almost everything it does begins by pulling the right ones out. That act of picking is called indexing.

AI is made of grids of numbers

A tensor is a grid of numbers, like a spreadsheet: rows, columns and a number in every cell. Each direction you can move in, down the rows or across the columns, is called an axis (engineers also say dimension), and the tensor’s shape lists its size along each axis. Three rows and four columns make shape (3, 4).

Almost anything can become a tensor. A photo is a grid of tiny squares called pixels, each stored as a brightness number; a colour photo stacks three such grids, for red, green and blue. Sound is a long list of numbers. Text becomes numbers too: every word gets an ID and a row of numbers describing its meaning. An AI model is itself a giant stack of grids, often billions of numbers in all.

What is a tensor: a photo becomes a grid of numbersA small photo of a smiling face is made of 8 by 8 pixels. Each pixel is stored as a brightness number, from 0 for black to 255 for white. A colour photo stacks three such grids, for red, green and blue, into a tensor of shape (3, 8, 8). Below: sound is a long list of numbers, text becomes ID numbers, and an AI model is a large stack of grids.WHAT IS A TENSOR?A grid of numbers. Photos, sound and text all become grids like this before AI can use them.1. A photo......is made of tiny squarescalled pixels.2. Each pixel is a number25025018018018018025025025018018018018018018025018018040180180401801801801804018018040180180180180180180180180180180180401801801801804018025018040404040180250250250180180180180250250brightness: 0 is black, 255 is white3. Stack grids: a tensorredgreenblueshape (3, 8, 8)3 colour layers × 8 rows× 8 columns0695-2-7Sounda long listof numbersthe464cat3797Textwords becomeID numbersAn AI modela stack of grids,billions of numbers
Figure 1. From photo to tensor. A photo is a grid of pixels, each pixel is stored as a brightness number, and a colour photo stacks three of those grids into a tensor of shape (3, 8, 8): three colour layers, eight rows, eight columns. Sound, text and AI models are stored as grids of numbers in the same way. (The word ID numbers are examples.)

Indexing is how AI picks what it needs

To index a tensor is to point at positions in it and take what is there: “row 2, column 3”, or “every cell above 100”. The position numbers are called indexes, and computers count them from 0, so the first row is row 0.

Picking is everywhere. A film recommender looks up the row of numbers that describes you and picks your best-scoring films. A translator swaps each word for its ID and picks that word’s row from a vocabulary table. Face recognition picks out the block of pixels holding a face, then the closest match among known faces. A chatbot writes one word at a time, scoring every possible next word and picking one. Indexing is one of the most common operations in AI code, and often the first thing a model does with your data.

Where indexing lives: three everyday AI featuresFilm recommendations: in a table of viewers by films, pick your row, then your two highest-scoring films. Translation: each word of 'the cat sat' picks its own row of numbers from a vocabulary table. Face recognition: the face is cropped out of the camera image by picking a block of pixels, then compared with stored faces, and the closest match is picked.WHERE INDEXING LIVESThree everyday AI features. In each one, a key step is picking cells out of a grid.Film recommendationsscores for viewers × filmsABCDEAna72538Ben46192You35928Dev614731. pick your row2. pick the top scoresRecommended: films C and ETranslationa vocabulary table, one row per wordthecatsata2715cat83622dog7461mat1924on5138sat36923the62471each word picks its own rowof numbers for the model to readFace recognitionthe camera image is a grid of pixelscrop the face:rows 2–6cols 3–7Alex3826Sam7194Kim5518closestmatchpick the face, then the best match
Figure 2. Three everyday AI features, one shared step. Each picks specific cells out of a grid: your row and your top-scoring films, the rows of numbers for the words in a sentence, and the block of pixels that holds a face.

Small rules, quiet mistakes

Indexing follows precise rules, and some are surprising. Engineers who misread them rarely see an error message. They get answers that look right and are not: a model that learns from the wrong numbers, a correction that silently changes nothing, a step that copies gigabytes of data and burns costly time on GPUs, the specialised chips that run AI. Understanding these rules is understanding how AI handles information.

Each idea below comes in four layers: plain English with an everyday comparison, the precise rule, code for engineers, and why it matters. The code is optional.

A quick testFour questions that fool most people

Before the rules, a test of intuition. We will use a tiny tensor: twelve numbers in three rows of four, numbered 0 to 11 so that every cell is easy to find. The code is written in PyTorch, one of the most popular toolkits for building AI. In it, x is the grid; t is a second tensor made of two such grids, stacked like the pages of a book; and rmask and cmask are lists of yes/no answers (True or False). rmask says yes to rows 0 and 2, cmask says yes to columns 1 and 3, and lists like these are called masks. Square brackets after a name, as in x[...], are how code asks for cells.

>>> import torch
>>> x = torch.arange(12).reshape(3, 4)
>>> x
tensor([[ 0,  1,  2,  3],
        [ 4,  5,  6,  7],
        [ 8,  9, 10, 11]])
>>> t = torch.arange(24).reshape(2, 3, 4)            # two 3 x 4 layers
>>> rmask = torch.tensor([True, False, True])        # rows 0 and 2
>>> cmask = torch.tensor([False, True, False, True]) # columns 1 and 3

Predict before you read on

  1. x[[0, 2], [1, 3]], meaning “rows 0 and 2, columns 1 and 3”. Most people expect four numbers: the corners 1, 3, 9 and 11.
  2. x[x % 3 == 0], meaning “every number divisible by 3”. Do they come back in their places in the grid?
  3. x[rmask][:, cmask] against x[rmask, cmask]: the same two masks, applied in two steps or in one. Same answer?
  4. x[rmask][:, cmask] = 0: set those cells to zero, in two steps. What happens to x?

Here is what PyTorch 2.14 answers:

>>> x[[0, 2], [1, 3]]          # 1. not a 2 x 2 block
tensor([ 1, 11])
>>> x[x % 3 == 0]              # 2. not a 3 x 4 grid with holes
tensor([0, 3, 6, 9])
>>> x[rmask][:, cmask]         # 3. the chain builds a grid...
tensor([[ 1,  3],
        [ 9, 11]])
>>> x[rmask, cmask]            #    ...one bracket builds pairs
tensor([ 1, 11])
>>> x[rmask][:, cmask] = 0     # 4. no error...
>>> x                          #    ...and no change
tensor([[ 0,  1,  2,  3],
        [ 4,  5,  6,  7],
        [ 8,  9, 10, 11]])

In plain English: asking for two rows and two columns returned two numbers, not four. Asking for the multiples of three returned them in a flat line, with the grid gone. Doing the same selection in two steps or in one gave different answers. And the attempt to set cells to zero ran without complaint and changed nothing at all.

None of this is a bug. Every result follows from a handful of rules that PyTorch applies with complete consistency, nearly all of them inherited from NumPy, the library that set the conventions for numerical computing in Python. We will build those rules in eight stops, and by the end all four answers should look obvious. Every snippet on this page was run on PyTorch 2.14, and each one starts from the setup above.

The route

  1. Slots: how each position inside the brackets changes the shape
  2. Broadcasting: stretching a row or a column to fit
  3. Views and copies: a window or a photocopy
  4. Lists of positions: the zipper
  5. Masks: the highlighter
  6. Masks beside other indexes: pairs, never grids
  7. Chained brackets: every step renumbers the grid
  8. Writing through an index: the lost write

Stop 1 · SlotsEach position in the brackets controls one direction

In plain English

Think of the square brackets as a form with one box per axis. In x[1, 2:4], the first box holds 1 and talks about the rows; the second holds 2:4 and talks about the columns. We will call each box a slot. What goes in a slot decides what happens to that direction of the grid.

A single number, such as 1, means “row 1 only”, and the row direction disappears: we are left with one line of four numbers. A range, called a slice, such as 1:2 (“from row 1 up to, but not including, row 2”), means “this stretch of rows”, and the row direction survives, even when the stretch is a single row. It is the difference between tearing one line out of a spreadsheet and keeping a spreadsheet that happens to have one line. Two special markers complete the set: None adds a new direction of size 1, like slipping a single page into a folder, and ..., the ellipsis, means “every direction I have not mentioned”.

The precise rule

Walk the slots from left to right and build the output shape as you go:

  • An integer consumes one axis and contributes nothing. The axis is gone.
  • A slice consumes one axis and contributes its own length, even when that length is 1.
  • None consumes nothing and contributes a new axis of size 1.
  • The ellipsis, ..., consumes every axis the other slots do not name and passes them through unchanged.

Any axes left over at the end behave as if we had written :, the slice that means “everything”, for them.

For engineers

>>> x[1]                    # integer: the row axis is gone
tensor([4, 5, 6, 7])
>>> x[1:2]                  # slice: the row axis survives with length 1
tensor([[4, 5, 6, 7]])
>>> x[None].shape           # None: a new leading axis
torch.Size([1, 3, 4])
>>> x[:, None].shape        # ...or a new axis between rows and columns
torch.Size([3, 1, 4])
>>> y = torch.arange(120).reshape(2, 3, 4, 5)
>>> y[0, ..., None].shape   # drop axis 0, keep (3, 4, 5), append 1
torch.Size([3, 4, 5, 1])
The slot ruleFour rows. x[1]: an integer drops its axis, shape (3, 4) becomes (4,). x[1:2]: a slice keeps its axis, (3, 4) becomes (1, 4). x[:, None]: None inserts a size-1 axis, (3, 4) becomes (3, 1, 4). y[0, ..., None]: the ellipsis stands for every axis not named, (2, 3, 4, 5) becomes (3, 4, 5, 1).THE SLOT RULEEach entry in the brackets fills one slot, and each slot decides the fate of one axis.INTEGERx[1]drops the axis it indexes341:4shape (3, 4)shape (4,)SLICEx[1:2]keeps its axis, even at length 1341:2:14shape (3, 4)shape (1, 4)NONEx[:, None]inserts a new axis of size 134:None:314shape (3, 4)shape (3, 1, 4)ELLIPSISy[0, ..., None]fills in every axis you skip23450...None3451shape (2, 3, 4, 5)shape (3, 4, 5, 1)Edges:x[5] raises IndexError · x[5:9] is an empty (0, 4) tensor · x[0, 0, 0] raises IndexError (too many indices)
Figure 3. The slot rule at a glance. Each box in the brackets acts on one direction of the grid: a single number removes that direction, a range keeps it, None adds a new direction of size 1, and the ellipsis stands for every direction not mentioned.

The difference between x[1] and x[1:2] is the first thing worth internalising. Both select the same four numbers, but one returns a vector of shape (4,) and the other a matrix of shape (1, 4). The data is identical; the contract is not. Integers are for when we are finished with an axis. Slices are for when that axis still means something downstream.

The edges of the rule are just as consistent. An integer must point at a position that exists, a slice is clamped to whatever exists, and a bracket cannot name more axes than the tensor has:

>>> x[5]
Traceback (most recent call last):
  ...
IndexError: index 5 is out of bounds for dimension 0 with size 3
>>> x[5:9].shape            # past the end: empty, not an error
torch.Size([0, 4])
>>> x[0, 0, 0]
Traceback (most recent call last):
  ...
IndexError: too many indices for tensor of dimension 2

Why it matters

Most shape mistakes in AI code are slot mistakes, and they rarely announce themselves. Writing a single number where later code expects a range, x[:, 0] instead of x[:, 0:1], silently changes how numbers line up a few lines further on. Ranges that run past the end are worse, because they never complain. A counting slip in the code that feeds a model its examples can produce an empty batch (the bundle of examples a model learns from at each step) instead of an error. The average of nothing is nan, “not a number”, and it flows quietly into the score the model is trying to improve.

Stop 2 · BroadcastingBroadcasting stretches rows and columns to fit

In plain English

Suppose we want to add a column of three numbers to a row of four. They are different shapes, so PyTorch stretches each one until they fit: the column is repeated across four columns, the row is repeated down three rows, and every cell now holds one number from each. This stretching is called broadcasting. Picture laying the same one-row ruler across every row of a table. It only works when the pieces are compatible: in each direction the sizes must match, or one of them must be 1, so that it can stretch.

There is one catch. Ask PyTorch to put the answer back into the column itself, an in-place update that changes the original instead of making something new, and it refuses: the column has room for three numbers and the answer has twelve. Stretching works on the way in, never on the way back.

Animation: a column of three numbers, 0, 1 and 2, sweeps across four columns while a row of four numbers, 0 to 3, sweeps down three rows; each cell becomes the sum of its row number and column number, filling a 3 by 4 table. A note then shows that writing the result back into the column fails.
Figure 4. Adding a column to a row. The teal column is stretched across all four columns and the amber row down all three rows, so every cell adds one number from each. Writing the result back into the column fails, because the column cannot grow.

The precise rule

Write the two shapes one above the other, aligned on the right, and compare them column by column. The sizes must be equal, or one of them must be 1, in which case it stretches to match the other. A shape that runs out of axes on the left is padded with 1s. The result takes the larger size in every column.

In-place operations are one-way: the result must fit the target’s existing shape, because the target cannot grow.

For engineers

>>> a = torch.arange(3).reshape(3, 1)   # a column
>>> b = torch.arange(4).reshape(1, 4)   # a row
>>> a + b                               # (3, 1) + (1, 4) gives (3, 4)
tensor([[0, 1, 2, 3],
        [1, 2, 3, 4],
        [2, 3, 4, 5]])
>>> a += b                              # but a must stay (3, 1)
Traceback (most recent call last):
  ...
RuntimeError: output with shape [3, 1] doesn't match the broadcast shape [3, 4]

Nothing is copied to perform the stretch. PyTorch rereads the same values along the stretched axis (internally, with a stride of 0), so broadcasting a column across a million columns costs no extra memory for the input. The output is another matter: a + b really is a new 3 × 4 tensor.

Assignment follows the same one-way rule. The left-hand side fixes the shape, and the right-hand side must broadcast to it. That makes some writes pleasantly compact:

>>> x[[0, 2]] = torch.tensor([100, 200]).reshape(2, 1)  # fills (2, 4)
>>> x
tensor([[100, 100, 100, 100],
        [  4,   5,   6,   7],
        [200, 200, 200, 200]])
Broadcasting lines shapes up from the rightFour cases. a + b: (3, 1) and (1, 4) give (3, 4). t + v: (2, 3, 4) and (4,) give (2, 3, 4) because missing leading axes count as 1. x + w: (3, 4) and (3,) fail because 4 and 3 differ and neither is 1. a += b fails because the in-place target keeps its own shape (3, 1).BROADCASTING: RIGHT-ALIGN, THEN COMPARERead shapes from the right. In each column the sizes must match, or one of them must be 1.a + b(3, 4)a13b41out43Each size-1 axis stretches to its partner.t + v(2, 3, 4)t432v411out432Missing leading axes count as size 1.x + werrorx43w3out??4 against 3, and neither is 1. w[:, None] fixes it.a += berrora13b41out13In place, the target keeps its shape (3, 1).The stretched operand is never materialised: PyTorch rereads the same values (stride 0).
Figure 5. How to tell whether two shapes fit. Line the sizes up from the right and compare each pair: they must match, or one of them must be 1. A missing size counts as 1. A mismatch fails, and so does an in-place update that would need more room than the original has.

Why it matters

The dangerous stretches are the ones that succeed. Suppose a model produces its predictions as a column of N numbers while the correct answers sit in a flat list of N. Subtracting one from the other should give N differences. Broadcasting instead stretches them into an N × N table, comparing every prediction with every answer. Every entry is a real number, so training carries on, measuring the wrong thing, and nothing crashes. PyTorch’s ready-made error measure, F.mse_loss, warns about exactly this mismatch; hand-written code does not. When a shape matters, check it.

Stop 3 · Views and copiesA window or a photocopy: views and copies

In plain English

When we ask for part of a tensor, PyTorch can answer in one of two ways. It can hand us a view: a window onto the original. No numbers move; we simply see a region of the same data, so changing a number through the window changes the original. Or it can hand us a copy: a photocopy of the numbers we asked for, on fresh memory, so writing on it never touches the original.

Which one we get depends on how we asked, not on what we asked for. Single numbers and ranges describe a regular pattern of positions, so they give a window. Lists of positions and masks can name any cells in any order, so they give a photocopy, even when they happen to pick a perfectly regular block. Engineers call the first kind basic indexing and the second advanced indexing.

Animation in four steps: a frame is drawn around rows 1 and 2 of the grid, a window onto the same numbers, and writing 99 through the window changes the grid itself. Then rows 1 and 2 are photocopied into a separate grid, and writing -1 on the copy leaves the original grid unchanged.
Figure 6. The same two rows, asked for in two ways. Asking with a range gives a window onto x, so writing 99 through it changes x. Asking with a list of positions gives a photocopy, so writing -1 on it leaves x alone.

The precise rule

Ask one question of every bracket: could the result be described as a starting point plus a fixed step along each axis of the original (an offset and strides)? Integers, slices, None and the ellipsis always can, so they give views. Lists, integer tensors and masks name positions, so they give copies, even when those positions happen to form a tidy slice.

For engineers

The four slot types from stop 1 share a property: their result can always be described as a starting offset plus a stride per axis into the original memory. So PyTorch moves no data at all. It returns a view, a new tensor header that points into the same storage. Python lists, integer tensors and boolean masks name arbitrary positions, which in general cannot be expressed as strides, so PyTorch gathers the selected values into fresh memory and returns a copy. Here ptr reports the address where a tensor’s memory block starts:

>>> ptr = lambda p: p.untyped_storage().data_ptr()
>>> ptr(x[1:3]) == ptr(x)                    # basic: shares x's memory
True
>>> ptr(x[torch.tensor([1, 2])]) == ptr(x)   # advanced: new memory
False

The difference becomes visible the moment we write through the result:

>>> v = x[1:3]                    # a view
>>> v[0, 0] = 99
>>> x[1, 0]                       # the write went through to x
tensor(99)
>>> c = x[torch.tensor([1, 2])]   # a copy of the same rows
>>> c[0, 0] = -1
>>> x[1, 0]                       # x never sees it
tensor(99)
Basic indexing returns a view, advanced indexing returns a copyLeft: v = x[1:3] is a window onto cells 4 to 11 of x's own storage, so nothing is copied. Right: c = x[torch.tensor([1, 2])] copies the same eight values into new storage.BASIC INDEXING → VIEWv = x[1:3]01234567891011xrows 1 and 2x's storage (one flat block of memory)01234567891011v points at cells 4–11 of the same block:offset 4, shape (2, 4), stride (4, 1).Writes through v land in x.int · slice · ... · NoneADVANCED INDEXING → COPYc = x[torch.tensor([1, 2])]01234567891011xrows 1 and 2x's storage (one flat block of memory)012345678910114567891011new blockc owns new memory; writes to c never reach x.lists · integer tensors · boolean masks
Figure 7. What happens in memory. The window (view) points at part of the original block of memory; the photocopy (copy) is a new block holding the eight numbers it picked.

Why it matters

This is where indexing meets the budget. A window costs nothing, even on a table of numbers that fills 10 gigabytes; a photocopy of the same data needs another 10 gigabytes, which can be the difference between a model fitting on an expensive GPU and crashing. Windows also link things: a change made through one changes the original, which is useful when intended and baffling when not. And a window keeps the whole original alive, so holding on to a small piece of a large batch keeps the entire batch in memory. Calling .clone() turns a window into a small, independent photocopy.

Stop 4 · The zipperTwo lists of positions zip together

In plain English

Now the first surprise. Give PyTorch a list of rows and a list of columns, and it does not take every combination. It pairs them like the two sides of a zipper: the first row with the first column, the second row with the second column, and so on. Each pair is the address of one cell, its coordinates, and each address yields one number. Rows 0 and 2 zipped with columns 1 and 3 give the cells (0, 1) and (2, 3): two numbers, 1 and 11, not four.

To get every combination, the two lists must be arranged so that they stretch into a grid, one standing as a column and the other lying as a row, using the broadcasting of stop 2.

Animation: the row list 0, 2 and the column list 1, 3 are joined like the teeth of a zipper into the pairs (0, 1) and (2, 3); the cells holding 1 and 11 light up and move into a two-number result, while the corners 3 and 9 are marked as never visited.
Figure 8. The zipper. Rows 0 and 2 pair up with columns 1 and 3, tooth by tooth, giving the addresses (0, 1) and (2, 3) and the numbers 1 and 11. The other corners of the block, 3 and 9, are never visited.

The precise rule

Broadcast all the index tensors in the bracket to one shape. Every position in that shape is one coordinate, assembled from the matching entries of each index. The result has exactly that shape, with one value per coordinate.

For engineers

>>> x[[0, 2], [1, 3]]          # coordinates (0, 1) and (2, 3)
tensor([ 1, 11])

The zipper is a special case of a more general rule. All the index tensors in one bracket are first broadcast against each other, exactly as in stop 2, and the broadcast shape becomes the shape of the result. Two lists of shape (2,) broadcast to (2,): two coordinates, two values. To get the grid, we give the indexes shapes that broadcast to a grid, a column of row numbers and a row of column numbers:

>>> x[torch.tensor([[0], [2]]), torch.tensor([1, 3])]  # (2, 1) by (2,)
tensor([[ 1,  3],
        [ 9, 11]])
Index lists zip; broadcasting makes a gridLeft: x[[0, 2], [1, 3]] pairs the lists position by position into (0, 1) and (2, 3) and returns [1, 11], shape (2,). Right: a (2, 1) row index and a (2,) column index broadcast to a 2 by 2 table of coordinates and return [[1, 3], [9, 11]], shape (2, 2).TWO LISTS ZIPx[[0, 2], [1, 3]]01(0, 1)23(2, 3)111two pairs, two valuesshape (2,)BROADCAST → GRIDx[rows[:, None], cols]021313911every combinationshape (2, 2)
Figure 9. Zip versus grid. Two lists of the same length pair up position by position. Arrange them as a column and a row instead, and they stretch into a table of addresses, one for every combination.

Stop 5 · MasksA highlighter marks the cells to pick

In plain English

A mask is a grid of yes/no answers that marks the cells to pick, like a highlighter. When you index with a mask, PyTorch reads the highlighted cells in order, row by row, and returns them as a single flat line. The grid’s layout is gone: you get the selected values, not a picture of the original with gaps.

The order matters. PyTorch reads the highlighted cells the way you read a page: left to right along the first row, then the second, and so on. Turn the grid on its side and the same cells come out in a different order, because the rows are different.

Animation: the cells divisible by 3 are highlighted; the selection then reads them in row-major order, left to right then down, and gathers them into a single flat row of four numbers, 0, 3, 6 and 9, with the 3 by 4 layout gone.
Figure 10. The highlighter at work. The cells divisible by 3 are highlighted, read in order like lines on a page, and returned as a single row of four numbers. The 3 × 4 layout does not survive.

The precise rule

A boolean mask in a slot is replaced by the coordinates of its True entries, listed in row-major order: row 0 from left to right, then row 1, and so on. A mask with k dimensions consumes k axes of the tensor and contributes a single axis whose length is the number of True values. The layout is not preserved, because the result is a list of selected elements, not a picture of the original.

For engineers

>>> x[x % 3 == 0]        # flat: the layout is gone
tensor([0, 3, 6, 9])
>>> x.T[x.T % 3 == 0]    # read in x.T's own row order
tensor([0, 9, 6, 3])

Row-major order is a property of the tensor being read, not of the values. Transpose it and the same four numbers come out in a different order, because x.T has four rows of three and is read row by row like everything else.

A mask does not have to cover every axis. A two-dimensional mask over the first two axes of t collapses those two axes into one and leaves the last axis alone:

>>> layrow = torch.tensor([[True, False, True], [False, True, False]])
>>> t[layrow].shape        # 3 trues, each picks a whole row of 4
torch.Size([3, 4])
>>> diag = torch.zeros(3, 4, dtype=torch.bool)
>>> diag[0, 0] = diag[1, 1] = diag[2, 2] = True
>>> t[:, diag].shape       # 3 trues, one per layer
torch.Size([2, 3])

Why it matters

The length of a masked result depends on the data, and that has two consequences. On a GPU, PyTorch has to count the True values before it can allocate the output, so x[mask] makes the host wait for the device, a synchronisation that can quietly serialise an otherwise asynchronous training step. And because the layout is gone, masking is the wrong tool whenever the shape must survive, for example when ignoring padded tokens in a batch. There the right call is torch.where, which keeps every position and changes only the values:

>>> torch.where(x % 3 == 0, x, torch.tensor(-1))
tensor([[ 0, -1, -1,  3],
        [-1, -1,  6, -1],
        [-1,  9, -1, 11]])   # shape preserved

Stop 6 · Masks beside other indexesMasks pair up, they never multiply

In plain English

A mask in a slot turns into the positions of its True values, so a one-dimensional mask becomes a single list of positions. From then on it behaves exactly like the lists of stop 4: it zips with other index tensors, broadcasts against integers, and leaves slices alone.

This is where intuition imported from other tools does the most damage. In a spreadsheet or a data table, selecting “these rows and these columns” usually means every combination: the Cartesian product. PyTorch does not do that. Two masks in one bracket are two lists of positions, and lists zip. They never multiply.

Two masks pair, never multiplyLeft: x[rmask, cmask] turns the row mask into rows 0 and 2 and the column mask into columns 1 and 3, then pairs them into (0, 1) and (2, 3), returning [1, 11]. Right: x[rmask][:, cmask] applies the row mask first, then the column mask to that result, returning the 2 by 2 grid [[1, 3], [9, 11]].ONE BRACKET: PAIRSx[rmask, cmask]rmask → rows 0 and 2cmask → columns 1 and 3pair them: (0, 1) and (2, 3)111two numbers, not a gridshape (2,)CHAINED: A GRIDx[rmask][:, cmask]rows first, then columnsevery combination13911a 2 by 2 gridshape (2, 2)
Figure 11. A highlighter is shorthand for a list of positions. Combined with another highlighter in one set of brackets, the two lists pair up. Applied one after the other, they build a grid.

The precise rule

Two masks in one bracket are two position lists, and position lists zip. They never form a Cartesian product. If the True counts differ, the lists cannot broadcast and PyTorch raises an error; if the counts happen to match, it returns paired values without complaint.

For engineers

>>> x[rmask, cmask]      # two masks: zip, not grid
tensor([ 1, 11])
>>> x[rmask][:, cmask]   # chain: grid
tensor([[ 1,  3],
        [ 9, 11]])

Why it matters

In pandas, df.loc[row_mask, col_mask] selects every kept row crossed with every kept column: the Cartesian product. The same-looking expression in PyTorch, and in NumPy, pairs the masks entry by entry. When the counts differ we at least get an error. When they match, as in our example, we get a plausible-looking vector of the wrong values. To mean “these rows and these columns”, say so explicitly: chain the masks when reading, or build a grid of positions with [:, None] when writing, as in stop 8.

Stop 7 · Chained bracketsEvery step renumbers the grid

In plain English

A chain such as t[:, 1][0] is two separate indexing operations. The first produces a new tensor with its own directions, numbered from 0, and the second bracket indexes that tensor knowing nothing about where its directions came from. A single bracket, by contrast, addresses the original directions all at once. The same characters can therefore land on different directions.

For reading, a chain is legitimate and often the clearest way to say “these rows, then these columns”. For writing, everything depends on what the first bracket returns. A chain of basic indexes is windows all the way down, so a write at the end of it reaches the original.

Animation on the two-layer tensor t: t[:, 1] removes the row direction, so the next [0] lands on the layer direction and returns [4, 5, 6, 7]; in one bracket t[:, 1, 0] keeps the original numbering and the 0 lands on the column direction, returning [4, 16].
Figure 12. Two pages of numbers, two ways of asking. In two steps, row 1 of each page is taken first, then the first page, giving [4, 5, 6, 7]. In one step, the same characters address the original directions and give [4, 16].

The precise rule

After each bracket, renumber the axes of the result from 0, then apply the next bracket to that. Every integer removes an axis, so the axes to its right shift one place left; every None inserts one, shifting the axes after it one place right.

For engineers

>>> t[:, 1][0]     # chain: first bracket drops rows, so [0] hits layers
tensor([4, 5, 6, 7])
>>> t[:, 1, 0]     # one bracket: 0 hits the column axis
tensor([ 4, 16])

Renumbering also explains the third surprise from the other side. Chaining two masks applies them one after the other, rows first and then the columns of that result, which is how we get the grid that a single bracket refuses to build.

For writing, everything depends on what the first bracket returns. A chain of basic indexes is views all the way down, so a write at the end of it reaches the original:

>>> x[1][0] = 99        # chain of basic indexes: write reaches x
>>> x[1, 0]
tensor(99)

Why it matters

A classic refactoring bug is collapsing a[:, i][j] into a[:, i, j] because it looks tidier. The two agree only when the first bracket contains nothing but integers. Chains also cost something at runtime: every bracket is a separate operation, and if one of them is advanced it allocates a full intermediate copy.

Stop 8 · Writing through an indexThe write that goes nowhere

In plain English

Every indexing form we have met can also appear on the left of an assignment, and the core rule is refreshingly simple. x[idx] = v is a single call that writes into the memory of x itself, whatever kind of index idx is. Masks, lists and index tensors all write in place, and the value broadcasts to the shape of x[idx] under the one-way rule from stop 2.

The traps come from statements that look like one write but are not. There are two of them.

The chained write that goes nowhere

Python evaluates x[rmask][:, cmask] = 0 as x.__getitem__(rmask).__setitem__((slice(None), cmask), 0). The getitem builds a copy (stop 3), the setitem dutifully writes zeros into that copy, and the copy, referenced by nothing, is thrown away:

>>> x[rmask][:, cmask] = 0      # no error, no warning
>>> x
tensor([[ 0,  1,  2,  3],
        [ 4,  5,  6,  7],
        [ 8,  9, 10, 11]])

The fix is to put the whole selection into one bracket, so that the only call is a setitem on x. Two masks would zip (stop 6), so we either convert them to position lists shaped into a grid (stop 4) or combine them into one two-dimensional mask:

>>> x[rmask.nonzero().squeeze(1)[:,None], cmask.nonzero().squeeze(1)[None,:]] = 0
>>> x
tensor([[ 0,  0,  2,  0],
        [ 4,  5,  6,  7],
        [ 8,  0, 10,  0]])
>>> x2 = torch.arange(12).reshape(3, 4)
>>> x2[rmask[:, None] & cmask[None, :]] = 0   # one 2-D mask
>>> torch.equal(x, x2)
True
A chained write lands in a temporary copyTop: x[rmask][:, cmask] = 0. The first bracket is advanced indexing and builds a copy; the zeros are written into that copy, which is then discarded, so x is unchanged. Bottom: one bracket, x[rows[:, None], cols] = 0, writes into x itself.CHAINED: THE WRITE IS LOSTx[rmask][:, cmask] = 001234567891011x(untouched)[rmask]copy0123891011temporary copy[:, cmask] = 0write002080100written, then discardedNo error and no warning: the first bracket is advanced indexing, so everything after it edits a copy.ONE BRACKET: THE WRITE STICKSx[rows[:, None], cols] = 0rows = rmask.nonzero().squeeze(1)cols = cmask.nonzero().squeeze(1)or: x[rmask[:, None] & cmask] = 00020456780100x itselffour cells zeroedin place
Figure 16. Top: a chain that starts with a mask writes into a temporary photocopy, which is thrown away. Bottom: a single set of brackets on x writes into x itself.

Duplicate positions are written once

b[idx] += 1 expands to three steps: read b[idx], which gathers a copy; add 1 to the copy; and write the copy back with b[idx] = .... If idx repeats a position, the increments never accumulate. index_put_ with accumulate=True, or its siblings index_add_ and scatter_add_, adds every contribution:

>>> b = torch.zeros(3)
>>> b[torch.tensor([0, 0, 1])] += 1
>>> b
tensor([1., 1., 0.])
>>> b = torch.zeros(3)
>>> idx = torch.tensor([0, 0, 1])
>>> b.index_put_((idx,), torch.ones(3), accumulate=True)
tensor([2., 1., 0.])

Without accumulation, PyTorch’s documentation is explicit that writing to duplicate positions gives an undefined result. Our example only looks predictable because every write carries the same value.

Why it matters

Both traps produce results that look fine. Repeated positions are normal in real data: the same word appears twice in a sentence, many links point to the same page, many events land in the same bucket of a histogram. Counting them with += quietly undercounts. And the lost write turns up in exactly the code that must not fail silently: blanking out invalid entries, capping extreme values, resetting the state of a conversation that has finished.

Putting it togetherThree sentences that explain every result

Eight stops reduce to three sentences. Engineers can carry them in this compact form:

Indexes become coordinates, masks become true positions, position lists broadcast together.

In plain English:

Indexes become coordinates. Everything inside the brackets is an instruction for one direction of the grid, and together the instructions spell out addresses. Single numbers and ranges describe a regular pattern, so they give a window onto the original; anything that lists positions gives a photocopy. A new set of brackets means a new tensor, with its directions numbered afresh.

Masks become true positions. A highlighter is shorthand for the addresses of the highlighted (True) cells, read like lines on a page. That is why the multiples of three came back as a flat line, and why the grid turned on its side gave a different order.

Position lists broadcast together. All the lists of positions in one set of brackets are stretched to a common shape and read in step, one address per position: the zipper. That is why two lists gave two numbers, why two masks gave pairs, and why arranging the lists as a column and a row produces a grid.

The fourth surprise needs one more fact, about Python rather than PyTorch: an assignment writes into whatever the final brackets are attached to. If an earlier step made a photocopy, that is where the write goes, and it is thrown away with the copy.

The field card

What goes in the bracketsExampleWhat happens to the shapeWindow or photocopy?
A single numberx[1]That direction disappearsWindow (view)
A range (slice)x[1:2]That direction stays, perhaps shorterWindow (view)
Nonex[:, None]A new direction of size 1 appearsWindow (view)
Ellipsisy[0, ..., None]Stands in for every direction not mentionedWindow (view)
A list of positionsx[[0, 2], [1, 3]]The lists pair up (zip) and the result takes their combined shape, which moves to the front if a range separates themPhotocopy (copy)
A mask (highlighter)x[x % 3 == 0]The highlighted directions collapse into one line of the selected valuesPhotocopy (copy)

Before writing the bracket

  • Want every combination of some rows and some columns? Arrange the two position lists as a column and a row so they stretch into a grid: rows[:, None] and cols[None, :].
  • Want to keep the grid’s layout while using a mask? Use torch.where(mask, x, fill), which blanks cells instead of removing them.
  • Changing values? Use a single set of brackets on the tensor you mean to change. In a chain, any list or mask before the last bracket sends the write to a copy.
  • Might positions repeat? Use index_put_(..., accumulate=True), index_add_ or scatter_add_ so that every update counts.
  • Merging two sets of brackets into one? Recount the directions first.
  • Keeping a small piece of something large? Call .clone(), or the whole original stays in memory.

None of this requires memorising special cases. It requires reading brackets the way PyTorch reads them: one direction at a time, one list of positions at a time, one result at a time. Once that habit forms, the four answers at the top of this page stop looking like surprises and start looking like the only possible answers. And the next time a recommendation lands just right or a translation reads naturally, it is worth remembering how much of that depended on picking exactly the right numbers.

Pocket glossary

WordMeaning
TensorA grid of numbers with any number of directions: a list, a table, or a stack of tables.
Axis (dimension)One direction in a tensor, such as down the rows or across the columns.
ShapeThe size of a tensor along each axis: 3 rows and 4 columns is shape (3, 4).
IndexA position number, counted from 0. Indexing means picking the values at chosen positions.
SliceA range of positions, such as 1:3 for positions 1 and 2.
MaskA grid of True/False answers that marks the cells to pick, like a highlighter.
BroadcastingStretching a direction of size 1 so that two shapes fit together.
ViewA window onto the original data. Changes made through it change the original.
CopyA photocopy of the selected data. Changes made to it stay in the copy.
In placeChanging the original tensor rather than producing a new one.
GPUThe specialised chip that runs most AI computation.

Further reading for engineers

Image credits

Cover illustration
Generated for this article
AI-generated
0 comments
Next up

One Equation Rules Them All: The Chain Rule, LLMs, and the Math That Runs Your Life

The chain rule—a 349-year-old calculus concept—is the mathematical engine behind every AI system you use, from chatbots to recommendation engines. It works by multiplying…
Read this next
Siddharth
Siddharth

Thoughts and essays, published with Yokush. See more posts

Comments 0

Name & email required. Your email is never shown publicly.
No comments yet — be the first.