Repository: mne-tools/mne-python

Issue:
make it easier to specify adjacency in permutation_cluster_test
I tried to do a time-frequency permutation_cluster_test over 126 channels for different observations and for two groups.
So I have 4D data (observations, times, frequencies, channels) for two groups of users but it didn't work. 

```py
adjacency = sparse.csr_matrix(np.zeros((126,126)))
sample = np.zeros((4,100,6,126))

print(sample.shape)
print(adjacency.shape)

F_obs, clusters, cluster_pv, H0 = mne.stats.permutation_cluster_test(X=[sample, sample], adjacency=adjacency)
```

the error raised is 

> ValueError: adjacency (len 126) must be of the correct size, i.e. be equal to or evenly divide the number of tests (75600). If adjacency was computed for a source space, try using the fwd["src"] or inv["src"] as some original source space vertices can be excluded during forward computation

at line 606 of "mne/stats/cluster_level.py" file.
(and 75600 is evenly dividable by 126, but "got_times" is different from "n_times" for the dimension more)

It seems like at most there can be three dimensions and not four.
I thought that with this version it was possible to do it.

Is it not?

Best,
Giovanni

_Originally posted by @GiovanniGr in https://github.com/mne-tools/mne-python/issues/7915#issuecomment-829705335_


Discussion:
The problem here is that the adjacency for your data needs to have dimensions `(126*6) x (126*6)`.  After a conversation with @larsoner we're thinking the best fix would be to provide a helper function that would contstruct the larger adjacency matrix for you, by allowing you to easily specify sensible parameters like "no adjacency between frequency bins", "standard lattice connectivity along the time axis", and "here is a sparse adjacency matrix for the channels axis" and have it construct the larger adjacency matrix for you.
actually @GiovanniGr it turns out we already have this function: `mne.stats.combine_adjacency` that will do what you need:

This works for me:

```py
import numpy as np
from scipy import sparse
import mne

freq_adjacency = sparse.csr_matrix(np.zeros((6, 6)))
chan_adjacency = sparse.csr_matrix(np.ones((126, 126)))
adjacency = mne.stats.combine_adjacency(freq_adjacency, chan_adjacency)

sample = np.zeros((4,100,6,126))

print(sample.shape)
print(adjacency.shape)

F_obs, clusters, cluster_pv, H0 = mne.stats.permutation_cluster_test(X=[sample, sample], adjacency=adjacency)
```

It can also create regular lattice adjacency along a particular dimension, by passing an `int` giving the size of that dimension.

Resolving patch:
diff --git a/mne/stats/_adjacency.py b/mne/stats/_adjacency.py
--- a/mne/stats/_adjacency.py
+++ b/mne/stats/_adjacency.py
@@ -28,6 +28,25 @@ def combine_adjacency(*structure):
     -------
     adjacency : scipy.sparse.coo_matrix, shape (n_features, n_features)
         The adjacency matrix.
+
+    Notes
+    -----
+    For 4-dimensional data with shape ``(n_obs, n_times, n_freqs, n_chans)``,
+    you can specify **no** connections among elements in a particular
+    dimension by passing a matrix of zeros. For example:
+
+    >>> import numpy as np
+    >>> from scipy.sparse import diags
+    >>> from mne.stats import combine_adjacency
+    >>> n_times, n_freqs, n_chans = (50, 7, 16)
+    >>> chan_adj = diags([1., 1.], offsets=(-1, 1), shape=(n_chans, n_chans))
+    >>> combine_adjacency(
+    ...     n_times,  # regular lattice adjacency for times
+    ...     np.zeros((n_freqs, n_freqs)),  # no adjacency between freq. bins
+    ...     chan_adj,  # custom matrix, or use mne.channels.find_ch_adjacency
+    ...     )  # doctest: +NORMALIZE_WHITESPACE
+    <5600x5600 sparse matrix of type '<class 'numpy.float64'>'
+            with 27076 stored elements in COOrdinate format>
     """
     from scipy import sparse
     structure = list(structure)
@@ -35,15 +54,11 @@ def combine_adjacency(*structure):
         name = f'structure[{di}]'
         _validate_type(dim, ('int-like', np.ndarray, sparse.spmatrix), name)
         if isinstance(dim, int_like):
-            dim = int(dim)
-            # Don't add the diagonal, because we explicitly remove it later:
-            # dim = sparse.eye(dim, format='coo')
-            # dim += sparse.eye(dim.shape[0], k=1, format='coo')
-            # dim += sparse.eye(dim.shape[0], k=-1, format='coo')
-            ii, jj = np.arange(0, dim - 1), np.arange(1, dim)
-            edges = np.vstack([np.hstack([ii, jj]), np.hstack([jj, ii])])
-            dim = sparse.coo_matrix(
-                (np.ones(edges.shape[1]), edges), (dim, dim), float)
+            # Don't add the diagonal, because we explicitly remove it later
+            dim = sparse.diags([1, 1],
+                               offsets=(-1, 1),
+                               shape=(int(dim), int(dim)),
+                               dtype=float).tocoo()
         else:
             _check_option(f'{name}.ndim', dim.ndim, [2])
             if dim.shape[0] != dim.shape[1]:
diff --git a/mne/utils/docs.py b/mne/utils/docs.py
--- a/mne/utils/docs.py
+++ b/mne/utils/docs.py
@@ -1782,7 +1782,7 @@
 stat_fun : callable | None
     Function called to calculate the test statistic. Must accept 1D-array as
     input and return a 1D array. If ``None`` (the default), uses
-    :func:`mne.stats.{}`.
+    `mne.stats.{}`.
 """
 docdict['clust_stat_f'] = docdict['clust_stat'].format('f_oneway')
 docdict['clust_stat_t'] = docdict['clust_stat'].format('ttest_1samp_no_p')
@@ -1803,10 +1803,11 @@
        'a square matrix with dimension ``{x}.shape[-1]`` (n_vertices) to save '
        'memory and computation, and to use ``max_step`` to define the extent '
        'of temporal adjacency to consider when clustering.')
+comb = ' The function `mne.stats.combine_adjacency` may be useful for 4D data.'
 st = dict(sp='spatial', lastdim='', parone='(n_vertices)',
           partwo='(n_times * n_vertices)', memory=mem)
 tf = dict(sp='', lastdim=' (or the last two dimensions if ``{x}`` is 2D)',
-          parone='', partwo='', memory='')
+          parone='(for 3D data)', partwo='(for 4D data)', memory=comb)
 nogroups = dict(eachgrp='', x='X')
 groups = dict(eachgrp='each group ', x='X[k]')
 docdict['clust_adj_st1'] = docdict['clust_adj'].format(**st).format(**nogroups)


Test patch: