Tutorial 1 - Approximating Pi

Introduction

This tutorial will guide you through the process of approximating the value of pi using a Monte Carlo simulation with the help of the baccarat library.

The approximation we use is based on the ratio of the area of a circle to the area of a square that circumscribes it. If we generate random points within the square and count how many fall inside the circle, we can estimate the ratio of the areas and thus the value of pi!

Step 1: Create a Simulator

To begin, import the Simulator class from the baccarat module.

from baccarat import Simulator

The Simulator class is an abstract base class that provides the logic for running your Monte Carlo simulation. There are three primary methods that you need to know, but only one is an abstract method that you need to implement in your own simulator.

The important methods are:

  • simulation (abstract)
  • compile_results
  • run

More details about these methods will follow as we complete the tutorial.

Step 3: Implement the Simulator

Since Simulator is an abstract base class, you need to create a subclass that inherits from Simulator where you will provide the implementation for the abstract method simulation. Let's call this class PiSimulator. When defining the class, we'll also define the parameters used in the simulation.

To define the parameters, we'll add class attributes that are concrete instances of the the Param class. These concrete instances are actually descriptors that define a custom generate method. Accessing a parameter attribute on PiSimulator will return the value of that parameter's generate method as a NumPy array. Since these are NumPy arrays containing all the random values for the simulation at once, it is recommended to assign them to variables at the beginning of the simulation method for clarity and to enable vectorized operations.

from baccarat import Simulator

class PiSimulator(Simulator):
    radius = 1  # Not a parameter
    x = UniformParam(-radius, radius)
    y = UniformParam(-radius, radius)
    # This is a parameter, but could also be a constant value
    r = StaticParam(radius)

    def simulation(self):
        """This is where we implement the logic for a single iteration of the simulation."""
        # Get values from the parameters of the simulator
        x, y, r = self.x, self.y, self.r
        # Check if point falls inside the circle
        return x**2 + y**2 <= r**2

The return value of the simulation method is stored in a NumPy array: PiSimulator.results. This array contains the results from all simulation samples. By default, this array is returned when the simulation completes, at the end of the run method.

Step 4: Implementing the compile_results Method

Returning an array of results is all well and good, but if we're using the simulation to approximate pi, it seems like this would fall short of our goal and leave us with more work to do! Luckily, we can specify a custom implementation of the compile_results method to do some postprocessing to modify the return value appropriately. With vectorized operations, we can efficiently process all the simulation results at once.

from baccarat import Simulator, UniformParam, StaticParam

class PiSimulator(Simulator):
    radius = 1  # Not a parameter
    x = UniformParam(-radius, radius)
    y = UniformParam(-radius, radius)
    # This is a parameter, but could also be a constant value
    r = StaticParam(radius)

    def simulation(self):
        """This is where we implement the logic for the simulation.
           The vectorized implementation processes all samples at once."""
        # Get arrays of values from the parameters of the simulator
        x, y, r = self.x, self.y, self.r
        # Check if points fall inside the circle (returns boolean array)
        return x**2 + y**2 <= r**2

    def compile_results(self):
        """Do work on the collection of simulation results to compute the approximation of pi."""
            # Apply the formula to compare the number of points inside the circle to the total number of points 
            # and return the approximation of pi
            # Pi = 4 * A_circle / A_square
            return 4 * len([res for res in self.results if res]) / len(self.results)

if __name__ == "__main__":
    num_sims = 1_000_000
    simulator = PiSimulator(num_sims)
    result = simulator.run()  # Will now return an approximation of pi!
    print(result)

Conclusion

In a handful of lines of code, we have a complete implementation of a Monte Carlo simulation to approximate the value of pi! The baccarat interface allowed us to separate the concerns of simulation logic and postprocessing and build our simulation incrementally.

The implementation takes advantage of NumPy's vectorized operations to efficiently process all simulation samples at once. When parameters are accessed (like self.x), they return NumPy arrays containing all the random values needed for the simulation. This vectorized approach provides substantial performance benefits compared to processing each sample individually in a loop.