3
\$\begingroup\$

Some friends and I were playing a board game and decided that the random dice rolls just weren't fair! So we came up with a scheme for making the rolls slightly more evenly distributed, and I implemented it in python. The class represents 2 six sided dice and makes the probability of a given combination coming up (there are 36 options for 2 dice) inversely proportional to the number of times it has come up previously - thereby ensuring the distribution tends to be more even than randomness allows:

from collections import Counter
from typing import Tuple, Counter as TCounter
import random


class LoadedDice:
    """
    Simulation of 2 Dice being rolled.
    Weights the random roll to try and tend to an even distribution
    """

    def __init__(self, sides: int = 6) -> None:
        self.sides = sides
        self.history: TCounter[int] = Counter(range(sides ** 2))

    def roll(self) -> Tuple[int, int]:
        result_index = self.get_weighted_random_roll_index()
        self.history[result_index] += 1
        return self.roll_value_from_index(result_index)

    def get_weighted_random_roll_index(self) -> int:
        roll_threshold = random.random()
        reciprocals_total = sum(self.reciprocals())
        running_total = 0
        for result_index, value in enumerate(self.reciprocals()):
            running_total += value / reciprocals_total
            if roll_threshold <= running_total:
                return result_index

    def roll_value_from_index(self, index: int) -> Tuple[int, int]:
        mod, remainder = divmod(index, self.sides)
        roll_1 = mod + 1
        roll_2 = remainder + 1
        return roll_1, roll_2

    def reciprocals(self):
        for v in self.history.values():
            yield 1 / v

    @property
    def roll_history(self) -> TCounter[Tuple[int, int]]:
        result: TCounter[Tuple[int, int]] = Counter()
        for roll_index, count in self.history.items():
            result[self.roll_value_from_index(roll_index)] += count
        return result

    @property
    def result_history(self) -> TCounter[int]:
        result: TCounter[int] = Counter()
        for roll_index, count in self.history.items():
            result[sum(self.roll_value_from_index(roll_index))] += count
        return result

    def __repr__(self):
        return repr(self.roll_history)

... which can be used like this:

especially_fair_dice = LoadedDice(6)  # simulate 2 6-sided dice
especially_fair_dice.roll()  # returns the dice values results like (1,5)

Feedback

I'd like some review on types, as I've never done them before, efficiency of my algorithm; I feel like it's a bit overly complicated, anything I've missed about pseudo-random numbers that make my approach undesirable etc.

Results

I plotted some of the results, for 100 rolls, the loaded die approach tends to perform better than random at keeping to the expected distribution, while still feeling random (that's the goal, interpret it as you wish, I don't know how to quantify it either:) comparison of rolls

\$\endgroup\$
5
  • 4
    \$\begingroup\$ Though it doesn't affect my comment on the code or implementation, the whole premise of this problem is classically fallacious; read about the gambler's fallacy here: en.wikipedia.org/wiki/Gambler%27s_fallacy \$\endgroup\$
    – Reinderien
    Commented Nov 19, 2020 at 17:21
  • 2
    \$\begingroup\$ What is your intent with 1 / v ** 0? You realize this will always evaluate to 1? \$\endgroup\$
    – Reinderien
    Commented Nov 19, 2020 at 17:23
  • 1
    \$\begingroup\$ @Reinderien Oops, sorry good catch, that was me messing with the exponent (the higher the exponent, the more strongly it tends to the expectede). Edited out \$\endgroup\$
    – Greedo
    Commented Nov 19, 2020 at 17:29
  • 1
    \$\begingroup\$ I should add that this is for short games with ~50 dice rolls, over a long game the random approach does of course tend towards expected, as should the loaded die (just the loaded die tends to expected faster) \$\endgroup\$
    – Greedo
    Commented Nov 19, 2020 at 17:33
  • 1
    \$\begingroup\$ You do know that dice have no memory, yes? \$\endgroup\$
    – paxdiablo
    Commented Nov 20, 2020 at 5:34

2 Answers 2

3
\$\begingroup\$

Performance

If you intend on using this for (e.g.) large-scale Monte-Carlo simulation, consider moving to vectorized Numpy rather than base Python.

Direct generators

There's no need for yield here:

    for v in self.history.values():
        yield 1 / v

Instead,

return (1/v for v in self.history.values())

Counter initialization

roll_history can be reduced, I think, to

return Counter(
    (
        sum(self.roll_value_from_index(roll_index)),
        count,
    )
    for roll_index, count in self.history.items()
)

In other words, Counter accepts an iterable of key-count tuples. If the above generator expression seems unwieldy to you, write a separate method to yield roll-count pairs:

    for roll_index, count in self.history.items():
        roll_1, roll_2 = self.roll_value_from_index(roll_index)
        yield roll_1 + roll_2, count
\$\endgroup\$
4
  • 1
    \$\begingroup\$ What's the advantage of the "Direct generators" and how are they "direct"? One might as well say "There's no need for return here", arguing for the opposite. \$\endgroup\$ Commented Nov 19, 2020 at 17:55
  • 1
    \$\begingroup\$ @superbrain Direct in the sense that you don't need to split out a for-loop, and the method itself will not be a generator; it will simply return a generator object. \$\endgroup\$
    – Reinderien
    Commented Nov 19, 2020 at 18:01
  • 1
    \$\begingroup\$ Well you have a for clause instead. What's the advantage? \$\endgroup\$ Commented Nov 19, 2020 at 18:04
  • 1
    \$\begingroup\$ Concision, in this case. \$\endgroup\$
    – Reinderien
    Commented Nov 19, 2020 at 18:09
3
\$\begingroup\$

By using the weights or cum_weights keyword argument, random.choices() can make a weighted selection from a population of objects. get_weighted_random_roll_index could be replace with:

def get_weighted_random_roll_index(self):
    choices = random.choices(range(self.sides**2), weights=self.reciprocals())
    return choices[0]

random.choices() returns a list, which defaults to just 1 element. I use return choices[0] because it's easy to overlook a [0] at the end of the previous line.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.