AI EngineeringZero to ProductionHome·About·What’s new·Contact
Data & App Building · Chapter B1

Numerical Computing & Data Analysis with NumPy and Pandas

Before an LLM ever sees your data, you have to load it, clean it, and understand it — and that's Pandas, riding on NumPy. This chapter is the data-analysis workhorse: the array/DataFrame model, the vectorized mindset, and the load→clean→transform→aggregate loop that precedes every ML or LLM pipeline.

⏱️ ~65 min🧪 4 labs🎯 Beginner→Expert
🌱 Start here — from zero Data & app building, from scratch — the data-wrangling and app skills that turn a model into something people can use.

AI apps need two everyday skills this section teaches: working with data (loading, cleaning, shaping it with NumPy/Pandas) and building interfaces (an API with FastAPI, a UI with Streamlit/Gradio). These are the glue between a model and a real, usable product.

The words you'll hear (in plain terms):

TermWhat it actually means
NumPy / PandasPython libraries for numeric arrays and tables (dataframes).
dataframea table of data you can filter, group, and transform in code.
API (FastAPI)a program that serves your app's functionality over HTTP for others to call.
UI (Streamlit/Gradio)tools to build a simple web interface for your app in pure Python.
endpointone callable URL of your API (e.g. /ask).

What you need before starting:

  • Python basics (the Python data chapter is a gentle lead-in).
  • pip install numpy pandas fastapi streamlit gradio for the labs.
  • No web-development background assumed.

New to the topic? Read this box, then take the chapters in order — each section is tagged essential → expert so you always know the depth you're at.

Learning objectives

  • Explain why NumPy arrays and Pandas DataFrames beat Python lists/dicts for data.
  • Adopt the vectorized mindset — operate on whole columns, not with loops.
  • Run the core Pandas loop: load → inspect → clean → transform → aggregate.
  • Select and filter rows/columns correctly (and avoid the classic gotchas).
  • Prepare a messy dataset into clean records ready for an LLM or a chart.
How this relates to A4 and P2The A4 page covers NumPy for tensors and model math (broadcasting, dtypes, cosine similarity). This chapter is the data-analysis use: NumPy as the engine under Pandas, which is what you actually reach for to wrangle CSVs, logs, and tables. Python data structures are in P2. We build on both; here the star is the DataFrame.

Why not just lists and dicts? essential

Picture the moment this bites you. You've built a little prototype that reads a CSV of customer orders into a list of dictionaries — one dict per row — and loops over them to total the revenue. On the 500-row sample it's instant, so you ship it. Then the real export lands: 4 million rows. The same loop now churns for a minute, your laptop fan spins up, and a colleague asks why the "total revenue" number takes so long to appear. Nothing in your code is wrong — it's the data structure that gave out under scale. This section is about the two libraries built precisely to survive that moment.

Here's the plain reason a list of dicts struggles. In Python, every value is a full object with its own header, type tag, and reference count — a single integer isn't 8 bytes of raw number, it's a boxed object sitting somewhere in memory. A list of dicts is therefore a pile of pointers to pointers: to add up a column of prices you touch a million little objects living in a million scattered places, and the CPU spends most of its time chasing addresses rather than doing arithmetic. NumPy flips this: it stores a whole column as one contiguous block of raw, same-type numbers — like a single long ruler instead of a scatter of loose beads — so the processor can stream through it and even use special wide instructions that crunch several numbers per clock tick.

The analogy that makes it stick: a Python list of dicts is a filing cabinet where each folder can hold anything and lives in a random drawer; a NumPy array (and the Pandas column built on it) is a printed spreadsheet where every cell in a column is the same kind of thing, lined up edge to edge. You don't rifle through drawers — you read straight down the page. That one layout choice — typed, contiguous, columnar — is what buys you both the speed and the one-line queries.

The mistake beginners make is assuming "it worked on my test data, so it'll scale." It won't — but the flip side matters too: a list of dicts is genuinely the right tool for a config file or a handful of records, and dragging in Pandas for a three-item list is its own kind of over-engineering. The skill is knowing where the line sits: reach for NumPy/Pandas when you have tabular data you'll compute over, and stay with plain Python when you don't. The table below is the honest side-by-side of what you trade and what you gain when you cross that line.

list of dicts: boxed objects, scattered int int int int chase pointers everywhere a header + type tag per value NumPy column: one typed block stream straight down same-size numbers, edge to edge Scattered beads vs one long ruler. A list of dicts is a pile of boxed objects in random memory — the CPU spends its time chasing addresses. A NumPy column is one contiguous block of same-type numbers the processor streams through in order.
🗺️ How to read this diagram

Compare the two halves side by side: the left is a Python list of dicts, the right is the NumPy/Pandas column that replaces it.

  • The left red boxes sit at random positions, joined by faint pointer arrows — each value is a full boxed object with its own header and type tag, living somewhere scattered in memory.
  • The dashed pointer lines are the cost: to add up a column the CPU must chase an address to each object one at a time, doing more pointer-following than arithmetic.
  • The right green cells are one contiguous block — same-size numbers packed edge to edge. The single straight arrow is the payoff: the processor streams straight down the block, and can even crunch several numbers per clock tick.

In short: Typed + contiguous + columnar is the whole win — it turns pointer-chasing into a straight read, which is where the speed and the one-line queries come from.

Python lists/dictsNumPy / Pandas
Each element is a boxed Python objectContiguous typed memory — compact & cache-friendly
Loops in Python (slow)Vectorized ops in C (fast)
Manual filtering/grouping codeOne-liner select/filter/group
No column types or labelsNamed, typed columns + an index
The one mental shift: think in columns, not rowsThe single habit that makes you productive here is the vectorized mindset: instead of looping over rows, you apply an operation to a whole column at once. df["price"] * 1.1 beats a for loop — shorter, and often 10–100× faster because the work happens in compiled code. If you catch yourself writing for row in ..., stop and ask "what's the column operation?"

NumPy: the array engine essential

