Education fund modelling with Haskell
Like most people, I don’t like big financial surprises or sudden, substantial changes to cashflow. Although we can’t control all circumstances, we can plan for projected future expenses, like your kids’ education. In this post I share a basic model built in Haskell to help plan for education expenses (or other large, future, time-bounded expenses).
This beginner-friendly post demonstrates many simple Haskell
functions, especially for working with lists. It also shows how to
build and execute stateful computations using State
from mtl. I
(mostly) avoid type signatures and just focus on defining the terms,
but there are plenty of links to API documentation. At the end of
the post I suggest some enhancements to the model that would be good
exercises for learners (and might be fun even for more
experienced Haskell programmers).
Scenario and simplifying assumptions §
The general scenario I built the model for is to save for private secondary school fees for two children. They are 3 years apart with the older child commencing in 4 years. Costs of primary (elementary) schooling are not considered in this scenario, although the model would accommodate that.
Given the time span (> 10 years) we have to consider inflation. The model uses a constant rate of inflation of 5% per annum, which is less than the rate of inflation in Australia at time of writing, but more than the our RBA’s long term target of 2–3%.
We also model an annual investment return of 8%. Short-term volatility is inevitable but this is less than the long-term average for the Australian stock market.
Contributions to the fund will be annual. In real life, for stable cashflow and to achieve dollar cost averaging, contributions could be made more regularly (e.g. each pay day). The model is therefore slightly pessimistic in this regard, but simpler to implement.
General description of the model §
The inputs to the model are a fee structure, and an annual contribution amount which is fixed (does not grow with inflation or income).
The model projects the costs of education in future years, and works backwards to determine how much money needs to be in the fund at the start of each year. The output of the model is a list of these required balances, the first of which is the required initial starting balance for the education fund. If the starting balance is too high, increase the yearly contribution and evaluate the model again. Continue until you find a balance between starting value and contribution amount that works for you.
Modelling the costs §
A school’s current fee structure, for the six years of secondary education, is the ordered list of these numbers:
=
feesBase 12000, 12000, 12000 -- grade 7, 8, 9
[ 13500, 13500, 13500 ] -- grade 10, 11, 12 ,
Child 1 will be starting high school in 4 years; Child 2 in 7 years.
We consider the intervening years to have nil cost, which we
represent by using replicate
to make lists of
0
of the required lengths. We append these sublists using
<>
. We also extend the shorter list with
additional zeroes (only the shorter list, because
repeat
makes an infinite list).
=
feesChild1Base replicate 4 0 <> feesBase <> repeat 0
=
feesChild2Base replicate 7 0 <> feesBase
The <>
function appends many types, where the
operation is associative and the result is always defined. You can
also append lists with ++
, which is
specific to the list type.
We could add the yearly fees using zipWith
,
whose arguments are a binary combining function and two lists:
=
feesCombinedBase zipWith (+) feesChild1Base feesChild2Base
However, many schools offer discounts when you have multiple
children enrolled. In this scenario, the school gives a 10%
discount for the younger (cheaper) child, when both are enrolled.
We define the combining function using min
,
max
, addition and multiplication:
-- define our custom combining function...
=
sumWithDiscount a b max a b + min a b * 0.9
-- ... and update feesCombinedBase to use it
=
feesCombinedBase zipWith sumWithDiscount feesChild1Base feesChild2Base
Let’s evaluate feesCombinedBase
and print
the
values. traverse_
applies an action to each
element of a list (or other container), then discards the result.
λ> traverse_ print feesCombinedBase
0.0
0.0
0.0
0.0
12000.0
12000.0
12000.0
24300.0
24300.0
24300.0
13500.0
13500.0
13500.0
Now inflation must have its way with these numbers. Use
iterate
to generate the inflation factor for
successive years (ad infinitum) by iteratively apply our inflation
rate, starting at 1
. To make the numbers presentable we’ll also
round
to 4 decimal places.
= (/ 10000) . fromIntegral . round . (* 10000)
round4 = fmap round4 (iterate (* 1.05) 1) inflation
fmap
applies a function (the first argument) to
every element of a container or producer (the second argument).
Then we can apply the inflation to our uninflated costs, year by
year. One thing I did not yet mention is that the fees above are
nearly a year old and will be going up soon, so we’ll use
drop
to “shift left” the inflation figures by one
year. Once more we use zipWith
for a very neat expression:
=
feesCombinedInflated zipWith (*) feesCombinedBase (drop 1 inflation)
Let’s print
the projected fees:
λ> traverse_ print feesCombinedInflated
0.0
0.0
0.0
0.0
15315.6
16081.2
16885.2
35903.25
37696.59
39582.27
23089.05
24244.65
25455.6
This looks right. The highest costs are in the three “overlap” years where both children are enrolled, and the impact of inflation is evident.
Modelling the fund balance §
A stateful computation will work out how much money we need in
the fund at the start of each year. Specifically, we use the
State
type from the mtl library
to define the computation.
The model takes into account the contribution amount and the growth
factor. To do this it has to work backwards in time. Each step
of the computation takes the schooling fee for that year, and the
state value tracks the required balance of the fund.
We define the step
function, which considers what happens to the
fund over one year. It must first subtract
the contribution from the required balance. This excludes the
contribution from the (presumed) growth over the year; a simplifying
assumption that is reasonable over the long-term. We also ensure
the balance will not be negative. We intend to exhaust the fund
when fees are paid in the final year, and a negative balance will
spoil the subsequent calculations. The modify
function applies its argument (the subtraction) to modify the
state value.
= do
step contrib fee max 0 . subtract contrib) modify (
Next we divide the balance (state value) by the growth rate, to obtain a nominal value of the fund at the start of the year:
/ 1.08) modify (
Finally, we have to pay the fees at (or near) the start of the year. So we must increase the required balance by that amount. Some schools offer a discount for full year payment up-front. This model applies a discount of 5%, but this is another area where the model could be parameterised.
+ (fee * 0.95)) modify (
All together the step
function contains a few simple operations.
At the end it returns the state value via the get
function. This will enable us to see how the value of the fund
changes year by year.
= do
step contrib fee max 0 . subtract contrib)
modify (/ 1.08)
modify (+ (fee * 0.95))
modify ( get
Now that we have the step
function, we can
traverse
the list of year costs and apply the
step
to each element. Like traverse_
, traverse
applies an
action to each element of a container, but instead of discarding the
results it replaces each element of the container with the “return
value” of the action.
We have to start at the final year and work backwards, so first
reverse
the list, then traverse
it:
go :: Double -> State Double [Double]
=
go contrib traverse (step contrib) (reverse feesCombinedInflated)
This is the first time I have shown a type signature in this
whole post! The go
function takes the contribution amount and
returns a state computation whose state variable (the required
balance) is a real number (Double
) and whose output is a list of
numbers (the required balance at the start of each year).
To actually run the state computation we use
evalState
, whose arguments are the state
computation and an initial state. Our initial state is $0,
because we intend to exhaust the fund when we pay for the final year
of schooling.
= fmap round (reverse (evalState go 0)) model contrib
evalState
yields the final output of the state computation,
discarding the state variable. If you instead want the final value
of the state variable, use execState
.
runState
yields the (state var, output)
pair.
The result of model
is a list of the required fund balance at the
start of each year, in order. The first value is the initial
balance required for the given yearly contribution amount.
Running the model §
Let’s see what the model tells us for various contribution amounts. First, let’s just pick a number, say $10,000.
λ> traverse_ print (model 10000)
43542
57025
71587
87314
104299
106929
108984
110379
92373
71086
46161
36165
24183
The final year’s value is 24183
. That will always be the final
value, regardless of contribution amount, because that is what the
final fee payment will be.
As for the required starting value, for a $10,000 yearly contribution we would need to start the fund with $43,542. But what if you don’t have any money to start the fund? With a bit of trial and error I found that a yearly contribution of $15,778 is enough (note the start value for the second year):
λ> traverse_ print (model 15778)
0
15776
32816
51220
71095
76847
82273
87309
73235
56195
35857
30815
24183
Finally, how much would you need if you wanted the fund to be completely passive—no further contributions after the initial amount?
λ> traverse_ print (model 0)
118903
128415
138688
149783
161766
158993
155213
150306
125494
96857
63994
45424
24183
$118,903. Not many people have that kind of money at their immediate disposal. But if you do, or if you get a big windfall, you could “set and forget” an investment for your children’s schooling, or other long-term financial objective.
Possible model enhancements or variations §
There are several ways the model could be improved, or tweaked to suit the circumstances or preferences of the investor.
You could consider increasing the contribution over time to
adjust for expected income growth. There are a several ways to do
it. One way is to pass the inflation factor to the step function
and apply it to the base contribution amount there. Another way is
to precompute the annual inflated contribution, zip
it with the
inflated fee list, and traverse
the step
function over the
(contribution, fee) pairs. I leave it to the reader to play
around, if interested.
Another obvious area for improvement is that the model is hardcoded
for exactly two children. Enhancing it to handle different
numbers of children would be a good exercise. zipWith
will no
longer cut it for combining fees. Furthermore, schools can have
diverse discount structures for multiple children (e.g. second child
10% off, 25% for third child, and so on). So the discount structure
should be parameterised. If you want to improve your skill working
with lists in Haskell, this would be a good exercise.
Other areas for improvement include dollar-cost averaging the yearly
contribution amount, or step functions for different payment
frequencies (e.g. quarterly / per term). Both of these tasks would
be good practice with State
computations.