Six sites and six means is a table you can read by eye. However, many groupings are much bigger than six, and most questions want more than one summary of each group.
This morning we take the pattern you just learned and increase the load. You will ask for several summaries in one call instead of one, ask for different summaries of different columns, and rank a grouped result the way you ranked a raw table on Wednesday. In addition, we will see what happens when you group by two things at once.
The data should look familiar: the National Park Service visitor records from Wednesdayβs colab. On Wednesday you ranked individual park-years.
This morning we will rank whole parks.
By the end of this session you will be able to:
pass a list to .agg() to get several summaries of one column
pass a dictionary to .agg() to summarise several columns differently
apply the top-N pattern to a grouped result
recognize a MultiIndex produced by grouping on two keys, and flatten it with .reset_index()
explain why a grouped mean should always be reported with the count it was computed from
Getting Started
Create the file. In the Explorer, hover over the EDS217 heading and click New Fileβ¦, then type the name in full, extension included: Session_5B_Aggregating_Data.ipynb
Check the kernel. The Kernel Selector in the notebookβs action bar should read Python 3.11.15 (Conda: eds217). If it reads anything else, click it, choose Change Kernel, and pick the eds217 entry.
Add a title cell. Click + Markdown in the action bar, and give it this content, with todayβs date:
# Day 5: Session 5B - Several Answers at Once[Session Webpage](https://eds-217-essential-python.github.io/course-materials/interactive-sessions/5b_aggregating_data.html)Date: 09/04/2026
Save with Ctrl + S (Cmd + S on macOS), and keep saving as you go.
Read the visitor records and rebuild two subsets from Wednesday, this time applied one after the other: keep the rows that describe a real year, and from those keep the rows that describe a National Park. Note the order: Wednesdayβs national_parks was taken from the whole file, so it still had the Total rows in it, while chaining the two filters here gives a smaller table under the same name.
Code
import pandas as pdurl ='https://eds-217-essential-python.github.io/data/national_parks.csv'parks = pd.read_csv(url)# Remove the rows that have park Totals in them and make a new dataframe:by_year = parks[~(parks['year'] =='Total')]national_parks = by_year[by_year['unit_type'] =='National Park'].copy()national_parks.shape
(4682, 12)
4,682 rows. Each one is a single park in a single year, running from 1904 to 2016, and there are 61 distinct parks in the dataset.
region
AK 488
IM 1590
MW 533
NC 46
NE 179
PW 1393
SE 453
Name: visitors, dtype: int64
Two calls, two Series, and you are already reading across them by eye and hoping you have lined them up correctly. .agg() removes the eye-matching by allowing you to do more than one calculation at a time.
Give it a list of aggregation names and it runs all of them on the same grouping and puts the answers side by side:
π .agg(['mean']) with one name in the list gives you a DataFrame with one column. .mean() gives you a Series. Same numbers, in a different kind of object. Ask for the one you want to work with next.
The reason count goes first
Look again at the table of counts and means. NE has the highest mean of any region, a little over 1.6 million visitors, though SE is within three per cent of it. If you were writing a report about which region of the park system draws the crowds, NE is your headline.
Now read the count column. NE has 179 rows out of 4,682. Ask how many parks that is:
region
AK 9
IM 18
MW 7
NC 1
NE 2
PW 17
SE 7
Name: unit_name, dtype: int64
Two. The entire Northeast region contains two national parks, Acadia and Shenandoah, and both of them are busy ones. The mean is not wrong, but it is a statement about two parks that happen to share a region code rather than a statement about a region.
NC is worse: one park and 46 rows, and its mean prints in exactly the same format as IMβs, whose mean is computed from 1,590 rows.
A mean is a summary of a count you did not print
Every grouped mean is really two numbers, and pandas prints only one of them. The habit that fixes it is a small one: put 'count' first in every .agg() list you write, and read it before you read anything else.
βοΈ Test your knowledge
Group national_parks by state and use .agg() with a list to get the count, mean and max of visitors. Find a state whose mean looks impressive, but which is based on a limited sample number.
Several columns, summarised differently
The list form runs the same summaries on one column. Often you want something else: the mean of this column, the sum of that one, the number of distinct values in a third.
For that, .agg() takes a dictionary. The keys are column names and the values are what to do with each:
Read the dictionary as three instructions applied to the same split: average the visitors, count the distinct park names, and find the earliest year of record.
Two things changed from the list form, and both follow from what a dictionary is:
there is no ['column'] before .agg(). The keys of the dictionary say which columns, so naming a column beforehand would be saying it twice.
the result has one column per key, named after the key.
NOTE: The dictionary here is the same kind you have been writing since Day 2. You used one to rename columns in .rename(columns={...}). Same syntax, same idea: a mapping from a name to what should happen to it.
Note
π A dictionary value can also be a list, which combines both forms π€―:
The column headings stack into two rows when you do this, which gets awkward fast. It is worth being able to read one when you meet it. Reach for it sparingly.
βοΈ Test your knowledge
Write one .agg() call, grouped by region, that reports the median number of visitors, the latest year of record, and the number of distinct states in each region. Then say in one sentence why the median might be the more honest of the two averages for this data.
Ranking a grouped result
On Wednesday afternoon you learned the top-N pattern:
df.sort_values('column', ascending=False).head(n)
You wrote it against raw tables. A grouped result is a table too, so the pattern works there without modification, and the combination answers a question the raw table could not.
Wednesdayβs ranking found the single busiest park-year. Grouping first finds the busiest park:
unit_name
Great Smoky Mountains National Park 6.069152e+06
Grand Canyon National Park 2.096805e+06
Cuyahoga Valley National Park 2.093105e+06
Olympic National Park 1.960219e+06
Rocky Mountain National Park 1.765457e+06
Yosemite National Park 1.715356e+06
Grand Teton National Park 1.680664e+06
Acadia National Park 1.668492e+06
Shenandoah National Park 1.556940e+06
Yellowstone National Park 1.549028e+06
Name: visitors, dtype: float64
Read the line left to right and it is four instructions, two from this morning and two from Wednesday: group by park, average the visitors, put the averages in order, and keep the top ten.
Great Smoky Mountains averages nearly three times the Grand Canyon over its record. It is free to enter and it straddles a major road, and it has been the busiest national park in most years since its record began in 1931. The busiest unit of any kind is usually the Blue Ridge Parkway, which is a road.
The same pattern run the other way gives the quietest parks:
unit_name
Kobuk Valley National Park 4475.857143
Gates of the Arctic National Park 6451.800000
National Park of American Samoa 8745.692308
Lake Clark National Park 10396.971429
Isle Royale National Park 13917.064935
Name: visitors, dtype: float64
When the grouped result is a DataFrame rather than a Series, .sort_values() needs to be told which column to sort on, exactly as it does on a raw table:
Now the count sits next to the mean while you read the ranking, so you can see how much record each average is built on without running a second call.
Averaging across unequal records
count in that table is the number of years each park has been reporting, and it ranges from 13 to 113. Cuyahoga Valleyβs average is computed from 39 years, all of them recent. Yellowstoneβs is computed from 113 years, reaching back to 1904, so about two decades of its record come from before mass car ownership. Cuyahoga Valleyβs record does not reach back that far at all.
Ranking parks by their all-time average is therefore partly a ranking of when each park existed. The imbalance is not a flaw in your code. It is a question you have to answer before your ranking means anything, and the usual fix is to filter to a common set of years first, and then group.
βοΈ Test your knowledge
Filter national_parks to the single year '2016', then group by state and use .agg() to report the count and mean of visitors. Rank the result by mean. Compare your top three states with the all-time ranking by state you built in the earlier exercise, the one that asked for count, mean and max. Did anything move? Say in one sentence which of the two rankings you would put in front of a park superintendent, and why.
Grouping by two keys
Everything so far has split the table by one column. .groupby() will take a list of columns and split by all of them together, making one group per combination that actually occurs:
region state
AK AK 1.204881e+05
IM AZ 1.009639e+06
CO 6.353273e+05
MT 9.908449e+05
NM 4.702549e+05
TX 1.935455e+05
UT 5.792425e+05
WY 1.606659e+06
MW AR 8.443021e+05
MI 1.391706e+04
MN 2.161420e+05
ND 4.142048e+05
OH 2.093105e+06
SD 5.786315e+05
NC VA 5.107866e+05
NE ME 1.668492e+06
VA 1.556940e+06
PW AS 8.745692e+03
CA 6.076323e+05
HI 8.838688e+05
NV 4.543101e+04
OR 3.068096e+05
WA 1.115853e+06
SE FL 3.961249e+05
KY 1.046299e+06
NC 6.069152e+06
SC 8.217781e+04
VI 4.330022e+05
Name: visitors, dtype: float64
Twenty-eight groups, because most states have parks in only one region. Look at the left-hand side of the output: there are two levels of labels, and the outer one is not repeated on every row. The two-level index is a MultiIndex, an index made of more than one column.
A MultiIndex is genuinely useful, and it is also a common reason that pandas code stops working, because everything you know about selecting from an index now needs a tuple:
You are not expected to become fluent with a MultiIndex this week! You should be able to recognize one and get out of it, and the way out is a single method:
Code
flat = two_key.reset_index()flat.head()
region
state
visitors
0
AK
AK
1.204881e+05
1
IM
AZ
1.009639e+06
2
IM
CO
6.353273e+05
3
IM
MT
9.908449e+05
4
IM
NM
4.702549e+05
.reset_index() takes the index levels and turns them back into ordinary columns. The result is a plain DataFrame with three columns, and everything you have learned this week works on it again:
Whenever a grouped result is going to be used rather than read. If you are filtering it, merging it, plotting it, or writing it to a file, flatten it first. If you are looking at it in a notebook cell and moving on, leave it alone.
.reset_index() also works on a single-key grouped result, and turns the Series into a two-column DataFrame, which is often exactly what you want before plotting.
βοΈ Test your knowledge
Run national_parks.groupby(['region', 'unit_name'])['visitors'].agg(['count', 'mean']) and look at what comes back. Then call .reset_index() on it and use the top-N pattern to find the five busiest parks. Say in one sentence what .reset_index() changed.
Putting it together
Letβs do one final question using every piece of this morning so far:
Which national parks in the Pacific West region have drawn the most visitors, and how much record is each average built on?
Filter, group, aggregate, rank. Four steps, and only two of them are new this morning! The answer is a table that captures all the relevant information necessary to answer the question.
Key points
.agg(['count', 'mean', 'max']) runs several summaries on one column and returns a DataFrame.
.agg({'col': 'fn', 'other': 'fn'}) runs different summaries on different columns. There is no ['column'] before it, because the dictionary keys name the columns.
Put 'count' first, every time. A grouped mean without a count beside it is half a number.
The top-N pattern works on a grouped result: .groupby(...)...sort_values(...).head(n).
On a grouped DataFrame, .sort_values() needs the name of the column to sort by.
Grouping by a list of keys produces a MultiIndex.
.reset_index() turns index levels back into columns. Use it whenever the result is going to be used rather than just read.