Every time you've felt a data script drag, the fix was probably one idea: stop looping, start vectorizing. NumPy is where that idea lives. It's the quiet engine under almost the entire scientific-Python world — Pandas, scikit-learn, PyTorch tensors, the embedding math in your RAG pipeline — all of it bottoms out in NumPy arrays. So the half-hour you spend getting comfortable here pays off in every library you touch later.

The one concept to internalize is vectorization: writing operations that apply to a whole array at once instead of to one element at a time in a Python loop. Why does that matter so much? Because a Python for loop pays a tax on every single iteration — the interpreter checks types, boxes and unboxes objects, updates counters — and on a million rows that tax dwarfs the actual arithmetic. When you write a * 10, NumPy hands the entire job to a tight, pre-compiled C loop that already knows every element is a number and just multiplies straight down the memory block. Same answer, but the per-item overhead vanishes, which is where the routine 10–100× speedups come from.

The analogy: a Python loop is you paying a toll booth once per car; vectorization is waving the whole convoy through on one pre-cleared pass. The cars (the numbers) are the same — you've just stopped stopping. The habit worth building now: whenever your fingers start typing for x in ... over numeric data, pause and ask "is there an array operation that does this to the whole thing at once?" Nine times out of ten there is, and it's both shorter and faster.

Two sub-ideas in the lab will follow you everywhere, so meet them by name up front. A boolean mask — a[a > 2] — first builds an array of True/False the same shape as your data, then keeps only the True positions; it's the seed of every row filter you'll write in Pandas. And axis is just "which direction do I collapse?": on a 2-D grid, axis=0 squashes the rows together to give you a per-column answer, and axis=1 squashes the columns to give a per-row answer. That axis convention trips up nearly everyone at first, so the plain reading — "axis=0 goes down, axis=1 goes across" — is worth pinning to memory before you read the code.

Python loop: one toll per item type-check + box overhead × n vectorized: one C pass a * 10 → all at once no per-item interpreter tax Vectorization removes the per-item tax. A Python loop pays interpreter overhead — type-check, box, unbox — on every element. A vectorized op hands the whole array to one compiled C loop, which is where the 10–100× speedups come from.
🗺️ How to read this diagram

Read left then right: the left is a per-element Python loop, the right is the same job vectorized. Both process the same numbers.

  • The left red row is your data; the down-arrows show the loop stopping at each element and doing extra work — the interpreter type-checks and boxes/unboxes every value before the actual arithmetic.
  • That repeated detour is the overhead × n: on a million rows the tax dwarfs the real math, which is why row-at-a-time loops drag.
  • The right green row is handed to one straight C pass — a * 10 multiplies the whole block in a single pre-compiled sweep with no per-item interpreter tax.

In short: Same numbers, but the loop pays a toll once per item while the vectorized version waves the whole convoy through — that removed overhead is the speedup.

Example code for learning — review, test, and adapt it before running against real or production systems. Commands can create, change, or delete resources. See the Terms & Disclaimer.
Lab B1.1
numpy_basics.pyimport numpy as np

a = np.array([1, 2, 3, 4])
print(a * 10)              # [10 20 30 40] — vectorized, no loop
print(a.mean(), a.sum())    # 2.5 10 — fast aggregates
print(a[a > 2])            # [3 4] — boolean mask selection

m = np.array([[1, 2], [3, 4]])
print(m.shape)              # (2, 2)
print(m.mean(axis=0))       # [2. 3.] — mean down each column
▶ How this works

This lab shows the one thing that makes NumPy fast and pleasant: you operate on a whole array at once instead of looping over items one by one. An array (an ndarray) is like a Python list, but all the same type and stored in one tight block of memory, so the math runs in compiled C code.

  1. np.array([1, 2, 3, 4]) builds a 1-D array a. a * 10 multiplies every element by 10 in a single step — this is vectorization. No for loop, and it is far faster than one.
  2. a.mean() and a.sum() are aggregates — they reduce the whole array to one number (the average, the total).
  3. a[a > 2] is a boolean mask: a > 2 first makes an array of True/False ([F, F, T, T]), then indexing with it keeps only the True positions. This exact idea becomes row-filtering in Pandas.
  4. m is a 2-D array (a grid of rows and columns). m.shape reports its size as (rows, cols). m.mean(axis=0) averages down each column; axis=1 would average across each row.

What the output means: The comments on each line are the printed results: [10 20 30 40], then 2.5 10, then [3 4], then (2, 2) and [2. 3.]. The 2. with a dot means it is a float.

Try this: Change a[a > 2] to a[a > 5] and predict the result before running — the mask is now all False, so you get an empty array []. Then try m.mean(axis=1) and see the per-row averages instead of per-column.

Two ideas you'll reuse constantlyBoolean masks (a[a > 2]) select elements by a condition — the foundation of filtering in Pandas too. Axis says which direction to reduce: axis=0 collapses rows (per-column result), axis=1 collapses columns (per-row result). Broadcasting and dtypes are covered in depth in A4 — here you just need the array feel.

Pandas: the DataFrame essential

If NumPy gave you a fast column of numbers, the DataFrame is what makes that column usable by a human. A raw NumPy array has no idea that column 3 is "price" and column 0 is "customer name" — it's just numbers with positions. The moment your data has named fields of different types — a text name, an integer age, a float spend — you've outgrown a bare array, and that's exactly the gap the DataFrame fills. This is the structure you'll actually live in for the rest of your data career, so it's worth building a solid mental picture rather than memorizing method names.

Here's the picture: a DataFrame is a spreadsheet you drive with code instead of a mouse. It has named, typed columns down the page and a row index — the labels running down the left margin, like the row numbers in Excel. One single column, pulled out on its own, is a Series: a labeled 1-D array. So a DataFrame is really just a bundle of aligned Series that share one index. Grasping that "column = Series, table = DataFrame, index = the row labels that keep everything aligned" is genuinely 90% of the fluency; almost every Pandas operation is either "do something to a column (Series)" or "do something to the whole table (DataFrame)."

