Source code for nngt.generation.rewiring

#-*- coding:utf-8 -*-
#
# rewiring.py
#
# This file is part of the NNGT project to generate and analyze
# neuronal networks and their activity.
# Copyright (C) 2015-2019  Tanguy Fardet
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

""" Rewiring functions """

from copy import deepcopy

import numpy as np

import nngt
from nngt.generation import graph_connectivity as gc
from nngt.lib import nonstring_container


__all__ = [
    "lattice_rewire",
    "random_rewire"
]


[docs]def lattice_rewire(g, target_reciprocity=1., node_attr_constraints=None, edge_attr_constraints=None, weight=None, weight_constraint="distance", distance_sort="inverse"): r''' Build a (generally irregular) lattice by rewiring the edges of a graph. .. versionadded:: 2.0 The lattice is based on a circular graph, meaning that the nodes are placed on a circle and connected based on the topological distance between them, the distance being defined through the positive modulo: .. math:: d_{ij} = (i - j) \% N with :math:`N` the number of nodes in the graph. Parameters ---------- g : :class:`~nngt.Graph` Graph based on which the lattice will be generated. target_reciprocity : float, optional (default: 1.) Value of reciprocity that should be aimed at. Depending on the number of edges, it may not be possible to reach this value exactly. node_attr_constraints : str, optional (default: randomize all attributes) Whether attribute randomization is constrained: either "preserve", where all nodes keep their attributes, or "together", where attributes are randomized by groups (all attributes of a given node are sent to the same new node). By default, attributes are completely and separately randomized. edge_attr_constraints : str, optional (default: randomize all but `weight`) Whether attribute randomization is constrained. If "distance" is used, then all number attributes (float or int) are sorted and are first associated to the shortest or longest edges depending on the value of `distance_sort`. Note that, for directed graphs, if a reciprocal edge exists, it is immediately assigned the next highest (respectively lowest) attribute after that of its directed couterpart. If "together" is used, edges attributes are randomized by groups (all attributes of a given edge are sent to the same new edge) either randomly if `weight` is None, or following the constrained `weight` attribute. By default, attributes are completely and separately randomized (except for `weight` if it has been provided). weight : str, optional (default: None) Whether a specific edge attribute should play the role of weight and have special constraints. weight_constraint : str, optional (default: "distance") Same as `edge_attr_constraints`` but only applies to `weight` and can only be "distance" or None since "together" was related to `weight`. distance_sort : str, optional (default: "inverse") How attributes are sorted with edge distance: either "inverse", with the shortest edges being assigned the largest weights, or with a "linear" sort, where shortest edges are assigned the lowest weights. ''' directed = g.is_directed() num_nodes = g.node_nb() num_edges = g.edge_nb() # check that requested lattice is possible if directed: if num_edges < int(num_nodes*(1 + target_reciprocity)): raise ValueError("The number of edges in the graph is not " "sufficient to make a lattice with requested " "reciprocity.") else: if num_edges < num_nodes: raise ValueError("The number of edges in the graph is not" "sufficient to make a lattice.") # check arguments if node_attr_constraints not in (None, "preserve", "together"): raise ValueError("`node_attr_constraints` must be either None, " "'preserve', or 'together'.") if edge_attr_constraints not in (None, "distance", "together"): raise ValueError("`edge_attr_constraints` must be either None, " "'distance', or 'together'.") if weight_constraint not in ("distance", None): raise ValueError("`weight_constraint` can only be 'distance' or None.") if distance_sort not in ("linear", "inverse"): raise ValueError("`distance_sort` must be either 'linear' or " "'inverse'.") if directed and target_reciprocity != 1: raise ValueError("Reciprocity is always 1 for undirected graphs.") # init graph and edges new_graph = nngt.Graph(nodes=num_nodes, directed=directed, name=g.name + "_latticized") ia_edges = np.full((num_edges, 2), -1, dtype=np.int64) # compute the coodination number of the closest regular lattice coord_nb, e_reglat = None, None if directed: # coordination number must be even coord_nb = 2*int(num_edges / (num_nodes*(1 + target_reciprocity))) e_reglat = int(0.5*num_nodes*(1 + target_reciprocity)*coord_nb) else: # coordination number must be even and resulting edges are half coord_nb = 2*int(num_edges / num_nodes) e_reglat = int(0.5*num_nodes*coord_nb) # generate the edges of the regular lattice ids = range(num_nodes) ia_edges[:e_reglat] = gc._circular( ids, ids, coord_nb, target_reciprocity, directed=directed, reciprocity_choice="closest-ordered") # add the remaining edges (remaining edges strictly smaller than num_nodes) e_remaining = num_edges - e_reglat if e_remaining: last_edges = np.full((e_remaining, 2), -1, dtype=np.int64) # new connections are one step above the max regular lattice distance dist = int(0.5*coord_nb) + 1 if directed: # make reciprocal edges num_recip = int(0.5*target_reciprocity*e_remaining) if num_recip: last_edges[:num_recip] = \ [(i, i + dist) for i in range(num_recip)] last_edges[num_recip:2*num_recip] = \ last_edges[:num_recip, ::-1] # make remaning non-reciprocal edges e_final = e_remaining - 2*num_recip if e_final: last_edges[2*num_recip:] = \ [(i, i + dist) for i in range(num_recip, num_recip + e_final)] else: last_edges[:] = [(i, i + dist) for i in range(e_remaning)] # put nodes back into [0, num_nodes[ last_edges[last_edges >= num_nodes] -= num_nodes ia_edges[e_reglat:] = last_edges # add the edges new_graph.new_edges(ia_edges, check_duplicates=False, check_self_loops=False, check_existing=False) # set the node attributes _set_node_attributes(g, new_graph, node_attr_constraints, num_nodes) # edge attributes order = None # start with the weight if weight is not None: order = _lattice_shuffle_eattr( weight, g, new_graph, coord_nb, target_reciprocity, weight_constraint, distance_sort) for eattr in g.edge_attributes: if eattr != weight: ordering = (order if edge_attr_constraints == "together" else edge_attr_constraints) order = _lattice_shuffle_eattr( eattr, g, new_graph, coord_nb, target_reciprocity, ordering, distance_sort) return new_graph
[docs]def random_rewire(g, constraints=None, node_attr_constraints=None, edge_attr_constraints=None): ''' Generate a new rewired graph from `g`. .. versionadded:: 2.0 Parameters ---------- g : :class:`~nngt.Graph` Base graph based on which a new rewired graph will be generated. constraints : str, optional (default: no constraints) Defines which properties of `g` will be maintained in the rewired graph. By default, the graph is completely rewired into an Erdos-Renyi model. Available constraints are "in-degree", "out-degree", "total-degree", "all-degrees", and "clustering". node_attr_constraints : str, optional (default: randomize all attributes) Whether attribute randomization is constrained: either "preserve", where all nodes keep their attributes, or "together", where attributes are randomized by groups (all attributes of a given node are sent to the same new node). By default, attributes are completely and separately randomized. edge_attr_constraints : str, optional (default: randomize all attributes) Whether attribute randomization is constrained. If `constraints` is "in-degree" (respectively "out-degree") or "degrees", this can be "preserve_in" (respectively "preserve_out"), in which case all attributes of a given edge are moved together to a new incoming (respectively outgoing) edge of the same node. Regardless of `constraints`, "together" can be used so that edges attributes are randomized by groups (all attributes of a given edge are sent to the same new edge). By default, attributes are completely and separately randomized. ''' directed = g.is_directed() num_nodes = g.node_nb() num_edges = g.edge_nb() new_graph = None if node_attr_constraints not in (None, "preserve", "together"): raise ValueError("`node_attr_constraints` must be either None, " "'preserve', or 'together'.") # check compatibility between `constraints` and `edge_attr_constraints` valid_e = (None, "preserve_in", "preserve_out", "together") if edge_attr_constraints not in valid_e: raise ValueError( "`edge_attr_constraints` must be in {}.".format(valid_e)) elif edge_attr_constraints == "preserve_in": assert constraints in ("in-degree", "all-degrees"), \ "Can only use 'preserve_in' if `constraints` is 'in-degree' or " \ "'all-degrees'." elif edge_attr_constraints == "preserve_out": assert constraints in ("out-degree", "all-degrees"), \ "Can only use 'preserve_out' if `constraints` is 'out-degree' " \ "or 'all-degrees'." # generate rewired graph if constraints is None: new_graph = gc.erdos_renyi(edges=num_edges, nodes=num_nodes, directed=directed) elif constraints == "all-degrees": raise NotImplementedError("Full degrees constraints is not yet " "implemented.") elif "degree" in constraints: degrees = g.get_degrees(constraints) new_graph = gc.from_degree_list(degrees, constraints, directed=directed) elif constraints == "clustering": raise NotImplementedError("Rewiring with constrained clustering is " "not yet available.") rng = nngt._rng # node attributes _set_node_attributes(g, new_graph, node_attr_constraints, num_nodes) # edge attributes order = np.arange(num_edges, dtype=int) if edge_attr_constraints == "together": rng.shuffle(order) elif edge_attr_constraints == "preserve_in": for i in range(num_nodes): old_edges = g.get_edges(target_node=i) new_edges = new_graph.get_edges(target_node=i) if len(new_edges): old_ids = g.edge_id(old_edges) new_ids = new_graph.edge_id(new_edges) order[new_ids] = old_ids elif edge_attr_constraints == "preserve_out": for i in range(num_nodes): old_edges = g.get_edges(source_node=i) new_edges = new_graph.get_edges(source_node=i) if len(new_edges): old_ids = g.edge_id(old_edges) new_ids = new_graph.edge_id(new_edges) order[new_ids] = old_ids for k in g.edge_attributes: v = deepcopy(g.get_edge_attributes(name=k)) if edge_attr_constraints is None: rng.shuffle(v) else: v = v[order] dtype = g.get_attribute_type(k, attribute_class="edge") new_graph.new_edge_attribute(k, dtype, values=v) # set spatial/network properties if g.is_spatial(): nngt.Graph.make_spatial(new_graph, shape=g.shape.copy(), positions=g.get_positions().copy()) if g.is_network(): nngt.Graph.make_network(new_graph, neural_pop=g.population.copy()) new_graph._name = g.name + "_rewired" return new_graph
# ----- # # Tools # # ----- # def _set_node_attributes(old_graph, new_graph, constraints, num_nodes): ''' Reassign node attributes ''' order = None if constraints == "together": order = [i for i in range(num_nodes)] nngt._rng.shuffle(order) # shuffled order for "together" for k, v in old_graph.node_attributes.items(): values = v.copy() if constraints is None: nngt._rng.shuffle(values) elif constraints == "together": values = v[order] dtype = old_graph.get_attribute_type(k, attribute_class="node") new_graph.new_node_attribute(k, dtype, values=values) def _lattice_shuffle_eattr(name, old_graph, new_graph, coord_nb, target_recip, order, distance_sort): ''' Reassign edge attributes based on a constraint or a pre-defined order for the lattice rewiring. Parameters ---------- name : str Name of the edge attribute. old_graph : :class:`~nngt.Graph` The old graph. new_graph : :class:`~nngt.Graph` The new graph. coord_nb : int Coordination number of the lattice. target_recip : float Target reciprocity of the lattice. order : array of indices, distance", or None Constraint on edge reassignment: either a precomputed order, "distance" if we perform a distance-based shuffle, or None if we randomly shuffle the attributes. distance_sort : str How the attributes should correlate with the distance (linearly or inversely) if `order` is "distance". Returns ------- order : array of indices The order in which the edge attributes have been shuffled. ''' num_nodes = new_graph.node_nb() num_edges = new_graph.edge_nb() # old attribute value_type = old_graph.get_attribute_type(name, "edge") values = old_graph.edge_attributes[name].copy() # compute order and reassign values if order is None: order = np.arange(num_edges, dtype=int) nngt._rng.shuffle(order) values = values[order] elif nonstring_container(order): # use precomputed order values = values[order] else: # distance sort directed = new_graph.is_directed() if directed: if target_recip < 1: # we need to find the reciprocal edges for the attribute # assignment (this relies on the precise implementation of the # function _circular_directed_recip that the closest distances # come first, then the reciprocal edges are at the end in the # same order) init_edges = int(0.5*num_nodes*(1 + target_recip)*coord_nb) num_recip = init_edges - num_nodes*int(0.5*coord_nb) # fill the order list in the following order order = np.zeros(num_edges, dtype=int) # the first entries are the initial edges that got a reciprocal # connection, we order them with every other first indices # since the reciprocal edges will come in between order[:2*num_recip:2] = np.arange(num_recip) # we enter the index of the reciprocal connection after each order[1:2*num_recip + 1:2] = np.arange(init_edges, init_edges + num_recip) # then we fill the last entries with the initial edges that did # not get a reciprocal connection order[2*num_recip + 1:] = np.arange(num_recip, init_edges) if distance_sort == "inverse": order = order[::-1] else: # For the fully reciprocal lattice, the edges go both ways, # with first the long-distance, left-pointing edges, then # decreasing distances until the middle of the edges array, # then increasing from the middle onward with the shortest # right-pointing edges first (see _circular_full). # Distances looks like [d_max, d_max... 1, 1... 1, 1... d_max]. # Therefore we proceed almost like for the partly reciprocal # lattice but we start at the middle and the reciprocal edges # are before the middle e_reglat = int(0.5*num_nodes*(1 + target_recip)*coord_nb) # we work based on the middle of the edges of the regular # lattice, other edges are added at the end middle = int(0.5*e_reglat) d_max = int(0.5*coord_nb) order = np.full(num_edges, -1, dtype=int) count = 0 for d_n in range(1, d_max + 1): d_middle = num_nodes*(d_n - 1) stop = count + 2*num_nodes # the entries from the middle shortest edges; we order them # with every other increasing indices since the reciprocal # edges will come in between order[count:stop:2] = np.arange( middle + d_middle, middle + d_middle + num_nodes) # we enter the index of the reciprocal connection after # however, here the first one (starting from zero) needs # to go at the end order[count + 1:stop - 1:2] = \ np.arange(middle - (d_middle + num_nodes) + 1, middle - d_middle) order[stop - 1] = middle - d_middle - num_nodes count = stop # add remaining edges (those not in the regular lattice) remaining = num_edges - count num_recip = int(0.5*remaining) order[count:count + 2*num_recip:2] = \ np.arange(count, count + num_recip) order[count + 1:count + 2*num_recip + 1:2] = \ np.arange(count + num_recip, count + 2*num_recip) if remaining - 2*num_recip: order[-1] = num_edges - 1 order = np.argsort(order) else: # we don't need to sort the new edges because they are ordered by # distance by default in the circular algorithm order = slice(num_edges) # sort the attribute if distance_sort == "linear": # order for other attributes if "together" is used order = np.argsort(values)[order] # sorted values values = values[order] else: # order for other attributes if "together" is used order = np.argsort(values)[::-1][order] # sorted values values = values[order] # set the new attributes new_graph.new_edge_attribute(name, value_type, values=values) return order