Chapter 6: NumPy basics

Open In Colab Download Notebook

A Python loop can normalise 2,400 exam scores. It can. It takes thirty lines, runs ten times slower than it should, and looks nothing like the code the person reviewing your pull request expects to see.

NumPy does it in one: (student_data - student_data.mean(axis=0)) / student_data.std(axis=0). No loop. No temporary variables. The operation, stated directly.

That shape, a short expression operating on an entire matrix, is the syntax that Pandas, scikit-learn, and PyTorch all speak. The standard library tools from Chapter 5 handle exact arithmetic on small collections; this chapter introduces the data structure that handles numbers at scale. Chapter 7 (07-numpy-advanced.ipynb) extends it with broadcasting, vectorisation, and linear algebra.

Callout markers: book cover page.

Meet NumPy

NumPy (numpy.org) was created in 2005 by Travis Oliphant to give Python scientists the numeric performance of Fortran and C without leaving the language. The idea was simple: store numbers in a contiguous block of memory with a fixed type, and let a thin Python wrapper call heavily optimised C and Fortran routines on that block. Two decades later, nearly every numerical computing library in Python (pandas, scikit-learn, PyTorch, TensorFlow) uses NumPy arrays as its currency.

How it compares

Approach Speed on large arrays Readable math When to use
Python list + loop Slow (Python objects, GIL) Verbose Small collections, mixed types
NumPy ndarray Fast (C/Fortran, contiguous) Concise (a * 2) Numeric data of any size
PyTorch Tensor Fast (optionally GPU) Similar to NumPy Deep learning, autodiff
JAX Array Very fast (XLA, JIT, GPU/TPU) NumPy-compatible Research, differentiable programs
CuPy ndarray GPU only NumPy-compatible Large-scale GPU computing

For everything up to classical ML on a laptop, NumPy is the right level of abstraction. PyTorch and JAX add complexity (device management, gradient tracking) that you don’t need yet.

Already in your environment

NumPy is included in pyproject.toml. If you ever start a standalone project:

uv add numpy          # or: pip install numpy

Official docs and API reference: numpy.org/doc

# Skill Covered in
1 Explain why NumPy arrays outperform Python lists for numeric data Sec. 1
2 Create arrays with array, arange, zeros, ones, and a Generator Sec. 2
3 Inspect and reshape arrays using shape, dtype, reshape, and stacking Sec. 3
4 Select data with integer indexing, slicing, and fancy indexing Sec. 4
5 Filter arrays with boolean masks and np.where Sec. 5
6 Compute per-column and per-row statistics with axis Sec. 6

1. Why NumPy? The ndarray

A Python list can hold anything (mixed types, nested objects), which makes it flexible but slow for numeric work: every element is a separate Python object, and arithmetic on a list means a Python-level loop.

A NumPy ndarray (“n-dimensional array”) is different: it stores one fixed dtype in a single contiguous block of memory. That uniformity lets NumPy hand the math off to compiled C/Fortran loops instead of the Python interpreter, often 10-100x faster, and with far less memory per element.

import numpy as np

study_hours = [12, 5, 18, 9, 22]

# A Python list has no element-wise arithmetic: this is string repetition, not math!
print("list  * 2 :", study_hours * 2)

hours = np.array(study_hours)
print("array * 2 :", hours * 2)  # element-wise multiplication
list  * 2 : [12, 5, 18, 9, 22, 12, 5, 18, 9, 22]
array * 2 : [24 10 36 18 44]

Two panels. Left red panel: Python list with pointer boxes and scattered PyObject heap cells. Right blue panel: NumPy ndarray with a contiguous block of float64 values labeled one cache line.

Memory layout comparison: Python list stores pointers to scattered heap objects, while NumPy ndarray stores uniform float64 values in a single contiguous block.

Key Concept: ndarray = dtype + shape + contiguous memory

A NumPy array is homogeneous (one dtype for every element) and has a fixed shape. Because elements sit next to each other in memory, NumPy can vectorise operations: apply one compiled loop to the whole array instead of looping in Python.

Lists are general-purpose containers; arrays are numeric data structures. Use a list for a heterogeneous bag of objects, an array for a column of numbers.

Activity 1: ndarray vs list arithmetic

Feel the difference between Python lists and NumPy arrays.

