Designing networks

Having actors and locations as agents makes the life of an agent-based modeler much easier. Managing the relationships between interacting entities in agent-based models is now very convenient. However, it is still very tedious to connect a large number of actors through a specific network structure. This is where the real magic of Pop2net comes into play.

Pop2net enables you to design a specific network structure by the definition of a special kind of class: the LocationDesigner. A LocationDesigner can be considered as an interface with a special syntax to create a certain number of location instances that connect specific actors in a specific way. The assignment of actors to locations by the definition of a LocationDesigner is primarely done through actor attributes. Hence, the creation of networks using pop2net makes the most sense when actors have informative attributes. However, even when the actors in your simulation do not have any attributes the LocationDesigner class makes it easy to quickly connect actors in a certain way, for instance using classic network models.

The creation of the network based on what is defined in a LocationDesigner is managed by the Creator class. The Creator reads the definition of a LocationDesigner and translates it into a certain number of certain location instances and then assigns the actors to the corresponding location instances.

The Creator class

In order to design a specific network in Pop2net, the first thing we need is an instance of the Creator class. The purpose of the Creator is to generate actors and locations as well as to connect actors and locations. Hence, the first step after creating an Environment is the creation of an instance of Creator.

[1]:
import pop2net as p2n

env = p2n.Environment()
creator = p2n.Creator(env)

Generating Actors

Although the main task of Pop2net and the Creator class is connecting existing actors via the definition of LocationDesigners, the Creator can also generate actors for you. A special feature of the Creator class is that it has some convenient methods to create actors based on micro-level data. This makes it easy to create empirical actors from survey data, for instance.

Important to know is that while the generation of locations highly depents on the actors and their attributes, the generation of actors is independent from the generation of locations and their connection. This may be different from classic network generators in which you generate nodes and edges in one step.

Tip: You do not have to generate your actor population using Pop2net or the Creator class. If you plan to create your actors in your own way and just want to connect your actors using Pop2net and the Creator class, you can skip this subsection! However, to connect actors using Pop2net, they must inherit from the Pop2net actor class and must be added to the environment.

The simplest way to generate actors using a creator is by using the method creator.create_actors() and define the desired number of actors by setting a certain n:

[3]:
creator.create_actors(n=10)
[3]:
[<pop2net.actor.Actor at 0x75e9d98dea20>,
 <pop2net.actor.Actor at 0x75e9db5773b0>,
 <pop2net.actor.Actor at 0x75e9d9832f00>,
 <pop2net.actor.Actor at 0x75e9d9830f20>,
 <pop2net.actor.Actor at 0x75e9d9832de0>,
 <pop2net.actor.Actor at 0x75e9d98333e0>,
 <pop2net.actor.Actor at 0x75e9d9833770>,
 <pop2net.actor.Actor at 0x75e9d9833500>,
 <pop2net.actor.Actor at 0x75e9d9833830>,
 <pop2net.actor.Actor at 0x75e9d9833320>]
[4]:
env.actors
[4]:
[<pop2net.actor.Actor at 0x75e9d98dea20>,
 <pop2net.actor.Actor at 0x75e9db5773b0>,
 <pop2net.actor.Actor at 0x75e9d9832f00>,
 <pop2net.actor.Actor at 0x75e9d9830f20>,
 <pop2net.actor.Actor at 0x75e9d9832de0>,
 <pop2net.actor.Actor at 0x75e9d98333e0>,
 <pop2net.actor.Actor at 0x75e9d9833770>,
 <pop2net.actor.Actor at 0x75e9d9833500>,
 <pop2net.actor.Actor at 0x75e9d9833830>,
 <pop2net.actor.Actor at 0x75e9d9833320>]

That is not very impressive. We could have done the same using a simple for-loop. Let’s reset everything by removing those actors from the environment:

[5]:
env.remove_actors(env.actors)
[6]:
env.actors
[6]:
[]

Now let’s have a look at an example for what Pop2net was originally designed: The creation of a population of actors based on survey data and their flexible connection via locations. For the following example, we assume that we want to create a network model for schools and have access to a sample of individual data collected in a school. This is our example data:

[7]:
import numpy as np
import pandas as pd

np.random.seed(0)

df_school = (
    pd.read_csv("example_school_data.csv", sep=";")
    .sample(frac=1, replace=False)
    .reset_index(drop=True)
)

df_school.head()
[7]:
status gender grade hours friend_group
0 pupil m 3.0 5 14
1 teacher m NaN 6 0
2 pupil w 2.0 4 12
3 pupil m 2.0 4 11
4 pupil m 3.0 5 6
[8]:
df_school.tail()
[8]:
status gender grade hours friend_group
37 teacher w NaN 9 0
38 pupil w 3.0 5 5
39 social_worker w NaN 6 0
40 pupil w 1.0 4 2
41 pupil m 1.0 4 1

To create actors from this dataset, we can simply use the method creator.create_actors(). This method creates one actor object for each row in the given dataset. Each column is translated into an actor attribute with the corresponding value.

[9]:
_ = creator.create_actors(df=df_school)

The created actors are added to the environment’s network and thus appear in env.actors:

