Python for Actuarial Students · Course 2
Lesson 2

Temporal Computations & Exposure Calculations

Parsing dates safely, doing arithmetic with them, and turning 22,185 policy terms into exact earned exposure.

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

  • Parse dates reliably with pd.to_datetime()
  • Use .dt properties and Timedelta / DateOffset arithmetic
  • Compute exposure in years that respects leap years and cancellations
  • Distinguish calendar-year, policy-year, and accident-year views
  • Earn premium pro rata alongside exposure
  • Produce an earned-exposure table for a whole portfolio
75 min
Concepts & worked examples
75 min
Lab: earned exposure, 2022–2025

Companion notebook: lesson-02-dates-exposure.ipynb — worked examples and self-checking exercises.

Part 1

Parsing & Using Dates

From text to timestamps, safely.

Parsingpd.to_datetime() — and its traps

policies = pd.read_csv("data/motor/policies.csv",
    parse_dates=["inception_date", "expiry_date",
                 "cancellation_date"])
policies["inception_date"].dtype      # datetime64[us]

pd.to_datetime("2024-03-04")                  # 4 March
pd.to_datetime("03/04/2024")                  # 4 March (US)
pd.to_datetime("03/04/2024", dayfirst=True)   # 3 April (UK)

pd.to_datetime(["2024-03-04", "04/03/2024"])
# ValueError: time data "04/03/2024" doesn't match
#             format "%Y-%m-%d"

pd.to_datetime(["2024-02-30", "2024-03-01"],
               format="%Y-%m-%d", errors="coerce")
# [NaT, Timestamp('2024-03-01')]      NaT = "not a time"
  • Parse on read with parse_dates=, or convert later
  • pandas infers the format from the first value, then insists every row match
  • format="%Y-%m-%d" makes the rule explicit and fast
  • errors="coerce" turns impossible dates into NaT — then count them

03/04/2024 is 4 March in the US and 3 April in the UK. Ambiguous formats are silent errors waiting to happen. Insist on ISO dates (YYYY-MM-DD) in every extract you control.

PropertiesThe .dt accessor

d = pd.to_datetime(pd.Series(["2024-02-29", "2023-12-31"]))

d.dt.year            # [2024, 2023]
d.dt.month           # [2, 12]
d.dt.day_name()      # ['Thursday', 'Sunday']
d.dt.dayofyear       # [60, 365]
d.dt.is_leap_year    # [True, False]
d.dt.days_in_month   # [29, 31]

claims["accident_year"] = claims["accident_date"].dt.year
claims["report_lag"] = (claims["report_date"]
                        - claims["accident_date"]).dt.days

Just as .str works on text, .dt works on every timestamp in a column at once.

CoverageMedian report lag
COLL8 days
COMP8 days
LIAB19 days

Liability claims are reported more slowly — third parties take time to come forward. Report lags drive IBNR, and you just measured them in one line.

ArithmeticTimedelta vs. DateOffset

start = pd.Timestamp("2024-01-15")

start + pd.Timedelta(days=365)     # 2025-01-14  ← a day short!
start + pd.DateOffset(years=1)     # 2025-01-15  ← calendar-aware

pd.Timestamp("2024-02-29") + pd.DateOffset(years=1)
# 2025-02-28   (no 29 Feb in 2025)

gap = pd.Timestamp("2025-01-14") - start
gap                                 # Timedelta('365 days')
gap.days                            # 365
gap / pd.Timedelta(days=365.25)     # 0.999315...
MeasuresUse for
Timedeltaan exact duration“30 days later”, differences between dates
DateOffsetcalendar steps“same date next year”, month-ends

A policy year is not 365 days. Terms that include 29 February run 366. Build expiry dates with DateOffset(years=1), never Timedelta(days=365).

In our data: 16,728 terms of 365 days, 5,457 of 366.

Part 2

Exposure

The denominator of every frequency and every rate.

ExposureWhat “one year of exposure” means

Earned exposure is the time each vehicle was actually insured, measured in years, up to the evaluation date.

EVAL = pd.Timestamp("2025-12-31")

p = policies
p["cover_end"] = p[["expiry_date",
                    "cancellation_date"]].min(axis=1)
p["earned_end"] = p["cover_end"].clip(upper=EVAL)

min(axis=1) skips missing values, so an uncanceled policy simply uses its expiry date. Convention: cover runs through the cancellation date; start and end dates both count.

Expiredcovered inception → expiry · 15,130 terms
Canceledcovered inception → cancellation date · 1,526 terms
In forcecovered inception → evaluation date; the rest is unearned · 5,529 terms

In this lesson we measure policy years. Most policies insure one car, but 1,808 insure two — joining to vehicles in the next lesson turns 18,579.58 policy years into 20,100.95 car years.

ExposureExact exposure: split by calendar year

def calendar_exposure(start, end, year):
    """Years of exposure earned in `year` by cover from start to end
    (inclusive). Each day weighs 1/365, or 1/366 in a leap year."""
    y0 = pd.Timestamp(f"{year}-01-01")
    y1 = pd.Timestamp(f"{year}-12-31")
    days = (end.clip(upper=y1) - start.clip(lower=y0)).dt.days + 1
    return days.clip(lower=0) / (366 if y1.is_leap_year else 365)


