Distribution-based Placement Strategy

Note

This example presents in more advanced tutorial to write BSB components. If you are not familiarized with BSB components, check out the getting started section on writing components.

We will start from the following configuration file (corresponds to the first network file from the getting started tutorial):

{
  "name": "Starting example",
  "storage": {
    "engine": "hdf5",
    "root": "network.hdf5"
  },
  "network": {
    "x": 200.0,
    "y": 200.0,
    "z": 200.0
  },
  "regions": {
    "brain_region": {
      "type": "stack",
      "children": ["base_layer", "top_layer"]
    }
  },
  "partitions": {
    "base_layer": {
      "type": "layer",
      "thickness": 100
    },
    "top_layer": {
      "type": "layer",
      "thickness": 100
    }
  },
  "cell_types": {
    "base_type": {
      "spatial": {
        "radius": 2.5,
        "density": 3.9e-4
      }
    },
    "top_type": {
      "spatial": {
        "radius": 7,
        "count": 40
      }
    }
  },
  "placement": {
    "example_placement": {
      "strategy": "bsb.placement.RandomPlacement",
      "cell_types": ["base_type"],
      "partitions": ["base_layer"]
    },
    "top_placement": {
      "strategy": "bsb.placement.RandomPlacement",
      "cell_types": ["top_type"],
      "partitions": ["top_layer"]
    }
  },
  "connectivity": {
    "A_to_B": {
      "strategy": "bsb.connectivity.AllToAll",
      "presynaptic": {
        "cell_types": ["base_type"]
      },
      "postsynaptic": {
          "cell_types": ["top_type"]
      }
    }
  }
}

Let’s save this new configuration in our project folder under the name config_placement.json

Description of the strategy to implement

We want here to implement a distribution placement strategy: cells will be placed within their Partition following a probability distribution along a certain axis and direction. For instance, let us use the alpha random distribution:

../_images/plot_alpha.png

The distribution should be a density function that produces random numbers, according to the distance along the axis from a border of the Partition.

Here, we want to control the distribution of the cells within the base_layer with respect to the border with the top_layer.

Components boiler plate

We will write our PlacementStrategy class in a placement.py file,

from bsb import config, PlacementStrategy

@config.node
class DistributionPlacement(PlacementStrategy):

    # add your attributes here

    def place(self, chunk, indicators):
        # write your code here
        pass

Here, our class will extend from PlacementStrategy which is an abstract class that requires you to implement the place function.

Note that this strategy leverages the @config.node python decorator. The configuration node decorator allows you to pass the parameters defined in the configuration file to the class. It will also handle the type testing of your configuration attributes (e.g., make sure your axis parameter is a positive integer). We will see in the following sections how to create your class configuration attributes.

Add configuration attributes

For our strategy, we need to pass a list of parameters to its class through our configuration file:

  • a density function distribution, defined by the user, from the list of scipy stats functions.

  • an axis along which to apply the distribution. The latter should be in [0, 1, 2]

  • a direction that if set will apply the distribution along the "negative" direction, i.e from top to bottom ("positive" direction if not set).

This translates into 3 configuration attributes that you can add to your class:

from bsb import config, PlacementStrategy, types

@config.node
class DistributionPlacement(PlacementStrategy):

    distribution = config.attr(type=types.distribution(), required=True)
    axis: int = config.attr(type=types.int(min=0, max=2), required=False, default=2)
    direction: str = config.attr(type=types.in_(["positive", "negative"]),
                                 required=False, default="positive")

    def place(self, chunk, indicators):
        # write your code here
        pass
In this case, distribution is required, and should correspond to a distribution node which interface scipy distributions.
axis here is an optional integer attribute with a default value set to 2.
Finally, direction is an optional string attribute that can be either the string "positive" or "negative" (see in_).

At this stage, you have created a python class with minimal code implementation, you should now link it to your configuration file. To import our class in our configuration file, we will modify the placement block:

"placement": {
  "alpha_placement": {
    "strategy": "placement.DistributionPlacement",
    "distribution": {
      "distribution": "alpha",
      "a": 8
    },
    "axis": 0,
    "direction": "negative",
    "cell_types": ["base_type"],
    "partitions": ["base_layer"]
  },
  "top_placement": {
    "strategy": "bsb.placement.RandomPlacement",
    "cell_types": ["top_type"],
    "partitions": ["top_layer"]
  }
}

Implement the python methods

The place function will be used here to produce and store a PlacementSet for each cell type population to place in the selected Partition. BSB is parallelizing placement jobs for each Chunk concerned.

The parameters of place includes a dictionary linking each cell type name to its PlacementIndicator, and the Chunk in which to place the cells.

