Crash Course: Python II

pandas

Brendan Chapuis

March 10, 2027

Course Home Page

Getting Started

Where We Are

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 R
  • You already know what you want to do with data
  • Today is mostly learning new names for the same verbs

Throughout, the R version is shown next to the Python version, on the same data.

Installing and Importing

Install into your project’s environment (the .venv from last class):

terminal
python3 -m pip install pandas pyarrow matplotlib

Then import, using the standard aliases:

import numpy as np
import pandas as pd

pd.__version__
'2.2.3'

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.

Our Data: JOLTS, New England

The same BLS JOLTS data as Assignments 3 and 5: annual averages of the monthly job openings and quits rates (percent), 2022–2023.

jolts = pd.DataFrame({
    "state": ["CT", "ME", "MA", "NH", "RI", "VT"] * 2,
    "year": [2022] * 6 + [2023] * 6,
    "openings": [6.5, 6.8, 7.2, 7.3, 7.2, 7.3,
                 5.2, 6.2, 6.4, 6.0, 5.9, 6.0],
    "quits": [2.2, 2.5, 2.0, 2.6, 2.6, 2.7,
              1.9, 2.3, 1.6, 2.3, 2.3, 2.4],
})
jolts <- tibble(
  state = rep(c("CT", "ME", "MA", "NH", "RI", "VT"), 2),
  year = rep(c(2022, 2023), each = 6),
  openings = c(6.5, 6.8, 7.2, 7.3, 7.2, 7.3,
               5.2, 6.2, 6.4, 6.0, 5.9, 6.0),
  quits = c(2.2, 2.5, 2.0, 2.6, 2.6, 2.7,
            1.9, 2.3, 1.6, 2.3, 2.3, 2.4)
)

A DataFrame is built from a dictionary: keys are column names, values are the columns.

Series and DataFrames

A First Look

jolts.head()
state year openings quits
0 CT 2022 6.5 2.2
1 ME 2022 6.8 2.5
2 MA 2022 7.2 2.0
3 NH 2022 7.3 2.6
4 RI 2022 7.2 2.6

A First Look: Shape and Columns

jolts.shape
(12, 4)
jolts.columns
Index(['state', 'year', 'openings', 'quits'], dtype='object')

Note: shape and columns are attributes (no parentheses); head() is a method.

Column Types

jolts.dtypes
state        object
year          int64
openings    float64
quits       float64
dtype: object


jolts.info()
<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.

Summaries

describe() summarizes every numeric column, like summary() in R.

jolts.describe()
year openings quits
count 12.000000 12.0000 12.000000
mean 2022.500000 6.5000 2.283333
std 0.522233 0.6742 0.321455
min 2022.000000 5.2000 1.600000
25% 2022.000000 6.0000 2.150000
50% 2022.500000 6.4500 2.300000
75% 2023.000000 7.2000 2.525000
max 2023.000000 7.3000 2.700000

Series

Each column of a DataFrame is a Series: a vector with labels.

jolts["quits"]
0     2.2
1     2.5
2     2.0
3     2.6
4     2.6
5     2.7
6     1.9
7     2.3
8     1.6
9     2.3
10    2.3
11    2.4
Name: quits, dtype: float64

Series Methods

Series have their own methods:

jolts["quits"].mean()
2.2833333333333337
jolts["state"].nunique()
6

Vectorized Operations

Arithmetic on a Series works element by element, like vectors in R.

(jolts["openings"] / jolts["quits"]).round(2)
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 Index

The bold numbers on the left are the index: a label for each row.

  • By default it is 0, 1, 2, ...
  • Filtering and sorting keep the original labels
  • Some operations (groupby, pivot) move columns into the index

Tibbles and data.tables have no row names, so this is the biggest conceptual difference from R.

When the index gets in your way, turn it back into a column:

df.reset_index()             # index becomes a column
df.reset_index(drop=True)    # throw the old index away

Reading and Writing Data

Reading a CSV

pd.read_csv() reads delimited files. The JOLTS files are tab-delimited:

series = pd.read_csv("./assignments/ps-3-raw-data/jt.series.txt", sep="\t")
series[["industry_code", "state_code", "dataelement_code"]].head(3)
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.

Telling pandas the Types

pandas 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:

