Script 2. of the online course "An introduction to agent-based modeling with Python" by Claudius Gräbner

For the course homepage and links to the acompanying videos (in German) see: http://claudius-graebner.com/introabmdt.html

For the Enlish version (currently without videos) see: http://claudius-graebner.com/introabmen.html

Last updated July 18 2019

Classes and OOP

In Python, everything is an object. As we have heard, every object has two types of characteristics:

  • Attribute data (or 'properties')
  • Attribute functions and corresponding methods

These characteristics are defined in classes. Classes are abstract blueprints for object, and every object is a particular instance of a class.

To better undstand of which class an object is an instance of, you can use the type function, and in fact we already did this when introducing various basic types in Python:

In [1]:
print(type(2))
<class 'int'>

So 2 is an instance of the class int.

In Python 3, types and classes have become synonymous.

When we define classes our own, we can create blueprints for objects that we are about to use multiple times, or into which we want to encapsulate certain methods and properties.

Later, common classes we are going to define include:

  • Models
  • Agents
  • The environment

If this somehow reminds of you of the presentation on systemism, this is no coincidence: there are some deeper philosophical relations between the concept of systemism and object-oriented programming, but we are not going into this topic here.

Defining new classes

Rather, let us define our first class to see how classes are actually structured:

In [2]:
class Student:
    """
    This is a student class.
    """
    profession = "student"

Classes are defined using they keyword class. The we first mention the name of the new class. It is a convention that class names are given in CamelCases.

After the definition there comes the docstring. So far, this is almost the same as in the case of functions.

Then we add class varialbles. They are the same for all instances of this class. For example, we would reasonably say that the profession of every student is 'student'.

Actually, this is already sufficient to create an (admittedly ver uninteresting) class:

In [3]:
Student
Out[3]:
__main__.Student

As I said before, classes are blueprints for their instances. To create an instance of the class student, simply use student with parenthesis, i.e. function notation:

In [4]:
tina = Student()
tina
Out[4]:
<__main__.Student at 0x111546198>

The "at ...." here indicates that Python has saved an object somewhere on your computer, and referenced it to the variable 'tina'.

Every class has its own namespace created, which we can assess just as the namespace of modules:

In [5]:
tina.profession
Out[5]:
'student'

Class functions and methods

Yet, our student is not very interesting and cannot do anything. We can change this by adding functions as attributes of Student:

In [6]:
class Student:
    """
    This is a student class.
    """
    profession = "student"
    
    def drink(self, beers):
        return beers*["Cheers!"]

You can verify that Student.drink is a function:

In [7]:
Student.drink
Out[7]:
<function __main__.Student.drink>

However:

In [8]:
tina = Student()
tina.drink
Out[8]:
<bound method Student.drink of <__main__.Student object at 0x111546b00>>

This remembers us about what we have said about methods in the previous session. tina.drink is a methods because it references a valid function attribute of the class.

The same is true, for example, for l_1.sort if we assume that l_1 is an instance of the class list.

In [9]:
tina.drink(3)
Out[9]:
['Cheers!', 'Cheers!', 'Cheers!']

Keep in mind that when drink was defined within the class body of Student it was specified to take two parameters: self and beers. This is typical of function attributes that define methdods: a method always takes as a first parameter the object from which it was called. In the present case, the instance 'tina'.

Thus, note the following correspondance:

In [10]:
Student.drink(tina, 3)
Out[10]:
['Cheers!', 'Cheers!', 'Cheers!']
In [11]:
tina.drink(3)
Out[11]:
['Cheers!', 'Cheers!', 'Cheers!']

Since the first call did not come from the object itself, the first parameter had to be passed to the function explicitly. In the second case, the function was called via the method, so the first parameters was implicitly given by the class instance through which it was called.

By the way, the same is true for the methods we encountered in the context of lists:

In [12]:
l_1 = [1,2,3]
l_1.append(10)
l_1
Out[12]:
[1, 2, 3, 10]
In [13]:
list.append(l_1, 20)
l_1
Out[13]:
[1, 2, 3, 10, 20]

Back to our class Student. So far it contains one class atribute that is shared by all instances of the class, and a function attribute.

The init function

We now add an init function that defines instance attributes:

In [14]:
class Student:
    """
    This is a student class.
    """
    profession = "student"
    
    def __init__(self, name, affiliation):
        self.name = name
        self.affiliation = affiliation
        self.beers = 0
        self.credits = 0
    
    def drink(self, beers):
        return beers*["Cheers!"] 

Most classes have an init function. Because it is very special, it comes with the two underscores around its names. The init function is called whenever an instance of this class is created.