[10]:
env.actors
[10]:
[<pop2net.actor.Actor at 0x75e9d96b38c0>,
 <pop2net.actor.Actor at 0x75e9d96b3800>,
 <pop2net.actor.Actor at 0x75e9d96b3650>,
 <pop2net.actor.Actor at 0x75e9d96b37a0>,
 <pop2net.actor.Actor at 0x75e9d96b37d0>,
 <pop2net.actor.Actor at 0x75e9d96b3710>,
 <pop2net.actor.Actor at 0x75e9d96b3b60>,
 <pop2net.actor.Actor at 0x75e9d96b3ce0>,
 <pop2net.actor.Actor at 0x75e9d96b3860>,
 <pop2net.actor.Actor at 0x75e9d96b3470>,
 <pop2net.actor.Actor at 0x75e9d96b3b90>,
 <pop2net.actor.Actor at 0x75e9d96b3680>,
 <pop2net.actor.Actor at 0x75e9d96b3770>,
 <pop2net.actor.Actor at 0x75e9d96b3bc0>,
 <pop2net.actor.Actor at 0x75e9d96b3d70>,
 <pop2net.actor.Actor at 0x75e9d96b3bf0>,
 <pop2net.actor.Actor at 0x75e9d96b3c50>,
 <pop2net.actor.Actor at 0x75e9d96b3c80>,
 <pop2net.actor.Actor at 0x75e9d96b38f0>,
 <pop2net.actor.Actor at 0x75e9d96b3aa0>,
 <pop2net.actor.Actor at 0x75e9d96b35c0>,
 <pop2net.actor.Actor at 0x75e9d96b3920>,
 <pop2net.actor.Actor at 0x75e9d96b3a70>,
 <pop2net.actor.Actor at 0x75e9d96b3950>,
 <pop2net.actor.Actor at 0x75e9d96b3890>,
 <pop2net.actor.Actor at 0x75e9d96b3cb0>,
 <pop2net.actor.Actor at 0x75e9d96b3da0>,
 <pop2net.actor.Actor at 0x75e9d96b3dd0>,
 <pop2net.actor.Actor at 0x75e9d96b3e00>,
 <pop2net.actor.Actor at 0x75e9d96b3b00>,
 <pop2net.actor.Actor at 0x75e9d96b3c20>,
 <pop2net.actor.Actor at 0x75e9d96b3d10>,
 <pop2net.actor.Actor at 0x75e9d96b3ec0>,
 <pop2net.actor.Actor at 0x75e9d96b3ad0>,
 <pop2net.actor.Actor at 0x75e9d96b3f20>,
 <pop2net.actor.Actor at 0x75e9d96b3f50>,
 <pop2net.actor.Actor at 0x75e9d96b3f80>,
 <pop2net.actor.Actor at 0x75e9d96b3fb0>,
 <pop2net.actor.Actor at 0x75e9d96b3e60>,
 <pop2net.actor.Actor at 0x75e9d96b3ef0>,
 <pop2net.actor.Actor at 0x75e9d96b3e30>,
 <pop2net.actor.Actor at 0x75e9d96b3d40>]

Let’s also take a look at the network graph to better understand that the actors are now in the graph of the environment, but not yet connected to each other:

[11]:
inspector = p2n.NetworkInspector(env)
inspector.plot_bipartite_network()

Let’s have look at the attributes of the first actor. We can see that each cell of the first row of the given data was translated in an actor attribute with the name of the corresponding column. That was done for each row of the given data.

[12]:
vars(env.actors[0])
[12]:
{'env': <pop2net.environment.Environment at 0x75e9da64f5c0>,
 'id_p2n': 10,
 'model': None,
 'type': 'Actor',
 'status': 'pupil',
 'gender': 'm',
 'grade': 3.0,
 'hours': 5,
 'friend_group': 14}

If we do not insert an actor class via the parameter actor_class, the default actor class of Pop2net is used to create the actor instances, but we could also use our own actor class (that inherits from p2n.Actor):

[13]:
class MyActor(p2n.Actor):
    pass


