Python for Actuarial Students · Course 1
Lesson 4 · Part A

Object-Oriented Programming: Classes & Objects

Bundle data with the calculations that belong to it — so a policy, a bond, or a loan can answer questions about itself.

Lesson 4 · OverviewThis lesson comes in two parts

Part A — this deck

  • Classes vs. instances; __init__ and self
  • Methods, encapsulation, and @property
  • Special methods: __repr__, __str__, __len__, __add__

≈ 75 min, concepts & worked examples

Part B — the lab

  • A CouponBond: cash flows, price, yield-to-maturity, duration
  • A MortgageLoan: a full month-by-month amortization schedule

≈ 90 min, hands-on

Companion notebook: lesson-04a-classes-objects.ipynb — worked examples and self-checking exercises.

MotivationWhy classes?

So far, a bond is scattered across loose variables and functions:

face, coupon_rate, years, freq = 1000, 0.06, 10, 2

price(face, coupon_rate, years, freq, ytm=0.05)
duration(face, coupon_rate, years, freq, ytm=0.05)
cash_flows(face, coupon_rate, years, freq)

Every call repeats the same four arguments — and one swapped argument is a silent error.

With a class, the bond carries its own data:

bond = CouponBond(face=1000, coupon_rate=0.06,
                  years=10, freq=2)

bond.price(0.05)
bond.macaulay_duration(0.05)
bond.cash_flows()

A class is a blueprint; each object built from it is an instance with its own data. You already use them: every list and dict is an object with methods.

Part 1

Class Architecture

Attributes, self, and the constructor.

AnatomyDefining a class

class Policy:
    """A single life insurance policy."""

    def __init__(self, policy_id: str, age: int,
                 sum_assured: float, annual_premium: float):
        if sum_assured <= 0:
            raise ValueError(f"sum_assured must be positive, got {sum_assured}")
        self.policy_id = policy_id
        self.age = age
        self.sum_assured = sum_assured
        self.annual_premium = annual_premium

    def premium_per_mille(self) -> float:
        return self.annual_premium / self.sum_assured * 1000

    def birthday(self) -> None:
        self.age += 1
  • class name in CapWords
  • __init__ is the constructor: runs once when an object is created
  • self is the object being built or used
  • self.age = age stores an attribute on that object
  • Functions inside a class are methods; their first parameter is always self

Validate in __init__: an object that can’t be created invalid never needs checking again.

InstancesCreating and using objects

>>> p = Policy("P001", 34, 250_000, 310.50)
>>> p.age
34
>>> p.premium_per_mille()
1.242
>>> p.birthday()
>>> p.age
35
>>> q = Policy("P002", 45, 500_000, 980.00)
>>> q.age                  # its own data
45
>>> p
<__main__.Policy object at 0x7f9e86b91400>
  • Calling the class — Policy(...) — creates an instance and runs __init__
  • p.premium_per_mille() is shorthand for Policy.premium_per_mille(p): that’s where self comes from
  • Each instance keeps its own attribute values
  • Methods can change the object’s state (birthday)

That last line is useless for debugging. We’ll fix it with __repr__.

Part 2

Methods, Encapsulation & Properties

Protect an object’s data; derive values instead of storing them.

EncapsulationControl how state changes

class Account:
    def __init__(self, balance: float):
        self._balance = balance         # "internal" by convention

    @property
    def balance(self) -> float:         # read-only view
        return self._balance

    def deposit(self, amount: float) -> None:
        if amount <= 0:
            raise ValueError("deposit must be positive")
        self._balance += amount


>>> a = Account(100)
>>> a.deposit(50)
>>> a.balance
150
>>> a.balance = 0
AttributeError: property 'balance' of 'Account' object has no setter
  • A leading underscore (_balance) says “internal — don’t touch from outside”
  • Python doesn’t enforce it; it’s a strong convention
  • Changes go through methods that can validate them
  • @property exposes a value that looks like an attribute but is computed by a method