series = pd.read_csv(
    "./assignments/ps-3-raw-data/jt.series.txt",
    sep="\t",
    dtype={"industry_code": str, "state_code": str},
)
series[["industry_code", "state_code"]].head(3)
industry_code state_code
0 000000 00
1 000000 00
2 000000 00

Telling pandas the Types: the R Version

In R, you set column types with col_types:

series <- read_tsv(
  "./assignments/ps-3-raw-data/jt.series.txt",
  col_types = cols(
    industry_code = col_character(),
    state_code = col_character()
  )
)

Same issue you met with industry_code and fread() in Assignment 5.

Writing a CSV

jolts.to_csv("jolts.csv", index=False)
write_csv(jolts, "jolts.csv")
  • Reading is a function (pd.read_csv), writing is a method (df.to_csv)
  • Use index=False, or the index is written as an extra unnamed column

Other formats follow the same pattern: pd.read_excel(), pd.read_stata(), df.to_stata(), …

Parquet

Parquet is a compressed, column-based file format that stores column types.

jolts.to_parquet("jolts.parquet")
jolts2 = pd.read_parquet("jolts.parquet")
  • Much smaller and faster than CSV for large data
  • Types survive the round trip (no leading-zero problems)
  • Readable from R with the arrow package: arrow::read_parquet()

Needs the pyarrow package installed.

Selecting and Filtering

Selecting Columns

jolts[["state", "quits"]].head(3)
state quits
0 CT 2.2
1 ME 2.5
2 MA 2.0
jolts |> select(state, quits)

Dropping columns:

jolts.drop(columns=["openings"]).head(3)
state year quits
0 CT 2022 2.2
1 ME 2022 2.5
2 MA 2022 2.0
jolts |> select(-openings)

One Bracket or Two?

type(jolts["quits"])
pandas.core.series.Series
type(jolts[["quits"]])
pandas.core.frame.DataFrame


  • df["col"] with a string gives a Series
  • df[["col"]] with a list gives a DataFrame

Similar to df$col versus df[, "col", drop = FALSE] in base R.

Filtering Rows with a Boolean Mask

A comparison on a Series gives a Series of True/False:

(jolts["quits"] > 2.5).head(4)
0    False
1    False
2    False
3     True
Name: quits, dtype: bool

Filtering Rows with a Boolean Mask: Keeping Rows

Put that mask inside [ ] to keep the True rows:

jolts[jolts["quits"] > 2.5]
state year openings quits
3 NH 2022 7.3 2.6
4 RI 2022 7.2 2.6
5 VT 2022 7.3 2.7
jolts |> filter(quits > 2.5)

Combining Conditions

Use & (and), | (or), ~ (not), with parentheses around each condition.

jolts[(jolts["year"] == 2023) & (jolts["quits"] > 2.3)]
state year openings quits
11 VT 2023 6.0 2.4
jolts |> filter(year == 2023 & quits > 2.3)

Python’s and / or do not work on Series. And without parentheses, & binds before ==, so you get an error.

Matching a Set of Values

.isin() is the pandas version of %in%.

jolts[jolts["state"].isin(["RI", "MA"])]
state year openings quits
2 MA 2022 7.2 2.0
4 RI 2022 7.2 2.6
8 MA 2023 6.4 1.6
10 RI 2023 5.9 2.3
jolts |> filter(state %in% c("RI", "MA"))

.query()

.query() takes the condition as a string, and reads a lot like filter().

jolts.query("year == 2023 and quits > 2.3")
state year openings quits
11 VT 2023 6.0 2.4
jolts |> filter(year == 2023, quits > 2.3)

Refer to Python variables with @:

cutoff = 2.5
jolts.query("quits > @cutoff")
state year openings quits
3 NH 2022 7.3 2.6
4 RI 2022 7.2 2.6
5 VT 2022 7.3 2.7

.loc: Select by Label

df.loc[rows, columns] selects rows and columns by label (or a boolean mask).

jolts.loc[jolts["year"] == 2023, ["state", "quits"]]
state quits
6 CT 1.9
7 ME 2.3
8 MA 1.6
9 NH 2.3
10 RI 2.3
11 VT 2.4

.loc: Label Slices

jolts.loc[0:2, "state":"openings"]
state year openings
0 CT 2022 6.5
1 ME 2022 6.8
2 MA 2022 7.2

