CONTrOL is an agent-based model developed for the purpose of studying behavioral mechanisms influencing social learning and performance within and across business organisations. CONTrOL stands for Complex Organisational and Network-driven Transmissions resulting in Organisation Learning.

Landscape package

Submodules

landscape.generate_landscape module

This python code package serves to create an NK fitness landscape graph with an limited number of edges connecting the graph nodes (i.e. landscape locations).

NK matrix generation

The imatrix_rand(N,K) method is a NumPy method that generates a random interaction matrix with N entities (=decision variables) and where each entity interacts with a specified number of other entities (K interactions). N is the number of rows and columns in the interaction matrix, which represents entities that interact with each other. K is a parameter that controls the number of interactions each entity has with other entities.

  • It creates an empty square matrix Int_matrix_rand of size NxN, where each element is initialized to 0. This matrix will store the binary interactions between entities.

  • It iterates through each entity (indexed by aa1) from 0 to N-1.

  • For each entity (aa1), it creates a list Indexes_1 containing all entity indices from 0 to N-1.

  • It removes the current entity (aa1) from Indexes_1 to avoid self-interactions.

  • It shuffles the order of indices in Indexes_1 randomly using np.random.shuffle(Indexes_1).

  • It appends the current entity (aa1) to the end of the shuffled list, ensuring that the entity interacts with itself.

  • It selects the last (K+1) indices from the shuffled list (excluding the current entity aa1) and stores them in the Chosen_ones list. This determines which entities the current entity will interact with.

  • For each selected entity (aa2) in Chosen_ones, it sets the corresponding element in the Int_matrix_rand to 1. This indicates that the current entity (aa1) interacts with the selected entities.

  • The function returns the resulting binary interaction matrix Int_matrix_rand.

def imatrix_rand(N, K):
    Int_matrix_rand = np.zeros((N, N))
    for aa1 in np.arange(N):
        Indexes_1 = list(range(N))
        Indexes_1.remove(aa1)
        np.random.shuffle(Indexes_1)
        Indexes_1.append(aa1)
        Chosen_ones = Indexes_1[-(K+1):]
        for aa2 in Chosen_ones:
            Int_matrix_rand[aa1, aa2] = 1 
    return(Int_matrix_rand)

Fitness value calculation

The calc_fit function computes the fitness values for a set of decision variables based on a given combination and a fitness landscape (represented by NK_land_) with interactions and specific indexing. The function returns a vector of fitness values, with one value for each decision variable.

  • It creates an empty array Fit_vector of size N, which will store the fitness values for each decision variable.

  • It iterates through each decision variable (indexed by ad1) from 0 to N-1.

  • For each decision variable, it calculates a fitness value using the following steps:

    • It constructs an index by performing element-wise multiplication between Current_position, inter_m[ad1], and Power_key_. This index identifies a specific location or value in the NK_land_ landscape.

    • It uses the calculated index to access the corresponding fitness value from the NK_land_ landscape and assigns it to the corresponding element in the Fit_vector.

  • After calculating the fitness values for all decision variables, it returns the Fit_vector, which is a vector of fitness values corresponding to the provided combination of decision variables.

def calc_fit(N, K, NK_land_, inter_m, Current_position, Power_key_):
    Fit_vector = np.zeros(N)
    for ad1 in np.arange(N):
        Fit_vector[ad1] = NK_land_[np.sum(Current_position * inter_m[ad1]
                                          * Power_key_), ad1]
    return(Fit_vector)

The comb_and_values(N, K, NK_land_, Power_key_, inter_m) function calculates values for all combinations on the landscape. It returns an array where each row represents a combination of decision variables, and the columns have the following meanings:

  • The first N columns (indexed from 0 to N-1) represent each of the combinations of decision variables.

  • The column indexed N is for the total fitness (the average of the entire fitness vector for that combination).

  • The column indexed N+1 represents the ID of the location.

  • The column indexed N+2 is a dummy variable, with 1 indicating a local peak.

  • The last column is a dummy variable, with 1 indicating the global peak.

The function systematically explores all combinations of decision variables on a landscape, calculates their fitness, and determines whether each combination is a local peak or the global peak.

  • It creates an empty array Comb_and_value to capture the results. The size of this array is (2^N, N+4) to accommodate all possible combinations and associated values.

  • It initializes two counters, c1 and c2, which will be used to iterate through all combinations.

  • It uses itertools.product to iterate over all possible combinations of binary values (0 and 1) for the N decision variables. For each combination, it performs the following steps:

    • Calculates the fitness of the combination using the calc_fit function.

    • Stores the combination and fitness values in the Comb_and_value array.

    • Calculates and stores the average fitness value in the N column of the array.

    • Assigns a unique ID (location reference) to the combination in the N+1 column.

  • It checks if each combination is a local peak by comparing its fitness with the fitness of its neighbors. For each combination (c3), it iterates through each neighbouring decision variable (c4) and checks if flipping the value of that variable results in a fitness value greater than the fitness of the current combination. If any of the neighbors has higher fitness, the combination is not a local peak, and the N+2 column is set to 0 (indicating it’s not a local peak).

  • It identifies a global peak by finding the combination with the highest average fitness in the N column and sets the N+3 column to 1 for that combination.

  • Finally, it returns the Comb_and_value array with all the calculated values.

