Week 1 — NumPy: Fast Math on Whole Columns¶
Course: Applied ML Foundations for SaaS Analytics
Who this is for: Software engineers. You do not need calculus or linear algebra.
You already have Python (Week 0). This week is not “become a numerical analyst.” It is: stop writing Python for loops over 160,000 usage events.
🎯 What you will be able to do¶
- Treat a NumPy array like a typed column in a database — one type, one block of memory
- Replace a loop with a single vectorized operation
- Picture broadcasting as “stamp this row down the table”
- Read a z-score as “how weird is this user, in units of typical spread”
- Know when to stay in Pandas / SQL instead
How to read this course¶
Every week uses the same colored boxes:
| Box | Meaning |
|---|---|
| Think of it like… | Everyday or software analogy. Start here. |
| Engineer mental model | How this shows up in a codebase. |
| Watch out | The foot-gun of the week. |
| Ship / don’t ship | Decision rule, not theory. |
| Math, translated | One formula, immediately in English. |
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from lib.course_data import find_data_dir
DATA = find_data_dir()
🏢 Scenario — CloudWave’s daily health numbers¶
You are on the growth team at CloudWave. The CEO wants a daily pulse:
- Which features are actually used?
- Who are the top 10% power users?
- Which usage numbers look “weird” enough to investigate?
The raw file feature_usage.csv has 160,000 rows. A Python loop will work. It will also be the slowest part of your job.
Think of it like… SIMD, SQL, or Excel — not “math class.”
A NumPy array is a single typed buffer, like int64_t usage[160000] in C, or one column in Postgres. When you write usage * 2, you are not looping in Python. You are asking the CPU to map an operation over the whole column — the same idea as SELECT usage * 2 FROM events.
If you already write software¶
A Python for loop over 160,000 usage events is the same smell as an N+1 query. It works in staging. It becomes the slowest line in the job.
NumPy is not “math class.” It is:
- a columnar store in RAM (one type, one contiguous buffer)
- a SIMD / vectorized API (
usage * 2is one CPU instruction stream, not 160k Python bytecode loops) - the thing Pandas, scikit-learn, and PyTorch all sit on
SQL NumPy
────────────────────── ──────────────────────────
SELECT usage * 2 usage * 2
WHERE usage > 10 usage[usage > 10]
AVG(usage) usage.mean()
one typed column ndarray, dtype=int64
Picture the memory¶
A Python list of integers is a list of pointers to objects. Each integer is a full PyObject with a header. The CPU cannot stream that.
A NumPy array is a C array. One header, then raw values. That is why the bar chart in this lesson looks the way it does — not because NumPy is “smarter math,” but because it stops talking to the Python interpreter per row.
What this is not¶
Not linear algebra. You do not need eigenvectors this week. Broadcasting is “make the shapes line up from the right, then stamp.” If you can picture a SQL SELECT over a column, you can picture NumPy.
Visual: Python list vs NumPy array¶
A Python list of numbers is a list of pointers to objects. A NumPy array is a contiguous block of raw values.
Python list [ 1, 2, 3, 4 ]
│ │ │ │
▼ ▼ ▼ ▼
int int int int ← 5 objects, extra headers, cache misses
NumPy array [ 1 | 2 | 3 | 4 ] ← one slab, one type, CPU-friendly
Engineer mental model
list = JSON array of values (flexible, slow). ndarray = a column in a parquet file (rigid, fast). If every cell is the same type and you want the same operation on every row, use the slab.
# Proof, not a slogan: loop vs vectorized multiply on 1 million numbers
import time
n = 1_000_000
py_list = list(range(n))
np_arr = np.arange(n)
t0 = time.perf_counter()
_ = [x * 2 for x in py_list]
loop_ms = (time.perf_counter() - t0) * 1000
t0 = time.perf_counter()
_ = np_arr * 2
vec_ms = (time.perf_counter() - t0) * 1000
fig, ax = plt.subplots(figsize=(8, 3.2))
bars = ax.barh(["Python list comprehension", "NumPy vectorized"], [loop_ms, vec_ms],
color=["#f59e0b", "#3b82f6"])
ax.set_xlabel("Milliseconds to double 1,000,000 numbers")
ax.set_title("Same work. Different runtime.")
for bar, val in zip(bars, [loop_ms, vec_ms]):
ax.text(val + 1, bar.get_y() + bar.get_height() / 2, f"{val:.1f} ms", va="center")
plt.tight_layout()
plt.show()
print(f"Speedup: {loop_ms / max(vec_ms, 1e-9):.0f}×")
print("You do not need to remember the number. Remember the shape of the bar chart.")
Worked example — real CloudWave usage¶
We will load usage_count as a NumPy array and ask ordinary product questions.
Visual cue — “one column, many questions”
Once the numbers live in an array, mean / median / percentile are the same kind of operation: walk the column once in compiled code.
usage = pd.read_csv(DATA / "feature_usage.csv")
print(usage.head(3))
print(f"\n{len(usage):,} rows | columns: {list(usage.columns)}")
counts = usage["usage_count"].to_numpy() # the slab
print(f"array dtype={counts.dtype} shape={counts.shape}")
print("\nHow 'busy' is a typical event row?")
print(f" mean {counts.mean():.2f} ← pulled up by heavy users")
print(f" median {np.median(counts):.2f} ← a better 'typical'")
print(f" p90 {np.percentile(counts, 90):.0f}")
print(f" p99 {np.percentile(counts, 99):.0f}")
print(f" max {counts.max():.0f}")
fig, ax = plt.subplots(figsize=(8, 3.4))
# Cap the x-axis so a few whales do not flatten the picture
cap = np.percentile(counts, 99)
ax.hist(counts[counts <= cap], bins=40, color="#6366f1", edgecolor="white")
ax.axvline(np.median(counts), color="#b45309", lw=2, label=f"median {np.median(counts):.0f}")
ax.axvline(np.percentile(counts, 90), color="#047857", lw=2, ls="--",
label=f"p90 {np.percentile(counts, 90):.0f}")
ax.set_title("Usage counts are skewed — the mean is not 'typical'")
ax.set_xlabel("usage_count (capped at p99 for readability)")
ax.set_ylabel("rows")
ax.legend()
plt.tight_layout()
plt.show()
Five lines of Pandas you need this week¶
Exercise 1 is a group-by. Exercise 2 is a pivot. Week 2 is the full SQL-in-Python course. This is the 5-line version so you are not surprised:
by_feature = usage.groupby("feature_name")["usage_count"].sum()
print(by_feature.sort_values(ascending=False).head())
sample_ids = usage["user_id"].drop_duplicates().head(12)
grid = usage.loc[usage["user_id"].isin(sample_ids)].pivot_table(
index="user_id", columns="feature_name",
values="usage_count", aggfunc="sum", fill_value=0,
)
print(grid.shape) # users × features — a 2-D slab you can hand to NumPy
groupby collapses many rows that share a key. pivot_table is a group-by on two keys, spread into a matrix. Broadcasting (next) is what you do once the matrix exists.
Broadcasting — stamping a row down a table¶
Broadcasting sounds like linear algebra. It is closer to CSS stretching or SQL joining a one-row lookup onto every day.
Imagine a week of daily active users for 4 regions, and a “normal Tuesday” baseline per region:
DAU (7 days × 4 regions) baseline (4 regions)
Mon [ 80 90 70 60 ] [ 100 100 80 70 ]
Tue [110 95 85 72 ] ÷
...
NumPy stamps the 4-number baseline onto every day, then divides. You never write a nested loop.
Math, translated
Shapes line up from the right. A (7, 4) table and a (4,) row are compatible because the last dimension matches. The length-4 row is repeated 7 times. That repetition is broadcasting. It does not copy the array 7 times in your head — the library does it.
# Tiny picture you can see — 7 days × 4 regions
rng = np.random.default_rng(7)
regions = ["NA", "EMEA", "APAC", "LATAM"]
dau = rng.integers(60, 140, size=(7, 4))
baseline = np.array([100, 100, 80, 70]) # "normal" DAU per region
ratio = dau / baseline # (7, 4) / (4,) → stamped automatically
fig, axes = plt.subplots(1, 3, figsize=(12, 3.6))
for ax, grid, title, cmap in [
(axes[0], dau, "Raw DAU", "Blues"),
(axes[1], np.tile(baseline, (7, 1)), "Baseline stamped 7×", "Oranges"),
(axes[2], ratio, "DAU / baseline", "RdYlGn"),
]:
im = ax.imshow(grid, cmap=cmap)
ax.set_xticks(range(4), regions, fontsize=8)
ax.set_yticks(range(7), ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"], fontsize=8)
ax.set_title(title)
for i in range(grid.shape[0]):
for j in range(grid.shape[1]):
ax.text(j, i, f"{grid[i, j]:.1f}" if grid is ratio else f"{int(grid[i, j])}",
ha="center", va="center", fontsize=8)
fig.colorbar(im, ax=ax, fraction=0.046)
plt.tight_layout()
plt.show()
print("Hottest cell = that region+day beat its own baseline the most.")
print(f"Best day/region: {['Mon','Tue','Wed','Thu','Fri','Sat','Sun'][np.unravel_index(ratio.argmax(), ratio.shape)[0]]} / {regions[np.unravel_index(ratio.argmax(), ratio.shape)[1]]}")
Z-score — “how weird is this user?”¶
Product people say “this account is an outlier.” Engineers should say how many typical-spreads away from the middle.
Math, translated
z = (value − mean) / std → “this user is z typical-spreads from average.” |z| > 2.5 is a useful hunt-list, not a law of nature. It is a smoke alarm, not a guilty verdict.
Watch out
On a skewed usage column the mean is dragged by whales, so z-scores flag even more whales. For “unusual,” start with percentiles (top 1%). Use z-scores after a log, or on something already bell-shaped.
per_user = usage.groupby("user_id")["usage_count"].sum().to_numpy()
z = (per_user - per_user.mean()) / per_user.std()
print(f"Users: {len(per_user):,}")
print(f"Top 1% usage threshold: {np.percentile(per_user, 99):,.0f}")
print(f"|z| > 2.5 users: {(np.abs(z) > 2.5).sum():,}")
fig, ax = plt.subplots(figsize=(8, 3.2))
ax.hist(z, bins=40, color="#8b5cf6", edgecolor="white")
ax.axvline(2.5, color="#b91c1c", ls="--", label="|z| = 2.5")
ax.axvline(-2.5, color="#b91c1c", ls="--")
ax.set_title("Z-score of total usage per user — right tail is the whale list")
ax.set_xlabel("z-score")
ax.legend()
plt.tight_layout()
plt.show()
When NumPy is the wrong tool¶
Ship / don’t ship
-
Use NumPy for one typed column (or a grid) and the same math on every cell: scores, percentiles, normalizations.
-
Use Pandas when rows have names, joins, group-bys, missing values, dates — that is next week, and it is most of SaaS work.
-
Use SQL / Spark / DuckDB when the table is bigger than RAM.
-
Stay in Python for strings, nested JSON, one-off business rules.
Engineer mental model
Pandas is your ORM + dataframe. NumPy is the compute kernel hiding under .values / .to_numpy(). You will bounce between them all course. That is normal.
✍️ Exercise¶
When you can explain the week out loud, do the exercises. Starter: python exercises/ml/week-01/starter.py from the repo root.
🤔 Reflection¶
- A teammate writes a
for user in usersloop to compute mean usage. When do you leave it, and when do you rewrite it? - Why can the mean of CloudWave usage lie to a PM, while the median and p90 tell a better story?
- Broadcasting failed with
operands could not be broadcast. What do you inspect first? (Print.shape. Align from the right.)
Before you leave¶
Try one self-check (Predict / Diagnose / Choose / Defend). Write the answer before you open the block.
🔗 Next week¶
Pandas: joins, group-bys, and building a Customer 360 table — the bread and butter of SaaS analytics. NumPy stays underneath.