Source code for stockpyl.demand_source

# ===============================================================================
# stockpyl - DemandSource Class
# -------------------------------------------------------------------------------
# Author: Larry Snyder
# License: GPLv3
# ===============================================================================

"""
.. include:: ../../globals.inc

Overview 
--------

This module contains the |class_demand_source| class. A |class_demand_source|
object represents external demand observed by a node.
The demand can be random or deterministic. Attributes specify the type of demand
distribution and its parameters. The object can generate demands from the specified distribution.

.. note:: |fosct_notation|

**Example:** Create a |class_demand_source| object representing demand that has a normal
distribution with a mean of 50 and a standard deviation of 10. Generate a random demand from the distribution.

	.. testsetup:: *

		from stockpyl.demand_source import *

	.. doctest::

		>>> ds = DemandSource(type='N', mean=50, standard_deviation=10)
		>>> ds.generate_demand()	# doctest: +SKIP
		46.75370030596123
		>>> # Tell object to round demands to integers.
		>>> ds.round_to_int = True
		>>> ds.generate_demand()	# doctest: +SKIP
		63

API Reference
-------------

"""


# ===============================================================================
# Imports
# ===============================================================================

import numpy as np
import scipy.stats 

from stockpyl.helpers import *

# ===============================================================================
# DemandSource Class
# ===============================================================================

