Chapter 2 · Learning Statistics with Python

DataFrames

Inspect tables, investigate and repair defects, validate joins, build grouped features, then explain the result.

Prof. Xuhu Wan

ISOM, HKUST Business School · 2026 Edition

What This Chapter Builds

  • Inspect a table and explain its rows, columns, index and axes.
  • Diagnose data defects, justify repairs and measure their scope.
  • Group, join and reshape without losing observation identity.
  • Build and validate grouped features from an inspected table.

Chapter 2 combines the column foundations into complete tables.

Roadmap

Section Concept Tool
2.1 DataFrame Foundations Inspect, update and summarise a table pd.read_csv, .agg
2.2 Data Quality: Audit, Repair and Selection Discover defects, repair with evidence, then select rows .isna, .drop_duplicates
2.3 Grouped Tables, Joins and Reshaping Group observations, validate joins and choose a representation .groupby, .merge, .pivot
2.4 Feature Pipelines and Research Applications Build readable transformations and validate grouped features .assign, .pipe, grouped windows
2.5 Interactive Visualisation Map columns to charts, traces and subplots Plotly Express, Graph Objects, make_subplots

DataFrame Foundations

Part Question
2.1.1 Table structure and axes What does each row and column represent?
2.1.2 Ordering, column updates and export How does the table change?
2.1.3 Alignment and table summaries Which index values, column names and axis does the calculation use?

Table structure and axes

Question: What does each row and column represent?

Result: A DataFrame with explicit columns that identify observations and a checked output shape.

A table extends the Series to two labelled axes. Establish its observation unit first.

Does read_csv know that dates are dates?

pd.read_csv(url) returns a DataFrame. usecols=[...] chooses file columns, index_col= chooses index values, and parse_dates=True parses the selected date index.

Selection Meaning
df["AEP"] One named column as a Series
df.loc[index_value, "AEP"] One labelled row and one named column
df.loc[rows, columns] Label-based selection on both axes
df.iloc[row_position, column_position] Position-based selection on both axes

In .loc[:, columns], : means all rows. A date stored as text is not yet a DatetimeIndex.

PJM is the grid operator for the eastern United States; each column is a utility’s hourly load in megawatts. We load five utilities with parse_dates=True and index_col="Datetime".

parse_dates turns the text column into datetime64 and index_col makes it the index, so a plain string indexes straight into the DatetimeIndex: you get the single AEP number for that hour. No strptime, no set_index afterwards — and .loc[‘2017’] selects a whole year the same way.

Before running: After loading, what does raw.loc["2017-07-04 15:00", "AEP"] return?

Construct a frame from named columns

An empty DataFrame can acquire named columns. A dictionary supplies the same columns in one call; each list must have the same length.

1. Start with two row positions

2. Add one column at a time

3. Build the same table from a dictionary

Both methods give rows 0, 1 and columns ticker, close. .equals(...) returns True when the tables match.

Choose and restore the row index

One row represents one illustrative security. set_index("ticker") makes its identifier the row index; verify_integrity=True rejects repeated index values.

1. Move ticker into the index

2. Select by an index value

.index.name reports the index name; .loc selects an index value and a column.

3. Restore ticker as a column

reset_index() preserves the identifiers. The restored table matches the original (True).

Illustrative tickers: move the identifier, keep each value attached.

Which axis is which?

The DataFrame’s row index is axis 0 and its column names are axis 1. df.axes contains these two Index objects in that order.

For reductions such as mean, sum and quantile:

Argument Direction Output
axis=0 Down the rows One result per column
axis=1 Across the columns One result per row

State the output length and units first. Summing hourly power readings and summing utilities at one hour represent different quantities.

df.axes is a list of two Index objects. Read the index values and column names in the printed table and match them to each axis.

axes[0] is the row index (the DatetimeIndex), axes[1] is the column Index. axis=0 means ‘down the rows’, axis=1 means ‘across the columns’ — the same numbering you pass to sum(), drop() and sort_index().

Before running: What does df.axes[1] return?

Sum down or sum across?

df.sum(axis=0) and df.sum(axis=1) are both legal. Compare the length and index of each result.

axis=1 collapses the columns, giving 8 760 combined hourly loads in MW. axis=0 collapses the rows, giving 5 sums of recorded MW readings. Computing energy in MWh additionally requires observation intervals and complete coverage.

Before running: How many numbers does df.sum(axis=1) return?

Three observed 2017 hours; select the axis and statistic.

Your turn: interquartile range of each row

Row-wise statistics use the same axis=1 switch. quantile accepts it too.

Compute the interquartile range of each row (across the five utilities) into iqr_row. Hint: df.quantile(q, axis=1) gives one quantile per row.

Ordering, column updates and export

Question: How does the table change?

Result: A checked table update and a CSV round-trip that preserves identifiers.

With the table structure known, inspect how ordering and updates affect it.

Sorting rows by a column — what happens to the index?

sort_values(by="AEP", ascending=False) puts the peak-load hours first.

The index values travel with their rows: the first row of the sorted frame is the timestamp of the peak. The frame is no longer chronological, which is why sort_index() exists to put it back.

Before running: After df.sort_values(by="AEP", ascending=False), what is the DatetimeIndex?

Observed hours: reorder rows without detaching timestamps from loads.

Your turn: sort the columns by the first row

sort_values also takes axis=1: then by= names a index value and the columns are reordered.

Reorder the columns of df so that the values in the first row are ascending, storing the result in by_first. Hint: df.sort_values(by=df.index[0], axis=1).

Making changes: add, derive, drop

New columns are assignments. drop returns a new frame — compare the original with the returned table.

drop() returns a new DataFrame and leaves df untouched unless you pass inplace=True or re-assign df = df.drop(…). Forgetting this is the most common ‘my column is still there’ bug.

Before running: After df2 = df.drop(["Month", "Season"], axis=1), does df still contain Month?

Rename columns and replace values

rename(columns=...) changes column names. replace(...) changes matching values inside a column. Both return a new result.

Change column names

Change category values

Export identifiers explicitly and check the CSV round trip

CSV stores text and values, without pandas dtype metadata. Save identifiers once; explicitly restore dates and dtypes when loading.

Illustrative prices: CSV preserves values, but carries no dtype metadata.

The file is saved in the current Python working directory. Reset a meaningful index first if its values must be saved as columns.

Alignment and table summaries

Question: Which index values, column names and axis does the calculation use?

Result: A labelled result with the aggregation axis explained.

Table calculations require both index/column matching and an explicit reduction axis.

Index alignment: compare df1, df2 and their sum

df1 is rows 0–2 × columns 0–2. df2 is rows 2–3 × columns 0–3. Their index values overlap in exactly one row.

Arithmetic aligns on the union of index values and the union of column names: 4 rows × 4 columns. Any cell whose index value or column name is missing from either operand becomes NaN, so only the shared row (02:00) and the shared three columns carry numbers.

Before running: What is the shape of df1 + df2, and how many cells are non-NaN?

Observed excerpts: only cells with two labelled partners can add.

agg: several statistics in one call

df.agg(["min", "max"]) calculates named summaries. A dictionary such as {"AEP": ["mean", "std"]} selects different summaries for different columns.

df.apply(function, axis=0) passes each column Series to a function. With axis=1, it passes each row Series. The function’s return value determines the combined result.

Prefer built-in vectorised methods for ordinary arithmetic. Use apply when a supplied function is necessary, and state what that function receives and returns before running it.

agg takes a list of function names, or a dict mapping columns to their own lists. Compare the requested functions with the row index values and column names of the result.

