The previous lesson ended by pointing at the culprit: Python lists don't know any maths. [12.50, 9.95] * 2 doesn't double the prices — it repeats the list. To add VAT to 487 amounts you need a loop or a comprehension, and for a million amounts that loop shows. NumPy (Numerical Python) solves both problems with a single structure, the ndarray: a compact block of numbers of the same type over which operations are written without loops and executed in C. It's the bottom-most piece of the PyData ecosystem — pandas, Matplotlib and scikit-learn are built on top of it — so understanding NumPy is understanding what the others do on the inside.

Contents

  1. Why lists aren't enough
  2. The ndarray: np.array, dtype, shape, ndim
  3. Vectorization: operating without loops
  4. Creating arrays: zeros, ones, arange, linspace
  5. Indexing, slicing and the star idea: boolean masking
  6. Aggregations: sum, mean, max, argmax, std
  7. 2D arrays and the axis parameter
  8. Basic broadcasting
  9. List vs array: when to use each

Why lists aren't enough

Install and try (inside the venv, as always):

pip install numpy
prices = [12.50, 9.95, 15.90, 21.00]   # Odyssey, Hamlet, Quixote, Faust

print(prices * 2)        # double the prices? No: repeat the list
# [12.5, 9.95, 15.9, 21.0, 12.5, 9.95, 15.9, 21.0]

# print(prices * 1.04)   # TypeError: can't multiply sequence by non-int

A list is a generic container: it can mix floats, strings and Book dataclasses, and that's why every element is a full Python object with its overhead. Two consequences:

  • Expressiveness: element-wise mathematical operations don't exist; you have to write the loop (or the M2 comprehension) every time.
  • Performance: the Python loop interprets every iteration; with lots of data, the gap versus compiled code is one to two orders of magnitude.

The ndarray

import numpy as np    # the 'np' alias is a universal convention

prices = np.array([12.50, 9.95, 15.90, 21.00])

print(prices)          # [12.5   9.95 15.9  21.  ]
print(type(prices))    # <class 'numpy.ndarray'>
print(prices.dtype)    # float64  -> ALL elements are of this type
print(prices.shape)    # (4,)     -> tuple with the size of each dimension
print(prices.ndim)     # 1        -> number of dimensions

Three attributes you'll consult constantly:

Attribute What it says Example
dtype Single type of all elements (float64, int64, bool...) float64
shape Size per dimension, as a tuple (4,) vector, (4, 6) matrix
ndim Number of dimensions 1 vector, 2 table