Steps:
1. Create scores_list = [78, 85, 92, 91, 55] and scores_arr = np.array(scores_list).
2. Compute the z-score: (scores_arr - scores_arr.mean()) / scores_arr.std().
3. Confirm the result has mean ~0 and std ~1 using .mean() and .std().
4. Try scores_list - scores_list[0] and note the error.

Expected: z-scores around [-0.3, 0.3, 1.1, 0.9, -2.0].

2. Creating Arrays

The most direct way to create an array is np.array() from a Python list (or list of lists, for 2D data). For larger or synthetic data, NumPy provides dedicated creation functions so you never have to type out values by hand.

Key Concept: use default_rng, not the legacy random API

rng = np.random.default_rng(42) creates an independent, reproducible stream. The legacy np.random.seed(42) sets global state shared by every library in your process: a hidden coupling that makes results hard to reproduce. Prefer default_rng for all new code.

# 1D array: one measurement per student
study_hours = np.array([12, 5, 18, 9, 22])

# 2D array: rows = students, columns = measurements
# columns: [study_hours, attendance_pct, prior_gpa]
student_data = np.array(
    [
        [12, 85, 3.1],
        [5, 60, 2.4],
        [18, 95, 3.8],
        [9, 70, 2.9],
        [22, 98, 3.9],
    ]
)
print(student_data)
print(f"shape: {student_data.shape}")  # (5 students, 3 columns)
[[12.  85.   3.1]
 [ 5.  60.   2.4]
 [18.  95.   3.8]
 [ 9.  70.   2.9]
 [22.  98.   3.9]]
shape: (5, 3)

For larger arrays, typing out every value is impractical. These functions build arrays from a rule instead of a literal list:

Function Produces
np.arange(start, stop, step) Evenly spaced integers/floats, like range()
np.linspace(start, stop, n) n evenly spaced floats, inclusive of both ends
np.zeros(shape) / np.ones(shape) Array filled with 0.0 / 1.0
np.full(shape, value) Array filled with a constant
np.eye(n) n x n identity matrix
print("arange(0, 10)       :", np.arange(0, 10))
print("arange(0, 10, 2)    :", np.arange(0, 10, 2))
print("linspace(0, 1, 5)   :", np.linspace(0, 1, 5))
print("zeros((2, 3))       :\n", np.zeros((2, 3)))
print("ones(3)             :", np.ones(3))
print("full((2, 2), 7)     :\n", np.full((2, 2), 7))
arange(0, 10)       : [0 1 2 3 4 5 6 7 8 9]
arange(0, 10, 2)    : [0 2 4 6 8]
linspace(0, 1, 5)   : [0.   0.25 0.5  0.75 1.  ]
zeros((2, 3))       :
 [[0. 0. 0.]
 [0. 0. 0.]]
ones(3)             : [1. 1. 1.]
full((2, 2), 7)     :
 [[7 7]
 [7 7]]

Random Data with a Generator

Synthetic data and simulations need reproducible randomness. Modern NumPy (1.17+) recommends np.random.default_rng(seed) over the legacy np.random.seed(...). A Generator object is self-contained, so two generators never interfere with each other’s state (unlike the old global np.random.seed, which silently affects every call anywhere in the program).

rng = np.random.default_rng(seed=42)  # one independent, reproducible stream

print("uniform [0, 1)      :", rng.random(3))
print("normal(mean=0,std=1):", rng.normal(0, 1, size=3))
print("integers [60, 100)  :", rng.integers(60, 100, size=5))
uniform [0, 1)      : [0.77395605 0.43887844 0.85859792]
normal(mean=0,std=1): [ 0.94056472 -1.95103519 -1.30217951]
integers [60, 100)  : [89 90 88 91 80]

Pro Tip: prefer default_rng over np.random.seed

np.random.seed(42) mutates a single global random state shared by your whole program. Any other code (or library) calling np.random.* shifts that shared state and breaks your reproducibility. rng = np.random.default_rng(42) gives you an isolated generator: pass it around explicitly, and your results stay reproducible no matter what else runs.

Activity 2: build a synthetic student dataset

Using rng = np.random.default_rng(7), generate a student_data array of shape (50, 3) for 50 students with columns:

  • study_hours: rng.uniform(0, 25, size=50)
  • attendance_pct: rng.uniform(50, 100, size=50)
  • prior_gpa: rng.uniform(2.0, 4.0, size=50)