The result has one row per distinct function name (sum, count, mean = 3 rows) and one column per named input column (2). Cells that were not requested — mean of AEP, sum of DEOK — are NaN.

Before running: What does num.agg({"AEP": ["sum","count"], "DEOK": ["mean","count"]}) return?

Three observed hours: distinguish unrequested statistics from missing source values.

apply: your own function, row by row or column by column

Build \(Y = 10 + 0.8X + \text{noise}\) with a function that receives one row at a time. Read the new Y column beside X and Noise, then inspect the column means.

With axis=1, each row is a Series whose index is the column names. row.loc["X"] selects its X value.

Before running: X runs from 0 to 99. Compare X, Noise and the new Y column, then explain why the mean of X is 49.5.

Original seeded synthetic example: trace the function's input and return.

Use vectorised arithmetic for 10 + 0.8*newdf.X + newdf.Noise; use apply for a custom function that needs a row or column. Section 2.4 connects simple column calculations into a readable pipeline.

Your turn: row range two ways

Compute the range (max − min) of each row of num twice: rng_agg via .agg(["max","min"], axis=1) and a subtraction, and rng_apply via .apply with a lambda. The two must agree.

Data Quality: Audit, Repair and Selection

Part Question
2.2.1 Missingness and duplicate observations What is missing, repeated or ambiguous?
2.2.2 Rows, columns and time subsets Which observations belong in the analysis?

Missingness and duplicate observations

Question: What is missing, repeated or ambiguous?

Result: A defect report before any rows or values are changed.

An inspectable table can still contain missing or repeated observations. Measure them first.

Audit first: count NaN per column

df.isna() gives one Boolean per cell. .sum(axis=0) counts missing cells per column; .mean(axis=0) gives each column’s missing fraction.

dropna(subset=[...]) tests only named required columns. fillna supplies a replacement; ffill carries past values and bfill uses later values.

Choose a policy from the variable’s meaning, source and decision clock. Fit statistical replacements on training rows. Record row counts and missingness before and after the operation; a successful run does not prove a sound repair.

AEP_hourly.csv contains 178 258 hours, 12 utilities, lots of NaN. We keep four utilities and 2011–2012.

isna() gives a boolean frame; the first sum() counts True per column, the second sum() adds those up; dividing by .size (rows × columns) gives the fraction of missing cells. The per-column counts are the number you act on — one column here is essentially empty.

Before running: What does load.isna().sum().sum() / load.size measure?

Observed 2011–2012 coverage: compare each column's missing count and fraction.

Fill forward, fill backward, fill a constant

Inspect AEP’s missing 04:00 observation on 6 December 2012. Trace the timestamp supplying each replacement.

bfill copies a later observation into the missing hour, using information unavailable at that decision time. ffill uses past observations. Statistical replacements must be fitted on training data; a constant such as zero still needs a valid meaning.

Before running: Which fill is look-ahead bias when the rows are ordered in time?

Observed AEP gap: distinguish past information, future information and an assumption.

Drop surgically, or fill and pay the price

dropna() removes any row with any NaN. Compare the retained tables and count their rows.

NI is present in exactly one hour of 17 540 — and in that hour COMED and FE are missing too, so a plain dropna() keeps nothing. subset=[‘FE’] drops only the rows missing the column you actually model, keeping 13 916; dropna(axis=1, thresh=…) keeps the columns that are at least half present.

Before running: How many rows does load.dropna() keep?

Rule of thumb from the lecture

Choose a fill from the variable’s meaning, not its missing fraction alone. Fit median or mode replacements on training rows. A long constant fill can shrink spread; retain the missingness audit and measure its effect.

Your turn: a surgical clean-up

Build clean from load in two moves: drop the hopeless NI column (drop(columns=…)), then drop the rows that still have a NaN. Result: no NaN, three columns, more than 13 000 rows.

Your turn: fill a column with its mean

Compare the observed FE values with a copy in which missing values are replaced by its mean.

Fill the NaN in load["FE"] with the column mean, into fe_mean. It must contain no NaN and its mean must equal the pre-fill mean (why is that automatic?).

A duplicated hour that nobody typed

Switch to the smaller pjm_hourly.csv (2014–2017). It has no NaN — but some timestamps appear twice. Print the repeated rows and count the duplicated timestamp index values.

Before running: Inspect every repeated timestamp below. Which index values occur twice, and how does duplicated() count the later copies?

Each repeated index value is 02:00 on the first Sunday of November in the archived file. The two records have different loads. Inspect timezone and source clock conventions before deciding whether either observation is expendable.

keep=“first”, keep=“last”, keep=False

drop_duplicates(subset=…) decides which copy survives. Compare the retained rows and the reported counts.

keep=‘first’ and keep=‘last’ each delete one copy per repeated index value (4 rows gone). keep=False deletes every copy — both rows of each pair — so 8 rows disappear.

Before running: hours has 4 duplicated timestamps. How many rows does keep=False remove?

Observed repeated local timestamp: choose a policy after investigating identity.

Your turn: drop both copies

Using the boolean mask form hours.loc[~hours.index.duplicated(keep=…)], remove both copies of every duplicated hour, into nodup. Check: 8 rows fewer than hours.

Rows, columns and time subsets

Question: Which observations belong in the analysis?

Result: An explicit subset with identities and chronological order preserved.

After checking missing values and duplicates, declare which rows and columns belong in the analysis.

Mask one column, slice the whole table

A Boolean mask inside .loc[...] selects rows, keeping all columns. Combine conditions with & (and) or | (or), with parentheses around each comparison.

& binds tighter than >, so Python reads 15000 & hours.AEP first — a bitwise AND of an integer with a float Series — and raises a TypeError. Parentheses around each comparison fix it; query() avoids the issue by parsing the string itself.

Before running: What happens with hours.loc[hours.AEP > 15000 & hours.AEP < 16000]?

Four observed source hours: compare band membership with an outside-range screen.

Same filter, more readable: query

.query() writes the filter as a string: and / or instead of & / |, and @name splices in a Python variable. Compare its rows with the boolean-mask result.

Identical rows. query reads the column names from the string and evaluates the same logic; the difference is legibility for long conditions and the @variable syntax. This example checks row equivalence; it does not benchmark speed.

Before running: hours.query("AEP > 15000 and AEP < 16000") returns…

isin: membership filters

isin tests each value against a list. Combine with day_name() to split weekends from weekdays.

~ negates a mask. Weekend mean load is 13 795 MW versus 15 240 MW on weekdays. This descriptive difference does not establish its cause. Section 2.3 computes it with groupby.

What type is a single column?

A DataFrame is a dict of Series sharing one index. Pull one column out and ask what it is.

A single column is a Series — the object from Chapter 1. That is the ‘dict of Series’ idea made concrete. Asking for a list of columns, hours[[‘AEP’]], returns a DataFrame instead, which is what models expecting a 2-D X need.

Before running: What does hours["AEP"] return?

loc is right-inclusive, iloc is right-exclusive

Before running: Compare the printed endpoint index values. How does the inclusive .loc endpoint differ from the excluded .iloc stop position?

Filtering with loc: rows by condition, columns by name

loc[mask, columns] does both selections in one call.

Select the last two columns of hours, keeping only the rows where the last column (DEOK) is above its own mean, into sel.

Your turn: a time-ordered 80/20 split

For time series you never shuffle: the first 80 % of hours train, the last 20 % test.

Split hours by position: train = first 80 % of rows, test = the remaining rows, in time order.

Grouped Tables, Joins and Reshaping

