Designing networks

The combination of agents and locations that exist in a model makes the life of an agent-based modeler much easier. Managing the relationships between agents in agent-based models is now very convenient. However, it is still very tedious to connect a large number of agents 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 agents in a specific way. The assignment of agents to locations by the definition of a LocationDesigner is primarely done through agent attributes. Hence, the creation of networks using pop2net makes the most sense when agents have informative attributes. However, even when the agents in your simulation do not have any attributes the LocationDesigner class makes it easy to quickly connect agents 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 agents 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 agents and locations as well as to connect agents and locations. Hence, the first step after creating a Model is the creation of an instance of Creator.

[1]:
import pop2net as p2n

model = p2n.Model()
creator = p2n.Creator(model)

Generating Agents

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

Important to know is that while the generation of locations highly depents on the agents and their attributes, the generation of agents 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 agent population using Pop2net or the Creator class. If you plan to create your agents in your own way and just want to connect your agents using Pop2net and the Creator class, you can skip this subsection! However, to connect agents using Pop2net, they must always inherit from the Pop2net agent class.

The simplest way to generate agents using a creator is by using the method creator.create_agents() and define the desired number or agents by setting a certain n:

[3]:
creator.create_agents(n=10)
[3]:
AgentList (10 objects)
[4]:
model.agents
[4]:
AgentList (10 objects)

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

[5]:
model.remove_agents(model.agents)
[6]:
model.agents
[6]:
AgentList (0 objects)

Now let’s have a look at an example for what Pop2net was originally designed: The creation of a population of agents 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 agents from this dataset, we can simply use the method creator.create_agents(). This method creates one agent object for each row in the given dataset. Each column is translated into an agent attribute with the corresponding value.

[9]:
creator.create_agents(df=df_school)
[9]:
AgentList (42 objects)

The created agents are added to the model’s network and thus appear in model.agents:

[10]:
model.agents
[10]:
AgentList (42 objects)

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

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

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

[12]:
vars(model.agents[0])
[12]:
{'_var_ignore': [],
 'id': 11,
 'type': 'Agent',
 'log': {},
 'model': Model,
 'p': {},
 'status': 'pupil',
 'gender': 'm',
 'grade': 3.0,
 'hours': 5,
 'friend_group': 14}

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

[13]:
class MyAgent(p2n.Agent):
    pass


model = p2n.Model()
creator = p2n.Creator(model)
agents = creator.create_agents(
    df=df_school,
    agent_class=MyAgent,
)
agents
[13]:
AgentList (42 objects)
[14]:
type(agents[0])
[14]:
__main__.MyAgent

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

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

The LocationDesigner

To generate locations using the Creator class, we have to use it in combination with the MagicLoation class. The LocationDesigner can be considered as an interface to controll the automatic generation of locations and assignment of agents 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 agents 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 agents and locations. Here is an overview of the attributes and methods of the LocationDesigner class:

[15]:
class MyLocation(p2n.LocationDesigner):
    n_agents: int | None = None
    overcrowding: bool | None = None
    only_exact_n_agents: bool = False
    n_locations: int | None = None
    recycle: bool = True

    def filter(self, agent: p2n.Agent) -> bool:
        """Assigns only agents with certain attributes to this location."""
        return True

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

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

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

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

    def nest(self) -> str | None:
        """Ensures that the agents 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 agents assigned to the instances of the returned location classes
        into one instance of this location class."""
        return []

    def project_weights(self, agent1: p2n.Agent, agent2: p2n.Agent) -> float:
        """Calculates the edge weight between two agents that are assigned to the same location
        instance."""
        return min([self.get_weight(agent1), self.get_weight(agent2)])

Generating locations (and assigning agents)

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 agents, which are already in the model, to the location instances:

[17]:
creator.create_locations(location_designers=[ClassRoom])
[17]:
LocationList (1 object)
[18]:
model.locations
[18]:
LocationList (1 object)

creator.create_locations() has not only created the location instances with respect to the given agent population, but has also assigned the agents 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(model)
inspector.plot_bipartite_network()

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

[20]:
inspector.plot_agent_network()

