Section 2.3 · Chapter 2 · Learning Statistics with Python

Grouped Tables, Joins and Reshaping

DataFrames

Prof. Xuhu Wan

ISOM, HKUST Business School · 2026 Edition

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()