6  Functional Abstractions

Alec Loudenback

The purpose of abstraction is not to be vague, but to create a new semantic level in which one can be absolutely precise. - Edsger Dijkstra (1972)

6.1 Chapter Overview

Demonstrate different approaches to a problem which gradually introduce more re-usable or general techniques. These techniques will allow for constructing sophisticated models while maintaining consistency and simplicity. Imperative programming, functional programming, and recursion.

6.2 Introduction

This chapter will center around a simple task: calculate the present value of a portfolio of a single fixed, risk-free, coupon-paying bonds under two different interest rate environments. The focus will be on describing different approaches to this problem, not be adding complexity to the problem (e.g. no getting into credit spreads, settlement timing, etc.).

Mathematically, the problem is to determine the \(\text{Present Value}\), where:

\[ \text{Present Value} = \sum{\text{Cashflow}_t \times \text{Discount Factor}_t} \]

Where

\[ \text{Discount Factor}_t = \prod^t{\frac{1}{1+\text{Discount Rate}_i}} \]

cf_bond = [10, 10, 10, 10, 110];
1rate = [0.05, 0.06, 0.05, 0.04, 0.05];
1
The rates are the one year forward rates for time 0, 1, 2, etc.

We will focus on this first discount vector, and introduce more scenarios later in the chapter.

We will repeatedly solve the same problem before extending it to more examples. It may feel repetitive but the focus here is not the problem, but rather the variations between the different approaches.

6.3 Imperative Style

One of the most familiar styles of programming is called imperative (or procedural), where we provide explicit, step-by-step instructions to the computer. The programmer defines the data involved and how that data moves through the program one step at a time. It commonly uses loops to perform tasks repeatedly or across a set of data. The program’s state (assignment and logic of the program’s variables) is defined and managed by the programmer explicitly.

Here’s an imperative style of calculating the present value of the bond.

let
1    pv = 0.0
    discount = 1.0

2    for i in 1:length(cf_bond)
        discount = discount / (1 + rate[i])
3        pv = pv + discount * cf_bond[i]
    end
    pv
end
1
Declare variables to keep track of the discount rate and running total for the present value pv
2
Loop over the length of the cashflow vector.
3
At each step of the loop, look up (via index i) update the discount factor to account for the prevailing rate and add the discounted cashflow to the running total present value.
121.48888490821489

This style is simple, digestible, and clear. If we were performing the calculation by hand, it would likely follow a pattern very similar to this. Look up the first cashflow and discount rate, compute a discount factor, and subtotal the value. Repeat for the next set of values.

6.3.1 Iterators

Note that in the prior code example we defined an index variable i and had to manually define the range over which it would operate (1 through the length of the bond’s cashflow vector). A couple of reasons this could be sub-optimal:

  1. We are making the assumption that the indices of the vectors start with one, when in reality Julia arrays can be defined to start at 0 or another arbitrary index.
  2. We manually perform the lookup of the values within each iteration.

We can solve the first one (partially) by letting Julia return an iterable set of values corresponding to the indices of the cf_bond vector. This is an example of an iterator which is an object upon which we can repeatedly ask for the next value until it tells us to stop.

By using eachindex we can get the indices of the vector since Julia already knows what they are:

eachindex(cf_bond)
Base.OneTo(5)
NoteLazy Programming

The result, Base.OneTo(5) is a lazy object which represents a collection that does not get fully instantiated until asked to (which may not actually be necessary). Many (most?) iterators are lazy but we can interact with them without fully instantiating the data that they represent. Instantiating means fully loading the values into memory.

An analogy is that we can write the “set of all numbers from 1 to 100” without writing out each of the 100 numbers, but we are referring to the same thing.

An example of operating on a lazy iterator, is that we could find the largest index:

maximum(eachindex(cf_bond))
5

The point is if we have an object that represents a set, we need not actually enumerate each element of the set to interact with it.

We can fully instantiate an iterator with collect