Generating agents and locations in one step

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

[21]:
model = p2n.Model()
creator = p2n.Creator(model)
inspector = p2n.NetworkInspector(model)

# Let the Creator create agents and locations
creator.create(
    df=df_school,
    location_designers=[ClassRoom],
)
[21]:
(AgentList (42 objects), LocationList (1 object))

The method create() also has the convenient argument clear. Set this to True if you want to remove all existing agents and locations from the model before adding new ones to the model. This way you do not have to create a new instances of Model, 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 agents and locations are added to the model without removing the existing ones:

[22]:
creator.create(
    df=df_school,
    location_designers=[ClassRoom],
    clear=False,
)
print(model.agents, model.locations)
AgentList (84 objects) LocationList (2 objects)

If clear is True only the new agents and locations remain in the model:

[23]:
creator.create(
    df=df_school,
    location_designers=[ClassRoom],
    clear=True,
)
print(model.agents, model.locations)
AgentList (42 objects) LocationList (1 object)

The keyword clear is also available in create_agents() and create_locations(), where it removes either all existing agents or all existing locations from the model.

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 agents. To set the number of agents per location, we need to set the class attribute n_agents to the desired value.

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

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

[25]:
model = p2n.Model()
creator = p2n.Creator(model)
inspector = p2n.NetworkInspector(model)

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

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 agents are then assigned randomly to one of these location instances. As you can see, some classrooms have have more than four members because the number of agents 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_agents = 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_agents to True:

[27]:
class ClassRoom(p2n.LocationDesigner):
    n_agents = 4
    only_exact_n_agents = True


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

inspector.plot_bipartite_network()

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

Defining the number of locations

The attribute n_agents 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_agents = 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_agents = 4 are created, in the example below n_agents 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 agents. But in many cases we want specific locations to be exclusively accessible to certain agents. For this scenario the method filter() exists. If this method returns True, an agent 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 agent attribute to contain a certain value.

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

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

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


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

inspector.plot_bipartite_network(agent_color="status")

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

By the way: Besides looking at the network graph, the function inspector.location_information() and inspector.location_crosstab() provide usefull overviews of the created location instances and the assigned agents:

Building separated locations

The above table shows that the classrooms are not separated by grade. To seperate agents by grade, we could define one location class for each grade and use filter() to assign only agents with a specific grade value to a specific location.

A more convenient way to do it is to use the method split(). For each agent, 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 agents. Thus, the Creator builds seperate classroom instances for each unique value of the agent attribute grade.

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

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

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


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

inspector.plot_networks(agent_color="grade")

Keeping agents 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_agents = 4

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

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


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