Label slices include the end point.

.iloc: Select by Position

df.iloc[rows, columns] selects by integer position, like df[1:3, 1:2] in R.

jolts.iloc[0:2, 0:2]
state year
0 CT 2022
1 ME 2022


Positions start at 0, and slices exclude the end point.

jolts.iloc[-1]
state         VT
year        2023
openings     6.0
quits        2.4
Name: 11, dtype: object

.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:

  • Use .loc when you want to change values: df.loc[mask, "col"] = value
  • After filtering or sorting, labels and positions no longer match, so be deliberate about which one you mean

Sorting

jolts.sort_values("quits", ascending=False).head(4)
state year openings quits
5 VT 2022 7.3 2.7
3 NH 2022 7.3 2.6
4 RI 2022 7.2 2.6
1 ME 2022 6.8 2.5
jolts |> arrange(desc(quits))

Sort by several columns with lists:

jolts.sort_values(["year", "quits"],
                  ascending=[True, False])
jolts |> arrange(year, desc(quits))

Notice the index labels travel with their rows.

Creating Columns

Assigning a New Column

The direct way changes the DataFrame itself:

jolts2 = jolts.copy()
jolts2["ratio"] = jolts2["openings"] / jolts2["quits"]
jolts2.head(3)
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.

jolts.assign(
    ratio=jolts["openings"] / jolts["quits"],
    quits_pct=jolts["quits"] / 100,
).head(3)
state year openings quits ratio quits_pct
0 CT 2022 6.5 2.2 2.954545 0.022
1 ME 2022 6.8 2.5 2.720000 0.025
2 MA 2022 7.2 2.0 3.600000 0.020
jolts |>
  mutate(
    ratio = openings / quits,
    quits_pct = quits / 100
  )

Column names are given without quotes, as keyword arguments.

.assign() with lambda

A 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:

jolts.assign(
    ratio=lambda d: d["openings"] / d["quits"],
    high=lambda d: d["ratio"] > 3,
).head(3)
state year openings quits ratio high
0 CT 2022 6.5 2.2 2.954545 False
1 ME 2022 6.8 2.5 2.720000 False
2 MA 2022 7.2 2.0 3.600000 True
jolts |>
  mutate(
    ratio = openings / quits,
    high = ratio > 3
  )

This lets later columns use earlier ones, and is what makes method chains work.

Conditional Columns and Renaming

np.where() is a vectorized if-else:

jolts.assign(
    tight=np.where(jolts["openings"] > 7, "yes", "no")
).head(3)
state year openings quits tight
0 CT 2022 6.5 2.2 no
1 ME 2022 6.8 2.5 no
2 MA 2022 7.2 2.0 yes
jolts |>
  mutate(tight = if_else(openings > 7, "yes", "no"))

Renaming uses a dictionary of {old: new}:

jolts.rename(columns={"quits": "quit_rate"})
jolts |> rename(quit_rate = quits)

Note the order is flipped: R is new = old, pandas is old: new.

Group By and Aggregate

Group and Summarize

jolts.groupby("year")["quits"].mean()
year
2022    2.433333
2023    2.133333
Name: quits, dtype: float64
jolts |>
  group_by(year) |>
  summarize(quits = mean(quits))

The result is a Series, and the group (year) has moved into the index.

Named Aggregation

.agg() with new_name=("column", "function") is the closest match to summarize():

jolts.groupby("year").agg(
    quits_avg=("quits", "mean"),
    quits_max=("quits", "max"),
    n=("quits", "size"),
)
quits_avg quits_max n
year
2022 2.433333 2.7 6
2023 2.133333 2.4 6
jolts |>
  group_by(year) |>
  summarize(
    quits_avg = mean(quits),
    quits_max = max(quits),
    n = n()
  )

Keeping the Groups as Columns

Use as_index=False (or .reset_index() afterward) to get an ordinary DataFrame back:

jolts.groupby("year", as_index=False).agg(
    quits_avg=("quits", "mean"),
    openings_avg=("openings", "mean"),
)
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).

Group and Mutate: .transform()

.transform() returns one value per row, like group_by() + mutate().