Why the index earns its own mention — it's the piece newcomers overlook and then get bitten by. The index isn't data; it's the row's name tag, and Pandas uses it to line up rows automatically when you combine, join, or assign between tables. Add two Series together and Pandas matches them by index label, not by position — which is a gift when your data lines up and a baffling source of surprise NaNs when it doesn't. The common mistake is treating the index as decorative and being shocked when a reset_index() or a mismatched index changes your results; the fix is to always know what your index is. Keep that in mind as you read the diagram below — the skinny leftmost strip isn't a column, it's the addressing system for everything else.

index 012 name (str) AliceBobCara age (int) 302541 spend (float) 120.59.044.2 a column is a Series · the whole table is a DataFrame Labeled, typed, columnar. Each column has a name and a dtype; the index labels rows. You select columns by name, filter rows by condition, and aggregate by group — all vectorized. This structure is why "load a CSV and understand it" is a handful of lines, not a script.
🗺️ How to read this diagram

This picture is the mental model for a DataFrame — the table you will spend most of your Pandas time in. Read it as a spreadsheet that you drive with code instead of a mouse.

  • The leftmost skinny column is the index (0, 1, 2) — the label for each row. It is not data; it is how you refer to rows.
  • Each of the other blocks is a column with a name and a type (dtype): name holds text (str), age holds whole numbers (int), spend holds decimals (float). Everything in one column is the same type.
  • The caption's key phrase: a single column is a Series; the whole table is a DataFrame. When you write df["age"] you get back one Series.
  • Because data is stored by column (columnar) and typed, Pandas can select, filter, and aggregate whole columns at once — the same vectorized speed you saw in NumPy.

In short: Think of it as a spreadsheet with a row-number margin: pick a column by its header name, pick rows by their index or by a condition. That is 90% of Pandas.

Lab B1.2 · The core loop: load → inspect → clean → transform → aggregate intermediate

Here's the reassuring secret of data work: it looks like a thousand different tasks, but underneath it's almost always the same five moves in the same order. Load the data, look at it, clean it, reshape it, summarize it. Once you've internalized that rhythm, a CSV you've never seen before stops being intimidating — you already know the first five things you're going to do to it. This section is that rhythm, and it's worth treating the order as a checklist you run every single time, not a suggestion.

The step people skip — and pay for — is inspect. The temptation is to jump straight from "load" to "compute the thing I care about," because inspecting feels like busywork. It isn't. Think of it like a cook tasting and smelling ingredients before throwing them in the pot: head() shows you what the rows actually look like, info() reveals the sneaky column that's stored as text when you assumed it was a number, and isna().sum() tells you where the holes are. Ten seconds of looking saves you from the classic disaster where you confidently average a column that was secretly full of nulls, or divide by a "price" that's actually the string "$1,200". The bug isn't loud — it produces a plausible-but-wrong number, which is the most dangerous kind.

The other mindset to carry through every stage is the vectorized one from the last section. Notice that when the loop reaches "transform," the code writes df["total"] = df["price"] * df["qty"] — a whole-column operation — rather than looping row by row. That's deliberate: the same "operate on columns, not rows" habit you learned in NumPy is what keeps this pipeline fast even when the CSV grows from a thousand rows to ten million. And the final move, aggregate, is where the payoff lands — a groupby collapses all that cleaned detail into the one-line answer a human (or your boss) actually asked for. Read the numbered comments in the lab as the stages of that pipeline; each block is one move, and the order is the whole lesson.

LOADread_csv INSPECThead/info/isna CLEANdropna/fillna TRANSFORMnew columns AGGREGATEgroupby ↑ don't skip this one the one-line answer The same five moves, in order, every time. Load → inspect → clean → transform → aggregate. The order is the lesson: inspecting before you compute is what stops you averaging a column that was secretly text or full of nulls.
🗺️ How to read this diagram

Read the boxes left to right — that arrow order is the whole point, a fixed checklist you run on every dataset.

  • LOAD pulls the raw file into a DataFrame; INSPECT looks before you leap with head/info/isna().sum().
  • The amber note over INSPECT flags the step people skip and pay for — it's the taste-test that reveals a wrong dtype or hidden nulls before they poison the result.
  • CLEAN fixes what inspect found, TRANSFORM adds derived columns (vectorized, no loop), and AGGREGATE collapses the detail into the one-line answer a human actually asked for.

In short: Five boxes, one arrow of order — and inspecting before transforming is the move that keeps the final number correct instead of plausible-but-wrong.

Lab B1.2
pipeline.pyimport pandas as pd

# 1 · LOAD
df = pd.read_csv("sales.csv")

# 2 · INSPECT — always look before you leap
df.head()          # first rows
df.info()          # columns, dtypes, non-null counts
df.describe()      # numeric summary stats
df.isna().sum()    # missing values per column

# 3 · CLEAN
df = df.dropna(subset=["price"])           # drop rows missing a price
df["region"] = df["region"].fillna("unknown")   # fill missing categories
df["date"] = pd.to_datetime(df["date"])  # fix a dtype

# 4 · TRANSFORM — vectorized, no loop
df["total"] = df["price"] * df["qty"]

# 5 · AGGREGATE
by_region = df.groupby("region")["total"].sum().sort_values(ascending=False)
print(by_region)
▶ How this works

