Indexing and Slicing
Indexing and Slicing
Core Principle: Basic slicing and integer indexing construct zero-copy views over existing buffers, whereas boolean masking and integer array indexing generate independent memory copies.
That distinction is the whole note. Everything else is syntax.
Basic indexing
array([[12, 13, 14, 15],
[16, 17, 18, 19],
[20, 21, 22, 23]])
Use the comma form a[0, 1, 2], not the chained form a[0][1][2]. The chained form creates intermediate views at every step and is measurably slower.
... (Ellipsis) is the idiom for "index the last axis regardless of how many there are": a[..., 0] works for 2-D, 3-D or 5-D input.
Slices are half-open and 0-based
seq[0:3] # elements 0, 1, 2: three of them
stop is excluded. This matches BED coordinates and not GFF/VCF/HGVS coordinates: see Coordinate Systems, which is where the off-by-one bugs in your genomics code will come from.
Slices are views
True
No copy was made. b is a window onto a's memory. This is a feature: slicing a 10 GB array is instant: but it means functions that slice and modify silently mutate their caller's data.
This code completed without producing output.
Full discussion in Views vs Copies.
Assignment through a slice
a[2:5] = 0 # broadcast a scalar into the window
a[2:5] = [1, 2, 3] # shape must match
a[a > 100] = 100 # clip via boolean mask
a[:, 0] = new_column
This is in-place mutation, which is the fast path: no new allocation.
The three indexing modes
| Mode | Syntax | Returns |
|---|---|---|
| Basic | integers, slices, ..., None |
view |
| Boolean | a[mask] |
copy: see Boolean Masking |
| Integer array ("fancy") | a0, 3, 5 |
copy: see Fancy Indexing |
Mixing modes gets subtle. a[0, [1, 2]] mixes basic and fancy; a[[0, 1], [1, 2]] pairs the indices elementwise rather than taking a submatrix. When you want a submatrix, use np.ix_.
Adding and dropping axes
v = np.arange(3) # shape (3,)
v[None, :] # (1, 3)
v[:, None] # (3, 1)
v[..., None] # (3, 1)
a[:, 0] # drops the axis → shape (n,)
a[:, 0:1] # keeps it → shape (n, 1)
np.squeeze(a) # drop all length-1 axes
An integer index removes an axis; a length-1 slice keeps it. This trips people up constantly when a downstream function demands 2-D input.
Bioinformatics examples
# expression matrix: genes × samples
expr[0] # first gene, all samples
expr[:, 3] # fourth sample, all genes → shape (n_genes,)
expr[:, 3:4] # same data, shape (n_genes, 1): keeps the axis
# extract a genomic window (converting from 1-based closed GFF coords)
region = coverage[start1 - 1 : end1]
# reverse complement of a one-hot encoded sequence: reverse position, flip base order
rc = onehot[::-1, ::-1]
# every third base: codon position 1
first_positions = seq_array[0::3]
Common mistakes
- Modifying a slice and being surprised the original changed. Or the reverse: expecting a slice to be a view when the operation actually produced a copy.
a[0][1] = xon a copy-producing first step: the write goes to a temporary and vanishes. This is the NumPy analogue of pandas chained assignment. See Copy-on-Write.- Assuming
a[[0,1], [2,3]]is a submatrix. It is[a[0,2], a[1,3]]. Usea[np.ix_([0,1],[2,3])]. - Off-by-one at format boundaries. Python slicing is half-open; VCF/GFF/HGVS are closed. See Coordinate Systems.
- Negative index confusion with slices.
a[-1]is the last element;a[:-1]is everything except the last;a[::-1]is reversed. - Out-of-bounds slicing is silent.
np.arange(5)[2:100]returns 3 elements, no error. Out-of-bounds integer indexing does raise. This asymmetry hides bugs at sequence ends.
See also
ndarray · Views vs Copies · Boolean Masking · Fancy Indexing · Coordinate Systems · loc vs iloc
Exercise
Write slice_report(depths, start, stop, step), which takes the extended slice
depths[start:stop:step] and explains it. Return ["step zero"] when the step
is zero and ["empty"] when the slice selects nothing, which happens whenever
the endpoints are the wrong way round for the step's direction.
Otherwise return five lines: values=<list>, the selected elements;
positions=<list>, the absolute indices those elements occupy, obtained by
resolving the slice against the array's length rather than by assuming start
and stop are already in range; agree=<bool>, comparing the slice against
indexing with that list of positions; is_view=<bool>, whether the slice is a
view onto depths rather than a copy; and first=<int> last=<int>. Negative
indices and negative steps must both work, and out-of-range endpoints clamp
rather than raise.
Test cases · 4
| # | via | input | expected stdout |
|---|---|---|---|
| 1 | stdin | 1 8 2 10 11 12 13 14 15 16 17 18 | values=[11, 13, 15, 17] positions=[1, 3, 5, 7] agree=True is_view=True first=11 last=17 |
| 2 | stdin | -1 -9 -3 10 11 12 13 14 15 16 17 18 | values=[18, 15, 12] positions=[8, 5, 2] agree=True is_view=True first=18 last=12 |
| 3 | stdin | 2 100 1 5 6 7 | values=[7] positions=[2] agree=True is_view=True first=7 last=7 |
| 4 | stdin | 5 1 1 5 6 7 | empty |
Hints · 2
01Hint
slice(start, stop, step).indices(n) returns the resolved (start, stop, step) triple, which range() then expands.
02Hint
A basic slice is a view, so its .base is the array it was cut from; an index list would give you a copy instead.