Lists, tuples, dictionaries, and sets — and the loops that turn them into series sums, perpetuities, and amortization schedules.
lists and tuples, and slice them with [start:stop:step]dicts — and explain why that’s fastsets for uniqueness and membershipfor and range()while — safelyCompanion notebook: lesson-02-collections-iteration.ipynb — worked examples and self-checking exercises.
Lists and tuples: many values, one name.
# Mortality rates q_x for ages 40..45
q = [0.0010, 0.0012, 0.0015, 0.0019, 0.0024, 0.0031]
q[0] # 0.001 first item (index 0!)
q[-1] # 0.0031 last item
len(q) # 6
q.append(0.0039) # add to the end
q[0] = 0.0011 # replace an item
max(q), min(q), sum(q)
sorted(q) # new sorted list
q.sort() # sorts q in place
Index 0 is age 40 here. Off-by-one errors between index and age (or time) are the classic actuarial coding bug — name things carefully.
[start:stop:step]q = [0.0010, 0.0012, 0.0015, 0.0019, 0.0024, 0.0031]
# 0 1 2 3 4 5
q[1:3] # [0.0012, 0.0015] stop is excluded
q[:2] # [0.001, 0.0012] from the start
q[3:] # [0.0019, 0.0024, 0.0031] to the end
q[::2] # [0.001, 0.0015, 0.0024] every 2nd item
q[::-1] # reversed
q[-3:] # last three
q[1:3] has 3 − 1 = 2 itemsq[4:100] just stops at the endThink of indexes as pointing between items: q[1:3] takes what’s between fence posts 1 and 3.
>>> base = [0.01, 0.02]
>>> stressed = base # NOT a copy
>>> stressed.append(0.03)
>>> base
[0.01, 0.02, 0.03] # base changed too!
>>> stressed = base.copy() # a real copy
>>> stressed.append(0.99)
>>> base
[0.01, 0.02, 0.03] # untouched
Assignment never copies. Both names point at the same list object.
The classic scenario-testing bug: you “stress” a copy of the base assumptions — and silently stress the base as well.
Copy with .copy(), list(base), or base[:].
tuplepolicy = ("P001", 34, "F") # parentheses
policy[1] # 34
policy[1] = 35 # TypeError: immutable
# Unpacking: one line, several names
policy_id, age, sex = policy
# Functions returning several values return a tuple
def pv_and_duration():
return 952.38, 4.6
pv, dur = pv_and_duration()
list | tuple | |
|---|---|---|
| Syntax | [1, 2] | (1, 2) |
| Mutable? | Yes | No |
| Typical use | many items of the same kind | one record with fixed fields |
| Dict key? | No | Yes |
Immutability is a feature: a tuple can’t be changed by accident.
Find things by name, not position.
rates = {"TERM": 1.24, "WHOLE_LIFE": 14.50, "UL": 9.10}
rates["TERM"] # 1.24
rates["ANNUITY"] # KeyError!
rates.get("ANNUITY") # None
rates.get("ANNUITY", 0.0) # 0.0 (a default)
"UL" in rates # True
rates["ANNUITY"] = 6.75 # add or update
del rates["UL"] # remove
for product, rate in rates.items():
print(f"{product:<11}{rate:>6.2f}")
key: value pairs.keys(), .values(), .items() to iterateRate tables, assumption sets, product parameters, a policy’s fields — dictionaries are everywhere in actuarial code.
Is policy "P999999" among a million IDs?
ids_list = [f"P{i:06d}" for i in range(1_000_000)]
ids_set = set(ids_list)
"P999999" in ids_list # checks items one by one
"P999999" in ids_set # hashes, then one check
| List | ≈ 12 ms | grows with size: O(n) |
| Set / dict | ≈ 40 ns | constant: O(1) |
Measured on one laptop: ~300,000× faster. Your timings will differ; the gap won’t.
products = ["TERM", "UL", "TERM", "TERM"]
set(products) # {'TERM', 'UL'}
in_force_q1 = {"P001", "P002", "P003"}
in_force_q2 = {"P002", "P003", "P004"}
in_force_q1 & in_force_q2 # {'P002', 'P003'} both
in_force_q1 | in_force_q2 # all four either
in_force_q1 - in_force_q2 # {'P001'} lapsed or claimed
in_force_q2 - in_force_q1 # {'P004'} new business
& intersection, | union, - differenceSet difference between two in-force extracts is a one-line movement analysis: exits and new entrants.
Sets print in arbitrary order — wrap in sorted() for stable output.
for when you know how many times; while when you know when to stop.
cash_flows = [50, 50, 50, 50, 1050]
i = 0.04
pv = 0.0
for t, cf in enumerate(cash_flows, start=1):
pv += cf * (1 + i) ** -t
print(f"{pv:,.2f}") # 1,044.52
list(range(5)) # [0, 1, 2, 3, 4]
list(range(1, 11, 3)) # [1, 4, 7, 10]
list(range(10, 0, -2)) # [10, 8, 6, 4, 2]
for age, qx in zip(range(40, 46), q):
print(age, qx)
for x in collection: runs the block once per itemrange(start, stop, step) — same “stop excluded” rule as slicingenumerate() gives (position, item) pairszip() walks several sequences in step+= is shorthand: pv = pv + …Avoid for k in range(len(cash_flows)) — enumerate is clearer and harder to get wrong.
for really does>>> it = iter([10, 20])
>>> next(it)
10
>>> next(it)
20
>>> next(it)
StopIteration
A for loop asks its collection for an iterator, calls next() repeatedly, and stops at StopIteration.
range are all iterablerange(1_000_000_000) uses almost no memory: it produces numbers on demand# Sum 1 + r + r^2 + ... until terms are negligible
r = 0.9
tol = 1e-10
max_iter = 10_000
total, term, k = 0.0, 1.0, 0
while term > tol:
total += term
term *= r
k += 1
if k >= max_iter:
raise RuntimeError("did not converge")
print(k, total) # 219 9.999999999046972
print(1 / (1 - r)) # 10.000000000000002
while condition: repeats as long as the condition holdstol) decides when we’re close enoughbreak exits a loop early; continue skips to the next passForget to update term and the loop runs forever. Ctrl+C in a terminal, or Interrupt kernel in Jupyter.
Approximate an infinite sum with a while loop and compare to the closed form.
Value a payment of 1 forever by summing vk; confirm it equals 1/i.
Evaluate cash flows as a polynomial in v, solve for a mortgage payment, and roll the balance forward.
Work in notebooks/lesson02_lab.ipynb. Explain each result in a Markdown cell: what the numbers mean, and whether they match theory.
Starting from the while loop on the earlier slide:
tol = 1e-10?abs(total - 1/(1-r)) for each r$$a_{\overline{\infty}|} = \sum_{k=1}^{\infty} v^k = \frac{1}{i}$$
i = 0.05
v = 1 / (1 + i)
total, term, k = 0.0, v, 0
while term > 1e-8:
total += term
term *= v
k += 1
print(k, total, 1 / i)
# 377 19.99999979456978 20.0
Then: a perpetuity-due (theory: 1/d = 21), and one paying 100 at time 1 growing 2% a year (theory: 100/(i − g) ≈ 3,333.33).
Present value of cash flows c0, c1, …, cn is a polynomial:
$$PV = c_0 + c_1 v + c_2 v^2 + \dots + c_n v^n$$
Horner’s method evaluates it with one multiply and one add per coefficient:
coeffs = [0, 50, 50, 50, 50, 1050] # c_0 ... c_5
v = 1 / 1.04
result = 0.0
for c in reversed(coeffs):
result = result * v + c
print(result) # 1044.5182233101614
Same bond as the for-loop slide — same answer, fewer powers.
j = 0.06 / 12, n = 360[0] + [1] * n and evaluate it at v → an ≈ 166.7916| Month | Interest | Principal | Balance |
|---|---|---|---|
| 1 | 1,000.00 | 199.10 | 199,800.90 |
| 2 | 999.00 | 200.10 | 199,600.80 |
| 12 | 988.77 | 210.33 | 197,543.98 |
Check: the balance after 360 payments should be ≈ 0. Is it exactly 0? Why not?
.get() defaultsfor with range, enumerate, and zipwhile loop I write has a tolerance and a guardPackage your formulas as reusable, tested functions — discount factors, interest conversions, and annuities.