inspector.plot_networks(agent_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 agent, the method stick_together() returns a specific value. Agents with the same return value are sticked together.

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

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

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

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


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

inspector.plot_networks(agent_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 agents are together in classrooms for five hours.

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

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

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

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

    def weight(self, agent):
        return 5


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

inspector.plot_networks(agent_color="grade")

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

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

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

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

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

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


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

inspector.plot_networks(agent_color="grade")

As the value returned by location.weight() refers to the weight between the agent and the location, all the weights between the agents and the locations must be somehow combined when determining the weight between agents (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_agents = 4

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

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

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

    def weight(self, agent):
        return agent.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 agent and the location.

Bringing together different agents

So far, we are able to connect agents who share a certain attribut value. But what if we want to explicitly connect agents who have different values on a certain attribute? One solution could be to simply give those agents 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 agents. Besides this, Pop2net offers two more convinient solutions.

The quick way: bridge()

With the bridge() method, we can quickly connect agents that have different values on an attribute. The bridge() method instructs the creator to create location instances that only connect agents with different values for an attribute (or more precisely: location instances that connect agents 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 agent.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, agent):
        return agent.status


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

inspector.plot_networks(agent_color="status")

We can see that there is one location instance of type PupilsAndTeachers that connects three agents that have different values on agent.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, agent):
        return agent.status

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


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

inspector.plot_networks(agent_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, agent):
        return agent.status in ["teacher", "pupil"]

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


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

inspector.plot_networks(agent_color="status")

What happened? If you take a closer look, you can see that there is still one location for one edge beteween 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 agent at least to one instance of the corresponding location type. That means that the agents with the most frequent value returned by bridge() determine the number of the location instances to build. Therefore it can be that agents 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 agents 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, agent):
        return agent.status in ["teacher", "pupil"]

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


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

inspector.plot_networks(agent_color="status")

The detailed way: melt()

The method bridge() is a quick way to bring together different agents. However, it is limited. To have more controll over the composition of agents 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 MeltLocationDesignerDesigner (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_agents = 1

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


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

    def filter(self, agent):
        return agent.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(agent_color="status")

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

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

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


class PupilsInClassRoom(p2n.MeltLocationDesigner):
    n_agents = 4

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

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

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


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

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

    def project_weights(self, agent1, agent2) -> float:
        return min([self.get_weight(agent1), self.get_weight(agent2)])
[44]:
creator.create(df=df_school, location_designers=[ClassRoom], clear=True)
inspector.plot_networks(agent_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_agents = 4

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

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

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

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

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


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 agent is assigned to a location instance, the agent 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_agents = 4

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

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

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

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

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


class School(p2n.LocationDesigner):
    n_locations = 2

    def stick_together(self, agent):
        return agent.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 agents 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 class. If nest() returns a location class, the location is nested into the returned location class. 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_agents = 4

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

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

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

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

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

    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 agents 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 agents into the level-2 locations.

The following example demonstrates that:

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

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


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

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

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


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

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

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

    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", agent_color="status")

As you can see in the graph and the table above, the compositions defined by ClassRoom are not always met. That is due to the fact that the agents are first assigned to the schools independently of the settings defined by ClassRoom. When the classrooms are built, there are not always the necessary agents 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 agents, 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_agents = 1

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


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

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

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

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


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

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

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


class School(p2n.LocationDesigner):
    n_locations = 2

    def stick_together(self, agent):
        return agent.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_agents = 1

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


class PupilsInClassRoom(p2n.MeltLocationDesigner):
    n_agents = 4

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

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

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


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

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

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


class School(p2n.LocationDesigner):
    n_locations = 2

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


class SoccerTeam(p2n.LocationDesigner):
    n_agents = 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,
)
[57]:
(AgentList (42 objects), LocationList (14 objects))
[58]:
inspector.plot_networks(location_color="label", agent_color="status")

This way we can nest multiple locations into one level-2 location and at the same time ensure that the composition of agents 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 graph generators

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

model = p2n.Model()
creator = p2n.Creator(model)
inspector = p2n.NetworkInspector(model)

N_AGENTS = 50


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


creator.create_agents(n=N_AGENTS)
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, agents are assigned to multiple instances of the SmallWorld location class:

[60]:
agent = model.agents[0]

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

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

[61]:
agent.neighbors(location_labels=["SmallWorld"])
[61]:
AgentList (4 objects)

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 agents or layer multiple network structures by defining multiple location classes:

[62]:
model = p2n.Model()
creator = p2n.Creator(model)
inspector = p2n.NetworkInspector(model)

N_AGENTS = 50
N_GROUPS = 2


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

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


class Bridge(p2n.LocationDesigner):
    n_locations = 1

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


creator.create_agents(n=N_AGENTS)

for i, agent in enumerate(model.agents):
    agent.group = i % N_GROUPS

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

Magic agent attributes

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

The following list shows all magic agent attributes:

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

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

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

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

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

  • agent.LOCATIONLABEL_tail: A bool which is True if the agent is the last agent 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 agent from each cluster and connect them to the location class Bridge. To select only one agent from each cluster, we filter by the magic agent attribute agent.Cluster_head that was set when the clusters were created. This way, only the first agent of each cluster is assigned to the Bridge location.

[63]:
model = p2n.Model()
creator = p2n.Creator(model)
inspector = p2n.NetworkInspector(model)


class Cluster(p2n.LocationDesigner):
    n_locations = 3


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


creator.create_agents(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, agent):
        return agent.Cluster_head


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

inspector.plot_networks()