def comb_and_values(N, K, NK_land_, Power_key_, inter_m):
    Comb_and_value = np.zeros((2**N, N+4))  
    c1 = 0  
    for c2 in itertools.product(range(2), repeat=N):
        Combination1 = np.array(c2)  
        fit_1 = calc_fit(N, K, NK_land_, inter_m, Combination1, Power_key_)
        Comb_and_value[c1, :N] = Combination1  
        Comb_and_value[c1, N] = np.mean(fit_1)
        Comb_and_value[c1, N+1] = c1
        c1 = c1 + 1
    for c3 in np.arange(2**N): 
        loc_p = 1 
        for c4 in np.arange(N): 
            new_comb = Comb_and_value[c3, :N].copy().astype(int)
            new_comb[c4] = abs(new_comb[c4] - 1)
            if ((Comb_and_value[c3, N] <
                 Comb_and_value[np.sum(new_comb*Power_key_), N])):
                loc_p = 0 
        Comb_and_value[c3, N+2] = loc_p
    max_ind = np.argmax(Comb_and_value[:, N])
    Comb_and_value[max_ind, N+3] = 1
    return(Comb_and_value)

NK landscape generation

The generate_nk_landscape(N, K, name, edge_removal_percentage, Power_key_) function generates an NK landscape and saves it as a NumPy array.

  • It generates a random interaction matrix Int_matrix using the imatrix_rand(N, K) function.

  • It creates a random NK landscape NK_land as a NumPy array of shape (2^N, N) with random values between 0 and 1.

  • It calculates the landscape data by calling the comb_and_values(N, K, NK_land, Power_key_, Int_matrix) function, which calculates values for all combinations on the landscape, identifies local peaks, and the global peak. The resulting data is stored in a Landscape_data object.

  • It constructs a filename for the landscape data using the provided name, N, K, and edge_removal_percentage (which is a parameter set WHERE), and then saves the Landscape_data as a NumPy binary file (.npy) to the specified directory.

  • Finally, it returns the Landscape_data, which contains information about the landscape, combinations, and peak identifications.

def generate_nk_landscape(N, K, name, edge_removal_percentage, Power_key_):
    Int_matrix = imatrix_rand(N, K).astype(int)
    NK_land = np.random.rand(2**N, N)  
    
    Landscape_data = comb_and_values(N, K, NK_land, Power_key_, Int_matrix)

    extra_naming = name+'_' if name != "" else ""
    np.save('../data/landscape/'+extra_naming+'N'+str(N)+'K'+str(K)+'_'+str(int(edge_removal_percentage*100))+'_raw.npy', Landscape_data)

    return Landscape_data

The generate_landscape(N, K, edge_removal_percentage, name) function generates an NK landscape, constructs a network based on the landscape, removes a specified percentage of edges, and saves the network data in a GEXF file. It takes the following arguments:

  • N is the number of decision variables (entities).

  • K is a parameter that controls the number of interactions each entity has with other entities.

  • edge_removal_percentage is a parameter indicating the percentage of edges to remove from the network (as a fraction, e.g., 0.1 for 10%).

  • name is a string used to name the landscape.

The function operates as follows:

  • It calculates a Power_key array, which is used in the landscape generation. This array contains powers of 2, starting from 2^(N-1) down to 2^0, and it is used for indexing purposes.

  • It generates an NK landscape using the generate_nk_landscape(N, K, name, edge_removal_percentage, Power_key) function. This generates the landscape data and saves it to a .npy file.

  • It initializes a graph object G using NetworkX (nx.Graph()), which will represent the network of landscape combinations.

  • It constructs an edge list edgelist that will store information about the edges in the network.

  • It iterates over all combinations (represented by integers from 0 to 2^N) and their neighboring combinations to build the edgelist. The purpose of this loop is to create edges between combinations based on their similarity.

  • It defines node attributes using the fitness values from the landscape data and associates these attributes with nodes in the graph to create the node_attrs dictionary.

  • It adds edges to the graph G based on the edgelist.

  • It calculates the number of edges to remove from the network based on the specified edge_removal_percentage and randomly selects edges to remove.

  • It writes the resulting network G to a .gexf file, which is a file format used to represent graph data.

def generate_landscape(N, K, edge_removal_percentage, name):
    Power_key = np.power(2, np.arange(N - 1, -1, -1))

    nk_data = generate_nk_landscape(N, K, name, edge_removal_percentage, Power_key)
    G = nx.Graph()
    edgelist = []

    for comb in np.arange(2**N):
        for neighbour in np.arange(N):
            new_comb = nk_data[comb, :N].copy().astype(int)
            new_comb[neighbour] = abs(new_comb[neighbour] - 1)
            if (int(nk_data[np.sum(new_comb*Power_key), N+1]), int(nk_data[comb, N+1])) not in edgelist:
                edgelist.append((int(nk_data[comb, N+1]), int(nk_data[np.sum(new_comb*Power_key), N+1])))

    node_attrs = {}
    for row in nk_data:
        node_attrs[int(row[N+1])] = {"fitness": row[N]}

    G.add_edges_from(edgelist)
    nx.set_node_attributes(G, node_attrs)

    num_edges_to_remove = int(edge_removal_percentage*(2**N)*N/2)
    edges_to_remove = random.choices(edgelist, k=num_edges_to_remove)
    G.remove_edges_from(edges_to_remove)

    extra_naming = name+'_' if name != "" else ""
    nx.write_gexf(G, '../data/landscape/'+extra_naming+'N'+str(N)+'K'+str(K)+'_'+str(int(edge_removal_percentage*100))+'.gexf')

Lastly, the generate_landscape.py file executes the actual creation of the NK landscape graph based on the provided arguments.

landscape.knowledge module

Knowledge sharing and aggregation

