'2.2.3'
pandas
March 10, 2027
Last class: installing Python, environments, packages, and the basics of the language.
Today: pandas, the main package for working with data in Python.
pandas plays the role of readr + dplyr + tidyr in RThroughout, the R version is shown next to the Python version, on the same data.
Install into your project’s environment (the .venv from last class):
Then import, using the standard aliases:
These slides were rendered with the version above. pandas 3.0 (released 2026) changed a few defaults; the slides point them out where they matter.
The same BLS JOLTS data as Assignments 3 and 5: annual averages of the monthly job openings and quits rates (percent), 2022–2023.
A DataFrame is built from a dictionary: keys are column names, values are the columns.
Note: shape and columns are attributes (no parentheses); head() is a method.
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 12 entries, 0 to 11
Data columns (total 4 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 state 12 non-null object
1 year 12 non-null int64
2 openings 12 non-null float64
3 quits 12 non-null float64
dtypes: float64(2), int64(1), object(1)
memory usage: 516.0+ bytes
Strings show up as object in pandas 2. In pandas 3 they get a dedicated str type.
describe() summarizes every numeric column, like summary() in R.
Each column of a DataFrame is a Series: a vector with labels.
Series have their own methods:
Arithmetic on a Series works element by element, like vectors in R.
0 2.95
1 2.72
2 3.60
3 2.81
4 2.77
5 2.70
6 2.74
7 2.70
8 4.00
9 2.61
10 2.57
11 2.50
dtype: float64
No loops needed, and much faster than one.
The bold numbers on the left are the index: a label for each row.
0, 1, 2, ...groupby, pivot) move columns into the indexTibbles and data.tables have no row names, so this is the biggest conceptual difference from R.
pd.read_csv() reads delimited files. The JOLTS files are tab-delimited:
| industry_code | state_code | dataelement_code | |
|---|---|---|---|
| 0 | 0 | 00 | HI |
| 1 | 0 | 00 | HI |
| 2 | 0 | 00 | JO |
Something went wrong: the industry code in the file is "000000", not 0.
pandas the Typespandas guessed industry_code was an integer and dropped the leading zeros. (state_code survived only because a few codes, like "NE", contain letters.)
Use dtype= to set column types yourself:
pandas the Types: the R VersionIn R, you set column types with col_types:
Same issue you met with industry_code and fread() in Assignment 5.
pd.read_csv), writing is a method (df.to_csv)index=False, or the index is written as an extra unnamed columnOther formats follow the same pattern: pd.read_excel(), pd.read_stata(), df.to_stata(), …
Parquet is a compressed, column-based file format that stores column types.
arrow package: arrow::read_parquet()Needs the pyarrow package installed.
df["col"] with a string gives a Seriesdf[["col"]] with a list gives a DataFrameSimilar to df$col versus df[, "col", drop = FALSE] in base R.
A comparison on a Series gives a Series of True/False:
Put that mask inside [ ] to keep the True rows:
Use & (and), | (or), ~ (not), with parentheses around each condition.
Python’s and / or do not work on Series. And without parentheses, & binds before ==, so you get an error.
.isin() is the pandas version of %in%.
.query().query() takes the condition as a string, and reads a lot like filter().
.loc: Select by Labeldf.loc[rows, columns] selects rows and columns by label (or a boolean mask).
.loc: Label SlicesLabel slices include the end point.
.iloc: Select by Positiondf.iloc[rows, columns] selects by integer position, like df[1:3, 1:2] in R.
Positions start at 0, and slices exclude the end point.
.loc vs .iloc.loc |
.iloc |
|
|---|---|---|
| Selects by | labels, boolean masks | integer positions |
[0:2] gives |
labels 0, 1, and 2 | positions 0 and 1 |
| Typical use | filtering, assigning values | “first 5 rows”, “last column” |
Rules of thumb:
.loc when you want to change values: df.loc[mask, "col"] = valueSort by several columns with lists:
Notice the index labels travel with their rows.
The direct way changes the DataFrame itself:
| state | year | openings | quits | ratio | |
|---|---|---|---|---|---|
| 0 | CT | 2022 | 6.5 | 2.2 | 2.954545 |
| 1 | ME | 2022 | 6.8 | 2.5 | 2.720000 |
| 2 | MA | 2022 | 7.2 | 2.0 | 3.600000 |
Like df$ratio <- df$openings / df$quits in base R.
.assign(): the mutate() of pandas.assign() returns a new DataFrame and leaves the original alone.
Column names are given without quotes, as keyword arguments.
.assign() with lambdaA lambda is a small unnamed function: lambda d: d["quits"] * 2 means “given d, return d["quits"] * 2”.
Inside .assign(), the lambda receives the DataFrame at that step:
This lets later columns use earlier ones, and is what makes method chains work.
np.where() is a vectorized if-else:
The result is a Series, and the group (year) has moved into the index.
.agg() with new_name=("column", "function") is the closest match to summarize():
Use as_index=False (or .reset_index() afterward) to get an ordinary DataFrame back:
| year | quits_avg | openings_avg | |
|---|---|---|---|
| 0 | 2022 | 2.433333 | 7.05 |
| 1 | 2023 | 2.133333 | 5.95 |
Group by several columns with a list: groupby(["state", "year"]).
Quick counts: df["state"].value_counts() is like count(df, state).
.transform().transform() returns one value per row, like group_by() + mutate().
Demeaning within a group is exactly what a fixed effect does in fixest.
Each method returns a new DataFrame, so you can call the next method on it, like |>.
Wrap the chain in parentheses so you can put one step per line.
Without chaining, you either nest calls or create many temporary objects:
Chains read top to bottom, like a pipe.
A lookup table with state names and 2023 population (millions). Vermont is missing; New York is extra.
merge()One method covers every join type through how=:
dplyr |
pandas |
|---|---|
inner_join(x, y, by = "state") |
x.merge(y, on="state", how="inner") |
left_join(x, y, by = "state") |
x.merge(y, on="state", how="left") |
right_join(x, y, by = "state") |
x.merge(y, on="state", how="right") |
full_join(x, y, by = "state") |
x.merge(y, on="state", how="outer") |
The default is how="inner", which silently drops unmatched rows. Always write how= explicitly.
| state | year | openings | quits | name | pop | |
|---|---|---|---|---|---|---|
| 6 | CT | 2023 | 5.2 | 1.9 | Connecticut | 3.6 |
| 7 | ME | 2023 | 6.2 | 2.3 | Maine | 1.4 |
| 8 | MA | 2023 | 6.4 | 1.6 | Massachusetts | 7.0 |
| 9 | NH | 2023 | 6.0 | 2.3 | New Hampshire | 1.4 |
| 10 | RI | 2023 | 5.9 | 2.3 | Rhode Island | 1.1 |
| 11 | VT | 2023 | 6.0 | 2.4 | NaN | NaN |
Vermont is kept, with NaN (missing) for name and pop. New York is dropped.
Checking the row count after every merge is a good habit.
indicator=True: Where Did Rows Come From?_merge
both 10
left_only 2
right_only 1
Name: count, dtype: int64
validate=: Check the RelationshipEach state-year in jolts should match at most one row of regions. Say so:
If regions accidentally has a duplicate state, the merge stops with an error instead of duplicating rows:
When the key has different names in the two tables, use left_on= and right_on=:
Merging on several keys: on=["state", "year"].
pd.concat() stacks DataFrames, like bind_rows().
pd.concat is a function that takes a list of DataFramesNaNignore_index=True, the old index labels are kept, duplicates and allSame rules as in R: each variable a column, each observation a row.
tidyr |
pandas |
|---|---|
pivot_longer() |
.melt() |
pivot_wider() |
.pivot() |
pivot_wider(values_fn = mean) |
.pivot_table(aggfunc="mean") |
.melt()id_vars are the columns to keep as identifiers; everything in value_vars gets stacked.
.pivot().pivot() puts the index= columns into the index, so finish with .reset_index().
.pivot_table(): Pivot and Aggregate.pivot() fails if an index/column pair appears more than once. .pivot_table() aggregates the duplicates instead:
Here: the New England average of each rate, by year.
pandas marks missing values as NaN (or None / pd.NA).
| R | pandas |
|---|---|
is.na(x) |
x.isna() |
drop_na(df, pop) |
df.dropna(subset=["pop"]) |
replace_na(x, 0) |
x.fillna(0) |
In R, mean(c(1, NA, 3)) is NA unless you add na.rm = TRUE.
pandas silently drops missing values in mean(), sum(), and groupby() aggregations. Count your missing values before you summarize.
0 1.5
1 2.0
2 NaN
dtype: float64
pd.to_numeric(x, errors="coerce") turns bad values into NaN, like as.numeric()df["col"].astype(str), .astype(int), .astype(float) convert typesfloatJOLTS stores dates as a year and a period like "M03". Build a real date:
Date columns have a .dt accessor: monthly["date"].dt.month, .dt.year, …
Operations between Series line up by index label, not by position.
MA 12.0
NH NaN
RI NaN
VT 23.0
dtype: float64
R would add these position by position (11, 22, 33) and ignore the names.
This also happens when you assign a Series as a column:
| state | year | openings | quits | q_sorted | |
|---|---|---|---|---|---|
| 0 | CT | 2022 | 6.5 | 2.2 | 2.2 |
| 1 | ME | 2022 | 6.8 | 2.5 | 2.5 |
| 2 | MA | 2022 | 7.2 | 2.0 | 2.0 |
The sorted values were put back in their original rows, so q_sorted equals quits.
To assign by position, drop the labels: s.to_numpy().
Two sets of brackets in a row, then an assignment:
[2.2, 2.5, 2.0, 2.6, 2.6, 2.7, 1.9, 2.3]
Nothing changed. The first [ ] made a new DataFrame, and the 0s went into it.
pandas 2 warns because it can’t tell whether you meant to change jolts too.
pandas 3 turns on Copy-on-Write: anything you derive from a DataFrame behaves like a separate copy.
recent never changes joltsEither way, if you plan to modify a subset, make the copy explicit: recent = jolts[...].copy(), or use .assign().
pandas vs data.tableRecall data.table’s := modifies by reference:
pandas with Copy-on-Write goes the other way:
df2 = df still gives two names for one object, like in data.tabledf["col"] = ... and df.loc[...] = ... modify df in placePrefer methods that return new DataFrames (.assign(), .query(), …). Skip the inplace=True arguments you will see online.
and/or instead of &/| in masks, or missing parentheses.loc[0:2] includes 2, .iloc[0:2] does notdf.quits works as a shortcut for df["quits"], but not for names with spaces, or names like count that are also methodsjolts.sort_values("quits") alone does not sort joltsgroupby drops rows where the group key is missing, unless you pass dropna=Falserename is {old: new}; mutate and rename in R are new = oldpandas plots with matplotlib behind the scenes.
Save it with the figure’s savefig method:
For anything more involved, see seaborn or plotnine (a ggplot2 clone).
dplyr to pandas| tidyverse | pandas |
|---|---|
read_csv(), write_csv() |
pd.read_csv(), df.to_csv(index=False) |
select(a, b), select(-a) |
df[["a", "b"]], df.drop(columns=["a"]) |
filter(x > 1) |
df[df["x"] > 1], df.query("x > 1") |
mutate(y = x * 2) |
df.assign(y=lambda d: d["x"] * 2) |
arrange(desc(x)) |
df.sort_values("x", ascending=False) |
rename(new = old) |
df.rename(columns={"old": "new"}) |
dplyr to pandas: Groups, Joins, Reshaping| tidyverse | pandas |
|---|---|
group_by(g) \|> summarize(m = mean(x)) |
df.groupby("g", as_index=False).agg(m=("x", "mean")) |
group_by(g) \|> mutate(m = mean(x)) |
df.groupby("g")["x"].transform("mean") |
count(g), distinct() |
df["g"].value_counts(), df.drop_duplicates() |
left_join(x, y, by = "k") |
x.merge(y, on="k", how="left") |
bind_rows(x, y) |
pd.concat([x, y], ignore_index=True) |
pivot_longer(), pivot_wider() |
df.melt(), df.pivot() / df.pivot_table() |
if_else(), is.na() |
np.where(), .isna() |
\|> |
method chaining inside ( ) |
Last class introduced Jupyter notebooks. When should you use one?
Notebooks (.ipynb) are good for:
Scripts (.py) are better for:
In VS Code, a .py file with # %% markers gets runnable cells:
clean_jolts.py
python clean_jolts.py runs it allThis is the Python version of running R scripts line by line in the terminal.
Redo a tidyverse-style wrangling task in pandas, using the JOLTS files from Assignment 5.
jt.series.txt, jt.state.txt, and jt.data.0.Current.txt (starter code on the next slide)seasonal == "S", industry_code == "000000", ratelevel_code == "R", dataelement_code == "QU", and years 2019–2023The BLS files pad their columns with spaces, so strip them when reading:
import pandas as pd
def read_bls(file):
df = pd.read_csv("raw-data/" + file, sep="\t", dtype=str)
df.columns = df.columns.str.strip()
return df.apply(lambda col: col.str.strip())
series = read_bls("jt.series.txt")
states = read_bls("jt.state.txt")
current = read_bls("jt.data.0.Current.txt")
current["year"] = current["year"].astype(int)
current["value"] = pd.to_numeric(current["value"])Everything is read as a string (dtype=str), so codes keep their leading zeros. Then convert year and value.
quits = (
current
.merge(series, on="series_id", how="inner", validate="many_to_one")
.merge(states, on="state_code", how="left", validate="many_to_one")
.query("seasonal == 'S' and industry_code == '000000' "
"and ratelevel_code == 'R' and dataelement_code == 'QU' "
"and 2019 <= year <= 2023")
.groupby(["state_text", "year"], as_index=False)
.agg(quits=("value", "mean"))
.pivot(index="state_text", columns="year", values="quits")
.reset_index()
.assign(change=lambda d: d[2023] - d[2019])
.sort_values("change", ascending=False)
)
quits.head()2019, …, so use d[2023], not d["2023"]dplyr and compare: the steps line up one for onepandas DataFrame is a set of Series with a shared indexpd.read_csv() (set dtype= for codes), write with df.to_csv(index=False)[[ ]], filter with masks or .query(), use .loc for labels and .iloc for positions.assign(), .sort_values(), .groupby().agg(), and .transform() cover most of dplyr.merge() with explicit how=, validate=, and indicator=; pd.concat() to stack.melt() and .pivot() / .pivot_table() reshape.venv and install pandas.py script with # %% cellsdplyr version