Python for Actuarial Students · Course 2
Lesson 1

Tabular Data Structures with pandas

Series and DataFrames, label vs. position indexing, types and missing values — and a first pass at cleaning a genuinely messy claims extract.

Lesson 1 · OverviewBy the end of this lesson you can…

  • Explain how a DataFrame is built from Series sharing an index
  • Select data by label with .loc[] and by position with .iloc[]
  • Create and convert columns with vectorized assignments
  • Tell NaN from pd.NA, and catch missing values that don’t look missing
  • Clean text with the .str accessor and filter with .query()
  • Ingest, validate, and standardize a raw claims file
75 min
Concepts & worked examples
75 min
Lab: cleaning claims_raw.csv

Companion notebook: lesson-01-pandas-fundamentals.ipynb — worked examples and self-checking exercises.

SetupThe course project and data pack

cd ~/actuarial_work
uv init wrangling --package --python 3.12
cd wrangling
uv add pandas pyarrow matplotlib seaborn \
       geopandas openpyxl
uv add --dev jupyterlab pytest ruff
mkdir notebooks tests

Copy the course data pack into data/: synthetic policies and claims on real North Carolina geography (US Census, Natural Earth).

This course uses pandas 3. Older tutorials show warnings and behaviors that no longer apply — we’ll flag them as we go.

data/
├── raw/
│   └── claims_raw.csv     messy — this lesson
├── motor/                 clean schema — Lesson 2 on
│   ├── policies.csv       22,185 policy terms
│   ├── vehicles.csv
│   ├── policy_drivers.csv
│   ├── persons.csv
│   ├── coverages.csv
│   └── claims.csv
└── geo/
    └── nc_geo.gpkg        Lesson 6
Part 1

Anatomy of pandas

Series, DataFrames, and the index that ties them together.

SeriesA labeled, typed column

import pandas as pd

paid = pd.Series([1200.0, 350.0, 8800.0],
                 index=["CLM1", "CLM2", "CLM3"],
                 name="paid")
paid
# CLM1    1200.0
# CLM2     350.0
# CLM3    8800.0
# Name: paid, dtype: float64

paid.index     # Index(['CLM1', 'CLM2', 'CLM3'], dtype='str')
paid.values    # array([1200.,  350., 8800.])
paid.dtype     # dtype('float64')
Indexlabels — here, claim IDs
Valuesa NumPy-style array (Course 1, Lesson 5)
dtype + nameone type for the whole column

The index is what makes pandas more than NumPy: operations line up by label, not position. Add two Series and matching claim IDs are added together.

DataFrameColumns of Series sharing one index

raw = pd.read_csv("data/raw/claims_raw.csv")
raw.shape          # (3369, 10)
raw.head(3)
raw.info()         # dtypes, non-null counts, memory
raw.describe()     # summary stats for numeric columns
raw.dtypes
# claim_id         str
# policy_id        str
# coverage         str
# accident_date    str
# status           str
# paid             str      ← should be numbers!
# outstanding      str      ← so should this
  • Each column is a Series with its own dtype; all columns share the row index
  • Storage is columnar: fast to compute down a column, slower across a row
  • str is pandas 3’s text dtype

First thing to do with any file: check .dtypes. Money read as str means something in that column isn’t a clean number. Finding out what is today’s lab.

Part 2

Indexing Strategies

.loc[] by label, .iloc[] by position.

Indexing.loc[] vs. .iloc[]

claims = clean.set_index("claim_id").sort_index()

# .loc — by LABEL: [row labels, column labels]
claims.loc["CLM000001", ["coverage", "paid", "status"]]
claims.loc["CLM000001":"CLM000003", ["coverage", "paid"]]
#           coverage     paid
# claim_id
# CLM000001     LIAB  1829.29
# CLM000002     COLL  2578.12
# CLM000003     LIAB  3507.10

# .iloc — by POSITION: [row numbers, column numbers]
claims.iloc[:3, [2, 7]]        # same three rows
claims.iloc[0].name            # 'CLM000001'
.loc.iloc
Selects bylabelinteger position
Slice endincludedexcluded (like lists)
Accepts masksyesarrays only

