jrj-d / cardano

Models random variables and bayesian nets using monads in Scala



Models random variables and bayesian nets using monads in Scala. Inference is performed by sampling.

Build Status codecov


Include this dependency in your sbt configuration:

"org.jrj-d" %% "cardano" % "0.2.3"


The scaladoc is available here.

Random variables as monads

cardano models random variable as monads. This should feel familiar to most Scala programmers since the collection library is built this way.

The base trait is Stochastic[A] which defines a random variable of concrete type A.

scala> def die: Stochastic[Int] = Stochastic.discreteUniform(6).map(_ + 1)
die: cardano.Stochastic[Int] = ...

The above line creates a uniform discrete random variable from 0 to 5 (Stochastic.discreteUniform(6)). Applying map(_ + 1) on it effectively creates a fair die with values from 1 to 6.

Let's test our die by computing its expectation and the probability of yielding a 1.

scala> die.expectation
res0: Double = 3.554

scala> die.map(_ == 1).expectation
res1: Double = 0.156

scala> die.map(_ == 1).expectation(10000)
res2: Double = 0.1667

The number of samples defaults to 1000 when no argument is given.

The filter operation allows us to compute conditional probabilities. The expectation of a die given that the value is less than 3:

scala> die.filter(_ < 3).expectation
res16: Double = 1.504

To model the sum of two dice, we will combine two dice using flatMap:

scala> val sumDice = die.flatMap(v => die.map(_ + v))
sumDice: cardano.Stochastic[Int] = ...

scala> sumDice.expectation
res21: Double = 7.062

scala> sumDice.std
res23: Double = 2.40265041152474

That's it! Along with Stochastic.constant(...) that allows us to build constant random variables, all the standard monadic Scala operations are here.

Let's combine all of this in a solution to the Monty Hall problem. Thanks to Scala syntactic sugar, we can define random variables in for loops:

scala> :paste
// Entering paste mode (ctrl-D to finish)

val keepStrategy = for(
    winningDoor <- Stochastic.discreteUniform(3);
    firstChosenDoor <- Stochastic.discreteUniform(3);
    revealedDoor <- Stochastic.discreteUniform(3)
      if revealedDoor != firstChosenDoor && revealedDoor != winningDoor
) yield winningDoor == firstChosenDoor

val changeStrategy = for(
    winningDoor <- Stochastic.discreteUniform(3);
    firstChosenDoor <- Stochastic.discreteUniform(3);
    revealedDoor <- Stochastic.discreteUniform(3)
      if revealedDoor != firstChosenDoor && revealedDoor != winningDoor
) yield (winningDoor != firstChosenDoor && winningDoor != revealedDoor)

// Exiting paste mode, now interpreting.

keepStrategy: cardano.Stochastic[Boolean] = ...
changeStrategy: cardano.Stochastic[Boolean] = ...

scala> keepStrategy.expectation
res65: Double = 0.311

scala> changeStrategy.expectation
res66: Double = 0.652

Indeed, if the player changes door after the host reveals a loosing door, they have a probability of winning of 2/3, 1/3 otherwise.

Duplicating random variables

What if we want to study the rolls of 5 dice? Chaining flatMap would be tedious.

Method repeat of Stochastic[A] allows us to duplicate a random variable. The duplicates are identically distributed and independent.

repeat takes as input a function of signature (=> A) => F[A] for some higher-kinded type F. Let's create a random variable of a sequence of 5 dice:

scala> val fiveDice = die.repeat(Seq.fill[Int](5))
fiveDice: cardano.Stochastic[Seq[Int]] = ...

scala> fiveDice.sample
res24: Seq[Int] = List(6, 2, 6, 4, 5)

scala> fiveDice.sample
res25: Seq[Int] = List(1, 2, 2, 6, 5)

scala> fiveDice.map(s => s.distinct.size == s.size).expectation
res40: Double = 0.0933

The last line gives an estimate of the probability that all dice have a different value.

repeat allows us more exotic constructs like an infinite sequence of die rolls:

scala> val dice = die.repeat(Stream.continually[Int])
dice: cardano.Stochastic[scala.collection.immutable.Stream[Int]] = ...

scala> dice.sample.take(10).toList
res46: List[Int] = List(5, 2, 5, 6, 1, 5, 2, 6, 3, 3)

scala> dice.sample.take(10).toList
res47: List[Int] = List(4, 4, 2, 4, 2, 6, 4, 5, 4, 5)

This allows us to estimate the number of dice required to reach a given sum, say 30:

scala> val runningSum = dice.map(_.scan(0)(_ + _))
runningSum: cardano.Stochastic[scala.collection.immutable.Stream[Int]] = ...

