A package for drawing samples from pseudo-random multinomial distribution.
Term | Meaning |
---|---|
Number of states of chain |
|
Stationary of state |
|
|
Linger probability |
The event emitted by all states belonging to the chain |
|
Chain transition probability from chain |
|
Expectation (number of consecutive occurrences) of event |
|
Nominal probability of event |
In one of our previous projects, we wanted to generate 0/1 bits such that 0 (failure) or 1 (success) events are promoted to occur consecutively, forming clusters. Additionally, we also want to be able to set the expected number of consecutive occurrences and overall nominal probability at will.
For this purpose, we came up with a statistical method called Pseudo-random Binomial Distribution.
It is based on a Markov State Machine consisting of a pair of sequential chains.
Each chain
The term
The key idea is that we constrain the transition probabilities as follows:
- The lingering probabilities
$\alpha^i_j$ must be a monotonically decreasing series:
- The lingering probability at the final state
$N_i$ of the chain$i$ is always 0. This constraint is expressed as a limit as$N_i$ can be infinity.
- The self-chain transition probability is zero.
Informally, at the beginning of each chain
To make the process controllable, we use parameterized functions
The parameter
This method can be easily generalized to pseudo-random multinomial distribution of
The base expectation of a chain
Finite chains | Infinite chains | |
---|---|---|
Base Expectation Formula |
The formula of finite chains (with reasonably small number of states) can be easily computed.
Meanwhile, for infinite chains, the sum of cumulative product of linger probabilities is a series that always converge
if
Then, the (real) expectation of each chain
To compute nominal probabilities, we need first to compute the entrance stationary
Note that the last equation contains the relationship between the expectation, entrance stationary, and probability
of event
numpy
cy-root
cython
(if compiled from source)
pseudo-multinomial has pre-built binaries hosted at TestPyPI (for now):
pip install -i https://test.pypi.org/simple/ pseudo-multinomial
Otherwise, pull this repo and install from source (requires a C++ compiler):
pip install .
Or build binaries inplace for development:
python setup.py build_ext -i
The state of the Markov chain is stored inside an instance of the PseudoMultinomialGenerator
class, which we can
initialize with two parameters:
chains
: A sequence of chains, subclasses ofpseudo_multinomial.chains.Chain
. The index of each chain in the list is the event it emits.chain_transition_matrix
: A 2D matrix describing the probabilities of moving from one chain to another.
In this example, we replicate the perfect coin flip using a pair of ForwardingChain
.
We use the PseudoMultinomialGenerator.generate
method to draw samples from the distribution.
from pseudo_multinomial import PseudoMultinomialGenerator, ForwardingChain
g = PseudoMultinomialGenerator(
chains=[ForwardingChain(), # this chain has 100% exit probability
ForwardingChain()],
chain_transition_matrix=[
[.5, .5], # both chains have 50% chance to
[.5, .5], # be chosen.
])
print(g.generate()) # draw a single sample
print(g.generate(size=100)) # draw 100 samples
Output:
1
[1 0 0 1 0 0 0 0 0 1 1 0 1 0 0 0 0 0 1 1 1 1 1 1 1 1 0 1 1 0 1 1 1 1 0 1 0 1 0 0 0 1 0 1 0 0 1 0 0 1 0 1 0 1 1 0 1 0 0 0 1 1 1 1 1 0 1 1 1 0 1 0 0 0 1 1 1 1 1 1 1 0 1 1 1 0 0 0 1 0 0 0 0 0 0 1 0 1 0 1]
By modifying the chain transition matrix
from pseudo_multinomial import PseudoMultinomialGenerator, ForwardingChain, pseudo_multinomial
g = PseudoMultinomialGenerator(
chains=[ForwardingChain(),
ForwardingChain()],
chain_transition_matrix=[
[0, 1], # alternate between chains
[1, 0],
])
# this function is a wrapper around PseudoMultinomialGenerator.generate
print(pseudo_multinomial(g, size=100, random_init=True))
Output:
[1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0]
Alternatively, using the from_pvals
method, PseudoMultinomialGenerator
can be initialized with pvals
parameter (mostly analogous to that of numpy.random.multinomial
).
In this example, we create an unnatural 6-faced dice that usually generate consecutive results using
the HarmonicChain
.
By setting pvals=[1/6] * 6
, the initialized generator is equivalent to the one returned by calling the default
constructor with chain_transition_matrix=np.full(shape=(6, 6), fill_value=1/6)
.
from pseudo_multinomial import PseudoMultinomialGenerator, HarmonicChain
g = PseudoMultinomialGenerator.from_pvals(
chains=[HarmonicChain(c=.5),
HarmonicChain(c=.5),
HarmonicChain(c=.5),
HarmonicChain(c=.5),
HarmonicChain(c=.5),
HarmonicChain(c=.5)],
pvals=[1/6] * 6,
repeat=True)
g.random_init() # optionally, we would like to start at a random state
print(g.generate(100))
Output:
[2 0 0 0 0 3 3 3 1 1 5 5 0 0 0 1 1 1 0 2 2 2 5 1 1 1 1 1 5 4 1 5 5 0 0 0 0 4 4 4 4 4 3 0 5 5 5 5 5 0 0 4 1 4 4 0 0 2 3 3 3 2 2 4 4 4 4 4 4 3 4 4 4 4 4 4 4 4 3 1 1 5 5 5 4 2 5 4 4 4 5 5 1 2 1 1 1 0 0 0]
Each event occurs approximately 2.63 times consecutively, however, the nominal probability of each event is still
exactly
print(g.expectations())
# [2.63343366 2.63343366 2.63343366 2.63343366 2.63343366 2.63343366]
print(g.probs())
# [0.16666667 0.16666667 0.16666667 0.16666667 0.16666667 0.16666667]
As mentioned in Background, we usually don't know in advance the hyperparameters of chains
that produces a desired behavior
(i.e. a predefined nominal probability or the average number of consecutive occurrences of an event)
and our current solution is to cast this to a root-finding problem using the RootFindingSolver
class.
One downside is that it only works reasonably well for solving a single hyperparameter and fixing all others (if there
are more than one) since vector root-finding problems are much harder.
If you are a Warcraft 3 or Dota 2 fan, you may notice that these games have a statistical mechanic affecting
certain skills or items.
It discourages the consecutive occurrence of success events to limit the influence of luck
(e.g. multiple crits in a row).
It entails slowly increasing the success probability by a constant value
We can recreate this with a LinearChain
with initial_state=2
for failure event (0) and a ForwardingChain
for
success event (1).
The success chain can also repeat itself with probability
This example also shows the use of the RootFindingSolver
to optimize a single parameter
from pseudo_multinomial import PseudoMultinomialGenerator, LinearChain, ForwardingChain
from pseudo_multinomial.solver import RootFindingSolver
c = 0.3 # an arbitrary initial value
g = PseudoMultinomialGenerator(
chains=[LinearChain(c=c, initial_state=2),
ForwardingChain()],
chain_transition_matrix=[
[0., 1.],
[1 - c, c],
])
def update_c(c): # define a function to update parameter c
g.chains[0].c = c
g.S[1, 1] = c
g.S[1, 0] = 1 - c
p = 0.05
while p < 1.01:
solver = RootFindingSolver(objective_fn=lambda: g.probs()[1],
objective_val=p,
update_fn=update_c)
solver.solve(method='bisect', a=1e-7, b=1, etol=1e-15, ertol=0, ptol=0, prtol=0)
print(f'desired_p={p:.02f}, solved_p={g.probs()[1]:.05f}, c={g.chains[0].c}')
p += .05
Output:
desired_p=0.05, solved_p=0.05000, c=0.003801658303553148
desired_p=0.10, solved_p=0.10000, c=0.01474584478107244
desired_p=0.15, solved_p=0.15000, c=0.03222091437308729
desired_p=0.20, solved_p=0.20000, c=0.055704042949781624
desired_p=0.25, solved_p=0.25000, c=0.08474409185231699
desired_p=0.30, solved_p=0.30000, c=0.11894919272540436
desired_p=0.35, solved_p=0.35000, c=0.15798309812574696
desired_p=0.40, solved_p=0.40000, c=0.20154741360775413
desired_p=0.45, solved_p=0.45000, c=0.24930699844016374
desired_p=0.50, solved_p=0.50000, c=0.3021030253487422
desired_p=0.55, solved_p=0.55000, c=0.36039785093316745
desired_p=0.60, solved_p=0.60000, c=0.4226497308103737
desired_p=0.65, solved_p=0.65000, c=0.4811254783372293
desired_p=0.70, solved_p=0.70000, c=0.5714285714285714
desired_p=0.75, solved_p=0.75000, c=0.666666666666665
desired_p=0.80, solved_p=0.80000, c=0.7500000000000004
desired_p=0.85, solved_p=0.85000, c=0.8235294117647065
desired_p=0.90, solved_p=0.90000, c=0.8888888888888895
desired_p=0.95, solved_p=0.95000, c=0.9473684210526316
desired_p=1.00, solved_p=1.00000, c=1.0
Note that the LinearChain
has finite number of states
See more: https://dota2.fandom.com/wiki/Random_Distribution
To define a custom chain, you can either extend the base classes (Chain
, FiniteChain
, InfiniteChain
), or
use the LambdaChain
.
This example defines:
- A finite
QuadraticChain
:$e_k = c.k^2$ with number of states$n=\left\lfloor\sqrt{\frac{1}{c}}\right\rfloor$ . - An infinite
AlgebraicChain
:$e_k = \frac{\sqrt{c}.k}{\sqrt{1 + c.k^2}}$ , which has nice properties$e_0=0$ and$\lim_{k\rightarrow\infty}{e_k}=1$ .
import math
from pseudo_multinomial import (
PseudoMultinomialGenerator, LinearChain, GeometricChain,
FiniteChain, InfiniteChain # Base classes
)
from pseudo_multinomial.utils import validate_generator
class QuadraticChain(FiniteChain):
def __init__(self, c=1., *args, **kwargs):
super().__init__(*args, **kwargs)
self.c = c
def exit_probability(self, k: int): # override this method
return self.c * k ** 2
def n_states(self): # override this method
return math.floor(math.sqrt(1 / self.c))
def __repr__(self):
return f'{self.__class__.__name__}(c={self.c})'
class AlgebraicChain(InfiniteChain):
def __init__(self, c=1., *args, **kwargs):
super().__init__(*args, **kwargs)
assert c >= 0
self.c = c
self._c_sqrt = math.sqrt(self.c)
def exit_probability(self, k: int):
return self._c_sqrt * k / math.sqrt(1 + self.c * k ** 2)
def __repr__(self):
return f'{self.__class__.__name__}(c={self.c})'
g = PseudoMultinomialGenerator.from_pvals([
LinearChain(c=0.4),
GeometricChain(a=1, r=.2),
QuadraticChain(c=.15),
AlgebraicChain(c=7),
], repeat=False)
validate_generator(g, n_rolls=100000)
Output:
[Checking Generator] n_rolls=100000
PseudoMultinomialGenerator(
0: LinearChain(c=0.4),
1: GeometricChain(a=1.0, r=0.2),
2: QuadraticChain(c=0.15),
3: AlgebraicChain(c=7),
)
--------------------------------------------------
Chain transition matrix:
[[0. 0.33333333 0.33333333 0.33333333]
[0.33333333 0. 0.33333333 0.33333333]
[0.33333333 0.33333333 0. 0.33333333]
[0.33333333 0.33333333 0.33333333 0. ]]
--------------------------------------------------
analytical probs: [0.27814694 0.19536008 0.3541522 0.17234078]
simulated probs : [0.27882 0.19422 0.353 0.17396]
analytical expectations: [1.72 1.2080641 2.19 1.06571782]
simulated expectations : [1.72569165 1.20193081 2.19281898 1.06743572]
Besides, you can write your own Cython Chain and build from source.
For more examples, check the examples
folder.
MIT licensed. See LICENSE.txt
.