Monte Carlo Simulations with Numba

Monte Carlo Simulations with Numba

Introduction

Monte Carlo simulations are a cornerstone of statistical modeling and computational science. By repeatedly drawing random samples and summarizing results, one can estimate probabilities, integrals, and complex distribution properties that may otherwise be difficult or impossible to compute analytically. However, running many simulations can be time-consuming.

This is where Numba comes in — a just-in-time (JIT) compiler that speeds up Python functions by translating them to optimized machine code. When applied to Monte Carlo algorithms, Numba can dramatically reduce execution time by leveraging multi-threading and vectorization while retaining a Pythonic syntax.

🔑 Key Idea

  • Monte Carlo simulations can use Numba to reduce execution time by leveraging multi-threading and vectorization.

In this article, we’ll explore how to harness Numba for common Monte Carlo tasks like basic random sampling, parallel simulation, and Markov Chain Monte Carlo (MCMC) implementations. We’ll wrap up with tips for optimizing simulation code further.

Using Numba for Monte Carlo Simulations

Why would we decide to use Numba for our Monte Carlo simulation implementations? Here are 3 pretty convincing reasons.

  1. High-Level Syntax: Numba works behind the scenes to accelerate numeric Python code without forcing you to rewrite everything in C++ or Fortran.
  2. Seamless Random Sampling: You can still rely on NumPy’s random number generators. Numba’s @njit decorator often plays well with np.random distributions, especially if you plan your code carefully.
  3. Easy Parallelization: Decorators like @njit(parallel=True) make it straightforward to leverage multiple CPU cores. For computationally heavy simulations, parallelization provides a huge boost in performance without complicated threading code.

Parallel Monte Carlo Example: Estimating π

A classic Monte Carlo task is estimating the value of π by sampling random points in the unit square. The fraction of points that fall within the unit circle multiplied by 4 approximates π. Below is a concise Numba-powered implementation:

import numpy as np
from numba import njit, prange

@njit(parallel=True)
def fast_monte_carlo_pi(n_points):
    inside_circle = 0
    for i in prange(n_points):
        x = np.random.uniform(-1, 1)
        y = np.random.uniform(-1, 1)
        if x * x + y * y <= 1:
            inside_circle += 1
    return 4.0 * inside_circle / n_points

def main():
    n_points = 10_000_000  # example size
    pi_estimate = fast_monte_carlo_pi(n_points)
    print("Estimated π:", pi_estimate)

if __name__ == "__main__":
    main()

If you run this, you will get something like the following (though not exactly):

Estimated π: 3.1409692

Here are the key takeaways:

  • @njit(parallel=True) and prange allow the loop to run in parallel.
  • Monte Carlo is often embarrassingly parallel, making it ideal for multi-core CPUs.
  • Even a simple example highlights how Numba can cut execution time significantly compared to pure Python.

MCMC with Numba: Metropolis-Hastings

Markov Chain Monte Carlo (MCMC) is used to sample from complex probability distributions. Below is a simplified Metropolis-Hastings approach. We define a target distribution and then generate samples that (after a burn-in period) approximate that distribution.

import numpy as np
from numba import njit

@njit
def target_distribution(x):
    # A simple Gaussian as an example
    return np.exp(-0.5 * x**2)

@njit
def metropolis_hastings(n_samples, initial, proposal_width):
    samples = np.empty(n_samples)
    current = initial

    for i in range(n_samples):
        # Propose new sample
        proposal = current + np.random.normal(0, proposal_width)

        # Calculate acceptance ratio
        current_prob = target_distribution(current)
        proposal_prob = target_distribution(proposal)

        # Accept or reject
        if np.random.random() < (proposal_prob / current_prob):
            current = proposal

        samples[i] = current

    return samples

def demo_mcmc():
    n_samples = 10000
    proposal_width = 1.0
    initial_guess = 0.0
    chain = metropolis_hastings(n_samples, initial_guess, proposal_width)
    print("First 10 MCMC samples:", chain[:10])

if __name__ == "__main__":
    demo_mcmc()

And the results:

First 10 MCMC samples: [ 0.32841741  0.16684185  0.26346494  0.93066615  0.93066615  0.93141447
 -0.10141702 -0.10141702  0.08508152  0.58756122]

Here are the key takeaways:

  • @njit speeds up the loop-heavy Metropolis-Hastings logic without restructuring the algorithm.
  • Complex target distributions can be dropped in with minimal fuss — just ensure they’re Numba-compatible (e.g., rely on supported NumPy functions).
  • Parallelizing MCMC depends on the problem; standard chains are inherently sequential, but you can run multiple chains in parallel to compare convergence.

⚠️ Stay Alert!

  • Pre-generate random numbers in bulk if repeated calls to np.random become a bottleneck. Numba’s random module if you need finer control over random seeds and streams.

  • Profile your code with tools like Python’s built-in cProfile or line-profiler to spot bottlenecks.

Conclusion

Monte Carlo methods allow you to explore probability distributions and solve computational challenges in everything from finance to physics. By pairing these algorithms with Numba, you can often improve performance by orders of magnitude without leaving Python’s ecosystem. As you grow more comfortable with these techniques, you’ll find that combining the flexibility of Python with Numba’s speed unlocks a wealth of advanced simulation capabilities, all while keeping your code clean and approachable.

Leave a Reply

Your email address will not be published. Required fields are marked *