Script 2 for the video lectures accompanying the online course "An Introduction to Agent-Based Modelling in Python" by Claudius Gräbner
For the course homepage see: https://claudius-graebner.com/teaching/introduction-abm-deutsch.html
In Python, everything is an object. As we have heard, every object has two types of characteristics:
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:
print(type(2))
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:
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.
Rather, let us define our first class to see how classes are actually structured:
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:
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:
tina = Student()
tina
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:
tina.profession
Yet, our student is not very interesting and cannot do anything.
We can change this by adding functions as attributes of Student
:
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:
Student.drink
However:
tina = Student()
tina.drink
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
.
tina.drink(3)
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:
Student.drink(tina, 3)
tina.drink(3)
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
:
l_1 = [1,2,3]
l_1.append(10)
l_1
list.append(l_1, 20)
l_1
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.
We now add an init function that defines instance attributes:
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:
claire = Student("Claire", "University of Love")
jon = Student("Jon", "University of Joy")
print(claire.profession)
print(jon.profession)
print(claire.name)
print(claire.affiliation)
print(jon.name)
print(jon.affiliation)
If we call an instance of the student class, the result is ugly and uninformative:
jon
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:
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:
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!"]
jon = Student("Jon", "University of Joy")
jon
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:
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
claire = Student("Claire", "University of Love")
jon = Student("Jon", "University of Joy")
print("The beers:")
print(claire.beers)
print(jon.beers)
print("The credits:")
print(claire.credits)
print(jon.credits)
Now let them drink and study:
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)
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.
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:
claire = Student("Claire", "University of Love")
beers
But:
claire.beers
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.
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.
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:
claire = Student("Claire", "University of Love")
jon = Student("Jon", "University of Joy")
claire.make_friend(jon)
jon.make_friend(claire)
claire.friends
We can then, for example, add a function that lets an agent drink with all her friends:
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)
claire = Student("Claire", "University of Love")
jon = Student("Jon", "University of Joy")
claire.make_friend(jon)
jon.make_friend(claire)
claire.drink_with_friends(2)
jon.drink_with_friends(6)
print(claire.beers)
print(jon.beers)
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:
claire.make_friend(8)
claire.friends
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:
isinstance(1, int)
isinstance(1, str)
We can use this to build in a defense against weird friendships into our Student class:
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)
claire = Student("Claire", "University of Love")
jon = Student("Jon", "University of Joy")
claire.make_friend(jon)
claire.friends
claire.make_friend(8)
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:
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:
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:
claire = BachelorStudent("Claire", "University of Love")
jon = MasterStudent("Jon", "University of Joy")
print(claire.affiliation)
print(jon.affiliation)
claire.drink(2)
jon.study(2)
So, claire can drink, and jon can study, but...
claire.study(2)
jon.drink(4)
Note that whenever you change something in the parent class, the changes mitigate to the children classes, this is helpful when you fix mistakes.
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.
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
(which is how the student Unions are called in Eastern Germany).
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):
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:
claire = Student("Claire", "University of Love")
jon = Student("Jon", "University of Love")
jill = Student("Jill", "University of Love")
stura_of_happiness = StuRa([claire, jon, jill])
stura_of_happiness.members
print(claire.friends)
stura_of_happiness.befriend_members()
print(claire.friends)
stura_of_happiness.hard_work(10)
stura_of_happiness.make_stura_party()
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!