This lesson covers what to do with a NumPy array. Indexing into specific rows and columns, slicing chunks out of larger arrays, reshaping a flat sequence into a grid, stacking arrays together, and one important detail: a slice of a NumPy array is a view into the original data, not a copy. Mutating the view mutates the source. Everything else in the lesson flows from that fact.
A 1D NumPy array indexes the same way a Python list does. Positive integers count from the start, negative integers count from the end.
Slicing also works the same way. The result is a new array containing the selected range.
prices[1:4] is everything from index 1 up to (but not including) index 4. prices[:3] is the first three elements. prices[::2] is every second element starting from the beginning. So far this is identical to list slicing. The difference appears when modifying the slice.
A slice of a NumPy array does not copy the data. It returns a view into the same underlying memory. Writing to the view writes through to the original.
The assignment discount_zone[0] = 0.0 looks like it should only change discount_zone. It changes both, because discount_zone points at the same memory cells inside prices. The view has its own shape and starting offset, but the bytes it sees are the bytes in prices. This is intentional, and it's what makes NumPy fast: a slice is constant-time and uses no extra memory, no matter how big the original array is. A list slice, by contrast, copies every element.
The same thing happens with the result of reshape, indexing with a range, transposing, and most "reshaping" operations. The general rule: anything that produces a different view of the same data returns a view. Anything that has to compute new values (arithmetic, boolean indexing, fancy indexing) returns a copy.
Two arrays can be checked for shared memory:
For an independent copy, call .copy() explicitly. It allocates new memory and duplicates the bytes. From that point on, modifying the copy doesn't touch the original.
The original is untouched. prices[1:4] returned a view, but .copy() then produced an independent buffer.
arr[1:4] is O(1) regardless of array size because it builds a view, no data is copied. arr[1:4].copy() is O(k) where k is the slice length because every element is duplicated. For tight loops that read but don't mutate, prefer the view. For code that mutates and must not touch the source, the copy is required.
For 2D arrays (rows and columns), NumPy uses a single bracket with a comma: arr[row, col]. This is different from a Python list of lists, which uses two brackets (arr[row][col]). The NumPy version is faster (one indexing operation instead of two) and supports slicing both dimensions at once, which the list-of-lists form does not.
spend[2] and spend[2, :] give the same thing: the third row. The : is a slice meaning "everything along this axis". spend[:, 0] is the first column, returned as a 1D array of length 4.
Slicing both axes at once gives a rectangular block:
A 2-by-2 block lifted out of the corner of the original. Like all basic slices, it's a view: writing to sub[0, 0] would change spend[1, 1].
The shape of the result depends on what the index asks for. A pair of integers gives a scalar (zero dimensions). One integer and one slice gives a 1D array. Two slices give a 2D array. The general rule: each integer in the index removes one dimension; each slice keeps one. Apply this rule and the shape of the result is always predictable.
NumPy supports indexing with an array of booleans the same shape as the array. Wherever the boolean is True, the value is selected; wherever it's False, it's skipped. This is the main mechanism for filtering.
The comparison prices > 20 is itself a NumPy operation: it returns a boolean array of the same shape, with True everywhere the condition holds. Passing that boolean array as an index pulls out only the matching elements. The result is always 1D, regardless of the original array's shape, because the count of matches isn't a known rectangular size.
Boolean indexing returns a copy, not a view. There's no single contiguous slice that corresponds to "values matching this predicate", so the data has to be gathered into a new buffer.
Combine conditions with & (and), | (or), and ~ (not). Wrap each condition in parentheses because the bitwise operators bind tighter than the comparison operators.
The familiar Python and / or won't work here because they ask for a single truthy value, and a boolean array has many. The result is a ValueError: The truth value of an array with more than one element is ambiguous. Use the bitwise & and |, which broadcast element-by-element.
Boolean indexing also works for assignment. Filter, then assign, and only the matching cells change.
The 49.99 became 30.0. Everything else was left alone because the mask was False there. This is a one-line pattern that would be a five-line loop in plain Python, and it's at the heart of how data-science code is structured: filter with a mask, then transform.
A third indexing flavor uses an array of integer positions. NumPy calls this "fancy indexing". Given a list (or array) of indices, it pulls out exactly those elements in exactly that order.
Like boolean indexing, fancy indexing returns a copy. The output's shape matches the index array's shape, which makes it more flexible than slicing for "pick these specific items" workflows.
In 2D, two axes can be indexed with two integer arrays. The arrays are paired up element by element, and the result is a 1D array of the selected cells.
The three values came from spend[0, 2], spend[1, 1], and spend[3, 0], paired up. This fits use cases like "for each customer, pull the value in their preferred category" where there is a list of customers and a parallel list of preferred categories.
reshape, flatten, ravelA NumPy array's shape is metadata, not a constraint on the underlying memory. As long as the total number of elements stays the same, reshape produces any shape.
The 12 values were laid out into a 4-by-3 grid, row by row. The "row by row" order is called C-order (after the C programming language) and is the NumPy default. The other order, F-order (after Fortran), fills column by column. C-order is the usual choice; F-order shows up when interfacing with Fortran libraries or some linear-algebra code.
reshape returns a view when it can. Writing to the reshaped array writes through to the original.
Passing -1 for one of the dimensions lets NumPy compute it. This works when one side is known and the other depends on the input size.
-1 means "whatever is needed to make the total work". Both shapes here are (6, 4) because 24 elements only fit one way given the other dimension.
For an impossible shape (one that doesn't divide the size evenly), NumPy raises an error:
The other two reshaping operations are flatten and ravel, both of which collapse a multi-dimensional array down to 1D. The difference is the same view-vs-copy distinction as before.
flatten always returns a copy: writing through it doesn't affect the original. ravel returns a view when it can (which is most of the time for contiguous arrays), so writing through it does affect the original. The rule of thumb: use ravel for fast reads, use flatten when the result will be mutated.
To combine multiple arrays into one, NumPy offers three main functions: hstack, vstack, and the more general concatenate. The first two are convenience wrappers for the common cases of stacking horizontally and vertically.
vstack builds rows. hstack builds columns when applied to 2D inputs, or concatenates 1D inputs end to end. For higher-dimensional arrays or when specifying which axis to join along, use np.concatenate(arrays, axis=...) directly.
All concatenation operations allocate a new array; there's no way to join two arrays without copying. That's a real cost for very large arrays.
np.concatenate is O(n) in the total number of elements because it allocates a new buffer and copies everything in. For small numbers of fixed-size arrays it's fine. For "append in a loop" patterns, build a Python list first and call np.array (or np.concatenate) once at the end. Repeated concatenation is quadratic.
The mirror operation is splitting. np.split cuts an array into equal-sized chunks. np.hsplit and np.vsplit are convenience versions for horizontal and vertical splits on 2D arrays.
The second argument can be an integer (number of equal pieces, must divide evenly) or a list of split points. np.split(arr, [3, 7]) cuts at indices 3 and 7, giving three pieces of lengths 3, 4, and the remainder.
astypeSometimes an array needs a different dtype than the one it came with. A price array loaded as floats might need to be cast to integers (for a discrete count), or a boolean mask might need to become a 0/1 integer array for downstream arithmetic. The method is astype(new_dtype), which always returns a new array.
The float-to-int conversion truncates toward zero. 29.99 becomes 29, not 30. For rounding, use np.rint(prices).astype(int) or np.round(prices).astype(int).
Going the other way is safer because every integer fits exactly in a float (within the usable range):
Booleans cast to integers as 0/1, which is sometimes exactly the goal:
flags.sum() works directly on the boolean array because bool arithmetic in NumPy treats True as 1 and False as 0. Casting isn't required. But casting is useful for contexts that need explicit numeric types, like writing to a CSV or feeding into a function that expects a particular dtype.
Arrays can have more than two dimensions. A common third dimension shows up with batch data, time series of grids, or color images (height by width by 3 RGB channels). The principles are the same with one more axis to track.
The same indexing rules apply. Each integer collapses one axis; each slice keeps one. With three axes there are more shapes the result can take, but the rule is identical: the count of integers in the index equals the number of dimensions removed.
The axis keyword on aggregation functions (sum, mean, etc.) refers to these same numbered axes. data.sum(axis=0) collapses across stores, giving a (7, 3) array of totals. data.sum(axis=(0, 1)) collapses across both stores and days, giving a length-3 array (one total per category). The foundation is "axis 0 is the first dimension, axis 1 is the second, and so on".
A NumPy array can be iterated with a for loop, but it's almost always the wrong tool. The loop runs in Python, one element at a time, and loses every speed advantage NumPy provides. The right answer is usually a vectorized operation. Iteration is sometimes the cleanest way to express a computation, especially during prototyping.
For a 2D array, the default iteration is over rows.
To iterate over every element regardless of shape, use arr.flat, which is a 1D iterator over the underlying memory.
Reserve iteration for the rare cases where vectorization isn't possible or readable. Anything that fits the pattern "do something arithmetic to every element" should be a vectorized expression, not a loop.
NumPy stores 2D arrays in memory as a single 1D block. The order is C-order by default, meaning row 0 sits first in memory, then row 1, and so on. Within each row, the columns are contiguous. This matters because iterating along axis 0 (rows) jumps from row to row in memory, while iterating along axis 1 (columns) walks contiguously through the buffer.
Same array, two different traversal orders. C-order walks row-first; F-order walks column-first. For most NumPy work the choice is invisible because operations are fast enough either way. It matters when chaining many operations on huge arrays or interfacing with libraries that expect one order or the other (BLAS routines, certain linear algebra calls, code calling into Fortran). Stick with the default C-order unless there is a specific reason not to.
A few details to watch out for the first time around.
Modifying a view by accident. Already covered, but worth restating: arr[1:4] is a view. arr[mask] is a copy. arr.reshape(...) is a view. arr.flatten() is a copy. arr.T (transpose) is a view. When in doubt, check with np.shares_memory.
Broadcasting confusion. When two arrays don't have the same shape, NumPy may "broadcast" the smaller one across the larger. The rules are precise, but easy to forget. Shape mismatches can either succeed (if broadcasting applies) or raise ValueError, depending on the alignment.
Integer overflow. NumPy's integer dtypes are fixed-width. An int8 can hold values from -128 to 127. Going over wraps around without a warning.
100 + 100 = 200, but 200 doesn't fit in int8, so it wraps to -56. Python int has no such limit; NumPy does, in exchange for the speed and memory savings. For arithmetic on small-dtype arrays, either size up to a wider dtype first (count.astype(np.int32)) or stay aware of the range.
Forgetting that boolean indexing returns a copy. Filter, mutate the result, and the original is unchanged.
The original prices is untouched because the boolean index made a copy. To modify the original through the mask, assign back through the mask directly: prices[prices > 20] = 0.0.
10 quizzes