The price of that homogeneity: if you mix types, NumPy converts everything to the most general one (np.array([1, 2.5])float64; if there's a string, everything ends up as text and goodbye maths). That restriction is exactly what lets it be compact and fast.

Vectorization: operating without loops

The operation applies element by element, without you writing the loop. Compare the three ways of adding the 4% VAT to the prices:

prices_list = [12.50, 9.95, 15.90, 21.00]
BOOK_VAT = 0.04

# 1) Classic loop (M2)
with_vat = []
for p in prices_list:
    with_vat.append(round(p * (1 + BOOK_VAT), 2))

# 2) Comprehension (M2)
with_vat = [round(p * (1 + BOOK_VAT), 2) for p in prices_list]

# 3) NumPy: the operation IS the expression
prices = np.array(prices_list)
with_vat = np.round(prices * (1 + BOOK_VAT), 2)
print(with_vat)    # [13.   10.35 16.54 21.84]

All three give the same result; the third isn't just shorter, it's faster (the loop happens in C) and it reads like maths: "prices times 1.04". Same thing with two arrays:

units = np.array([154, 121, 182, 63])    # sold Jan-Jun (11-01)
revenue = prices * units                  # element by element
print(revenue)         # [1925.   1203.95 2893.8  1323.  ]
print(revenue.sum())   # 7345.75  -> total revenue at list price

And the whole course's member tariff (4% VAT and 5% discount, the M3 formula), now in one line:

BOOK_VAT, MEMBER_DISCOUNT = 0.04, 0.05
member_prices = np.round(prices * (1 + BOOK_VAT) * (1 - MEMBER_DISCOUNT), 2)
print(member_prices)    # [12.35  9.83 15.71 20.75]  <- the M3 values, spot on

Creating arrays

Function What it creates Example
np.zeros(n) n zeros (accumulators) np.zeros(7) → counter per weekday
np.ones(n) n ones np.ones(4)
np.arange(a, b, step) Like range, but an array (accepts floats) np.arange(1, 7) → months [1 2 3 4 5 6]
np.linspace(a, b, n) n evenly spaced points including both ends np.linspace(0, 1, 5)[0. 0.25 0.5 0.75 1.]
months = np.arange(1, 7)
print(months)                      # [1 2 3 4 5 6]
print(np.zeros(3))                 # [0. 0. 0.]
print(np.linspace(9.95, 21.0, 3))  # [ 9.95  15.475 21.   ]

Indexing, slicing and boolean masking

Indices and slices work like they do on lists (M4):

amounts = np.array([12.50, 19.90, 15.71, 21.00, 31.80, 47.70, 25.00])

print(amounts[0])      # 12.5
print(amounts[-1])     # 25.0
print(amounts[1:4])    # [19.9  15.71 21.  ]

And now the star idea of the entire ecosystem, the one that will reappear identical in pandas: comparing an array produces an array of booleans, and that array works as a filter.

big = amounts > 15.00
print(big)              # [False  True  True  True  True  True  True]
print(amounts[big])     # [19.9  15.71 21.   31.8  47.7  25.  ]

# In one line, as it's written in practice:
print(amounts[amounts > 15.00])

# Combining conditions: & (and), | (or), ~ (not) — parentheses are mandatory
print(amounts[(amounts > 15) & (amounts < 30)])   # [19.9  15.71 21.   25.  ]

Read it slowly: amounts > 15.00 is not an if, it's a vectorized operation returning (7,) booleans; using it inside brackets selects the elements where there's a True. It's the filter/comprehension-with-if from M2 turned into algebra. A useful relative: np.where(condition, if_true, if_false) applies a "vectorized if" (np.where(is_member, price * 0.95, price)).

Aggregations

print(amounts.sum())      # 173.61
print(amounts.mean())     # 24.80142857142857
print(amounts.max())      # 47.7
print(amounts.argmax())   # 5  -> the INDEX of the maximum, not the value
print(amounts.std())      # 11.29... standard deviation: how spread out they are

argmax deserves attention: it returns where the maximum is, which lets you answer "which title brings in the most revenue?" by crossing parallel arrays:

titles = np.array(["The Odyssey", "Hamlet", "Don Quixote", "Faust"])
print(titles[revenue.argmax()])    # Don Quixote  (2893.80 EUR)

And combining mask + aggregation, whole questions in one line: amounts[amounts > 15].mean() — "mean amount of the big sales".

2D arrays and the axis parameter

Sales per title and per month form a table: 4 rows (titles) × 6 columns (January-June). Conceptually, the units from sales_2026.csv rearranged:

# rows: Odyssey, Hamlet, Quixote, Faust | columns: Jan..Jun
units_2d = np.array([
    [18, 17, 21, 50, 25, 23],    # The Odyssey
    [15, 13, 17, 39, 19, 18],    # Hamlet
    [22, 20, 25, 59, 29, 27],    # Don Quixote
    [ 7,  8,  8, 20, 11,  9],    # Faust
])
print(units_2d.shape)   # (4, 6)
print(units_2d.ndim)    # 2
print(units_2d[2, 3])   # 59 -> Don Quixote in April (row 2, column 3)
print(units_2d[:, 3])   # [50 39 59 20] -> ALL titles in April

axis says along which dimension to aggregate — the dimension that disappears:

print(units_2d.sum())          # 520 -> semester total
print(units_2d.sum(axis=0))    # [ 62  58  71 168  84  77] -> per MONTH (collapses rows)
print(units_2d.sum(axis=1))    # [154 121 182  63]         -> per TITLE (collapses columns)

There it is, in numbers, the M10 cliffhanger: April (168) more than doubles any other month. Sant Jordi exists and the arrays can see it. The mnemonic rule: axis=0 aggregates "downwards" (result per column), axis=1 "rightwards" (result per row).

Basic broadcasting

Broadcasting is the rule by which NumPy operates on arrays of different shapes by "stretching" the small one. Two cases cover 90% of the usage:

# 1) Scalar against array: the scalar applies to everything (you've used it: prices * 1.04)
print(units_2d * 2)

# 2) Vector against matrix: the (6,) vector applies to EACH row of the (4, 6)
month_weight = np.array([1.0, 1.0, 1.0, 0.5, 1.0, 1.0])   # April "discounted" for comparison
print((units_2d * month_weight).sum(axis=1))
# [129.  101.5 152.5  53. ] -> "deseasonalized" sales per title

The formal rule (shapes are compared right to left and must match or be 1) goes much further, but with scalar-against-array and vector-against-matrix you have what this course needs.

List vs array: when to use each

Criterion list np.ndarray
Mixed types (Book, strings, floats) Yes — its great virtue No: one single dtype
Growing element by element (append) Cheap and natural Expensive (recreates the array): build the list and convert at the end
Element-wise maths Manual loop/comprehension One expression, executed in C
Filtering by condition Comprehension with if Boolean mask
Thousands/millions of numbers Slow and memory-heavy Its natural habitat
The Papyrus catalog (rich objects) M5's list[Book], correct Not applicable

The promised honesty: in Papyrus's day-to-day you will write almost no pure NumPy — you'll use pandas, which puts names on rows and columns and reads CSV by itself. But pandas is NumPy underneath: every DataFrame column is an array, the boolean masks from this lesson are written identically there, and axis, dtype and the aggregations reappear under the same names. What you learned here isn't thrown away: it's inherited.

Common Mistakes and Tips

  • and/or in masks. (a > 1) and (a < 5) raises ValueError: The truth value of an array is ambiguous. With arrays you use &, |, ~ — and always with parentheses, because & binds tighter than >.
  • Forgetting that a slice is a view. b = a[1:4]; b[0] = 99 also modifies a (unlike M4's lists, whose slicing copies). If you need independence: a[1:4].copy().
  • dtype silently integer. np.array([12, 9, 15]) is int64; a later division works (it produces floats), but assigning a[0] = 12.5 would truncate to 12. If you're working with prices, start from floats: np.array([12.0, ...]).
  • Confusing max and argmax. One returns the value, the other the index. For "which title sells the most?" you need argmax + the titles array.
  • Calling append on an array in a loop. np.append recreates the whole array every time. Accumulate in a list and do np.array(the_list) at the end — or better, let pandas.read_csv do it all in 11-03.

Exercises

  1. With prices = np.array([12.50, 9.95, 15.90, 21.00]) and units = np.array([154, 121, 182, 63]): compute, in one expression each, the revenue per title at the member tariff (4% VAT + 5% discount, rounding the price to 2 decimals before multiplying) and the title with the lowest revenue at list price.
  2. With amounts = np.array([12.50, 19.90, 15.71, 21.00, 31.80, 47.70, 25.00, 9.95, 12.35]): (a) how many sales exceed the mean? (b) what percentage of the total amount do they contribute? Hints: a mask can be summed (True counts as 1) and used twice.
  3. With the lesson's units_2d matrix: get each title's monthly mean excluding April (so Sant Jordi doesn't inflate the "normal month"), using slicing or a mask over the columns. Which title has the highest normal month?

Solutions

  1. member_revenue = np.round(prices * 1.04 * 0.95, 2) * units
    print(member_revenue)                  # [1901.9  1189.43 2859.22 1307.25]
    titles = np.array(["The Odyssey", "Hamlet", "Don Quixote", "Faust"])
    print(titles[(prices * units).argmin()])   # Hamlet (1203.95 EUR at list price)
    
    Watch the nuance in the statement: you round the member tariff (12.35, 9.83...) and then multiply — the way Papyrus actually charges — not the other way around.
  2. mean = amounts.mean()                        # 21.77...
    mask = amounts > mean
    print(mask.sum())                            # 3 sales (31.80, 47.70, 25.00)
    print(amounts[mask].sum() / amounts.sum() * 100)   # 53.2...%
    
    Three sales out of nine contribute more than half the amount: 11-01's skewness again. mask.sum() counts Trues because booleans are 0/1 — a NumPy idiom you'll see constantly.
  3. without_april = np.delete(units_2d, 3, axis=1)      # or units_2d[:, [0,1,2,4,5]]
    means = without_april.mean(axis=1)
    print(np.round(means, 1))    # [20.8 16.4 24.6  8.6]
    titles = np.array(["The Odyssey", "Hamlet", "Don Quixote", "Faust"])
    print(titles[means.argmax()])    # Don Quixote
    
    axis=1 because you want to collapse the months (columns) and keep one value per title (row). Don Quixote also leads the "normal" month: its dominance isn't only a Sant Jordi thing.

Conclusion

NumPy has given you the engine: the homogeneous, compact ndarray; vectorization that turns loops into expressions (prices * 1.04); boolean masks that turn filters into algebra (amounts[amounts > 15]); the axis aggregations that made the Sant Jordi peak visible (April: 168 units); and broadcasting, which stretches scalars and vectors without copying them. It also showed you its limit for Papyrus: arrays are anonymous numbers — row 2 "is" Don Quixote only because you remember it, and stuffing the CSV's 487 rows into parallel arrays by hand is stonecutter's work. The next lesson introduces pandas, which wraps these arrays in tables with names: labelled columns, read_csv loading sales_2026.csv in one line (M6's csv.DictReader on steroids, as promised), and groupby to finally answer Ana's questions — with data, once and for all.

Python Programming Course

Module 1: Introduction to Python

Module 2: Control Structures

Module 3: Functions and Modules

Module 4: Data Structures

Module 5: Object-Oriented Programming

Module 6: File Handling

Module 7: Error and Exception Handling

Module 8: Advanced Topics

Module 9: Testing and Debugging

Module 10: Web Development with Python

Module 11: Data Science with Python

Module 12: Final Project

© Copyright 2026. All rights reserved