The union(knowledgeList) function is used to create a union of multiple knowledge objects represented as instances of a class called Knowledge. It operates on a list of knowledge objects and returns a new knowledge object that represents the union of the input knowledge objects.

  • It initializes a new knowledge_union as a copy of the first knowledge object in the input list knowledgeList (i.e., knowledge_union = Knowledge(knowledgeList[0])). This will be the base for the union operation.

  • It then iterates over the remaining knowledge objects in knowledgeList starting from the second one (index 1). For each of these knowledge objects:

    • It iterates over the landscape layers within the knowledge object. A layer is essentially a NetworkX-based graph structure.

    • It creates a reduced view of each layer (a subgraph) by removing nodes with zero degree (i.e., nodes that are not connected to any other nodes). This reduced view essentially filters out isolated nodes from the layers.

    • It uses the nx.compose() function from the NetworkX library to merge the reduced view of the layer with the corresponding layer in the knowledge_union. This operation combines the graphs from both knowledge objects, layer by layer.

    • If the layer is marked as active (according to the original knowledge object), it increments the layerSharingNum attribute in the knowledge_union for that layer. This attribute tracks the number of knowledge objects sharing a particular layer.

  • After the union process, it checks each layer in the knowledge_union. If a layer has at least one edge (i.e., it’s not empty), it marks that layer as active in the knowledge_union. The purpose of this step is to identify active layers based on the merged information.

  • Finally, it returns the knowledge_union as the result, which represents the union of knowledge from all the input knowledge objects. The resulting object contains information about shared layers and active layers, making it a consolidated representation of knowledge.

def union(knowledgeList):
  knowledge_union = Knowledge(knowledgeList[0])
  for knowledge in knowledgeList[1:]:
    for l_idx, layer in enumerate(knowledge.layers):
      reduced_view = nx.subgraph_view(layer, filter_node=lambda n: layer.degree[n] > 0)
      knowledge_union.layers[l_idx] = nx.compose(knowledge_union.layers[l_idx], reduced_view)
      if knowledge.isLayerActive[l_idx]:
        knowledge_union.layerSharingNum[l_idx] += 1
  
  for l_idx, layer in enumerate(knowledge_union.layers):
    if layer.number_of_edges() > 0:
      knowledge_union.isLayerActive[l_idx] = True

  return knowledge_union

The hasOverlap(knowledgeList) function is used to determine whether there is an overlap in the nodes of specific layers among a list of knowledge objects.

  • It initializes a boolean variable has_intersect as False. This variable will be used to track whether there is an overlap.

  • The function iterates over the layers of the first knowledge object in the knowledgeList. For each layer:

    • It initializes an intersect set with the nodes from that layer in the first knowledge object (i.e., knowledgeList[0].nodesInLayers[l_idx]).

    • It then iterates over the other knowledge objects (from the second one onwards) in knowledgeList. For each knowledge object:

    • It takes the intersection of the current intersect set with the nodes from the same layer in the current knowledge object (i.e., knowledge.nodesInLayers[l_idx]). This means it finds the common nodes shared by all knowledge objects for a specific layer.

    • If the size (length) of the intersect set is greater than 0, it means that there is an overlap in the nodes of that layer among all knowledge objects in knowledgeList. In that case, it sets the has_intersect variable to True.

  • After iterating through all layers and checking for overlaps, the function returns the has_intersect boolean variable.

def hasOverlap(knowledgeList):
  has_intersect = False
  for l_idx, layer in enumerate(knowledgeList[0].layers):
    intersect = set(knowledgeList[0].nodesInLayers[l_idx])
    for knowledge in knowledgeList[1:]:
      intersect = intersect.intersection(knowledge.nodesInLayers[l_idx])
    if len(intersect) > 0:
      has_intersect = True

  return has_intersect

The getShareableKnowledge(sourceKnowledge, targetKnowledge, isLayerRevealAllowed) function is designed to identify and return a set of edges that can be shared between two individuals (source and target) in each available layer within their respective knowledge graphs. The function considers whether layer reveal is allowed and takes the following steps to determine shareable edges:

  • It initializes an empty list shareable_edges to store the edges that can be shared between the source and target individuals.

  • The function iterates over each layer in the knowledge graphs. For each layer (indexed by l_idx), it does the following:

    • It calculates the intersection of nodes between the source and target knowledge graphs for the current layer. This identifies nodes that both individuals have in common in that layer.

    • For each node in the intersection, the function then iterates over the edges in the source knowledge graph for that node. It forms an edge with the corresponding layer index and checks whether the edge is already in the shareable_edges list and whether it is missing in the target knowledge graph.

    • If the edge meets these conditions, it is added to the shareable_edges list, indicating that it can be shared between the source and target individuals.

  • Finally, the function returns the shareable_edges list, which contains information about the edges that can be shared between the source and target individuals in each available layer.

def getShareableKnowledge(sourceKnowledge, targetKnowledge, isLayerRevealAllowed):
  shareable_edges = []
  for l_idx in range(len(sourceKnowledge.layers)):
    intersection = set(sourceKnowledge.nodesInLayers[l_idx]).intersection(targetKnowledge.nodesInLayers[l_idx])
    for node in intersection:
      for edge in sourceKnowledge.layers[l_idx].edges(node):
        edge_l = [edge, l_idx]
        if (edge_l not in shareable_edges) and not targetKnowledge.layers[l_idx].has_edge(*edge):
          shareable_edges.append(edge_l)

  return shareable_edges

Knowledge object (instance) creation