scala> runningSum.sample.take(10).toList
res48: List[Int] = List(0, 6, 10, 16, 21, 22, 27, 32, 37, 42)

scala> val nbDice = runningSum.map(_.takeWhile(_ < 30).length)
nbDice: cardano.Stochastic[Int] = ...

scala> nbDice.expectation
res49: Double = 9.014

scala> nbDice.std
res51: Double = 1.4758045263516417

Markov chain

Method markov of Stochastic creates a Markov chain of the first order. It takes as input a function indicating the relationship between two consecutive terms.

Here is a model of the Californian weather (90% chance of a sunny day after a sunny day, 50% after a rainy day):

scala> :paste
// Entering paste mode (ctrl-D to finish)

sealed trait Weather
case object Sunny extends Weather
case object Rainy extends Weather

def boolToWeather(b: Boolean): Weather = if(b) Sunny else Rainy

def transition(weather: Weather): Stochastic[Weather] = weather match {
  case Sunny => Stochastic.coin(0.9).map(boolToWeather)
  case Rainy => Stochastic.coin(0.5).map(boolToWeather)

val weatherChain: Stochastic[Stream[Weather]] = Stochastic.constant[Weather](Sunny).markov(transition)

// Exiting paste mode, now interpreting.

defined trait Weather
defined object Sunny
defined object Rainy
boolToWeather: (b: Boolean)Weather
transition: (weather: Weather)cardano.Stochastic[Weather]
weatherChain: cardano.Stochastic[Stream[Weather]] = ...

scala> weatherChain.sample.take(10).toList
res54: List[Weather] = List(Sunny, Sunny, Rainy, Sunny, Sunny, Sunny, Sunny, Sunny, Sunny, Rainy)

Bayesian inference

Sampling is often used to compute posteriors in intractable cases. For the sake of brevity, we will work here with a toy example that is in fact tractable.

We want to determine the bias of a coin by repeatedly flipping it. The prior is a Beta distribution with a rather flat belief in dealing with a fair coin (2, 2).

scala> :paste
// Entering paste mode (ctrl-D to finish)

val unknownCoin = Stochastic.coin(0.6)

val prior = Stochastic.beta(2, 2)

val observations = Seq.fill(100)(unknownCoin.sample)

val posterior = Stochastic.posterior(prior, observations) { (bias, observation) =>
    if(observation) bias else 1 - bias

// Exiting paste mode, now interpreting.

unknownCoin: cardano.Stochastic[Boolean] = ...
prior: cardano.Stochastic[Double] = ...
observations: Seq[Boolean] = ...

scala> posterior.expectation
res55: Double = 0.6051810933353833

scala> posterior.std
res56: Double = 0.0464702905875049

See also posteriorByLog where the log-likelihood is provided by the user.

MCMC sampling

cardano includes the Metropolis-Hastings and Metropolis sampling procedures, along with a facility to build maximum entropy distributions. This can be especially useful when you only have access to the unnormalized density of the distribution.

Suppose we don't have Stochastic.beta but we want to create a Beta random variable. Metropolis-Hastings is a sampling procedure that builds a Markov chain whose equilibrium distribution is the target distribution. It takes as input as a transition function and an unnormalized target density.

The transition function consists in adding a small uniform noise to the current value and capping it to [0,1]. The unnormalized target distribution can be found on Wikipedia.

scala> :paste
// Entering paste mode (ctrl-D to finish)

def logDensity(a: Double, b: Double, value: Double) = (a - 1) * math.log(value) + (b - 1) * math.log(1 - value)

def cap(value: Double) = math.min(1.0, math.max(0.0, value))

val metropolisBeta = Stochastic.metropolis(Stochastic.constant(0.5))(logDensity(20, 10, _)) { lastValue =>
    Stochastic.continuousUniform.map(noise => cap(lastValue + 0.1 * noise - 0.05))

val exactBeta = Stochastic.beta(20, 10)

// Exiting paste mode, now interpreting.

logDensity: (a: Double, b: Double, value: Double)Double
cap: (value: Double)Double
metropolisBeta: cardano.Stochastic[Double] = ...
exactBeta: cardano.Stochastic[Double] = ...

scala> metropolisBeta.expectation
res61: Double = 0.6743298870740473

scala> exactBeta.expectation
res62: Double = 0.6646562222468744

scala> metropolisBeta.std
res63: Double = 0.08492853676864531

scala> exactBeta.std
res64: Double = 0.0850264034989098

Refer to the scaladoc to set the burn-in period and the interval between two consecutive samples.

Note that the Bayesian inference capabilities of cardano exposed through methods posterior and posteriorByLog builds upon those MCMC sampling techniques.