Combine the three 1D arrays into one (50, 3) array with np.column_stack.
student_data.shape  # -> (50, 3)
student_data[0]     # -> array([study_hours_0, attendance_pct_0, prior_gpa_0])

Hint: np.column_stack([a, b, c]) stacks 1D arrays as columns of a 2D array.

rng = np.random.default_rng(7)

study_hours = rng.uniform(0, 25, size=50)
attendance_pct = rng.uniform(50, 100, size=50)
prior_gpa = rng.uniform(2.0, 4.0, size=50)

student_data = ...  # TODO: combine the three arrays into one (50, 3) array

# print(f"student_data.shape : {student_data.shape}")
# print(f"student_data[0]    : {student_data[0]}")

3. Shape, Size, and dtype

Every array carries metadata you should check before trusting a computation: shape (size along each dimension), ndim (number of dimensions), size (total element count), and dtype (the single data type of every element).

Key Concept: check .shape and .dtype before any computation

Shape mismatches and dtype surprises are the most common silent NumPy bugs. A (5, 3) array minus a (3, 5) array either errors or broadcasts in a way you did not intend. Print arr.shape, arr.dtype whenever a result looks wrong.

student_data = np.array(
    [
        [12.0, 85.0, 3.1],
        [5.0, 60.0, 2.4],
        [18.0, 95.0, 3.8],
        [9.0, 70.0, 2.9],
    ]
)

print(f"shape : {student_data.shape}")  # (rows, columns)
print(f"ndim  : {student_data.ndim}")
print(f"size  : {student_data.size}")  # rows * columns
print(f"dtype : {student_data.dtype}")
shape : (4, 3)
ndim  : 2
size  : 12
dtype : float64

Common Mistake: mixed int/float input silently upcasts

np.array([1, 2, 3.5]) produces a float64 array, not a mix of int and float. NumPy must pick one dtype for the whole array and silently widens every element to fit. This is usually harmless, but np.array([1, 2, 3], dtype=np.int32) / 2 truncating, or an unexpected int8 overflowing past 127, are the same root cause: always check .dtype when results look wrong.

Reshaping

reshape() returns the same data viewed with a different shape. It doesn’t copy or reorder values, so the total element count (size) must stay the same. -1 tells NumPy “infer this dimension from the others”:

x = np.arange(12)
print(f"x          : {x}  shape={x.shape}")

x_grid = x.reshape(3, 4)
print(f"reshape(3,4):\n{x_grid}")

# -1 means "figure this dimension out for me"
x_col = x.reshape(-1, 1)  # turn a 1D array into a single column
print(f"reshape(-1,1) shape: {x_col.shape}")

x_flat = x_grid.flatten()  # back to 1D - always returns a COPY
print(f"flatten()   : {x_flat}")
x          : [ 0  1  2  3  4  5  6  7  8  9 10 11]  shape=(12,)
reshape(3,4):
[[ 0  1  2  3]
 [ 4  5  6  7]
 [ 8  9 10 11]]
reshape(-1,1) shape: (12, 1)
flatten()   : [ 0  1  2  3  4  5  6  7  8  9 10 11]

Common Mistake: reshape() returns a view, flatten() returns a copy

Mutating the result of .reshape() mutates the original array too. They share the same underlying memory. .flatten() always copies, so mutating it is safe. This distinction (view vs. copy) comes up constantly in NumPy; Sec. 11 covers it in more depth.

original = np.arange(6)
view = original.reshape(2, 3)
view[0, 0] = 99  # mutating the reshaped VIEW...

print(f"view     :\n{view}")
print(f"original : {original}")  # ...also changed the original!
view     :
[[99  1  2]
 [ 3  4  5]]
original : [99  1  2  3  4  5]

Combining arrays: column_stack, hstack, vstack

Assembling separate 1D arrays into one 2D matrix, or stacking two matrices together, is a common pattern. Use the right function for the shape change you want:

Function Effect
np.column_stack([a, b, ...]) 1D arrays -> columns of a 2D array
np.hstack([a, b]) Join side-by-side (same number of rows)
np.vstack([a, b]) Stack on top of each other (same number of columns)
np.concatenate([a, b], axis=...) General join along a chosen axis
gpa = np.array([3.1, 2.4, 3.8, 2.9])
attendance = np.array([85, 60, 95, 70])