env = p2n.Environment()
creator = p2n.Creator(env)
actors = creator.create_actors(
    df=df_school,
    actor_class=MyActor,
)
actors
[13]:
[<__main__.MyActor at 0x75e9d94d69c0>,
 <__main__.MyActor at 0x75e9d94d6990>,
 <__main__.MyActor at 0x75e9d94d6ab0>,
 <__main__.MyActor at 0x75e9d94d6a20>,
 <__main__.MyActor at 0x75e9d94d6b10>,
 <__main__.MyActor at 0x75e9d94d4b60>,
 <__main__.MyActor at 0x75e9d94d69f0>,
 <__main__.MyActor at 0x75e9d94d63c0>,
 <__main__.MyActor at 0x75e9d94d6a80>,
 <__main__.MyActor at 0x75e9d94d6660>,
 <__main__.MyActor at 0x75e9d94d6720>,
 <__main__.MyActor at 0x75e9d94d6b40>,
 <__main__.MyActor at 0x75e9d94d6b70>,
 <__main__.MyActor at 0x75e9d94d6ba0>,
 <__main__.MyActor at 0x75e9d94d6bd0>,
 <__main__.MyActor at 0x75e9d94d6c00>,
 <__main__.MyActor at 0x75e9d94d6c30>,
 <__main__.MyActor at 0x75e9d94d6c60>,
 <__main__.MyActor at 0x75e9d94d6c90>,
 <__main__.MyActor at 0x75e9d94d6cc0>,
 <__main__.MyActor at 0x75e9d94d6cf0>,
 <__main__.MyActor at 0x75e9d94d6d20>,
 <__main__.MyActor at 0x75e9d94d6d50>,
 <__main__.MyActor at 0x75e9d94d6d80>,
 <__main__.MyActor at 0x75e9d94d6db0>,
 <__main__.MyActor at 0x75e9d94d6de0>,
 <__main__.MyActor at 0x75e9d94d6e10>,
 <__main__.MyActor at 0x75e9d94d6e40>,
 <__main__.MyActor at 0x75e9d94d6e70>,
 <__main__.MyActor at 0x75e9d94d6ea0>,
 <__main__.MyActor at 0x75e9d94d6ed0>,
 <__main__.MyActor at 0x75e9d94d6f00>,
 <__main__.MyActor at 0x75e9d94d6f30>,
 <__main__.MyActor at 0x75e9d94d6f60>,
 <__main__.MyActor at 0x75e9d94d6f90>,
 <__main__.MyActor at 0x75e9d94d6fc0>,
 <__main__.MyActor at 0x75e9d94d6ff0>,
 <__main__.MyActor at 0x75e9d94d70b0>,
 <__main__.MyActor at 0x75e9d94d70e0>,
 <__main__.MyActor at 0x75e9d94d7110>,
 <__main__.MyActor at 0x75e9d94d7140>,
 <__main__.MyActor at 0x75e9d94d71a0>]
[14]:
type(actors[0])
[14]:
__main__.MyActor

For now, this is enough information on how to create actors based on micro-level data using Pop2net.

Tip: More useful options for the generation of actors based on micro-level data can be found in chapter >>>From survey participants to actors<<< (not online yet).

The LocationDesigner

To generate locations using the Creator class, we have to use it in combination with the LocationDesigner class. The LocationDesigner can be considered as an interface to controll the automatic generation of locations and assignment of actors to the locations by the creator.

The LocationDesigner class has various attributes and methods which can be customized by the user to specify which kind of locations are created and how the actors are assigned to which location instances. The attributes and methods of the LocationDesigner are read by the Creator and then translated into the bipartite network of actors and locations. Here is an overview of the attributes and methods of the LocationDesigner class:

[15]:
from __future__ import annotations  # noqa: F404


class MyLocation(p2n.LocationDesigner):
    n_actors: int | None = None
    overcrowding: bool | None = None
    only_exact_n_actors: bool = False
    n_locations: int | None = None
    recycle: bool = True

    def filter(self, actor: p2n.Actor) -> bool:
        """Assigns only actors with certain attributes to this location."""
        return True

    def split(self, actor: p2n.Actor) -> str | float | str | list | None:
        """Creates separated locations for each value of an agent-attribute."""
        return None

    def bridge(self, actor: p2n.Actor) -> float | str | list | None:
        """Create locations with one actor for each unique value returned."""
        return None

    def stick_together(self, actor: p2n.Actor) -> str | float:
        """Ensures that actors with a shared value on an attribute are assigned to the same
        location instance."""
        return actor.id_p2n

    def weight(self, actor: p2n.Actor) -> float:
        """Defines the edge weight between the actor and the location instance."""
        return 1

    def nest(self) -> str | None:
        """Ensures that the actors assigned to the same instance of this location class
        are also assigned to the same instance of the returned location class."""
        return None

    def melt(self) -> list[p2n.Location] | tuple[p2n.Location]:
        """Merges the actors assigned to the instances of the returned location classes
        into one instance of this location class."""
        return []

    def project_weights(self, actor1: p2n.Actor, actor2: p2n.Actor) -> float:
        """Calculates the edge weight between two actors that are assigned to the same location
        instance."""
        return min([self.get_weight(actor1), self.get_weight(actor2)])

Generating locations (and assigning actors)

We start by just using the default class of LocationDesigner without defining any further attributes or methods. Every location class defined by the user must inherit - directly or indirectly - from p2n.LocationDesigner. We call our first location ClassRoom.

[16]:
class ClassRoom(p2n.LocationDesigner):
    pass

Now we use the creator.create_locations() to create the location instances and - and this is important - to assign the actors, which are already in the environment, to the location instances:

[17]:
creator.create_locations(location_designers=[ClassRoom])
[17]:
[<pop2net.creator.Location object at 0x75e9d96b3350>]
[18]:
env.locations
[18]:
[<pop2net.creator.Location object at 0x75e9d96b3350>]

creator.create_locations() has not only created the location instances with respect to the given actor population, but has also assigned the actors to the location according to the rules specified by the location classes. We can check this by looking at the bipartite network:

[19]:
inspector = p2n.NetworkInspector(env)
inspector.plot_bipartite_network()

