Repository: bids-standard/pybids

Issue:
analysis: implement auto_contrasts
`auto_contrasts` in the BIDS model are not yet implemented in pybids. that is, that an identty contrast is automatically created for all variables (HRF_variables at the `run` level)


Discussion:

Resolving patch:
diff --git a/bids/analysis/analysis.py b/bids/analysis/analysis.py
--- a/bids/analysis/analysis.py
+++ b/bids/analysis/analysis.py
@@ -51,7 +51,7 @@ def _load_blocks(self, blocks):
             block = Block(self.layout, index=i, **block_args)
             self.blocks.append(block)
 
-    def setup(self, blocks=None, agg_func='mean', identity_contrasts=True,
+    def setup(self, blocks=None, agg_func='mean', auto_contrasts=None,
               **kwargs):
         ''' Set up the sequence of blocks for analysis.
 
@@ -69,7 +69,7 @@ def setup(self, blocks=None, agg_func='mean', identity_contrasts=True,
                 name of a function recognized by apply() in pandas, or a
                 Callable that takes a DataFrame as input and returns a Series
                 or a DataFrame. NOTE: CURRENTLY UNIMPLEMENTED.
-            identity_contrasts (bool): If True, a contrast is automatically
+            auto_contrasts (bool): If True, a contrast is automatically
                 created for each column in the design matrix.
         '''
 
@@ -86,7 +86,7 @@ def setup(self, blocks=None, agg_func='mean', identity_contrasts=True,
             if blocks is not None and i not in blocks and b.name not in blocks:
                 continue
 
-            b.setup(input_nodes, identity_contrasts, **selectors)
+            b.setup(input_nodes, auto_contrasts, **selectors)
             input_nodes = b.output_nodes
 
 
@@ -108,10 +108,14 @@ class Block(object):
             generated when the model is fit.
         input_nodes (list): Optional list of AnalysisNodes to use as input to
             this Block (typically, the output from the preceding Block).
+        auto_contrasts (bool): If True, a contrast is automatically
+            created for each column in the design matrix. This parameter is
+            over-written by the setting in setup(). Default is True.
     '''
 
     def __init__(self, layout, level, index, name=None, transformations=None,
-                model=None, contrasts=None, input_nodes=None):
+                model=None, contrasts=None, input_nodes=None,
+                auto_contrasts=True):
 
         self.layout = layout
         self.level = level
@@ -122,6 +126,7 @@ def __init__(self, layout, level, index, name=None, transformations=None,
         self.contrasts = contrasts or []
         self.input_nodes = input_nodes or []
         self.output_nodes = []
+        self.auto_contrasts = auto_contrasts
 
     def _filter_objects(self, objects, kwargs):
         # Keeps only objects that match target entities, and also removes those
@@ -158,20 +163,23 @@ def _concatenate_input_nodes(self, nodes):
         entities = pd.concat(entities, axis=1).T
         return BIDSVariableCollection.from_df(data, entities, self.level)
 
-    def setup(self, input_nodes=None, identity_contrasts=True, **kwargs):
+    def setup(self, input_nodes=None, auto_contrasts=None, **kwargs):
         ''' Set up the Block and construct the design matrix.
 
         Args:
             input_nodes (list): Optional list of Node objects produced by
                 the preceding Block in the analysis. If None, uses any inputs
                 passed in at Block initialization.
-            identity_contrasts (bool): If True, a contrast is automatically
+            auto_contrasts (bool): If True, a contrast is automatically
                 created for each column in the design matrix.
             kwargs: Optional keyword arguments to pass onto load_variables.
         '''
         self.output_nodes = []
         input_nodes = input_nodes or self.input_nodes or []
 
+        if auto_contrasts is not None:
+            self.auto_contrasts = auto_contrasts
+
         # TODO: remove the scan_length argument entirely once we switch tests
         # to use the synthetic dataset with image headers.
         if self.level != 'run':
@@ -208,7 +216,7 @@ def setup(self, input_nodes=None, identity_contrasts=True, **kwargs):
                 transform.select(coll, model['variables'])
 
             node = AnalysisNode(self.level, coll, self.contrasts, input_nodes,
-                                identity_contrasts)
+                                self.auto_contrasts)
 
             self.output_nodes.append(node)
 
@@ -286,17 +294,17 @@ class AnalysisNode(object):
         collection (BIDSVariableCollection): The BIDSVariableCollection
             containing variables at this Node.
         contrasts (list): A list of contrasts defined in the originating Block.
-        identity_contrasts (bool): If True, a contrast is automatically
+        auto_contrasts (bool): If True, a contrast is automatically
             created for each column in the design matrix.
     '''
 
     def __init__(self, level, collection, contrasts, input_nodes=None,
-                 identity_contrasts=True):
+                 auto_contrasts=True):
         self.level = level
         self.collection = collection
         self._block_contrasts = contrasts
         self.input_nodes = input_nodes
-        self.identity_contrasts = identity_contrasts
+        self.auto_contrasts = auto_contrasts
         self._contrasts = None
 
     @property
@@ -387,14 +395,22 @@ def get_contrasts(self, names=None, entities=False):
         # Construct a list of all contrasts, including identity contrasts
         contrasts = list(self._block_contrasts)
 
+        # Check that all contrasts have unique name
+        contrast_names = [c['name'] for c in contrasts]
+        if len(set(contrast_names)) < len(contrast_names):
+            raise ValueError("One or more contrasts have the same name")
+        contrast_names = list(set(contrast_names))
+
         ### Add ability to set different types of identity contrasts
-        if self.identity_contrasts:
+        if self.auto_contrasts:
             for col_name in self.collection.variables.keys():
-                contrasts.append({
-                    'name': col_name,
-                    'condition_list': [col_name],
-                    'weights': [1],
-                })
+                if col_name not in contrast_names:
+                    contrasts.append({
+                        'name': col_name,
+                        'condition_list': [col_name],
+                        'weights': [1],
+                    })
+
         # Filter on desired contrast names if passed
         if names is not None:
             contrasts = [c for c in contrasts if c['name'] in names]


Test patch:
diff --git a/bids/analysis/tests/test_analysis.py b/bids/analysis/tests/test_analysis.py
--- a/bids/analysis/tests/test_analysis.py
+++ b/bids/analysis/tests/test_analysis.py
@@ -15,6 +15,14 @@ def analysis():
     analysis.setup(scan_length=480, subject=['01', '02'])
     return analysis
 
+@pytest.fixture
+def analysis_force_auto_contrasts():
+    layout_path = join(get_test_data_path(), 'ds005')
+    layout = BIDSLayout(layout_path, exclude='derivatives/')
+    json_file = join(layout_path, 'models', 'ds-005_type-test_model.json')
+    analysis = Analysis(layout, json_file)
+    analysis.setup(scan_length=480, subject=['01', '02'], auto_contrasts=True)
+    return analysis
 
 def test_design_matrix_info(analysis):
     result = analysis['run'].get_design_matrix(subject=['01', '02', '03'])
@@ -73,7 +81,7 @@ def test_post_first_level_sparse_design_matrix(analysis):
     result = analysis['session'].get_design_matrix(entities=False)
     assert len(result) == 2
     assert len(result[0]) == 3
-    assert result[0].sparse.shape == (27, 2)
+    assert result[0].sparse.shape == (3, 2)
     assert result[0].entities == {
         'subject': '01',
         'task': 'mixedgamblestask'}
@@ -87,11 +95,11 @@ def test_post_first_level_sparse_design_matrix(analysis):
     result = analysis['group'].get_design_matrix()
     assert len(result) == 1
     data = result[0].sparse
-    assert len(data) == 22
+    assert len(data) == 6
     assert data['subject'].nunique() == 2
 
     # # Make sure columns from different levels exist
-    varset = {'sex', 'age', 'RT', 'respnum'}
+    varset = {'sex', 'age', 'RT-trial_type'}
     assert not (varset - set(data['condition'].unique()))
 
     # Calling an invalid level name should raise an exception
@@ -106,6 +114,10 @@ def test_contrast_matrix_info(analysis):
         assert isinstance(contrasts[0], ContrastMatrixInfo)
         assert c._fields == ('data', 'index', 'entities')
 
+def test_force_auto_contrasts(analysis_force_auto_contrasts):
+    contrasts = analysis_force_auto_contrasts['run'].get_contrasts(subject='01')
+    assert contrasts[0][0].shape == (8, 9)
+
 
 # def test_get_contrasts(analysis):
 #     contrasts = analysis['run'].get_contrasts(subject='01')
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
@@ -4,6 +4,7 @@
   "blocks": [
     {
       "name": "run",
+      "auto_contrasts": false,
       "level": "run",
       "transformations": [
         {
@@ -35,4 +36,4 @@
       "level": "dataset"
     }
   ]
-}
\ No newline at end of file
+}