# Two 1D arrays -> one (4, 2) matrix
combined = np.column_stack([gpa, attendance])
print(f"column_stack:\n{combined}")

# Two (4, 2) batches of students -> one (8, 2) matrix
more_students = np.array([[3.5, 90], [2.0, 55]])
all_students = np.vstack([combined, more_students])
print(f"vstack shape: {all_students.shape}")
column_stack:
[[ 3.1 85. ]
 [ 2.4 60. ]
 [ 3.8 95. ]
 [ 2.9 70. ]]
vstack shape: (6, 2)

Activity 3: build a student data matrix

Stack 1D arrays into a 2D student data matrix and inspect its metadata.

study_hours = np.array([12, 5, 18, 9, 22])
attendance = np.array([85, 60, 95, 70, 98])
gpa = np.array([3.1, 2.4, 3.8, 2.9, 3.9])

Steps:
1. Stack them into a (5, 3) matrix with np.column_stack.
2. Print .shape and .dtype.
3. Reshape to (3, 5) and confirm shapes swapped.

Expected: original shape (5, 3), reshaped (3, 5).

4. Indexing and Slicing

NumPy indexing extends Python’s list slicing to multiple dimensions. For a 2D array, the convention is array[rows, columns], and negative indices still count from the end.

Key Concept: basic slices return views, not copies

Writing sub = student_data[:2] does not copy data: sub shares memory with student_data. Modifying sub[0, 0] = 99 also changes student_data[0, 0]. Call .copy() when you need an independent array.

scores = np.array([62, 78, 85, 91, 55, 73, 88, 95, 67, 80])

print(f"first three   : {scores[:3]}")
print(f"last three    : {scores[-3:]}")
print(f"between 3 & 7 : {scores[3:7]}")
print(f"every other   : {scores[::2]}")
print(f"reversed      : {scores[::-1]}")
first three   : [62 78 85]
last three    : [95 67 80]
between 3 & 7 : [91 55 73 88]
every other   : [62 85 55 88 67]
reversed      : [80 67 95 88 73 55 91 85 78 62]
student_data = np.array(
    [
        [12, 85, 3.1],
        [5, 60, 2.4],
        [18, 95, 3.8],
        [9, 70, 2.9],
        [22, 98, 3.9],
    ]
)

print(f"row 0              : {student_data[0]}")
print(f"column 1 (all rows): {student_data[:, 1]}")  # every row, attendance column
print(f"rows 1-3, col 0 & 2: \n{student_data[1:4, [0, 2]]}")  # fancy column indexing
print(f"single cell [2, 1] : {student_data[2, 1]}")
row 0              : [12.  85.   3.1]
column 1 (all rows): [85. 60. 95. 70. 98.]
rows 1-3, col 0 & 2: 
[[ 5.   2.4]
 [18.   3.8]
 [ 9.   2.9]]
single cell [2, 1] : 95.0

Common Mistake: basic slices are views; fancy/boolean indexing copies

student_data[:, 1] (a slice) returns a view: mutating it mutates student_data. student_data[1:4, [0, 2]] (a list of indices, known as “fancy indexing”) always returns a copy. If you need an independent array from a slice, call .copy() explicitly: col = student_data[:, 1].copy().

Activity 4: select top performers

Given the student_data array above (columns: study_hours, attendance_pct, prior_gpa), use slicing to print:

  1. The prior_gpa column (all rows)
  2. The first two rows, all columns
  3. The study_hours and prior_gpa columns (skip attendance) for every row

Hint: For (3), use fancy column indexing: student_data[:, [0, 2]].

student_data = np.array(
    [
        [12, 85, 3.1],
        [5, 60, 2.4],
        [18, 95, 3.8],
        [9, 70, 2.9],
        [22, 98, 3.9],
    ]
)

gpa_column = ...  # TODO
first_two_rows = ...  # TODO
hours_and_gpa = ...  # TODO

print(f"gpa_column     : {gpa_column}")
print(f"first_two_rows :\n{first_two_rows}")
print(f"hours_and_gpa  :\n{hours_and_gpa}")
gpa_column     : Ellipsis
first_two_rows :
Ellipsis
hours_and_gpa  :
Ellipsis

5. Boolean masking and vectorised conditionals

Comparing an array to a value produces a boolean array of the same shape: a “mask.” Using that mask to index the original array keeps only the True positions. This replaces if/for filtering loops entirely.