This is the core loop of almost all data work, in five numbered stages: LOAD, INSPECT, CLEAN, TRANSFORM, AGGREGATE. Learn this order once and every dataset yields to the same moves. The comments (the # 1, # 2 …) mark each stage.

  1. 1 · LOAD — pd.read_csv("sales.csv") reads a comma-separated file straight into a DataFrame called df. One line, and you have a table.
  2. 2 · INSPECT — look before you leap. df.head() shows the first rows, df.info() lists columns and their types, df.describe() gives summary stats, and df.isna().sum() counts missing values per column.
  3. 3 · CLEAN — dropna(subset=["price"]) deletes rows with no price; fillna("unknown") replaces missing regions with a placeholder; pd.to_datetime(...) fixes a column that was text into real dates.
  4. 4 · TRANSFORM — df["total"] = df["price"] * df["qty"] makes a new column by multiplying two columns element-by-element. Vectorized: no loop.
  5. 5 · AGGREGATE — groupby("region")["total"].sum() totals total within each region, and sort_values(ascending=False) orders the result biggest-first.

What the output means: print(by_region) shows one line per region with its summed total, highest first — e.g. the region that sold the most money appears at the top.

Try this: Swap .sum() for .mean() to get the average sale per region instead of the total. The rest of the pipeline does not change — that is the point of the loop.

Inspect first — every timeThe most common mistake is transforming before understanding. head(), info(), describe(), and isna().sum() take ten seconds and tell you the shape, the dtypes, and where the dirt is. Skipping them is how you divide by a column that's secretly a string, or average over rows that are silently null.

Lab B1.3 · Selecting & filtering (and the gotchas) intermediate

This is the operation you'll perform more than any other, so it's worth getting right in your fingers. "Give me the EU rows where price is over 100" is the bread and butter of analysis, and Pandas gives you a small, consistent set of tools to say exactly that. The good news is there are only a handful of patterns to learn. The catch is that a couple of them have traps sitting right next to the correct version — traps that don't crash, they just quietly give you the wrong answer — so this section spends as much time on the gotchas as on the happy path.

Start with the cleanest mental split. Selecting columns uses square brackets with names: one name gives you a Series (df["price"]), a list of names gives you a smaller DataFrame (df[["name","price"]]) — the double brackets are just "a list, inside the indexing brackets," not magic. Selecting rows uses a boolean mask, the exact same idea you met in NumPy: df["price"] > 100 builds a column of True/False, and df[that] keeps the True rows. If masks felt abstract in the NumPy section, this is where they earn their keep.

Two traps deserve a warning before you write a single line. First, when you combine conditions you cannot use Python's plain and/or — you must use & and |, and you must wrap each condition in parentheses: df[(df["price"] > 100) & (df["region"] == "EU")]. Why? Because and tries to boil a whole array down to a single True/False and throws an error, while & combines the masks element by element the way you meant. Second — and this is the famous one — never assign through a filter in two steps. Writing df[df.x > 0]["y"] = 1 looks reasonable but silently edits a throwaway copy and leaves your real DataFrame untouched (Pandas even prints a "SettingWithCopy" warning you'll learn to respect). The fix is to say it in one breath with .loc: df.loc[df.x > 0, "y"] = 1.

That last point leads to the final distinction: .loc versus .iloc. Think of it as "by name" versus "by seat number." .loc selects by labels or conditions — index labels and column names, or a boolean mask — which is what you want the vast majority of the time. .iloc selects by raw integer position, like slicing a list, ignoring whatever the labels say. Reach for .iloc only when position genuinely is what you mean (the first five rows, regardless of their labels); reach for .loc for everything else. Keep that pairing straight and the rest of the lab is mechanical.

price column 120 80 150 40 > 100 → mask True False True False df[mask] → kept 120 150 only the True rows False rows are dropped, not deleted from df Filtering is masking. A condition like df["price"] > 100 first builds a True/False array the same length as the column; df[mask] then keeps only the rows where the mask is True — the exact NumPy idea, now on DataFrame rows.
🗺️ How to read this diagram

Read it left to right in three columns: the data, the mask it produces, and the rows that survive.

  • The left column is the raw price values. Applying the condition > 100 produces the middle column — a True/False array the same length as the data.
  • Green cells are True (price over 100), red cells are False. This boolean array is the "mask" — it's just a column of yes/no, one per row.
  • The right column is df[mask]: only the rows lined up with a green True are kept. The False rows are filtered out of the view, not deleted from the original df.

In short: Condition → True/False mask → keep the True rows. It's the same boolean-mask move from NumPy, and it's the engine behind every row filter you'll write in Pandas.

Lab B1.3
select.pydf["price"]                 # one column → Series
df[["name", "price"]]      # several columns → DataFrame

# boolean-mask filtering (the NumPy idea, on rows)
df[df["price"] > 100]                          # rows where price > 100
df[(df["price"] > 100) & (df["region"] == "EU")]  # combine with & / | and parens

# label- vs position-based access
df.loc[df["region"] == "EU", "total"]   # .loc = labels/conditions
df.iloc[0:5]                             # .iloc = integer positions
▶ How this works

Getting the exact rows and columns you want is the operation you will do most. This lab collects the correct patterns — plus the classic traps that catch every beginner.

  1. df["price"] (single name, single brackets) returns one column as a Series. df[["name", "price"]] (a list of names, double brackets) returns a smaller DataFrame with just those columns.
  2. Filtering rows uses a boolean mask, exactly like NumPy: df[df["price"] > 100] keeps rows where price is over 100.
  3. To combine conditions you must use & (and) / | (or) and wrap each condition in parentheses: df[(df["price"] > 100) & (df["region"] == "EU")]. Plain and/or will error here.
  4. Label vs position: .loc selects by labels or a condition (df.loc[df["region"] == "EU", "total"] = the total column for EU rows), while .iloc selects by integer position (df.iloc[0:5] = the first five rows, regardless of their labels).

Try this: The biggest trap is chained assignment: df[df.x > 0]["y"] = 1 changes a throwaway copy and silently does nothing. Always write it as one .loc: df.loc[df.x > 0, "y"] = 1. This is the "SettingWithCopy" warning the warning box below is about.

GotchaFix
and/or in a mask → errorUse &/| and wrap each condition in parentheses
Chained assignment (df[m]["col"] = ...) silently failsAssign with df.loc[mask, "col"] = ...
Confusing .loc and .iloc.loc = labels/conditions; .iloc = integer positions
Looping to compute a columnVectorize: df["c"] = df["a"] * df["b"]
The SettingWithCopy trapWriting df[df.x > 0]["y"] = 1 modifies a temporary copy, not df — Pandas even warns you. Always assign through a single .loc: df.loc[df.x > 0, "y"] = 1. This bug is subtle because it fails silently — your change just doesn't stick.