.loc slices include the end label — "CLM000001":"CLM000003" gives three rows. .iloc follows Python’s exclusive rule.

Prefer .loc with meaningful labels. Positions change when data is sorted or filtered; claim IDs don’t.

IndexingBoolean masks and assignment

open_claims = claims[claims["status"] == "Open"]

# rows AND columns in one .loc call
claims.loc[claims["paid"] > 50_000, ["coverage", "paid"]]

# assign through .loc
claims.loc[claims["cause"].isna(), "cause"] = "Unknown"

# combine conditions: &  |  ~   (parentheses required)
large_coll = claims[(claims["coverage"] == "COLL")
                    & (claims["paid"] > 20_000)]
  • A comparison gives a boolean Series; indexing with it keeps the True rows
  • To change values, always go through one .loc[rows, cols] = …

pandas 3: Copy-on-Write. A filtered frame like open_claims behaves as an independent copy — changing it never changes claims. The old SettingWithCopyWarning you’ll see in tutorials is gone, and chained assignment like df[mask]["col"] = x simply doesn’t work.

Part 3

Columns, Types & Missing Values

Vectorized assignment, conversions, and the many faces of “missing”.

ColumnsVectorized assignment and conversion

# New columns: whole-column arithmetic, no loops
claims["incurred"] = claims["paid"] + claims["outstanding"]
claims["large"] = claims["incurred"] > 25_000

# Converting types
raw["paid"].astype(float)
# ValueError: could not convert string to float: '$3,386.85'

pd.to_numeric(raw["paid"].str.replace(r"[$,]", "", regex=True))
pd.to_datetime(raw["accident_date"])
raw["coverage"].astype("category")
ToolUse for
pd.to_numerictext → numbers; errors="coerce" turns failures into missing
pd.to_datetimetext → timestamps (Lesson 2)
.astype("category")few distinct labels, many rows
.astype("Int64")integers that may be missing

category stores each label once plus small integer codes. The coverage column shrinks from 178 KB to 4 KB — and typos can’t sneak in as new values.

Missing valuesNaN, None, and pd.NA

import numpy as np

np.nan == np.nan                         # False (!)
pd.isna(np.nan)                          # True — always test this way

pd.Series([1, None, 3]).dtype            # float64 — ints became floats
pd.Series([1, None, 3], dtype="Int64")   # [1, <NA>, 3]
pd.Series(["a", None], dtype="string")   # ['a', <NA>]

df.isna().sum()                          # missing count per column
df["cause"].fillna("Unknown")
df.dropna(subset=["accident_date"])
MarkerWhere you’ll see it
NaNfloat columns (a NumPy float value)
NonePython object columns
pd.NA“nullable” dtypes: Int64, string, boolean

Nullable dtypes keep integers as integers even with gaps — useful for counts and IDs.

Missing valuesMissing values that don’t look missing

In claims_raw.csv, the outstanding column hides four spellings of “nothing”:

TokenRowsDefault read_csv
null34→ NaN
(empty)28→ NaN
N/A27→ NaN
-28stays as text!
raw = pd.read_csv("data/raw/claims_raw.csv")
raw["outstanding"].dtype          # str   ← 28 dashes did this
raw["outstanding"].isna().sum()   # 89

raw = pd.read_csv("data/raw/claims_raw.csv",
                  na_values=["-"])
raw["outstanding"].dtype          # float64
raw["outstanding"].isna().sum()   # 117

Twenty-eight dashes turned an entire money column into text. Always look at the value_counts() of anything unexpected before deciding how to read a file.

Part 4

Strings & Queries

Clean text in bulk; filter readably.

StringsThe .str accessor

raw["coverage"].value_counts()
# COLL 1024 · LIAB 944 · COMP 453 · COLL␣ 112 · Coll 102
# collision 102 · Third Party 98 · LIAB␣ 95 · liability 94
# Liability 77 · comprehensive 46 · …  (15 spellings)