The Knowledge class is designed to represent and alter a knowledge object, which includes multiple layers of knowledge, nodes within those layers, and learned edges between nodes. It can be initialized in two different ways, as indicated by the __init__ constructor.

  1. Initialization of a Knowledge object as a subgraph of a landscape (multiple arguments provided):

  • If the constructor receives more than one argument, it is initialized from a set of parameters (e.g. landscape, sourceNode etc.).

  • The constructor then initializes the following attributes:

    • landscape: Stores the associated landscape or graph structure.

    • capacity: Stores the capacity value.

    • layers: A list that will store graph structures for each layer.

    • nodesInLayers: A list that will store the nodes within each layer.

    • isLayerActive: A list that tracks the activation status of each layer.

    • learned_edges: A deque that stores learned edges. A deque is a double-ended queue in which elements can be both inserted and deleted from either the left or the right end of the queue.

  • For each layer specified in initLayers (argument), it initializes a graph G for that layer and activates the layer. It then proceeds to populate the layer:

    • A node from the nodes_to_consider list is randomly selected.

    • It checks if the degree of the selected node in G is less than or equal to initMaxDegree argument.

    • If the condition is met, it selects a neighboring node from the landscape, connects it to the selected node in G, and adds the edge to the learned_edges deque.

    • The selected neighboring node is added to the nodes_to_consider list. This process continues until initNodeNum nodes are initialized in the layer or the loop exceeds a maximum number of iterations.

  • The constructed graph G for each layer is stored in the layers list, and the nodes within each layer are stored in the nodesInLayers list.

  1. Initialization of knowledge overlap implementation (only the landscape provided as an argument):

  • If the constructor receives a single argument, it is initialized from an existing knowledge graph, which is useful for copying or extending existing knowledge.

  • The existing knowledge graph is provided as the argument.

  • The constructor copies various attributes from the existing knowledge, including the associated landscape, layer activation status, layer sharing information, nodes within layers, capacity, and learned edges. It also copies the graph structures for each layer.

class Knowledge:
  def __init__(self, *args):
    if len(args) > 1:
      landscape = args[0]
      sourceNode = args[1]
      initLayers = args[2]
      initNodeNum = args[3]
      initMaxDegree = args[4]
      capacity = args[5]

      self.landscape = landscape
      self.capacity = capacity
      self.layers = []
      self.nodesInLayers = []
      self.isLayerActive = []
      self.learned_edges = deque()

      self.layerSharingNum = []

      for l_idx in range(len(self.landscape.layers)):
        G = nx.create_empty_copy(self.landscape.layers[l_idx])
        self.isLayerActive.append(False)
        self.layerSharingNum.append(0)
        if l_idx in initLayers:
          self.isLayerActive[l_idx] = True
          self.layerSharingNum[l_idx] += 1
          iter_num = 1
          node_num = 1
          nodes_to_consider = [sourceNode]
          while node_num <= initNodeNum and iter_num < initNodeNum*10:
            iter_num += 1
            selected_node = np.random.choice(nodes_to_consider)
            if G.degree[selected_node] <= initMaxDegree:
              neighbours = [n for n in self.landscape.layers[l_idx].neighbors(selected_node)]
              new_node = np.random.choice(neighbours)
              if not G.has_edge(selected_node, new_node):
                G.add_edge(selected_node, new_node)
                self.learned_edges.append((selected_node, new_node,))
                if new_node not in nodes_to_consider:
                  nodes_to_consider.append(new_node)
                  node_num += 1
          
        self.layers.append(G)
        reduced_view = nx.subgraph_view(G, filter_node=lambda n: G.degree[n] > 0)
        self.nodesInLayers.append(list(reduced_view.nodes))

    else:
      knowledge = args[0]
      self.landscape = knowledge.landscape
      self.layers = []
      self.isLayerActive = knowledge.isLayerActive
      self.layerSharingNum = knowledge.layerSharingNum
      self.nodesInLayers = knowledge.nodesInLayers
      self.capacity = knowledge.capacity
      self.learned_edges = knowledge.learned_edges

      for layer in knowledge.layers:
        self.layers.append(layer.copy())

The addEdge(self, edge, layer) method of the Knowledge class is used to add an edge to a specific layer within the knowledge object.

  • It takes two arguments:

    • edge: The edge to be added, which is typically represented as a tuple of two nodes.

    • layer: The index of the layer to which the edge should be added.

  • It adds the specified edge to the graph structure of the specified layer using the add_edge method of the NetworkX graph (self.layers[layer].add_edge(*edge)).

  • It sets the isLayerActive flag for the specified layer to True. This indicates that the layer is active because it contains at least one edge.

  • It appends the edge to the learned_edges deque. This deque is used to keep track of the learned edges in the knowledge object.

  • It checks if the number of edges in the graph of the specified layer exceeds the defined capacity (self.capacity). If the capacity is exceeded, the method removes the oldest learned edge from the layer.

  • It updates the nodesInLayers attribute for the specified layer. It creates a reduced view of the graph of that layer, filtering out nodes with zero degree, and updates the list of nodes within that layer based on the reduced view.

class Knowledge: CONTINUED
  def addEdge(self, edge, layer):
    self.layers[layer].add_edge(*edge)
    self.isLayerActive[layer] = True
    self.learned_edges.append(edge)
    if self.layers[layer].number_of_edges() > self.capacity: 
      self.layers[layer].remove_edge(*self.learned_edges.popleft())
    reduced_view = nx.subgraph_view(self.layers[layer], filter_node=lambda n: self.layers[layer].degree[n] > 0)
    self.nodesInLayers[layer] = reduced_view.nodes

