I watched a YouTube show the other day in which a variant of a Galton board is used to make a random selection from a range of movies that will be reviewed in that show. In contrast to a standard Galton board in which the ball is usually dropped from the mid-point, which results in a normal distribution, the starting position of the ball in the show is random. While watching, I was wondering whether this change really resulted in a uniform distribution of choices, and I sat down to write a Galton board simulator to test.
I've ended up with a class
Galton that can be initialized with different board parameters (number of rows, number of bins). It provides the method
simulate() that can be called with a parameter controlling the starting position of each bead during a simulation run; the number of beads per simulation can also be specified.
I believe my implementation is correct, but apart from a visual inspection of the the histograms I have no good idea how to test that formally. The class and the methods contain doc-strings, and I've used type-hints (even if I'm not not a huge fan of this feature).
In which ways can my code be improved with regard to coding style, documentation style, or program logic? Is there something that I've missed?
from collections import Counter from matplotlib import pyplot as plt from matplotlib.ticker import PercentFormatter from pandas import Series from random import randint class Galton: """ A Galton board class that can be used to simulate the distribution into bins of a number of beads running through the board. The board is assumed to comprise an even number of 'row pairs'. Whenever a bead passes through a member of a row pair in a simulated run, the bin position of the bead is moved randomly either a half-bin unit to the left or to the right. Consequently, after passing a row pair, the bin position of the bead has either moved a full bin unit to the left, stayed at the same bin unit, or moved a full bin unit to the right. If this results in a bin position outside the board, the bead is bounced back into the board by one full bin unit. """ def __init__(self, row_pairs: int, bins: int): """ Initialize a Galton board with the specified number of row pairs and bins. Arguments -------- row_pairs: int The number of "row pairs" bins: int The number of bins (zero is not included as a bin) """ self.bins = bins self.rows = row_pairs * 2 def is_valid(self, position: float) -> bool: """ Check if the argument is a valid board position. Argument -------- position: float The position value to be checked (in bin units, including half-bins) Returns ------- check: bool True if the position value is valid, or false otherwise """ return 0 < position < self.bins + 1 def move_down(self, position: float) -> float: """ Move the bead down one row on the board by updating its bin position. Argument -------- position: float The current position of the bead (in bin units, including half-bins) Returns ------- position: float The new position of the bead after passing a row (in bin units, including half-bins) """ d = -0.5 if randint(0, 1) else 0.5 position += d if not self.is_valid(position): position -= d * 2 return position def run_bead(self, start=None) -> int: """ Run a bead from its starting position into a final bin by passing it through the board. Argument -------- start: The starting position of the bead (see `simulate()` for details) Returns ------- position: int The bin in which the bead ends up after running through the board """ position = start or randint(1, self.bins) if not self.is_valid(position): raise ValueError("Bin position out of range") for _ in range(self.rows): position = self.move_down(position) return int(position) def simulate(self, beads: int, start=None) -> None: """ Show the histogram of results for a specified number of beads on the board. The simulation condition can be specified by specifying the `start` argument (see below). Arguments --------- beads: int The number of beads that will be used in the simulation start: int, or None If `start` is an integer, its value is used by as the starting bin position of every bead. If `start` is None (the default), the starting bin position of each bead will be randomly chosen from the possible bin positions of the board. """ count = Counter(self.run_bead(start) for _ in range(beads)) (Series(count).sort_index() .mul(100) .div(beads) .plot(kind="bar", xlabel="Bins", ylabel="Relative frequency")) plt.xticks(rotation=0) plt.gca().yaxis.set_major_formatter(PercentFormatter()) plt.show() # Simulate a classic Galton board in which all beads are released at the # midpoint of the board: Galton(row_pairs=11, bins=21).simulate(beads=100000, start=11) # Simulate the variant used in the YouTube show in which any starting position # is possible: Galton(row_pairs=11, bins=21).simulate(beads=100000, start=None)