collect(eachindex(cf_bond))
5-element Vector{Int64}:
 1
 2
 3
 4
 5

Laziness is generally a good thing in programming because sometimes it can be computationally or memory expensive to fully instantiate the collection of interest (this will be discussed further in Chapter 9).

And when used in context:

let
    pv = 0.0
    discount = 1.0

    for i in eachindex(cf_bond)
        discount = discount / (1 + rate[i])
        pv = pv + discount * cf_bond[i]
    end
    pv
end
121.48888490821489

Here Julia gave us the index associated with the bond cashflows, but we are still looking up the values (why not just ask for the values instead of their index?) as well as assuming that the indices are the same for the discount rates.

We can get the value and the associated index with enumerate:

collect(enumerate(cf_bond))
5-element Vector{Tuple{Int64, Int64}}:
 (1, 10)
 (2, 10)
 (3, 10)
 (4, 10)
 (5, 110)

This would allow us to skip the step of needing to look up the bond’s cashflows. However, we can go even further by just asking for value associated with both collections. With zip (named because it’s sort of like zipping up two collections together), we get an iterator that provides the values of the underlying collections:

collect(zip(cf_bond, rate))
5-element Vector{Tuple{Int64, Float64}}:
 (10, 0.05)
 (10, 0.06)
 (10, 0.05)
 (10, 0.04)
 (110, 0.05)

This provides the simplest implementation of the imperative approaches:

let
    pv = 0.0
    discount = 1.0

    for (cf, r) in zip(cf_bond, rate)
        discount = discount / (1 + r)
        pv = pv + discount * cf
    end
    pv
end
121.48888490821489

The primary downsides to iterative approaches to algorithms are:

  1. Needing to keep track of state is fine in simple cases, but can quickly become difficult to reason about and error prone as the number and complexity of variables grows.
  2. Program flow is explicitly stated, leaving fewer places that the compiler can automatically optimize or parallelize.
Tip

In the imperative style, we mentioned needing to explicitly handle program state. In general, it’s advisable to minimize as many temporary state variables as possible - more mutability tends to produce more complex, difficult to maintain code. An example of maintaining state in the examples above is keeping track of the current index as well as interim pv and discount variables.

Avoiding modifying values is often avoidable by restructuring the logic, using functional techniques, or finding the right abstractions. However, sometimes for performance reasons, clarity, or expediency you may find modifying state to be the preferred option and that’s okay.

6.4 Functional Techniques and Terminology

Functional programming is a paradigm which attempts to minimize state via composing functions together.

Table 6.1 introduces a set of core functional methods to familiarize yourself with. Note that anonymous functions (Section 5.5.6) are used frequently to define intermediary steps.

Table 6.1: Important Functional Methods.
Function Description Example
map(f,v) Apply function f to each element of the collection v.
map(
    x->x^2,
    [1,3,5]
) # [1,9,25]
reduce(op,v)

Apply binary op to pairs of values, reducing the dimension of the collection v.

Has a couple of important, optional keyword arguments to note (which also apply to other variants of reduce below):

  • init defines the identity element (e.g. the initial value of + and * is 0 and 1 respectively)
  • dims defines which dimension to reduce across (if the dimension of v is more than one).
reduce(
    *,
    [1,3,5]
) # 15
mapreduce(f,op,v) Maps f over collection v and returns a reduced result using op.
mapreduce(
    x->x^2,
    *,
    [1,3,5]
) # 225
foldl(op,v)foldr(op,v) Like reduce, but applies op from left to right (foldl) or right to left (foldr). Also has mapfoldl and mapfoldr versions.
foldl(
    *,
    [1,3,5]
) # 15
accumulate(op,v) Apply op along v , creating a vector with the cumulative result.
accumulate(
    +,
    [1,3,5]
) # [1, 4, 9]
filter(f,v) Apply f along v and return a copy of v with elements where f is true
filter(
    >=(3),
    [1,3,5]
) # [3, 5]

