$$ \newcommand{\tp}{\thinspace .} \newcommand{\Prob}[1]{\hbox{P}(#1)} $$

 

 

 

This chapter is taken from the book A Primer on Scientific Programming with Python by H. P. Langtangen, 5th edition, Springer, 2016.

Drawing integers

Suppose we want to draw a random integer among the values 1, 2, 3, and 4, and that each of the four values is equally probable. One possibility is to draw real numbers from the uniform distribution on, e.g., \( [0,1) \) and divide this interval into four equal subintervals:

import random
r = random.random()
if 0 <=  r < 0.25:
    r = 1
elif 0.25 <= r < 0.5:
    r = 2
elif 0.5 <= r < 0.75:
    r = 3
else:
    r = 4

Nevertheless, the need for drawing uniformly distributed integers occurs quite frequently, so there are special functions for returning random integers in a specified interval \( [a, b] \).

Random integer functions

Python's random module has a built-in function randint(a,b) for drawing an integer in \( [a,b] \), i.e., the return value is among the numbers a, a+1, \( \ldots \), b-1, b.

import random
r = random.randint(a, b)

The numpy.random.randint(a, b, N) function has a similar functionality for vectorized drawing of an array of length N of random integers in \( [a, b) \). The upper limit b is not among the drawn numbers, so if we want to draw from a, a+1, \( \ldots \), b-1, b, we must write

import numpy as np
r = np.random.randint(a, b+1, N)

Another function, random_integers(a, b, N), also in numpy.random, includes the upper limit b in the possible set of random integers:

r = np.random.random_integers(a, b, N)

Example: Throwing a die

Scalar version

We can make a function that lets the computer throw a die N times and returns the fraction of the throws the die shows six eyes:

def six_eyes(N):
    M = 0                  # no of times we get 6 eyes
    for i in xrange(N):
        outcome = random.randint(1, 6)
        if outcome == 6:
            M += 1
    return float(M)/N

We use xrange instead of range because the former avoids storing N numbers in memory, which can be an important feature when N is large.

Vectorized version

Too speed up the experiments, we can vectorize the drawing of the random numbers and the counting of the number successful experiments:

import numpy as np

def six_eyes_vec(N):
    eyes = np.random.randint(1, 7, N)
    success = eyes == 6     # True/False array
    M = np.sum(success)     # treats True as 1, False as 0
    return float(M)/N

The eyes == 6 construction results in an array with True or False values, and np.sum applied to this array treats True as 1 and False as 0 (the integer equivalents to the boolean values), so the sum is the number of elements in eyes that equals 6. A very important point here for computational efficiency is to use np.sum and not the standard sum function that is available in standard Python. With np.sum function, the vectorized version runs about 60 times faster than the scalar version. With Python's standard sum function, the vectorized versions is in fact twice as slow as the scalar version (!). We can illustrate the gain and loss in efficiency by the follow IPython session:

In [1]: from roll_die import six_eyes, six_eyes_vec

In [2]: %timeit six_eyes(100000)
1 loops, best of 3: 250 ms per loop

In [3]: %timeit six_eyes_vec(100000)
100 loops, best of 3: 4.11 ms per loop

In [4]: 250/4.11           # performance fraction
Out[4]: 60.8272506082725

In [5]: from roll_die import np

In [6]: np.sum = sum  # fool numpy to use built-in Python sum

In [7]: %timeit six_eyes_vec(100000)
1 loops, best of 3: 543 ms per loop

(Note how we can bind Python's built-in sum function to the np.sum name such that np.sum in six_eyes_vec applies Python's sum function instead of the original np.sum.)

Vectorized version with batches

The disadvantage with the vectorized version is that all the random numbers must be stored in the computer's memory. A large N may cause the program to run out of memory and raise a MemoryError. Instead of drawing all random numbers at once, we can draw them in batches of size arraysize. There will be N//arraysize such batches, plus a rest. Note the double slash in N//arraysize: here we indeed want integer division, which is explicitly instructed in Python by the double forward slash. The rest is obtained by the mod operator: rest = N % arraysize. The size of the batches can be stored in a list:

rest = N % arraysize
batch_sizes = [arraysize]*(N//arraysize) + [rest]

We can now make one batch of random numbers at a time and count how many times we get six:

def six_eyes_vec2(N, arraysize=1000000):
    # Split all experiments into batches of size arraysize,
    # plus a final batch of size rest
    # (note: N//arraysize is integer division)
    rest = N % arraysize
    batch_sizes = [arraysize]*(N//arraysize) + [rest]

    M = 0
    for batch_size in batch_sizes:
        eyes = np.random.randint(1, 7, batch_size)
        success = eyes == 6      # True/False array
        M += np.sum(success)     # treats True as 1, False as 0
    return float(M)/N

Because we fix the seed, the computed f will always be the same in this function.

Verification of the scalar version

Verifying computations with random numbers requires the seed to be fixed. When we believe the scalar version in function six_eyes is correct, mainly by observing that the return value approaches 1/6 as the number of experiments, N, grows, we can call the function with a small N and record the return value for use in a test function:

def test_scalar():
    random.seed(3)
    f = six_eyes(100)
    f_exact = 0.26
    assert abs(f_exact - f) < 1E-15

Verification of all versions

Since we have three alternative functions for computing the same quantity, a verification can be based on comparing the output of all three functions. This is somewhat problematic since the scalar and vectorized versions apply different random number generators. Fixing the seed of Python's random module and numpy.random does not help as these two tools will generate different sequences of random integers. Nevertheless, we can fool the scalar version in the six_eyes function to use np.random.randint instead of random.randit: this is just a matter of setting random = np.random (with a declaration global random) before calling six_eyes. The problem is that the call np.random.randint(1, 6) in six_eyes will then generate the numbers up to but not including 6, so M will always be zero. A little trick, can solve the problem: we redefine random.randit to be a function that calls np.random.randint:

random.randint = lambda a, b: np.random.randint(a, b+1, 1)[0]

The call random.randint(1, 6) in six_eyes now becomes np.random.randint(1, 7, 1)[0], i.e., we generate an array of 1 random integer and extract the first element so the result is a scalar number as before.

A test function can call all three functions, with the same fixed seed, and compare the returned values:

def test_all():
    # Use np.random as random number generator for all three
    # functions and make sure all of them applies the same seed
    N = 100
    arraysize = 40
    random.randint = lambda a, b: np.random.randint(a, b+1, 1)[0]
    tol = 1E-15

    np.random.seed(3)
    f_scalar = six_eyes(N)
    np.random.seed(3)
    f_vec = six_eyes_vec(N)
    assert abs(f_scalar - f_vec) < tol

    np.random.seed(3)
    f_vec2 = six_eyes_vec2(N, arraysize=80)
    assert abs(f_vec - f_vec2) < tol

All the functions above are found in the file roll_die.py.

Drawing a random element from a list

Given a list a, the statement

re = random.choice(a)

picks out an element of a at random, and re refers to this element. The shown call to random.choice is the same as

re = a[random.randint(0, len(a)-1)]

There is also a function shuffle that performs a random permutation of the list elements:

random.shuffle(a)

Picking now a[0], for instance, has the same effect as random.choice on the original, unshuffled list. Note that shuffle changes the list given as argument.

The numpy.random module has also a shuffle function with the same functionality.

A small session illustrates the various methods for picking a random element from a list:

>>> awards = ['car', 'computer', 'ball', 'pen']
>>> import random
>>> random.choice(awards)
'car'
>>> awards[random.randint(0, len(awards)-1)]
'pen'
>>> random.shuffle(awards)
>>> awards[0]
'computer'

Example: Drawing cards from a deck

The following function creates a deck of cards, where each card is represented as a string, and the deck is a list of such strings:

def make_deck():
    ranks = ['A', '2', '3', '4', '5', '6', '7',
             '8', '9', '10', 'J', 'Q', 'K']
    suits = ['C', 'D', 'H', 'S']
    deck = []
    for s in suits:
        for r in ranks:
            deck.append(s + r)
    random.shuffle(deck)
    return deck

Here, 'A' means an ace, 'J' represents a jack, 'Q' represents a queen, 'K' represents a king, 'C' stands for clubs, 'D' stands for diamonds, 'H' means hearts, and 'S' means spades. The computation of the list deck can alternatively (and more compactly) be done by a one-line list comprehension:

    deck = [s+r for s in suits for r in ranks]

We can draw a card at random by

deck = make_deck()
card = deck[0]
del deck[0]
# or better:
card = deck.pop(0)  # return and remove element with index 0

Drawing a hand of n cards from a shuffled deck is accomplished by

def deal_hand(n, deck):
    hand = [deck[i] for i in range(n)]
    del deck[:n]
    return hand, deck

Note that we must return deck to the calling code since this list is changed. Also note that the n first cards of the deck are random cards if the deck is shuffled (and any deck made by make_deck is shuffled).

The following function deals cards to a set of players:

def deal(cards_per_hand, no_of_players):
    deck = make_deck()
    hands = []
    for i in range(no_of_players):
        hand, deck = deal_hand(cards_per_hand, deck)
        hands.append(hand)
    return hands

players = deal(5, 4)
import pprint; pprint.pprint(players)

The players list may look like

[['D4', 'CQ', 'H10', 'DK', 'CK'],
 ['D7', 'D6', 'SJ', 'S4', 'C5'],
 ['C3', 'DQ', 'S3', 'C9', 'DJ'],
 ['H6', 'H9', 'C6', 'D5', 'S6']]

The next step is to analyze a hand. Of particular interest is the number of pairs, three of a kind, four of a kind, etc. That is, how many combinations there are of n_of_a_kind cards of the same rank (e.g., n_of_a_kind=2 finds the number of pairs):

def same_rank(hand, n_of_a_kind):
    ranks = [card[1:] for card in hand]
    counter = 0
    already_counted = []
    for rank in ranks:
        if rank not in already_counted and \ 
               ranks.count(rank) == n_of_a_kind:
            counter += 1
            already_counted.append(rank)
    return counter

Note how convenient the count method in list objects is for counting how many copies there are of one element in the list.

Another analysis of the hand is to count how many cards there are of each suit. A dictionary with the suit as key and the number of cards with that suit as value, seems appropriate to return. We pay attention only to suits that occur more than once:

def same_suit(hand):
    suits = [card[0] for card in hand]
    counter = {}   # counter[suit] = how many cards of suit
    for suit in suits:
        count = suits.count(suit)
        if count > 1:
            counter[suit] = count
    return counter

For a set of players we can now analyze their hands:

for hand in players:
    print """\ 
The hand %s
    has %d pairs, %s 3-of-a-kind and %s cards of the same suit."""%\ 
    (', '.join(hand), same_rank(hand, 2),
     same_rank(hand, 3),
     '+'.join([str(s) for s in same_suit(hand).values()]))

The values we feed into the printf string undergo some massage: we join the card values with comma and put a plus in between the counts of cards with the same suit. (The join function requires a string argument. That is why the integer counters of cards with the same suit, returned from same_suit, must be converted to strings.) The output of the for loop becomes

The hand D4, CQ, H10, DK, CK
    has 1 pairs, 0 3-of-a-kind and 2+2 cards of the same suit.
The hand D7, D6, SJ, S4, C5
    has 0 pairs, 0 3-of-a-kind and 2+2 cards of the same suit.
The hand C3, DQ, S3, C9, DJ
    has 1 pairs, 0 3-of-a-kind and 2+2 cards of the same suit.
The hand H6, H9, C6, D5, S6
    has 0 pairs, 1 3-of-a-kind and 2 cards of the same suit.

The file cards.py contains the functions make_deck, hand, same_rank, same_suit, and the test snippets above. With the cards.py file one can start to implement real card games.

Example: Class implementation of a deck

To work with a deck of cards with the code from the previous section one needs to shuffle a global variable deck in and out of functions. A set of functions that update global variables (like deck) is a primary candidate for a class: the global variables are stored as data attributes and the functions become class methods. This means that the code from the previous section is better implemented as a class. We introduce class Deck with a list of cards, deck, as data attribute, and methods for dealing one or several hands and for putting back a card:

class Deck(object):
    def __init__(self):
        ranks = ['A', '2', '3', '4', '5', '6', '7',
                 '8', '9', '10', 'J', 'Q', 'K']
        suits = ['C', 'D', 'H', 'S']
        self.deck = [s+r for s in suits for r in ranks]
        random.shuffle(self.deck)

    def hand(self, n=1):
        """Deal n cards. Return hand as list."""
        hand = [self.deck[i] for i in range(n)]  # pick cards
        del self.deck[:n]                        # remove cards
        return hand

    def deal(self, cards_per_hand, no_of_players):
        """Deal no_of_players hands. Return list of lists."""
        return [self.hand(cards_per_hand) \ 
                for i in range(no_of_players)]

    def putback(self, card):
        """Put back a card under the rest."""
        self.deck.append(card)

    def __str__(self):
        return str(self.deck)

This class is found in the module file Deck.py. Dealing a hand of five cards to p players is coded as

from Deck import Deck
deck = Deck()
print deck
players = deck.deal(5, 4)

Here, players become a nested list as shown in the section Example: Drawing cards from a deck.

One can go a step further and make more classes for assisting card games. For example, a card has so far been represented by a plain string, but we may well put that string in a class Card:

class Card(object):
    """Representation of a card as a string (suit+rank)."""
    def __init__(self, suit, rank):
        self.card = suit + str(rank)

    def __str__(self):   return self.card
    def __repr__(self):  return str(self)

Note that str(self) is equivalent to self.__str__().

A Hand contains a set of Card instances and is another natural abstraction, and hence a candidate for a class:

class Hand(object):
    """Representation of a hand as a list of Card objects."""
    def __init__(self, list_of_cards):
        self.hand = list_of_cards

    def __str__(self):   return str(self.hand)
    def __repr__(self):  return str(self)

With the aid of classes Card and Hand, class Deck can be reimplemented as

class Deck(object):
    """Representation of a deck as a list of Card objects."""

    def __init__(self):
        ranks = ['A', '2', '3', '4', '5', '6', '7',
                 '8', '9', '10', 'J', 'Q', 'K']
        suits = ['C', 'D', 'H', 'S']
        self.deck = [Card(s,r) for s in suits for r in ranks]
        random.shuffle(self.deck)

    def hand(self, n=1):
        """Deal n cards. Return hand as a Hand object."""
        hand = Hand([self.deck[i] for i in range(n)])
        del self.deck[:n]         # remove cards
        return hand

    def deal(self, cards_per_hand, no_of_players):
        """Deal no_of_players hands. Return list of Hand obj."""
        return [self.hand(cards_per_hand) \ 
                for i in range(no_of_players)]

    def putback(self, card):
        """Put back a card under the rest."""
        self.deck.append(card)

    def __str__(self):
        return str(self.deck)

    def __repr__(self):
        return str(self)

    def __len__(self):
        return len(self.deck)

The module file Deck2.py contains this implementation. The usage of the two Deck classes is the same,

from Deck2 import Deck
deck = Deck()
players = deck.deal(5, 4)

with the exception that players in the last case holds a list of Hand instances, and each Hand instance holds a list of Card instances.

The golden rule is that the __repr__ method should return a string such that one can recreate the object from this string by the aid of eval. However, we did not follow this rule in the implementation of classes Card, Hand, and Deck. Why? The reason is that we want to print a Deck instance. Python's print or pprint on a list applies repr(e) to print an element e in the list. Therefore, if we had implemented

class Card(object):
    ...
    def __repr__(self):
        return "Card('%s', %s)" % (self.card[0], self.card[1:])

class Hand(object):
    ...
    def __repr__(self):  return 'Hand(%s)' % repr(self.hand)

a plain printing of the deck list of Hand instances would lead to output like

[Hand([Card('C', '10'), Card('C', '4'), Card('H', 'K'), ...]),
 ...,
 Hand([Card('D', '7'), Card('C', '5'), ..., Card('D', '9')])]

This output is harder to read than

[[C10, C4, HK, DQ, HQ],
 [SA, S8, H3, H10, C2],
 [HJ, C7, S2, CQ, DK],
 [D7, C5, DJ, S3, D9]]

That is why we let __repr__ in classes Card and Hand return the same pretty print string as __str__, obtained by returning str(self).