An account balance, a reserve, a policy status: values with business rules about how they may change. Encapsulation puts those rules in one place.

PropertiesDerived values stay in sync

class CouponBond:
    def __init__(self, face, coupon_rate, years, freq=2):
        self.face = face
        self.coupon_rate = coupon_rate
        self.years = years
        self.freq = freq

    @property
    def coupon(self) -> float:
        return self.face * self.coupon_rate / self.freq

    @property
    def n_periods(self) -> int:
        return round(self.years * self.freq)


>>> b = CouponBond(1000, 0.06, 10)
>>> b.coupon, b.n_periods
(30.0, 20)

Why not store self.coupon = face * coupon_rate / freq in __init__?

A stored copy goes stale: change b.face later and b.coupon is silently wrong.

A property is recomputed each time it’s read, so it can’t disagree with the attributes it’s derived from.

No parentheses: b.coupon, not b.coupon().

Part 3

The Python Data Model

Special “dunder” methods make your objects behave like built-ins.

Special methods__repr__ and __str__

class CashFlows:
    def __init__(self, amounts: list[float]):
        self.amounts = list(amounts)      # defensive copy

    def __repr__(self) -> str:
        return f"CashFlows({self.amounts!r})"

    def __str__(self) -> str:
        return (f"{len(self)} cash flows totalling "
                f"{sum(self.amounts):,.2f}")


>>> bond = CashFlows([60, 60, 1060])
>>> bond                    # uses __repr__
CashFlows([60, 60, 1060])
>>> print(bond)             # uses __str__
3 cash flows totalling 1,180.00
AudienceUsed by
__repr__developersREPL, notebooks, debuggers, lists of objects
__str__end usersprint(), str(), f-strings

Always write a __repr__. Aim for text that could recreate the object if pasted back into Python.

If there’s no __str__, Python falls back to __repr__.

Special methods__len__ and __add__

class CashFlows:
    ...
    def __len__(self) -> int:
        return len(self.amounts)

    def __add__(self, other: "CashFlows") -> "CashFlows":
        n = max(len(self), len(other))
        a = self.amounts + [0.0] * (n - len(self))
        b = other.amounts + [0.0] * (n - len(other))
        return CashFlows([x + y for x, y in zip(a, b)])

    def pv(self, i: float) -> float:
        return sum(cf * (1 + i) ** -t for t, cf in enumerate(self.amounts, 1))

>>> net = CashFlows([60, 60, 1060]) + CashFlows([-500, -500])
>>> net
CashFlows([-440, -440, 1060.0])
>>> len(net), round(net.pv(0.05), 2)
(3, 97.53)
  • len(x) calls x.__len__()
  • a + b calls a.__add__(b)
  • __add__ returns a new object; neither input changes
__eq__==
__lt__<, and so sorted()
__mul__*
__iter__for x in obj

Asset flows + liability flows = net flows. Operators that mean what actuaries mean make code read like the math.

DesignWhen to reach for a class

A class earns its place when…

  • Several values always travel together
  • Several functions all take those same values
  • There are rules about valid states or changes
  • You want many instances: a portfolio of bonds, a book of policies

A function is enough when…

  • It’s a single formula: annuity_due(n, i)
  • There’s no state to remember
  • The inputs vary call by call

Good classes use good functions. CouponBond.price() can call the discount_factor() you wrote in Lesson 3.

RecapLesson 4A checklist

  • I can define a class with __init__ and attributes
  • I understand what self is and where it comes from
  • I validate inputs in the constructor
  • I use _underscore names and methods to protect state
  • I use @property for derived values
  • Every class I write has a useful __repr__
  • I can implement __len__ and __add__
  • I can decide between a class and a function
Up next
Lesson 4 · Part B

Lab: Bonds & Mortgages as Objects

Build a CouponBond that prices itself and finds its own yield, and a MortgageLoan that writes its own amortization schedule.