The node in the center of the graph is the location. All other nodes are actors. This means that if we use the default location class without further customization, only one location instance is created to which all actors are connected. Since each actor is assigned to this one location instance, the result is a fully connected actor graph:

[20]:
inspector.plot_actor_network()

Generating actors and locations in one step

Before diving into all the details of the definition of location classes, let’s simplify the process of generating actors and locations. The method create() combines create_actors() and create_locations() into one simple method. However, note that create() always creates the actors based on a given dataset. If you already have a population of actors, use create_locations() instead.

[21]:
env = p2n.Environment()
creator = p2n.Creator(env)
inspector = p2n.NetworkInspector(env)

# Let the Creator create actors and locations
creator.create(
    df=df_school,
    location_designers=[ClassRoom],
)

The method create() also has the convenient argument clear. Set this to True if you want to remove all existing actors and locations from the environment before adding new ones to the environment. This way you do not have to create new instances of Environment, Creator and Inspector if you want to create a completely new network. In the following, we first run create() with clear explicitly set to False and then with clear set to True.

If clear is False (which is the default value) the new actors and locations are added to the environment without removing the existing ones:

[22]:
creator.create(
    df=df_school,
    location_designers=[ClassRoom],
    clear=False,
)
print("actors:", len(env.actors), "locations:", len(env.locations))
actors: 84 locations: 2

If clear is True only the new actors and locations remain in the environment:

[23]:
creator.create(
    df=df_school,
    location_designers=[ClassRoom],
    clear=True,
)
print("actors:", len(env.actors), "locations:", len(env.locations))
actors: 42 locations: 1

The keyword clear is also available in create_actors() and create_locations(), where it removes either all existing actors or all existing locations from the environment.

Setting the location size

In the next step, we specify the number of people in one classroom. In this example, we assume tiny classrooms of four actors. To set the number of actors per location, we need to set the class attribute n_actors to the desired value.

[24]:
class ClassRoom(p2n.LocationDesigner):
    n_actors = 4

Let’s use the modified ClassRoom to create the network:

[25]:
creator.create(df=df_school, location_designers=[ClassRoom], clear=True)
inspector.plot_networks()

The network diagrams above now show multiple clusters. Each cluster represents one classroom. If we set a specific size for a location, the Creator creates as many location instances with that size as needed. The actors are then assigned randomly to one of these location instances. As you can see, some classrooms have more than four members because the number of actors assigned to classrooms cannot be divided exactly by the desired number of four.

The overcrowding attribute determines how the number of required locations is rounded. By default, overcrowding is set to None which means that the number of required locations is either rounded up or rounded down using round. Below we change overcrowding to False to create one more location instance to avoid overcrowding the classrooms.

[26]:
class ClassRoom(p2n.LocationDesigner):
    n_actors = 4
    overcrowding = False


creator.create(df=df_school, location_designers=[ClassRoom], clear=True)

inspector.plot_bipartite_network()

The plot above shows that there is now is no class room that has more than 4 members. However, now there is a class room which only has two members. We could also set overcrowding to True to always round down the number of necessary location instances.

If we do not want locations that are either overcrowded or undercrowded but only locations that have exactly the size we defined, we could use the attribute only_exact_n_actors to True:

[27]:
class ClassRoom(p2n.LocationDesigner):
    n_actors = 4
    only_exact_n_actors = True


creator.create(df=df_school, location_designers=[ClassRoom], clear=True)

inspector.plot_bipartite_network()

As a consequence, two actors are not assigned to any location.

Defining the number of locations

The attribute n_actors implicitly changes the number of the created locations. Using the attribute n_locations, you can also set the number of locations explicitly:

[28]:
class ClassRoom(p2n.LocationDesigner):
    n_actors = 4
    n_locations = 4


creator.create(df=df_school, location_designers=[ClassRoom], clear=True)

inspector.plot_bipartite_network()

While in the example above four classrooms with the defined size of n_actors = 4 are created, in the example below n_actors is not set explicitly:

[29]:
class ClassRoom(p2n.LocationDesigner):
    n_locations = 4


creator.create(df=df_school, location_designers=[ClassRoom], clear=True)

inspector.plot_bipartite_network()

Specifying location visitors

The classrooms above are made of all actors. But in many cases we want specific locations to be exclusively accessible to certain actors. For this scenario the method filter() exists. If this method returns True, an actor gets assigned to an instance of this location class. The most common way to use this method is to specify a condition that requires a certain actor attribute to contain a certain value.

In this example we want classrooms to be only accessible for pupils.

[30]:
class ClassRoom(p2n.LocationDesigner):
    n_actors = 4

    def filter(self, actor):
        return actor.status == "pupil"


creator.create(df=df_school, location_designers=[ClassRoom], clear=True)

inspector.plot_bipartite_network(actor_color="status")

Now classrooms consist only of pupils, while all other actors do not belong to any location.

Building separated locations

Currently, classrooms are not separated by grade. To seperate actors by grade, we could define one location class for each grade and use filter() to assign only actors with a specific grade value to a specific location.

A more convenient way to do it is to use the method split(). For each actor, the method split() returns one value. For each unique value, seperated location instances are created. In this case, the method split() returns the attribute grade for each actors. Thus, the Creator builds seperate classroom instances for each unique value of the actor attribute grade.