We need to apply our distribution to each Partition of our circuit to see how the cells are distributed within, along our directed axis. Let’s make two for loops to iterate over the Partitions of each indicator. Then, we extract the number of cells to place within the total Partition, using the guess function. For that, we will convert the partition into a list of voxels.

    def place(self, chunk, indicators):
        # For each placement indicator
        for name_indic, indicator in indicators.items():
            # Prepare an array to store positions
            all_positions = np.empty((0, 3))
            # For each partitions
            for p in indicator.partitions:
                # Guess the number of cells to place within the partition.
                num_to_place = indicator.guess(voxels=p.to_voxels())

Now, our function is supposed to place cells within only one Chunk. Fortunately, Partitions can be decomposed into Chunks. So, we can retrieve from the distribution the number of cells to place within the chunk parameter of the function, according to its position along the directed axis.

To do so, we need to define the interval occupied by the chunk within the partition. We will leverage the lowest and highest coordinates of the chunk and partition with respectively the attributes ldc and mdc.

                # Extract the size of the partition
                partition_size = p._data.mdc - p._data.ldc
                # Retrieve the ratio interval occupied by the current Chunk along the axis
                chunk_borders = np.array([chunk.ldc, chunk.mdc])
                ratios = (chunk_borders - p._data.ldc) / partition_size
                bounds = ratios[:, self.axis]

bounds is here the interval of ratios of the space occupied by a Chunk within the Partition along the chosen axis.

We also need to take into account the case where the direction is negative. In this case, we should invert the interval. E.g., if the bounds is [0.2, 0.3] then the inverted interval is [0.7, 0.8].

                bounds = ratios[:, self.axis]
                if self.direction == "negative":
                    # If the direction on which to apply the distribution is inverted,
                    # then the ratio interval should be inverted too.
                    bounds = 1 - bounds
                    bounds = bounds[::-1]

Great, we have the interval on which we want to place our cells. Now, we will check how many cells to place within this interval according to our distribution along the provided axis, knowing the total number of cells to place within the partition. We will create a separate function for this called draw_interval. Additionally, we also need to take into account the two other dimensions. We will compute the ratio of area occupied by the chunk along the two other directions:

                # Draw according to the distribution the random number of cells to
                # place in the Chunk
                num_selected = self.draw_interval(
                    num_to_place, lower=bounds[0], upper=bounds[1]
                )
                # ratio of area occupied by the chunk along the two other dimensions
                ratio_area = np.diff(np.delete(ratios, self.axis, axis=1), axis=0)
                num_selected *= np.prod(ratio_area)

So, your final place function should look like this

    def place(self, chunk, indicators):
        # For each placement indicator
        for name_indic, indicator in indicators.items():
            # Prepare an array to store positions
            all_positions = np.empty((0, 3))
            # For each partitions
            for p in indicator.partitions:
                # Guess the number of cells to place within the partition.
                num_to_place = indicator.guess(voxels=p.to_voxels())
                # Extract the size of the partition
                partition_size = p._data.mdc - p._data.ldc
                # Retrieve the ratio interval occupied by the current Chunk along the axis
                chunk_borders = np.array([chunk.ldc, chunk.mdc])
                ratios = (chunk_borders - p._data.ldc) / partition_size
                bounds = ratios[:, self.axis]
                if self.direction == "negative":
                    # If the direction on which to apply the distribution is inverted,
                    # then the ratio interval should be inverted too.
                    bounds = 1 - bounds
                    bounds = bounds[::-1]

                # Draw according to the distribution the random number of cells to
                # place in the Chunk
                num_selected = self.draw_interval(
                    num_to_place, lower=bounds[0], upper=bounds[1]
                )
                # ratio of area occupied by the chunk along the two other dimensions
                ratio_area = np.diff(np.delete(ratios, self.axis, axis=1), axis=0)
                num_selected *= np.prod(ratio_area)
                if num_selected > 0:
                    # Assign a random position to the cells within this Chunk
                    positions = (
                        np.random.rand(num_selected, 3) * chunk.dimensions + chunk.ldc
                    )
                    all_positions = np.concatenate([all_positions, positions])
            self.place_cells(indicator, all_positions, chunk)

draw_interval will leverage an acceptance-rejection method . In short, this method will draw n random values within [0, 1] and return the number which value is less than the probability according to the the distribution to fall in the provided interval boundaries. We will retrieve the interval of definition of the distribution and within boundaries of our provided ratio interval.

        # Extract the interval of values that can be generated by the distribution
        # Since some distribution values can reach infinite, we clamp the interval
        # so that the probability to be out of this interval is 1e-9
        distrib_interval = self.distribution.definition_interval(1e-9)
        # Retrieve the value at the lower and upper bound ratios of the
        # distribution's interval
        value_upper = upper * np.diff(distrib_interval) + distrib_interval[0]
        value_lower = lower * np.diff(distrib_interval) + distrib_interval[0]