Key Concept: use & and | for array logic, not and / or

scores >= 70 and attendance >= 80 raises ValueError on arrays. Write (scores >= 70) & (attendance >= 80). The parentheses matter because & binds tighter than >=.

scores = np.array([62, 78, 85, 91, 55, 73, 88, 95, 67, 80])

passing_mask = scores >= 70
print(f"mask     : {passing_mask}")
print(f"passing  : {scores[passing_mask]}")  # boolean indexing: keeps True positions
print(f"n passing: {passing_mask.sum()}")  # True counts as 1, False as 0
mask     : [False  True  True  True False  True  True  True False  True]
passing  : [78 85 91 73 88 95 80]
n passing: 7

Combine conditions with & (and) / | (or), not Python’s and/or, which only work on single booleans, not arrays. Each side needs its own parentheses because &/| bind tighter than comparison operators:

attendance = np.array([85, 60, 95, 70, 98, 45, 88, 92, 55, 80])
scores = np.array([62, 78, 85, 91, 55, 73, 88, 95, 67, 80])

# Parentheses are required: & binds tighter than >= without them
at_risk = (scores < 70) & (attendance < 70)
print(f"at_risk mask : {at_risk}")
print(f"n at risk    : {at_risk.sum()}")
at_risk mask : [False False False False False False False False  True False]
n at risk    : 1

np.where(condition, if_true, if_false) builds a new array by choosing between two values element-wise: the vectorised equivalent of a ternary expression inside a loop:

labels = np.where(scores >= 70, "pass", "fail")
print(labels)

# np.select handles more than two outcomes
grade = np.select(
    [scores >= 90, scores >= 80, scores >= 70, scores >= 60],
    ["A", "B", "C", "D"],
    default="F",
)
print(grade)
['fail' 'pass' 'pass' 'pass' 'fail' 'pass' 'pass' 'pass' 'fail' 'pass']
['D' 'C' 'B' 'A' 'F' 'C' 'B' 'A' 'D' 'B']

Activity 5: flag students needing intervention

Given scores and attendance arrays, build a boolean mask needs_help that flags students with score < 70 or attendance < 60, then print how many students were flagged and their scores.
scores     = [62, 78, 85, 91, 55, 73, 88, 95, 67, 80]
attendance = [85, 60, 95, 70, 98, 45, 88, 92, 55, 80]
# needs_help -> True at indices 0, 4, 5, 8  (score<70 OR attendance<60)
scores = np.array([62, 78, 85, 91, 55, 73, 88, 95, 67, 80])
attendance = np.array([85, 60, 95, 70, 98, 45, 88, 92, 55, 80])

needs_help = ...  # TODO: boolean mask, score < 70 OR attendance < 60

print(f"needs_help        : {needs_help}")
# print(f"n flagged         : {needs_help.sum()}")
# print(f"flagged scores    : {scores[needs_help]}")
needs_help        : Ellipsis

6. Aggregations along an axis

mean(), sum(), std(), min(), max() collapse an array to a single number by default. On a 2D matrix, the axis argument controls which dimension gets collapsed: this is the single most common source of “right function, wrong number” bugs in NumPy code, so get the convention straight now:

  • axis=0 collapses rows -> one result per column
  • axis=1 collapses columns -> one result per row

Key Concept: axis=0 collapses rows, axis=1 collapses columns

student_data.mean(axis=0) gives one value per column: per-column statistics. student_data.mean(axis=1) gives one value per row: per-row statistics. Omitting axis collapses everything to a single scalar.

student_data = np.array(
    [
        [12.0, 85.0, 3.1],
        [5.0, 60.0, 2.4],
        [18.0, 95.0, 3.8],
        [9.0, 70.0, 2.9],
        [22.0, 98.0, 3.9],
    ]
)

print(f"overall mean        : {student_data.mean():.2f}")  # one number, all 15 values
print(f"per-column mean     : {student_data.mean(axis=0)}")  # shape (3,): one per column
print(f"per-student mean    : {student_data.mean(axis=1)}")  # shape (5,): one per row
print(f"per-column std      : {student_data.std(axis=0)}")
print(f"per-column min/max  : {student_data.min(axis=0)} / {student_data.max(axis=0)}")
overall mean        : 32.67
per-column mean     : [13.2  81.6   3.22]
per-student mean    : [33.36666667 22.46666667 38.93333333 27.3        41.3       ]
per-column std      : [ 6.11228272 14.56845908  0.56356011]
per-column min/max  : [ 5.  60.   2.4] / [22.  98.   3.9]

