Ruin Probability via Stochastic Process Simulation [Julia] vs [Python]
Problem Introduction and the Julia Language
I have recently been using the Julia programming language for simulation problems or problems that require heavy computational processing. I even used it in my undergraduate thesis, which was a great way to boost my learning of this new tool.
I chose it because of its ability to solve the two-language problem, by allowing (i) rapid prototyping of functional code and (ii) achieving excellent computational performance, all without the need to rewrite the algorithm in a compiled language like C/C++.
So I want to share a case where using Julia was computationally advantageous and that many actuarial science students will likely encounter during their studies: calculating the ruin probability of an insurance company through Cramér-Lundberg simulations via Monte Carlo simulation.
If you want to check all the implemented code without going through the entire step-by-step below, here is the repository on github.
The algorithm simulates an insurance company's net worth over a discrete time period of 10 years (120 months), aiming to obtain the company's ruin probability by calculating the ratio between simulated cases that resulted in ruin and the total number of simulated cases. Here I will cover the classic case, but I may bring more advanced variations that incorporate additional relevant variables in the future.
I will also present the same steps in Julia and Python so that the reader can reproduce and compare the processing time of both on their personal machine.
Problem Assumptions
- 120 months (10 years) of the company's net worth will be evaluated
- A constant premium of 500 reais per client is assumed
- New client acquisition follows a \(Poisson(\lambda)\) distribution with mean 50. (\(n_p\))
- A 5% churn rate is incorporated, considered before new acquisitions
- For the Aggregate Claim calculation, \(S_t\):
- The number of claims \(n_t\) follows a \(Poisson(\lambda)\) distribution with mean equal to 10% of the client base that month
- Each claim, \(X_t\), follows an \(Exp(\alpha)\) distribution with mean 5000.
Number of policyholders with 5% churn + new clients
Considering an initial number of clients (\(n_0\)) and the assumptions of 5% churn of current policyholders plus the addition of new policyholders (\(n_p\)) following a \(Poisson(\lambda)\) distribution, we can derive the following steps for time t=1 and t=2.
\[ n_1 = 0,95 \cdot n_0 + n_p \\ n_2 = 0,95 \cdot n_1 + n_p \\ n_2 = 0,95(0,95 \cdot n_0 + n_p) + n_p \\ n_2 = 0,95^2 \cdot n_0 + 0,95 \cdot n_p + n_p \\ n_2 = 0,95^2 \cdot n_0 + (0,95 + 1)n_p \]
When we generalize this function in t, we get:
\[n_t = 0,95^tn_0 + (0,95^{t-1} + ... + 0,95 + 1)n_p\]
Since this summation is a geometric series, we can obtain the following equivalences \((0,95^{t-1} + ... + 0,95 + 1) = \frac{1-0,95^t}{1-0,95} = \frac{1-0,95^t}{0,05} = 20 * (1 - 0,95^t)\), thus resulting in the final equation applied in the algorithm.
\[n_t = 0,95^tn_0 + 20(1-0,95^t)n_p\]
Methodology
The classic model of Cramer-Lundberg is a stochastic process that simulates the behavior of net worth through the following discrete formula:
\[U_t = U_{t-1} + P_t - S_t, \;\;\; t \ge 1\]
where \(t = 1,...,n\)
We assume an initial net worth value (\(U_0\)) at the start of time \(t\), which will be a scenario study parameter. Next, we assume \(P_t = c \cdot n_t\), where the premium value for the period is the premium constant times the number of clients in that period, according to the adopted assumption.
Finally, we move to the calculation of the Aggregate Claim or Convoluted Claim (\(S_t\)), which is basically the convolution of the following two random variables:
- \(N \sim Poisson(\alpha = 0.1*n_t)\), which simulates the number of claims that occurred.
- \(X \sim Exp(\lambda = 1/5000)\), which simulates the severity of the claims.
Thus, the Aggregate Claim is given by the following formula:
\[S_t = \sum_{i=1}^{n_t} X_i\] where the number of claims (\(n_t\)) is first simulated through a Poisson distribution, the claim values (\(X_i\)) are drawn from an Exponential distribution, and finally all values are summed, resulting in the period's Aggregate Claim (\(S_t\)).
With this, we can simulate, for example, 5000 trajectories of the net worth (\(U_t\)) and check how many cases resulted in company ruin (\(U_t < 0\)). Thus, the proportion of ruin cases to total simulated cases equals the insurance company's ruin probability, given the adopted assumptions \(\psi(U_0) = \frac{N_{ruins}}{N_{simulations}}\).
Coding the Algorithm
First, we import the necessary packages for both languages.
Julia
using Distributions
Python
import numpy as np
from numpy.random import exponential as expon
from numpy.random import poisson as poiss
Number of clients
Below is the implementation of the final equation for the number of policyholders (\(N_t\)), where the function takes as arguments the initial number of policyholders at time t=0, the Poisson distribution corresponding to the number of new clients per month, and the time t being evaluated. This allows obtaining the number of policyholders at any time t without needing to compute the entire vector from time 1 to t.
Julia
function novos_clientes(n₀, poiss, t)
C = n₀*0.95^t + 20*(1 - 0.95^t)*rand(poiss)
round(Int, C)
end
Python
def novos_clientes(N, lbds, i):
clientes = N*0.95**(i-1) + 20*(1 -(0.95)**(i-1))*poiss(lbds)
return int(clientes)
Aggregate Claim
First, the number of claims is drawn in variable N, following a Poisson distribution. Then, N claim values are drawn following an Exponential distribution. Finally, all drawn values are summed, totaling the period's aggregate claim.
Julia
function sin_convol(expn, cliente)
N = rand(Poisson(0.1 * cliente))
sum(rand(expn, N))
end
Python
def sin_convoluto(expn, cliente):
N = poiss(0.1 * cliente)
S = expon(expn, N)
return np.sum(S)
Checking for Ruin
Given the arguments of k simulations, initial number of policyholders \(n_0\), Poisson mean \(\lambda\), Exponential mean \(\alpha\), Initial Net Worth \(U_0\), and premium constant \(c\), the function calculates the net worth \(U_t\) for each time \(t\) and checks whether ruin occurred, returning 1 if the company went bankrupt or 0 otherwise.
Initially I created the algorithm by computing full vectors (120 months) and then checking for ruin. However, this is inefficient, so I adjusted the code to compute each element at the corresponding time t. This way there are no unnecessary calculations in the Monte Carlo simulation.
Julia
function testa_ruina(k, N₀, λ, α, U₀, c)
# vetor do patrimônio líquido
U = Vector{Float64}(undef, k)
# gera os tipos de distribuições do modelo
poiss = Poisson(λ)
expn = Exponential(α)
# executa o primeiro mês para considerar o argumento U₀
ncliente = novos_clientes(N₀, poiss, 1)
P₁ = c * ncliente
S₁ = sin_convol(expn, ncliente)
U[1] = U₀ + P₁ - S₁
# executa os demais caminhos para verificar se houve ruína
R = 0
for i in 2:k
ncliente = novos_clientes(N₀, poiss, i)
Pᵢ = c * ncliente
Sᵢ = sin_convol(expn, ncliente)
U[i] = U[i-1] + Pᵢ - Sᵢ
if U[i] < 0
R = 1
break
end
end
return R
end
Python
def testa_ruina(K, N0, lbds, alpha, U0, C):
U = np.array([])
ncliente = novos_clientes(N0, lbds, 1)
P1 = C * ncliente
S1 = sin_convoluto(alpha, ncliente)
U = np.append(U, U0)
U[0] = U[0] + P1 + S1
R = 0
for i in range(1, K):
ncliente = novos_clientes(N0, lbds, i)
P = C * ncliente
S = sin_convoluto(alpha, ncliente)
new_U = U[i-1] + P - S
U = np.append(U, new_U)
if U[i] < 0:
R = 1
break
return R
Simulating the Ruin Probability
Using the previous function, we can repeat the test X times and thus calculate the ruin probability by summing the ruin cases and dividing by the number of simulations performed. This probability calculation was encapsulated in the following function.
Julia
function prob_ruina(n_sim; k=120, n₀=50, λ=50.0, α=5000.0, U₀=10000.0, c=500.0)
R = Vector{Float64}(undef, n_sim)
for i in 1:n_sim
R[i] = testa_ruina(k, n₀, λ, α, U₀, c)
end
return sum(R)/n_sim
end
Python
def prob_ruina(NSIM, K=120, N0=50, lbds=50, alpha=5000, U0=10000, C=500):
ruinas = np.array([])
for _ in range(NSIM):
ruinas = np.append(ruinas, testa_ruina(K, N0, lbds, alpha, U0, C))
return np.sum(ruinas) / NSIM
Comparing Algorithm Performance
When evaluating a scenario with 5000 simulations, for example, we obtain the following execution times.
In Julia, we use the @belapsed macro from the BenchmarkTools package to run the desired function 10000 times and return an average execution time in seconds for the 5000 simulations.
using BenchmarkTools
tempo_julia = @belapsed prob_ruina(5000)
> 0.0551965
In Python, we need to run multiple executions manually to calculate the average execution time in seconds, as shown in the code below.
from timeit import default_timer as timer
from datetime import timedelta
import statistics
t = []
for _ in range(30):
t1 = timer()
_ = prob_ruina(5000)
t2 = timer()
t.append(t2-t1)
tempo_python = statistics.mean(t)
print(tempo_python, "seconds")
> 5.638724613333332 seconds
Finally, we compute the ratio of execution times in seconds to evaluate how many times faster Julia is compared to Python for the same algorithm.
# razao do tempo de execução (python/julia)
razao_tempo = py$tempo_python / julia_eval("tempo_julia")
razao_tempo
> [1] 102.1573
This high execution time ratio empirically demonstrates that it is advantageous to use Julia for Monte Carlo simulations. Therefore, for generating multiple scenarios, I will proceed with Julia only.
Evaluating Different Scenarios
Aiming to understand the impact of initial capital and the initial number of policyholders on the ruin probability, I generated 24 different scenarios considering values of 50, 80, and 100 policyholders, and for initial capital ranging from 0 to 1 million. Below are all 24 scenarios, which are all combinations of the two variables.
capital = Float64[0, 10000, 30000, 50000, 70000, 100000, 500000, 1000000];
nseg = [50, 80, 100];
combin_prod = Base.product(nseg, capital) |> collect;
combin = [combin_prod...]
24-element Vector{Tuple{Int64, Float64}}:
(50, 0.0)
(80, 0.0)
(100, 0.0)
(50, 10000.0)
(80, 10000.0)
(100, 10000.0)
(50, 30000.0)
(80, 30000.0)
(100, 30000.0)
(50, 50000.0)
⋮
(50, 100000.0)
(80, 100000.0)
(100, 100000.0)
(50, 500000.0)
(80, 500000.0)
(100, 500000.0)
(50, 1.0e6)
(80, 1.0e6)
(100, 1.0e6)
To run the 5000 simulations across the 24 different scenarios, I pass the scenario vector to the map() function to apply the simulation function to each scenario.
k = 5000;
probs_ruina = map(combin) do x
prob_ruina(k, n₀ = x[1], U₀ = x[2]);
end;
results =[vcat(collect.(combin)'...) probs_ruina];
Scenario Analysis
With the results, we can generate the base table with the scenario outcomes.
using DataFrames, PrettyTables
df_ruina = DataFrame(nseg = results[:,1],
cap_ini = results[:,2],
prob_ruina = 100 * results[:,3]);
pretty_table(df_ruina,
nosubheader=true,
title = "\nResultado das 5 mil simulacoes";
formatters = (ft_printf("%6.0f", 2),
ft_printf("%5.2f", 3)) )
Resultado das 5 mil simulacoes
┌──────┬─────────┬────────────┐
│ nseg │ cap_ini │ prob_ruina │
├──────┼─────────┼────────────┤
│ 50 │ 0 │ 97.00 │
│ 80 │ 0 │ 96.62 │
│ 100 │ 0 │ 95.64 │
│ 50 │ 10000 │ 95.46 │
│ 80 │ 10000 │ 95.58 │
│ 100 │ 10000 │ 95.42 │
│ 50 │ 30000 │ 93.98 │
│ 80 │ 30000 │ 93.36 │
│ 100 │ 30000 │ 93.78 │
│ 50 │ 50000 │ 91.32 │
│ 80 │ 50000 │ 90.90 │
│ 100 │ 50000 │ 91.40 │
│ 50 │ 70000 │ 88.10 │
│ 80 │ 70000 │ 89.02 │
│ 100 │ 70000 │ 89.44 │
│ 50 │ 100000 │ 84.34 │
│ 80 │ 100000 │ 85.58 │
│ 100 │ 100000 │ 84.64 │
│ 50 │ 500000 │ 44.30 │
│ 80 │ 500000 │ 44.36 │
│ 100 │ 500000 │ 44.94 │
│ 50 │ 1000000 │ 14.18 │
│ 80 │ 1000000 │ 14.74 │
│ 100 │ 1000000 │ 14.78 │
└──────┴─────────┴────────────┘
Increasing the Number of Simulations
Since Julia allows simulating a larger number of scenarios than Python, I tested running 5000, 50000, and 100000 simulations to evaluate the convergence of results and generated a plot with R that summarizes the scenario analysis and the number of simulations well.

The chart clearly shows the inverse relationship between initial capital and number of policyholders with the insurance company's ruin probability. The higher the initial capital or the initial number of policyholders, the lower the chance of the company going bankrupt. Furthermore, with 100,000 simulations, good convergence is observed with little variance in the results, thus eliminating the need to increase the number of simulations to obtain satisfactory results.
Final Julia Code
Below is an image with all the Julia code implemented in a summary image created on the Carbon platform.

In the following repository on github you can find more detailed code, including the R script I used to generate the final plot.