Part Question
2.3.1 Grouped aggregation, transform and filter Should the result have one row per group or per observation?
2.3.2 Joins on multiple columns and validation Which rows survive the join, and can repeated matches multiply rows?
2.3.3 MultiIndex and long/wide representations How should the same observations be arranged?

Grouped aggregation, transform and filter

Question: Should the result have one row per group or per observation?

Result: A grouped result with its output shape checked.

A selected sample can be divided into meaningful groups. Identify what one output row represents.

groupby: one number per group

df.groupby("sector")["feature"] divides observations by a grouping column and selects the values to process.

Final operation Result shape Use
.agg("mean") One summary per group Compare sectors
.transform("mean") One aligned value per input row Subtract each row’s sector mean
.filter(function) Original rows of qualifying groups Retain groups passing a rule

A feature to assign back to the original rows normally needs transform. State whether the output represents groups or individual observations.

Group two years of hourly load by weekday. Check that the result has one row for each observed weekday.

groupby splits the rows into seven groups, applies mean() to each group’s AEP, and combines the seven results into a Series indexed by weekday. That is aggregation. transform (two slides on) is the other shape — it keeps every original row.

Before running: How many rows does pjm.groupby("WeekDay")["AEP"].mean() return?

Several aggregations at once, your own functions, named columns

One row per group (7 weekdays) and one column per aggregation (mean, std, iq = 3). agg([…]) turns a list of functions into a summary table; the named form agg(name=(column, function)) is the cleanest way to get well-labelled columns from different source columns.

Before running: pjm.groupby("WeekDay")["AEP"].agg(["mean", "std", iq]) returns a…

transform: same shape as the input

Standardise AEP within each weekday. Compare the input and output indexes and lengths.

transform applies the function to each group and then broadcasts the result back to the original rows, so the output has exactly len(pjm) entries, aligned to pjm’s index — ready to be assigned as a new column.

Before running: What is the length of pjm.groupby("WeekDay")["AEP"].transform(lambda x: (x - x.mean()) / x.std())?

The final Sunday hour is 1.7 sd above the overall mean and 2.4 sd above the Sunday mean. These are full-sample descriptive statistics. For prediction, estimate weekday means and scales on training data only.

filter: keep whole groups that pass a test

Before running: Group means of B: foo → 3, bar → 4. Which rows survive filter(lambda g: g["B"].mean() > 3)?

Original small frame · B values grouped by A.

Your turn: interquartile range by weekday

Use groupby("WeekDay")["AEP"].apply(...) with a lambda to compute the interquartile range (75 % − 25 % quantile) of AEP for each weekday, into iq_day.

Weekly resampling groups clock time, not every seventh row

groupby(level=0).mean() groups repeated values of the first row-index level. Here we explicitly average repeated timestamps for a descriptive weekly report. Then choose the weekly statistic and inspect coverage. A mean load has units MW; energy over hourly readings has units MWh.

The first Sunday bucket has only 24 valid hours; the next has 168. Taking every 168th row keeps one observation per step and does not compute weekly mean load.

Joins on multiple columns and validation

Question: Which rows survive the join, and can repeated matches multiply rows?

Result: A validated join with unmatched rows accounted for.

Illustrative distinct matching values · missing partner remains unknown.

Check for missing values in the columns used for matching. State which columns to match and how many rows each match may produce.

A second frame: Times university rankings 2011–2016

Text columns hide numbers ('-' for missing income). pd.to_numeric(errors="coerce") turns the junk into NaN.

size counts university-year records, not distinct institutions. Print it beside the research-score mean and state the sample used to rank countries.

Joins: inner, left, outer — who survives?

pd.merge(..., on=["ticker", "date"], how=...) matches values in both named columns. validate checks whether each match has the expected number of rows; indicator=True reports unmatched rows.

Two tables → one. The 2015 list has 401 universities, the 2016 list 800. pd.merge(u15, u16, on="university_name", how=…). Compare the retained rows and the reported counts.

inner keeps only names present in both years — fewer than 401. left keeps every 2015 row (401) and fills NaN for the ones that left the 2016 list. outer keeps the union: 401 + 800 − 393 matched = 808. Rule of thumb: left to enrich a spine, inner for matched-only, outer to audit gaps.

Before running: Compare the unmatched rows below. Which year does each join preserve, and where do missing values appear?

Your turn: count the universities that dropped out

How many 2015 universities have no 2016 row? Use the left merge: count the NaN in its research_16 column, into dropped (an integer). Check it against len(u15) - len(inner).

One research row means one security and one date

Load nine stocks from the public monthly panel: three each in Technology, Financial Services and Energy. Use the ticker and date columns together to identify each row. The stock selection and sector classifications are fixed for this teaching sample.

The sample contains 639 stock/month rows: nine stocks and 71 dates. It is a current-survivor teaching sample with unverified historical sector/availability metadata; use it to practise table operations and feature calculations.

Join on both stock and date and inspect unmatched rows

Joining only on ticker would pair observations from different months. Here the reference table deliberately omits one security-month so the coverage check has something to find.

Before running: A left join loses one reference match. How many candidate rows should remain?

Illustrative stock/month rows · MSFT reference row deliberately omitted.

The join on ticker and date retains 639 candidates: 638 matched rows and one unmatched row. Require the intended cardinality and an explicit unmatched-record policy.

Combine tables: matching columns or concatenating axes

merge matches rows using the chosen columns and combines their remaining columns. DataFrame.join normally matches indexes. pd.concat joins whole objects along the requested axis.

Operation What it does Important keywords
merge Match rows using the chosen columns on, how, validate
join Match row indexes by default how, validate
concat(axis=0) Append rows; align column names axis, ignore_index
concat(axis=1) Add columns; align row indexes axis, join, keys

concat uses join="outer" by default; join="inner" keeps only the common index values for axis=1. ignore_index=True replaces the concatenation-axis index, so retain meaningful identifiers elsewhere first.

pd.concat: side by side, or on top

pd.concat concatenates a list of Series or DataFrames. Use axis=1 for columns and axis=0 for rows. Use pd.concat for appending rows; DataFrame.append() has been removed.

Concatenating two Close series with different date ranges. Inspect the unmatched dates in the combined table.

concat(axis=1) performs an outer join on the index values: every date from either series appears, and a series that has no value for that date gets NaN. The keys= parameter names the resulting columns.

Before running: msft covers 2015–2018, fb only 2016. What does pd.concat([msft, fb], axis=1) contain for 2015 rows?

Source closes · 30–31 December 2015 and 4 January 2016.

Stop a join when a stock/date pair is repeated

Allowing repeated stock/date pairs in both tables would conceal this defect. Duplicate one reference row, then ask pandas to require at most one matching row on each side.

Illustrative repeated matching value K · Cartesian matches are not new observations.

many_to_one requires unique stock/date pairs in the reference table; one_to_one requires both sides unique. Check missing values in the matching columns: pandas can match a missing value to another missing value.

Your turn: assemble a pair and correlate it

pair holds MSFT and FB prices on the same 2016 dates. Set rho to the Pearson correlation of their daily percentage changes (a plain float).

MultiIndex and long/wide representations

Question: How should the same observations be arranged?

Result: A reshaped table that retains the stock and date for every observation.

After checking that each stock/date pair occurs once, move those values between index levels and columns.

A MultiIndex identifies each row using two index levels

AAPL appears on several dates; a date appears for several stocks. The (ticker, date) pair identifies a row. Two index levels belong to the same row axis; they are not the row and column axes.

