Monte Carlo Methods
Monte Carlo methods involve using random numbers to produce different realizations of a system and then building up statistics.
Pseudo-random numbers
Generally, producing truly random numbers is hard and we most often use pseudo-random numbers. These should have the following properties:
The are fast to generate
Each number is not correlated with the one that came before it (actually, there are a number of statistical tests they should satisfy).
They uniformly sample the range of interest (usually \([0, 1)\))
Note
True random number generators usually use hardware, noise, or some natural source of statistical randomness
There are a few popular methods for generating pseudo-random numbers, including:
linear congruential generators : these are easy to code up and fast but don’t give the best statistical properties.
Mersenne twister : this method is one of the most widely used now and generates good quality random numbers. It is more complicated to code up.
The C++ standard library has these methods and more for us to use in the random library.
Caution
Some (historical) pseudo-random number generators provided by compiler vendors have been “truly horrible”, such as the IBM RANDU generator.
Uniform distribution
Here’s a simple example of a uniform random number distribution:
#include <iostream>
#include <random>
int main() {
// a uniform random number generator that will be used as our seed
std::random_device rd;
// the Mersenne Twister -- we initialize it with a random seed
std::mt19937 generator(rd());
// our random number distribution
std::uniform_real_distribution<double> uniform(0.0, 1.0);
for (int n = 0; n < 10; ++n) {
// sample our distribution with our generator
std::cout << uniform(generator) << std::endl;
}
}
Some notes:
In C++
<random>
, the generator and distribution are separate concepts. Here we pick the Mersenne Twisterstd::mt19937
generator and use it with the uniform distributionstd::uniform_real_distribution
.To initialize the generator, we use
std::random_device
—this is a true random number generator (depending on system support) and will provide a seed as the starting point for our pseudo-random number generator.
Notice that each time we run it we get a different sequence of random numbers.
Tip
We could alternately use std::default_random_engine by changing the line:
std::mt19937 generator(rd());
to
std::default_random_engine generator(rd());
However, this is implementation dependent, so you may not be sure what engine it is using.
Other distributions
We can create other distributions. Imagine that \(q(z)\) is the probably of getting a number between \(z\) and \(z +dz\). For the uniform distribution, \(q(z) = 1\). We want to find an \(x(z)\) that maps the uniformly-distributed numbers into another distribution. Since the total probability is \(1\), we require:
where \(p(x)\) will be the new distribution function. Integrating this allows us to map the uniform distribution to other distributions.
Tip
The cppreference site has a nice example of a normal distributon
that also introduces std::map
to hold “key:value” pairs.
Seeding
Sometimes we want reproducible random numbers—i.e. we want the sequence of numbers we get to be random but we want to get the same sequence each time we run. To accomplish this, we can explicitly feed a seed into our random generator:
std::mt19937 generator(12345);
To do better, you can use std::seed_seq
(https://en.cppreference.com/w/cpp/numeric/random/seed_seq) to build a
better seed:
std::seed_seq seed{1, 2, 3, 4, 5};
std::mt19937 generator(seed);
Warning
For applications that require security, you need to be more careful. But for physics simulations where we are not worried about someone being able to guess our sequence, seeding allows for reproducibility, and is good for testing.
Example: computing \(\pi\)
A fun way to compute \(\pi\) is to pick pairs of random numbers in
the range \([0, 1)\)—these will all fall inside a unit square,
but we also check to see if they fall inside a circle of diameter
1
inscribed inside the unit square.
We can write
We then pick a large number (\(N\)) of random number pairs. We approximate the area of the circle and square as simply the number of random pairs that fall inside each shape. If we call the number of random numbers that fall inside the circle \(N_\mathrm{circle}\) and note that all \(N\) fall inside the square, so we can approximate \(\pi\) as:
Here’s an implementation of our computing \(\pi\):
#include <iostream>
#include <random>
#include <cmath>
const int N = 1000000;
int main() {
std::random_device rd;
std::mt19937 generator(rd());
std::uniform_real_distribution<double> uniform(0.0, 1.0);
int N_circle{0};
for (int n = 0; n < N; ++n) {
double x = uniform(generator);
double y = uniform(generator);
double r = std::sqrt(std::pow(x - 0.5, 2) + std::pow(y - 0.5, 2));
if (r <= 0.5) {
N_circle++;
}
}
std::cout << "pi = " << 4 * static_cast<double>(N_circle) / N << std::endl;
}
Example: hypersphere volume
Consider a \(d\)-dimensional hypersphere with radius 1 defined on a domain \([-1, 1]^d\). The volume is:
We can integrate this using Monte Carlo methods.
Mean value Monte Carlo integration works by sampling the domain that the function is defined on to estimate the mean. In one-dimension, we know:
where \(I\) is the integral we desire:
This means we can approximate \(I\) by doing \(N\) samples as:
where \(x_i\) is a randomly selected point in \([a, b]\).
This extends to an arbitrary dimension easily, as:
where \({\bf x}_i\) is a randomly-selected point in the \(d\)-dimensional domain.
For \(d > 3\), Monte Carlo integration is usually less computationally-expensive than doing the trapezoid rule in each dimension.
Here’s an example of computing the volume of the \(d\)-dimensional hypersphere using mean-value Monte Carlo integration:
// compute the volume of a D-dimensional hypersphere
// via mean-value Monte-Carlo integration
#include <iostream>
#include <iomanip>
#include <random>
#include <cmath>
#include <numbers>
constexpr int N_MAX{1000000};
double integrand(std::vector<double> x) {
// inner_product() here will compute x . x
double radius = std::sqrt(std::inner_product(x.cbegin(), x.cend(), x.cbegin(), 0.0));
if (radius <= 1.0) {
return 1.0;
}
return 0.0;
}
double analytic(const double D) {
return std::pow(std::numbers::pi, D/2.0) / std::tgamma(D/2.0 + 1.0);
}
int main() {
std::random_device rd;
std::mt19937 generator(rd());
std::uniform_real_distribution<double> distribution(-1.0, 1.0);
double D{-1};
std::cout << "Enter dimensionality of the hypersphere: ";
std::cin >> D;
// we'll hold D random numbers, representing {r_0, r_1, ..., r_{D-1}}
std::vector<double> r(D, 0.0);
int N = 10;
while (N <= N_MAX) {
double V{0.0};
for (int n = 0; n < N; ++n) {
// get D random numbers between -1 and 1
for (auto &e : r) {
e = distribution(generator);
}
V += integrand(r);
}
V *= std::pow(2.0, D);
std::cout << "samples: " << std::setw(7) << N << "; "
<< "volume: " << V / N << std::endl;
N *= 10;
}
std::cout << "analytic result: " << analytic(D) << std::endl;
}
Tip
This requires compiling with C++20 support due to the use of the
<numbers>
header.
Here’s the output for \(d = 5\):
Enter dimensionality of the hypersphere: 5
samples: 10; volume = 12.8
samples: 100; volume = 8.32
samples: 1000; volume = 5.504
samples: 10000; volume = 5.2416
samples: 100000; volume = 5.31168
samples: 1000000; volume = 5.26419
analytic result: 5.26379
Note
There are better ways to do Monte Carlo integration, including importance sampling.