Python for Actuarial Students · Course 1
Lesson 3

Functional Programming & Core Math Applications

Write each formula once, name it, test it, and reuse it everywhere: discount factors, interest conversions, and annuities.

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

  • Define functions with positional, keyword, and default arguments
  • Explain local vs. global scope — and why pure functions are safer
  • Document intent with type hints
  • Handle errors with try/except and reject bad input with raise ValueError
  • Map and filter data with list and dictionary comprehensions
  • Build a small, trustworthy library of interest-theory functions
75 min
Concepts & worked examples
75 min
Lab: an interest-theory toolkit

Companion notebook: lesson-03-functions.ipynb — worked examples and self-checking exercises.

Part 1

Defining Functions

Name a calculation, give it inputs, get back an output.

FunctionsAnatomy of a function

def discount_factor(i, t):
    """Present value of 1 due at time t, at effective rate i."""
    return (1 + i) ** -t


discount_factor(0.05, 10)      # 0.6139132535407591
discount_factor(0.05, 0.5)     # works for fractional t too
  • def + a name + parameters in parentheses + colon
  • The indented body runs each time you call it
  • return sends a value back (no return → None)
  • The first string is the docstring: help(discount_factor) shows it

Written once, tested once, used a thousand times. If the formula is wrong, you fix it in one place.

ArgumentsPositional, keyword, and default

def pv(amount, rate=0.05, years=1):
    return amount * (1 + rate) ** -years


pv(1000)                          # 952.38...   defaults used
pv(1000, 0.03)                    # 970.87...   positional
pv(1000, years=10)                # 613.91...   skip rate
pv(amount=1000, rate=0.03, years=10)   # 744.09...
  • Positional: matched by order
  • Keyword: matched by name — clearer, order-free
  • Defaults make parameters optional; they come after required ones

For anything with more than two numeric arguments, call with keywords. pv(1000, 0.03, 10) invites swapping rate and term.

GotchaNever use a mutable default

def add_claim(amount, claims=[]):     # ❌
    claims.append(amount)
    return claims

add_claim(1)     # [1]
add_claim(2)     # [1, 2]   — surprise!


def add_claim(amount, claims=None):  # ✅
    if claims is None:
        claims = []
    claims.append(amount)
    return claims

Default values are created once, when the function is defined — not on every call. A default list is shared by every call that uses it.

Use None as the default and create the list inside. Ruff flags the mistake for you (rule B006).

ScopeLocal vs. global

rate = 0.05                    # global

def pv_bad(amount, years):
    return amount * (1 + rate) ** -years   # reads a global

def pv_good(amount, rate, years):
    return amount * (1 + rate) ** -years   # everything passed in

x = 10
def f():
    x = 5          # a NEW local x
    return x

f(), x             # (5, 10)
  • Names assigned inside a function are local — they vanish when it returns
  • Reading a global works; relying on it is fragile
  • A pure function depends only on its arguments and changes nothing outside itself

In a notebook, pv_bad silently picks up whatever rate a cell set last. Pure functions give the same answer every time — which is what an auditor wants.

Type hintsSay what goes in and what comes out

def annuity_immediate(n: int, i: float) -> float:
    """Present value of 1 paid at the end of each of n periods."""
    if i == 0:
        return float(n)
    return (1 - (1 + i) ** -n) / i


def pv_cash_flows(cash_flows: list[float],
                  i: float) -> float: ...

def lookup(table: dict[int, float], age: int) -> float | None: ...
  • name: type for parameters, -> type for the result
  • Built-in generics: list[float], dict[str, float], tuple[int, ...]
  • float | None means “a float, or nothing”
  • The typing module adds more (Callable, Sequence, …)

Python does not enforce hints at run time. They are documentation that your editor (Pylance) and tools can check.

Part 2

Errors & Defensive Programming

Fail loudly and early — never silently wrong.

ExceptionsReading a traceback

Traceback (most recent call last):
  File "~/actuarial_work/foundations/scratch.py", line 7, in <module>
    total = pv_cash_flows(flows, "0.05")
  File "~/actuarial_work/foundations/scratch.py", line 3, in pv_cash_flows
    return sum(cf * (1 + i) ** -t for t, cf in enumerate(cash_flows, 1))
                     ~~^~~
TypeError: unsupported operand type(s) for +: 'int' and 'str'
  1. Read the last line first: the exception type and message
  2. Read upwards: the chain of calls that led there
  3. Find the first line that’s your code

Here the rate arrived as the text "0.05", so 1 + i failed. The fix belongs where the value was read in, not deep in the formula.

ExceptionsRaise errors for bad inputs