This paradigm is very powerful in a few ways:

  1. It provides a language for talking about what a computation is doing. Instead of “looping over a collection called portfolio and calling a value function” we can more concisely refer to this as mapreduce(value,portfolio).
  2. Often times you are forced to think about the design of the program more deeply, recognizing the core calculations and data used within the model.
  3. The compiler is free to apply more optimizations. For example, with reduce, the compiler could drive the calculation in any order since the operation is associative.
  4. The lack of mutable state.

Let’s build a version of the present value calculation using the functional building blocks described above. We will work up to it by discussing the core functional programming building blocks, culminating in combining mapreduce and accumulate to do the bond valuation.

6.4.1 map

map is so named for the mathematical concept of mapping an input to an output. Here, it’s effectively the same thing. We take a collection and use the given function to calculate an output. The size of the output equals the size of the input.

First, we will use map to compute the one-period discount factors:

map(x -> 1 / (1 + x), rate)
5-element Vector{Float64}:
 0.9523809523809523
 0.9433962264150942
 0.9523809523809523
 0.9615384615384615
 0.9523809523809523

map transforms the rate collection by applying the anonymous function x -> 1 / (1 + x), which is the single period discount factor. This operation is conveyed visually in Figure 6.1.

G cluster_0 Input Array (rate) cluster_1 Map Function cluster_2 Output Array rate1 0.05 map1 1 / (1+x) rate1->map1 rate2 0.06 map2 1 / (1+x) rate2->map2 rate3 0.05 map3 1 / (1+x) rate3->map3 rate4 0.04 map4 1 / (1+x) rate4->map4 rate5 0.05 map5 1 / (1+x) rate5->map5 output1 0.9524 map1->output1 output2 0.9434 map2->output2 output3 0.9524 map3->output3 output4 0.9615 map4->output4 output5 0.9524 map5->output5
Figure 6.1: A diagram showing that map creates a new collection mirroring the old one, after applying the given function to each element in the original collection.
Tip

map is an absolute workhorse of a function and the authors recommend using it liberally within your code. We find ourselves using map frequently, usually avoiding defining an explicit loop (unless we are modifying some existing collection).

map would likely be a better tool for a loop like this:

output = []
for x in collection
    result = # ... do stuff ...
    push!(output,result)
end
output

Instead, map simplifies this to:

map(collection) do x
    # ... do stuff
end

Not only does this have the advantage of being clearer, more concise, and less work, it also let’s Julia infer the output type of your computation so you don’t have to worry about the type of output.

6.4.2 accumulate

accumulate takes an operation and a collection and returns a collection where each element is the cumulative result of applying the operation from the first element to the current one. For example, to calculate the cumulative product of the one-period discount factors:

accumulate(*, map(x -> 1 / (1 + x), rate))
5-element Vector{Float64}:
 0.9523809523809523
 0.898472596585804
 0.8556881872245752
 0.822777103100553
 0.7835972410481457

This results in a vector of the cumulative discount factors for each point in time corresponding to the given cashflows.

G cluster_3 cluster_2 Output Array cluster_0 Input Array (discount factors) cluster_1 Accumulate Function (*) df1 0.9524 acc1 df1 * init df1->acc1 df2 0.9434 acc2 df2 * output_1 df2->acc2 df3 0.9524 acc3 df3 * output_2 df3->acc3 df4 0.9615 acc4 df4 * output_3 df4->acc4 df5 0.9524 acc5 df5 * output_4 df5->acc5 output1 0.9524 acc1->output1 output2 0.8985 acc2->output2 output3 0.8561 acc3->output3 output4 0.8230 acc4->output4 output5 0.7831 acc5->output5 output0 init=1.0 output0->acc1 output1->acc2 output2->acc3 output3->acc4 output4->acc5
Figure 6.2: A diagram showing that accumulate creates a new collection where each element is the cumulative result of applying the given operation to all previous elements.
Note