The distance function takes a sourceNode and a targetNode, merges the edges from multiple layers of a knowledge structure to form a flattened multiplex graph, and then calculates and returns the shortest path length between the source and target nodes within this merged graph.

  • It starts by initializing a new graph, flattened_multiplex_graph, by making a copy of the first layer of the knowledge structure (self.layers[0].copy()). This graph represents a merged version of the multiplex graph, combining edges from multiple layers.

  • It then iterates over the remaining layers of the knowledge structure and updates the flattened_multiplex_graph by adding the edges from each of these layers to the graph. The .update() method is used to combine the edges of the current layer with the existing graph.

  • After merging all the layers, the function calculates the shortest path length between the sourceNode and the targetNode within the flattened_multiplex_graph using the nx.shortest_path_length() function from the NetworkX library.

  • Finally, the function returns the calculated shortest path length as the result.

class Knowledge: CONTINUED
  def distance(self, sourceNode, targetNode):
    flattened_multiplex_graph = self.layers[0].copy()
    for layer in self.layers[1:]:
      flattened_multiplex_graph.update(edges=layer.edges)

    return nx.shortest_path_length(flattened_multiplex_graph, sourceNode, targetNode)

The unknownNeighbors method is designed to identify and return the neighbors of a sourceNode in a specific layer of the knowledge object that are unknown (i.e. not connected) in the same layer of the knowledge graph.

  • It starts by obtaining a list of all neighbors of the sourceNode within the specified layer of the associated landscape.

  • It then obtains a list of neighbors that are known or connected in the same layer of the knowledge graph (known_neighbours). These are retrieved from the graph structure of the specified layer in the knowledge object.

  • Next, it initializes an empty list unknown_neighbours to store the neighbors that are unknown or not connected in the knowledge graph.

  • It iterates through each node in the all_neighbours list and checks if the node is also present in the known_neighbours list. If a node is not found in the known_neighbours list, it means that the node is unknown or not connected in the same layer of the knowledge graph. In this case, the node is added to the unknown_neighbours list.

  • Finally, the method returns the list of unknown_neighbours.

class Knowledge: CONTINUED
  def unknownNeighbors(self, sourceNode, layer):
    all_neighbours = [n for n in self.landscape.layers[layer].neighbors(sourceNode)]
    known_neighbours = [n for n in self.layers[layer].neighbors(sourceNode)]
    unknown_neighbours = []
    for node in all_neighbours:
      if node in known_neighbours:
        unknown_neighbours.append(node)

    return unknown_neighbours

Knowledge graph visualisation

The visualise method is used to visualise the knowledge graph. It provides options to customize the visualisation.

  • It starts by creating an empty list reduced_layers, which will store copies of the graph structures for layers that are active. It iterates through each layer in and appends a copy of the layer to reduced_layers if it is active.

  • If includeUnreachable is set to False (the default), the method performs additional filtering to remove nodes that are unreachable in all layers. This ensures that only nodes that have at least one connection within the knowledge structure are included in the visualisation.

    • It iterates through nodes in the first layer, and for each node, it checks if the node is unreachable in all layers. If the node is unreachable in all layers, it is marked for removal.

    • After identifying nodes to remove, it iterates through reduced_layers and removes the marked nodes from each layer.

  • It initializes a 3D figure fig and adds a 3D subplot ax to it using matplotlib.

  • It creates the network visualisation using a custom function called MultiplexNetworkVisualisation (refer to visualisation.py). The reduced_layers are passed to this function, and the visualisation is generated on the ax subplot.

  • It turns off the axis for the 3D subplot using ax.set_axis_off() to make the visualisation cleaner.

  • If disableShow is set to False (the default), it displays the visualisation using plt.show(). If disableShow is set to True, it will not automatically display the visualisation.

class Knowledge: CONTINUED
  def visualise(self, includeUnreachable=False, disableShow=False, nodeSize=80):
    reduced_layers = []
    for l_idx, layer in enumerate(self.layers):
      if self.isLayerActive[l_idx]:
        reduced_layers.append(layer.copy())

    if not includeUnreachable:
      for node in self.layers[0].nodes():
        remove_node = True
        for layer in self.layers:
          if layer.degree[node] > 0:
            remove_node = False

        if remove_node:    
          for layer in reduced_layers:
            layer.remove_node(node)

    fig = plt.figure()
    ax = fig.add_subplot(111, projection='3d')
    MultiplexNetworkVisualisation(reduced_layers, ax=ax, layout=nx.spring_layout, node_size=nodeSize)
    ax.set_axis_off()
    if not disableShow:
      plt.show()

landscape.make_multiplex_landscape module

Multiplex landscape construction

It starts by importing the necessary modules, including networkx for working with graphs and sys for handling command-line arguments.

import sys
import networkx as nx

The make_multiplex_landscape function defines the construction of a multiplex network (a network with multiple layers) from a list of graphs, associates fitness values with nodes, and writes the resulting multiplex network to a .gexf file for further analysis and visualization. This function takes a name and a list of graphs as input.

  • It initializes a MultiGraph named G_mpx. A MultiGraph is a type of graph structure in NetworkX that allows multiple edges (parallel edges) between the same pair of nodes. This structure is suitable for creating a multiplex network where each layer is represented as a separate edge set.

  • It adds nodes to G_mpx from the first graph in the list of graphs (i.e., graphs[0]). The nodes in G_mpx are initially populated from the nodes in the first graph.

  • It then iterates through the list of graphs. For each graph in the list, it performs the following actions:

    • It iterates over the nodes of the current graph and assigns a node attribute to each node in G_mpx. The node attribute is named after the index of the current graph (i.e., i) and is set to the fitness value of the corresponding node in the current graph. This allows fitness values from different layers (graphs) to be associated with the same node in the multiplex network.

    • It adds edges to G_mpx from the current graph with the specified layer index (i). These edges represent connections or relationships within a specific layer of the multiplex network.

  • After iterating through all the graphs and adding nodes and edges to G_mpx, the function writes the resulting multiplex network to a GEXF (Graph Exchange XML Format) file in the ‘../data/landscape/’ directory. The file is named using the provided name with a ‘.gexf’ extension.

