Chapter 3 — Python Tools for AI¶
Chapter Overview
Before writing a single line of model code, a practitioner needs a reproducible environment, reliable numerical computing, efficient data manipulation, and clear visualisation. This chapter builds that foundation methodically. Every library introduced here is used throughout the rest of the textbook. Learning them well now pays compound interest.
Learning Objectives¶
By the end of this chapter you will be able to:
- Set up an isolated, reproducible Python environment using
uvand apyproject.toml. - Manipulate multi-dimensional arrays in NumPy with confidence, including broadcasting and
einsum. - Load, clean, merge, and aggregate tabular data with Pandas, handling missing values correctly.
- Produce publication-quality figures with Matplotlib and Seaborn.
- Write Jupyter notebooks that are reproducible, well-structured, and shareable via
nbconvert.
3.1 Environment Setup¶
3.1.1 Python Version Requirements¶
Applied AI work requires Python 3.10 or later. The structural pattern matching syntax (match/case), improved typing module ergonomics, and tomllib in the standard library all first appeared in 3.10+. As of 2025, 3.11 and 3.12 are recommended — they deliver 10–60 % speed improvements over 3.10 in CPU-bound code.
Check your version
3.1.2 uv vs pip vs conda¶
| Feature | pip + venv | conda | uv |
|---|---|---|---|
| Speed | Moderate | Slow (SAT solver) | Very fast (written in Rust) |
| Lock files | pip freeze (imprecise) | conda env export | uv.lock (exact, cross-platform) |
| Non-Python deps | No | Yes (BLAS, CUDA) | No |
| Workspace support | No | No | Yes |
| PEP 517/518 | Yes | Partial | Yes |
| Recommended for | Simple projects | CUDA/non-Python deps | Most AI projects |
For most pure-Python ML workflows, uv is the right choice. Use conda only when you need non-Python system libraries (e.g., a specific CUDA toolkit version) that are not provided by pip wheels.
3.1.3 Setting Up a Project with uv¶
# Install uv (once per machine)
curl -Lso- https://astral.sh/uv/install.sh | sh
# Create a new project
uv init applied-ai-project
cd applied-ai-project
# Add dependencies
uv add numpy pandas matplotlib seaborn scikit-learn jupyter
# Add development-only dependencies
uv add --dev pytest ruff mypy
# Run a script inside the managed environment
uv run python scripts/train.py
# Sync the environment from lock file (reproducible install)
uv sync
The result is a pyproject.toml that serves as the single source of truth:
[project]
name = "applied-ai-project"
version = "0.1.0"
requires-python = ">=3.10"
dependencies = [
"numpy>=1.26",
"pandas>=2.1",
"matplotlib>=3.8",
"seaborn>=0.13",
"scikit-learn>=1.4",
"jupyter>=1.0",
]
[tool.ruff]
line-length = 88
select = ["E", "F", "I", "UP"]
[tool.mypy]
python_version = "3.11"
strict = true
3.1.4 requirements.txt vs pyproject.toml¶
requirements.txt | pyproject.toml | |
|---|---|---|
| Standard | No (informal) | Yes (PEP 518 / 621) |
| Dev / test split | Manual (two files) | [project.optional-dependencies] |
| Build metadata | No | Yes (author, license, entry points) |
| Tool config | No | Yes ([tool.ruff], [tool.mypy]) |
| Recommendation | Legacy projects | All new projects |
uv.lock vs pinned requirements.txt
uv.lock pins every transitive dependency to an exact version and hash. This gives stronger reproducibility guarantees than pip freeze > requirements.txt, which only pins your direct dependencies' versions but not hashes.
3.2 NumPy¶
NumPy is the bedrock of scientific Python. PyTorch, TensorFlow, JAX, and scikit-learn all expose interfaces that mirror NumPy's API or interoperate with it directly.
3.2.1 ndarray Fundamentals¶
The core data structure is numpy.ndarray. Key attributes:
| Attribute | Meaning | Example |
|---|---|---|
shape | Tuple of dimension sizes | (32, 3, 224, 224) |
dtype | Element data type | float32, int64, bool |
ndim | Number of axes | 4 |
size | Total number of elements | 32 * 3 * 224 * 224 |
strides | Bytes to step along each axis | Advanced memory layout |
itemsize | Bytes per element | 4 for float32 |
import numpy as np
from numpy.typing import NDArray
# Creating arrays
zeros: NDArray[np.float32] = np.zeros((3, 4), dtype=np.float32)
ones: NDArray[np.float64] = np.ones((2, 5))
rng = np.random.default_rng(seed=42)
rand: NDArray[np.float64] = rng.standard_normal((100, 10))
arange: NDArray[np.int64] = np.arange(0, 20, 2) # [0, 2, 4, ..., 18]
linspace: NDArray[np.float64] = np.linspace(0, 1, 50) # 50 evenly spaced points
print(f"rand shape: {rand.shape}, dtype: {rand.dtype}") # (100, 10), float64
print(f"Memory (MB): {rand.nbytes / 1e6:.4f}") # 0.008 MB
# dtype matters for memory and GPU transfer
x_f64 = np.ones((1000, 1000), dtype=np.float64) # 8 MB
x_f32 = np.ones((1000, 1000), dtype=np.float32) # 4 MB
print(f"float64: {x_f64.nbytes // 1_000_000} MB, float32: {x_f32.nbytes // 1_000_000} MB")
float64 vs float32
NumPy defaults to float64. Most GPU frameworks (PyTorch, JAX) default to float32. When bridging the two, always check dtypes — a silent upcast or downcast can introduce bugs or blow up memory.
3.2.2 Broadcasting Rules¶
Broadcasting lets NumPy perform arithmetic on arrays of different (but compatible) shapes without explicit replication. The rules applied left-to-right on the shape tuples (right-aligned, prepend 1s for shorter arrays):
- If the shapes differ in the number of dimensions, prepend
1s to the smaller shape. - Dimensions with size
1are stretched to match the other array. - If shapes disagree on a dimension and neither is
1, raiseValueError.
Shape examples:
(3, 4) op (4,) → (3, 4) # row broadcast
(3, 1) op (1, 4) → (3, 4) # outer-product style
(3, 4) op (3, 1) → (3, 4) # column broadcast
(3, 4) op (3,) → ERROR # rightmost mismatch
graph TD
A["Array A: shape (3, 4)"]
B["Array B: shape (4,)"]
B1["B after prepend: shape (1, 4)"]
B2["B stretched: shape (3, 4)"]
C["Result: shape (3, 4)"]
A --> C
B --> B1 --> B2 --> C
style C fill:#27ae60,color:#fff import numpy as np
from numpy.typing import NDArray
# Subtract per-feature mean from a (n_samples, n_features) matrix
X: NDArray[np.float64] = np.random.randn(100, 10)
mu: NDArray[np.float64] = X.mean(axis=0) # shape (10,)
X_centred: NDArray[np.float64] = X - mu # broadcasting: (100, 10) - (10,) → (100, 10)
# Add a bias vector to each row in a batch
W: NDArray[np.float64] = np.random.randn(5, 3) # (5, 3)
b: NDArray[np.float64] = np.array([1.0, 2.0, 3.0]) # (3,)
out: NDArray[np.float64] = W + b # (5, 3) + (3,) → (5, 3)
# Outer product via broadcast
u = np.array([[1], [2], [3]]) # (3, 1)
v = np.array([[4, 5, 6]]) # (1, 3)
outer = u * v # (3, 3)
print(outer)
# [[4 5 6]
# [8 10 12]
# [12 15 18]]
3.2.3 Vectorised Operations vs Loops¶
Python loops over NumPy arrays are slow — each iteration has interpreter overhead and forfeits SIMD vectorisation. The rule is: never loop over array elements if a NumPy operation exists.
import numpy as np
import time
from numpy.typing import NDArray
N = 10_000_000
x: NDArray[np.float64] = np.random.rand(N)
# Loop — slow (Python interpreter)
start = time.perf_counter()
total = 0.0
for v in x:
total += v * v
loop_time = time.perf_counter() - start
# Vectorised — fast (C/BLAS/SIMD)
start = time.perf_counter()
total_vec = np.dot(x, x) # or (x ** 2).sum()
vec_time = time.perf_counter() - start
print(f"Loop: {loop_time:.3f}s") # e.g., 3.2s
print(f"Vectorised: {vec_time:.4f}s") # e.g., 0.012s
print(f"Speedup: {loop_time / vec_time:.0f}x")
When you think you need a loop
Before writing a Python for loop over array elements, ask: Can I express this with np.apply_along_axis, np.vectorize (still slow), a reduction (np.sum, np.prod), a conditional (np.where), or np.einsum? Usually yes. np.einsum is the most expressive single function in NumPy.
3.2.4 Key Operations: reshape, transpose, dot, einsum¶
import numpy as np
A = np.arange(24).reshape(2, 3, 4) # shape (2, 3, 4)
# Transpose reorders axes
B = A.transpose(0, 2, 1) # shape (2, 4, 3)
# Flatten and restore
flat = A.reshape(-1) # shape (24,)
restored = flat.reshape(2, 3, 4)
# View vs copy — reshape returns a view when possible
C = A.reshape(6, 4)
C[0, 0] = 999
print(A[0, 0, 0]) # also 999 — same memory
# Use .copy() when you need an independent array
D = A.reshape(6, 4).copy()
import numpy as np
A = np.random.randn(3, 4)
B = np.random.randn(4, 5)
C1 = np.dot(A, B) # classic dot — works for 1-D and 2-D
C2 = A @ B # @ operator — preferred for matrices
C3 = np.matmul(A, B) # explicit matmul — handles batch dims
assert np.allclose(C1, C2) and np.allclose(C2, C3)
# Batched matmul: (B, m, k) @ (B, k, n) → (B, m, n)
batch = np.random.randn(8, 3, 4)
W = np.random.randn(8, 4, 5)
out = batch @ W # shape (8, 3, 5)
import numpy as np
A = np.random.randn(3, 4)
B = np.random.randn(4, 5)
# Matrix multiply: sum over k
C = np.einsum("ik,kj->ij", A, B)
# Batch outer product: (B, m) and (B, n) → (B, m, n)
u = np.random.randn(8, 3)
v = np.random.randn(8, 5)
outer = np.einsum("bi,bj->bij", u, v) # shape (8, 3, 5)
# Trace of a matrix
M = np.random.randn(5, 5)
trace = np.einsum("ii->", M)
assert np.isclose(trace, np.trace(M))
# Multi-head attention QK^T: (B, H, T, d) @ (B, H, d, T) → (B, H, T, T)
Q = np.random.randn(2, 4, 10, 64)
K = np.random.randn(2, 4, 10, 64)
scores = np.einsum("bhid,bhjd->bhij", Q, K) # (2, 4, 10, 10)
print(scores.shape)
3.3 Pandas¶
Pandas provides the DataFrame — a labelled, column-heterogeneous 2-D data structure built on NumPy arrays. It is the lingua franca for tabular data in Python.
3.3.1 DataFrame and Series¶
import pandas as pd
import numpy as np
# Creating a DataFrame
df = pd.DataFrame({
"name": ["Alice", "Bob", "Charlie", "Diana"],
"age": [29, 34, 22, 41],
"salary": [95_000.0, 120_000.0, 72_000.0, 145_000.0],
"dept": ["eng", "eng", "sales", "eng"],
})
print(df.dtypes)
# name object
# age int64
# salary float64
# dept object
# A Series is a single column — 1-D with an index
ages: pd.Series = df["age"]
print(ages.mean(), ages.std())
# Basic info
print(df.shape) # (4, 4)
print(df.describe()) # summary statistics for numeric columns
3.3.2 Reading Data¶
import pandas as pd
# CSV
df_csv = pd.read_csv(
"data/train.csv",
dtype={"user_id": "int32", "amount": "float32"},
parse_dates=["timestamp"],
na_values=["N/A", "null", ""],
)
# JSON (records orient)
df_json = pd.read_json("data/events.json", orient="records", lines=True)
# Parquet — columnar format, much faster for large datasets
df_parquet = pd.read_parquet("data/features.parquet", engine="pyarrow")
# Reading only specific columns (large files)
df_cols = pd.read_csv("data/big.csv", usecols=["id", "label", "feature_1"])
Parquet over CSV for ML
Parquet encodes column dtypes, compresses efficiently, and supports predicate pushdown. For datasets over a few hundred MB, always prefer Parquet. Reading a 1 GB CSV can take 30+ seconds; reading the same data as Parquet typically takes 2–3 seconds.
3.3.3 Indexing: loc vs iloc¶
loc | iloc | |
|---|---|---|
| Index type | Label-based | Integer position |
| Slice end | Inclusive | Exclusive (Python convention) |
| Accepts boolean arrays | Yes | Yes |
| Use for | Labelled access, boolean masks | Positional slicing |
import pandas as pd
df = pd.DataFrame({
"val": [10, 20, 30, 40, 50],
}, index=["a", "b", "c", "d", "e"])
# loc: label-based (inclusive on both ends)
print(df.loc["b":"d"]) # rows b, c, d
# iloc: position-based (exclusive end)
print(df.iloc[1:4]) # positions 1, 2, 3 → rows b, c, d
# Boolean mask (works with both)
mask = df["val"] > 25
print(df.loc[mask]) # c, d, e
# Setting values — always use loc/iloc to avoid SettingWithCopyWarning
df.loc[df["val"] > 40, "val"] = 999
SettingWithCopyWarning
df[mask]["col"] = value does not reliably modify df. Pandas may be operating on a copy. Use df.loc[mask, "col"] = value instead. This is one of the most common Pandas bugs.
3.3.4 groupby, merge, pivot_table¶
import pandas as pd
df = pd.DataFrame({
"dept": ["eng", "eng", "sales", "eng", "sales"],
"name": ["Alice", "Bob", "Charlie", "Diana", "Eve"],
"salary": [95_000, 120_000, 72_000, 145_000, 68_000],
"score": [4.2, 3.8, 4.5, 4.9, 3.6],
})
# Group and aggregate
summary = (
df.groupby("dept")
.agg(
headcount=("name", "count"),
avg_salary=("salary", "mean"),
max_score=("score", "max"),
)
.reset_index()
)
print(summary)
# dept headcount avg_salary max_score
# 0 eng 3 120000.00 4.9
# 1 sales 2 70000.00 4.5
# Apply custom function
df["salary_rank"] = df.groupby("dept")["salary"].rank(ascending=False)
import pandas as pd
users = pd.DataFrame({
"user_id": [1, 2, 3, 4],
"name": ["Alice", "Bob", "Carol", "Dave"],
})
orders = pd.DataFrame({
"order_id": [101, 102, 103, 104, 105],
"user_id": [1, 2, 2, 3, 9], # 9 not in users
"amount": [50, 30, 70, 20, 10],
})
# Inner join (only matching user_ids)
inner = pd.merge(orders, users, on="user_id", how="inner")
# Left join (all orders, NaN for unmatched users)
left = pd.merge(orders, users, on="user_id", how="left")
print(left[left["name"].isna()]) # user_id=9
# Anti-join: orders with no matching user
anti = orders[~orders["user_id"].isin(users["user_id"])]
import pandas as pd
import numpy as np
df = pd.DataFrame({
"month": ["Jan", "Jan", "Feb", "Feb", "Mar", "Mar"],
"product": ["A", "B", "A", "B", "A", "B"],
"revenue": [100, 150, 120, 130, 140, 160],
})
pivot = df.pivot_table(
values="revenue",
index="month",
columns="product",
aggfunc="sum",
fill_value=0,
)
print(pivot)
# product A B
# month
# Feb 120 130
# Jan 100 150
# Mar 140 160
3.3.5 Handling Missing Data¶
import pandas as pd
import numpy as np
df = pd.DataFrame({
"age": [25.0, np.nan, 30.0, np.nan, 45.0],
"income": [50_000.0, 60_000.0, np.nan, 80_000.0, np.nan],
"city": ["NYC", None, "LA", "NYC", None],
})
# Detect
print(df.isna().sum()) # count missing per column
print(df.isna().mean()) # fraction missing
# Drop rows with any NaN
df_dropped = df.dropna()
# Drop columns with > 30 % missing
threshold = 0.30
df_filtered = df.loc[:, df.isna().mean() < threshold]
# Fill — different strategies per column
df_filled = df.copy()
df_filled["age"] = df["age"].fillna(df["age"].median())
df_filled["income"] = df["income"].fillna(df["income"].mean())
df_filled["city"] = df["city"].fillna("Unknown")
# Forward fill (time-series)
df_ffill = df.ffill()
print(df_filled.isna().sum()) # all zeros
Mean imputation limitations
Filling with the mean removes variance and distorts correlations between features. For ML pipelines, prefer sklearn.impute.IterativeImputer (MICE) or explicitly model missingness as a feature. Always impute after the train/test split to avoid data leakage.
3.4 Matplotlib and Seaborn¶
3.4.1 Figure Anatomy¶
A Matplotlib figure has a hierarchy:
Figure (fig)
└── Axes (ax) — one or many, arranged in a grid
├── x-axis (xlabel, xticks, xlim)
├── y-axis (ylabel, yticks, ylim)
├── Title
├── Artists (Line2D, PathCollection, BarContainer, …)
└── Legend
Always use the object-oriented interface (fig, ax = plt.subplots()) rather than the implicit stateful interface (plt.plot()) in production code:
import matplotlib.pyplot as plt
import numpy as np
fig, ax = plt.subplots(figsize=(8, 4))
x = np.linspace(0, 4 * np.pi, 300)
ax.plot(x, np.sin(x), label="sin(x)", color="#2563EB", linewidth=2)
ax.plot(x, np.cos(x), label="cos(x)", color="#DC2626", linewidth=2, linestyle="--")
ax.set_xlabel("x", fontsize=13)
ax.set_ylabel("Amplitude", fontsize=13)
ax.set_title("Trigonometric Functions", fontsize=15, fontweight="bold")
ax.legend(fontsize=12)
ax.grid(alpha=0.3)
fig.tight_layout()
fig.savefig("trig.png", dpi=150, bbox_inches="tight")
plt.close(fig) # release memory — always close in scripts
3.4.2 Common Plot Types¶
import matplotlib.pyplot as plt
import numpy as np
rng = np.random.default_rng(0)
n = 200
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(11, 4))
# Training curve (line)
steps = np.arange(n)
loss = 2.0 * np.exp(-steps / 40) + 0.05 * rng.standard_normal(n)
ax1.plot(steps, loss, color="#2563EB", lw=1.5, alpha=0.8, label="train loss")
ax1.set_xlabel("Step"); ax1.set_ylabel("Loss"); ax1.set_title("Training Curve")
ax1.legend()
# Scatter with colour encoding
x_vals = rng.standard_normal(n)
y_vals = 0.7 * x_vals + 0.5 * rng.standard_normal(n)
labels = rng.integers(0, 2, n)
scatter = ax2.scatter(x_vals, y_vals, c=labels,
cmap="RdBu", alpha=0.7, edgecolors="white", lw=0.3)
plt.colorbar(scatter, ax=ax2, label="Class")
ax2.set_title("Scatter — Two Classes")
fig.tight_layout()
fig.savefig("line_scatter.png", dpi=150, bbox_inches="tight")
plt.close(fig)
import matplotlib.pyplot as plt
import seaborn as sns
import numpy as np
rng = np.random.default_rng(1)
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(11, 4))
# Histogram
data = rng.standard_normal(2000)
ax1.hist(data, bins=40, color="#2563EB", edgecolor="white", alpha=0.85)
ax1.axvline(data.mean(), color="red", lw=2, linestyle="--", label=f"mean={data.mean():.2f}")
ax1.set_xlabel("Value"); ax1.set_ylabel("Count")
ax1.set_title("Distribution of Samples")
ax1.legend()
# Heatmap (correlation matrix)
corr = np.corrcoef(rng.standard_normal((5, 100))) # 5 variables
labels = [f"F{i}" for i in range(5)]
sns.heatmap(
corr, ax=ax2, annot=True, fmt=".2f",
cmap="coolwarm", center=0, vmin=-1, vmax=1,
xticklabels=labels, yticklabels=labels,
)
ax2.set_title("Correlation Matrix")
fig.tight_layout()
fig.savefig("hist_heatmap.png", dpi=150, bbox_inches="tight")
plt.close(fig)
import seaborn as sns
import pandas as pd
import matplotlib.pyplot as plt
import numpy as np
rng = np.random.default_rng(3)
df = pd.DataFrame({
"score": rng.normal(loc=[70, 80, 75], scale=10, size=(100, 3)).ravel(),
"group": ["A"] * 100 + ["B"] * 100 + ["C"] * 100,
"gender": rng.choice(["M", "F"], 300),
})
fig, axes = plt.subplots(1, 2, figsize=(12, 5))
# Violin plot
sns.violinplot(data=df, x="group", y="score", hue="gender",
split=True, inner="quart", ax=axes[0], palette="Set2")
axes[0].set_title("Score Distribution by Group and Gender")
# Box plot
sns.boxplot(data=df, x="group", y="score", ax=axes[1], palette="Set2")
axes[1].set_title("Score Box Plot by Group")
fig.tight_layout()
fig.savefig("seaborn_plots.png", dpi=150, bbox_inches="tight")
plt.close(fig)
3.4.3 Saving Publication-Quality Figures¶
import matplotlib as mpl
import matplotlib.pyplot as plt
import numpy as np
# Global style settings — set once at the top of a script
mpl.rcParams.update({
"font.family": "DejaVu Sans",
"font.size": 12,
"axes.spines.top": False,
"axes.spines.right": False,
"axes.grid": True,
"grid.alpha": 0.3,
"figure.dpi": 150,
})
fig, ax = plt.subplots(figsize=(6, 4))
x = np.linspace(0, 1, 100)
ax.plot(x, x**2, lw=2, label=r"$y = x^2$")
ax.set_xlabel("x"); ax.set_ylabel("y")
ax.legend()
ax.set_title("Publication-Ready Figure")
# PNG for web/notebooks (raster)
fig.savefig("figure.png", dpi=300, bbox_inches="tight")
# PDF/SVG for papers (vector)
fig.savefig("figure.pdf", bbox_inches="tight")
fig.savefig("figure.svg", bbox_inches="tight")
plt.close(fig)
Always use bbox_inches='tight'
Without it, tight titles and labels are clipped. The dpi=300 setting produces figures sharp enough for print. Use dpi=150 in notebooks for faster rendering without wasting memory.
3.5 Jupyter Notebooks¶
3.5.1 Best Practices for Reproducible Notebooks¶
A notebook that only runs top-to-bottom on the author's machine is not reproducible. Follow these practices:
notebooks/
├── 01-EDA.ipynb # exploration — output committed
├── 02-feature-eng.ipynb # feature work
└── 03-model-eval.ipynb # final evaluation
Do:
- Run
Kernel > Restart & Run Allbefore committing. Cells executed out of order are the single biggest source of notebook bugs. - Set the random seed in the first cell of every notebook.
- Use relative paths via
pathlib.Path— never hardcode/Users/yourname/.... - Keep notebooks short (< 200 cells). Extract reusable logic into
.pymodules andimportthem. - Record environment:
pip freeze > requirements.txtoruv export > requirements.txt.
Avoid:
- Storing raw data or large binaries inside the notebook (embed only small summary tables).
- Long-running training loops inside notebooks (move to scripts; show results in notebooks).
- Side effects that modify files outside the notebook's directory.
# Cell 1: imports and seeds — always the same, always first
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from pathlib import Path
rng = np.random.default_rng(seed=42)
DATA_DIR = Path("../data")
OUT_DIR = Path("../outputs")
OUT_DIR.mkdir(parents=True, exist_ok=True)
print("Environment ready.")
3.5.2 Useful Notebook Extensions and Shortcuts¶
| Action | Keyboard Shortcut |
|---|---|
| Run cell, move down | Shift+Enter |
| Run cell in-place | Ctrl+Enter |
| Insert cell above | A (command mode) |
| Insert cell below | B (command mode) |
| Delete cell | DD (command mode) |
| Toggle code/markdown | M / Y (command mode) |
| Enter command mode | Esc |
| Comment/uncomment | Ctrl+/ |
3.5.3 nbconvert for Sharing¶
nbconvert transforms notebooks into HTML, PDF, Markdown, or scripts:
# HTML report (self-contained, share with stakeholders)
jupyter nbconvert --to html notebooks/03-model-eval.ipynb \
--output-dir reports/ --no-input # --no-input hides code
# Python script (for code review / version control diff)
jupyter nbconvert --to script notebooks/01-EDA.ipynb --output src/eda
# PDF (requires LaTeX; use HTML → PDF via browser print for simpler setups)
jupyter nbconvert --to pdf notebooks/03-model-eval.ipynb
# Run all cells non-interactively (CI/CD)
jupyter nbconvert --to notebook --execute notebooks/01-EDA.ipynb \
--output notebooks/01-EDA-executed.ipynb
Papermill for parameterised notebooks
If you need to run the same notebook with different parameters (e.g., different hyperparameters or datasets), use Papermill. Tag a cell with parameters and override values from the command line:
3.6 Common Mistakes¶
Mistake 1: Modifying Arrays In-Place Unexpectedly
NumPy operations like reshape, ravel, and slicing often return views, not copies. Modifying a view modifies the original.
Mistake 2: Pandas Chained Indexing
Mistake 3: Shape Mismatches in Broadcasting
Mistake 4: Data Leakage in Preprocessing
# WRONG: fit scaler on full dataset (leaks test statistics into training)
from sklearn.preprocessing import StandardScaler
import numpy as np
X_all = np.random.randn(200, 10)
scaler = StandardScaler().fit(X_all) # leaks test mean/std!
# CORRECT: fit only on training data
X_train, X_test = X_all[:160], X_all[160:]
scaler = StandardScaler().fit(X_train)
X_train_s = scaler.transform(X_train)
X_test_s = scaler.transform(X_test)
Mistake 5: Mutable Default Arguments
# WRONG — list is shared across all calls
def add_feature(x: float, features: list = []) -> list:
features.append(x)
return features
# CORRECT
from typing import Optional
def add_feature(x: float, features: Optional[list] = None) -> list:
if features is None:
features = []
features.append(x)
return features
3.7 Exercises¶
Exercise 3.1 — Broadcasting Implementation
Without using any loop, implement a function pairwise_distances(X, Y) that computes the Euclidean distance between every row of X (shape (m, d)) and every row of Y (shape (n, d)), returning a matrix of shape (m, n). Use only broadcasting and np.sqrt. Verify against scipy.spatial.distance.cdist.
Exercise 3.2 — Pandas EDA Pipeline
Download the Titanic dataset (pd.read_csv("https://raw.githubusercontent.com/datasciencedojo/datasets/master/titanic.csv")). Using only Pandas (no sklearn), perform: a) Compute survival rate by Pclass and Sex. b) Impute missing Age values with the median age per Pclass. c) Create a binary feature IsAlone = 1 if SibSp + Parch == 0. d) Produce a pivot table of mean Fare by Pclass and Embarked.
Exercise 3.3 — Einsum Practice
Using np.einsum, implement the following without any Python loops: a) Batch dot product: given U of shape (B, d) and V of shape (B, d), compute the elementwise dot products yielding shape (B,). b) Batched outer product: given U of shape (B, m) and V of shape (B, n), compute shape (B, m, n). c) Trace of each matrix in a batch: given M of shape (B, n, n), compute shape (B,).
Exercise 3.4 — Matplotlib Dashboard
Given a training log with columns epoch, train_loss, val_loss, train_acc, val_acc (generate synthetic data), produce a two-panel figure: - Left panel: train and val loss curves with shaded ± 1 standard deviation bands. - Right panel: train and val accuracy curves. Save the figure at 300 DPI as both PNG and PDF. Labels, legend, and grid must be publication-ready.
Exercise 3.5 — Reproducible Notebook
Convert notebooks/01-EDA.ipynb to an HTML report using nbconvert --no-input. Then write a Makefile target make report that (a) re-executes the notebook with --execute and (b) converts it to HTML. Verify that running make report twice produces byte-identical output (requires setting all seeds and avoiding datetime.now() in cells).
3.8 Summary¶
| Topic | Key Takeaways |
|---|---|
| Environment | Use uv + pyproject.toml; lock with uv.lock; use Python ≥ 3.10. |
| NumPy | Arrays are views by default — copy when needed. Vectorise everything; use einsum for complex contractions. |
| Broadcasting | Shapes are matched right-to-left; size-1 dims stretch. When in doubt, add [:, np.newaxis] explicitly. |
| Pandas | Use loc/iloc for assignments. Impute after splitting. Prefer Parquet for large data. |
| Matplotlib/Seaborn | Use the OO interface; set rcParams globally; always bbox_inches='tight' when saving. |
| Notebooks | Restart & Run All before committing. Extract logic to modules. Use nbconvert for sharing. |
What Comes Next
Chapter 4 introduces scikit-learn and the standard ML pipeline: train/test splits, feature engineering, model evaluation, and cross-validation. The NumPy and Pandas skills from this chapter will be used without further explanation from that point on.