Pro Tip: say the axis name out loud

axis=0” is easy to misremember. Read it as: “collapse axis 0 (the row axis): what’s left is one value per column.” If you want one statistic per column (the usual case before normalising a student data matrix), that is always axis=0.

Activity 6: per-column and per-row statistics

Compute aggregations along both axes.

Using the student_data array from Activity 3:
1. Compute the mean and std of each column (axis=0).
2. Compute each student’s mean score across all columns (axis=1).
3. Z-score normalise the entire matrix with one expression:
student_data_z = (student_data - student_data.mean(axis=0)) / student_data.std(axis=0)
4. Confirm student_data_z.mean(axis=0) is all zeros (within floating-point tolerance).

What’s Next

Chapter 6 covered the NumPy fundamentals: creating arrays, shapes, indexing, masking, and aggregations. Chapter 7 (07-numpy-advanced.ipynb) builds on these with the four topics that make NumPy genuinely powerful: broadcasting, vectorisation, linear algebra, and common gotchas.

7. Capstone exercises

Apply everything from this notebook together. Each exercise is self-contained.

Activity 7: build, normalise, and predict

Using the students dataset below:

  1. Build a (6, 3) array student_data with np.column_stack
  2. Z-score normalise student_data (Sec. 6)
  3. Predict exam_score with the given weights/bias using @ (Sec. 9), applied to the normalised data
  4. Compute the RMSE against actual_scores using np.linalg.norm
study_hours = np.array([12, 5, 18, 9, 22, 14])
attendance_pct = np.array([85, 60, 95, 70, 98, 80])
prior_gpa = np.array([3.1, 2.4, 3.8, 2.9, 3.9, 3.3])
actual_scores = np.array([88.0, 65.0, 95.0, 78.0, 99.0, 84.0])

weights = np.array([0.8, 0.5, 6.0])
bias = 55.0

# TODO: 1) build student_data, 2) normalise it, 3) predict, 4) compute RMSE
student_data = ...
student_data_z = ...
predicted = ...
rmse = ...

if rmse is not ...:
    print(f"predicted: {predicted}")
    print(f"RMSE     : {rmse:.2f}")
else:
    print("(complete the TODO above to see output)")
(complete the TODO above to see output)

Further Reading

Resource Why it matters
Harris, C.R. et al. (2020). Array programming with NumPy. Nature 585, 357–362. The primary citation for NumPy; the paper explains the design decisions behind broadcasting and ufuncs
VanderPlas, J. (2016). Python Data Science Handbook, Ch. 2. O’Reilly. Free online: the most readable treatment of fancy indexing, broadcasting, and structured arrays
NumPy documentation: Broadcasting Official broadcasting rules with diagrams; bookmark for the next time the shapes don’t align
NumPy documentation: Indexing Covers basic, advanced, and boolean indexing in one place

Summary

Concept Key rule
ndarray One dtype, contiguous memory, fast because it skips the Python interpreter
Creation np.array, arange, linspace, zeros/ones; np.random.default_rng(seed) for reproducible random data
shape/dtype Always check before trusting a result; mixed-type input silently upcasts
reshape Returns a view, same data, same size, different shape
flatten Always returns a copy
Slicing Basic slices (student_data[:, 0]) are views; fancy/boolean indexing always copies
Boolean masks & / \| (not and/or) on arrays, each side parenthesised
np.where / np.select Vectorised if/else and multi-branch labelling
axis=0 vs axis=1 0 collapses rows -> one value per column; 1 collapses columns -> one value per row
Broadcasting Compare shapes right-to-left; dims match if equal or one is 1; use keepdims=True to broadcast a per-row stat
Vectorisation A NumPy expression beats a Python loop by 10-100x, look for one before writing for
@ / np.dot Matrix multiplication: X @ weights predicts every row in one call
np.allclose Always compare floats with a tolerance, never ==
.npy / .npz Compact, dtype/shape-preserving array storage between pipeline stages

Next: 14-matplotlib.ipynb, covering how to visualise arrays and DataFrames with matplotlib.