Gamblers ruin, parallel compute trials Context

The following script is a quick implementation of the gambler's ruin problem. For each given upper bound on the number of rounds in a game max_iter, simulate several games (trials) in parallel and summarize statistics.

Question

Is there a more efficient/neater way to parallelize? For example, the main method simulate_game only takes in one input parameter for convenience when calling Pool and it'd be nice to instead have e.g. simulate_game(max_iter, args).

import time
import numpy as np
from multiprocessing import Pool
from matplotlib import pyplot as plt
from random import random as unif_rand

def simulate_game(max_iter):
    """Gambler wins/loses with prob 0.5 and the game stops when either:
        i) the gambler runs out of money, or
        ii) house runs out of money, or
        iii) max_iter reached.

    Params:
        max_iter (int): upper bound on number of rounds in game.

    Returns:
        curr_iter (int): num iterations reached at end of game.

    Notes:
        "game" is synonymous with "trial".
    """
    # hardcoded vals to make parallelization easier...
    gambler_limit=100
    house_limit=200
    gambler_win_prob=0.5
    curr_iter = 0
    # simulate game
    while gambler_limit and house_limit and curr_iter &lt; max_iter:
        curr_iter += 1
        u = unif_rand()
        payout = 1 if u &gt; gambler_win_prob else -1
        gambler_limit += payout
        house_limit += -payout
    return curr_iter

class GamblerStats(object):
    """Compute and record stats."""

    def __init__(self, avg_duration_list=[], var_duration_list=[]):
        self.avg_duration_list = avg_duration_list
        self.var_duration_list = var_duration_list

    def compute_duration_stats(self, duration_list):
        self.avg_duration = np.mean(duration_list)
        self.var_duration = np.var(duration_list)
        return None

    def update_stats(self, duration_list):
        """Update stats based on new duration_list."""
        self.compute_duration_stats(duration_list)
        self.avg_duration_list.append(self.avg_duration)
        self.var_duration_list.append(self.var_duration)
        return None

if __name__ == "__main__":
    start_time = time.time()
    procs = 3  # num physical cores - 1
    max_range = 20
    scale_iter = 2e4
    max_iter_list = [n*scale_iter for n in range(1, max_range)]  # max iter per trial
    n_trials = 250  # num trials per experiment
    # Execute Simulated Experiments
    gambler_stats = GamblerStats()
    for max_iter in max_iter_list:
        # parallel compute trials
        jobs = [max_iter] * n_trials
        duration_list = Pool(procs).map(simulate_game, jobs)
        # Update Gambler Stats
        gambler_stats.update_stats(duration_list)
    # Summarize Time
    end_time = time.time()
    work_time = end_time - start_time
    print('Time taken for all simulations', work_time)
    # Example Plot
    plt.title('Avg Game Duration [iter]')
    plt.xlabel('max_iter x {:.0E}'.format(scale_iter))
    plt.ylabel('Avg Duration')
    plt.plot(gambler_stats.avg_duration_list, '.-')
Assigning number of processors

You currently set procs = 3, specifically for your 4-core machine. You can get the number of (logical) cores of your machine using the multiprocessing.cpu_count function:

from multiprocessing import Pool, cpu_count
procs = cpu_count() - 1


Create Pool just once

Rather than creating a new Pool every iteration of your for-loop, it is better to just create it once and keep using the same one. By using the with statement, it also makes sure to close it all neatly when you're done with it.

with Pool(procs) as p:
    for max_iter in max_iter_list:
        jobs = [max_iter] * n_trials
        duration_list = p.map(simulate_game, jobs)
        gambler_stats.update_stats(duration_list)


Hardcoded values in simulate_games function

First of all, you can define your default values in the function definition. This also makes the difference more clear between the hardcoded defaults and initializations like cur_iter = 0

def simulate_game(max_iter, 
                  gambler_limit=100, 
                  house_limit=200, 
                  gambler_win_prob=0.5):


Create different fixed-value alternative using partial

To have a fixed different set of default values, you could use functools.partial:

from functools import partial
simulate_high_roller = partial(simulate_game, gambler_limit=1_000)
with Pool(procs) as p:
    duration_list = p.map(simulate_high_roller , jobs)


Mapping with multiple arguments: starmap

If instead you want to simulate using a whole range of parameter values, Pool.starmap is probably what you're looking for. Just like the 'regular' itertools.starmap, it allows you to pass in a list of tuples that it will unpack and pass to your function.

So if you have pre-defined lists of parameters you want to simulate, you can use zip to create the tuples.

# Example
max_iter_list = [10, 20, 50]
gambler_limits = [50, 100, 200]
house_limits = [150, 200, 250]
win_probabilities = [0.6, 0.5, 0.4]

with Pool(procs) as p:
    for arguments in zip(max_iter_list, gambler_limits, house_limits, win_probabilities):
        jobs = [arguments] * n_trials
        duration_list = p.starmap(simulate_game, jobs)


In case of a full grid-search of your parameters, this combines nicely with itertools.product, although that set of combinations will grow quite fast, so be deliberate about which ones you need to test.

&gt;&gt;&gt; from itertools import product
&gt;&gt;&gt; arguments = list(product(max_iter_list, gambler_limits, 
...                          house_limits, win_probabilities))
&gt;&gt;&gt; print(arguments)
[(10, 50, 150, 0.6),
 (10, 50, 150, 0.5),
 (10, 50, 150, 0.4),
 (10, 50, 200, 0.6),
 ...
 (50, 200, 200, 0.4),
 (50, 200, 250, 0.6),
 (50, 200, 250, 0.5),
 (50, 200, 250, 0.4)]
&gt;&gt;&gt; len(arguments)
81