def make_multiplex_landscape(name, graphs):
   G_mpx = nx.MultiGraph()
   G_mpx.add_nodes_from(graphs[0])
   for i, graph in enumerate(graphs):
       for k in range(0, len(graph.nodes)):
           G_mpx.nodes[k][i] = graph.nodes[k]["fitness"]
       G_mpx.add_edges_from(graph.edges, layer=i)

   nx.write_gexf(G_mpx, '../data/landscape/'+name+'.gexf')

Next, a script is designed to process a list of GEXF files representing individual layers of a multiplex network and create a multiplex network from these layers, with the ability to assign fitness values to nodes in each layer. The resulting multiplex network is saved to a GEXF file for further analysis and visualization. The script takes the desired name for the output file and a list of input files as command-line arguments.

  • It initializes an empty list called graphs.

  • The script uses a for loop to iterate through the command-line arguments (sys.argv). The loop starts from the third argument (index 2) and continues to the end of the arguments. The reason for starting at index 2 is that the first argument (sys.argv[0]) is the script’s name, and the second argument (sys.argv[1]) is expected to be the desired name for the output file.

  • Within the loop, it reads GEXF files specified in the command-line arguments using nx.read_gexf(). Each GEXF file represents a separate graph or layer of the multiplex network. The loaded graphs are added to the graphs list.

  • After all the specified GEXF files have been read and the corresponding graphs are stored in the graphs list, the script calls the make_multiplex_landscape function with two arguments:

    • The first argument (sys.argv[1]) is expected to be the desired name for the output file.

    • The second argument is the list of loaded graphs (graphs).

  • The make_multiplex_landscape function constructs a multiplex network from the list of graphs and writes the resulting multiplex network to a GEXF file with the specified name.

graphs = []
for i in range(2, len(sys.argv)):
    graphs.append(nx.read_gexf('../data/landscape/'+sys.argv[i]+'.gexf', node_type=int))

make_multiplex_landscape(sys.argv[1], graphs)

landscape.multiplex module

This module primarily serves to efficiently assess shortest path lengths in a pregenerated multiplex landscape and fitness values of the associated nodes in the network.

Library and module imports

First, some necessary libraries and modules are imported, including networkx for network analysis, matplotlib for plotting, and MultiplexNetworkVisualisation as constructed in the landscape.visualisation module.

import networkx as nx
import matplotlib.pyplot as plt
from landscape.visualisation import MultiplexNetworkVisualisation

Shortest pathway object initialisation

The MultiplexLandscape class represents a multiplex network landscape by reading network layers from GEXF files and precomputing the shortest path lengths between nodes in each layer. This allows for efficient analysis and manipulation of the multiplex network landscape.

  • self.num_layers is initialised with the number of layers, which is the length of the file_list (argument).

  • self.layers is initialised as an empty list.

  • A for loop iterates through the file names in file_list, and for each file, it reads the GEXF file located in the specified path (‘./data/landscape/’) using nx.read_gexf. The resulting network is added to the self.layers list.

  • self.path_length is initialised as a list, where each element of the list corresponds to a network layer. For each layer, it computes and stores the shortest path lengths between all pairs of nodes using nx.all_pairs_shortest_path_length.

class MultiplexLandscape: 
  def __init__(self, file_list):
    self.num_layers = len(file_list)
    self.layers = []
    for filename in file_list:
        self.layers.append(nx.read_gexf('./data/landscape/'+filename+'.gexf', node_type=int))
    
    self.path_length = [dict(nx.all_pairs_shortest_path_length(layer)) for layer in self.layers]

Retrieving fitness values

The MultiplexLandscape class provides two methods for retrieving fitness values associated with nodes in the network layers. These methods allow to access fitness values for specific nodes within individual layers or across all layers of the multiplex network landscape.

  • getFitnessForLayer(self, layer, node): This method retrieves the fitness value of the specified node in the specified layer and returns it. It takes two arguments:

    • layer: An integer representing the index of the layer within the multiplex landscape.

    • node: An integer representing the node for which to retrieve the fitness value in the specified layer.

  • getFitness(self, node): This method iterates through all layers and retrieves the fitness value associated with the specified node in each layer. It returns a list of fitness values, where each element of the list corresponds to a different layer in the multiplex landscape. It takes a single argument:

    • node: An integer representing the node for which to retrieve fitness values across all layers.

class MultiplexLandscape: CONTINUED
  def getFitnessForLayer(self, layer, node):
    nodes = self.layers[layer].nodes(data=True)
    fitness = nodes[node]["fitness"]
    return fitness

  def getFitness(self, node):
    fitness_list = []
    for l_idx in range(self.num_layers):
      nodes = self.layers[l_idx].nodes(data=True)
      fitness_list.append(nodes[node]["fitness"])

    return fitness_list

Visualisation