jolts.assign(
    quits_dm=lambda d: d["quits"]
    - d.groupby("state")["quits"].transform("mean")
).head(4)
state year openings quits quits_dm
0 CT 2022 6.5 2.2 0.15
1 ME 2022 6.8 2.5 0.10
2 MA 2022 7.2 2.0 0.20
3 NH 2022 7.3 2.6 0.15
jolts |>
  group_by(state) |>
  mutate(quits_dm = quits - mean(quits)) |>
  ungroup()

Demeaning within a group is exactly what a fixed effect does in fixest.

Method Chaining

Each method returns a new DataFrame, so you can call the next method on it, like |>.

(
    jolts
    .query("year == 2023")
    .assign(ratio=lambda d: d["openings"] / d["quits"])
    .sort_values("ratio", ascending=False)
    .head(3)
)
state year openings quits ratio
8 MA 2023 6.4 1.6 4.000000
6 CT 2023 5.2 1.9 2.736842
7 ME 2023 6.2 2.3 2.695652
jolts |>
  filter(year == 2023) |>
  mutate(ratio = openings / quits) |>
  arrange(desc(ratio)) |>
  head(3)

Wrap the chain in parentheses so you can put one step per line.

Why Chain?

Without chaining, you either nest calls or create many temporary objects:

df_2023 = jolts.query("year == 2023")
df_2023 = df_2023.assign(ratio=df_2023["openings"] / df_2023["quits"])
df_2023 = df_2023.sort_values("ratio", ascending=False)
df_2023.head(3)

Chains read top to bottom, like a pipe.

  • If a chain gets long, break it into named steps
  • To debug a chain, comment out the later lines and run it step by step

Merging and Concatenating

A Second Table

A lookup table with state names and 2023 population (millions). Vermont is missing; New York is extra.

regions = pd.DataFrame({
    "state": ["CT", "MA", "ME", "NH", "RI", "NY"],
    "name": ["Connecticut", "Massachusetts", "Maine",
             "New Hampshire", "Rhode Island", "New York"],
    "pop": [3.6, 7.0, 1.4, 1.4, 1.1, 19.6],
})
regions <- tibble(
  state = c("CT", "MA", "ME", "NH", "RI", "NY"),
  name = c("Connecticut", "Massachusetts", "Maine",
           "New Hampshire", "Rhode Island", "New York"),
  pop = c(3.6, 7.0, 1.4, 1.4, 1.1, 19.6)
)

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.

Left Merge

jolts.merge(regions, on="state", how="left").query("year == 2023")
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.

Inner and Outer

inner = jolts.merge(regions, on="state", how="inner")
inner.shape
(10, 6)
outer = jolts.merge(regions, on="state", how="outer")
outer.shape
(13, 6)


  • Inner: 12 rows minus the 2 Vermont rows = 10
  • Outer: all 12 rows, plus 1 row for New York = 13

Checking the row count after every merge is a good habit.

indicator=True: Where Did Rows Come From?

check = jolts.merge(
    regions, on="state", how="outer", indicator=True
)
check["_merge"].value_counts()
_merge
both          10
left_only      2
right_only     1
Name: count, dtype: int64


Find the unmatched rows, like anti_join():

check.query("_merge == 'left_only'")[["state", "year"]]
state year
11 VT 2022.0
12 VT 2023.0
anti_join(jolts, regions, by = "state")

Why is year now 2022.0? The New York row has no year, and an integer column with a missing value becomes float.

validate=: Check the Relationship

Each state-year in jolts should match at most one row of regions. Say so:

jolts.merge(regions, on="state", how="left",
            validate="many_to_one")
left_join(jolts, regions, by = "state",
          relationship = "many-to-one")

If regions accidentally has a duplicate state, the merge stops with an error instead of duplicating rows:

regions_dup = pd.concat([regions, regions.head(1)])
try:
    jolts.merge(regions_dup, on="state", how="left", validate="many_to_one")
except pd.errors.MergeError as err:
    print("MergeError:", err)
MergeError: Merge keys are not unique in right dataset; not a many-to-one merge

Different Key Names

When the key has different names in the two tables, use left_on= and right_on=:

jolts.merge(
    regions.rename(columns={"state": "abbr"}),
    left_on="state", right_on="abbr",
    how="left",
)
left_join(
  jolts,
  regions |> rename(abbr = state),
  by = join_by(state == abbr)
)

Merging on several keys: on=["state", "year"].

Concatenating

pd.concat() stacks DataFrames, like bind_rows().