ALLOWABLE_TYPES = ('N', 'P', 'UD', 'UC', 'NB', 'D', 'CD', None)
[docs]class DemandSource(object): """ A |class_demand_source| object represents external demand observed by a node. The demand can be random or deterministic. Attributes specify the type of demand distribution and its parameters. The object can generate demands from the specified distribution. Parameters ---------- **kwargs Keyword arguments specifying values of one or more attributes of the |class_demand_source|, e.g., ``type='N'``. Attributes ---------- type : str The demand type, as a string. Currently supported strings are: * None * 'N' (normal) * 'P' (Poisson) * 'UD' (uniform discrete) * 'UC' (uniform continuous) * 'NB' (negative binomial) * 'D' (deterministic) * 'CD' (custom discrete) round_to_int : bool Round demand to nearest integer? demand_list : list, optional List of demands, one per period (for deterministic demand types), or list of possible demand values (for custom discrete demand types). For deterministic demand types, if demand is required in a period beyond the length of the list, the list is restarted at the beginning. This also allows ``demand_list`` to be a singleton, in which case it is used in every period. Required if ``type`` == 'D' or 'CD'. [:math:`d`] probabilities : list, optional List of probabilities of each demand value (for custom discrete demand types). Required if ``type`` == 'CD'. lo : float, optional Low value of demand range (for uniform demand types). Required if ``type`` == 'UD' or 'UC'. hi : float, optional High value of demand range (for uniform demand types). Required if ``type`` == 'UD' or 'UC'. n : int, optional Parameter for negative binomial distribution indicating number of trial successes. Required if ``type`` == 'NB'. [:math:`n`] p : float, optional Parameter for negative binomial distribution indicating probability of success for each trial. Required if ``type`` == 'NB'. [:math:`p`] """ def __init__(self, **kwargs): """DemandSource constructor method. Parameters ---------- kwargs : optional Optional keyword arguments to specify |class_demand_source| attributes. Raises ------ AttributeError If an optional keyword argument does not match class_demand_source| attribute. """ # Initialize parameters. self.initialize() # Set attributes specified by kwargs. for key, value in kwargs.items(): if key in vars(self): vars(self)[key] = value elif f"_{key}" in vars(self): vars(self)[f"_{key}"] = value else: raise AttributeError(f"{key} is not an attribute of DemandSource") _DEFAULT_VALUES = { '_type': None, '_mean': None, '_standard_deviation': None, '_demand_list': None, '_probabilities': None, '_lo': None, '_hi': None, '_n': None, '_p': None, '_round_to_int': None } # SPECIAL METHODS def __eq__(self, other): """Determine whether ``other`` is equal to this demand source object. Two demand source objects are considered equal if all of their attributes are equal. Parameters ---------- other : |class_demand_source| The demand source object to compare to. Returns ------- bool True if the demand source objects are equal, False otherwise. """ if other is None: return False else: for attr in self._DEFAULT_VALUES.keys(): if getattr(self, attr) != getattr(other, attr): return False return True def __ne__(self, other): """Determine whether ``other`` is not equal to this demand source object. Two demand source objects are considered equal if all of their attributes are equal. Parameters ---------- other : |class_demand_source| The demand source object to compare to. Returns ------- bool True if the demand source objects are not equal, False otherwise. """ return not self.__eq__(other) # PROPERTY GETTERS AND SETTERS @property def type(self): return self._type @type.setter def type(self, value): self._type = value @property def mean(self): """Return mean set by user, if any; or, for distributions whose mean is not set but is calculated from other parameters, returns the calculated mean. If neither is true, return ``None``. """ if self._mean is not None: return self._mean elif self.type in ('UC', 'UD', 'NB', 'CD'): return self.demand_distribution.mean() else: return None @mean.setter def mean(self, value): self._mean = value @property def standard_deviation(self): """Return standard deviation set by user, if any; or, for distributions whose standard deviation is not set but is calculated from other parameters, returns the calculated standard deviation. If neither is true, return ``None``. """ if self._standard_deviation is not None: return self._standard_deviation elif self.type in ('P', 'UC', 'UD', 'NB', 'CD'): return self.demand_distribution.std() else: return None @standard_deviation.setter def standard_deviation(self, value): self._standard_deviation = value @property def demand_list(self): return self._demand_list @demand_list.setter def demand_list(self, value): self._demand_list = value @property def probabilities(self): return self._probabilities @probabilities.setter def probabilities(self, value): self._probabilities = value @property def lo(self): return self._lo @lo.setter def lo(self, value): self._lo = value @property def hi(self): return self._hi @hi.setter def hi(self, value): self._hi = value @property def n(self): return self._n @n.setter def n(self, value): self._n = value @property def p(self): return self._p @p.setter def p(self, value): self._p = value @property def round_to_int(self): return self._round_to_int @round_to_int.setter def round_to_int(self, value): self._round_to_int = value # READ-ONLY PROPERTIES @property def demand_distribution(self): """Demand distribution, as a ``scipy.stats.rv_continuous`` or ``scipy.stats.rv_discrete`` object. Returns ``None`` if demand source ``type`` is ``'D'``. Read only. """ # Check that the appropriate parameters have been set. If not, raise an exception. self.validate_parameters() if self.type is None: distribution = None elif self.type == 'N': distribution = scipy.stats.norm(self.mean, self.standard_deviation) elif self.type == 'P': distribution = scipy.stats.poisson(self.mean) elif self.type == 'UD': distribution = scipy.stats.randint(self.lo, self.hi+1) elif self.type == 'UC': distribution = scipy.stats.uniform(self.lo, self.hi - self.lo) elif self.type == 'NB': distribution = scipy.stats.nbinom(self.n, self.p) elif self.type == 'CD': distribution = scipy.stats.rv_discrete(name='custom', values=(self.demand_list, self.probabilities)) else: distribution = None return distribution @property def is_discrete(self): """``True`` if the distribution is discrete, ``False`` if it is continuous. Read only. The distribution is discrete if ``self.type`` is 'P', 'UD', 'CD', 'NB', or 'D'. Returns ------- bool ``True`` if the distribution is discrete, ``False`` if it is continuous. """ return self.type in ('P', 'UD', 'CD', 'NB', 'D') # SPECIAL MEMBERS def __repr__(self): """ Return a string representation of the |class_demand_source| instance. Returns ------- A string representation of the |class_demand_source| instance. """ # Build string of parameters. if self.type is None: return "DemandSource(None)" elif self.type == 'N': param_str = "mean={:.2f}, standard_deviation={:.2f}".format( self.mean, self.standard_deviation) elif self.type == 'P': param_str = "mean={:.2f}".format(self.mean) elif self.type in ('UD', 'UC'): param_str = "lo={:.2f}, hi={:.2f}".format( self.lo, self.hi) elif self.type == 'D': if not is_list(self.demand_list) or len(self.demand_list) <= 8: param_str = "demand_list={}".format(self.demand_list) else: param_str = "demand_list={}...".format(self.demand_list[0:8]) elif self.type == 'NB': param_str = "n={:d}, p={:.2f}".format(self.n, self.p) elif self.type == 'CD': if len(self.demand_list) <= 8: param_str = "demand_list={}, probabilities={}".format( self.demand_list, self.probabilities) else: param_str = "demand_list={}..., probabilities={}...".format( self.demand_list[0:8], self.probabilities[0:8]) else: param_str = "" return "DemandSource({:s}: {:s})".format(self.type, param_str) def __str__(self): """ Return the full name of the |class_demand_source| instance. Returns ------- The demand_source name. """ return self.__repr__() # ATTRIBUTE HANDLING
[docs] def initialize(self): """Initialize the parameters in the object to their default values. """ for attr in self._DEFAULT_VALUES.keys(): setattr(self, attr, self._DEFAULT_VALUES[attr])
[docs] def validate_parameters(self): """Check that appropriate parameters have been provided for the given demand type. Raise an exception if not. """ if self.type not in ALLOWABLE_TYPES: raise AttributeError(f"Valid type in {ALLOWABLE_TYPES} must be provided") # Note: Importane to use the '_' attributes here, rather than associated properties, # to avoid infinite recursion. (For example, if self._mean is None, calling self.mean calls # self.demand_distribution(), which calls self.mean.) Plus, these attributes have to be set by # user, not just calculated. if self.type == 'N': if self._mean is None: raise AttributeError("For 'N' (normal) demand, mean must be provided") if self._mean < 0: raise AttributeError("For 'N' (normal) demand, mean must be non-negative") if self._standard_deviation is None: raise AttributeError("For 'N' (normal) demand, standard_deviation must be provided") if self._standard_deviation < 0: raise AttributeError("For 'N' (normal) demand, standard_deviation must be non-negative") elif self.type == 'P': if self._mean is None: raise AttributeError("For 'P' (Poisson) demand, mean must be provided") if self._mean < 0: raise AttributeError("For 'P' (Poisson) demand, mean must be non-negative") elif self.type == 'UD': if self._lo is None: raise AttributeError("For 'UD' (uniform discrete) demand, lo must be provided") if self._lo < 0 or not is_integer(self._lo): raise AttributeError("For 'UD' (uniform discrete) demand, lo must be a non-negative integer") if self._hi is None: raise AttributeError("For 'UD' (uniform discrete) demand, hi must be provided") if self._hi < 0 or not is_integer(self._hi): raise AttributeError("For 'UD' (uniform discrete) demand, hi must be a non-negative integer") if self._lo > self._hi: raise AttributeError("For 'UD' (uniform discrete) demand, lo must be <= hi") elif self.type == 'UC': if self._lo is None: raise AttributeError("For 'UC' (uniform continuous) demand, lo must be provided") if self._lo < 0: raise AttributeError("For 'UC' (uniform continuous) demand, lo must be non-negative") if self._hi is None: raise AttributeError("For 'UC' (uniform continuous) demand, hi must be provided") if self._hi < 0: raise AttributeError("For 'UC' (uniform continuous) demand, hi must be non-negative") if self._lo > self._hi: raise AttributeError("For 'UC' (uniform continuous) demand, lo must be <= hi") elif self.type == 'NB': if self._n is None: raise AttributeError("For 'NB' (negative binomial) demand, n must be provided") if self._n <= 0: raise AttributeError("For 'NB' (negative binomial) demand, n must be positive") if self._p is None: raise AttributeError("For 'NB' (negative binomial) demand, p must be provided") if self._p < 0 or self._p > 1: raise AttributeError("For 'NB' (negative binomial) demand, p must be in [0, 1]") elif self.type == 'D': if self._demand_list is None: raise AttributeError("For 'D' (deterministic) demand, demand_list must be provided") elif self.type == 'CD': if self._demand_list is None: raise AttributeError("For 'CD' (custom discrete) demand, demand_list must be provided") if self._probabilities is None: raise AttributeError("For 'CD' (custom discrete) demand, probabilities must be provided") if len(self._demand_list) != len(self._probabilities): raise AttributeError("For 'CD' (custom discrete) demand, demand_list and probabilities must have equal lengths") if np.sum(self._probabilities) != 1: raise AttributeError("For 'CD' (custom discrete) demand, probabilities must sum to 1")
# CONVERSION TO/FROM DICTS
[docs] def to_dict(self): """Convert the |class_demand_source| object to a dict. List attributes (``demand_list``, ``probabilities``) are deep-copied so changes to the original object do not get propagated to the dict. Returns ------- dict The dict representation of the object. """ # Initialize dict. ds_dict = {} # Attributes. for attr in self._DEFAULT_VALUES.keys(): # Remove leading '_' to get property names. prop = attr[1:] if attr[0] == '_' else attr ds_dict[prop] = getattr(self, prop) return ds_dict
[docs] @classmethod def from_dict(cls, the_dict): """Return a new |class_demand_source| object with attributes copied from the values in ``the_dict``. List attributes (``demand_list``, ``probabilities``) are deep-copied so changes to the original dict do not get propagated to the object. Any missing attributes are set to their default values. Parameters ---------- the_dict : dict Dict representation of a |class_demand_source|, typically created using ``to_dict()``. Returns ------- DemandSource The object converted from the dict. """ if the_dict is None: ds = cls() else: # Build empty DemandSource. ds = cls() # Fill attributes. for attr in cls._DEFAULT_VALUES.keys(): # Remove leading '_' to get property names. prop = attr[1:] if attr[0] == '_' else attr # Some attributes require special handling. if prop == 'demand_list': if prop not in the_dict or the_dict[prop] is None: value = None elif the_dict[prop] is not None: # If elements of demand_list are dicts (keys = products, values = demands), # replace string keys with integers. value = [{int(k): v for k, v in d.items()} if is_dict(d) else d for d in the_dict[prop]] else: if prop in the_dict: value = the_dict[prop] else: value = cls._DEFAULT_VALUES[attr] # Set the property/attribute. setattr(ds, prop, value) return ds
# DEMAND GENERATION
[docs] def generate_demand(self, period=None): """Generate a demand value using the demand type specified in ``type``. If ``type`` is ``None``, returns ``None``. Parameters ---------- period : int, optional The period to generate a demand value for. If ``type`` = 'D' (deterministic), this is required if ``demand_list`` is a list of demands, one per period. If omitted, will return first (or only) demand in list. Returns ------- demand : float The demand value. """ if self.type is None: return None if self.type == 'N': demand = self._generate_demand_normal() elif self.type == 'P': demand = self._generate_demand_poisson() elif self.type == 'UD': demand = self._generate_demand_uniform_discrete() elif self.type == 'UC': demand = self._generate_demand_uniform_continuous() elif self.type == 'NB': demand = self._generate_demand_negative_binomial() elif self.type == 'D': demand = self._generate_demand_deterministic(period) elif self.type == 'CD': demand = self._generate_demand_custom_discrete() else: demand = None if self.round_to_int: demand = int(np.round(demand)) return demand
def _generate_demand_normal(self): """Generate demand from normal distribution. Returns ------- demand : float The demand value. """ return max(0, float(np.random.normal(self.mean, self.standard_deviation))) def _generate_demand_poisson(self): """Generate demand from Poisson distribution. Returns ------- demand : int The demand value. """ return int(np.random.poisson(self.mean)) def _generate_demand_uniform_discrete(self): """Generate demand from discrete uniform distribution. Returns ------- demand : float The demand value. """ return int(np.random.randint(int(self.lo), int(self.hi) + 1)) def _generate_demand_uniform_continuous(self): """Generate demand from continuous uniform distribution. Returns ------- demand : float The demand value. """ return float(np.random.uniform(self.lo, self.hi - self.lo)) def _generate_demand_negative_binomial(self): """Generate demand from negative binomial distribution. Returns ------- demand : int The demand value. """ return float(np.random.negative_binomial(self.n, self.p)) def _generate_demand_deterministic(self, period=None): """Generate deterministic demand. Parameters ---------- period : int, optional The period to generate a demand value for. This is required if ``demand_list`` is a list of demands, one per period. If omitted, will return first (or only) demand in list. Returns ------- demand : float The demand value. """ if is_iterable(self.demand_list): if period is None: # Return first demand in demand_list list. return self.demand_list[0] else: # Get demand for period mod (# periods in demand_list list), i.e., # if we are past the end of the demand_list list, loop back to the beginning. return self.demand_list[period % len(self.demand_list)] else: # Return demand_list singleton. return self.demand_list def _generate_demand_custom_discrete(self): """Generate demand from custom discrete distribution. Returns ------- demand : float The demand value. """ return np.random.choice(self.demand_list, p=self.probabilities) # OTHER METHODS
[docs] def cdf(self, x): """Cumulative distribution function of demand distribution. In some cases, this is just a wrapper around ``cdf()`` function of ``scipy.stats.rv_continuous`` or ``scipy.stats.rv_discrete`` object. Parameters ---------- x : float Value to calculate cdf for. Returns ------- F : float cdf of ``x``. """ if self.type in (None, 'D'): return None else: distribution = self.demand_distribution return distribution.cdf(x)
[docs] def lead_time_demand_distribution(self, lead_time): """Return lead-time demand distribution, as a ``scipy.stats.rv_continuous`` or ``scipy.stats.rv_discrete`` object. .. note:: For 'UC', 'UD', 'NB', and 'CD' demands, this method calculates the lead-time demand distribution as the sum of ``lead_time`` independent random variables. Therefore, the method requires ``lead_time`` to be an integer for these distributions. If it is not, it raises a ``ValueError``. Parameters ---------- lead_time : float or int The lead time. [:math:`L`] Returns ------- distribution : rv_continuous or rv_discrete The lead-time demand distribution object. Raises ------ ValueError If ``type`` is 'UC', 'UD', 'NB', or 'CD' and ``lead_time`` is not an integer. """ # Check whether lead_time is an integer. if self.type in ('UC', 'UD', 'NB', 'CD') and not is_integer(lead_time): raise ValueError("lead_time must be an integer for 'UC', 'UD', 'NB', or 'CD' demand") # Get distribution object. if self.type == 'N': return scipy.stats.norm(self.mean * lead_time, self.standard_deviation * math.sqrt(lead_time)) elif self.type == 'P': return scipy.stats.poisson(self.mean * lead_time) elif self.type == 'UC': distribution = sum_of_continuous_uniforms_distribution(lead_time, self.lo, self.hi) elif self.type == 'UD': distribution = sum_of_discrete_uniforms_distribution(lead_time, self.lo, self.hi) elif self.type == 'NB': # Build probability list. min_demand = 0 max_demand = int(self.demand_distribution.ppf(0.9999)) prob = [self.demand_distribution.pmf(d) for d in range(min_demand, max_demand + 1)] prob = [prob[d] / sum(prob) for d in range(min_demand, max_demand + 1)] distribution = sum_of_discretes_distribution(lead_time, min_demand, max_demand, prob) elif self.type == 'CD': # Convert probability list to a list with 0 values for x values not in support. min_demand = min(self.demand_list) max_demand = max(self.demand_list) prob = [] for x in range(min_demand, max_demand + 1): if x in self.demand_list: prob.append(self.probabilities[self.demand_list.index(x)]) else: prob.append(0) distribution = sum_of_discretes_distribution(lead_time, min_demand, max_demand, prob) else: return None return distribution