A Ciw Implementation of SimPy’s Bank Renege Example

Simulation
Discrete-Event-Simulation
Python
Ciw
Author

Galen Seilis

Published

February 2, 2024

In this post we look at implementing SimPy’s Bank Renege example using Ciw. At a first glance this example is essentially an M/M/1 queue, but it has a couple of custom features. The first customized aspect is reneging customers with a uniformly-distributed reneging patience. See Bocquet 2005 for some discussion of basic queueing theory with reneging. The second aspect is that there is a fixed total number of customers.

Initially we could consider the arrivals to follow an exponential distribution

\[T_{\text{Arrivals}} \sim \text{Exponential} \left( \frac{1}{10} \right)\]

but since this will only be true for the first 5 customers we need a more customized distribution. Specifically

\[T_{\text{Arrivals}} \sim \begin{cases} \delta(\infty) & \text{Customers} \geq 5 \\ \text{Exponential} \left( \frac{1}{10} \right) & \text{Customers} < 5 \end{cases}\]

where \(\delta(\infty)\) denotes a Dirac delta distribution centered at infinity. You can consider this an abuse of notation that simply indicates that arrivals stop after 5 customers, or you could take a couple of distinct approaches: one defining this concept in terms of limits or the other in terms of the extended real numbers. We’ll just stick to it being an informal abuse of notation here. In Python we’ll define this as MaxArrivalDist:

"""A distribution that stops arrivals after a fixed count.

An M/M/1 queue normally has an unlimited stream of arrivals. The Bank Renege
example instead fixes the total number of customers at five. This distribution
wraps an ordinary inter-arrival distribution and returns a sample from it for
the first ``max_arrivals`` calls, then returns positive infinity so that no
further arrivals ever occur.
"""

from __future__ import annotations

from typing import Optional

import ciw


class MaxArrivalDist(ciw.dists.Distribution):
    """Limit the number of arrivals from an underlying distribution.

    Attributes
    ----------
    dist:
        The underlying distribution used for sampling.
    max_arrivals:
        The maximum number of arrivals allowed.
    arrival_count:
        The current count of arrivals.
    """

    def __init__(self, dist: ciw.dists.Distribution, max_arrivals: int = 5) -> None:
        """Initialise the distribution.

        Parameters
        ----------
        dist:
            The underlying distribution to sample from.
        max_arrivals:
            The maximum number of arrivals allowed. Defaults to 5.
        """
        self.dist = dist
        self.max_arrivals = max_arrivals
        self.arrival_count = 0

    def sample(
        self, t: Optional[float] = None, ind: Optional[ciw.Individual] = None
    ) -> float:
        """Sample from the underlying distribution, up to ``max_arrivals``.

        Parameters
        ----------
        t:
            Optional time parameter.
        ind:
            Optional individual parameter representing the arrival.

        Returns
        -------
        float
            A sample from the underlying distribution, or positive infinity
            once the maximum number of arrivals has been reached.
        """
        if self.arrival_count < self.max_arrivals:
            self.arrival_count += 1
            return self.dist.sample()
        return float("inf")

Just so we don’t simulate for too much or too little time, we can also set the termination condition of our simulation to be the number of customers that have left the system (i.e. have received service and left). Following the Ciw guide How to Simulate For a Certain Number of Customers we can call the simulate_until_max_customers(5, method='Finish') method on an instance of Simulation to achieve this result. This way the current_time at the end of the simulation will be the amount of time for all five customers to sojourn.

The service distribution is just an exponential distribution:

\[T_{\text{Service}} \sim \text{Exponential} \left( \frac{1}{12} \right)\]

The reneging times for the customers will simply be according to a continuous uniform distribution:

\[T_{\text{Renege}} \sim \mathcal{U}_{[1,3]}.\]

Following Ciw guide How to Simulate Reneging Customers, we can simply pass in reneging_time_distributions=[ciw.dists.Uniform(1,3)].

"""The Bank Renege network and its simulation.

The model is an M/M/1 queue with two custom features:

* only five customers ever arrive, enforced by
  :class:`~ciw_simpy_bank_renege.max_arrival.MaxArrivalDist`;
* waiting customers renege after a uniformly distributed patience time.

The simulation runs until five customers have *finished* (been served and left,
whether by service or by reneging), so the time at the end of the simulation is
the sojourn time for all five customers.
"""

from __future__ import annotations

import ciw

from ciw_simpy_bank_renege.max_arrival import MaxArrivalDist

arrival_rate = 1 / 10

service_rate = 1 / 12

max_customers = 5

reneging_time = (1, 3)

seed = 2018