cy = pd.DataFrame({
    y: calendar_exposure(p["inception_date"], p["earned_end"], y)
    for y in range(2022, 2026)
})
p["exposure"] = cy.sum(axis=1)
  • Clip each policy’s cover to the calendar year, count the overlapping days, and weight by that year’s length
  • A full calendar year earns exactly 1.0, leap year or not
  • .clip(lower=0) handles policies that don’t touch the year at all
  • Four vectorized passes: one per year, over all 22,185 terms

Keep the per-year table cy: it’s your calendar-year exposure, ready for frequencies by year.

ExposureThree policies, three situations

PolicyInceptionExpiryCanceled202320242025Total
POL000347 — renewal spanning 29 Feb2023-07-062024-07-05—179/365 = 0.490411187/366 = 0.510929—1.001340
POL000016 — canceled2023-11-042024-11-032024-07-3058/365 = 0.1589212/366 = 0.5792—0.7381
POL000004 — in force at evaluation2025-09-182026-09-17———105/365 = 0.28770.2877

POL000347’s term has 366 days, but only 187 fall in the leap year — so it earns slightly more than 1.0. Exact methods can disagree at the fourth decimal; what matters is choosing one and using it consistently.

POL000004 still has 260 unearned days at 31 December 2025. Its premium for those days is part of the unearned premium reserve.

ExposureDoes the method matter?

MethodTotal policy yearsvs. exact
Calendar split (1/365 or 1/366 per day)18,579.58—
Fraction of term (days ÷ term days)18,579.95+0.002%
Days ÷ 365.2518,581.67+0.011%
Days ÷ 36518,594.40+0.080%

For the whole portfolio the methods agree to within 0.1%. So why fuss?

  • Calendar-year figures must add up to the total — the calendar split does by construction
  • Premium and exposure must use the same convention, or loss ratios drift
  • Auditors reconcile to the cent; “about right” fails reconciliation
Part 3

Calendar, Policy & Accident Years

Three ways to slice the same book of business.

AlignmentThree years, three questions

BasisGroups byAnswersPairs with
Calendar year (CY)when exposure was earnedWhat happened during 2024?accident-year claims
Policy year (PY)when the policy inceptedHow did business written in 2024 perform?claims on those policies
Accident year (AY)when the loss occurredHow many 2024 accidents?CY exposure
YearCY exposurePY exposureAY claimsAY freq.
20222,440.524,842.104690.1922
20235,039.235,251.159300.1846
20245,410.655,583.139600.1774
20255,689.172,903.209950.1749

Policy year 2025 looks small because most of its policies are still earning at evaluation. Calendar year 2022 looks small because the book only started that year. Same data — the “year” you choose changes the story.

PremiumEarned premium, pro rata

term_days = (p["expiry_date"] - p["inception_date"]).dt.days + 1
year_days = pd.Series({y: 366 if pd.Timestamp(f"{y}-01-01").is_leap_year
                          else 365 for y in cy.columns})

# CY exposure × days-in-year → covered days in that year;
# ÷ term days → share of the term; × written premium
ep = (cy.mul(year_days, axis=1)
        .div(term_days, axis=0)
        .mul(p["written_premium"], axis=0))
ep.sum().round(0)
# 2022    4,418,313
# 2023    9,068,643
# 2024    9,614,807
# 2025    9,984,868
  • Written premium is charged for the term; it’s earned day by day as cover is provided
  • Same day-counting as exposure, so premium and exposure stay consistent
  • .mul(…, axis=0) multiplies each row by that policy’s value; axis=1 each column by that year’s value

Earned premium by calendar year is the denominator of the loss ratio — Lesson 4.

Actuarial Lab · 75 minEarned exposure for the whole book

  1. Load policies.csv with parsed dates
  2. Add cover_end, earned_end, and a status column (Expired / Canceled / In force) with np.select
  3. Build the calendar-year exposure table cy and check it sums to 18,579.58
  4. Reproduce the three worked policies exactly
  5. Exposure by status and by policy year
  6. Earned premium by calendar year
  7. Claims by accident year ÷ CY exposure = frequency

Answer key

Exposure: expired15,129.62
Exposure: canceled708.45
Exposure: in force2,741.50
Total18,579.58
Renewal terms12,985
New-business terms9,200

Renewals: renewal_of is not missing.

Lab · StretchChecks and extensions

Reconcile

Show that the four CY columns sum to the per-policy exposure for every row — then that the PY view sums to the same total.

Renewal chains

Follow renewal_of links to find each customer’s tenure in years. What share of 2022 new business is still in force?

Package it

Move calendar_exposure into src/wrangling/exposure.py, with a test: a policy covering all of 2024 earns exactly 1.0.

Common bug: forgetting the + 1 when counting inclusive days. Every term then earns 364/365 — a 0.3% understatement that nobody notices until the reconciliation fails.

RecapLesson 2 checklist

  • I parse dates with an explicit format and count any NaTs
  • I use .dt for properties and date differences
  • I use DateOffset for calendar steps, Timedelta for durations
  • I handle cancellations and the evaluation date with min and clip
  • My calendar-split exposure totals 18,579.58 policy years
  • I can explain CY, PY, and AY — and which pairs with which
  • I earn premium on the same basis as exposure
  • I computed claim frequency by accident year
Up next
Lesson 3

Relational Data Modeling & Multi-Table Joins

Keys, cardinality, and merges — assembling six tables into one analytical base table.