Classes

When implementing an agent-based model, we will often use classes. A class is a way to create your own data taype. An instance of a class is called an object (see the [Python class documentation]((https://docs.python.org/3/tutorial/classes.html) for an overview.). The general approach to programming using classes and objects is called object-oriented programming.

To define a class use the class keyword, followed by a name and a colon:. A class can be thought of as a blueprint for creating objects. Classes contain data called attributes and functions called methods. Some initial observations about creating a class:

  1. There are two types of attributes, a class attribute and an instance attribute. Class attirbutes are the same among all instances of the class while the instance attributes may vary for different instances of a class.

  2. Typically, a class contains a __init__ method which will be run every time a new instance is created.

  3. The first parameter of any class method is self which refers to an instance of the class.

  4. Class attributes should be defined outside of the __init__ method, while instance attributes are defined inside the body of the __init__ method.

class Agent():
    
    class_name = 'Agent Class'
    def __init__(self, unique_id):
        self.unique_id = unique_id
        
a = Agent(1)
b = Agent(2)

print(a.class_name)
print("Agent a's unique id is ", a.unique_id)

print(b.class_name)
print("Agent b's unique id is ", b.unique_id)
Agent Class
Agent a's unique id is  1
Agent Class
Agent b's unique id is  2

In a class self refers to an instance of that class. In the following code, printing self and printing the object instance generates the same ouput (the memory location of the instance of the Agent class).

class Agent():
    
    class_name = 'Agent Class'
    def __init__(self, unique_id):
        self.unique_id = unique_id
        print(self)

a = Agent(1)
print(a)
<__main__.Agent object at 0x10ec06f70>
<__main__.Agent object at 0x10ec06f70>

To illustrate class methods, let’s give our Agent the ability to flip a fair coin. To do this, we use the random package to generate a random number between 0 and 1. If the randomly generated number is less than 0.5, we output ‘H’ and if it is greater than or equal to 0.5 we output ‘T’. The first thing we have to do is import the random package.

import random

Next, we add class method called flip that accepts self as its only parameter.

class Agent():
    
    class_name = 'Agent Class'
    def __init__(self, unique_id):
        self.unique_id = unique_id

    def flip(self): 
        return 'H' if random.random() < 0.5 else 'T'
a = Agent(1) # create an agent

# let agent a flip a fair coin 5 times
# there will be different outputs everytime this code is executed
for i in range(5):
    print(a.flip())
T
T
T
H
T

Let’s add an another method to the Agent class that returns multiple flips of a coin. The method will be called multiple_flips and have a keyword parameter num with a default value of 5. Note that we call the class method flip from the multiple_flip function as follows: self.flip().

class Agent():
    
    class_name = 'Agent Class'
    def __init__(self, unique_id):
        self.unique_id = unique_id

    def flip(self): 
        return 'H' if random.random() < 0.5 else 'T'
    
    def multiple_flips(self, num = 5): 
        return [self.flip() for n in range(num)]
a = Agent(1) # create an agent

print(a.multiple_flips(10))
['H', 'T', 'H', 'H', 'T', 'H', 'T', 'H', 'H', 'H']

Inheritence and Subclasses

Inheritance allows one class to “inherit” methods and attributes from another class. For example, suppose that we have a general Agent class and CoinFlipper class that implements an agent that flips a coin of a fixed bias which is a subclass of the Agent class.

class Agent():
    
    class_name = 'Agent Class'
    def __init__(self, unique_id):
        self.unique_id = unique_id

class CoinFlipper(Agent):
    
    def __init__(self, bias):
        self.bias = bias
        
    def flip(self): 
        return 'H' if random.random() < self.bias else 'T'
    
    def multiple_flips(self, num = 5): 
        return [self.flip() for n in range(num)]

An instance of the CoinFlipper class, has access to the attributes and methods of the parent class.

a = CoinFlipper(0.5)
print(a.multiple_flips())
print(a.class_name)
['H', 'T', 'H', 'H', 'T']
Agent Class

However, there is a problem with the above implementation. An instance of the CoinFlipper hasn’t set the unique_id of the partent class.

a = CoinFlipper(0.5)
a.unique_id # produces an error since the CoinFlipper class doesn't have a unique_id attribute
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
<ipython-input-10-aec31d94d2cf> in <module>
      1 a = CoinFlipper(0.5)
----> 2 a.unique_id # produces an error since the CoinFlipper class doesn't have a unique_id attribute

AttributeError: 'CoinFlipper' object has no attribute 'unique_id'

The problem with the above code is that when initializing the CoinFlipper instance we didn’t call the __init__ method of the Agent class. There are two ways to do this:

  1. Explicitly call the base class __init__ method

  2. Use the super() builtin function to instantiate the base class

class Agent():
    
    class_name = 'Agent Class'
    def __init__(self, unique_id):
        self.unique_id = unique_id

class CoinFlipper(Agent):
    
    def __init__(self, unique_id, bias):
        Agent.__init__(self, unique_id) # explicitly call the base class __init__ function
        self.bias = bias
        
    def flip(self): 
        return 'H' if random.random() < self.bias else 'T'
    
    def multiple_flips(self, num = 5): 
        return [self.flip() for n in range(num)]

a = CoinFlipper(1, 0.5)
a.unique_id 
1

The second approach using the super() key word is often a better approach to do the same thing. See https://realpython.com/python-super/ for a discussion.

class Agent():
    
    class_name = 'Agent Class'
    def __init__(self, unique_id):
        self.unique_id = unique_id

class CoinFlipper(Agent):
    
    def __init__(self, unique_id, bias):
        super().__init__(unique_id) # super() refers to the base class
        self.bias = bias
        
    def flip(self): 
        return 'H' if random.random() < self.bias else 'T'
    
    def multiple_flips(self, num = 5): 
        return [self.flip() for n in range(num)]

a = CoinFlipper(1, 0.5)
a.unique_id 
1

Decorators

One programming construct that is not specific to classes, but is often used when creating a class is a decorator. A decorator “decorates” a function/method with additional functionality. That is, it is a function that accepts another function as a paramter and adds functionality to that function.

See https://realpython.com/primer-on-python-decorators/ for an overview of decorators.

Warning

In the following code, we use the the parameter to the inner function wrapper is *args. This is a way of passing an arbitrary number of arguments to wrapper. The problem is that original_func accepts a single argument which needs to be passed to the decorator. See https://realpython.com/python-kwargs-and-args/ for an overview.

def original_func(n):
    print("Original function")
    return n*2

# a decorator
def my_decorator(func):  # takes our original function as input
    
    def wrapper(*args):  # wraps our original function with some extra functionality
        print(f"A decoration before {func.__name__}.")
        result = func(*args)
        print(f"A decoration after {func.__name__} with result {result}")
        return result + 10 # add 10 the result of func
    
    return wrapper  # returns the unexecuted wrapper function which we can can excute later
original_func(10)
Original function
20
my_decorator(original_func)(10)
A decoration before original_func.
Original function
A decoration after original_func with result 20
30

We can now use this decorator for other functions using by adding @my_decorator before the definition of the function.

@my_decorator
def another_func(n): 
    print("Another func")
    return n + 2

another_func(5)
A decoration before another_func.
Another func
A decoration after another_func with result 7
17

Some commonly used decorators are Python builtins @classmethod, @staticmethod, and @property. We focus here on the @property decorator. This can be used to customize getters and setters for class attributes. Suppose that we want to create a Coin class that has a fixed bias (which may be changed).

Note

A common approach in object-oriented programming is to make attributes of a class private so that users of the class can only get and set these attributes through so-called “getter” and “setter” functions. In Python there is no way to force a variable to be “private” (this is different than languages such as C++ or Java). A common approach is to add an underscore “_” to the begining of a variable name that should be private (see https://www.geeksforgeeks.org/private-variables-python/.

class Coin():
    
    def __init__(self, bias = 0.5): 
        
        self._bias = 0.5 
        
    @property
    def bias(self): 
        """Get the bias of the coin"""
        return self._bias
    
    @bias.setter
    def bias(self, b):
        """Set the bias and raise and error if bias is not between 0 and 1"""
        if b >= 0 and b <=1:
            self._bias = b
        else:
            raise ValueError("Bias must be between 0 and 1")
    
    @property
    def pr_heads(self):
        """Get the probability of heads"""
        return self._bias
    
    @property
    def pr_tails(self):
        """Get the probability of heads"""
        return 1 - self._bias

    def flip(self):
        """flip the coin"""
        return 'H' if random.random() < self._bias else 'T'

    def flips(self, num=10):
        """flip the coin"""
        return [self.flip() for n in range(num)]
    
c = Coin()
print("the bias of c is ", c.bias)
print("the probability of heads is ", c.pr_heads)
print("the probability of tails is ", c.pr_tails)
print(c.flips(), "\n")

# now change the bias
c.bias = 0.75
print("the bias of c is ", c.bias)
print("the probability of heads is ", c.pr_heads)
print("the probability of tails is ", c.pr_tails)
print(c.flips())
the bias of c is  0.5
the probability of heads is  0.5
the probability of tails is  0.5
['T', 'T', 'H', 'T', 'H', 'H', 'T', 'H', 'T', 'T'] 

the bias of c is  0.75
the probability of heads is  0.75
the probability of tails is  0.25
['H', 'T', 'T', 'T', 'H', 'H', 'H', 'H', 'T', 'H']

Trying to assign a bias greater than 1 generates an error:

c.bias = 1.5
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
<ipython-input-18-6e018a2fac61> in <module>
----> 1 c.bias = 1.5

<ipython-input-17-cae20d2aafaf> in bias(self, b)
     16             self._bias = b
     17         else:
---> 18             raise ValueError("Bias must be between 0 and 1")
     19 
     20     @property

ValueError: Bias must be between 0 and 1