def discount_factor(i: float, t: float) -> float:
    if i <= -1:
        raise ValueError(f"interest rate must be > -1, got {i}")
    return (1 + i) ** -t


>>> discount_factor(-1.5, 3)
ValueError: interest rate must be > -1, got -1.5
  • raise stops the function and signals the caller
  • ValueError: right type, unacceptable value
  • TypeError: wrong type entirely
  • Put the offending value in the message

Unlike assert, raise can’t be switched off. Use it for anything that validates real inputs.

ExceptionsHandling errors with try / except

def to_float(text: str) -> float | None:
    """Parse a number from a data file; None if impossible."""
    try:
        return float(text.replace(",", ""))
    except ValueError:
        return None


to_float("1,200")     # 1200.0
to_float("N/A")       # None
  • Code in try runs; if a named exception occurs, the matching except runs instead
  • Optional else: runs if nothing failed; finally: always runs
  • Catch only what you expect and can handle

Never write a bare except: or except Exception: pass. It hides typos and real bugs along with the error you meant to catch.

Part 3

Comprehensions

Build new collections by mapping and filtering, in one line.

ComprehensionsMap and filter, declaratively

# Map: a transformed copy
factors = [round(discount_factor(0.05, t), 4) for t in range(1, 6)]
# [0.9524, 0.907, 0.8638, 0.8227, 0.7835]

claims = [1200, 350, 8800, 40, 15000]

# Filter
large = [c for c in claims if c > 1000]
# [1200, 8800, 15000]

# Map with a cap
capped = [min(c, 10_000) for c in claims]
# [1200, 350, 8800, 40, 10000]

# Dict comprehension
table = {t: round(discount_factor(0.05, t), 4) for t in (1, 5, 10)}
# {1: 0.9524, 5: 0.7835, 10: 0.6139}

[ expression for item in iterable if condition ]

  • Equivalent to a for loop that appends — but reads as what you want, not how
  • Slightly faster than the loop
  • sum(... for ...) works without the brackets (a generator)

If a comprehension needs nested loops and conditions, write the plain loop. Clarity wins.

Actuarial Lab · 75 minAn interest-theory toolkit

Write pure, typed, documented functions in src/foundations/interest.py, then import and test them from notebooks/lesson03_lab.ipynb.

FunctionFormulaCheck at i = 5%
discount_factor(i, t)$v^t = (1+i)^{-t}$t = 10 → 0.613913
effective_from_nominal(i_m, m)$(1 + i^{(m)}/m)^m - 1$6% monthly → 0.0616778
nominal_from_effective(i, m)$m\,[(1+i)^{1/m} - 1]$m = 12 → 0.0488895
discount_rate(i)$d = i/(1+i)$0.0476190
force_of_interest(i)$\delta = \ln(1+i)$0.0487902
annuity_immediate(n, i)$a_{\overline{n}|} = (1 - v^n)/i$n = 10 → 7.721735
annuity_due(n, i)$\ddot{a}_{\overline{n}|} = (1 - v^n)/d$n = 10 → 8.107822

Lab · RequirementsMake them trustworthy

  1. Every function: type hints and a one-line docstring
  2. Reject impossible inputs with ValueError: i ≤ −1, m ≤ 0, n < 0
  3. Handle i = 0 in the annuities (the formula divides by zero)
  4. Import into the notebook: from foundations.interest import annuity_due
  5. Check identities with math.isclose:
    • $\ddot{a}_{\overline{n}|} = (1+i)\,a_{\overline{n}|}$
    • nominal → effective → nominal round-trips
    • $d = 1 - v$ and $\delta = \ln(1+i)$
import math
from foundations.interest import (effective_from_nominal,
                                  nominal_from_effective)

i12 = nominal_from_effective(0.05, 12)
back = effective_from_nominal(i12, 12)
back                           # 0.050000000000001
back == 0.05                   # False
math.isclose(back, 0.05)       # True

That last digit is floating point at work (Lesson 1). Identities are checked with a tolerance — exactly how Lesson 8’s tests will do it.

Stretch: a {m: i_m} dict comprehension of nominal rates for m in (1, 2, 4, 12, 365), plus the limit δ.

RecapLesson 3 checklist

  • I can write functions with defaults and call them with keywords
  • I never use a mutable default
  • I pass values in rather than reading globals
  • I add type hints and docstrings
  • I can read a traceback from the bottom up
  • I raise ValueError for bad inputs and catch only specific exceptions
  • I use comprehensions to map and filter
  • My foundations/interest.py passes its identity checks
Up next
Lesson 4

Object-Oriented Programming for Financial Instruments

Bundle data and behavior into classes — and build a coupon bond and a mortgage that know how to value themselves.