Lastly, the visualise method in the MultiplexLandscape class is used to visualize the multiplex landscape using a 3D plot. It allows for creating a 3D visualization of the multiplex landscape, and its custimisation using parameters control whether it’s displayed interactively.

  • It creates a new 3D plot within a figure.

  • It uses the MultiplexNetworkVisualisation class to visualise the multiplex landscape, passing in the layers of the landscape as input data. The layout parameter is set to nx.spring_layout, which is a NetworkX layout algorithm used to determine the positions of nodes in the visualisation.

  • It sets the size of the nodes in the visualisation using the node_size parameter.

  • It turns off the axis labels using ax.set_axis_off(), making the plot cleaner.

  • Finally, it shows the plot using plt.show() unless the disableShow parameter is set to True. If disableShow is True, the plot won’t be displayed, which can be useful for saving the plot as an image file or use it for further analysis without displaying it interactively.

  def visualise(self, disableShow=True, node_size=300):
    fig = plt.figure()
    ax = fig.add_subplot(111, projection='3d')
    MultiplexNetworkVisualisation(self.layers, ax=ax, layout=nx.spring_layout, node_size=node_size)
    ax.set_axis_off()
    if not disableShow:
      plt.show()

landscape.visualisation module

Library and module imports

import numpy as np
import matplotlib.pyplot as plt
import networkx as nx

from mpl_toolkits.mplot3d import Axes3D
from mpl_toolkits.mplot3d.art3d import Line3DCollection

Initialisation of 3D-multiplex network visualisation

A class named MultiplexNetworkVisualisation is defined for the purpose of visualising multiplex networks in 3D space.

  • The class constructor (__init__) takes several parameters to set up the visualisation:

    • graphs: A list of networkx.Graph objects, one for each layer of the multiplex network.

    • node_labels: A dictionary mapping node IDs to labels (or None if nodes should not be labeled).

    • layout: A layout function (defaulting to networkx.spring_layout) is used to compute the positions of nodes.

    • ax: An optional instance of mpl_toolkits.mplot3d.Axes3d where the visualization will be plotted. If not provided, a new figure and axis are created.

  • The constructor stores the input parameters in instance variables for later use, such as self.graphs, self.node_labels, self.layout, and self.node_size

  • Since an ax parameter is not provided, a new 3D axis is created using matplotlib.

  • The constructor then creates an internal representation of nodes and edges for the multiplex network, considering connections within and between layers.

    • self.get_nodes(): This method compiles a list of all nodes present in any of the layers of the multiplex network.

    • self.get_edges_within_layers(): This method determines the edges within each layer by iterating through the provided graphs.

    • self.get_edges_between_layers(): This method determines the edges between layers. Nodes in subsequent layers are connected if they have the same node ID. This allows the visualisation to show connections between the same nodes across different layers.

  • The constructor calculates the positions of nodes in 3D space using the self.get_node_positions function.

  • The self.draw() method is then called to create the actual visualisation based on the computed node positions, edges within layers, and edges between layers. This visualisation represents the multiplex network with different layers separated along the z-axis. The provided node_size parameter controls the size of the nodes in the visualisation.

class MultiplexNetworkVisualisation(object):
    def __init__(self, graphs, node_labels=None, layout=nx.spring_layout, ax=None, node_size=300):
        self.graphs = graphs
        self.total_layers = len(graphs)

        self.node_labels = node_labels
        self.layout = layout

        self.node_size = node_size

        if ax:
            self.ax = ax
        else:
            fig = plt.figure()
            self.ax = fig.add_subplot(111, projection='3d')

        self.get_nodes()
        self.get_edges_within_layers()
        self.get_edges_between_layers()

        self.get_node_positions()
        self.draw()

Class method definitions

Several of the functions which the __init__ constructors calls are defined in the following code section.

  • get_nodes(self): This method constructs an internal representation of nodes in the multiplex network. The internal representation is a list of tuples, where each tuple contains a node ID and its corresponding layer. The method iterates through the list of graphs (one for each layer) and collects nodes from each layer, mapping each node to its layer index.

  • get_edges_within_layers(self): This method remaps edges within individual layers to the internal representations of node IDs. It creates a list of tuples, where each tuple contains a pair of nodes in the format ((source node, layer), (target node, layer)). It iterates through the list of graphs and their edges, mapping each edge to the internal representation with layer information.

  • get_edges_between_layers(self): This method determines the edges between layers in the multiplex network. It assumes that nodes in subsequent layers are connected if they have the same ID. It creates a list of tuples, where each tuple contains a pair of nodes in different layers that share the same node ID. It iterates through pairs of adjacent graphs (layers) and identifies nodes that exist in both layers, indicating connectivity between layers.

class MultiplexNetworkVisualisation(object): CONTINUED
    def get_nodes(self):
        self.nodes = []
        for z, g in enumerate(self.graphs):
            self.nodes.extend([(node, z) for node in g.nodes()])


    def get_edges_within_layers(self):
        self.edges_within_layers = []
        for z, g in enumerate(self.graphs):
            self.edges_within_layers.extend([((source, z), (target, z)) for source, target in g.edges()])


    def get_edges_between_layers(self):
        self.edges_between_layers = []
        for z1, g in enumerate(self.graphs[:-1]):
            z2 = z1 + 1
            h = self.graphs[z2]
            shared_nodes = set(g.nodes()) & set(h.nodes())
            self.edges_between_layers.extend([((node, z1), (node, z2)) for node in shared_nodes])