From the distribution, we can retrieve the probability for a drawn random value be lesser than the upper bound and the probability for it to be greater than the lower bound.

        # Retrieve the probability of a value to be lower than the upper value
        selected_lt = self.distribution.cdf(value_upper)
        # Retrieve the probability of a value to be greater than the lower value
        selected_gt = self.distribution.sf(value_lower)

Finally, we apply the acceptance-rejection algorithm to see how much of the cells to place in the partition should be placed in the chunk:

        # Apply the acceptance-rejection method: a random point within [0,1] is
        # accepted if it is lower than the probability to be less than the upper
        # value and the probability to be greater than the lower value
        random_numbers = np.random.rand(n)
        selected = random_numbers <= selected_lt * selected_gt
        # Returns the number of point passing the test
        return np.count_nonzero(selected)

We are done with the Placement! Here is how the full strategy looks like:

import numpy as np

from bsb import PlacementStrategy, config, types


@config.node
class DistributionPlacement(PlacementStrategy):
    """
    Place cells based on a scipy distribution along a specified axis.
    """

    distribution = config.attr(type=types.distribution(), required=True)
    """Scipy distribution to apply to the cell coordinate along the axis"""
    axis: int = config.attr(type=types.int(min=0, max=2), required=False, default=2)
    """Axis along which to apply the distribution (i.e. x, y or z)"""
    direction: str = config.attr(
        type=types.in_(["positive", "negative"]), required=False, default="positive"
    )
    """Specify if the distribution is applied along positive or negative axis direction"""

    def draw_interval(self, n, lower, upper):
        """
        Apply an acceptance-rejection method to n points according to the provided
        interval of the distribution.
        This method draws n random values and returns the number which value is
        less than the probability to fall in the provided interval boundaries.

        :param int n: Number of points to draw
        :param float lower: Lower bound of the interval within [0, 1]
        :param float upper: Upper bound of the interval within [0, 1]
        :return: Number of points passing the acceptance-rejection test.
        :rtype: int
        """
        # Extract the interval of values that can be generated by the distribution
        # Since some distribution values can reach infinite, we clamp the interval
        # so that the probability to be out of this interval is 1e-9
        distrib_interval = self.distribution.definition_interval(1e-9)
        # Retrieve the value at the lower and upper bound ratios of the
        # distribution's interval
        value_upper = upper * np.diff(distrib_interval) + distrib_interval[0]
        value_lower = lower * np.diff(distrib_interval) + distrib_interval[0]
        # Retrieve the probability of a value to be lower than the upper value
        selected_lt = self.distribution.cdf(value_upper)
        # Retrieve the probability of a value to be greater than the lower value
        selected_gt = self.distribution.sf(value_lower)
        # Apply the acceptance-rejection method: a random point within [0,1] is
        # accepted if it is lower than the probability to be less than the upper
        # value and the probability to be greater than the lower value
        random_numbers = np.random.rand(n)
        selected = random_numbers <= selected_lt * selected_gt
        # Returns the number of point passing the test
        return np.count_nonzero(selected)

    def place(self, chunk, indicators):
        # For each placement indicator
        for name_indic, indicator in indicators.items():
            # Prepare an array to store positions
            all_positions = np.empty((0, 3))
            # For each partitions
            for p in indicator.partitions:
                # Guess the number of cells to place within the partition.
                num_to_place = indicator.guess(voxels=p.to_voxels())
                # Extract the size of the partition
                partition_size = p._data.mdc - p._data.ldc
                # Retrieve the ratio interval occupied by the current Chunk along the axis
                chunk_borders = np.array([chunk.ldc, chunk.mdc])
                ratios = (chunk_borders - p._data.ldc) / partition_size
                bounds = ratios[:, self.axis]
                if self.direction == "negative":
                    # If the direction on which to apply the distribution is inverted,
                    # then the ratio interval should be inverted too.
                    bounds = 1 - bounds
                    bounds = bounds[::-1]

                # Draw according to the distribution the random number of cells to
                # place in the Chunk
                num_selected = self.draw_interval(
                    num_to_place, lower=bounds[0], upper=bounds[1]
                )
                # ratio of area occupied by the chunk along the two other dimensions
                ratio_area = np.diff(np.delete(ratios, self.axis, axis=1), axis=0)
                num_selected *= np.prod(ratio_area)
                if num_selected > 0:
                    # Assign a random position to the cells within this Chunk
                    positions = (
                        np.random.rand(num_selected, 3) * chunk.dimensions + chunk.ldc
                    )
                    all_positions = np.concatenate([all_positions, positions])
            self.place_cells(indicator, all_positions, chunk)

Enjoy

You have done the hardest part! Now, you should be able to run the reconstruction once again with your brand new component.

bsb compile config_placement.json --verbosity 3