Four observations from the archived return sample; returns are decimals.
Ordinary stock/date columns
row index ticker date ret_1_0
0 AAPL 2020-01-31 0.054010
1 AAPL 2020-02-29 -0.114701
2 MSFT 2020-01-31 0.079455
3 MSFT 2020-02-29 -0.045688
One MultiIndex with two levels
ticker date ret_1_0
AAPL 2020-01-31 0.054010
AAPL 2020-02-29 -0.114701
MSFT 2020-01-31 0.079455
MSFT 2020-02-29 -0.045688

Teal cells form the row index. Each row still has one return. A MultiIndex can have more than two levels.

Six rows for the index examples

Use two stocks and three month-end dates from the validated research table. Keep the complete small input visible; the reverse date order will make the sorting step clear.

Set two columns as the index

set_index(["ticker", "date"]) uses the columns in that order: outer level ticker, inner level date. verify_integrity=True rejects duplicate complete pairs. By default, the selected columns leave the data columns.

index.names names the levels; index.nlevels is 2; index.is_unique checks the complete pair, not each level separately.

Sort a MultiIndex before selecting a range

sort_index() orders rows by the outer level first, then the inner level within it. It moves complete rows, so returns stay attached to their stock and date. sort_values() orders by data-column values instead.

Sort all levels before the date-range examples below. Selection using an exact index value does not itself require a sorted index.

Move one index level into a column

reset_index(level="date") moves only the named level into an ordinary column. The ticker level remains the row index. Here, level means which component of the MultiIndex, not a row number.

Use the name "date" rather than its position 1 when possible. A repeated ticker index is expected after this partial reset.

Add a column to the existing index

set_index("date", append=True) adds the date column as another index level. append=True retains the existing ticker index; the default append=False would replace that index.

The result matches panel (True). This append is a keyword of set_index, not the removed DataFrame method.

Restore all index levels as columns

reset_index() without level moves every row-index level into columns and creates a default integer index. This is useful before a column-based merge or export.

The default drop=False preserves the identifiers as columns. drop=True discards them instead; use it only when those identifiers may be lost.

Select one stock using the outer index level

panel.loc[rows, columns] still has two axes. With ticker first, "AAPL" selects all its dates. A single ticker removes that selected level from the result; a list retains the MultiIndex.

Both outputs are DataFrames with the same three returns. Compare their index names and structure.

Select one date using a named index level

xs means cross-section. level="date" selects the date level even though it is not first. drop_level=False keeps the selected date level in the result.

Both select AAPL and MSFT on 29 February. drop_level changes the returned index structure, not which observations are selected.

Select one index pair or one cell

A tuple gives both index values in level order: ("AAPL", "2020-02-29"). The comma outside that tuple separates row selection from column selection.

Left: a row returned as a Series. Right: a single number. loc selects by index values; iloc continues to select by integer positions.

What pd.IndexSlice means

pd.IndexSlice lets you combine lists, exact values and ranges inside a MultiIndex row selector. The slots follow the current index-level order.

Read the brackets from the inside out.
panel.loc[idx[ticker selector, date selector], column selector]
Row selector: two index levels

First slot → ticker
Second slot → date
: → every value of that level

Column selector: data columns

["ret_1_0"] keeps a DataFrame.
The outer comma separates rows and columns.

idx = pd.IndexSlice is a convenience alias for writing slices across index levels. It does not create an index or change the table.

Use IndexSlice for a range or all stocks

Here idx = pd.IndexSlice makes a short name for the helper. The first example selects two stocks over a date range; the second uses : for every stock on one date. loc ranges include both endpoints.

These selectors retain both row-index levels. They select existing observations; they do not add missing stock/date combinations.

Swap index levels, then sort the new order

swaplevel("ticker", "date") exchanges the two levels. It does not sort the rows. Sorting afterwards puts dates first and tickers together within each date.

Choose ticker-first for a stock history, or date-first for a cross-section. The same six observations and returns remain.

Apply the index operations to the full research panel

The small example used six observations to expose the structure. Apply the same two-level index to all 639 validated stock/month rows. Selecting a stock or a range changes the sample, not the stored panel.

Reshape the return panel without aggregating repeated stock/date pairs

unstack(level="ticker") moves the ticker level from the row index to columns. Date stays in the row index. The values are rearranged, not calculated again.

unstack("ticker"): the ticker index level becomes the column axis.
Before: Series, two row-index levels
ticker date ret_1_0
AAPL 2020-01-31 0.054010
AAPL 2020-02-29 -0.114701
MSFT 2020-01-31 0.079455
MSFT 2020-02-29 -0.045688
After: DataFrame, date index
date / ticker AAPL MSFT
2020-01-31 0.054010 0.079455
2020-02-29 -0.114701 -0.045688

Each return moves once. No averaging. level="ticker" names the level to move; an omitted level means the last level.

Run unstack and compare the Series with the table

Select the return column as a Series, then move its ticker level to columns. Print the input Series directly so its index, name and dtype remain visible.

Four indexed values become a 2 × 2 table. Repeated complete index pairs must be resolved before unstack; it cannot choose between two values for one cell.

Stack columns into an inner index level

stack() moves the column axis into the innermost row-index level. This frame has one column level, named ticker; it becomes the second row-index level.

stack(): the ticker column axis becomes an inner row-index level.
Before: one date index + ticker columns
date / ticker AAPL MSFT
2020-01-31 0.054010 0.079455
2020-02-29 -0.114701 -0.045688
After: Series, date then ticker
date ticker ret_1_0
2020-01-31 AAPL 0.054010
2020-01-31 MSFT 0.079455
2020-02-29 AAPL -0.114701
2020-02-29 MSFT -0.045688

The new level is appended after the existing date index. Compare date / ticker here with the original ticker / date.

Missing combinations after unstack

A wide table contains a cell for each row/column combination. If a pair was absent, unstack places NaN there. This example deliberately removes the last of the four observed returns.

An absent observation creates an empty wide cell.
Three observed stock/date pairs
ticker date ret_1_0
AAPL 2020-01-31 0.054010
AAPL 2020-02-29 -0.114701
MSFT 2020-01-31 0.079455
Two dates × two ticker columns
date / ticker AAPL MSFT
2020-01-31 0.054010 0.079455
2020-02-29 -0.114701 NaN

NaN means no observed return for that pair. Replacing it with zero would assert a zero return without evidence.

Here dropna() explicitly keeps only non-missing observations after stacking. It would also remove originally recorded missing returns, so it is not a universal round-trip policy.

Transpose swaps the entire row and column axes

.T exchanges the two axes of a DataFrame. It does not move just one MultiIndex level and does not aggregate. Compare this with unstack("ticker") and stack().

The result still has four cells. Its row index is now ticker and its columns are dates.

Verify the full long–wide–long round trip

After stacking, swaplevel restores the original level order; sort_index restores row order. Check the data values and both index levels.

The complete sample is 71 dates × 9 stocks; both checks return True. With gaps, state the missing-combination policy before claiming that a round trip preserves the source.

melt, pivot and pivot_table: the structural difference

pivot rearranges unique pairs. pivot_table can aggregate repeated pairs. melt turns headers into ordinary columns containing names and values.

pivot moves column values into headers; melt moves headers back into rows.
Long: stock and date in ordinary columns
ticker date ret_1_0
AAPL 2020-01-31 0.054010
AAPL 2020-02-29 -0.114701
MSFT 2020-01-31 0.079455
MSFT 2020-02-29 -0.045688
Wide: ticker values become headers
date / ticker AAPL MSFT
2020-01-31 0.054010 0.079455
2020-02-29 -0.114701 -0.045688