For accumulate and reduce, an important, optional value is the init (an optional keyword argument), which is the initial value to start the accumulation or reduction. For common operations this identity element is already predefined. For example, for + the identity is 0 while for * it is 1. The identity element \(e\) is the one where for a given binary operation \(\bigodot\), that \(x \bigodot e = x\).

Another example is string concatenation. In Julia, two strings are concatenated with * (like in mathematics, \(a * b\) is also written as \(ab\)). The identity element for strings where the binary operation \(\bigodot = *\) is "". For example:

accumulate(*, ["a", "b", "c"], init="")
3-element Vector{String}:
 "a"
 "ab"
 "abc"

This is a taste of a branch of mathematics known as Category Theory, a very rich subject but largely beyond the immediate scope of this book. The category theoretical term for sets of things that work with the binary operator and identity elements as described above is a monoid. There will not be a quiz on this trivia.

6.4.3 reduce

reduce takes an operation and a collection and applies the operation repeatedly to pairs of elements until there is only a single value left.

For example, we start with the calculation of the vector of discounted cashflows

dfs = accumulate(*, map(x -> 1 / (1 + x), rate))
discounted_cfs = map(*, cf_bond, dfs)
5-element Vector{Float64}:
  9.523809523809524
  8.98472596585804
  8.556881872245752
  8.22777103100553
 86.19569651529602

Then we can sum them with reduce:

reduce(+, discounted_cfs)
121.48888490821487
G cluster_0 Input Array (discounted cashflows) cluster_1 Reduce Function (+) cluster_2 Output Value cf1 9.52 red0 init=0.0 + cf1 cf1->red0 cf2 8.98 red1 red0 + cf2 cf2->red1 cf3 8.56 red2 red1 + cf3 cf3->red2 cf4 8.23 red3 red2 + cf4 cf4->red3 cf5 86.20 red4 red3 + cf5 cf5->red4 red0->red1 red1->red2 red2->red3 red3->red4 output 121.49 red4->output
Figure 6.3: A diagram showing how reduce applies the given operation to pairs of elements, ultimately reducing the collection to a single value.

6.4.4 mapreduce

We can combine map, accumulate and reduce to concisely calculate the present value in a functional style. This calculates the discount factors, applies them to the cashflows with map, and sums the result with a reduction:

1dfs = accumulate(*, map(x -> 1 / (1 + x), rate))
2mapreduce(*, +, cf_bond, dfs)
1
Multiplicatively accumulate a discount factor derived from the rate vector.
2
Multiply the discount factor and bond cashflows (map the multiplication), then sum the result (additive reduce).
121.48888490821487

Contrast this example with the earlier imperative styles:

  • This functional approach is more concise.
  • The functions used are more descriptive and obvious (once familiar with them, of course!).
  • There is no state that the user/programmer keeps track of.
  • The compiler is able to potentially optimize the code, as it can deduce that certain operations are associative.

This completes the example of using a functional approach to determine the present value of bond cashflows.

6.4.5 filter

For completeness, we will also cover filter even though it’s not necessary for the bond cashflow example.

filter does what you might think - filter a collection based on some criterion that can be determined as true or false.

For example filtering out even numbers using the isodd function:

filter(isodd, 1:6)
3-element Vector{Int64}:
 1
 3
 5

Or filtering out things that don’t match a criteria:

filter(x -> ~(x == 5), 1:6)
5-element Vector{Int64}:
 1
 2
 3
 4
 6

While we didn’t need filter to calculate a bond’s present value in the example above, one can imagine how you may want to filter dates that a bond might pay a cashflow, say last day of a quarter:

using Dates
let d = Date(2024, 01, 01)
    filter(d -> lastdayofquarter(d) == d, d:Day(1):lastdayofyear(d))
end
4-element Vector{Date}:
 2024-03-31
 2024-06-30
 2024-09-30
 2024-12-31

6.4.6 More Tips on Functional Styles

6.4.6.1 do Syntax for Function Arguments

