This chapter is taken from the book A Primer on Scientific Programming with Python by H. P. Langtangen, 5th edition, Springer, 2016.
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] \).
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)
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.
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
.)
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.
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
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.
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'
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.
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)
.