Stochastic population model in R, Rcpp, Racket, and Typed Racket

On my journey to learn Racket, I look for small pieces of R code to try to implement in Racket. A blog post about speeding up population simulations in R with the Rcpp package seemed like a good candidate for implementing in Racket. I was particularly interested in the (superficial?) parallels between R/Rcpp and Racket/Typed Racket.

Running a single simulation

First, let’s get some of the setup code1 out of the way.2 In this chunk of code, we are loading the math library and stochastic-logistic-typed.rkt, which contains the typed versions of the functions. Using require with a file is similar to loading a script with source in R. Racket is a programming language laboratory with the nice feature that it is easy to use code from different languages (#langs) in the same file.

#lang racket

(require math

(define YINIT 1.0)        ; initial population size
(define R 1.4)            ; maximum population growth rate
(define K 20.0)           ; carrying capacity
(define THETASD 0.1)      ; standard deviation for adding noise to population
(define T 100)            ; number of years to run simulation
(define REPS 1000)        ; number of replications
(define T2 (* T REPS))    ; used to compare difference between long-running simulation and many calls to simulation
(define TIME-SAMPLES 100) ; number of samples to run when timing functions

In the spirit of learning, I wanted to try to write the Racket version of the logmodr function with recursion. I managed to work out a clunky version (not shown here) and reached out to the Racket mailing list for help. Here is one suggested alternative:

(define (logmod t y r k thetasd)

  (define (calc y)
    (define theta (flvector-ref (flnormal-sample 0.0 thetasd 1) 0))
    (* y (- r (* r (/ y k))) (exp theta)))
  (define (loop y i)
    (if (= i t)
        (list y)
        (cons y (loop (calc y) (add1 i)))))
  (loop y 1))

My clunky version had calc and loop as separate functions and I struggled to figure out how to pass the extra function arguments (i.e., r, k, and thetasd) to the standalone loop function. Several solutions were suggested on the mailing list. I gravitated to the nested functions approach shown above,3 but, as I write this, I’m having trouble articulating why I liked that option the best. Check out the thread on the mailing list and decide for yourself.

I also wrote a version, logmod-vec, that is a direct translation of the R code in the logmodr function. This version was easy to write because I had already invested time in learning about for loops in Racket..

(define (logmod-vec t y r k thetasd)
  (define y-vec (make-vector t))
  (vector-set! y-vec 0 y)
  (define theta-vec (flnormal-sample 0.0 thetasd t))
  (for ([i (in-range 1 t)])
    (define last-y (vector-ref y-vec (sub1 i)))
    (define theta (flvector-ref theta-vec i))
    (vector-set! y-vec i (* last-y (- r (* r (/ last-y k))) (exp theta)))))

We can create a typed version of logmod simply by adding type annotations. The rest of the code is the same as in the untyped version. The syntax for the type annotations is straightforward. The type of each argument is specified, followed by ->, and then the type of the output. All of the types and their relationships are thoroughly described in the documentation. Moreover, the type-checker was helpful for pointing me to the places where I was misspecifying types.

#lang typed/racket

(require math)

(provide logmod-typed repeat-logmod-typed)

(: logmod-typed : Integer Flonum Flonum Flonum Flonum -> (Listof Flonum))
(define (logmod-typed t y r k thetasd)

  (: calc : Flonum -> Flonum)
  (define (calc y)
    (define theta (flvector-ref (flnormal-sample 0.0 thetasd 1) 0))
    (* y (- r (* r (/ y k))) (exp theta)))

  (: loop : Flonum Integer -> (Listof Flonum))
  (define (loop y i)
    (if (= i t)
        (list y)
        (cons y (loop (calc y) (add1 i)))))
  (loop y 1))

We need to make a quick digression to talk about the code4 that I wrote to get sample timings for the different functions. time-apply-cpu is a simple wrapper function to time-apply that runs time-apply repeatedly and prints the min, mean, and max cpu time to the interactions pane. time-apply produces multiple output values that are not contained in a data structure. define-values allows you to capture those outputs and bind them to names (in this case, results, cpu-time, real-time, gc-time). string-append is similar to paste in R, but string-append requires that all arguments are strings and, thus, requires some conversion (e.g., number->string).5

(define (time-apply-cpu proc lst reps)
  (define out
    (for/list ([i (in-range reps)])
      (define-values (results cpu-time real-time gc-time) (time-apply proc lst))
  (displayln (string-append "min: " (number->string (apply min out))
                            " mean: " (number->string (round (mean out)))
                            " max: " (number->string (apply max out))
                            "    function: "
                            (symbol->string (object-name proc)))))

We will now use time-apply-cpu to compare our 3 functions.

> (time-apply-cpu logmod (list T2 YINIT R K THETASD) TIME-SAMPLES)
min: 84 mean: 148 max: 1385    function: logmod
> (time-apply-cpu logmod-typed (list T2 YINIT R K THETASD) TIME-SAMPLES)
min: 66 mean: 101 max: 828    function: logmod-typed
> (time-apply-cpu logmod-vec (list T2 YINIT R K THETASD) TIME-SAMPLES)
min: 25 mean: 28 max: 59    function: logmod-vec

I want to be cautious not to overinterpret these results because I know just enough to be dangerous. I am intrigued by the good performance of logmod-vec because, generally, I am most comfortable programming in an imperative style. The relatively modest speedup provided by Typed Racket presumably is not representative of the performance bumps that you can get in other situations. For example…

Performance Warning: Matrix values are arrays, as exported by math/array. The same performance warning applies: operations are currently 25-50 times slower in untyped Racket than in Typed Racket, due to the overhead of checking higher-order contracts. We are working on it. Source

I will also cautiously interpret the timings of the R code. Although the Racket and R timing results are all in milliseconds, it seems unlikely that this is an apples-to-apples comparison. Nonetheless, in this simple example, R performance is arguably comparable to Racket.

Unit: milliseconds
                                 expr       min        lq      mean    median        uq      max neval
 logmodc(1e+05, yinit, r, k, thetasd)  5.874801  6.568564  6.702172  6.622926  6.732324 10.74266   100
 logmodr(1e+05, yinit, r, k, thetasd) 24.786605 25.487129 25.914484 25.654279 26.014754 31.76419   100

One clear result is that the 19x speedup of logmodc over logmodr reported in the original blog post was not reproduced here. The original blog post is only 2 years old but R releases in those two years have targeted performance improvements.

Running multiple simulations

I took a slightly different approach to running multiple simulations than in the original blog post. Instead of running multiple simulations where one parameter is varied, I repeated the simulation many times with the same paramters. In Racket, I used for/list to loop through the number of replications.

(define (repeat-logmod reps t y r k thetasd)
  (for/list ([i (in-range reps)]) (logmod t y r k thetasd)))
(define (repeat-logmod-vec reps t y r k thetasd)
  (for/list ([i (in-range reps)]) (logmod-vec t y r k thetasd)))

The timings for the Racket code showed similar results to the single simulation results. Good performance of repeat-logmod-vec and a modest speedup of repeat-logmod-typed over repeat-logmod.

> (time-apply-cpu repeat-logmod (list REPS T YINIT R K THETASD) TIME-SAMPLES)
min: 54 mean: 77 max: 1437    function: repeat-logmod
> (time-apply-cpu repeat-logmod-typed (list REPS T YINIT R K THETASD) TIME-SAMPLES)
min: 43 mean: 58 max: 795    function: repeat-logmod-typed
> (time-apply-cpu repeat-logmod-vec (list REPS T YINIT R K THETASD) TIME-SAMPLES)
min: 25 mean: 29 max: 65    function: repeat-logmod-vec

In R, I only slightly modified the purrr::map examples from the original blog post. Again, results for multiple simulations are similar to single simulation results. R performance is comparable to Racket and logmodc speedup over logmodr is not as large as reported in the original blog post.

> reps <- 1:1000
> mb3 <- microbenchmark(
+   purrr::map(reps, ~logmodc(t, yinit, r, k, thetasd)),
+   purrr::map(reps, ~logmodr(t, yinit, r, k, thetasd))
+ )
> mb3
Unit: milliseconds
                                                expr       min        lq     mean    median       uq      max neval
 purrr::map(reps, ~logmodc(t, yinit, r, k, thetasd))  8.863207  9.245297 11.48015 10.048379 11.14099 44.91200   100
 purrr::map(reps, ~logmodr(t, yinit, r, k, thetasd)) 28.869978 29.357927 32.00268 30.180079 33.39805 48.81793   100

Update 2019-04-21: All of the previous timings were run in DrRacket. Running from the command line yields much better performance (2-3x).

> racket stochastic-logistic.rkt
min: 34 mean: 41 max: 218    function: logmod
min: 27 mean: 33 max: 195    function: logmod-typed
min: 14 mean: 15 max: 16    function: logmod-vec
min: 32 mean: 40 max: 213    function: repeat-logmod
min: 26 mean: 27 max: 28    function: repeat-logmod-typed
min: 14 mean: 15 max: 16    function: repeat-logmod-vec

  1. All of the Racket code is a available in this gist.

  2. See the original blog post for the R code.

  3. Throughout this post, we are using flnormal-sample to draw random numbers. I wrote about generating random numbers in this post.

  4. See this post for an improved version.

  5. The racket/format library provides functions for formatting strings with more compact code.

Travis Hinkelman
Ecological Modeler