In more complex situations such as with multiple collections or multi-line logic, there is a clearer syntax that is often used. do is a reserved keyword in Julia that creates an anonymous function and passes its arguments to a function like map. For example, this (terrible) code which decides if a number is prime. The anonymous function requires a begin block since the logic of the function is extended into multiple lines.

map(x -> begin
  if x == 1
  "prime"
  elseif x == 2
  "not prime"
  elseif x == 3
  "prime"
  elseif x > 4
  "probably not prime"
  end
end, 
[1, 2, 3, 10]
)

This can be written more cleanly with the do syntax:

map([1, 2, 3, 10]) do x
if x == 1
"prime"
elseif x == 2
"not prime"
elseif x == 3
"prime"
elseif x > 4
"probably not prime"
end)

6.4.6.2 Multiple Collections

map and the other functional operators discussed in this section can take multiple arguments. This is convenient if you have multiple arguments to a function:

discounts = [0.9, 0.81, 0.73]
cashflows = [10, 10, 10]

map((d, c) -> d * c, discounts, cashflows)
3-element Vector{Float64}:
 9.0
 8.100000000000001
 7.3

Or an example with the do syntax:

map(discounts, cashflows) do d, c
    d * c
end
3-element Vector{Float64}:
 9.0
 8.100000000000001
 7.3

6.4.6.3 Using More Functions

At the risk of sounding obvious, an easy way to make the program more “functional” is to simply use more functions. Do this one thing and it will improve the model’s organization, maintainability, and reduce bugs!

Take the example from earlier:

pv = 0.0
discount = 1.0

for (cf, r) in zip(cf_bond, rate)
    discount = discount / (1 + r)
    pv = pv + discount * cf
end
pv

We can easy turn this code into a function so that it can operate on data beyond the single pair of cf_bond and rate previously defined:

function pv(rates,cashflows)
    pv = 0.0
    discount = 1.0

1    for (cf, r) in zip(rates, cashflows)
        discount = discount / (1 + r)
        pv = pv + discount * cf
    end
    pv
end
1
Here, cf_bond and rate would refer to whatever was passed as arguments to the function instead of any globally defined values.

Now we could use this definition of pv on other instances of rates and cashflows.

6.4.6.4 Mixing Functional And Imperative Styles

One of the best things about Julia is how natural it can be to mix the different styles Sometimes the best is the mix of both styles and that’s one of the benefits of Julia: use the style that’s most natural to the problem.

NoteFlexibility and the Lisp Curse

Lisp (“list processing”) is another, much older language than Julia (created in the 1950s!). One of it’s claims to fame is how flexible and powerful the tools are within the language to build upon. There’s a couple aspects of this curse that we wish to describe because we can learn from it while Julia is still a relatively young language.

Part of the “curse” is that: because there’s so much freedom in what can be expressed in the language, there’s not an obvious “best” way of doing things. This can lead to decision paralysis where you are trying to over-analyze what’s the best way to write part of your code. Our advice: don’t worry about it! A working implementation of something is better than an over-optimized idea.

The other part of the “curse” is that because is that it’s relatively easy to implement so many things from the building blocks that Julia provides and compose them together to do what you want. This has a downside because the general approach to packages is smaller, standalone pieces that you call as needed. For example, consider Python’s Pandas library, upon which Python’s data science community was built. It came bundled with a CSV reader, Excel reader, Database reader, DataFrame type, visualization library, and statistical functions. In Julia, each of those are separate packages that specialize for the respective topics. This is advantageous in that they can progress independently from one another, you don’t have to include functionality that you don’t need, and you can mix and match libraries depending on your preference.

6.5 Array-Oriented Styles

Another paradigm is array-oriented, which is a style that relies heavily on putting similar data into arrays and operating on the entire array at the same time (as opposed to going element-by-element).

Array-oriented programming is one that is practiced in two main contexts:

  1. GPU programming
  2. Python numerical computing

