Marker/edge centric fc #169

Merged
LeSasse merged 16 commits from marker/edge_centric_fc into main 2023-01-19 13:40:02 +00:00
10 changed files with 386 additions and 8 deletions

View file

@ -182,6 +182,16 @@ Available
- Calculate (f)ALFF and aggregate using spheres placed on coordinates
fraimondo commented 2023-01-11 12:55:44 +00:00 (Migrated from github.com)

@LeSasse Is there any related publication explaining the method?

@LeSasse Is there any related publication explaining the method?
synchon commented 2023-01-11 12:58:01 +00:00 (Migrated from github.com)

From the class' docstrings:

Jo et al. (2021)
Subject identification using edge-centric functional connectivity
doi: https://doi.org/10.1016/j.neuroimage.2021.118204
From the class' docstrings: ``` Jo et al. (2021) Subject identification using edge-centric functional connectivity doi: https://doi.org/10.1016/j.neuroimage.2021.118204 ```
fraimondo commented 2023-01-11 13:25:24 +00:00 (Migrated from github.com)

Can you add this to the docs?

Can you add this to the docs?
synchon commented 2023-01-11 13:26:19 +00:00 (Migrated from github.com)

Like you mean in the builtin.rst?

Like you mean in the `builtin.rst`?
LeSasse commented 2023-01-11 13:27:23 +00:00 (Migrated from github.com)

we can also add this one as it was published a bit earlier (by the same group): https://www.nature.com/articles/s41593-020-00719-y but the 2021 one was the one i used as a reference for implementation

we can also add this one as it was published a bit earlier (by the same group): https://www.nature.com/articles/s41593-020-00719-y but the 2021 one was the one i used as a reference for implementation
fraimondo commented 2023-01-11 13:29:38 +00:00 (Migrated from github.com)

Like you mean in the builtin.rst?

yes

The idea is that people looking at markers list can know what they are.

> Like you mean in the `builtin.rst`? yes The idea is that people looking at markers list can know what they are.
LeSasse commented 2023-01-11 13:30:17 +00:00 (Migrated from github.com)

this paper is also on topic but by a different group and more of a reaction to the eFC method, but may be an interesting read for users: https://www.nature.com/articles/s41467-022-29775-7

this paper is also on topic but by a different group and more of a reaction to the eFC method, but may be an interesting read for users: https://www.nature.com/articles/s41467-022-29775-7
synchon commented 2023-01-11 13:45:06 +00:00 (Migrated from github.com)

Like you mean in the builtin.rst?

yes

The idea is that people looking at markers list can know what they are.

Which column should it go in then?

> > Like you mean in the `builtin.rst`? > > yes > > The idea is that people looking at markers list can know what they are. Which column should it go in then?
fraimondo commented 2023-01-11 15:11:33 +00:00 (Migrated from github.com)

In the description.

In the description.
synchon commented 2023-01-12 09:58:40 +00:00 (Migrated from github.com)

I have added the reference.

I have added the reference.
- Done
- 0.0.1
* - :class:`junifer.markers.EdgeCentricFCParcels`
- Calculate edge-centric functional connectivity over parcellation, as found in
`Jo et al. (2021) <https://doi.org/10.1016/j.neuroimage.2021.118204>`_
- Done
- 0.0.2
* - :class:`junifer.markers.EdgeCentricFCSpheres`
- Calculate edge-centric functional connectivity over spheres placed on coordinates,
as found in `Jo et al. (2021) <https://doi.org/10.1016/j.neuroimage.2021.118204>`_
- Done
- 0.0.2
Planned
~~~~~~~
@ -199,10 +209,6 @@ Planned
* - Permutation entropy, Range entropy, Multiscale entropy and Hurst exponent
- Calculate Permutation entropy, Range entropy, Multiscale entropy and Hurst exponent
- :gh:`61`
* - EdgeCentricFC
- Calculate edge-centric functional connectivity
- :gh:`64`
Parcellations
-------------

View file

@ -32,6 +32,9 @@ Enhancements
- Organize functional connectivity markers in ``junifer.markers.functional_connectivity``
(:gh:`107` by `Synchon Mandal`_).
- Add :class:`junifer.markers.EdgeCentricFCParcels` and :class:`junifer.markers.EdgeCentricFCSpheres`
(:gh:`64` by `Leonard Sasse`_).
Bugs
~~~~

View file

@ -14,6 +14,8 @@ from .functional_connectivity import (
FunctionalConnectivityParcels,
FunctionalConnectivitySpheres,
CrossParcellationFC,
EdgeCentricFCParcels,
EdgeCentricFCSpheres,
)
from .reho import ReHoParcels, ReHoSpheres
from .falff import (

View file

@ -6,3 +6,5 @@
from .functional_connectivity_parcels import FunctionalConnectivityParcels
from .functional_connectivity_spheres import FunctionalConnectivitySpheres
from .crossparcellation_functional_connectivity import CrossParcellationFC
from .edge_functional_connectivity_parcels import EdgeCentricFCParcels
from .edge_functional_connectivity_spheres import EdgeCentricFCSpheres

View file

@ -0,0 +1,90 @@
"""Provide class for edge-centric functional connectivity using parcels."""
# Authors: Leonard Sasse <l.sasse@fz-juelich.de>
# Synchon Mandal <s.mandal@fz-juelich.de>
# License: AGPL
from typing import Any, Dict, List, Optional, Union
from ...api.decorators import register_marker
from ..parcel_aggregation import ParcelAggregation
from ..utils import _ets
from .functional_connectivity_base import FunctionalConnectivityBase
@register_marker
class EdgeCentricFCParcels(FunctionalConnectivityBase):
"""Class for edge-centric FC using parcellations.
Parameters
----------
parcellation : str or list of str
The name(s) of the parcellation(s). Check valid options by calling
:func:`junifer.data.parcellations.list_parcellations`.
agg_method : str, optional
The method to perform aggregation of BOLD time series.
Check valid options in :func:`junifer.stats.get_aggfunc_by_name`
(default "mean").
agg_method_params : dict, optional
Parameters to pass to the aggregation function. Check valid options in
:func:`junifer.stats.get_aggfunc_by_name` (default None).
cor_method : str, optional
The method to perform correlation. Check valid options in
:class:`nilearn.connectome.ConnectivityMeasure`
(default "covariance").
cor_method_params : dict, optional
Parameters to pass to the correlation function. Check valid options in
:class:`nilearn.connectome.ConnectivityMeasure` (default None).
mask : str, optional
The name of the mask to apply to regions before extracting signals.
Check valid options by calling :func:`junifer.data.masks.list_masks`
(default None).
name : str, optional
The name of the marker. If None, will use the class name (default
None).
References
----------
.. [1] Jo et al. (2021)
Subject identification using
edge-centric functional connectivity
doi: https://doi.org/10.1016/j.neuroimage.2021.118204
"""
def __init__(
self,
parcellation: Union[str, List[str]],
agg_method: str = "mean",
agg_method_params: Optional[Dict] = None,
cor_method: str = "covariance",
cor_method_params: Optional[Dict] = None,
mask: Optional[str] = None,
name: Optional[str] = None,
) -> None:
self.parcellation = parcellation
super().__init__(
agg_method=agg_method,
agg_method_params=agg_method_params,
cor_method=cor_method,
cor_method_params=cor_method_params,
mask=mask,
name=name,
)
def aggregate(self, input: Dict[str, Any]) -> Dict:
"""Perform parcel aggregation."""
parcel_aggregation = ParcelAggregation(
parcellation=self.parcellation,
method=self.agg_method,
method_params=self.agg_method_params,
mask=self.mask,
on="BOLD",
)
bold_aggregated = parcel_aggregation.compute(input)
ets, edge_names = _ets(
bold_aggregated["data"], bold_aggregated["columns"]
)
return dict(data=ets, columns=edge_names)

View file

@ -0,0 +1,97 @@
"""Provide class for edge-centric functional connectivity using spheres."""
# Authors: Leonard Sasse <l.sasse@fz-juelich.de>
# Synchon Mandal <s.mandal@fz-juelich.de>
# License: AGPL
from typing import Any, Dict, Optional
from ...api.decorators import register_marker
from ..sphere_aggregation import SphereAggregation
from ..utils import _ets, raise_error
from .functional_connectivity_base import FunctionalConnectivityBase
@register_marker
class EdgeCentricFCSpheres(FunctionalConnectivityBase):
"""Class for edge-centric FC using coordinates (spheres).
Parameters
----------
coords : str
The name of the coordinates list to use. See
:func:`junifer.data.coordinates.list_coordinates` for options.
radius : float, optional
The radius of the sphere in mm. If None, the signal will be extracted
from a single voxel. See :class:`nilearn.maskers.NiftiSpheresMasker`
for more information (default None).
agg_method : str, optional
The aggregation method to use.
See :func:`junifer.stats.get_aggfunc_by_name` for more information
(default None).
agg_method_params : dict, optional
The parameters to pass to the aggregation method (default None).
cor_method : str, optional
The method to perform correlation using. Check valid options in
:class:`nilearn.connectome.ConnectivityMeasure` (default "covariance").
cor_method_params : dict, optional
Parameters to pass to the correlation function. Check valid options in
:class:`nilearn.connectome.ConnectivityMeasure` (default None).
mask : str, optional
The name of the mask to apply to regions before extracting signals.
Check valid options by calling :func:`junifer.data.masks.list_masks`
(default None).
name : str, optional
The name of the marker. By default, it will use
KIND_EdgeCentricFCSpheres where KIND is the kind of data it
was applied to (default None).
References
----------
.. [1] Jo et al. (2021)
Subject identification using
edge-centric functional connectivity
doi: https://doi.org/10.1016/j.neuroimage.2021.118204
"""
def __init__(
self,
coords: str,
radius: Optional[float] = None,
agg_method: str = "mean",
agg_method_params: Optional[Dict] = None,
cor_method: str = "covariance",
cor_method_params: Optional[Dict] = None,
mask: Optional[str] = None,
name: Optional[str] = None,
) -> None:
self.coords = coords
self.radius = radius
if radius is None or radius <= 0:
fraimondo commented 2023-01-11 08:58:57 +00:00 (Migrated from github.com)

Do we really need > 0? In nilearn terms (same as junifer), radius=0 means "single voxel".

Do we really need > 0? In nilearn terms (same as junifer), radius=0 means "single voxel".
synchon commented 2023-01-11 09:45:04 +00:00 (Migrated from github.com)

From nilearn's docs, radius = None extracts signal from single voxel, not sure about 0 though. I think the condition can be improved in the sense that, radius is None is valid so it can be removed. This was copied from FunctionalConnectivitySpheres, so need to update that as well. I can open a new PR to address this for both.

From nilearn's docs, `radius = None` extracts signal from single voxel, not sure about 0 though. I think the condition can be improved in the sense that, `radius is None` is valid so it can be removed. This was copied from `FunctionalConnectivitySpheres`, so need to update that as well. I can open a new PR to address this for both.
fraimondo commented 2023-01-11 12:56:59 +00:00 (Migrated from github.com)

Yes please. The SphereAggregator takes care of that error. There's no reason to check it in other markers with a different condition (unless it invalidates the marker)

Yes please. The SphereAggregator takes care of that error. There's no reason to check it in other markers with a different condition (unless it invalidates the marker)
raise_error(f"radius should be > 0: provided {radius}")
super().__init__(
agg_method=agg_method,
agg_method_params=agg_method_params,
cor_method=cor_method,
cor_method_params=cor_method_params,
mask=mask,
name=name,
)
def aggregate(self, input: Dict[str, Any]) -> Dict:
"""Perform sphere aggregation."""
sphere_aggregation = SphereAggregation(
coords=self.coords,
radius=self.radius,
method=self.agg_method,
method_params=self.agg_method_params,
mask=self.mask,
on="BOLD",
)
bold_aggregated = sphere_aggregation.compute(input)
ets, edge_names = _ets(
bold_aggregated["data"], bold_aggregated["columns"]
)
return dict(data=ets, columns=edge_names)

View file

@ -0,0 +1,60 @@
"""Provide tests for edge-centric functional connectivity using parcels."""
# Authors: Leonard Sasse <l.sasse@fz-juelich.de>
# Synchon Mandal <s.mandal@fz-juelich.de>
# License: AGPL
from pathlib import Path
from nilearn import datasets, image
from junifer.markers.functional_connectivity import EdgeCentricFCParcels
from junifer.storage import SQLiteFeatureStorage
def test_EdgeCentricFCParcels(tmp_path: Path) -> None:
"""Test EdgeCentricFCParcels.
Parameters
----------
tmp_path : pathlib.Path
The path to the test directory.
"""
# get a dataset
ni_data = datasets.fetch_spm_auditory(subject_id="sub001")
fmri_img = image.concat_imgs(ni_data.func) # type: ignore
# Check empirical correlation method parameters
efc = EdgeCentricFCParcels(
parcellation="TianxS1x3TxMNInonlinear2009cAsym",
cor_method_params={"empirical": True},
)
all_out = efc.fit_transform({"BOLD": {"data": fmri_img, "meta": {}}})
out = all_out["BOLD"]
# for 16 ROIs we should get (16 * (16 -1) / 2) edges in the ETS
n_edges = int(16 * (16 - 1) / 2)
assert "data" in out
assert "row_names" in out
assert "col_names" in out
assert out["data"].shape[0] == n_edges
assert out["data"].shape[1] == n_edges
assert len(set(out["row_names"])) == n_edges
assert len(set(out["col_names"])) == n_edges
# check correct output
assert efc.get_output_type("BOLD") == "matrix"
uri = tmp_path / "test_fc_parcellation.sqlite"
# Single storage, must be the uri
storage = SQLiteFeatureStorage(uri=uri, upsert="ignore")
meta = {"element": {"subject": "test"}, "dependencies": {"numpy"}}
input = {"BOLD": {"data": fmri_img, "meta": meta}}
all_out = efc.fit_transform(input, storage=storage)
features = storage.list_features()
assert any(
x["name"] == "BOLD_EdgeCentricFCParcels" for x in features.values()
)

View file

@ -0,0 +1,73 @@
"""Provide tests for edge-centric functional connectivity using spheres."""
# Authors: Leonard Sasse <l.sasse@fz-juelich.de>
# Synchon Mandal <s.mandal@fz-juelich.de>
# License: AGPL
from pathlib import Path
from nilearn import datasets, image
from junifer.markers.functional_connectivity import EdgeCentricFCSpheres
from junifer.storage import SQLiteFeatureStorage
def test_EdgeCentricFCSpheres(tmp_path: Path) -> None:
"""Test EdgeCentricFCSpheres.
Parameters
----------
tmp_path : pathlib.Path
The path to the test directory.
"""
# get a dataset
ni_data = datasets.fetch_spm_auditory(subject_id="sub001")
fmri_img = image.concat_imgs(ni_data.func) # type: ignore
efc = EdgeCentricFCSpheres(
coords="DMNBuckner", radius=5.0, cor_method="correlation"
)
all_out = efc.fit_transform({"BOLD": {"data": fmri_img, "meta": {}}})
out = all_out["BOLD"]
# There are six DMNBuckner coordinates, so
# for 6 ROIs we should get (6 * (6 -1) / 2) edges in the ETS
n_edges = int(6 * (6 - 1) / 2)
assert "data" in out
assert "row_names" in out
assert "col_names" in out
assert out["data"].shape[0] == n_edges
assert out["data"].shape[1] == n_edges
assert len(set(out["row_names"])) == n_edges
assert len(set(out["col_names"])) == n_edges
# check correct output
assert efc.get_output_type("BOLD") == "matrix"
# Check empirical correlation method parameters
efc = EdgeCentricFCSpheres(
coords="DMNBuckner",
radius=5.0,
cor_method="correlation",
cor_method_params={"empirical": True},
)
meta = {
"element": {"subject": "sub001"},
"dependencies": {"nilearn"},
}
all_out = efc.fit_transform({"BOLD": {"data": fmri_img, "meta": meta}})
uri = tmp_path / "test_fc_parcellation.sqlite"
# Single storage, must be the uri
storage = SQLiteFeatureStorage(uri=uri, upsert="ignore")
meta = {"element": {"subject": "test"}, "dependencies": {"numpy"}}
input = {"BOLD": {"data": fmri_img, "meta": meta}}
all_out = efc.fit_transform(input, storage=storage)
features = storage.list_features()
assert any(
x["name"] == "BOLD_EdgeCentricFCSpheres" for x in features.values()
)

View file

@ -7,6 +7,7 @@
# License: AGPL
import numpy as np
import pytest
from junifer.markers.utils import _ets
@ -25,5 +26,24 @@ def test_ets() -> None:
n_time, n_rois = bold_ts.shape
n_edges = int(n_rois * (n_rois - 1) / 2)
# test without labels
edge_ts = _ets(bold_ts)
assert edge_ts.shape == (n_time, n_edges)
# test with labels
roi_labels = [f"Label_{x}" for x in range(n_rois)]
edge_ts, edge_labels = _ets(bold_ts, roi_labels)
assert edge_ts.shape == (n_time, n_edges)
assert len(edge_labels) == n_edges
def test_ets_incorrect_label_list() -> None:
"""Test edge-wise timeseries computing function with incorrect labels."""
bold_ts = np.arange(30).reshape(10, 3)
# one label is missing, the bold time series suggests three ROIs
roi_labels = ["label 1", "label 2"]
with pytest.raises(
ValueError, match="List of roi names does not correspond"
):
_ets(bold_ts, roi_labels)

View file

@ -7,7 +7,7 @@
# Federico Raimondo <f.raimondo@fz-juelich.de>
# License: AGPL
from typing import Any, Callable, Dict, Type, Union
from typing import Any, Callable, Dict, List, Type, Union
import numpy as np
import pandas as pd
@ -55,7 +55,9 @@ def singleton(cls: Type) -> Type:
return get_instance
def _ets(bold_ts: np.ndarray) -> np.ndarray:
def _ets(
bold_ts: np.ndarray, roi_names: Union[None, List[str]] = None
) -> np.ndarray:
"""Compute the edge-wise time series based on BOLD time series.
Take a timeseries of brain areas, and calculate timeseries for each
@ -66,12 +68,21 @@ def _ets(bold_ts: np.ndarray) -> np.ndarray:
----------
bold_ts : np.ndarray
BOLD time series (time x ROIs)
roi_names : List[str] or None
List containing the names of the ROIs.
Order of the ROI names should correspond to order of the columns
in bold_ts. If None (default), only the edge-wise time series are
returned, without corresponding edge labels.
Returns
-------
np.ndarray
ets : np.ndarray
edge-wise time series, i.e. estimate of functional connectivity at each
time point.
edge_names : List[str]
List of edge names corresponding to columns in the
edge-wise time series. This is only returned if the roi_names
are specified.
References
----------
@ -88,7 +99,21 @@ def _ets(bold_ts: np.ndarray) -> np.ndarray:
# indices of unique edges (lower triangle)
u, v = np.tril_indices(n_roi, k=-1)
# Compute the ETS
return timeseries[:, u] * timeseries[:, v]
ets = timeseries[:, u] * timeseries[:, v]
# Obtain the corresponding edge labels if specified else return
if roi_names is None:
return ets
else:
if len(roi_names) != n_roi:
raise_error(
"List of roi names does not correspond "
"to the number of ROIs in the timeseries!"
)
roi_names = np.array(roi_names)
edge_names = [
"~".join([x, y]) for x, y in zip(roi_names[u], roi_names[v])
]
return ets, list(edge_names)
def _correlate_dataframes(