key = raw["coverage"].str.strip().str.upper()
sorted(key.unique())
# ['COLL', 'COLLISION', 'COMP', 'COMPREHENSIVE',
#  'LIAB', 'LIABILITY', 'THIRD PARTY']

cov_map = {"LIAB": "LIAB", "LIABILITY": "LIAB", "THIRD PARTY": "LIAB",
           "COLL": "COLL", "COLLISION": "COLL",
           "COMP": "COMP", "COMPREHENSIVE": "COMP"}
raw["coverage"] = key.map(cov_map)     # unmapped → NaN
  • .str applies string methods to every value at once: strip, upper, title, replace, contains, startswith, split
  • Normalize first (strip + case), then map synonyms
  • .map(dict) leaves anything not in the dict as missing — so an isna() check afterwards catches spellings you didn’t anticipate

Missing values pass through .str methods as missing. Use na=False in str.contains when you need a clean boolean mask.

FilteringReadable filters with .query()

clean.query("status == 'Open'")                          # 411 rows
clean.query("coverage == 'COMP' and paid > 20_000")     # 4 rows
clean.query("status == 'Open' and outstanding > 50_000") # 4 rows

threshold = 25_000
clean.query("incurred > @threshold")                    # 163 rows

# the same filter, written as a mask:
clean[(clean["status"] == "Open") & (clean["outstanding"] > 50_000)]
  • The query string reads like a sentence — handy for business rules
  • and / or / not instead of & / | / ~; no extra parentheses
  • @name refers to a Python variable

Column names with spaces need backticks inside a query: `paid amount` > 0. Better still: rename columns to snake_case on the way in.

Actuarial Lab · 75 minIngesting claims_raw.csv

  1. Read with the right na_values; check dtypes
  2. Profile: value_counts on every text column
  3. Parse money: strip $ and commas → float64
  4. Standardize coverage and status → category
  5. De-duplicate exact repeat rows
  6. Validate: flag, count, and remove invalid records
  7. Save the result and a list of rejected rows with reasons

Answer key

Rows read3,369
paid values with $464
Coverage spellings → codes15 → 3
Status spellings → values8 → 2
Exact duplicates15
Invalid records39
Clean rows3,315

Lab · Step 6Validation as a table of rules

policies = pd.read_csv("data/motor/policies.csv",
                       usecols=["policy_id"])

checks = pd.DataFrame({
    "negative_paid": df["paid"] < 0,
    "missing_accident_date": df["accident_date"].isna(),
    "reported_before_accident":
        df["report_date"] < df["accident_date"],
    "unknown_policy":
        ~df["policy_id"].isin(policies["policy_id"]),
})
checks.sum()
# negative_paid               12
# missing_accident_date        8
# reported_before_accident    11
# unknown_policy               8

bad = checks.any(axis=1)          # 39 rows
clean = df[~bad]
rejects = df[bad].assign(reason=checks[bad].idxmax(axis=1))
  • One boolean column per business rule — easy to read, audit, and extend
  • checks.sum() counts failures per rule; any(axis=1) flags rows failing any rule
  • Keep the rejects with a reason: someone will ask what was dropped, and why

Never silently drop data. A validation log — rule, count, examples — belongs in every data-preparation step of a reserving or pricing exercise.

Stretch: write clean_claims(raw: pd.DataFrame) -> pd.DataFrame in src/wrangling/claims.py, plus a test that it returns 3,315 rows.

RecapLesson 1 checklist

  • I can explain Series, DataFrame, index, and dtype
  • I choose .loc or .iloc deliberately — and know their slice rules
  • I assign through .loc, and understand Copy-on-Write
  • I convert types with to_numeric, to_datetime, astype
  • I test for missing values with isna(), never ==
  • I profile text columns before trusting them
  • I standardize categories with .str + .map
  • My cleaned claims file has 3,315 rows and a rejects log
Up next
Lesson 2

Temporal Computations & Exposure Calculations

Dates, leap years, and mid-term cancellations — computing earned car years exactly.