y22 = jolts.query("year == 2022")
y23 = jolts.query("year == 2023")
pd.concat([y22, y23], ignore_index=True).shape
(12, 4)
bind_rows(y22, y23)
  • pd.concat is a function that takes a list of DataFrames
  • Columns are matched by name; missing columns are filled with NaN
  • Without ignore_index=True, the old index labels are kept, duplicates and all

Reshaping

Tidy Data, Again

Same 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")

Wide to Long: .melt()

long = jolts.melt(
    id_vars=["state", "year"],
    value_vars=["openings", "quits"],
    var_name="measure",
    value_name="rate",
)
long.head(4)
state year measure rate
0 CT 2022 openings 6.5
1 ME 2022 openings 6.8
2 MA 2022 openings 7.2
3 NH 2022 openings 7.3
long <- jolts |>
  pivot_longer(
    cols = c(openings, quits),
    names_to = "measure",
    values_to = "rate"
  )

id_vars are the columns to keep as identifiers; everything in value_vars gets stacked.

Long to Wide: .pivot()

wide = (
    long
    .pivot(index=["state", "measure"],
           columns="year", values="rate")
    .reset_index()
)
wide.head(4)
year state measure 2022 2023
0 CT openings 6.5 5.2
1 CT quits 2.2 1.9
2 MA openings 7.2 6.4
3 MA quits 2.0 1.6
wide <- long |>
  pivot_wider(
    names_from = year,
    values_from = rate
  )

.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:

long.pivot_table(
    index="measure", columns="year",
    values="rate", aggfunc="mean",
).round(2)
year 2022 2023
measure
openings 7.05 5.95
quits 2.43 2.13
long |>
  pivot_wider(
    id_cols = measure,
    names_from = year,
    values_from = rate,
    values_fn = mean
  )

Here: the New England average of each rate, by year.

Missing Values, Types, and Dates

Missing Values

pandas marks missing values as NaN (or None / pd.NA).

m = jolts.merge(regions, on="state", how="left")
m["pop"].isna().sum()
2
sum(is.na(m$pop))
R pandas
is.na(x) x.isna()
drop_na(df, pop) df.dropna(subset=["pop"])
replace_na(x, 0) x.fillna(0)

Missing Values Are Skipped by Default

x = pd.Series([1, np.nan, 3])
x.mean()
2.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.

Types: Checking and Converting

raw = pd.Series(["1.5", "2", "n/a"])
pd.to_numeric(raw, errors="coerce")
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 types
  • An integer column that gains a missing value becomes float

String columns have a .str accessor, like stringr:

regions["name"].str.upper().head(2)
0      CONNECTICUT
1    MASSACHUSETTS
Name: name, dtype: object

Dates

JOLTS stores dates as a year and a period like "M03". Build a real date:

monthly = pd.DataFrame({
    "year": [2023, 2023, 2023],
    "period": ["M01", "M02", "M03"],
})
monthly["date"] = pd.to_datetime(
    monthly["year"].astype(str) + "-"
    + monthly["period"].str[1:] + "-01"
)
monthly
year period date
0 2023 M01 2023-01-01
1 2023 M02 2023-02-01
2 2023 M03 2023-03-01
monthly |>
  mutate(date = make_date(
    year,
    as.integer(str_sub(period, 2)),
    1
  ))

Date columns have a .dt accessor: monthly["date"].dt.month, .dt.year, …

Common Pitfalls

Pitfall 1: Index Alignment

Operations between Series line up by index label, not by position.

a = pd.Series([1, 2, 3], index=["RI", "MA", "VT"])
b = pd.Series([10, 20, 30], index=["MA", "VT", "NH"])
a + b
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.

Pitfall 1: Index Alignment

This also happens when you assign a Series as a column:

s = jolts["quits"].sort_values()
jolts.assign(q_sorted=s).head(3)
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().

Pitfall 2: Chained Indexing

Two sets of brackets in a row, then an assignment:

bad = jolts.copy()
bad[bad["year"] == 2023]["quits"] = 0
bad["quits"].head(8).tolist()
[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.

Do the selection and assignment in one .loc call:

good = jolts.copy()
good.loc[good["year"] == 2023, "quits"] = 0
good["quits"].head(8).tolist()
[2.2, 2.5, 2.0, 2.6, 2.6, 2.7, 0.0, 0.0]

Pitfall 3: Views and Copies

recent = jolts[jolts["year"] == 2023]
recent["quits"] = recent["quits"] * 100     # SettingWithCopyWarning (pandas 2)

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.

  • Changing recent never changes jolts
  • Chained assignment like the last slide never works
  • The warning is gone

Either way, if you plan to modify a subset, make the copy explicit: recent = jolts[...].copy(), or use .assign().

Copies: pandas vs data.table

Recall data.table’s := modifies by reference:

dt2 <- dt             # dt2 and dt are the *same* table
dt2[, x := 0]         # changes dt too!
dt2 <- copy(dt)       # a real copy

pandas with Copy-on-Write goes the other way:

  • New objects from filtering, selecting, or methods act like copies
  • But plain assignment df2 = df still gives two names for one object, like in data.table
  • df["col"] = ... and df.loc[...] = ... modify df in place

Prefer methods that return new DataFrames (.assign(), .query(), …). Skip the inplace=True arguments you will see online.

Other Gotchas

  • and/or instead of &/| in masks, or missing parentheses
  • .loc[0:2] includes 2, .iloc[0:2] does not
  • df.quits works as a shortcut for df["quits"], but not for names with spaces, or names like count that are also methods
  • Methods return a new object: jolts.sort_values("quits") alone does not sort jolts
  • groupby drops rows where the group key is missing, unless you pass dropna=False
  • rename is {old: new}; mutate and rename in R are new = old

Plotting and Workflow

A Quick Plot

pandas plots with matplotlib behind the scenes.

ax = (
    jolts
    .pivot(index="state", columns="year", values="quits")
    .plot.barh(xlabel="", title="Quits rate (%)")
)

Saving a Plot

Save it with the figure’s savefig method:

ax.figure.savefig("quits.png", dpi=300, bbox_inches="tight")

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

Notebooks or Scripts?

Last class introduced Jupyter notebooks. When should you use one?

Notebooks (.ipynb) are good for:

  • Exploring a new dataset
  • Sharing results with the output included
  • Teaching and short demonstrations

Notebooks or Scripts? (Scripts)

Scripts (.py) are better for:

  • Your research pipeline: anything that must run start to finish, again and again
  • Code that others will run, test, or review in git (notebook diffs are a mess)
  • Functions you want to import and reuse

The Middle Ground

In VS Code, a .py file with # %% markers gets runnable cells:

clean_jolts.py
# %%
import pandas as pd

# %%
states = pd.read_csv("raw-data/jt.state.txt", sep="\t")
states.head()
  • Run one cell at a time in the Interactive Window, like a notebook
  • The file is still a plain script: clean git diffs, and python clean_jolts.py runs it all

This is the Python version of running R scripts line by line in the terminal.

Class Activity

Class Activity (~15 minutes)

Redo a tidyverse-style wrangling task in pandas, using the JOLTS files from Assignment 5.

  1. Read jt.series.txt, jt.state.txt, and jt.data.0.Current.txt (starter code on the next slide)
  2. Merge the three tables, checking the row counts
  3. Filter to seasonal == "S", industry_code == "000000", ratelevel_code == "R", dataelement_code == "QU", and years 2019–2023
  4. Group and summarize: the average quits rate by state and year
  5. Reshape to wide: one row per state, one column per year
  6. Add a column with the change from 2019 to 2023, and sort by it. Which five states saw the largest increase?

Starter Code

The 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.

One Solution

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()
  • After the pivot, the year columns are named by the integers 2019, …, so use d[2023], not d["2023"]
  • The result has 56 rows: the states, DC, four Census regions, and the US total
  • Write the same task with dplyr and compare: the steps line up one for one

Summary

Summary

  • A pandas DataFrame is a set of Series with a shared index
  • Read with pd.read_csv() (set dtype= for codes), write with df.to_csv(index=False)
  • Select with [[ ]], 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
  • Watch out for index alignment, chained indexing, and silently skipped missing values

Live Coding Example

Live Coding Example

  • Create a new project folder with a .venv and install pandas
  • Read the JOLTS files in a .py script with # %% cells
  • Work through the class activity in the Interactive Window
  • Compare each step with the dplyr version
  • Save the result to CSV and a plot to PNG