def build_network() -> ciw.network.Network:
    """Return the Bank Renege network.

    Returns
    -------
    ciw.network.Network
        A single-server network with bounded arrivals and reneging.
    """
    return ciw.create_network(
        arrival_distributions=[
            MaxArrivalDist(ciw.dists.Exponential(arrival_rate), max_customers)
        ],
        service_distributions=[ciw.dists.Exponential(service_rate)],
        number_of_servers=[1],
        reneging_time_distributions=[ciw.dists.Uniform(*reneging_time)],
    )


def run_simulation(random_seed: int = seed) -> ciw.Simulation:
    """Simulate until all five customers have left the system.

    Parameters
    ----------
    random_seed:
        Seed for Ciw's random number generator, for repeatability.

    Returns
    -------
    ciw.Simulation
        The simulation after running to the finish of five customers.
    """
    ciw.seed(random_seed)
    network = build_network()
    simulation = ciw.Simulation(network)
    simulation.simulate_until_max_customers(max_customers, method="Finish")
    return simulation

How long should we expect this simulation to take? According to queueing theory, such a system is stable if \(\rho \triangleq \frac{\lambda}{\mu} < 1\). In this case \(\lambda = \frac{1}{10}\) and \(\mu = \frac{1}{12}\) so \(\rho = \frac{6}{5} > 1\). But there actually is something importantly different from this classic theoretical setting: we know ahead of time we’re simulating for only 5 customers. So the theoretical concern of the length of the waitlist having unbounded asymptotic behaviour is not a concern here. That doesn’t mean that the distribution is easy to estimate. One cannot, for example, simply add the random variables to get the correct distribution as that would ignore that there is waiting time on the queue and that some of the customers will renege. Instead, we’ll simulate and record the simulation times that result.

"""Sample many Bank Renege run times and fit a Gamma distribution.

The post estimates the distribution of the time for all five customers to
sojourn by simulating many times and fitting a Gamma distribution to the
results. This module reproduces that experiment: sample, fit, histogram, and
overlay the fitted density.
"""

from __future__ import annotations

import matplotlib.pyplot as plt
import numpy as np
from scipy.stats import gamma

from ciw_simpy_bank_renege.network import build_network, max_customers, seed

n_simulations = 1_000_000

output_path = "figures/bank-renege-run-times.png"


def run_time(network) -> float:
    """Return the sojourn time for all five customers in one simulation.

    Parameters
    ----------
    network:
        A network from
        :func:`ciw_simpy_bank_renege.network.build_network`. Ciw copies the
        network for each simulation, so it can be reused.
    """
    import ciw

    simulation = ciw.Simulation(network)
    simulation.simulate_until_max_customers(max_customers, method="Finish")
    return max(record.exit_date for record in simulation.get_all_records())


def sample_run_times(
    n: int = n_simulations, random_seed: int = seed
) -> np.ndarray:
    """Return ``n`` simulated sojourn times.

    Parameters
    ----------
    n:
        The number of simulations to run. Defaults to one million, as in the
        post.
    random_seed:
        Seed for Ciw's random number generator.

    Returns
    -------
    numpy.ndarray
        The simulated sojourn times.
    """
    import ciw

    ciw.seed(random_seed)
    network = build_network()
    return np.array([run_time(network) for _ in range(n)])


def fit_gamma(x: np.ndarray):
    """Fit a Gamma distribution to ``x`` and return its parameters."""
    return gamma.fit(x)


def plot_run_times(
    x: np.ndarray,
    output: str = output_path,
    bins: int = 200,
) -> tuple:
    """Histogram the run times and overlay a fitted Gamma density.

    Parameters
    ----------
    x:
        The simulated sojourn times.
    output:
        Where to save the figure.
    bins:
        Number of histogram bins.

    Returns
    -------
    tuple
        The fitted Gamma parameters ``(shape, loc, scale)``.
    """
    plt.style.use("dark_background")
    params = fit_gamma(x)
    q = np.linspace(x.min(), x.max(), num=100)
    p = gamma.pdf(q, *params)

    plt.hist(x, density=True, bins=bins)
    plt.plot(
        q,
        p,
        label=f"Gamma(shape={np.round(params[0], 2)},scale={np.round(params[2], 2)})",
    )
    plt.xlabel("Simulation Time")
    plt.ylabel("Density")
    plt.legend()
    plt.savefig(output, dpi=300, transparent=True)
    plt.close()
    return params


def main() -> None:
    """Simulate one million run times and save the fitted-density plot."""
    x = sample_run_times()
    plot_run_times(x)


if __name__ == "__main__":
    main()

The above code results in the plot:

You may be wondering how I knew that it would be a Gamma distribution that would fit so well to the simulation results. Honestly, I guessed. Gamma distributions ‘often’ give good approximations to time-to-event distributions, and happen to generalize some common waiting time distributions such as exponential distributions and Erlang distributions. Nonetheless, it is satisfying to see such a nearly-perfect fit to a relatively simple distribution.