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
- Why lists aren't enough
- The
ndarray:np.array,dtype,shape,ndim - Vectorization: operating without loops
- Creating arrays:
zeros,ones,arange,linspace - Indexing, slicing and the star idea: boolean masking
- Aggregations:
sum,mean,max,argmax,std - 2D arrays and the
axisparameter - Basic broadcasting
- List vs array: when to use each
Why lists aren't enough
Install and try (inside the venv, as always):
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-intA 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 dimensionsThree 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 priceAnd 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 onCreating 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 areargmax 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 Aprilaxis 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 titleThe 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/orin masks.(a > 1) and (a < 5)raisesValueError: 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] = 99also modifiesa(unlike M4's lists, whose slicing copies). If you need independence:a[1:4].copy(). dtypesilently integer.np.array([12, 9, 15])isint64; a later division works (it produces floats), but assigninga[0] = 12.5would truncate to12. If you're working with prices, start from floats:np.array([12.0, ...]).- Confusing
maxandargmax. One returns the value, the other the index. For "which title sells the most?" you needargmax+ the titles array. - Calling
appendon an array in a loop.np.appendrecreates the whole array every time. Accumulate in alistand donp.array(the_list)at the end — or better, letpandas.read_csvdo it all in 11-03.
Exercises
- With
prices = np.array([12.50, 9.95, 15.90, 21.00])andunits = 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. - 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 (Truecounts as 1) and used twice. - With the lesson's
units_2dmatrix: 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
-
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.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) -
Three sales out of nine contribute more than half the amount: 11-01's skewness again.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...%mask.sum()countsTrues because booleans are 0/1 — a NumPy idiom you'll see constantly. -
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 Quixoteaxis=1because 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
- Introduction to Python
- Setting Up the Development Environment
- Python Syntax and Basic Data Types
- Variables and Constants
- Basic Input and Output
- Virtual Environments and Package Management
Module 2: Control Structures
Module 3: Functions and Modules
- Defining Functions
- Function Arguments
- Lambda Functions
- Modules and Packages
- Standard Library Overview
Module 4: Data Structures
Module 5: Object-Oriented Programming
Module 6: File Handling
Module 7: Error and Exception Handling
- Introduction to Exceptions
- Handling Exceptions
- Raising Exceptions
- Custom Exceptions
- Best Practices and Error Logging
Module 8: Advanced Topics
- Type Hints
- Decorators
- Generators
- Context Managers
- Concurrency: Threads and Processes
- Asyncio for Asynchronous Programming
Module 9: Testing and Debugging
- Introduction to Testing
- Unit Testing with unittest
- Testing with pytest
- Test-Driven Development
- Debugging Techniques
- Using pdb for Debugging
Module 10: Web Development with Python
- Introduction to Web Development
- Flask Framework Fundamentals
- Building REST APIs with Flask
- Introduction to Django
- Building Web Applications with Django
Module 11: Data Science with Python
- Introduction to Data Science
- NumPy for Numerical Computing
- Pandas for Data Manipulation
- Matplotlib for Data Visualization
- Introduction to Machine Learning with scikit-learn