[31]:
class ClassRoom(p2n.LocationDesigner):
    n_actors = 4

    def filter(self, actor):
        return actor.status == "pupil"

    def split(self, actor):
        return actor.grade


creator.create(df=df_school, location_designers=[ClassRoom], clear=True)

inspector.plot_networks(actor_color="grade")

Keeping actors together

In the following plot the nodes are colored by their attribute friend_group. It shows that the members of friend groups are distributed over different classrooms.

[32]:
class ClassRoom(p2n.LocationDesigner):
    n_actors = 4

    def filter(self, actor):
        return actor.status == "pupil"

    def split(self, actor):
        return actor.grade


creator.create(df=df_school, location_designers=[ClassRoom], clear=True)

inspector.plot_networks(actor_color="friend_group")

Although this is a very realistic situation, in this example, we want that all friend group members are always in the same class. To implement that, we use the location method stick_together(): For each actor, the method stick_together() returns a specific value. Actors with the same return value are sticked together.

[33]:
class ClassRoom(p2n.LocationDesigner):
    n_actors = 4

    def filter(self, actor):
        return actor.status == "pupil"

    def split(self, actor):
        return actor.grade

    def stick_together(self, actor):
        return actor.friend_group


creator.create(df=df_school, location_designers=[ClassRoom])

inspector.plot_networks(actor_color="friend_group")

Edge weights

Until now, all edges between nodes have a weight of 1. The location method weight() can be used to set different weights. In the following, we set the weight of all edges generated by a classroom to 5. This number could, for instance, represent that actors are together in classrooms for five hours.

[34]:
class ClassRoom(p2n.LocationDesigner):
    n_actors = 4

    def filter(self, actor):
        return actor.status == "pupil"

    def split(self, actor):
        return actor.grade

    def stick_together(self, actor):
        return actor.friend_group

    def weight(self, actor):
        return 5


creator.create(df=df_school, location_designers=[ClassRoom], clear=True)

inspector.plot_networks()

To implement individual weights between an actor and a location, we could also let weight() return an actor attribute. In this case we use the actor attribute actor.hours:

[35]:
class ClassRoom(p2n.LocationDesigner):
    n_actors = 4

    def filter(self, actor):
        return actor.status == "pupil"

    def split(self, actor):
        return actor.grade

    def stick_together(self, actor):
        return actor.friend_group

    def weight(self, actor):
        return actor.hours


creator.create(df=df_school, location_designers=[ClassRoom], clear=True)

inspector.plot_networks()

As the value returned by location.weight() refers to the weight between the actor and the location, all the weights between the actors and the locations must be somehow combined when determining the weight between actors (aka graph projection). The location method project_weights() defines how those weights are combined. By default, project_weights() returns the smallest weight of the two to be combined. The code cell below shows how project_weights() combines the two weights by default. In this example, we keep this way of combining the weights, but this method could be easily rewritten.

[36]:
class ClassRoom(p2n.LocationDesigner):
    n_actors = 4

    def filter(self, actor):
        return actor.status == "pupil"

    def split(self, actor):
        return actor.grade

    def stick_together(self, actor):
        return actor.friend_group

    def weight(self, actor):
        return actor.hours

    def project_weights(self, agent1, agent2) -> float:
        return min([self.get_weight(agent1), self.get_weight(agent2)])

Note that we use the method location.get_weight() to access the weight between the actor and the location.

Bringing together different actors

So far, we are able to connect actors who share a certain attribut value. But what if we want to explicitly connect actors who have different values on a certain attribute? One solution could be to simply give those actors we want to be in the same location the same value on a certain attribute and then define a location class that brings together these actors. Besides this, Pop2net offers two more convinient solutions.

The quick way: bridge()

With the bridge() method, we can quickly connect actors that have different values on an attribute. The bridge() method instructs the creator to create location instances that only connect actors with different values for an attribute (or more precisely: location instances that connect actors for which bridge() has returned different values). That sounds complicated. Let’s look at an example.

Let’s define a location PupilsAndTeachers that connects teachers and pupils. Since pupils and teachers differ in their actor.status attribute, we can use this attribute to bring them together. To make it clearer, we first create only one location by setting n_locations to 1:

[37]:
class PupilsAndTeachers(p2n.LocationDesigner):
    n_locations = 1

    def bridge(self, actor):
        return actor.status


creator.create(df=df_school, location_designers=[PupilsAndTeachers], clear=True)

inspector.plot_networks(actor_color="status")

We can see that there is one location instance of type PupilsAndTeachers that connects three actors that have different values on actor.status, including social workers. As we only want teachers and pupils in that location, we add a filter statement:

[38]:
class PupilsAndTeachers(p2n.LocationDesigner):
    n_locations = 1

    def bridge(self, actor):
        return actor.status

    def filter(self, actor):
        return actor.status in ["teacher", "pupil"]


creator.create(df=df_school, location_designers=[PupilsAndTeachers], clear=True)

inspector.plot_networks(actor_color="status")

As we do not only want to have one of that location, let’s remove the specifiction of n_locations:

[39]:
class PupilsAndTeachers(p2n.LocationDesigner):
    def filter(self, actor):
        return actor.status in ["teacher", "pupil"]

    def bridge(self, actor):
        return actor.status


creator.create(df=df_school, location_designers=[PupilsAndTeachers], clear=True)

inspector.plot_networks(actor_color="status")

What happened? If you take a closer look, you can see that there is still one location for one edge between one teacher and one pupil. However, teachers are assigned to more than one location of this type. This is because the LocationDesigner attribute recycle is set to True by default. If recycle is True the creator builds as many locations as needed to assign every actor at least to one instance of the corresponding location type. That means that the actors with the most frequent value returned by bridge() determine the number of the location instances to build. Therefore it can be that actors from the other groups are assigned multiple times so that everybody has a partner.

To turn this behavior off, set recycle to False. As a result, nobody will be assigned multiple times and the actors with the least frequent value determine the number of locations of this type. In the following, we set recycle ot False and thus only get 8 location instances of this type, as there are only 8 teachers.

[40]:
class PupilsAndTeachers(p2n.LocationDesigner):
    recycle = False

    def filter(self, actor):
        return actor.status in ["teacher", "pupil"]

    def bridge(self, actor):
        return actor.status


creator.create(df=df_school, location_designers=[PupilsAndTeachers], clear=True)

inspector.plot_networks(actor_color="status")

The detailed way: melt()

The method bridge() is a quick way to bring together different actors. However, it is limited. To have more controll over the composition of actors that are assigned to a location, we have to melt different locations together. To do this, we have to define at least three location classes: Two or more locations that are the components that get melted into one location and one location that melts those components together.

Assume we want to create classrooms that consist of one teacher and four pupils. To create such a location, we first define a MeltLocationDesigner (TeachersInClassRoom) that consists of only one teacher. Then we define a MeltLocationDesigner (PupilsInClassRoom) that consists of four pupils. Finally, we define a location (ClassRoom) that uses the method melt() to melt the two previously defined locations into one location. The method melt() must return a tuple or list of at least two location classes that shall be melted into one.

[41]:
# a location for teachers
class TeachersInClassRoom(p2n.MeltLocationDesigner):
    n_actors = 1

    def filter(self, actor):
        return actor.status == "teacher"


# a location for pupils
class PupilsInClassRoom(p2n.MeltLocationDesigner):
    n_actors = 4

    def filter(self, actor):
        return actor.status == "pupil"


# a location for teachers and pupils
class ClassRoom(p2n.LocationDesigner):
    def melt(self):
        return TeachersInClassRoom, PupilsInClassRoom
[42]:
creator.create(df=df_school, location_designers=[ClassRoom], clear=True)
inspector.plot_networks(actor_color="status")

Now we bring back all other settings we made so far:

[43]:
class TeachersInClassRoom(p2n.MeltLocationDesigner):
    n_actors = 1

    def filter(self, actor):
        return actor.status == "teacher"


class PupilsInClassRoom(p2n.MeltLocationDesigner):
    n_actors = 4

    def filter(self, actor):
        return actor.status == "pupil"

    def split(self, actor):
        return actor.grade

    def stick_together(self, actor):
        return actor.friend_group


class ClassRoom(p2n.LocationDesigner):
    def melt(self):
        return TeachersInClassRoom, PupilsInClassRoom

    def weight(self, actor):
        return actor.hours
[44]:
creator.create(df=df_school, location_designers=[ClassRoom], clear=True)
inspector.plot_networks(actor_color="status")

More than one location

The melting of locations combines different locations into one location, but does not create multiple different location classes. If we want to generate multiple different location types, we have to simply feed more than one location class into the Creator.

In the following, we introduce a School as a second type of location. (In order to keep the code clean, we skip the melting of locations temporarely.)

[45]:
class ClassRoom(p2n.LocationDesigner):
    n_actors = 4

    def filter(self, actor):
        return actor.status == "pupil"

    def split(self, actor):
        return actor.grade

    def stick_together(self, actor):
        return actor.friend_group

    def weight(self, actor):
        return actor.hours


class School(p2n.LocationDesigner):
    n_locations = 2
[46]:
creator.create(df=df_school, location_designers=[ClassRoom, School], clear=True)
inspector.plot_networks(location_color="label")

Nesting locations

The plot above shows two big clusters. Each of those clusters represents one school. The plot above also shows something unrealistic: The schools are connected because members of one class are not always in the same school.

Nesting locations using stick_together()

We can use the School-method stick_together() to fix this issue. This works because whenever an actor is assigned to a location instance, the actor gets a new attribute named after the location class. This new attribute is set to a location instance identifier value.

[47]:
class ClassRoom(p2n.LocationDesigner):
    n_actors = 4

    def filter(self, actor):
        return actor.status == "pupil"

    def split(self, actor):
        return actor.grade

    def stick_together(self, actor):
        return actor.friend_group

    def weight(self, actor):
        return actor.hours


class School(p2n.LocationDesigner):
    n_locations = 2

    def stick_together(self, actor):
        return actor.ClassRoom
[48]:
creator.create(df=df_school, location_designers=[ClassRoom, School], clear=True)
inspector.plot_networks(location_color="label")