pivot(index="date", columns="ticker", values="ret_1_0") goes right.
melt(id_vars="date", value_vars=["AAPL", "MSFT"], var_name="ticker", value_name="ret_1_0") goes left after restoring date as a column.

pivot: choose the row index, column headings and values

index="date" supplies the output row index; columns="ticker" supplies the headers; values="ret_1_0" supplies the cell contents. pivot does not aggregate; duplicate complete pairs raise an error.

The values and axes match the earlier unstack("ticker") result (True). The input representation differs: columns here, index levels there.

melt: retain identifiers and turn value columns into rows

id_vars keeps identifier columns; value_vars chooses which columns to lengthen. var_name names the column holding former headers; value_name names the column holding their values.

Two dates × two selected value columns become four rows. The output has ordinary columns and a default row index; melt does not itself create a MultiIndex.

pivot_table: two grouping columns become rows × columns

pivot_table(..., aggfunc="mean") combines scores for each country/year pair.

Eight actual university-year records; HK = Hong Kong, UK = United Kingdom.
Repeated country/year pairs
country year research
HK 2012 58.6
HK 2012 80.1
HK 2013 64.6
HK 2013 85.9
UK 2012 94.2
UK 2012 96.6
UK 2013 95.6
UK 2013 98.1
One mean per country/year
country / year 2012 2013
HK 69.35 75.25
UK 95.4 96.85

HK / 2012: (58.6 + 80.1) / 2 = 69.35.
index="country" → rows; columns="year" → headers; values="research" → scores; aggfunc="mean" → how scores are combined.

Run pivot_table with explicit keyword arguments

The small sample uses HKU, HKUST, Oxford and Cambridge in 2012–2013. aggfunc defaults to "mean", but writing it explicitly states what each cell represents.

pivot would reject these repeated pairs. Aggregation loses individual university scores; melt cannot recover them. fill_value replaces missing output cells after aggregation; leave them visible unless a replacement is justified.

The same table by groupby + unstack

Two routes produce the same country/year means, shown in the final table.

Same eight records; HK = Hong Kong, UK = United Kingdom.

Direct route: pivot_table(..., aggfunc="mean") → the same table on the right.

Source: eight rows
country year research
HK 2012 58.6
HK 2012 80.1
HK 2013 64.6
HK 2013 85.9
UK 2012 94.2
UK 2012 96.6
UK 2013 95.6
UK 2013 98.1
groupby + mean
country year mean research
HK 2012 69.35
HK 2013 75.25
UK 2012 95.4
UK 2013 96.85
unstack("year")
country / year 2012 2013
HK 69.35 75.25
UK 95.4 96.85

mean() calculates four values. unstack(level="year") only rearranges them. Both routes give the same four means.

Inspect the grouped Series before unstacking it

Read the intermediate result before reshaping it. level="year" selects a named index level; it does not mean a DataFrame column or a year value such as 2012.

The numerical check returns True. Use the same aggregation, grouping columns and missing-value rules before treating the two routes as equivalent.

Apply the comparison to four countries and six years

The full selected university sample produces 24 country/year means. Keep record counts beside the means: changing sample composition can change a country average.

melt and pivot on the five-stock price panel

Apply the same mappings to stockdata2.csv: one row per date/stock pair. pivot gives 2 305 dates × 5 stock columns; melt restores 11 525 rows. These are recorded price levels, not returns.

Inventory prices by release year: pivot_table and crosstab

pd.crosstab(index=..., columns=...) counts category combinations by default. To summarise a numeric column, also pass values=... and aggfunc="mean". Here it uses the same row groups, column groups and mean as pivot_table.

inventory.csv (Kaggle retail inventory, 20 000 SKUs). Use pivot_table(index="ReleaseYear", columns="New_Release_Flag", values="PriceReg") for releases from 2010 on, then compare its index and column names with the source rows.

Before running: Compare the seven release-year index values and the two flag columns with the source table. What does one cell summarise?

Inventory counts: unstack either named level

pd.crosstab counts combinations when no values or aggfunc is supplied. A grouped count is a MultiIndex Series; choose explicitly which level to move to columns.

Inventory prices: stack flag columns into the index

stack() moves the flag columns into an inner index level after release year. Each value is still a previously computed mean price; stacking does not calculate another mean.

Inventory prices: transpose the year and flag axes

.T exchanges the complete row and column axes. Flags become the row index; release years become the column headers. The mean prices do not change.

pd.cut: assign values to intervals

pd.cut assigns numeric values to intervals. bins supplies the interval boundaries; labels supplies one name per interval. By default, right=True gives right-closed intervals, such as \((150, 300]\).

Before running: pd.cut([100, 200, 300, 400, 50, 175], bins=[0, 150, 300, 1000], labels=["low", "mid", "high"]) — note 300 sits on the 150–300 edge.

300 belongs to mid, whose interval is \((150, 300]\). Values outside the supplied intervals and missing inputs receive a missing category. In the inventory example, the resulting price-tier Series becomes the row grouping for crosstab.

Plotting a frame

Your turn: citations vs rank, top 100 of 2016

Keep the 2016 universities with world_rank ≤ 100 in top100 (exactly 100 rows), then plot citations against rank.

Plotly Express (px) creates interactive figures; §2.5 explains its interfaces.

In Colab

Colab / local Python
Previously introduced setup · 14 lines

Copy complete code includes this earlier setup and the visible code below. Run it in Colab or local Python.

Introduced earlier: groupby: one number per group; A second frame: Times university rankings 2011–2016; melt and pivot: long ↔ wide; Your turn: citations vs rank, top 100 of 2016

# Previously introduced: groupby: one number per group
import numpy as np, pandas as pd
url = "https://statpython.pages.dev/data/"
# Previously introduced: A second frame: Times university rankings 2011–2016
uni = pd.read_csv(url + "university.csv")
uni["total_score"] = pd.to_numeric(uni["total_score"], errors="coerce")
# Previously introduced: melt and pivot: long ↔ wide
long = pd.read_csv(url + "stockdata2.csv", usecols=["Date", "stock", "value"])
wide = long.pivot(index="Date", columns="stock", values="value")
wide.index = pd.to_datetime(wide.index)
# Previously introduced: Your turn: citations vs rank, top 100 of 2016
uni["world_rank"] = pd.to_numeric(uni["world_rank"].astype(str).str.replace("=", ""), errors="coerce")
top100 = uni[(uni.year == 2016) & (uni.world_rank <= 100)]
# Table display: retain every column and keep rows on one line.
import pandas as pd
pd.set_option('display.max_columns', None)
pd.set_option('display.max_colwidth', None)
pd.set_option('display.width', None)
pd.set_option('display.expand_frame_repr', False)
import plotly.express as px
fig = px.line(wide / wide.iloc[0], title="growth of $1")          # interactive, hover shows values
fig.show()
fig = px.scatter(top100, x="world_rank", y="citations", color="country")
fig.show()

Feature Pipelines and Research Applications

Part Question
2.4.1 Readable chains and safe updates Can another analyst follow every transformation?
2.4.2 Organise a monthly stock table How do we keep each stock’s rows in order?
2.4.3 Feature definitions, neutralisation and validation How is this feature defined and checked?

Parts 2.4.1–2.4.3 build a readable feature pipeline: organise the observations, calculate grouped measurements and check the result.

Readable chains and safe updates

Question: How do daily prices become a checked feature table?

Result: Build the table step by step, then express the same steps as a short pipeline.

The task: prepare a daily movement report

A market-risk team receives Microsoft prices. It needs a daily return and a flag for moves larger than 3% in either direction, followed by a monthly count.