The former because GPUs want large blocks of similar data to operate in parallel. The latter is because native Python is too slow for many modeling problems so libraries like NumPy,SciPy,and tensor libraries utilize C++ (or similar) libraries for users to call out to.

Array-oriented programming is not always natural for financial and actuarial applications. Differences in behavior or timing of underling cashflows can make a set of otherwise similar products difficult to capture in nicely gridded arrays. Nonetheless, certain applications (scenario generation, some valuation routines) fit very naturally into this paradigm. Furthermore, for those that work well it’s often a great way to extract additional performance due to the parallelization offered via CPU or GPU array programming.

Table 6.2 shows the bond present value example in this style.

Table 6.2: The two code examples demonstrate the same logic using Julia and Numpy (Python’s most popular array package). Julia’s broadcasting facilitate an array-oriented style, similar to the approach that would be used with Python’s NumPy.
Julia Python (NumPy)
cf_bond = [10, 10, 10, 10, 110]
rate = [0.05, 0.06, 0.05, 0.04, 0.05]

discount_factors = cumprod(1 ./ (1 .+ rate))
result = sum(cf_bond .* discount_factors)
import numpy as np

cf_bond = np.array([10, 10, 10, 10, 110])
rate = np.array([0.05, 0.06, 0.05, 0.04, 0.05])

discount_factors = np.cumprod(1 / (1 + rate))
result = np.sum(cf_bond * discount_factors)

The downsides to this style are:

  1. Sometimes it is unnatural because of non-uniformity of the data we are working with. For example if the length of the cashflows were shorter than the discount rates, we would have to perform intermediate steps to shorten or lengthen arrays in order to get them to be the same size.
  2. A good bit of runtime performance is lost because the computer needs to allocate and fill many intermediate arrays (note how in Table 6.2, the discount_factors needs to instantiate an entirely new vector even though it’s only temporarily used). See more on allocations in Chapter 9.

6.6 Recursion

A recursive function which is a pattern where current steps are defined in a way that depends on previous steps. Typically, an explicit starting condition is also required to be specified.

The Fibbonaci sequence is a classic example of a recursive algorithm, with the starting conditions of \(n\) specified for the first two steps:

\[F(n) = \begin{cases} 0, & \text{if } n = 0\\ 1, & \text{if } n = 1\\ F(n-1) + F(n-2), & \text{if } n > 1 \end{cases}\]

In code, this translates into a function definition that refers to itself:

function fibonacci(n)
    if n == 0
        return 0
    elseif n == 1
        return 1
    else
        return fibonacci(n-1) + fibonacci(n-2)
    end
end

How could a recursive pattern be defined for valuing our bond? A possible pattern is defining the present value to be the discounted value of:

  • the current period’s cashflow, plus

  • the accumulated cashflows up to that point in time

Here’s how that might be defined:

function pv_recursive(rates,cashflows,accumulated_value=0.0,discount_factor=1.0)
1  if isempty(cashflows)
    return accumulated_value
  else
2    discount_factor = discount_factor / (1+first(rates))
3    av = first(cashflows) * discount_factor + accumulated_value
    remaining_rates = rates[begin+1:end]
    remaining_cfs = cashflows[begin+1:end]
4    return pv_recursive(remaining_rates,remaining_cfs, av,discount_factor)
  end
end
1
Add a terminating condition, that if we have no more cashflows then return the accumulated value.
2
Decrement the discount factor as we step forward in time.
3
Take the prior accumulated value and add the first value in the given cashflows.
4
Pass the remaining subset of the cashflow vector, the running total, and the current discount factor to the next call of the recursive function.
pv_recursive (generic function with 3 methods)

And an example of its use:

pv_recursive(rate,cf_bond)
121.48888490821489

The recursive pattern often works very nicely for simpler examples. However, more complex logic and conditionals can make this approach unwieldy. Nonetheless, attempting to distill the desired functionality into a single function can be a beneficial thought exercise.