Note that it is very important that the actors get assigned to classrooms before getting assigned to schools. This means that the order of the creation of the locations is important and, hence, the order of the location classes given to the location_designers argument.

If we build schools before classrooms, the above method does not work the way intended:

[49]:
creator.create(df=df_school, location_designers=[School, ClassRoom], clear=True)

inspector.plot_networks(location_color="label")

As we saw, the method stick_together() can be used to nest locations into other locations. However, this approach is limited because we can only specify one location class as the return value of stick_together().

Nesting locations using nest()

Another way to nest locations into other locations is to use the location method nest(). The method nest() can return None or a location label. If nest() returns a location label, the location is nested into the corresponding location type. For instance, to nest classrooms within schools, we must define the method nest() for the location ClassRoom and let this method return "School". Again, the order of location creation plays a crucial role: The level-1 location must always be created after the level-2 location.

[50]:
class ClassRoom(p2n.LocationDesigner):
    def setup(self):
        self.n_actors = 4

    def filter(self, actor):
        return actor.status == "pupil"

    def split(self, actor):
        return actor.grade

    def stick_together(self, actor):
        return actor.friend_group

    def weight(self, actor):
        return actor.hours

    def nest(self):
        return "School"


class School(p2n.LocationDesigner):
    n_locations = 2
[51]:
creator.create(df=df_school, location_designers=[School, ClassRoom], clear=True)
inspector.plot_networks(location_color="label")

nest() allows us to nest as many locations in as many levels as we want. However, nest() has one disadvantage: Because the actors are first grouped into the level-2 location and then into the level-1 location, specific compositions defined at level 1 are not considered when grouping the actors into the level-2 locations.

The following example demonstrates that:

[52]:
class TeachersInClassRoom(p2n.MeltLocationDesigner):
    def setup(self):
        self.n_actors = 1

    def filter(self, actor):
        return actor.status == "teacher"


class PupilsInClassRoom(p2n.MeltLocationDesigner):
    def setup(self):
        self.n_actors = 4

    def filter(self, actor):
        return actor.status == "pupil"

    def split(self, actor):
        return actor.grade


class ClassRoom(p2n.LocationDesigner):
    def melt(self):
        return [TeachersInClassRoom, PupilsInClassRoom]

    def weight(self, actor):
        return actor.hours * 10

    def nest(self):
        return "School"


class School(p2n.LocationDesigner):
    n_locations = 2
[53]:
creator.create(df=df_school, location_designers=[School, ClassRoom], clear=True)
inspector.plot_networks(location_color="label", actor_color="status")

As you can see in the graph above, the compositions defined by ClassRoom are not always met. That is due to the fact that the actors are first assigned to the schools independently of the settings defined by ClassRoom. When the classrooms are built, there are not always the necessary actors in each school needed to meet the composition defined in ClassRoom. This might not always be a problem. However, if we want to ensure that classrooms always have the defined composition of actors, they have to be created before the schools are created and, thus, we have to use the method stick_together() to nest classrooms into schools:

[54]:
class TeachersInClassRoom(p2n.MeltLocationDesigner):
    def setup(self):
        self.n_actors = 1

    def filter(self, actor):
        return actor.status == "teacher"


class PupilsInClassRoom(p2n.MeltLocationDesigner):
    def setup(self):
        self.n_actors = 4

    def filter(self, actor):
        return actor.status == "pupil"

    def split(self, actor):
        return actor.grade

    def stick_together(self, actor):
        return actor.friend_group


class ClassRoom(p2n.LocationDesigner):
    def melt(self):
        return [TeachersInClassRoom, PupilsInClassRoom]

    def weight(self, actor):
        return actor.hours * 10


class School(p2n.LocationDesigner):
    n_locations = 2

    def stick_together(self, actor):
        return actor.ClassRoom
[55]:
creator.create(df=df_school, location_designers=[ClassRoom, School], clear=True)
inspector.plot_networks(location_color="label")

It is also possible to combine both nesting techniques. We could first nest the class ClassRoom into School using stick_together() in order to get the composition wanted for ClassRoom and then use nest() to nest further locations into School, as it is done in the next example:

[56]:
class TeachersInClassRoom(p2n.MeltLocationDesigner):
    n_actors = 1

    def filter(self, actor):
        return actor.status == "teacher"


class PupilsInClassRoom(p2n.MeltLocationDesigner):
    n_actors = 4

    def filter(self, actor):
        return actor.status == "pupil"

    def split(self, actor):
        return actor.grade

    def stick_together(self, actor):
        return actor.friend_group


class ClassRoom(p2n.LocationDesigner):
    def melt(self):
        return [TeachersInClassRoom, PupilsInClassRoom]

    def weight(self, actor):
        return actor.hours * 10


class School(p2n.LocationDesigner):
    n_locations = 2

    def stick_together(self, actor):
        return actor.ClassRoom


class SoccerTeam(p2n.LocationDesigner):
    n_actors = 11

    def nest(self):
        return "School"
[57]:
creator.create(
    df=df_school,
    location_designers=[
        ClassRoom,  # nested into School using `stick_together()`
        School,
        SoccerTeam,  # nested into School using `nest()`
    ],
    clear=True,
)
[58]:
inspector.plot_networks(location_color="label", actor_color="status")

