Repository: bids-standard/pybids

Issue:
Grouping by Dataset level variables not possible
In #892 , we discovered that in the example model, it is not possible to `GroupBy: ['sex']`

It looks like the issue is that it is a "special"  behavior to load participants.tsv and match them to existing inputs as entities. Looks like this has not been implemented yet:

![image](https://user-images.githubusercontent.com/2774448/188969418-72326fd6-8de8-4e20-98a1-2a511ad3c348.png)

The variable are available for modeling, but not as grouping variables.



Discussion:

Resolving patch:
diff --git a/bids/modeling/statsmodels.py b/bids/modeling/statsmodels.py
--- a/bids/modeling/statsmodels.py
+++ b/bids/modeling/statsmodels.py
@@ -343,6 +343,13 @@ def _build_groups(objects, group_by):
         entities = [obj.entities for obj in objects]
         df = pd.DataFrame.from_records(entities)
 
+        # Separate BIDSVariableCollections
+        collections = [obj.to_df() for obj in objects if type(obj) == BIDSVariableCollection]
+        if collections:
+            metadata_vars = reduce(pd.DataFrame.merge, collections)
+            on_vars = list({'subject', 'session', 'run'} & set(metadata_vars.columns))
+            df = df.merge(metadata_vars, how='left', on=on_vars)
+
         # Single-run tasks and single-session subjects may not have entities
         dummy_groups = {"run", "session"} - set(df.columns)
         group_by = set(group_by) - dummy_groups
diff --git a/bids/variables/io.py b/bids/variables/io.py
--- a/bids/variables/io.py
+++ b/bids/variables/io.py
@@ -16,7 +16,8 @@
 
 
 def load_variables(layout, types=None, levels=None, skip_empty=True,
-                   dataset=None, scope='all', **kwargs):
+                   dataset=None, scope='all', regex_search=None,
+                   **kwargs):
     """A convenience wrapper for one or more load_*_variables() calls.
 
     Parameters
@@ -71,7 +72,7 @@ def load_variables(layout, types=None, levels=None, skip_empty=True,
             lev_map = {
                 'run': ['events', 'physio', 'stim', 'regressors'],
                 'session': ['scans'],
-                'subject': ['sessions'],
+                'subject': ['sessions', 'scans'],
                 'dataset': ['participants']
             }
             [types.extend(lev_map[l.lower()]) for l in listify(levels)]
@@ -94,7 +95,7 @@ def load_variables(layout, types=None, levels=None, skip_empty=True,
     for t in ({'scans', 'sessions', 'participants'} & set(types)):
         kwargs.pop('suffix', None) # suffix is always one of values above
         dataset = _load_tsv_variables(layout, t, dataset, scope=scope,
-                                      **kwargs)
+                                      regex_search=regex_search, **kwargs)
 
     return dataset
 
@@ -376,7 +377,8 @@ def _load_time_variables(layout, dataset=None, columns=None, scan_length=None,
 
 
 def _load_tsv_variables(layout, suffix, dataset=None, columns=None,
-                        prepend_type=False, scope='all', **selectors):
+                        prepend_type=False, scope='all', regex_search=None,
+                        **selectors):
     """Reads variables from scans.tsv, sessions.tsv, and participants.tsv.
 
     Parameters
