5.4.3 · D3Scientific Computing (Python)

Worked examples — Indexing and slicing — basic, boolean masking, fancy indexing

4,707 words21 min readBack to topic

Throughout, an array is just numbered boxes in a row (or a grid). Indexing means "hand me the boxes at these positions." The three tools:

  • Slicing start:stop:step → a view (a window onto the same boxes).
  • Boolean mask → a copy of every box where a True/False stencil shows True.
  • Fancy indexing → a copy of boxes at a list of positions, in that order.

The scenario matrix

Every cell below is hit by at least one worked example. If your real code lands in a cell, the matching example shows the mechanics and the sanity check. Figure s01 draws the three tools on one row of boxes so you can see the difference before we start.

Figure — Indexing and slicing — basic, boolean masking, fancy indexing

In figure s01: the cyan dashed window is a slice (a view onto the real boxes); the amber T/F row underneath is a boolean stencil; the white arrow is a fancy index list picking positions out of order. Keep this picture in mind — every example is one of these three, or a combination.

Cell Case class Covered by
A Positive-step slice, count formula Ex 1
B Negative step (reverse) + omitted bounds a[:5], a[5:], a[::-1] Ex 2
C Degenerate slice → empty result + zero-step error Ex 3
D Out-of-bounds slice → silent clamping Ex 4
E 2-D slice = block; view side-effect Ex 5
F Boolean mask + combining with &, ~ Ex 6
G In-place assignment through a mask Ex 7
H Fancy indexing: reorder, repeat, negative index Ex 8
I 2-D fancy: pointwise zip vs true block (np.ix_) Ex 9
J Mixed-mode multi-D: fancy + slice, mask on a 2-D array Ex 10
K Real-world word problem (sensor data) + dtype pitfall Ex 11
L Exam twist: view-vs-copy chained assignment Ex 12

Ex 1 — Cell A · positive-step slice & the count formula

Steps

  1. Recall the count formula from the parent note. For step : Why this step? We need to know how many boxes come out before listing them — it stops us miscounting the last index. ( is the round-up defined above.)

  2. Plug in : Why this step? is not whole, so we round up — there's a partial "gap" that still fits one more index. Ceiling captures exactly that.

  3. List the indices . The next would be → excluded. Why this step? stop is exclusive (up to but not including). Index 15 is the last one strictly below 17.

Answer: a[3:17:4] → array([3, 7, 11, 15]), count .

Recall Verify

so 19 is correctly dropped; and matches the 4 listed values. ✓


Ex 2 — Cell B · negative step + omitted bounds

Steps

  1. Omitted bounds fill in the defaults. A missing start means ; a missing stop means len(a) (here 20); a missing step means . So:

    • a[:5] = a[0:5:1][0,1,2,3,4] (first 5).
    • a[5:] = a[5:20:1][5,6,…,19] (everything from index 5 on, 15 elements). Why this step? Most real slicing omits bounds; you must know what NumPy silently fills in. See figure s02, panel (a): the cyan window snaps to the array edge whenever a bound is blank.
  2. a[::-1] reverses. With step = -1 and both bounds omitted, NumPy uses the reverse defaults: start = last index (19), stop = "just past the front". So it walks 19,18,…,1,0. Why this step? This is the idiom for reversing an array. The defaults flip because a negative step must start high and end low — figure s02, panel (b) shows the amber arrow running right-to-left.

  3. a[15:3:-4] — an explicit negative step. For a negative step the count formula flips: we count integers with (strictly greater, since we go down and stop is still exclusive). Using and the defined above: Why this step? With step<0 the plain would be negative; we reframe by measuring the downward distance (its size via ), and clamp in case that distance is itself negative (a wrong-direction range — see Ex 3).

  4. List going down by 4 from 15: . Next is → excluded. Why this step? Figure s02, panel (c) — the amber arrow hops left and must stop before the box labelled 3.

Figure — Indexing and slicing — basic, boolean masking, fancy indexing

Answer: (a) [0,1,2,3,4]; (b) [5,6,…,19] (15 elements); (c) [19,18,…,1,0] (reversed, 20 elements); (d) [15,11,7], count 3.

Recall Verify

len(a[:5])==5, len(a[5:])==15, a[::-1] first element is 19 & last is 0 with length 20, and a[15:3:-4]==[15,11,7] (count ). ✓


Ex 3 — Cell C · degenerate / empty slice + zero-step error

Steps

  1. a[5:5]: . Count . Why this step? No index satisfies . Zero boxes fit.

  2. a[8:3]: default step , but . Count formula gives , then . Why this step? Walking upward from 8 can never reach below 3, so nothing qualifies. The clamp is why negatives become empty, not errors.

  3. a[3:8:-1]: step means walk down from 3, but is above 3. Downward distance . Why this step? You'd need to go up to reach 8, but the step says go down. Impossible → empty.

  4. a[::0]: a step of zero is illegal — it means "advance by nothing", so the walk never moves and can never terminate. Python raises ValueError: slice step cannot be zero. Why this step? Every other degenerate slice returns an empty array silently, but a zero step is the one slicing case that actually raises. Distinguishing "empty vs error" here is the key takeaway.

Answer: a[5:5], a[8:3], a[3:8:-1] are all array([], dtype=int64) — length 0. a[::0] raises ValueError: slice step cannot be zero.

Recall Verify

All three ordinary slices clamp to count 0; len(a[5:5]) == len(a[8:3]) == len(a[3:8:-1]) == 0, and a[::0] raises ValueError. ✓


Ex 4 — Cell D · out-of-bounds slice → silent clamping

Steps

  1. Slices clamp bounds to the array; they never raise for being out of range. Any stop above len(a) is treated as len(a); any start below -len(a) is treated as the front. So:

    • a[15:999]stop clamped to 20 → [15,16,17,18,19].
    • a[-999:3]start clamped to the front (0) → [0,1,2].
    • a[18:25]stop clamped to 20 → [18,19]. Why this step? This is the key difference from scalar indexing. a[25] raises IndexError, but a[18:25] politely gives whatever exists. This is why "grab the next 5 starting here" is safe near the end — you just get fewer if there aren't 5 left.
  2. Sanity: the count of a[15:999] is min(999, 20) - 15 = 5; of a[-999:3] is 3 - max(-999, -20 → 0) = 3; of a[18:25] is min(25,20) - 18 = 2. Why this step? Mentally clamp each bound into first, then apply the ordinary count formula.

Answer: a[15:999] → [15,16,17,18,19], a[-999:3] → [0,1,2], a[18:25] → [18,19]. None raise.

Recall Verify

len(a[15:999])==5, len(a[-999:3])==3, len(a[18:25])==2, and none raise. ✓


Ex 5 — Cell E · 2-D block slice & the view side-effect

Steps

  1. Build A: values 0..11 in a 3×4 grid (row-major). So row 0 is [0,1,2,3], row 1 is [4,5,6,7]. Why this step? We must know exactly which boxes rows {0,1} × cols {1,2} pick.

  2. A[0:2, 1:3]: rows 0-1, cols 1-2 → the block Why this step? Both indices are ranges, so we intersect them into a rectangle — see the amber block in figure s03.

  3. B is a view: a range-slice only changes the offset/strides recipe, no data copied. So B[0,0] = 99 writes into the shared buffer at A's position (0,1). Why this step? This is the parent note's Trap 1 — NumPy slices are not independent copies like Python lists.

Figure — Indexing and slicing — basic, boolean masking, fancy indexing

Figure s03: the amber cells are the sliced block [[1,2],[5,6]]; the cyan dashed rectangle marks the rows/cols the two ranges select. Because it's a view, editing an amber cell rewrites the matching cell of A.

Answer: B → [[99, 2],[5, 6]] and A[0,1] == 99 (A is mutated!).

Recall Verify

After the write, A row 0 is [0, 99, 2, 3], so A[0,1] == 99 and B[0,0] == 99. ✓


Ex 6 — Cell F · boolean mask with & and ~

Steps

  1. Build the two elementwise conditions: c1 = a > 0 and c2 = a <= 5. Why this step? Each is a boolean array the same shape as a — a True/False stencil, not a single yes/no.

  2. Combine with & (elementwise AND), each condition in parentheses:

mask = (a > 0) & (a <= 5)

Why & not and? and needs one boolean; here both sides are 8-long arrays. & ANDs them box-by-box. Parentheses are required because & binds tighter than >.

  1. Evaluate per element:
a >0 <=5 &
3 T T T
-1 F T F
4 T T T
-2 F T F
5 T T T
0 F T F
-7 F T F
8 T F F

Why this step? A mask returns a 1-D copy of the True cells, in order → [3, 4, 5].

  1. Complement a[~mask]: the ~ operator flips every entry of mask (True↔False), so ~mask is True exactly where mask was False. Reading the table, mask is False for the elements -1, -2, 0, -7, 8 — so those are precisely the ones the complement collects. Why this step? The mask and its complement partition the array: every element is in exactly one of the two selections. Note 5 had mask = True, so it stays in the first group and is absent from the complement.

Answer: a[mask] → array([3, 4, 5]); a[~mask] → array([-1, -2, 0, -7, 8]).

Recall Verify

The two selections partition all 8 elements: and together (as multisets) they equal the whole array. ✓


Ex 7 — Cell G · in-place assignment through a mask

Steps

  1. Build the mask a < 0[F, T, F, T, F, T]. Why this step? We want to target exactly the negative boxes.

  2. Assign into the masked positions:

a[a < 0] = 0

Why this step? When a mask sits on the left of =, NumPy writes the right-hand value into every True cell — a vectorized "set these to 0", no loop.

  1. Only the True positions change; positives are untouched. Why this step? The stencil covers cells 1, 3, 5 → those become 0; cells 0, 2, 4 keep 3, 4, 5.

Answer: a → array([3, 0, 4, 0, 5, 0]).

Recall Verify

Sum before was ; after clipping negatives to 0 the sum is , and no positive value changed. ✓


Ex 8 — Cell H · fancy indexing: reorder, repeat, negative

Steps

  1. Fancy indexing takes an integer list of positions and returns a copy with elements in that exact order, repeats allowed. Why this step? Unlike a slice (a regular stride), we're naming arbitrary positions, so NumPy must gather them into fresh memory.

  2. Resolve each index against a (length 5). Negative -1 means "last", i.e. position 4. Why this step? Negative fancy indices follow the same "count from end" rule as basic indexing.

  3. Read off: pos 4 → 50, pos 0 → 10, pos 0 → 10 (repeat), pos -1 → 50, pos 2 → 30. Why this step? The result shape matches the index list (length 5), not a's shape — here both are 5, but see Ex 9 for the general rule.

Answer: a[[4, 0, 0, -1, 2]] → array([50, 10, 10, 50, 30]).

Recall Verify

Length of output (matches index list); value at output position 3 is a[-1] == 50. ✓


Ex 9 — Cell I · 2-D fancy: pointwise zip vs true block

Steps

  1. Recall A (3×4): row 0 [0,1,2,3], row 1 [4,5,6,7], row 2 [8,9,10,11]. Why this step? We must locate specific (row, col) cells.

  2. A[[0,2],[1,3]] zips the two index arrays pointwise: index 0 → (row 0, col 1), index 1 → (row 2, col 3). Why this step? This is the parent's Trap 2 — two fancy index arrays are paired element-by-element, giving a 1-D result of length 2, not a rectangle.

  3. For the actual block of rows {0,2} × cols {1,3}, use np.ix_, which reshapes the indices to broadcast into a grid:

A[np.ix_([0,2],[1,3])]

Why this step? np.ix_ turns [0,2] into a column and [1,3] into a row so they broadcast to a 2×2 grid of all combinations — see figure s04.

Figure — Indexing and slicing — basic, boolean masking, fancy indexing

Figure s04: the amber cells 1 and 11 are the pointwise zip A[[0,2],[1,3]] (a diagonal pick); the cyan cells 1, 3, 9, 11 are the full block from np.ix_. Notice the zip is just the block's main diagonal.

Answer: A[[0,2],[1,3]] → array([1, 11]) (the amber diagonal); A[np.ix_([0,2],[1,3])] → [[1,3],[9,11]] (the cyan block).

Recall Verify

The zip gives exactly the two corners 1 and 11, which are the block's main diagonal; the block additionally contains 3 and 9. ✓


Ex 10 — Cell J · mixed-mode multi-D indexing