Group-by: the analytical workhorse intermediate

Almost every real question anyone asks of data has the same secret shape: "per X, what's the Y?" Revenue per region. Average latency per endpoint. Error count per customer. Ticket volume per day. Once you notice that pattern, you realize a single Pandas move answers all of them — and that move is groupby. This is the tool that turns a raw table into an insight, which is why it earns the "analytical workhorse" title. Master this one operation and you can answer most of what a business will ever ask you.

The formal name is split-apply-combine, and it's worth saying slowly because the three words are the algorithm. Split: chop the rows into groups that share a key — all the EU rows here, all the US rows there. Apply: run the same summary calculation on each pile independently — sum the totals in EU, sum the totals in US. Combine: stack those per-group answers into one small result table, one row per group. Picture a mail sorter dumping a bin of letters into labeled pigeonholes (split), weighing each pigeonhole (apply), and writing the weights on a single sheet (combine). The diagram below draws exactly that flow left to right.

The beautiful part is how little the code changes as the question changes. df.groupby("region")["total"].sum() reads almost like the English sentence "for each region, sum the total." Want the average instead of the total? Swap .sum() for .mean(). Want how many orders per region? .count(). Want several statistics at once? .agg([...]). The shape of the operation is fixed; you're just turning two dials — which column to group by, and which summary to apply — to ask an entirely new business question. If you've met SQL, this is precisely GROUP BY (T5); the mental model transfers straight across, and knowing both lets you pick whichever tool the data already lives in.

all rows EUUSAPAC sum(total)per group result Split → apply → combine. df.groupby("region")["total"].sum() splits rows by region, sums total in each, and combines into one Series indexed by region. Swap .sum() for .mean(), .count(), or .agg([...]) for multiple stats at once. This one pattern answers "per X, what's the Y?" for any X and Y.
🗺️ How to read this diagram

This diagram explains group-by, the single move that answers most "per X, what's the Y?" questions. Its formal name is split → apply → combine, and the picture walks left to right through those three steps.

  • Split (left): the box all rows fans out into groups — here one group per region: EU, US, APAC. Rows with the same region land together.
  • Apply (middle): the same aggregate, sum(total), runs on each group separately — one total per region.
  • Combine (right): the per-group answers are stitched into one small result — a Series indexed by region.
  • In code this whole flow is one line: df.groupby("region")["total"].sum(). Swap .sum() for .mean(), .count(), or .agg([...]) to ask a different question of the same groups.

In short: Read it as a sentence: "for each region, sum the total." Change the group column or the aggregate and you have answered a brand-new business question with the same shape.

Lab B1.4 · From messy data to LLM-ready records advanced

This is the section that explains why a Pandas chapter is sitting in an AI-engineering course at all. A language model is only as good as what you feed it, and models don't want a DataFrame — they want clean, well-shaped text or JSON, one tidy record at a time. Garbage in the table becomes garbage in the prompt, which becomes a confidently wrong answer out of the model. So the humble cleaning and reshaping you've been practicing turns out to be the load-bearing step of the whole pipeline: Pandas is the bridge between the raw, messy world and the pristine input an LLM can actually reason over.

The pattern to hold in your head is a round trip: in and out. On the way in, you clean a column of free text — drop the empty rows, strip the whitespace, filter out the junk — and then convert the surviving rows into a list of small dictionaries with to_dict(orient="records"). That list-of-dicts shape ([{"id": 1, "review": "..."}, ...]) is the natural hand-off point, because it's trivially JSON-serializable and trivially loopable — you iterate over it, sending each record to the model. On the way out, the model's structured answers get packed straight back into a new DataFrame and joined onto your cleaned rows, so predictions sit right beside the data that produced them, ready to aggregate or chart. Think of Pandas as the airport on both ends of the flight: it handles departures and arrivals.

One technique in the lab is worth calling out because it's what senior Pandas code looks like: the method chain. Instead of reassigning df over and over, you thread the operations together — df.dropna(...).assign(...).loc[...] — so the data flows through each step like a pipeline, and the code reads top to bottom as the sequence of transformations you're applying. It's cleaner to read and it sidesteps the SettingWithCopy trap from the last section entirely, because you're never assigning back into a filtered slice. The common mistake here is the opposite extreme: shaping data after it's already gone to the model, or handing the model rows you never cleaned — an empty or malformed record wastes a call and pollutes your results. Clean first, shape second, call the model last. Read the lab as that in-and-out bridge, and notice it's the exact loop the Data Analyst Agent project (P5) automates.

the round trip: DataFrame ⇄ LLM messy dfraw table clean recordslist[dict] LLM step resultsnew df merged departures arrivals Pandas is the airport on both ends. On the way in, a messy table is cleaned and shaped into a list of dicts the model can consume; on the way out, the model's structured answers are packed into a DataFrame and joined back beside the rows that produced them.
🗺️ How to read this diagram

Follow the arrows left to right — it's one continuous round trip from table, out to the model, and back to a table.

  • The messy df is cleaned and shaped into records — a list[dict] via to_dict(orient="records"). That's the "departures" side: trivially JSON-serializable and loopable.
  • Each record flows through the LLM step (a classifier or prompt) and comes back as structured answers, collected into a results DataFrame — the "arrivals" side.
  • The last arrow joins results onto the cleaned rows into merged, so each prediction sits right beside the data that produced it — ready to aggregate or chart.

In short: Clean → records → model → results → merge. Pandas handles both departures and arrivals, and it's the exact loop the Data Analyst Agent (P5) automates.

Lab B1.4

Illustrative fragment — defines demo values / files are needed before this runs standalone.

