Repository: bids-standard/pybids

Issue:
handling kwargs in loopable Transformations?
AFAICT the following is a valid Transformation:
```
{
            "Name": "Assign",
            "Input": ["response_time", "response_time", "response_time"],
            "Target": ["pumps", "control_pumps", "cash"],
            "Output": ["pumps_rt", "control_pumps_rt", "cash_rt"],
            "InputAttr": ["value", "value", "value"],
            "TargetAttr": ["duration", "duration", "duration"]
}
```

according to [the transformer specification](https://docs.google.com/document/d/1uxN6vPWbC7ciAx2XWtT5Y-lBrdckZKpPdNUNpwRxHoU/edit#) as long as "Target", "Output", "InputAttr", and "TargetAttr" are the same length, then the transformer should perform a one-to-one mapping of variables, instead I get the following traceback:

```
Traceback (most recent call last):
  File "/opt/miniconda-latest/envs/neuro/lib/python3.9/site-packages/nipype/interfaces/base/core.py", line 398, in run
    runtime = self._run_interface(runtime)
  File "/opt/miniconda-latest/envs/neuro/lib/python3.9/site-packages/fitlins/interfaces/bids.py", line 250, in _run_interface
    self._results['all_specs'] = self._load_graph(runtime, graph)
  File "/opt/miniconda-latest/envs/neuro/lib/python3.9/site-packages/fitlins/interfaces/bids.py", line 258, in _load_graph
    specs = node.run(inputs, group_by=node.group_by, **filters)
  File "/opt/miniconda-latest/envs/neuro/lib/python3.9/site-packages/bids/modeling/statsmodels.py", line 464, in run
    node_output = BIDSStatsModelsNodeOutput(
  File "/opt/miniconda-latest/envs/neuro/lib/python3.9/site-packages/bids/modeling/statsmodels.py", line 581, in __init__
    dfs = self._collections_to_dfs(collections)
  File "/opt/miniconda-latest/envs/neuro/lib/python3.9/site-packages/bids/modeling/statsmodels.py", line 657, in _collections_to_dfs
    coll = transformer.transform(coll.clone(), transformations['instructions'])
  File "/opt/miniconda-latest/envs/neuro/lib/python3.9/site-packages/bids/modeling/transformations/base.py", line 465, in transform
    func(collection, cols, **kwargs)
  File "/opt/miniconda-latest/envs/neuro/lib/python3.9/site-packages/bids/modeling/transformations/base.py", line 97, in __new__
    return t.transform()
  File "/opt/miniconda-latest/envs/neuro/lib/python3.9/site-packages/bids/modeling/transformations/base.py", line 271, in transform
    result = self._transform(data[i], **kwargs)
  File "/opt/miniconda-latest/envs/neuro/lib/python3.9/site-packages/bids/modeling/transformations/munge.py", line 39, in _transform
    target = self.collection.variables[target].clone()
TypeError: unhashable type: 'list'
```


Discussion:

Resolving patch:
diff --git a/bids/modeling/transformations/base.py b/bids/modeling/transformations/base.py
--- a/bids/modeling/transformations/base.py
+++ b/bids/modeling/transformations/base.py
@@ -91,6 +91,11 @@ class Transformation(metaclass=ABCMeta):
     # be passed through as-is even if categorical.
     _allow_categorical = None
 
+    # Boolean indicating whether to treat each key word argument as a one-to-one
+    # mapping with each variable or to treat the key word argument as applying to
+    # every input variable.
+    _sync_kwargs = True
+
     def __new__(cls, collection, variables, *args, **kwargs):
         t = super(Transformation, cls).__new__(cls)
         t._setup(collection, variables, *args, **kwargs)
@@ -117,7 +122,11 @@ def _setup(self, collection, variables, *args, **kwargs):
                 # 'variables'
                 kwargs[arg_spec.args[2 + i]] = arg_val
 
-        self.kwargs = kwargs
+        # listify kwargs if synced
+        if self._sync_kwargs:
+            self.kwargs = {k: listify(v) for k, v in kwargs.items()}
+        else:
+            self.kwargs = kwargs
 
         # Expand any detected variable group names or wild cards
         self._expand_variable_groups()
@@ -255,20 +264,22 @@ def select_type(col):
         if not self._loopable:
             variables = [variables]
 
+        i_kwargs = kwargs
         for i, col in enumerate(variables):
-
+            if self._sync_kwargs:
+                i_kwargs = {k: v[i] for k, v in kwargs.items()}
             # If we still have a list, pass all variables in one block
             if isinstance(col, (list, tuple)):
-                result = self._transform(data, **kwargs)
+                result = self._transform(data, **i_kwargs)
                 if self._return_type not in ['none', None]:
                     col = col[0].clone(data=result, name=self.output[0])
             # Otherwise loop over variables individually
             else:
                 if self._groupable and self.groupby is not None:
                     result = col.apply(self._transform, groupby=self.groupby,
-                                       **kwargs)
+                                       **i_kwargs)
                 else:
-                    result = self._transform(data[i], **kwargs)
+                    result = self._transform(data[i], **i_kwargs)
 
             if self._return_type in ['none', None]:
                 continue
diff --git a/bids/modeling/transformations/compute.py b/bids/modeling/transformations/compute.py
--- a/bids/modeling/transformations/compute.py
+++ b/bids/modeling/transformations/compute.py
@@ -192,6 +192,7 @@ class Sum(Transformation):
     _groupable = False
     _aligned_required = True
     _output_required = True
+    _sync_kwargs = False
 
     def _transform(self, data, weights=None):
         data = pd.concat(data, axis=1, sort=True)
diff --git a/bids/modeling/transformations/munge.py b/bids/modeling/transformations/munge.py
--- a/bids/modeling/transformations/munge.py
+++ b/bids/modeling/transformations/munge.py
@@ -299,6 +299,7 @@ class Split(Transformation):
     _return_type = 'variable'
     _allow_categorical = ('by',)
     _densify = ('variables', 'by')
+    _sync_kwargs = False
 
     def _transform(self, var, by):
 


Test patch:
diff --git a/bids/modeling/tests/test_transformations.py b/bids/modeling/tests/test_transformations.py
--- a/bids/modeling/tests/test_transformations.py
+++ b/bids/modeling/tests/test_transformations.py
@@ -344,6 +344,20 @@ def test_assign(collection):
     assert np.array_equal(t2.duration, pg.duration)
 
 
+def test_assign_multiple(collection):
+    # test kwarg distribution
+    transform.Assign(collection, ['RT', 'respcat'], target=['gain', 'loss'],
+                     input_attr=['amplitude', 'amplitude'], target_attr=['duration', 'amplitude'],
+                     output=['gain_rt', 'loss_cat'])
+    rt = collection['RT']
+    gain_rt = collection['gain_rt']
+    loss_cat = collection['loss_cat']
+    rc = collection['respcat']
+
+    assert np.array_equal(gain_rt.duration, rt.values.values)
+    assert np.array_equal(loss_cat.values.values, rc.values.values)
+
+
 def test_copy(collection):
     transform.Copy(collection, 'RT', output='RT_copy')
     assert 'RT_copy' in collection.variables.keys()