Steps

  1. (a) fancy + slice. Axis 0 gets the fancy list [0,2] (pick rows 0 and 2); axis 1 gets the slice 1:3 (cols 1,2). NumPy applies each axis independently: each chosen row is sliced to its cols 1-2, giving a 2 × 2 result. Why this step? When one axis is fancy and another is a plain slice, they don't "zip" — the slice acts like a normal column-window on every fancy-selected row. Result:

  2. (b) 2-D boolean mask. A > 6 is a 3×4 True/False grid; A[mask] gathers every True cell into a 1-D copy, row-major. The values are 7,8,9,10,11. Why this step? A boolean mask over a 2-D array always flattens — the True cells don't form a rectangle, so there's no shape to keep (parent's Trap 4). Result: array([7, 8, 9, 10, 11]), shape (5,).

  3. (c) slice + fancy on axis 1. : keeps all 3 rows; [3,0] reorders columns to (last, first). Each row becomes [row[3], row[0]], giving a 3 × 2 result: Why this step? Fancy indexing on a single axis just reorders/selects along that axis while the sliced axis stays full — a common "column reshuffle" idiom.

Answer: (a) [[1,2],[9,10]], shape (2,2); (b) [7,8,9,10,11], shape (5,) (flattened!); (c) [[3,0],[7,4],[11,8]], shape (3,2).

Recall Verify

(a) shape (2,2) with values [[1,2],[9,10]]; (b) flattened to [7,8,9,10,11]; (c) shape (3,2) with values [[3,0],[7,4],[11,8]]. ✓


Ex 11 — Cell K · real-world word problem (sensor data) + dtype pitfall

Steps

  1. Build a "valid" mask valid = t != -999. Why this step? We must exclude sentinel values before doing any statistics — averaging them would poison the result.

  2. Count valid: there are 2 flags among 10 → valid. Why this step? valid.sum() counts Trues (True ).

  3. Mean of valid = t[valid].mean(). The valid values are 21,19,23,22,25,20,27,24; their sum is Why this step? t[valid] is a boolean-mask copy of just the good readings, so .mean() ignores the flags.

  4. Dtype pitfall. t was built from integer literals, so its dtype is integer (int64). Writing a float into an integer array does not upgrade the array — NumPy casts the value down to int, truncating the fraction:

t[t == -999] = round(22.625, 1)   # you MEANT 22.6 ...
# ... but t is int64, so 22.6 is truncated to 22 on write!

Why this step? An array's dtype is fixed at creation; assignment converts the value to fit, it never changes the array's type. So the flagged cells actually become 22, not 22.6 — a silent data-corruption bug.

  1. The fix. To keep the decimal, make the array float first:
t = t.astype(float)          # now dtype float64
t[t == -999] = round(22.625, 1)   # stores 22.6 correctly

Why this step? A float64 array can hold 22.6 exactly enough for our purposes, so the fill is preserved.

Answer: (a) 8 valid readings. (b) mean °C. (c) On the original int array, each -999 becomes 22 (the float is truncated!). Only after t.astype(float) do the flags become 22.6.

Recall Verify

valid flags total; ; on an int array int(22.6) == 22 (truncated fill); after astype(float) the stored fill is 22.6. ✓


Ex 12 — Cell L · exam twist: view-vs-copy chained assignment

Steps

  1. Recall A: row 0 [0,1,2], row 1 [3,4,5]. The condition A > 2 is True for 3, 4, 5 (all of row 1). Why this step? We need to know which cells the mask selects before reasoning about the write.

  2. Attempt 1 — Python evaluates A[A > 2] first. A boolean mask returns a copy (a brand-new temporary array [3, 4, 5]). Then [0] = 100 writes 100 into element 0 of that temporary copy. Why this step? Because masking makes a copy, the write lands in an object that nobody keeps a reference to — the temporary is discarded the instant the line finishes. A is never touched.

  3. So after attempt 1, A is still [[0,1,2],[3,4,5]] (sum ), completely unchanged. Why this step? This is the "chained indexing on a copy" gotcha — the assignment succeeds but modifies a ghost.

  4. Attempt 2 — here the mask is on the left of a single =. NumPy reads A[mask] = value as one atomic "set these cells" operation and writes directly into A's real buffer. Why this step? Left-side masking is assignment, not read-then-write; there is no intermediate copy to escape into. The True cells 3, 4, 5 all become 100.

  5. So after attempt 2 (on a fresh A), A is [[0,1,2],[100,100,100]], sum . Why this step? Only row 1 was > 2, so exactly those three cells change.

Answer: Attempt 1 does nothing to A (writes into a discarded copy, A sum stays 15). Attempt 2 sets A → [[0,1,2],[100,100,100]] (sum 303).

Recall Verify

Attempt 1 leaves A as arange(6).reshape(2,3) (sum ). Attempt 2 on a fresh A gives sum . ✓


Recall One-line recap of every cell

Slice count (Ex1); omitted bounds default to front/end and a[::-1] reverses, negative step flips to (Ex2); impossible ranges clamp to empty while step=0 errors (Ex3); out-of-bounds slices clamp silently (Ex4); 2-D slices are views that leak edits (Ex5); masks combine with & ~ (Ex6) and assign in place (Ex7); fancy = reorder+repeat+negative (Ex8); two fancy arrays zip — use np.ix_ for a block (Ex9); mixing fancy with a slice gives a block and a 2-D mask flattens (Ex10); clean sensor data by masking flags but mind the int→float dtype trap (Ex11); never edit a chained-mask ghost (Ex12).


Connections

  • Parent: Indexing & slicing
  • Views vs Copies — memory model (Ex 5 & Ex 12 hinge on this)
  • np.where and conditional selection (a cleaner alternative to Ex 7 / Ex 11)
  • Vectorization — replacing Python loops (why mask-assignment beats a for)
  • Broadcasting (how np.ix_ in Ex 9 works under the hood)
  • NumPy arrays — shape, strides, dtype (why the dtype in Ex 11 is fixed at creation)
  • Pandas .loc / .iloc indexing