Input: daily prices

Date index
price

252 stock-day rows

Daily feature table

price · ret · large_move

251 observed-return rows

Monthly report

observations · mean_return
large_moves

12 month rows

A feature is a derived measurement attached to an observation. A pipeline is an ordered sequence of transformations. Here, each daily row describes one stock on one date.

Start with the price table

Read the supplied prices, retain adjusted close and select 2015. Rename the column price so the calculation is easy to read. The date is the index; the single column contains prices in USD per share.

There are 252 rows and one column. Adjusted close accounts for the adjustments in this supplied historical file; this exercise does not establish when a vendor published each adjusted value.

First calculate the return, then inspect it

pct_change(fill_method=None) computes \(p_t/p_{t-1}-1\) without filling missing prices. The result is a decimal: 0.03 means 3%. .copy() creates a separate working table.

The first return is NaN: the selected table has no preceding price. This is the return ending on the row’s date, available after that close; it is not the following day’s return.

Flag only rows with an observed return

dropna(subset=["ret"]) retains rows with an observed return. subset names the columns checked for missing values.

1. Retain observed returns

2. Add the flag

abs() measures magnitude in either direction. Column arithmetic handles all rows without a Python loop. A missing return is excluded from this report, never treated as zero.

assign returns a new DataFrame

table.assign(new_column=values) returns a DataFrame with the added column. Save that return value to keep it. The original table retains its columns.

Create a new table

Inspect the original

assigned has price and ret; prices still has only price.

lambda names the table received by assign

A lambda is a short function written as lambda parameter: expression. In assign, pandas supplies the current DataFrame as that parameter.

Part Meaning in this example
table A local parameter name for the DataFrame received by the function
: Separates the parameter from the returned expression
table["price"].pct_change(...) Calculates the new column from that table

The two versions give the same result. The lambda is useful when an earlier step changes the table that the next step receives.

Connect the familiar steps into a short chain

Read from top to bottom: calculate returns → retain observed returns → add the flag. Each method receives the preceding method’s result. The parentheses allow line breaks.

Both versions produce the same index, columns and values. Use named intermediate tables when a step needs investigation; use a short chain when its sequence is already clear.

Prepare a month column before grouping

The daily features are ready. Now create a separate monthly report. index.to_period("M") converts each date to its calendar month; it keeps one value per daily row.

The observation unit is still a stock-day. Several dates now share the same month value; no rows have been aggregated yet.

Aggregate daily rows into a monthly report

groupby("month") collects the rows of each month. In named agg, each output is written as new_name=("input_column", "operation").

The output has 12 monthly rows. count counts observed returns; Boolean sum counts True flags. mean_return is the average daily return, not the compounded monthly return. January has 19 observed returns because the first selected day’s return is unavailable.

Give a repeated report step a function

A function gives a repeated transformation a name. Its first parameter is the input DataFrame; it returns the transformed table. nlargest(n, column) selects the n rows with the largest values of that column.

The function does not change the original monthly table. Tied counts follow nlargest’s default first-occurrence rule.

pipe passes the table to a named function

table.pipe(function, argument) calls function(table, argument): the table is supplied as the first argument. The function determines what is returned.

Here pipe is another way to call the function just taught. It is useful for inserting a named calculation into a longer sequence; ordinary function calls remain clear for a standalone step.

A separate subset has its own columns

Creating an independent subset and editing it does not add a column to its source table. .copy() makes the intended separation explicit.

Edit the separate table

Inspect the source table

The source still has no review column. Writing to the subset is appropriate when that separate table is the intended output.

Use one loc write to update the intended table

For frame.loc[rows, column] = value, the Boolean condition chooses rows and the column name identifies the destination. Both refer directly to frame.

There are 16 check rows and 235 normal rows. Avoid chained assignment such as frame.loc[rows]["review"] = value: under copy-on-write it cannot update the original. Warning behaviour depends on pandas settings and version.

Your turn: change the reporting threshold

The team now wants to review moves larger than 2%. Keep the same 251 observed returns and print the revised table.

Change the threshold in the supplied code from 3% to 2%. Do not change the price input or replace missing returns with zero.

Explain why the flag count changes while the number of daily observations stays the same.

Organise a monthly stock table

Question: How do we organise each stock’s rows before calculating historical values?

Result: A sorted monthly table, checked stock/month rows and grouped historical values.

The task: build a monthly research panel

The team compares nine stocks at each month-end. Each row represents one stock and one month.

Use the ticker and date columns together to identify each row. A stock can appear on several dates, and a date can contain several stocks.

This teaching file contains one row per month for every selected stock. The stock selection and sector classifications are fixed.

Identify the observation
Column Meaning
ticker Which stock?
date Which month-end?
Attach known inputs
Column Role
ret_1_0 Return ending this month
Calculate the historical feature
Column Role
momentum_3m Compound three earlier months

Read the source before selecting the research sample

parse_dates=["date"] creates a datetime column. Inspect the source before filtering. Returns in this file use decimals: 0.05 means 5%.

Select the sample and put each stock in time order

isin retains the named stocks. The date condition retains 2019 onwards. Sort by stock and then date before within-stock time calculations; reset_index(drop=True) supplies a simple unique row index.

The sample contains 639 rows: 9 stocks × 71 months. Ticker is sufficient for this fixed classroom file; it is not a permanent real-world security identifier.

Check stock/date columns before adding any feature

duplicated(id_columns) marks repeated stock/month pairs. Check that the ticker and date columns have no missing values and that each pair occurs once. assert stops execution when its condition is false.

An empty problem table is the expected result here. Investigate a failed check before merging, rolling or fitting.

Grouped shift cannot borrow the previous stock’s row

groupby("ticker")["ret_1_0"].shift(1) shifts separately inside each stock. The first row of every stock becomes missing. An ordinary whole-column shift could copy the previous stock’s last return.

Ordered inputs
Stock / month Return
A / Jan rA1
A / Feb rA2
B / Jan rB1
B / Feb rB2
groupby(stock).shift(1)
Stock / month Lag
A / Jan NaN
A / Feb rA1
B / Jan NaN
B / Feb rB1

Inspect the first lag for each stock

Read the printed rows in date order: February takes January’s return; January has no earlier row in this sample. Compare the starts of AAPL and MSFT.

There are nine missing first lags: one for each stock.

A grouped rolling result has an extra index level

rolling(3, min_periods=3).mean() uses the current and previous two rows within a stock and requires three observed values. The result is a Series indexed by ticker and original row index.

The extra ticker level records group membership. Do not assign these two-level index values directly to a table with a one-level row index.

Restore row indexes, then shift the completed window

droplevel(0) removes only the added ticker level. reindex(research.index) puts the values in the original row order. Shift the completed mean by one within each stock to exclude the current month.

The first three rows per stock are warm-up rows: 27 missing shifted means. The fourth row uses the first three completed months.

Your turn: look back two rows within each stock

Use shift(2) to get the value two rows earlier within each stock. This teaching file has one row per month for every stock.

Change the supplied one-row shift to a two-row shift. Print the first four rows of each of the two stocks.

Feature definitions, neutralisation and validation

Question: What does a feature measure, and which rows and dates define it?

Result: Explicit feature definitions, intermediate calculations and dated validation checks.

Define the feature before choosing the pandas method

A feature name alone does not specify its calculation. Record the columns that identify an observation, formula, input, units, window endpoint, availability and missing-value policy.

