The transition matrix
A credit rating grades how likely a borrower is to default, that is, to miss a payment it owes. This case study uses three ratings, A (strongest), B and C, and a fourth state, D, for default.
A one-year transition matrix , also called a migration matrix, lists the chance of each move in a year. Its entry is the conditional probability (lesson 5.1.3) of being rated in a year, given rating now. Rows are the rating now; columns are the rating in a year.
| now, in a year | A | B | C | D |
|---|---|---|---|---|
| A | 0.90 | 0.08 | 0.015 | 0.005 |
| B | 0.05 | 0.85 | 0.08 | 0.02 |
| C | 0.01 | 0.09 | 0.80 | 0.10 |
| D | 0 | 0 | 0 | 1 |
In a year a borrower has exactly one of the four ratings: the four outcomes are disjoint and cover every case. So by the axioms (lesson 5.1.1), each row adds up to 1. The last row says a defaulted borrower stays in default: D is absorbing.
Read off the table:
- Default within a year is the last column: 0.5% for A, 2% for B and 10% for C.
- A downgrade is a move to any lower state. Those moves are disjoint, so their chances add: for B, . A and C have 10% too.
- An upgrade for B is 5%, and for C, .
Try it
P[1] is row B, counting from 0, and P[:, 3] the default column. np.allclose checks that every row adds up to 1, allowing for float noise.
P[1, 2:].sum() adds the moves from B to C and D.
Two years: total probability as a matrix product
To default within two years, a B borrower passes through some rating at the end of year one. Those ratings form a partition, so the law of total probability (lesson 5.1.3) adds over them:
The second factor needs an assumption. The move in year two depends only on the rating at the start of that year, through the same table. Then it is , the default column of .
| rating after year 1, | A | B | C | D |
|---|---|---|---|---|
| 0.05 | 0.85 | 0.08 | 0.02 | |
| 0.005 | 0.02 | 0.10 | 1 | |
| product | 0.00025 | 0.017 | 0.008 | 0.02 |
The products add up to 0.04525. That sum is row B of dotted with column D of : the entry of , by the rule of lesson 3.2.1. So the matrix product gives every two-year probability at once:
| now, in 2 years | A | B | C | D |
|---|---|---|---|---|
| A | 0.81415 | 0.14135 | 0.0319 | 0.0126 |
| B | 0.0883 | 0.7337 | 0.13275 | 0.04525 |
| C | 0.0215 | 0.1493 | 0.64735 | 0.18185 |
| D | 0 | 0 | 0 | 1 |
The rows of add up to 1 again. A B borrower is rated C or in default after two years with chance : the chance of ending the two years below B.
Why more than 3.96%? A steady 2% a year treats the borrower as a B in both years. In fact 8% are C after one year and then face 10%: they add 0.008. The 5% upgraded to A face only 0.5%, which does not make up for it.
Try it
The first line is the table of products and their total. P @ P is the two-year matrix, and its rows still add up to 1. Its entries have at most 5 decimals, so np.round(P2, 5) shows them exactly.
The last lines simulate 100,000 B borrowers for two years, with the seed 12. Each year, a uniform number u picks the next rating from the borrower's row: the first state whose running total of the row passes u.
The seeded run gives 0.0446 for default and 0.1772 for C or D, against the exact 0.04525 and 0.178.
Five and ten years: matrix powers
The same step repeats every year, so years of moves are the power (lesson 3.6.2). Because default is absorbing, being in D after years means having defaulted at some point within them. So column D of is the chance of default within years.
| years | 1 | 2 | 3 | 5 | 10 |
|---|---|---|---|---|---|
| A | 0.005 | 0.0126 | 0.02269 | 0.04945 | 0.14223 |
| B | 0.02 | 0.04525 | 0.07364 | 0.13443 | 0.2809 |
| C | 0.1 | 0.18185 | 0.24968 | 0.35496 | 0.51839 |
Case study 2.5.5 gave a borrower a constant hazard rate. At 2% a year that meant 9.52% within 5 years, and a steady 2% each year gives 9.61%. The rating matrix says 13.44% for B, because B borrowers drift down.
For C it goes the other way. A steady 10% gives 40.95% within 5 years, but the matrix only 35.50%, because C borrowers drift up.
Default year by year
The chance of default in year , given no default before, is a conditional probability (lesson 5.1.3):
The top is the chance of defaulting in year itself; the bottom shrinks the sample space to the borrowers still standing. For B it rises from 2% in year 1 to 3.69% in year 10. For A it rises from 0.5% to 2.33%, while for C it falls from 10% to 5.02%.
Default within years, and in year alone for borrowers still standing. Strong ratings get riskier as their survivors drift down; weak ones get safer, since the survivors are increasingly those that improved.
A single constant rate cannot show either shape. In this table every borrower defaults in the end: after 100 years, column D of is above 0.95 for every rating. Lesson 5.11.2 studies such long runs as a Markov chain.
Try it
matrix_power(P, k) is . The loop prints column D for A, B and C, then the year-by-year conditional chance for each rating.
The last line compares B and C with a steady 2% and 10% a year over 5 years.
A loan book
A bank's book of loans is a mix of ratings. Write the shares as a row vector, : 40% A, 40% B, 20% C and none in default. By the law of total probability, next year's mix is the row vector times the matrix:
The default share after one year is . After five years it is the last entry of , 0.1445.
Lesson 3.6.2's two towns used the other layout: the columns of added up to 1, and multiplied a column of shares. Here the rows add up to 1, and the row of shares multiplies from the left. The two are transposes: is lesson 3.6.2's form.
Try it
s @ P is the next year's mix and s @ P5 the mix after five years. The last line works in lesson 3.6.2's layout: times the column for a B borrower gives row B of .
An early-warning signal: Bayes' rule
Banks watch for warning signs: late payments, a broken loan condition, a falling share price. Say the bank's signal has been tested on past borrowers.
Of those that defaulted within the year, 70% had been flagged at its start: the hit rate. Of those that did not, 10% were flagged: the false-alarm rate.
A B borrower is flagged. Its prior is the table's 2%, and Bayes' rule (lesson 5.1.4) gives
A table of counts checks it. Of 10,000 B borrowers, 200 default, and 140 of those were flagged. Of the 9,800 others, 980 were flagged, so 140 of the 1,120 flags are real: 12.5%.
In odds (lesson 5.1.4), the likelihood ratio is . The odds go from to , a probability of .
The flag makes this B borrower riskier than the table's C, at 10%. No flag works the other way: the likelihoods are and , and the chance falls to 0.676%.
| rating | prior | flagged | not flagged |
|---|---|---|---|
| A | 0.005 | 0.034 | 0.00167 |
| B | 0.02 | 0.125 | 0.00676 |
| C | 0.10 | 0.4375 | 0.03571 |
The base-rate trap of lesson 5.1.4 shows up for A. A flagged A borrower still has only a 3.4% chance of default: most flags on strong borrowers are false alarms.
Try it
posterior is Bayes' rule with a hit rate and a false-alarm rate. Without a flag, both are replaced by their complements.
Then a million B borrowers with the seed 11: each defaults with probability 0.02 and is flagged with probability 0.7 or 0.1.
In the seeded run 111,993 borrowers were flagged, and a share 0.1266 of them defaulted, against the exact 0.125. Among the rest the share was 0.00676.
Try it
The first line renormalises a rounded row that adds up to 1.0018. The second shows a typo caught by the row check: B's default entry typed as 0.03.
Default probabilities from a rating table
Write two functions.
default_probability(P, start, years)returns the chance that a borrower in statestartis in default afteryearsyears.Pis a square transition matrix (a NumPy array or nested lists), rows the state now, with default as the last state. Return entry(start, last)of , so 0 years gives 0, or 1 ifstartis the default state. RaiseValueErrorifPis not square, has an entry outside , or has a row that does not add up to 1 within . Raise it too ifyearsis negative or not a whole number.flagged_default(prior, hit, false_alarm, flagged=True)returns the chance of default after the signal, by Bayes' rule. Without a flag (flagged=False), use and as the likelihoods. RaiseValueErrorif any input is outside , or if the evidence has probability 0.
The tests use the table of this case study: 0.04525 for B over 2 years, 0.125 for a flagged B.
See the tests
import numpy as np
P = [
[0.90, 0.08, 0.015, 0.005],
[0.05, 0.85, 0.08, 0.02],
[0.01, 0.09, 0.80, 0.10],
[0.00, 0.00, 0.00, 1.00],
]
def _raises(f, *args, what=""):
try:
f(*args)
except ValueError:
return
raise AssertionError(f"expected ValueError for {what}")
def test_b_two_years():
"""A B borrower defaults within 2 years with probability 0.04525"""
expect_close(default_probability(P, 1, 2), 0.04525, what="default_probability(P, 1, 2)", rel=1e-9)
def test_one_year_is_the_table():
"""After 1 year the answer is the table's default column: 0.005, 0.02, 0.10"""
for start, expected in [(0, 0.005), (1, 0.02), (2, 0.10)]:
expect_close(default_probability(P, start, 1), expected, what=f"default_probability(P, {start}, 1)", rel=1e-12)
def test_longer_horizons():
"""B within 5 years is about 0.1344, A within 10 years about 0.1422"""
expect_close(default_probability(P, 1, 5), 0.13442818405, what="default_probability(P, 1, 5)", rel=1e-9)
expect_close(default_probability(P, 0, 10), 0.1422328059941972, what="default_probability(P, 0, 10)", rel=1e-9)
def test_zero_years_and_default_start():
"""After 0 years nobody has defaulted, and a defaulted borrower stays in default"""
expect_close(default_probability(P, 1, 0), 0.0, what="default_probability(P, 1, 0)")
expect_close(default_probability(P, 3, 7), 1.0, what="default_probability(P, 3, 7)")
def test_other_sizes_and_arrays():
"""A two-state table works too: 1 - 0.9 cubed after 3 years, from a NumPy array"""
expect_close(default_probability(np.array([[0.9, 0.1], [0.0, 1.0]]), 0, 3), 0.271, what="two-state table", rel=1e-9)
def test_bad_tables():
"""A row adding up to 1.01, a negative entry or a non-square table raises ValueError"""
typo = [row[:] for row in P]
typo[1][3] = 0.03
_raises(default_probability, typo, 1, 2, what="a row adding up to 1.01")
negative = [[1.1, -0.1], [0.0, 1.0]]
_raises(default_probability, negative, 0, 1, what="a negative entry")
_raises(default_probability, [[0.5, 0.5, 0.0], [0.0, 0.5, 0.5]], 0, 1, what="a 2 x 3 table")
def test_bad_years():
"""Negative or fractional years raise ValueError"""
_raises(default_probability, P, 1, -1, what="years = -1")
_raises(default_probability, P, 1, 2.5, what="years = 2.5")
def test_flag_on_b():
"""A flagged B borrower (prior 2%, hit 70%, false alarm 10%) defaults with probability 0.125"""
expect_close(flagged_default(0.02, 0.7, 0.1), 0.125, what="flagged_default(0.02, 0.7, 0.1)", rel=1e-9)
def test_no_flag_on_b():
"""Without a flag the same borrower's chance falls to about 0.00676"""
expect_close(flagged_default(0.02, 0.7, 0.1, flagged=False), 0.006756756756756757, what="flagged_default(..., flagged=False)", rel=1e-9)
def test_edge_cases():
"""An uninformative signal leaves the prior; no false alarms give 1; a prior of 0 stays 0"""
expect_close(flagged_default(0.3, 0.6, 0.6), 0.3, what="hit = false alarm", rel=1e-12)
expect_close(flagged_default(0.01, 0.9, 0.0), 1.0, what="false_alarm = 0", rel=1e-12)
expect_close(flagged_default(0.0, 0.9, 0.05), 0.0, what="prior = 0")
def test_bad_probabilities():
"""Inputs outside [0, 1], or evidence with probability 0, raise ValueError"""
_raises(flagged_default, 2.0, 0.7, 0.1, what="prior = 2")
_raises(flagged_default, 0.02, 70, 0.1, what="hit = 70 (a percentage, not a decimal)")
_raises(flagged_default, 0.02, 0.0, 0.0, what="a signal that never fires")
Summary card
- A transition matrix holds conditional probabilities ; each row adds up to 1, and default is absorbing.
- Two-year probabilities come from the law of total probability over the rating after one year, which is the matrix product . For B, 4.525% against 3.96% at a steady 2%.
- Column D of is the chance of default within years. The chance in each year, given survival, rises for strong ratings and falls for weak ones.
- A loan book's mix moves as a row vector times ; lesson 3.6.2's layout is the transpose.
- A warning signal updates the default chance by Bayes' rule: a flagged B goes from 2% to 12.5%, a flagged A only to 3.4%.