This way we can nest multiple locations into one level-2 location and at the same time ensure that the composition of actors defined in ClassRoom is met in each school. The order of the locations plays a crucial role:

  1. The classrooms with the desired compositions are created

  2. The schools are created keeping together whole classrooms

  3. Soccer teams are created within the schools

Integrating networkx graphs

One great features of Pop2net is that we do not have to forego classic network models because networkX graph (generators) are easily integrateble. To build a network component based on a networkX graph or networkX graph generator, we only have to set the LocationDesigner class attribute nxgraph to the desired graph (generator).

In the following example, we define a location class that creates a small world network from a networkx graph generator:

[59]:
import networkx as nx

env = p2n.Environment()
creator = p2n.Creator(env)
inspector = p2n.NetworkInspector(env)

N_ACTORS = 50


class SmallWorld(p2n.LocationDesigner):
    nxgraph = nx.watts_strogatz_graph(n=N_ACTORS, k=4, p=0.05)


creator.create_actors(n=N_ACTORS)
creator.create_locations(
    location_designers=[
        SmallWorld,
    ],
)
inspector.plot_networks()

As we can see in the bipartite network graph, networkX graphs are translated into a pop2net graph by creating one location instance for each bilateral relation between nodes. Thus, actors are assigned to multiple instances of the SmallWorld location class:

[60]:
actor = env.actors[0]

for location in actor.locations:
    print(location.label)
SmallWorld
SmallWorld
SmallWorld
SmallWorld

To get all neighbors that are connected to an actor via the small world graph we could do the following:

[61]:
actor.neighbors(location_labels=["SmallWorld"])
[61]:
[<pop2net.actor.Actor at 0x75e9d87a63f0>,
 <pop2net.actor.Actor at 0x75e9d87a6930>,
 <pop2net.actor.Actor at 0x75e9d87a75f0>,
 <pop2net.actor.Actor at 0x75e9d87a7620>]

When building locations based on networkX graphs we still retain the flexibility of Pop2net and its LocationDesigners. For instance, we could create seperated small worlds for each group of actors or layer multiple network structures by defining multiple location classes:

[62]:
N_ACTORS = 50
N_GROUPS = 2


class SmallWorld(p2n.LocationDesigner):
    nxgraph = nx.watts_strogatz_graph(n=int(N_ACTORS / N_GROUPS), k=4, p=0.05)

    def split(self, actor):
        return actor.group


class Bridge(p2n.LocationDesigner):
    n_locations = 1

    def bridge(self, actor):
        return actor.group


creator.create_actors(n=N_ACTORS, clear=True)

for i, actor in enumerate(env.actors):
    actor.group = i % N_GROUPS

creator.create_locations(
    location_designers=[
        SmallWorld,
        Bridge,
    ],
    clear=True,
)
inspector.plot_networks(actor_color="group")

Magic actor attributes

When creating locations with the Creator class, the Creator temporarily sets certain actor attributes that provide information about the specific location to which the actor has been assigned and the position of the actor within this location. These temporary actor attributes can be used to address specific actors when defining a second location type. Each of the temporary actor attributes begins with the name of the location label.

The following list shows all magic actor attributes:

  • actor.LOCATIONLABEL: An identifier of the location the actor was assigned to.

  • actor.LOCATIONLABEL_assigned: A bool which is True if the actor was assigned to this location class.

  • actor.LOCATIONLABEL_id: An identifier of the sub-location instance.

  • actor.LOCATIONLABEL_position: An identifier of the agent’s position within the location. The first actor gets a 0.

  • actor.LOCATIONLABEL_head: A bool which is True if the actor is the first actor within the location.

  • actor.LOCATIONLABEL_tail: A bool which is True if the actor is the last actor within the location.

In the following example, we first create 3 simple clusters by defining the ‘Cluster’ location class. In the next step we select an actor from each cluster and connect them to the location class Bridge. To select only one actor from each cluster, we filter by the magic actor attribute actor.Cluster_head that was set when the clusters were created. This way, only the first actor of each cluster is assigned to the Bridge location.

[63]:
env = p2n.Environment()
creator = p2n.Creator(env)
inspector = p2n.NetworkInspector(env)


class Cluster(p2n.LocationDesigner):
    n_locations = 3


class Bridge(p2n.LocationDesigner):
    def filter(self, actor):
        return actor.Cluster_head


creator.create_actors(n=30)
creator.create_locations(
    location_designers=[
        Cluster,
        Bridge,
    ],
)

inspector.plot_networks()

As the creator builds one location class after another, it is very important that the order of the location classes provided to the creator is correct. In the following, the connection of the clusters fails due to an incorrect order of the location classes provided to the creator.

[64]:
class Cluster(p2n.LocationDesigner):
    n_locations = 3


class Bridge(p2n.LocationDesigner):
    def filter(self, actor):
        return actor.Cluster_head


creator.create_actors(n=30, clear=True)
creator.create_locations(
    location_designers=[
        Bridge,
        Cluster,
    ],
    clear=True,
)

inspector.plot_networks()