Feature Definition used in this section
Lagged return Previous completed month’s decimal return
Three-month momentum Compound the previous three months, excluding the current month
Three-month volatility Sample standard deviation of those months’ log returns
Sector-relative momentum Subtract the same-date sector mean

Use the same feature_lab table and momentum_3m column throughout the following comparisons.

Put the feature’s time window on the page

At the end of April, the three-month example uses January, February and March. April’s realised return is excluded by our definition; May’s outcome is future information.

Different strategies may include the current completed month. Either choice must be declared before calculation.

Feature inputs
Month Use?
January Yes
February Yes
March Yes
Boundary
Month Role
April Decision; skip current month
May Future target; never an input

Compounded return is not a sum of returns

For simple returns, momentum is \(\prod_j(1+r_j)-1\). Two moves of +10% and −10% leave \(1.1\times0.9-1=-0.01\), a 1% loss.

np.log(x) means \(\ln(x)\); np.log1p(r) means \(\ln(1+r)\). A 5% return needs \(\ln(1.05)\). Sum the log returns, then use np.expm1(x) — meaning \(e^x-1\) — to recover the compound return. Require \(r>-1\).

Shift first so the window contains only earlier months

Use the same within-stock lag already taught. Keeping the shifted log return as a column makes the window inputs visible.

The missing first value is expected. Do not fill it with a return from another stock or an invented zero.

Calculate and inspect three-month momentum

Sum three shifted log returns within each stock, restore the original row index, then convert back to a simple cumulative return. min_periods=3 refuses an incomplete window.

The fourth monthly row is the first valid three-month value for each stock. Across nine stocks, 612 rows are valid and 27 are warm-up rows.

Volatility measures dispersion, not direction

The sample standard deviation is \(s=\sqrt{\sum_j(x_j-\bar x)^2/(n-1)}\). Here \(x_j\) is a monthly log return. ddof=1 uses the sample denominator; min_periods=3 requires three observations.

Both use the same past-only window. Momentum records compounded direction; volatility records dispersion. Annualising a monthly standard deviation by \(\sqrt{12}\) assumes uncorrelated-return scaling.

Sector demeaning compares stocks with their current peers

Stocks in the same sector can share a broad movement. Subtract the same-date sector mean to measure each stock relative to its peers: \(x_{i,t}-\bar x_{s,t}\).

The diagram uses an illustrative three-stock sector. The code on the next slide uses the actual research table.

Raw feature
Stock x
A 0.1
B 0.2
C 0.3
Subtract sector mean 0.2
Stock x − mean
A −0.1
B 0
C 0.1
Divide by sample sd 0.1
Stock z-score
A −1
B 0
C 1
Illustrative same-date peer group. Centred values and z-scores both have group mean zero.

transform returns one peer mean for each original row

groupby(["date", "sector"]) creates the peer groups. transform("mean") repeats each group mean along its original rows, so subtraction aligns with the feature Series.

Use the first month with a complete historical window to inspect the comparison. Subtract each same-month sector mean from the three-month momentum just calculated.

A z-score also rescales by peer dispersion

A sector z-score is \((x-\bar x_s)/s_s\). transform("std") supplies the peer sample standard deviation. Replace zero dispersion with missing before division; a one-stock group also has no sample standard deviation.

Each sector in this fixed sample has three stocks. Never replace an undefined peer score with a neutral zero without an explicit policy.

A cross-sectional rank answers a different question

groupby("date").rank(pct=True) orders stocks against all stocks at that date. It does not subtract sector effects. With tied values, pandas uses average ranks by default.

A rank describes position within the same-month comparison. Larger momentum receives a larger percentile rank; ties receive their average rank.

Package the past-only momentum calculation for a time check

A causality check reruns exactly the declared past-only formula. The function sorts the input, lags by stock, forms a three-row window and restores the caller’s row index.

Change future data and compare earlier features

Change returns strictly after a fixed cutoff. A correctly past-only feature at or before the cutoff must remain unchanged. assert_series_equal checks the values and pandas structure, including the index.

Matching earlier outputs is evidence about code timing. It does not verify a vendor’s historical release times or the original universe.

Report feature coverage by date

A single whole-sample percentage can hide a broken month. count() reports valid values per feature and date; a missing-value fraction reports the share unavailable.

The first three dates have zero ready three-month features; the fourth has nine. Inspect peer counts and unusually large or small values as well. A valid extreme observation is not automatically a data defect.

Feature readiness belongs to a stated operation

A missing feature needs an explanation. The first three months of a three-month historical window are unavailable because there is not enough earlier data; filling them with zero would change the meaning.

Retain the stock and date columns, report coverage by month and investigate unexpected gaps. Compare the input and output tables before using a derived column.

Interactive Visualisation

Part Question
2.5.1 Plotly Express and column mappings Which columns control the chart?
2.5.2 Graph Objects, traces and subplots Which trace belongs on which panel?

Matplotlib and Plotly: compare the interfaces

The same observations and variables can be drawn through different interfaces.

Compare Matplotlib Plotly
Draw plt.scatter(x, y) or ax.scatter(x, y) px.scatter(df, x="a", y="b")
Figure structure Figure → Axes → drawing objects Figure → data traces + layout
Inspect Static image here; interactive backends also exist Browser hover, zoom and toolbar
Share PNG / SVG / PDF Interactive HTML or static image

Use the same sample, units and axis ranges. The library does not change what the data mean.

Use the same observations in both libraries

Keep the first three university observations from 2016 and their two score columns.

Read the three score pairs before plotting

One point represents one university. Both examples use exactly these rows and the same axis ranges.

Matplotlib draws the three score pairs

scatter draws paired Series; Axes sets labels and limits.

Plotly retains university names on hover

Hover reveals the university for the same three score pairs.

Plotly Express and column mappings

Question: Which columns control the chart?

Result: An interactive comparison with stable identities, scales and labels.

The engineering workflow produced a table. Map its columns to an interactive chart. Run the Python boxes here, then hover, zoom or change a column name and run again.

Choose Express for column mappings and Graph Objects for control

University excerpt · data and layout meet inside a Figure

Prepare chart columns before drawing

Reuse the original university ranking file. Parse numeric columns, keep explicit names and select a stated sample.

One row describes one university in one year. Inspect the original column names and values before changing them.

Convert the rank and international columns

Remove the tied-rank = marker. errors="coerce" leaves rank bands missing instead of inventing an exact rank.

Select the 2016 top 100

Keep rows satisfying both conditions, then inspect the selected table and missingness.

After removing the tied-rank = marker, the sample contains 100 universities and no missing rank, citations or teaching values. Rank bands elsewhere in the source become missing numeric ranks rather than invented exact positions.

Express draws a histogram from a named column

Map one numeric column to the horizontal axis and name the sample in the title.

Hover reveals the displayed bins. nbins guides automatic binning; use explicit xbins through Graph Objects when exact shared edges matter.

Open the interactive chart.

Colour and hover labels retain the row meaning

Map country to colour and retain university identity in hover information.

2016 top-four excerpt · university/year columns · scores 0–100

Country colour gives groups; numeric colour gives a continuous scale.

Open the interactive chart.

Text labels and annotations serve different purposes

Label a small subset of observations. Add a separate annotation for the sample definition.

text labels data rows. An annotation can explain the chart in data coordinates or normalised paper coordinates.

Open the interactive chart.

Multiple y columns create comparable lines on one scale

Express accepts a list of value columns. Here both scores use the same 0–100 scale.

Sort the horizontal coordinate before connecting points. This is a rank profile, not a time trend.

Open the interactive chart.

A third coordinate adds a variable and a viewing problem

Rotate the 3D view to inspect teaching, research and citations together.

