From 63acf11d62a2a8a0c8c686d5c47f415e9849c93c Mon Sep 17 00:00:00 2001 From: Joni Herttuainen Date: Thu, 26 Oct 2023 18:55:36 +0200 Subject: [PATCH 1/3] provide divergence and convergence stats for EdgePopulation --- CHANGELOG.rst | 1 + bluepysnap/edges/edge_population.py | 6 ++ bluepysnap/edges/edge_population_stats.py | 79 +++++++++++++++++++++++ tests/test_edge_population.py | 2 + tests/test_edge_population_stats.py | 43 ++++++++++++ 5 files changed, 131 insertions(+) create mode 100644 bluepysnap/edges/edge_population_stats.py create mode 100644 tests/test_edge_population_stats.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index c44809f1..859ac090 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -11,6 +11,7 @@ New Features - Added an alias ``validate-circuit`` for the old ``validate`` subcommand - deprecated ``validate`` +- Added ``EdgePopulation.stats`` with two methods: ``divergence``, ``convergence`` Breaking Changes diff --git a/bluepysnap/edges/edge_population.py b/bluepysnap/edges/edge_population.py index 32854b88..77376a24 100644 --- a/bluepysnap/edges/edge_population.py +++ b/bluepysnap/edges/edge_population.py @@ -28,6 +28,7 @@ from bluepysnap import query, utils from bluepysnap.circuit_ids import CircuitEdgeIds from bluepysnap.circuit_ids_types import IDS_DTYPE, CircuitEdgeId +from bluepysnap.edges.edge_population_stats import StatsHelper from bluepysnap.exceptions import BluepySnapError from bluepysnap.sonata_constants import DYNAMICS_PREFIX, ConstContainer, Edge @@ -147,6 +148,11 @@ def property_dtypes(self): """ return self.get([0], list(self.property_names)).dtypes.sort_index() + @cached_property + def stats(self): + """Access edge population stats methods.""" + return StatsHelper(self) + def container_property_names(self, container): """Lists the ConstContainer properties shared with the EdgePopulation. diff --git a/bluepysnap/edges/edge_population_stats.py b/bluepysnap/edges/edge_population_stats.py new file mode 100644 index 00000000..5e2faa1d --- /dev/null +++ b/bluepysnap/edges/edge_population_stats.py @@ -0,0 +1,79 @@ +"""EdgePopulation stats helper.""" + +import numpy as np + +from bluepysnap.exceptions import BluepySnapError + + +class StatsHelper: + """EdgePopulation stats helper.""" + + def __init__(self, edge_population): + """Initialize StatsHelper with an EdgePopulation instance.""" + self._edge_population = edge_population + + def divergence(self, source, target, by, sample=None): + """`source` -> `target` divergence. + + Args: + source (int/CircuitNodeId/CircuitNodeIds/sequence/str/mapping/None): source nodes + target (int/CircuitNodeId/CircuitNodeIds/sequence/str/mapping/None): target nodes + by (str): 'synapses' or 'connections' + sample (int): if specified, sample size for source group + + Returns: + Array with synapse / connection count per each cell from `source` sample + (taking into account only connections to cells in `target`). + """ + by_alternatives = {"synapses", "connections"} + if by not in by_alternatives: + raise BluepySnapError(f"`by` should be one of {by_alternatives}; got: {by}") + + source_sample = self._edge_population.source.ids(source, sample=sample) + + result = {id_: 0 for id_ in source_sample} + if by == "synapses": + connections = self._edge_population.iter_connections( + source_sample, target, return_synapse_count=True + ) + for pre_gid, _, synapse_count in connections: + result[pre_gid] += synapse_count + else: + connections = self._edge_population.iter_connections(source_sample, target) + for pre_gid, _ in connections: + result[pre_gid] += 1 + + return np.array(list(result.values())) + + def convergence(self, source, target, by=None, sample=None): + """`source` -> `target` convergence. + + Args: + source (int/CircuitNodeId/CircuitNodeIds/sequence/str/mapping/None): source nodes + target (int/CircuitNodeId/CircuitNodeIds/sequence/str/mapping/None): target nodes + by (str): 'synapses' or 'connections' + sample (int): if specified, sample size for target group + + Returns: + Array with synapse / connection count per each cell from `target` sample + (taking into account only connections from cells in `source`). + """ + by_alternatives = {"synapses", "connections"} + if by not in by_alternatives: + raise BluepySnapError(f"`by` should be one of {by_alternatives}; got: {by}") + + target_sample = self._edge_population.target.ids(target, sample=sample) + + result = {id_: 0 for id_ in target_sample} + if by == "synapses": + connections = self._edge_population.iter_connections( + source, target_sample, return_synapse_count=True + ) + for _, post_gid, synapse_count in connections: + result[post_gid] += synapse_count + else: + connections = self._edge_population.iter_connections(source, target_sample) + for _, post_gid in connections: + result[post_gid] += 1 + + return np.array(list(result.values())) diff --git a/tests/test_edge_population.py b/tests/test_edge_population.py index 0d110b9b..3bba00ac 100644 --- a/tests/test_edge_population.py +++ b/tests/test_edge_population.py @@ -14,6 +14,7 @@ from bluepysnap.circuit import Circuit from bluepysnap.circuit_ids import CircuitEdgeIds, CircuitNodeIds from bluepysnap.circuit_ids_types import IDS_DTYPE, CircuitEdgeId +from bluepysnap.edges.edge_population_stats import StatsHelper from bluepysnap.exceptions import BluepySnapError from bluepysnap.sonata_constants import DEFAULT_EDGE_TYPE, Edge @@ -41,6 +42,7 @@ def test_basic(self): assert self.test_obj.source.name == "default" assert self.test_obj.target.name == "default" assert self.test_obj.size, 4 + assert isinstance(self.test_obj.stats, StatsHelper) assert sorted(self.test_obj.property_names) == sorted( [ Synapse.SOURCE_NODE_ID, diff --git a/tests/test_edge_population_stats.py b/tests/test_edge_population_stats.py new file mode 100644 index 00000000..213c6cdc --- /dev/null +++ b/tests/test_edge_population_stats.py @@ -0,0 +1,43 @@ +from unittest.mock import Mock + +import numpy.testing as npt +import pytest + +import bluepysnap.edges.edge_population_stats as test_module +from bluepysnap.exceptions import BluepySnapError + + +class TestStatsHelper: + def setup_method(self): + self.edge_pop = Mock() + self.stats = test_module.StatsHelper(self.edge_pop) + + def test_divergence_by_synapses(self): + self.edge_pop.source.ids.return_value = [1, 2] + self.edge_pop.iter_connections.return_value = [(1, None, 42), (1, None, 43)] + actual = self.stats.divergence("pre", "post", by="synapses") + npt.assert_equal(actual, [85, 0]) + + def test_divergence_by_connections(self): + self.edge_pop.source.ids.return_value = [1, 2] + self.edge_pop.iter_connections.return_value = [(1, None), (1, None)] + actual = self.stats.divergence("pre", "post", by="connections") + npt.assert_equal(actual, [2, 0]) + + def test_divergence_error(self): + pytest.raises(BluepySnapError, self.stats.divergence, "pre", "post", by="err") + + def test_convergence_by_synapses(self): + self.edge_pop.target.ids.return_value = [1, 2] + self.edge_pop.iter_connections.return_value = [(None, 2, 42), (None, 2, 43)] + actual = self.stats.convergence("pre", "post", by="synapses") + npt.assert_equal(actual, [0, 85]) + + def test_convergence_by_connections(self): + self.edge_pop.target.ids.return_value = [1, 2] + self.edge_pop.iter_connections.return_value = [(None, 2), (None, 2)] + actual = self.stats.convergence("pre", "post", by="connections") + npt.assert_equal(actual, [0, 2]) + + def test_convergence_error(self): + pytest.raises(BluepySnapError, self.stats.convergence, "pre", "post", by="err") From 3adb92517fb8cc54f4b275a8cf6c9d48008a9d5d Mon Sep 17 00:00:00 2001 From: Joni Herttuainen Date: Mon, 15 Jan 2024 16:24:17 +0100 Subject: [PATCH 2/3] Further explain 'by connections' vs 'by synapses' in docstrings --- bluepysnap/edges/edge_population_stats.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/bluepysnap/edges/edge_population_stats.py b/bluepysnap/edges/edge_population_stats.py index 5e2faa1d..5d04c18c 100644 --- a/bluepysnap/edges/edge_population_stats.py +++ b/bluepysnap/edges/edge_population_stats.py @@ -15,6 +15,11 @@ def __init__(self, edge_population): def divergence(self, source, target, by, sample=None): """`source` -> `target` divergence. + Calculate the divergence based on number of `"connections"` or `"synapses"` each `source` + cell shares with the cells specified in `target`. + * `connections`: number of unique target cells each source cell shares a connection with + * `synapses`: number of unique synapses between a source cell and its target cells + Args: source (int/CircuitNodeId/CircuitNodeIds/sequence/str/mapping/None): source nodes target (int/CircuitNodeId/CircuitNodeIds/sequence/str/mapping/None): target nodes @@ -48,6 +53,11 @@ def divergence(self, source, target, by, sample=None): def convergence(self, source, target, by=None, sample=None): """`source` -> `target` convergence. + Calculate the convergence based on number of `"connections"` or `"synapses"` each `target` + cell shares with the cells specified in `source`. + * `connections`: number of unique source cells each target cell shares a connection with + * `synapses`: number of unique synapses between a target cell and its source cells + Args: source (int/CircuitNodeId/CircuitNodeIds/sequence/str/mapping/None): source nodes target (int/CircuitNodeId/CircuitNodeIds/sequence/str/mapping/None): target nodes From ec1067e7494177f747e3c32c9c7a591603a4317c Mon Sep 17 00:00:00 2001 From: Joni Herttuainen Date: Mon, 15 Jan 2024 16:38:17 +0100 Subject: [PATCH 3/3] newline before a list (for docs) --- bluepysnap/edges/edge_population_stats.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bluepysnap/edges/edge_population_stats.py b/bluepysnap/edges/edge_population_stats.py index 5d04c18c..fadd2b67 100644 --- a/bluepysnap/edges/edge_population_stats.py +++ b/bluepysnap/edges/edge_population_stats.py @@ -17,6 +17,7 @@ def divergence(self, source, target, by, sample=None): Calculate the divergence based on number of `"connections"` or `"synapses"` each `source` cell shares with the cells specified in `target`. + * `connections`: number of unique target cells each source cell shares a connection with * `synapses`: number of unique synapses between a source cell and its target cells @@ -55,6 +56,7 @@ def convergence(self, source, target, by=None, sample=None): Calculate the convergence based on number of `"connections"` or `"synapses"` each `target` cell shares with the cells specified in `source`. + * `connections`: number of unique source cells each target cell shares a connection with * `synapses`: number of unique synapses between a target cell and its source cells