Every indexing operation in this guide targets a single array: data,
loaded from neural_data.npy. Its five axes encode the full
experimental structure. Shape: (G=3, T=3, S=30, C=32, F=6).
axis 0
G =
3
Groups
Control · Patient · Treatment
axis 1
T =
3
Timepoints
Baseline · Task · Rest
axis 2
S =
30
Subjects
per group
axis 3
C =
32
Channels
EEG electrodes
axis 4
F =
6
Features
δ θ α β γ broadband
Single-element access
Provide one integer per axis to pull out a single scalar. Python counts from zero,
and negative indices count backwards from the end —
-1 is always the last element on that axis.
# First element on every axis → scalar v = data[0, 0, 0, 0, 0] # Patient(1) · timepoint 2 · subject 14 · channel 9 · alpha(2) v2 = data[1, 2, 14, 9, 2] # Negative indices count from the end data[-1, -1, -1, -1, -1] # last Treatment · last time · last subject · Oz · gamma
Mental model: Think of each integer as locking a door on that axis. Five integers → five locked doors → one scalar falls out. The result has zero dimensions — it is a pure Python number.
Start & stop along one axis
Replace an integer with start:stop to select a range of
elements along that axis. Use bare : to select all
elements. Omitting start defaults to 0; omitting
stop defaults to the axis length.
# ':' means ALL elements along that axis ts = data[1, :, :, 9, 2] # shape (3, 30) — Patient, all time, all subjects, Cz, alpha # First half of channels first_half = data[1, 0, 0, :C//2, 2] # shape (16,) # Last half of channels secnd_half = data[1, 0, 0, C//2:, 2] # shape (16,) # Controls · Baseline · all subjects · all channels · alpha epoch = data[0, 0:2, :, :, 2] # shape (2, 30, 32)
Stop is exclusive. data[..., :C//2, ...] returns
indices 0 … C//2 - 1. This is why :C//2 and
C//2: partition the axis perfectly without overlap or gap.
Combining slices across all dims
Any mix of integers and slices can appear across the five axes simultaneously. NumPy resolves each axis independently and returns the tensor product of the surviving index sets. A common EEG pattern: lock group + timepoint, slice subjects + channels, lock feature.
fron = regions["frontal"] # Patient · Baseline · all subjects · frontal channels only · alpha fron_alpha_incorrect = data[1, 0, :, fron, 2] fron_alpha_correct = data[1, 0, :, 2:7, 2] # shape (30, 5) # All groups · patient+treatment windows · first 10 subjects · all ch · theta+alpha combo = data[:, 1:3, :10, :, 1:3] # shape (3, 2, 10, 32, 2) # Compare occipital vs frontal alpha across ALL data occ_alpha = data[:, :, :, 17:20, 2].mean() # scalar fro_alpha = data[:, :, :, 2:7, 2].mean() # scalar
Output shape rule: Count the colons — each one contributes
a dimension to the output. Integers reduce the rank; slices preserve it.
data[1, 0, :, 2:7, 2] has 2 surviving axes → shape (30, 5).
Downsampling & reversal
Add a third number to the slice syntax: start:stop:step.
A step of 2 takes every other element. A step of -1
reverses the axis — perfect for flipping the time axis to
inspect causality or symmetry. Both operations return views.
# Take every alternate subject ds4 = data[:, :, 0::2, :, :] # shape (3, 3, 15, 32, 6) # Every alternate (odd-indexed) subject ds4_odd = data[:, :, 1::2, :, :] # shape (3, 3, 15, 32, 6) # Reverse the time axis for Control · sub0 · channel0 · all features rev = data[0, ::-1, 0, 0, :] # shape (3, 6) — time runs backward
⤵ Downsampling
::2 → indices 0, 2, 4, …
1::2 → indices 1, 3, 5, …
Together they perfectly partition subjects into even and odd cross-validation folds.
↺ Reversal
::-1 → step is -1, start defaults to the last
index. Produces a flipped view — no data is copied
and no extra memory is allocated.
Filter by value condition
A comparison like data > 1.0 produces a boolean array of the same
shape. Passing it back into data[mask] extracts only the elements where
the mask is True, returning a 1-D array of surviving values.
np.where(condition) (single argument) returns the indices
of True positions — useful for downstream operations that need
coordinates, not values.
# What fraction of values could be artifacts (> 1.0)? art_mask = data > 1.0 art_mask.sum() # total artifact cells # Per-subject mean alpha for Control group → shape (30,) ctrl_alpha = data[0, :, :, :, 2] # (3, 30, 32) subj_mean = ctrl_alpha.mean(axis=(0,2)) # collapse time & channels → (30,) threshold = subj_mean.mean() + subj_mean.std() high_alpha = np.where(subj_mean > threshold)[0] # high-alpha subject indices # Find timepoints where Patient broadband spikes above mean + 2σ pat_bb = data[1, :, :, :, 5].mean(axis=(1,2)) # (3,) thr2 = pat_bb.mean() + 2 * pat_bb.std() spike_t = np.where(pat_bb > thr2)[0] # spike timepoint indices print(f" Broadband spikes (Patient) : {len(spike_t)} timepoints, first few={spike_t[:5]}") # Compound condition: high alpha AND high beta simultaneously hi_a = data[1,:,:,:,2] > data[1,:,:,:,2].mean() + data[1,:,:,:,2].std() hi_b = data[1,:,:,:,3] > data[1,:,:,:,3].mean() + data[1,:,:,:,3].std() print(f" High-alpha & high-beta (Patient): {(hi_a & hi_b).sum():,} cells " f"({100*(hi_a & hi_b).mean():.2f}%)")
Boolean indexing always returns a copy. The output is a flat 1-D array,
because there is no guarantee the surviving elements form a regular rectangular block.
Modifying it does not affect data.
np.where, np.take & np.clip
Three closely related utilities extend the indexing toolbox beyond pure subscripting:
np.where(cond, a, b)— element-wise ternary; same shape ascondnp.where(cond)— returns index arrays ofTruepositions (likefind())np.take(arr, indices, axis)— gather non-contiguous elements along one axisnp.clip(arr, lo, hi)— clamp all values into[lo, hi]
# np.where(cond, true_val, false_val) — zero out artifacts clean = np.where(data > 1.0, 0.0, data) # clean.max() ≤ 1.0 # np.clip — related utility for clamping (not indexing but often paired) clamped = np.clip(data, 0, 1.0) # np.where with one arg returns indices (like find()) idxs = np.where(subj_mean > threshold) # tuple of arrays, one per dim print(f" np.where(condition) → indices: {idxs[0]}") # np.take — gather along one axis with an index array tba = np.take(data, indices=[1, 2, 3], axis=4)
np.where (3-arg)
Operates element-by-element. Returns an array of the same shape. Ideal for artifact zeroing without losing the original array layout.
np.take vs fancy index
np.take(data, [1,2,3], axis=4) equals data[..., [1,2,3]]. Both produce
a copy. np.take is preferred when the axis is chosen at runtime.
Changing array shape
reshape reinterprets the memory layout without touching the data —
it returns a view when possible. The total number of elements must stay
constant. ravel() flattens to 1-D, and np.newaxis inserts a
length-1 dimension for broadcasting or framework compatibility.
# Some analysis pipelines want the data in specific order # Bring subjects axis next to groups → (G, S, T, C, F) d_swap = data.transpose(0, 2, 1, 3, 4) # Move features axis first: (G,T,S,C,F) → (F,G,T,S,C) feat_first = data.transpose(4, 0, 1, 2, 3) # Step 2: flatten groups × subjects into rows, rest into columns X = d_swap.reshape(G * S, T * C * F) print(f" Design matrix (G*S, T*C*F) : {X.shape} — ready for PCA / sklearn") # Flatten completely to 1-D flat = data.ravel() # Merge subjects & channels into one axis (common for connectivity analyses) merged = data.reshape(G, T, S * C, F) print(f" Merge S×C : {merged.shape}")
Design Matrix for scikit-learn
90 = 3 groups × 30 subjects • 576 = 3 times × 32 channels × 6 features
Always transpose before reshape when collapsing multiple axes together.
reshape merges axes in C-order (row-major), so the axes you want to merge
must be adjacent first. A transpose rearranges them into the
right order.
Assigning data to a variable & memory issues
When assigning array slices to a variable, understanding the distinction between a VIEW and a COPY is critical to prevent accidental data mutation.
Shared Google Doc (VIEW): Basic slicing (e.g. grades[0:2]) creates a view. Both variables share the same physical memory space. Modifying the view changes the original array too, just like two people editing a shared Google Doc online.
Downloaded Word Doc (COPY): Fancy indexing (e.g. grades[[0, 1]]) or explicit .copy() creates an independent copy in memory. Like downloading a Google Doc to your local machine as a Word document, you can modify it without altering the original.
- ✓Basic slice:
top_two = grades[0:2] - ✓Shares same physical memory
- ⚠Mutating slice mutates original! 🚨
- ✓Fancy index:
first_two = grades[[0, 1]] - ✓Explicit copy:
grades[0:2].copy() - ✓Independent memory — original SAFE! ✅
# A simple 1D array of student grades grades = np.array([85, 90, 75, 100, 95]) print(f"Original Grades: {grades}\n") # BASIC SLICING = "Shared Google Doc" (VIEW) # Slicing creates a VIEW. They share the same memory. top_two = grades[0:2] top_two[0] = 10 # Oh no, the teacher made a typo! print(f"The View changed to: {top_two}") print(f"The Original array is: {grades} <-- MUTATED! 🚨\n") # Reset grades array for second scenario grades = np.array([85, 90, 75, 100, 95]) # FANCY INDEXING = "Downloaded Doc" (COPY) # Passing a list of indices creates a COPY. Independent memory. first_two = grades[[0, 1]] first_two[0] = 10 print(f"The Copy changed to: {first_two}") print(f"The Original array is: {grades} <-- SAFE! ✅\n") # Best practice: explicitly demand a copy first_two_grades = grades[0:2].copy() # Explicit copy
The silent-mutation bug: Basic slicing works like a Shared Google Doc. If you pass a slice to a function and that function modifies it in-place, your original array is permanently altered — with no warning or error. Always call .copy() to create an independent copy when modifying data.
The Golden Rule
grades[0:2] → VIEW (Shared Doc)Fancy indexing
grades[[0,1]] or .copy() → COPY (Downloaded Doc)
When in doubt: arr.base is not None indicates arr is a view.