Also, the code defines a method called get_node_positions. This method is used to compute the positions of nodes in 3D space for the multiplex network visualisation.

  • The method starts by initialising a variable called composition with the first graph from the list of self.graphs. This graph is referred to as the composition because it represents the composition of all layers of the multiplex network.

  • The method then iterates through the remaining graphs (layers) in self.graphs starting from the second graph.

  • For each layer, it combines the composition graph with the current layer h using the nx.compose function from NetworkX. This results in a single graph that represents the entire multiplex network with all layers.

  • Next, the method computes the node positions in 3D space using a specified layout function (self.layout). It passes the composed graph composition as well as any additional arguments and keyword arguments provided in *args and **kwargs.

  • An empty dictionary called self.node_positions is created, which will store the positions of nodes in the format (node ID, layer) : (x, y, z, fitness).

  • The method then iterates through the list of graphs (layers) in self.graphs, and for each graph, it associates node positions with layers.

  • For each node in a graph (layer), the method adds an entry to the self.node_positions dictionary. The entry’s key is a tuple containing the node’s ID and layer, and the value is a tuple containing the node’s 3D position (x, y, z) and its fitness value. The result is a self.node_positions dictionary that maps each node in the multiplex network to its 3D position and fitness value.

class MultiplexNetworkVisualisation(object): CONTINUED
    def get_node_positions(self, *args, **kwargs):
        composition = self.graphs[0]
        for h in self.graphs[1:]:
            composition = nx.compose(composition, h)

        pos = self.layout(composition, *args, **kwargs)

        self.node_positions = dict()
        for z, g in enumerate(self.graphs):
            self.node_positions.update({(node[0], z) : (*pos[node[0]], z, float(node[1]['fitness'])) for node in g.nodes(data=True)})

Draw methods

The draw_nodes method is responsible for rendering and visualising the nodes of the multiplex network in a 3D plot. The result is a 3D scatter plot of the specified nodes in the multiplex network, where the color of each node is determined by its fitness value, and the nodes are distributed in 3D space according to their positions.

class MultiplexNetworkVisualisation(object): CONTINUED
    def draw_nodes(self, nodes, *args, **kwargs):
        x, y, z, f = zip(*[self.node_positions[node] for node in nodes])
        self.ax.scatter(x, y, z, c=f, cmap='Purples', *args, **kwargs)

The draw_edges method is responsible for rendering and visualising the edges (connections) between nodes in the multiplex network in a 3D plot. The result is a 3D plot that visualises the edges (connections) between nodes in the multiplex network. The line segments connecting the nodes are rendered in 3D space according to their starting and ending coordinates.

class MultiplexNetworkVisualisation(object): CONTINUED
    def draw_edges(self, edges, *args, **kwargs):
        segments = [(
            (self.node_positions[source][0], self.node_positions[source][1], self.node_positions[source][2]), 
            (self.node_positions[target][0], self.node_positions[target][1], self.node_positions[target][2])
        ) for source, target in edges]
        line_collection = Line3DCollection(segments, *args, **kwargs)
        self.ax.add_collection3d(line_collection)

The get_extent method is responsible for calculating the extent of the 3D plot and setting the boundaries of the plot when rendering it, taking into account the positions of nodes.

class MultiplexNetworkVisualisation(object): CONTINUED
   def get_extent(self, pad=0.1):
        xyz = np.array(list(self.node_positions.values()))
        xmin, ymin, _, _ = np.min(xyz, axis=0)
        xmax, ymax, _, _ = np.max(xyz, axis=0)
        dx = xmax - xmin
        dy = ymax - ymin
        return (xmin - pad * dx, xmax + pad * dx), \
            (ymin - pad * dy, ymax + pad * dy)

The draw_plane method is responsible for drawing a plane (i.e. a flat surface) at a specified z height position within the 3D plot, covering the extent of the x-y plane.

class MultiplexNetworkVisualisation(object): CONTINUED
   def draw_plane(self, z, *args, **kwargs):
        (xmin, xmax), (ymin, ymax) = self.get_extent(pad=0.1)
        u = np.linspace(xmin, xmax, 10)
        v = np.linspace(ymin, ymax, 10)
        U, V = np.meshgrid(u ,v)
        W = z * np.ones_like(U)
        self.ax.plot_surface(U, V, W, *args, **kwargs)

The draw_node_labels method iterates through the nodes in the multiplex network, checks if each node has a label, and if so, it adds a label at the 3D position of the node in the plot. The appearance of the labels can be customised using the provided arguments and keyword arguments.

class MultiplexNetworkVisualisation(object): CONTINUED
   def draw_node_labels(self, node_labels, *args, **kwargs):
        for node, z in self.nodes:
            if node in node_labels:
                ax.text(*self.node_positions[(node, z)], node_labels[node], *args, **kwargs)

Lastly, the draw method is responsible for drawing the multiplex network in 3D. It assembles and visualises the multiplex network by drawing edges, planes representing layers, nodes, and labels. It allows for customisation of various visual aspects, such as colors, transparency, and sizes, to create a 3D representation of the network.

class MultiplexNetworkVisualisation(object): CONTINUED
   def draw(self):

        self.draw_edges(self.edges_within_layers,  color='k', alpha=0.3, linestyle='-', zorder=2)
        self.draw_edges(self.edges_between_layers, color='k', alpha=0.3, linestyle='--', zorder=2)

        for z in range(self.total_layers):
            self.draw_plane(z, alpha=0.2, zorder=1)
            self.draw_nodes([node for node in self.nodes if node[1]==z], s=self.node_size, zorder=3)

        if self.node_labels:
            self.draw_node_labels(self.node_labels,
                                  horizontalalignment='center',
                                  verticalalignment='center',
                                  zorder=100)

Module contents