As with all class functions, its first parameter is always the instance of the class itself.

All following parameters must be specified whenever a new instance of this class gets created. In our case: whenever you define a new instance of the class student, you have to specify the name and the affiliation of this student.

Then, there are two additional instance attributes specified with a default value: every new student starts with the property of not having drunk any beers, and not having gained any credit points.

However, in contrast to the class attribute 'student', the instance attributes might vary across instances, introducing heterogeneity among instances:

In [15]:
claire = Student("Claire", "Uni Erfurt")
jon = Student("Jon", "Uni Jena")

print(claire.profession)
print(jon.profession)
student
student
In [16]:
print(claire.name)
print(claire.affiliation)
print(jon.name)
print(jon.affiliation)
Claire
Uni Erfurt
Jon
Uni Jena

The repr function

If we call an instance of the student class, the result is ugly and uninformative:

In [17]:
jon
Out[17]:
<__main__.Student at 0x1115463c8>

By default, Python prints the class description ('__main__.Student') and the location on which the object is saved ('0x1089a89b0').

But it is nicer to tell Python explicitly what it should print if we just call the instance of a class student. For example, when we just call the instance of an integer, Python simply prints the value of this integer, not its location on the hard drive:

In [18]:
2
Out[18]:
2

To achieve a similar thing for our students, we specify the __repr__ function. More specifically, we want Python to print the name of the student:

In [19]:
class Student:
    """
    This is a student class.
    """
    profession = "student"
    
    def __init__(self, name, affiliation):
        self.name = name
        self.affiliation = affiliation
        self.beers = 0
        self.credits = 0
        
    def __repr__(self):
        return self.name
    
    def drink(self, beers):
        return beers*["Cheers!"] 
In [20]:
jon = Student("Jon", "Uni Jena")
jon
Out[20]:
Jon

Adding heterogeneity

For the moment, the two studentes Jon and Claire have the same amount of beers and credits. Let us amend the class function a bit so that we can change this after having defined the instances:

In [21]:
class Student:
    """
    This is a student class.
    """
    profession = "student"
    
    def __init__(self, name, affiliation):
        self.name = name
        self.affiliation = affiliation
        self.beers = 0
        self.credits = 0
        
    def __repr__(self):
        return self.name
    
    def drink(self, beers):
        self.beers += beers
        return beers*["Cheers!"] 
    
    def study(self, cp_earned):
        self.credits += cp_earned
In [22]:
claire = Student("Claire", "Uni Erfurt")
jon = Student("Jon", "Uni Jena")

print("The beers:")
print(claire.beers)
print(jon.beers)

print("The credits:")
print(claire.credits)
print(jon.credits)
The beers:
0
0
The credits:
0
0

Now let them drink and study:

In [23]:
jon.drink(2)
jon.study(8)
claire.drink(12)
claire.study(1)

print("The beers:")
print(claire.beers)
print(jon.beers)

print("The credits:")
print(claire.credits)
print(jon.credits)
The beers:
12
2
The credits:
1
8

You can now see how later heterogeneity among the agents in an ABM can emerge: agents are all instances of the same class, which is why they share the same attributes and the same class attributes, but they might differ in their instance attributes.

Classes and their namespaces

Note that all classes create their own, private namespaces: you cannot simply enter the properties of the class and change or print them from the outside. You must be explicit in addressing these attributes:

In [24]:
claire = Student("Claire", "Uni Erfurt")
beers
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
<ipython-input-24-4825b7f5d8f6> in <module>()
      1 claire = Student("Claire", "Uni Erfurt")
----> 2 beers

NameError: name 'beers' is not defined

But:

In [25]:
claire.beers
Out[25]:
0

More complex classes, such as lists or strings are created in exactly the same manner as our simple class student. They come with class attributes and class functions, just with more and more sophisticated ones.

Relationships

Class and instance attributes can be many things and once you have grasped the concept you will be able to express and study a vast number of interesting phenomena.

As an example, lets teach our students to make friends.

To this end, we add a new attribute to the class, called friends, and a new function through which the agents can make friends.

In [26]:
class Student:
    """
    This is a student class.
    """
    profession = "student"
    
    def __init__(self, name, affiliation):
        self.name = name
        self.affiliation = affiliation
        self.beers = 0
        self.credits = 0
        self.friends = set()
        
    def __repr__(self):
        return self.name
    
    def drink(self, beers):
        self.beers += beers
        return beers*["Cheers!"] 
    
    def study(self, cp_earned):
        self.credits += cp_earned
    
    def make_friend(self, new_friend):
        self.friends.add(new_friend)

Now we can befriend Jon and Claire:

In [27]:
claire = Student("Claire", "Uni Erfurt")
jon = Student("Jon", "Uni Jena")

claire.make_friend(jon)
jon.make_friend(claire)

claire.friends
Out[27]:
{Jon}

We can then, for example, add a function that lets an agent drink with all her friends:

In [28]:
class Student:
    """
    This is a student class.
    """
    profession = "student"
    
    def __init__(self, name, affiliation):
        self.name = name
        self.affiliation = affiliation
        self.beers = 0
        self.credits = 0
        self.friends = set()
    
    def __repr__(self):
        return self.name
    
    def drink(self, beers):
        self.beers += beers
        return beers*["Cheers!"] 
    
    def study(self, cp_earned):
        self.credits += cp_earned
    
    def make_friend(self, new_friend):
        self.friends.add(new_friend)
    
    def drink_with_friends(self, beers):
        for f in self.friends:
            self.drink(beers)
            f.drink(beers)
In [29]:
claire = Student("Claire", "Uni Erfurt")
jon = Student("Jon", "Uni Jena")

claire.make_friend(jon)
jon.make_friend(claire)

claire.drink_with_friends(2)
jon.drink_with_friends(6)
print(claire.beers)
print(jon.beers)
8
8

As you can see, classes allow you to model a variety of various phenomena already. You could, for example, add a happiness attribute, which itself depends on the number of friends and the credits gained. Then suppose making new friends is random and depends on the character of an agents, which is given to the agents during their instantiation. And suppose their marks depends negatively on the beers students have drunk. Then you could explore whethter having many friends, which might mean you drink more, is actually good for happiness, for if an agent has no friends but studies hard, he has good marks (which makes him happy), but few friends (which makes him unhappy), etc. etc.

Such explorations must be introduced in a much more systematic way, but you hopefully see how our future journey might look like.

When you program classes it is easy to make mistakes. The assertion function we encountered last week can help you to avoid this.

For example, lets suppose you want to make sure that students can also make friends with other students, and not, say, integers.

At the moment, there is nothing that prevents students to make friends with integers:

In [30]:
claire.make_friend(8)
claire.friends
Out[30]:
{8, Jon}

A combination of assertions and the function isinstance can help us to avoid such strange relationships between Students and ints.

The name of the function isinstance is illustrative. It tests whether a particular object is an instance of a given class:

In [31]:
isinstance(1, int)
Out[31]:
True
In [32]:
isinstance(1, str)
Out[32]:
False

We can use this to build in a defense against weird friendships into our Student class:

In [33]:
class Student:
    """
    This is a student class.
    """
    profession = "student"
    
    def __init__(self, name, affiliation):
        self.name = name
        self.affiliation = affiliation
        self.beers = 0
        self.credits = 0
        self.friends = set()
        
    def __repr__(self):
        return self.name
    
    def drink(self, beers):
        self.beers += beers
        return beers*["Cheers!"] 
    
    def study(self, cp_earned):
        self.credits += cp_earned
    
    def make_friend(self, new_friend):
        assert isinstance(new_friend, Student), "A student can make friends only with other students!"
        self.friends.add(new_friend)
    
    def drink_with_friends(self, beers):
        for f in self.friends:
            self.drink(beers)
            f.drink(beers)
In [34]:
claire = Student("Claire", "Uni Erfurt")
jon = Student("Jon", "Uni Jena")
claire.make_friend(jon)
claire.friends
Out[34]:
{Jon}
In [35]:
claire.make_friend(8)
---------------------------------------------------------------------------
AssertionError                            Traceback (most recent call last)
<ipython-input-35-43811bfb2d2e> in <module>()
----> 1 claire.make_friend(8)

<ipython-input-33-816e6589eb5f> in make_friend(self, new_friend)
     23 
     24     def make_friend(self, new_friend):
---> 25         assert isinstance(new_friend, Student), "A student can make friends only with other students!"
     26         self.friends.add(new_friend)
     27 

AssertionError: A student can make friends only with other students!

Class inheritance

Lets take a look at one of the most important concepts in the context of object oriented programming: class inheritance.

Supppose we have defined our class Student as follows:

In [36]:
class Student:
    """
    This is a student class.
    """
    profession = "student"
    
    def __init__(self, name, affiliation):
        self.name = name
        self.affiliation = affiliation
        self.beers = 0
        self.credits = 0
        self.friends = set()
        
    def __repr__(self):
        return self.name
        
    def make_friend(self, new_friend):
        assert isinstance(new_friend, Student), "A student can make friends only with other students!"
        self.friends.add(new_friend)

Now suppose we want to create two new classes that both can do everything that the Student class can do, but also more things. In this case, we would like them to inherit everything from the Student class. This can be achieved the following way:

In [37]:
class BachelorStudent(Student):
    """
    Inherits from the Student class.
    Additionally, BachelorStudent can also drink.
    """
    profession = "Bachelor Student"
    
    def drink(self, beers):
        self.beers += beers
        return beers*["Cheers!"] 

class MasterStudent(Student):
    """
    Inherits from the Student class.
    Additionally, MasterStudent can also study.
    """
    profession = "Master Student"
    
    def study(self, cp_earned):
        self.credits += cp_earned

Note that in the class body we write down only those attributes that differ from the parent class (here: Student). This feature is very useful whenever you create several similar classes.

In the present case, both Bachelor and Master Students share the same __init__ and __repr__, but they have different attribute functions:

In [38]:
claire = BachelorStudent("Claire", "Uni Erfurt")
jon = MasterStudent("Jon", "Uni Jena")
print(claire.affiliation)
print(jon.affiliation)
claire.drink(2)
jon.study(2)
Uni Erfurt
Uni Jena

So, claire can dring, and jon can study, but...

In [39]:
claire.study(2)
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
<ipython-input-39-04c15b703a85> in <module>()
----> 1 claire.study(2)

AttributeError: 'BachelorStudent' object has no attribute 'study'
In [40]:
jon.drink(4)
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
<ipython-input-40-d4729d0cb6b1> in <module>()
----> 1 jon.drink(4)

AttributeError: 'MasterStudent' object has no attribute 'drink'

Note that whenever you change something in the parent class, the changes mitigate to the children classes, this is helpful when you fix mistakes.

Nested classes

The last thing we want to consider in the context of classes is how to nest classes. This will later become super useful when we build models.

Suppose we have again our class student as defined above.

In [41]:
class Student:
    """
    This is a student class.
    """
    profession = "student"
    
    def __init__(self, name, affiliation):
        self.name = name
        self.affiliation = affiliation
        self.beers = 0
        self.credits = 0
        self.friends = set()
        
    def __repr__(self):
        return self.name
    
    def drink(self, beers):
        self.beers += beers
        return beers*["Cheers!"] 
    
    def study(self, cp_earned):
        self.credits += cp_earned
    
    def make_friend(self, new_friend):
        assert isinstance(new_friend, Student), "A student can make friends only with other students!"
        self.friends.add(new_friend)
    
    def drink_with_friends(self, beers):
        for f in self.friends:
            self.drink(beers)
            f.drink(beers)

Now lets define an additional class called StuRa. It has a class attribute, which indicates its function (which is the same for all StuRas), an instance attribute indicating its members (which are not the same for all StuRas, but specific for every particular StuRa), and an attribute function that makes its members work (which is the same for all StuRas, I suppose):

In [42]:
class StuRa:
    """
    A StuRa class.
    """
    function = "Organize body of students"
    
    def __init__(self, members):
        assert isinstance(members, list), "Members must be given as list!"
        for m in members:
            assert isinstance(m, Student), "Members themselves must be Students!"
        self.members = members
        
    def befriend_members(self):
        """
        Befriends all members of the StuRa with each other.
        Function ensures nobody is befriended with him or herself!
        """
        for m in self.members:
            for n in self.members:
                if m is not n:
                    m.make_friend(n)
    
    def hard_work(self, amount_work):
        for m in self.members:
            print(m, " works hard!")
            m.drink(amount_work)
    
    def make_stura_party(self):
        for m in self.members:
            print(m, "drinks with all his friends!")
            m.drink_with_friends(2)

You see that it is easy to define a class that contains instances of other classes as members, and encapsulates typical operations on and among these members:

In [43]:
claire = Student("Claire", "Uni Erfurt")
jon = Student("Jon", "Uni Erfurt")
jill = Student("Jill", "Uni Erfurt")

stura_erfurt = StuRa([claire, jon, jill])
In [44]:
stura_erfurt.members
Out[44]:
[Claire, Jon, Jill]
In [45]:
print(claire.friends)
stura_erfurt.befriend_members()
print(claire.friends)
set()
{Jon, Jill}
In [46]:
stura_erfurt.hard_work(10)
Claire  works hard!
Jon  works hard!
Jill  works hard!
In [47]:
stura_erfurt.make_stura_party()
Claire drinks with all his friends!
Jon drinks with all his friends!
Jill drinks with all his friends!

Here the class StuRa naturally contains members of the class Student and some methods that specify the typical actions of and relations among members of a StuRa.

As you might have guessed at this point, we will usually build a class Model, which contains all elements of our model, such as Agents, which themselves will be classes.

This makes it much easier to analyze models!