Rotation changes apparent distances and overlap. Use a 2D comparison or facets when a reader must compare values precisely.

Open the interactive chart.

Facets repeat the same chart for comparable groups

Keep the three countries with the most observations in this sample and share axis ranges.

2016 top-four excerpt · US and UK panels · paired scores

Open the interactive chart.

Animation needs stable identities and stable axes

Compare the top 20 in each year. University name identifies the same institution across frames.

Four source institutions · top-20 selection · archived year frames

Use Play or the year slider to compare six frames, 2011–2016. The downloadable lab uses the same data.

Open the interactive chart.

Keep animation comparisons honest

Stable identities preserve which observation moves and fixed scales prevent rescaling from looking like change.

Discuss: What makes year-to-year animated points comparable?

Graph Objects, traces and subplots

Question: Which trace belongs on which panel?

Result: An editable figure with explicit trace and layout choices.

Explicit traces and layouts provide control beyond the first column mapping.

Graph Objects makes the trace and layout explicit

Build one trace and one layout, then combine them in a Figure.

The trace supplies data and its drawing mode. The layout supplies the shared chart presentation.

Open the interactive chart.

A figure can also be described as a dictionary

The same structure uses data and layout entries. Use documented properties, then let go.Figure validate them.

One trace survives and the title is Dictionary figure. Dictionaries are useful for serialisation; they follow the same figure schema.

Add a trace and update the existing figure

Construct a rank profile, add the second score and refine titles without rebuilding the data.

add_trace changes the plotted data series. update_layout changes shared presentation. update_traces changes trace properties.

Open the interactive chart.

make_subplots assigns traces to named grid positions

Place the teaching and research profiles in separate panels with a shared score range.

Illustrative traces · Plotly row/col positions begin at one

Open the interactive chart.

Choose the figure interface

Express creates Graph Objects figures; add_trace and update_layout can refine either interface.

Discuss: How do add_trace and update_layout refine a figure created with either interface?

Change a column mapping and rerun the chart

Change x_column from "teaching" to "research", then press Run Code. Compare the horizontal axis and hover information.

The same 100 university rows remain in the chart; changing the mapping changes which score is compared with citations.

Route a trace to its subplot

Plotly make_subplots uses one-based row and column positions; route each trace with row and col.

Discuss: Which operation places a Plotly trace in the second panel of a one-row grid?

Your turn: communicate the engineered table

For the group deliverable, produce one labelled chart of missingness, group coverage or a derived feature. State the sample, units and data limitation.

Recreate it in Express and refine one layout property. Recheck row counts whenever you change the chart’s sample.

Engineering readiness after Chapters 1–2

You should now be able to turn an unfamiliar table into a checked dataset and explain each transformation.

Task Evidence to show
Inspect and repair Row meaning, shape, missing values and repeated observations
Group and join Expected output shape and explained row counts
Reshape The same observations represented in long or wide form
Build a feature A stated formula and calculations within each stock
Explain the result Input/output previews, coverage checks and a labelled chart

Working with an AI Copilot

Three prompts that make an LLM useful for this chapter — and the pitfall each one guards against.

  1. “Before you aggregate, print df.index.duplicated().sum() and df.isna().sum(); if either is non-zero, stop and show me the offending rows.” Repeated local index values and missing columns require explicit policies.
  2. “Show me the .shape after every load, reshape or merge (read_csv, pivot_table, melt, unstack, concat, pd.merge) and explain each change in row count.” Reconcile row identifiers and counts: 2 305 × 5 melts to 11 525 × 3; the 2015/2016 inner join keeps 393 of 401 left rows. A recurring capacity-sized count is a signal to investigate, not proof of truncation.
  3. “Write every modification as one .loc[rows, cols] = or as an assign inside a chain — never df[mask][col] = … — and tell me if pandas would raise SettingWithCopyWarning.” Under copy-on-write, an intermediate-object write does not update its parent; warning or error behaviour depends on pandas version and settings.

Mistakes Library: Public Health England’s missing 15 841 cases (October 2020)

On 4 October 2020, PHE reported 15 841 positive tests from 25 September–2 October omitted from daily reporting and delayed transfer to contact tracing. The patients had received their test results. PHE statement, Parliament update.

Illustrative seven-record CSV: reconcile source rows before publishing the loaded table.

Legacy .xls sheets hold at most 65 536 rows. Verify file capacity, source row identifiers and counts before publication; successful execution alone does not establish completeness. Microsoft compatibility limits.

Decision Memo — Which utilities enter the 2011–2012 load model, and how

To: Head of Forecasting, grid analytics From: <Your name>, data engineering Subject: Handling of missing and duplicated hours in AEP_hourly.csv / pjm_hourly.csv before modelling Date: 2026-09-15

Recommendation: For this proposed aligned model, use AEP, COMED and FE; drop NI. Evaluate forward fills for the two single-hour gaps with imputation flags; exclude FE’s five-month gap rather than invent loads. Preserve repeated local-time records with a repeated-index flag until source clock and timezone evidence resolves their identity. Choose aggregation for a stated hourly-profile estimand.

Evidence: - load.isna().sum() on 2011–2012 (17 540 hours): AEP and COMED each miss 1 hour, FE misses 3 624 (January–May 2011), NI is present in 1 hour of 17 540. A plain dropna() keeps 0 rows; dropna(subset=["FE"]) keeps 13,916. - Filling FE’s gap with its median keeps every row but cuts the column’s standard deviation from 1,422.5 to 1,267.7 MW: a fifth of the series would sit at one value, understating the variance any risk or forecast model is built on. - At the missing hour, bfill uses a later observation unavailable then. ffill uses earlier information but still assumes the carried load is suitable. Fit statistical replacements on training data and validate their effect. - pjm_hourly.csv 2014–2017 has no NaN and 4 repeated local index values, each 02:00 on the first Sunday of November. The paired loads differ; the file supplies no timezone offset. keep="first" or "last" removes one row per index value; keep=False removes both. These row policies do not establish which physical observations the index values represent.

Caveats: - Excluding FE’s gap removes January–May 2011 for an aligned three-utility model. Separately dropping each utility’s missing row keeps 17 539 AEP or COMED records; filling instead retains records with explicit imputation flags. - Retaining, dropping or aggregating repeated index values changes weights and coverage. Measure its effect on the actual hourly means and peak audit; a row-count fraction alone does not determine the change in a mean.

Next step: Assert the declared schema, expected row counts and identifying-column uniqueness. Report missingness and repeated local index values for investigation before aggregation; retain source provenance and the chosen policy.

Chapter Summary

Concept Tool
Axes, sorting, alignment df.axes, sum(axis=), sort_values, sort_index, df1 + df2
Aggregation agg([...]), agg({col: [...]}), agg(name=(col, fn)), apply(f, axis=)
Missing and duplicates isna().sum(), fillna, ffill, dropna(subset=), index.duplicated, drop_duplicates(keep=)
Filtering and subsets (a) & (b), query, isin, loc[mask, cols], iloc
Reshaping and joining groupby().agg/transform/filter, pd.merge(how=), pivot_table, crosstab, melt, stack/unstack, pd.concat, pd.cut
Research joins and panels merge(on=[...], validate=, indicator=), set_index, xs, swaplevel
Grouped features and checks grouped shift/rolling, transform, counts and missing-value reports
Visual communication Plotly Express, Graph Objects, make_subplots
Idioms (df.query().assign().groupby().agg()), pipe, one .loc[rows, cols] =, vectorised column arithmetic

Next: Chapter 3 — Reshaping Statistics.