@@ -407,6 +409,8 @@ def _load_tsv_variables(layout, suffix, dataset=None, columns=None,
     -------
     A NodeIndex instance.
     """
+    if regex_search is None:
+        regex_search = layout.regex_search
 
     # Sanitize the selectors: only keep entities at current level or above
     valid_entities_map = {
@@ -483,11 +487,12 @@ def make_patt(x, regex_search=False):
         # Filter rows on all selectors
         comm_cols = list(set(_data.columns) & set(selectors.keys()))
         for col in comm_cols:
-            ent_patts = [make_patt(x, regex_search=layout.regex_search)
-                            for x in listify(selectors.get(col))]
-            patt = '|'.join(ent_patts)
-
-            _data = _data[_data[col].str.contains(patt)]
+            vals = listify(selectors.get(col))
+            if regex_search and any(isinstance(val, str) for val in vals):
+                patt = '|'.join(make_patt(x, regex_search=True) for x in vals)
+                _data = _data[_data[col].str.contains(patt)]
+            else:
+                _data = _data[_data[col].isin(vals)]
 
         level = {'scans': 'session', 'sessions': 'subject',
                  'participants': 'dataset'}[suffix]


Test patch:
diff --git a/bids/modeling/tests/test_statsmodels.py b/bids/modeling/tests/test_statsmodels.py
--- a/bids/modeling/tests/test_statsmodels.py
+++ b/bids/modeling/tests/test_statsmodels.py
@@ -223,13 +223,13 @@ def test_entire_graph_smoketest(graph):
     # BY-GROUP NODE
     outputs = graph["by-group"].run(inputs)
     # 3 contrasts
-    assert len(outputs) == 3
+    assert len(outputs) == 6
     cis = list(chain(*[op.contrasts for op in outputs]))
     # two groups x 3 contrasts
-    assert len(cis) == 3
+    assert len(cis) == 6
     model_spec = outputs[0].model_spec
     assert model_spec.__class__.__name__ == "GLMMSpec"
-    assert model_spec.X.shape == (2, 1)
+    assert model_spec.X.shape == (1, 1)
     assert model_spec.Z is None
     assert not set(model_spec.terms.keys()) - {"intercept"}
 
@@ -246,7 +246,7 @@ def test_entire_graph_smoketest(graph):
 
     contrast = outputs[0].contrasts[0]
     assert contrast.name == 'gain'
-    
+
 
 def test_expand_wildcards():
     # No wildcards == no modification
diff --git a/bids/tests/data/ds005/models/ds-005_type-test_model.json b/bids/tests/data/ds005/models/ds-005_type-test_model.json
--- a/bids/tests/data/ds005/models/ds-005_type-test_model.json
+++ b/bids/tests/data/ds005/models/ds-005_type-test_model.json
@@ -59,7 +59,8 @@
             "Name": "by-group",
             "Level": "Dataset",
             "GroupBy": [
-                "contrast"
+                "contrast",
+                "sex"
             ],
             "Model": {
                 "X": [
diff --git a/bids/tests/data/ds005/sub-01/sub-01_scans.tsv b/bids/tests/data/ds005/sub-01/sub-01_scans.tsv
new file mode 100644
--- /dev/null
+++ b/bids/tests/data/ds005/sub-01/sub-01_scans.tsv
@@ -0,0 +1,6 @@
+filename	acq_time	meanRT
+anat/sub-01_T1w.nii.gz	1880-01-10T05:17:54	n/a
+dwi/sub-01_dwi.nii.gz	1880-01-10T05:18:54	n/a
+func/sub-01_task-mixedgamblestask_run-01_bold.nii.gz	1880-01-10T05:22:54	0.4
+func/sub-01_task-mixedgamblestask_run-02_bold.nii.gz	1880-01-10T05:37:54	0.5
+func/sub-01_task-mixedgamblestask_run-03_bold.nii.gz	1880-01-10T05:52:54	0.3
diff --git a/bids/tests/data/ds005/sub-02/sub-02_scans.tsv b/bids/tests/data/ds005/sub-02/sub-02_scans.tsv
new file mode 100644
--- /dev/null
+++ b/bids/tests/data/ds005/sub-02/sub-02_scans.tsv
@@ -0,0 +1,6 @@
+filename	acq_time	meanRT
+anat/sub-02_T1w.nii.gz	1880-01-10T05:17:54	n/a
+dwi/sub-02_dwi.nii.gz	1880-01-10T05:18:54	n/a
+func/sub-02_task-mixedgamblestask_run-01_bold.nii.gz	1880-01-10T05:22:54	0.5
+func/sub-02_task-mixedgamblestask_run-02_bold.nii.gz	1880-01-10T05:37:54	0.3
+func/sub-02_task-mixedgamblestask_run-03_bold.nii.gz	1880-01-10T05:52:54	0.4
diff --git a/bids/variables/tests/test_entities.py b/bids/variables/tests/test_entities.py
--- a/bids/variables/tests/test_entities.py
+++ b/bids/variables/tests/test_entities.py
@@ -54,8 +54,10 @@ def test_get_or_create_node(layout1):
 
 def test_get_nodes(layout1):
     index = load_variables(layout1, scan_length=480)
+    # scans.tsv
     nodes = index.get_nodes('session')
-    assert len(nodes) == 0
+    assert len(nodes) == 2
+    # participants.tsv
     nodes = index.get_nodes('dataset')
     assert len(nodes) == 1
     assert all([isinstance(n, Node) for n in nodes])
diff --git a/bids/variables/tests/test_io.py b/bids/variables/tests/test_io.py
--- a/bids/variables/tests/test_io.py
+++ b/bids/variables/tests/test_io.py
@@ -56,7 +56,7 @@ def test_load_participants(layout1):
     assert age.index.shape == (16, 2)
     assert age.values.shape == (16,)
 
-    index = load_variables(layout1, types='participants', subject=['^1.*'])
+    index = load_variables(layout1, types='participants', subject=['^1.*'], regex_search=True)
     age = index.get_nodes(level='dataset')[0].variables['age']
     assert age.index.shape == (7, 2)
     assert age.values.shape == (7,)