to_llm.py# clean + shape a table, then hand rows to an LLM step
clean = (df
    .dropna(subset=["review"])
    .assign(review=lambda d: d["review"].str.strip())
    .loc[lambda d: d["review"].str.len() > 0]
)

records = clean[["id", "review"]].to_dict(orient="records")
# records = [{"id": 1, "review": "..."}, ...] — ready to batch through a classifier (Ch 2)

# and the reverse: model results back into a DataFrame for analysis / a chart
results = pd.DataFrame([classify(r["review"]) for r in records])
merged = clean.reset_index(drop=True).join(results)
▶ How this works

This ties Pandas to the rest of the course: an LLM (or a chart) needs clean, structured input. Here you clean a text column, turn the rows into a list of little dictionaries a prompt can consume, then collect the model's answers back into a table. In and out — both ways.

  1. The clean = (df.dropna(...).assign(...).loc[...]) block is a method chain: each step feeds the next. dropna(subset=["review"]) drops rows with no review, .assign(review=lambda d: d["review"].str.strip()) trims whitespace, and the .loc[lambda d: d["review"].str.len() > 0] keeps only non-empty reviews.
  2. to_dict(orient="records") converts the two chosen columns into a list of dictionaries — one {"id": ..., "review": ...} per row. That shape is exactly what you loop over to send to a classifier (Chapter 2).
  3. pd.DataFrame([classify(r["review"]) for r in records]) runs each review through the model and packs the structured results into a new DataFrame.
  4. clean.reset_index(drop=True).join(results) glues the model's answers back onto the cleaned rows, side by side, so you can analyze or chart them.

What the output means: Nothing prints here — this is a shaping fragment. The payoff is merged: your original clean rows with the model's predicted labels attached, ready to groupby and count or feed to a chart.

Try this: This exact in→out loop is the data half of the Data Analyst Agent project (P5). Try printing records[:2] to see the little dictionaries before they go to the model — that list is the hand-off point between Pandas and the LLM.

Pandas is the on-ramp and the off-ramp for LLM dataOn the way in, Pandas cleans and shapes raw data into the tidy records a prompt or batch job consumes (Chapter 2's classifier, or the Data Analyst project). On the way out, it collects the model's structured results back into a table you can aggregate and chart (B2). The Data Analyst Agent project (P5) is exactly this loop wrapped in an agent.

Common pitfalls advanced

Here's the honest truth about Pandas bugs: the dangerous ones don't crash. A syntax error is a gift — the program stops and points at the line. The pitfalls that actually hurt you in production are the silent ones: the code runs, prints a number, and everyone believes it, but the number is wrong. This table is a field guide to the handful of quiet failures that catch nearly everyone at least once, so you can recognize the smell before it costs you a bad decision.

Read down the list and you'll notice they all share one root cause — a mismatch between what you think Pandas is doing and what it's actually doing. You think a loop is fine; it's just slow (and gets slower as data grows). You think you cleaned the data; a column is secretly still a string. You think your assignment stuck; it edited a copy. You think you averaged the real values; you averaged around a pile of hidden NaNs. The defense against every one of them is the same discipline this whole chapter has been drilling: inspect before you trust, vectorize instead of loop, assign through .loc, and know your missing data on purpose. Treat the table as a checklist to run in your head whenever a result looks even slightly off — the fix is almost always one of these six.

PitfallFix
Looping over rows to compute valuesVectorize — operate on whole columns
Transforming before inspectinghead/info/describe/isna().sum() first
Chained assignment (SettingWithCopy)Assign through a single .loc[mask, col]
and/or in boolean masksUse &/| with parentheses
Wrong dtype (numbers as strings)Check info(); cast with astype/to_datetime/to_numeric
Silently averaging over NaNsKnow your missing data; decide drop vs fill deliberately

Exercises advanced

Exercise B1.1 — Vectorize a loop

Context: The fastest way to feel why arrays beat loops is to time both on the same data. Once you see the gap on a few thousand rows, vectorization stops being advice and becomes a habit.

Your task: Take a list-of-dicts dataset and a Python for loop that computes a derived value per row, rewrite it as a single vectorized Pandas column assignment, and time both on a few thousand rows.

Requirements:

  • Start from the row-by-row loop version and reproduce its result
  • Replace it with one vectorized column assignment on a DataFrame
  • Time both approaches over a few thousand rows
  • Confirm the two produce identical values
  • Note the speed difference you observed

💡 Hint: A column-to-column arithmetic expression on a DataFrame is the vectorized equivalent of the per-row loop — no apply or iteration needed.

Exercise B1.2 — The full loop on a real CSV

Context: The real skill isn't any one step — it's running load → inspect → clean → transform → aggregate end to end and letting the inspect step surprise you before you trust an answer.

Your task: Grab any CSV and run the full loop — load, inspect, clean, transform, aggregate — ending in a groupby that answers one real question, and write down what the inspection revealed.

Requirements:

  • Load a real CSV and inspect it with something like info() and isna().sum()
  • Fix at least one issue the inspection surfaced (wrong dtype, nulls, or a typo category variant)
  • End with a groupby answering a concrete question (e.g. total spend per region)
  • Clean before aggregating so the answer is correct, not plausible-but-wrong
  • Record what isna().sum() showed and how you handled it

💡 Hint: The inspect step almost always exposes something — a wrong dtype or a category like "EU" vs "eu" — and catching it before the aggregate is the whole point.

Show what to look for

The inspect step almost always surprises you — a column that's the wrong dtype, unexpected nulls, or a category with a typo variant ("EU" vs "eu"). Handling those before aggregating is the difference between a correct answer and a plausible-but-wrong one.

Exercise B1.3 — Table → LLM → table

Context: This is the data half of the Data Analyst project: clean text, run it through a classifier, and fold the predictions back into the table so you can count them.

Your task: Clean a free-text column, turn the rows into records, run them through the Chapter 2 classifier, merge the predicted labels back into the DataFrame, and group by the label to count each class.

Requirements:

  • Clean the free-text column (reviews or tickets) before classifying
  • Convert the rows into records the classifier can consume
  • Merge the returned labels back onto the original DataFrame
  • groupby the predicted label to get per-class counts
  • End with a table showing the count of each predicted class

💡 Hint: Reuse the DataFrame-to-records bridge from the ladder, classify the records, then join the labels back on a stable row key before the final group-by.

🪜 Practice ladder beginner → industry

Six graded exercises, easy to real-world. Try each before opening its solution.

Exercise 1 · Vectorize a loop with NumPyBeginner

Context: Row-at-a-time Python loops are the first thing that buckles when a dataset grows from hundreds to millions of rows. NumPy pushes the same arithmetic down into optimized C, so the code reads closer to the math and runs far faster.

Your task: Convert a list of USD prices to EUR at a fixed rate using a NumPy array and a single vectorized multiply, then show the plain Python loop version for contrast.

Requirements:

  • Build a np.array of the prices and multiply by the scalar rate — no for loop over elements
  • The result is element-wise: every price is converted in one operation
  • Round the output for display (e.g. np.round(..., 2))
  • Also write the equivalent list-comprehension loop so the difference is visible
  • Confirm both produce the same numbers

💡 Hint: The whole conversion is one expression: array times scalar broadcasts across every element at once.

Show solution

NumPy applies the multiply element-wise across the whole array at once:

import numpy as np

usd = np.array([10.0, 25.5, 100.0, 3.75])
rate = 0.92                      # USD -> EUR
eur = usd * rate                 # vectorized: no Python loop
print(np.round(eur, 2))          # [ 9.2  23.46 92.   3.45]

# the loop version does the same thing, slower and noisier:
eur_loop = [p * rate for p in usd]
print([round(x, 2) for x in eur_loop])

Vectorized ops run in optimized C under the hood, so they’re faster and read closer to the math.

Exercise 2 · Clean a messy DataFrame columnIntermediate

Context: Real-world spreadsheet columns arrive as strings like "$1,200" with stray symbols and gaps. Turning that into trustworthy numbers is the unglamorous step that decides whether every downstream total is right or quietly wrong.

Your task: Given a DataFrame amount column of dollar-formatted strings with some missing values, parse it into a clean numeric column, treat missing as 0, and report the total.

Requirements:

  • Strip the $ and comma characters before converting
  • Coerce to numbers with errors="coerce" so bad/None values become NaN instead of raising
  • Fill the resulting NaN with 0, then cast to an integer column
  • Store the cleaned values in a new column (leave the raw one intact)
  • Print the DataFrame and the summed total

💡 Hint: Chain the steps on the string column: replace, then pd.to_numeric, then fillna, then astype — coercion is what keeps unparseable values from blowing up the pipeline.

Show solution
import pandas as pd
import numpy as np

df = pd.DataFrame({"amount": ["$1,200", "$350", None, "$45"]})

# strip $ and commas, coerce to number, fill missing, cast to int
df["amount_num"] = (
    df["amount"]
      .str.replace(r"[$,]", "", regex=True)   # "$1,200" -> "1200"
      .pipe(pd.to_numeric, errors="coerce")   # bad/None -> NaN
      .fillna(0)
      .astype(int)
)
print(df)
print("total:", df["amount_num"].sum())       # total: 1595

errors="coerce" turns unparseable values into NaN instead of raising, then fillna(0) makes the missing explicit — a safe cleaning pattern.

Exercise 3 · Group-by: the analytical workhorseAdvanced

Context: Nearly every analytical question — spend per region, latency per endpoint — is a group-by underneath. Split-apply-combine is the single most reused pattern in data work, and naming your aggregates keeps the output readable.

Your task: Given order rows with a region and a revenue column, compute the total and mean revenue (and order count) per region, sorted by total descending, and state the split-apply-combine idea in one line.

Requirements:

  • Group by region and aggregate revenue in a single pass
  • Produce named outputs — total, mean, and count — as clean column names
  • Sort the summary by total revenue, highest region first
  • One-line explanation: split rows into groups, apply an aggregation, combine into one table
  • Print the resulting summary table

💡 Hint: groupby("region")["revenue"].agg(...) with keyword arguments gives you named columns in one call; sort the result afterward.

Show solution

Split-apply-combine: split rows into groups (by region), apply an aggregation to each, combine into one result table.

import pandas as pd

df = pd.DataFrame({
    "region":  ["US", "EU", "US", "APAC", "EU", "US"],
    "revenue": [100, 80, 120, 60, 40, 30],
})

summary = (
    df.groupby("region")["revenue"]
      .agg(total="sum", mean="mean", orders="count")
      .sort_values("total", ascending=False)
)
print(summary)
# region  total  mean  orders
# US        250  83.3       3
# EU        120  60.0       2
# APAC       60  60.0       1

.agg with named outputs gives clean column names in one pass.

Exercise 4 · Join two tables and handle the missesExpert

Context: Joining a fact table to a lookup table is routine — until a key has no match and a silent NaN hides a data-quality problem. A left join plus an explicit orphan flag turns that silent gap into something you can see and act on.

Your task: Left-join a customers name table onto an orders table by customer_id so no order is dropped, and flag any order whose customer is missing rather than letting it vanish.

Requirements:

  • Use a left join keyed on customer_id so every order survives
  • Fill missing names with a sentinel like <UNKNOWN>
  • Add a boolean orphan column that is true where the name was missing
  • Assert the number of orphaned orders matches expectation
  • Print the merged table so the unmatched id is visible

💡 Hint: A left join keeps all left rows and inserts NaN for unmatched right columns; convert that NaN into a named sentinel and a boolean flag.

Show solution
import pandas as pd

orders = pd.DataFrame({"order_id": [1, 2, 3], "customer_id": [10, 11, 99]})
customers = pd.DataFrame({"customer_id": [10, 11], "name": ["Ana", "Ben"]})

merged = orders.merge(customers, on="customer_id", how="left")   # keep all orders
merged["name"] = merged["name"].fillna("<UNKNOWN>")
merged["orphan"] = merged["name"] == "<UNKNOWN>"
print(merged)
#    order_id  customer_id      name  orphan
# 0         1           10       Ana   False
# 1         2           11       Ben   False
# 2         3           99  <UNKNOWN>    True
assert merged["orphan"].sum() == 1

A left join keeps every order; the orphan flag surfaces the unmatched customer_id (99) instead of letting a NaN hide a data-quality problem.

Exercise 5 · Turn a DataFrame into LLM-ready recordsProfessional

Context: An LLM or tool-calling step wants clean JSON records, not a DataFrame. Selecting, renaming to snake_case, and dropping rows that are missing a required field is the bridge between tabular data and a well-grounded prompt.

Your task: Turn a DataFrame with human-friendly column names into a list of JSON-serializable dicts: rename columns to snake_case, drop rows missing the required field, and emit the records.

Requirements:

  • Rename the display columns (e.g. Ticket ID) to snake_case keys
  • Drop rows where the required field (e.g. subject) is missing
  • Convert to a list of dicts with to_dict(orient="records")
  • Serialize the records to JSON to prove they are clean
  • Assert every emitted record actually has a non-empty required field

💡 Hint: to_dict(orient="records") is the tabular-to-JSON hop; do the dropna(subset=[...]) first so no ungrounded row reaches the model.

Show solution
import pandas as pd
import json

df = pd.DataFrame({
    "Ticket ID": [1, 2, 3],
    "Subject":   ["Login fails", "Refund?", None],
    "Priority":  ["high", "low", "high"],
})

clean = (
    df.rename(columns={"Ticket ID": "id", "Subject": "subject", "Priority": "priority"})
      .dropna(subset=["subject"])          # subject is required for the LLM
)
records = clean.to_dict(orient="records")  # list[dict], one per row
print(json.dumps(records, indent=2))
# [
#   {"id": 1, "subject": "Login fails", "priority": "high"},
#   {"id": 2, "subject": "Refund?",     "priority": "low"}
# ]
assert all("subject" in r and r["subject"] for r in records)

to_dict(orient="records") is the bridge from tabular data to the JSON most LLM/tool calls expect; dropping rows missing the required field prevents ungrounded/empty prompts downstream.

Exercise 6 · A reusable, tested cleaning pipelineIndustry scenario

Context: In a data pipeline the cleaning step is a function other stages depend on, so it needs a contract, defensive parsing, and a test that fails loudly when the shape breaks. This is how a one-off notebook cell becomes a shippable pipeline stage.

Your task: Wrap the ingest→clean→aggregate flow into a function that takes raw order dicts and returns per-region revenue totals as a dict, skipping any malformed rows.

Requirements:

  • The function has a docstring stating its input contract and output
  • Coerce revenue defensively (values may be strings or None)
  • Drop rows missing region or a valid revenue before aggregating
  • Handle the empty-input case by returning an empty dict, not crashing
  • Return {region: total} and include an assert that acts as a test

💡 Hint: Build a DataFrame from the rows, coerce and drop the bad ones, then groupby("region")["revenue"].sum().to_dict() — the assert doubles as your regression check.

Show solution
import pandas as pd

def region_totals(rows):
    # rows: list of dicts with 'region' and 'revenue' (revenue may be str/None).
    # Returns {region: total_revenue}, skipping rows we can't clean.
    df = pd.DataFrame(rows)
    if df.empty:
        return {}
    df["revenue"] = pd.to_numeric(df.get("revenue"), errors="coerce")
    df = df.dropna(subset=["region", "revenue"])          # drop malformed
    out = df.groupby("region")["revenue"].sum().astype(float)
    return out.to_dict()

data = [
    {"region": "US", "revenue": "100"},
    {"region": "US", "revenue": 50},
    {"region": "EU", "revenue": None},     # dropped
    {"region": None, "revenue": 10},       # dropped
    {"region": "EU", "revenue": "80"},
]
result = region_totals(data)
assert result == {"US": 150.0, "EU": 80.0}, result
print(result)   # {'US': 150.0, 'EU': 80.0}

Docstring (contract), defensive coercion, dropping malformed rows, and an assert that doubles as a test — the shape a pipeline step ships in.

✓ Checkpoint — you can move on when you can…

  • Say why arrays/DataFrames beat lists/dicts for data work.
  • Rewrite a row loop as a vectorized column operation.
  • Run load → inspect → clean → transform → aggregate on a real CSV.
  • Select/filter correctly with masks and .loc/.iloc, avoiding SettingWithCopy.
  • Shape a table into LLM-ready records and merge results back.
🏗️ Toward the capstone & projectsThe Data Analyst Agent (P5) turns plain-English questions into Pandas/SQL over a dataset — this chapter is the data engine underneath it. And any capstone metric ("deploy failure rate this week", "cost per incident") is a groupby away once the data's in a DataFrame. Clean data in, structured records out — then B2 charts it and B3/B4 serve it. See the Data Analyst Agent project →

Knowledge check check yourself

✓ Knowledge check

What is the "vectorized mindset" in Pandas/NumPy, and why is df["price"] * 1.1 better than a for loop over rows?

Show answer
It means operating on a whole column at once instead of looping over rows; the column operation is shorter and often 10–100× faster because the work runs in compiled C code rather than a Python loop.
✓ Knowledge check

What is the "SettingWithCopy" trap, and how do you correctly assign to a filtered subset?

Show answer
Chained assignment like df[df.x > 0]["y"] = 1 modifies a throwaway copy and silently fails; assign through a single .loc instead: df.loc[df.x > 0, "y"] = 1.
© 2026 studybydoing.in · AI Engineering: Zero to Production · All rights reserved. · About · Privacy Policy · Terms · Contact
Educational content, provided as-is and without warranty. Code samples are examples — review, test, and adapt them before using in production. See the Terms of Use & Disclaimer. Use at your own risk.
© studybydoing.in