Repository: bids-standard/pybids

Issue:
get_collections fails when meta-data includes lists
For me this error occurs when:

```
bids_analysis = Analysis(bids_layout, model)
bids_analysis.setup(**entities)
```

`TypeError: unhashable type: 'list'`

Looking at the traceback:

```
collections = self.layout.get_collections(self.level, drop_na=drop_na, **kwargs)
```
seems to be what's failing because in:

```
> /celery_worker/src/pybids/bids/variables/entities.py(161)get_nodes()
    159         sort_cols = [sc for sc in sort_cols if sc in set(rows.columns)]
    160         sort_cols += list(set(rows.columns) - set(sort_cols))
--> 161         rows = rows.sort_values(sort_cols)
    162         inds = rows['node_index'].astype(int)
    163         return [self.nodes[i] for i in inds]
```

In this dataaset, `rows` contains some values that are lists because of structured meta-data.

For example:
```
rows['ImageType'] 
0    [ORIGINAL, PRIMARY, FMRI, NONE, ND, NORM, MOSA]
Name: ImageType, dtype: object
```


I can't really make sense of why that requires sorting (if its not being returned), and if only `node_index` is being accesed. But either way, I think we need to add meta-data with lists to the tests, as that's probably why we didn't catch this. 


get_collections fails when meta-data includes lists
For me this error occurs when:

```
bids_analysis = Analysis(bids_layout, model)
bids_analysis.setup(**entities)
```

`TypeError: unhashable type: 'list'`

Looking at the traceback:

```
collections = self.layout.get_collections(self.level, drop_na=drop_na, **kwargs)
```
seems to be what's failing because in:

```
> /celery_worker/src/pybids/bids/variables/entities.py(161)get_nodes()
    159         sort_cols = [sc for sc in sort_cols if sc in set(rows.columns)]
    160         sort_cols += list(set(rows.columns) - set(sort_cols))
--> 161         rows = rows.sort_values(sort_cols)
    162         inds = rows['node_index'].astype(int)
    163         return [self.nodes[i] for i in inds]
```

In this dataaset, `rows` contains some values that are lists because of structured meta-data.

For example:
```
rows['ImageType'] 
0    [ORIGINAL, PRIMARY, FMRI, NONE, ND, NORM, MOSA]
Name: ImageType, dtype: object
```


I can't really make sense of why that requires sorting (if its not being returned), and if only `node_index` is being accesed. But either way, I think we need to add meta-data with lists to the tests, as that's probably why we didn't catch this. 




Discussion:
Yeah, I second this, seems like i prevents using fitlins with any dataset where you've defined slice timing.
I think downgrading to 0.8.0 may be a temporary workaround.
Downgrading pybids to 0.8.0 requires downgrading fitlins to 0.4.0, which isn't necessarily the end of the world. 

Since they're unhashable types, the obvious solution here is to coerce lists to `tuple`s on the way in. We could probably do this sensibly as a filter on all incoming JSON.
Haven't looked at it yet, but I think the issue is at database level—the indexing code will probably need to be updated to detect data structures and save the dtype as `'json'` in the `Tag` model. I'm pretty sure that doesn't already happen. So I think is a problem with any kind of structured data right now.

A more general question is how to handle nested metadata. Even if we do the above, that will still only work for one level—there won't be any provision for searching deeper keys. I think we'd talked about using the convention of `FirstLevelKey.SecondLevelKey`, but I have my reservations, and that would in any case take a little more work. But as a start, the solution above should be fine.
If we need a fully hashable replacement for a JSON object:

```Python
from frozendict import frozendict

def safe_json(obj):
    if isinstance(obj, list):
        return tuple(safe_json(elem) for elem in obj)
    if isinstance(obj, dict):
        return frozendict((key, safe_json(val)) for key, val in obj.items()}
    return obj
```
@effigies Nice recursive implementation. @tyarkoni Right now I don't see a use case for searching deeper keys.

Should we tweak one of the tests to have a list like @adelavega's example?

@effigies, should I try to put together a PR for this and variable globing from #487 or do you think you'll have a time to do that?

@effigies - a side note - if the intent of hashing is to restore to previous state, this may require order preservation or other requirements. insertion order in a dict is preserved by python only as of 3.7, but supported as of 3.6 (but not guaranteed).
As far as I know, we don't depend on insertion ordering, but I don't know what Pandas is doing here, hashing, really.
@Shotgunosine If you have the cycles, it might be more efficient for you to propose and me to review... Otherwise, feel free to pick one and I can see if I can work on the other.
I'll take a shot at #487 and see how far I can get with that
@Shotgunosine Incidentally, while we should handle list metadata, `SliceTiming` should generally not be seen by FitLins, as you shouldn't expect it to work on non-preprocessed data. If you've slice-timing-corrected or head-motion-corrected, the validity of slice timing information is destroyed and should be removed from metadata.

@adelavega What's the context you were encountering this?
That particular example occurred because I was setting up an analysis without passing the derivatives directory. So you're right, slice_timing shouldn't be an issue. Other random metadata coming through from the dicom may still be an issue. Let me double check to reproduce the error.

@effigies do you mean if you only set up the layout on the fmriprep outputs? I'm trying to remember which dataset this happened in, but I think it's because I set up the layout like: `BIDSLayout(raw, derivatives=preproc_dir)` (in Neuroscout, not fitlins).

So yes, I think fitlins should usually not have trouble with this. And maybe I should just change this code to only set up the `BIDSLayout` using the preproc_dir. I think I used to do it this way to emulate what fitlins (used to) do. 

Yeah, now that I'm going back through my debugging from last night I think I was also only encountering this when setting up the BIDSLayout outside of fitlins. Maybe this can just be closed then?
Well, it's still a valid way to set up a `BIDSLayout` and `get_collections` has uses outside of fitlins (e.g. to get the events in a raw bids dataset), so I think it's important to fix. 

Also, can't fitlins read the original event files in a dataset, and then would encounter this problem then? Just the way I run fitlins I don't point it to the raw dataset ever.
We do read the original events files. Just we don't collect metadata from the original BOLD files.
But for me this was happening simply doing `analysis.setup()`

Regardless, given you have a seemingly easy fix, it makes sense to implement.
Sure. Do you know off the top of your head a dataset that reproduces the issue?
Looks like that was the `Raiders` dataset http://datasets.datalad.org/?dir=/labs/haxby/raiders

In particular `task-raiders_bold.json`
On further investigation, I agree we may be able to avoid this issue by just dropping this line:

```python
        sort_cols += list(set(rows.columns) - set(sort_cols))
```

The sorting on the basic entities (subject, session, etc.) is necessary to ensure that data are returned in a predictable order (i.e., you don't want to get back run 2 before run 1), but I don't think users should reasonably have an expectation that all the *other* columns in the DF are sorted. Since we know that the basic entities are always hashable, that should be sufficient to address this problem. I don't believe there was any other reason for sorting on the extra columns, but I'll think about it a bit more just to be sure...

It might make sense to sort by other filename level entities like echo, but not by all of the entities from the json.
@tyarkoni Even dropping that, we hit other issues related to these lists.
Also, if we do decide to retain the sorting on the rest of the columns, I think I lean towards adding a line of two to drop any unhashable columns from the sort list rather than mutating the actual data in the DF. I definitely don't think users should have *any* sensible expectations about how to sort columns based on list or dict datatypes!
@effigies ah, well then scratch that.

I'm a bit wary of mutating the data, as there's no telling what users will want to do with it downstream. If you're expecting lists from the JSON, I don't think we should hand you tuples. What are the other downstream problems? If the issues arise at DB level (which was my original thought), we can just store as JSON directly.
Using `///labs/haxby/raiders`:

```Python
import bids
layout = bids.BIDSLayout('/data/bids/haxby/raiders')
col = layout.get_collections('run')
```

```Python
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
TypeError: unhashable type: 'list'

The above exception was the direct cause of the following exception:

SystemError                               Traceback (most recent call last)
<ipython-input-7-68660299ebb2> in <module>
----> 1 col = layout.get_collections('run', drop_na=False)

~/Projects/bids-standard/pybids/bids/layout/layout.py in get_collections(self, level, types, variables, merge, sampling_rate, skip_empty, **kwargs)
    846         from bids.variables import load_variables
    847         index = load_variables(self, types=types, levels=level,
--> 848                                skip_empty=skip_empty, **kwargs)
    849         return index.get_collections(level, variables, merge,
    850                                      sampling_rate=sampling_rate)

~/Projects/bids-standard/pybids/bids/variables/io.py in load_variables(layout, types, levels, skip_empty, dataset, scope, **kwargs)
     76         _kwargs = kwargs.copy()
     77         _kwargs.update(type_flags)
---> 78         dataset = _load_time_variables(layout, dataset, scope=scope, **_kwargs)
     79 
     80     for t in ({'scans', 'sessions', 'participants'} & set(types)):

~/Projects/bids-standard/pybids/bids/variables/io.py in _load_time_variables(layout, dataset, columns, scan_length, drop_na, events, physio, stim, regressors, skip_empty, scope, **selectors)
    167 
    168         run = dataset.get_or_create_node('run', entities, image_file=img_f,
--> 169                                          duration=duration, repetition_time=tr)
    170         run_info = run.get_info()
    171 

~/Projects/bids-standard/pybids/bids/variables/entities.py in get_or_create_node(self, level, entities, *args, **kwargs)
    180         '''
    181 
--> 182         result = self.get_nodes(level, entities)
    183 
    184         if result:

~/Projects/bids-standard/pybids/bids/variables/entities.py in get_nodes(self, level, entities, strict)
    151         query = ' and '.join(query)
    152 
--> 153         rows = self.index.query(query)
    154         if rows.empty:
    155             return []

~/anaconda3/lib/python3.7/site-packages/pandas/core/frame.py in query(self, expr, inplace, **kwargs)
   3086         kwargs['level'] = kwargs.pop('level', 0) + 1
   3087         kwargs['target'] = None
-> 3088         res = self.eval(expr, **kwargs)
   3089 
   3090         try:

~/anaconda3/lib/python3.7/site-packages/pandas/core/frame.py in eval(self, expr, inplace, **kwargs)
   3201             kwargs['target'] = self
   3202         kwargs['resolvers'] = kwargs.get('resolvers', ()) + tuple(resolvers)
-> 3203         return _eval(expr, inplace=inplace, **kwargs)
   3204 
   3205     def select_dtypes(self, include=None, exclude=None):

~/anaconda3/lib/python3.7/site-packages/pandas/core/computation/eval.py in eval(expr, parser, engine, truediv, local_dict, global_dict, resolvers, level, target, inplace)
    292 
    293         parsed_expr = Expr(expr, engine=engine, parser=parser, env=env,
--> 294                            truediv=truediv)
    295 
    296         # construct the engine and evaluate the parsed expression

~/anaconda3/lib/python3.7/site-packages/pandas/core/computation/expr.py in __init__(self, expr, engine, parser, env, truediv, level)
    747         self.env.scope['truediv'] = truediv
    748         self._visitor = _parsers[parser](self.env, self.engine, self.parser)
--> 749         self.terms = self.parse()
    750 
    751     @property

~/anaconda3/lib/python3.7/site-packages/pandas/core/computation/expr.py in parse(self)
    764     def parse(self):
    765         """Parse an expression"""
--> 766         return self._visitor.visit(self.expr)
    767 
    768     @property

~/anaconda3/lib/python3.7/site-packages/pandas/core/computation/expr.py in visit(self, node, **kwargs)
    329         method = 'visit_' + node.__class__.__name__
    330         visitor = getattr(self, method)
--> 331         return visitor(node, **kwargs)
    332 
    333     def visit_Module(self, node, **kwargs):

~/anaconda3/lib/python3.7/site-packages/pandas/core/computation/expr.py in visit_Module(self, node, **kwargs)
    335             raise SyntaxError('only a single expression is allowed')
    336         expr = node.body[0]
--> 337         return self.visit(expr, **kwargs)
    338 
    339     def visit_Expr(self, node, **kwargs):

~/anaconda3/lib/python3.7/site-packages/pandas/core/computation/expr.py in visit(self, node, **kwargs)
    329         method = 'visit_' + node.__class__.__name__
    330         visitor = getattr(self, method)
--> 331         return visitor(node, **kwargs)
    332 
    333     def visit_Module(self, node, **kwargs):

~/anaconda3/lib/python3.7/site-packages/pandas/core/computation/expr.py in visit_Expr(self, node, **kwargs)
    338 
    339     def visit_Expr(self, node, **kwargs):
--> 340         return self.visit(node.value, **kwargs)
    341 
    342     def _rewrite_membership_op(self, node, left, right):

~/anaconda3/lib/python3.7/site-packages/pandas/core/computation/expr.py in visit(self, node, **kwargs)
    329         method = 'visit_' + node.__class__.__name__
    330         visitor = getattr(self, method)
--> 331         return visitor(node, **kwargs)
    332 
    333     def visit_Module(self, node, **kwargs):

~/anaconda3/lib/python3.7/site-packages/pandas/core/computation/expr.py in visit_BoolOp(self, node, **kwargs)
    691 
    692         operands = node.values
--> 693         return reduce(visitor, operands)
    694 
    695 

~/anaconda3/lib/python3.7/site-packages/pandas/core/computation/expr.py in visitor(x, y)
    684         def visitor(x, y):
    685             lhs = self._try_visit_binop(x)
--> 686             rhs = self._try_visit_binop(y)
    687 
    688             op, op_class, lhs, rhs = self._maybe_transform_eq_ne(

~/anaconda3/lib/python3.7/site-packages/pandas/core/computation/expr.py in _try_visit_binop(self, bop)
    679         if isinstance(bop, (Op, Term)):
    680             return bop
--> 681         return self.visit(bop)
    682 
    683     def visit_BoolOp(self, node, **kwargs):

~/anaconda3/lib/python3.7/site-packages/pandas/core/computation/expr.py in visit(self, node, **kwargs)
    329         method = 'visit_' + node.__class__.__name__
    330         visitor = getattr(self, method)
--> 331         return visitor(node, **kwargs)
    332 
    333     def visit_Module(self, node, **kwargs):

~/anaconda3/lib/python3.7/site-packages/pandas/core/computation/expr.py in visit_Compare(self, node, **kwargs)
    664             op = self.translate_In(ops[0])
    665             binop = ast.BinOp(op=op, left=node.left, right=comps[0])
--> 666             return self.visit(binop)
    667 
    668         # recursive case: we have a chained comparison, a CMP b CMP c, etc.

~/anaconda3/lib/python3.7/site-packages/pandas/core/computation/expr.py in visit(self, node, **kwargs)
    329         method = 'visit_' + node.__class__.__name__
    330         visitor = getattr(self, method)
--> 331         return visitor(node, **kwargs)
    332 
    333     def visit_Module(self, node, **kwargs):

~/anaconda3/lib/python3.7/site-packages/pandas/core/computation/expr.py in visit_BinOp(self, node, **kwargs)
    435         op, op_class, left, right = self._maybe_transform_eq_ne(node)
    436         left, right = self._maybe_downcast_constants(left, right)
--> 437         return self._maybe_evaluate_binop(op, op_class, left, right)
    438 
    439     def visit_Div(self, node, **kwargs):

~/anaconda3/lib/python3.7/site-packages/pandas/core/computation/expr.py in _maybe_evaluate_binop(self, op, op_class, lhs, rhs, eval_in_python, maybe_eval_in_python)
    422         if res.op in eval_in_python:
    423             # "in"/"not in" ops are always evaluated in python
--> 424             return self._maybe_eval(res, eval_in_python)
    425         elif self.engine != 'pytables':
    426             if (getattr(lhs, 'return_type', None) == object or

~/anaconda3/lib/python3.7/site-packages/pandas/core/computation/expr.py in _maybe_eval(self, binop, eval_in_python)
    398         # call will be evaluated using isin (in python space)
    399         return binop.evaluate(self.env, self.engine, self.parser,
--> 400                               self.term_type, eval_in_python)
    401 
    402     def _maybe_evaluate_binop(self, op, op_class, lhs, rhs,

~/anaconda3/lib/python3.7/site-packages/pandas/core/computation/ops.py in evaluate(self, env, engine, parser, term_type, eval_in_python)
    398             # base cases
    399             if self.op in eval_in_python:
--> 400                 res = self.func(left.value, right.value)
    401             else:
    402                 res = pd.eval(self, local_dict=env, engine=engine,

~/anaconda3/lib/python3.7/site-packages/pandas/core/computation/ops.py in _in(x, y)
    239     """
    240     try:
--> 241         return x.isin(y)
    242     except AttributeError:
    243         if is_list_like(x):

~/anaconda3/lib/python3.7/site-packages/pandas/core/series.py in isin(self, values)
   4001         Name: animal, dtype: bool
   4002         """
-> 4003         result = algorithms.isin(self, values)
   4004         return self._constructor(result, index=self.index).__finalize__(self)
   4005 

~/anaconda3/lib/python3.7/site-packages/pandas/core/algorithms.py in isin(comps, values)
    429             comps = comps.astype(object)
    430 
--> 431     return f(comps, values)
    432 
    433 

~/anaconda3/lib/python3.7/site-packages/pandas/core/algorithms.py in <lambda>(x, y)
    405 
    406     # faster for larger cases to use np.in1d
--> 407     f = lambda x, y: htable.ismember_object(x, values)
    408 
    409     # GH16012

pandas/_libs/hashtable_func_helper.pxi in pandas._libs.hashtable.ismember_object()

SystemError: <built-in method view of numpy.ndarray object at 0x7f641452b800> returned a result with an error set
```

Dropping into this frame in pdb:

```Python
~/Projects/bids-standard/pybids/bids/variables/entities.py in get_nodes(self, level, entities, strict)
    151         query = ' and '.join(query)
    152 
--> 153         rows = self.index.query(query)
```

`query` is:

`"datatype == 'func' and subject == 'rid000005' and TaskName == 'raiders' and Manufacturer == 'Philips' and SliceTiming in [0.0, 0.427, 0.854, 1.28, 1.707, 2.134, 0.061, 0.488, 0.915, 1.341, 1.768, 2.195, 0.122, 0.549, 0.976, 1.402, 1.829, 2.256, 0.183, 0.61, 1.037, 1.463, 1.89, 2.317, 0.244, 0.671, 1.098, 1.524, 1.951, 2.378, 0.305, 0.732, 1.159, 1.585, 2.012, 2.439, 0.366, 0.793, 1.22, 1.646, 2.073] and RepetitionTime == 2.5 and extension == 'nii.gz' and dcmmeta_shape in [80, 80, 41] and level == 'run' and PhaseEncodingDirection == 'j-' and task == 'raiders' and ProcedureStepDescription == 'Haxby_Nastase_raiders' and ImageType in ['ORIGINAL', 'PRIMARY', 'FMRI', 'NONE', 'ND', 'NORM', 'MOSA'] and dcmmeta_version == 0.6 and FlipAngle == 90 and EchoTime == 0.035 and CogAtlasID == 'http://www.cognitiveatlas.org/task/film_viewing' and suffix == 'bold' and MagneticFieldStrength == 3 and acquisition == '8ch336vol' and ManufacturersModelName == 'Achieva' and dcmmeta_reorient_transform in [[0, -1, 0, 95], [1, 0, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]] and run == 1"`
What's the value of `entities` in `get_nodes()`?
```Python
{'CogAtlasID': 'http://www.cognitiveatlas.org/task/film_viewing',
 'EchoTime': 0.035,
 'FlipAngle': 90,
 'ImageType': ['ORIGINAL', 'PRIMARY', 'FMRI', 'NONE', 'ND', 'NORM', 'MOSA'],
 'MagneticFieldStrength': 3,
 'Manufacturer': 'Philips',
 'ManufacturersModelName': 'Achieva',
 'PhaseEncodingDirection': 'j-',
 'ProcedureStepDescription': 'Haxby_Nastase_raiders',
 'RepetitionTime': 2.5,
 'SliceTiming': [0.0,
                 0.427,
                 0.854,
                 1.28,
                 1.707,
                 2.134,
                 0.061,
                 0.488,
                 0.915,
                 1.341,
                 1.768,
                 2.195,
                 0.122,
                 0.549,
                 0.976,
                 1.402,
                 1.829,
                 2.256,
                 0.183,
                 0.61,
                 1.037,
                 1.463,
                 1.89,
                 2.317,
                 0.244,
                 0.671,
                 1.098,
                 1.524,
                 1.951,
                 2.378,
                 0.305,
                 0.732,
                 1.159,
                 1.585,
                 2.012,
                 2.439,
                 0.366,
                 0.793,
                 1.22,
                 1.646,
                 2.073],
 'TaskName': 'raiders',
 'acquisition': '8ch336vol',
 'datatype': 'func',
 'dcmmeta_reorient_transform': [[0, -1, 0, 95],
                                [1, 0, 0, 0],
                                [0, 0, 1, 0],
                                [0, 0, 0, 1]],
 'dcmmeta_shape': [80, 80, 41],
 'dcmmeta_version': 0.6,
 'extension': 'nii.gz',
 'level': 'run',
 'run': 1,
 'subject': 'rid000005',
 'suffix': 'bold',
 'task': 'raiders'}
```
I see. I think maybe the sensible solution here is to only pass the built-in BIDS entities forward to `get_nodes()`, rather than including the metadata fields. In practice I have a hard time seeing a use case where someone needs to do the latter; I think it just ends up this way because it wasn't an issue prior to the move to the DB (when entities and metadata were stored separately), and it didn't occur to me to guard against it.
Another alternative is to run the values of all the keys through `json.dumps` interally, and json.load when outputting the values. For all string values they would be essentially unaffected. Lists would get turned into strings (which we can recover later).
Except isn't one of the considerations that we might want to pass lists to select multiple values? That's certainly how `query` is operating. (Note the `in`s instead of `==`.)
I don't think it's the lists that are causing the problem here; I think it's the `dcmmeta_reorient_transform`, which is 2d. If you pass in a list for a standard entity (e.g., `subject`), it should be handled fine. So the only use case we'd be precluding under my proposal is where the user explicitly wants to retrieve only nodes that match one of several list values. I have a hard time seeing this actually arise in practice (or potentially even in principle), given that this is just picking out individual files, and I can't imagine under what situation you'd need something like `dcmmeta_reorient_transform` as your disambiguating field.
I'm happy to work up a fix (probably not today though), which will probably go inside `_load_time_variables`. My initial thought is to only pass forward BIDS-defined entities *unless* they're explicitly passed as extra selectors in `load_variables`, in which case they'd still be included.

That ensures that if you really, really want to use a metadata field as a selector, you can still do it. If you pass in a list of unhashable values (e.g., list of lists), it will crash and tell you not to do that—and I think I'm okay with that.
Yeah, I can't really see a use case for selecting on a specific slice timing sequence. If it's a meaningful sequence difference, it will also have to have some kind of filename distinction, such as `acq-`.
Oh, and to address the first issue that came up, are we all okay dropping the sorting on the non-basic entities, assuming there are no side effects?
> are we all okay dropping the sorting on the non-basic entities

i am ok with dropping sorting on anything that cannot be sorted (is unhashable) :)
Well this would be more inclusive than that. But keep in mind that we're not silently dropping columns that users have *asked* to sort on; there's no user option for sorting. This was just being done internally for the sake of... I don't actually know. There isn't really a good reason for it, once you get past the columns necessary for aligning things properly.
Yeah, I second this, seems like i prevents using fitlins with any dataset where you've defined slice timing.
I think downgrading to 0.8.0 may be a temporary workaround.
Downgrading pybids to 0.8.0 requires downgrading fitlins to 0.4.0, which isn't necessarily the end of the world. 

Since they're unhashable types, the obvious solution here is to coerce lists to `tuple`s on the way in. We could probably do this sensibly as a filter on all incoming JSON.
Haven't looked at it yet, but I think the issue is at database level—the indexing code will probably need to be updated to detect data structures and save the dtype as `'json'` in the `Tag` model. I'm pretty sure that doesn't already happen. So I think is a problem with any kind of structured data right now.

A more general question is how to handle nested metadata. Even if we do the above, that will still only work for one level—there won't be any provision for searching deeper keys. I think we'd talked about using the convention of `FirstLevelKey.SecondLevelKey`, but I have my reservations, and that would in any case take a little more work. But as a start, the solution above should be fine.
If we need a fully hashable replacement for a JSON object:

```Python
from frozendict import frozendict

def safe_json(obj):
    if isinstance(obj, list):
        return tuple(safe_json(elem) for elem in obj)
    if isinstance(obj, dict):
        return frozendict((key, safe_json(val)) for key, val in obj.items()}
    return obj
```
@effigies Nice recursive implementation. @tyarkoni Right now I don't see a use case for searching deeper keys.

Should we tweak one of the tests to have a list like @adelavega's example?

@effigies, should I try to put together a PR for this and variable globing from #487 or do you think you'll have a time to do that?

@effigies - a side note - if the intent of hashing is to restore to previous state, this may require order preservation or other requirements. insertion order in a dict is preserved by python only as of 3.7, but supported as of 3.6 (but not guaranteed).
As far as I know, we don't depend on insertion ordering, but I don't know what Pandas is doing here, hashing, really.
@Shotgunosine If you have the cycles, it might be more efficient for you to propose and me to review... Otherwise, feel free to pick one and I can see if I can work on the other.
I'll take a shot at #487 and see how far I can get with that
@Shotgunosine Incidentally, while we should handle list metadata, `SliceTiming` should generally not be seen by FitLins, as you shouldn't expect it to work on non-preprocessed data. If you've slice-timing-corrected or head-motion-corrected, the validity of slice timing information is destroyed and should be removed from metadata.

@adelavega What's the context you were encountering this?
That particular example occurred because I was setting up an analysis without passing the derivatives directory. So you're right, slice_timing shouldn't be an issue. Other random metadata coming through from the dicom may still be an issue. Let me double check to reproduce the error.

@effigies do you mean if you only set up the layout on the fmriprep outputs? I'm trying to remember which dataset this happened in, but I think it's because I set up the layout like: `BIDSLayout(raw, derivatives=preproc_dir)` (in Neuroscout, not fitlins).

So yes, I think fitlins should usually not have trouble with this. And maybe I should just change this code to only set up the `BIDSLayout` using the preproc_dir. I think I used to do it this way to emulate what fitlins (used to) do. 

Yeah, now that I'm going back through my debugging from last night I think I was also only encountering this when setting up the BIDSLayout outside of fitlins. Maybe this can just be closed then?
Well, it's still a valid way to set up a `BIDSLayout` and `get_collections` has uses outside of fitlins (e.g. to get the events in a raw bids dataset), so I think it's important to fix. 

Also, can't fitlins read the original event files in a dataset, and then would encounter this problem then? Just the way I run fitlins I don't point it to the raw dataset ever.
We do read the original events files. Just we don't collect metadata from the original BOLD files.
But for me this was happening simply doing `analysis.setup()`

Regardless, given you have a seemingly easy fix, it makes sense to implement.
Sure. Do you know off the top of your head a dataset that reproduces the issue?
Looks like that was the `Raiders` dataset http://datasets.datalad.org/?dir=/labs/haxby/raiders

In particular `task-raiders_bold.json`
On further investigation, I agree we may be able to avoid this issue by just dropping this line:

```python
        sort_cols += list(set(rows.columns) - set(sort_cols))
```

The sorting on the basic entities (subject, session, etc.) is necessary to ensure that data are returned in a predictable order (i.e., you don't want to get back run 2 before run 1), but I don't think users should reasonably have an expectation that all the *other* columns in the DF are sorted. Since we know that the basic entities are always hashable, that should be sufficient to address this problem. I don't believe there was any other reason for sorting on the extra columns, but I'll think about it a bit more just to be sure...

It might make sense to sort by other filename level entities like echo, but not by all of the entities from the json.
@tyarkoni Even dropping that, we hit other issues related to these lists.
Also, if we do decide to retain the sorting on the rest of the columns, I think I lean towards adding a line of two to drop any unhashable columns from the sort list rather than mutating the actual data in the DF. I definitely don't think users should have *any* sensible expectations about how to sort columns based on list or dict datatypes!
@effigies ah, well then scratch that.

I'm a bit wary of mutating the data, as there's no telling what users will want to do with it downstream. If you're expecting lists from the JSON, I don't think we should hand you tuples. What are the other downstream problems? If the issues arise at DB level (which was my original thought), we can just store as JSON directly.
Using `///labs/haxby/raiders`:

```Python
import bids
layout = bids.BIDSLayout('/data/bids/haxby/raiders')
col = layout.get_collections('run')
```

```Python
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
TypeError: unhashable type: 'list'

The above exception was the direct cause of the following exception:

SystemError                               Traceback (most recent call last)
<ipython-input-7-68660299ebb2> in <module>
----> 1 col = layout.get_collections('run', drop_na=False)

~/Projects/bids-standard/pybids/bids/layout/layout.py in get_collections(self, level, types, variables, merge, sampling_rate, skip_empty, **kwargs)
    846         from bids.variables import load_variables
    847         index = load_variables(self, types=types, levels=level,
--> 848                                skip_empty=skip_empty, **kwargs)
    849         return index.get_collections(level, variables, merge,
    850                                      sampling_rate=sampling_rate)

~/Projects/bids-standard/pybids/bids/variables/io.py in load_variables(layout, types, levels, skip_empty, dataset, scope, **kwargs)
     76         _kwargs = kwargs.copy()
     77         _kwargs.update(type_flags)
---> 78         dataset = _load_time_variables(layout, dataset, scope=scope, **_kwargs)
     79 
     80     for t in ({'scans', 'sessions', 'participants'} & set(types)):

~/Projects/bids-standard/pybids/bids/variables/io.py in _load_time_variables(layout, dataset, columns, scan_length, drop_na, events, physio, stim, regressors, skip_empty, scope, **selectors)
    167 
    168         run = dataset.get_or_create_node('run', entities, image_file=img_f,
--> 169                                          duration=duration, repetition_time=tr)
    170         run_info = run.get_info()
    171 

~/Projects/bids-standard/pybids/bids/variables/entities.py in get_or_create_node(self, level, entities, *args, **kwargs)
    180         '''
    181 
--> 182         result = self.get_nodes(level, entities)
    183 
    184         if result:

~/Projects/bids-standard/pybids/bids/variables/entities.py in get_nodes(self, level, entities, strict)
    151         query = ' and '.join(query)
    152 
--> 153         rows = self.index.query(query)
    154         if rows.empty:
    155             return []

~/anaconda3/lib/python3.7/site-packages/pandas/core/frame.py in query(self, expr, inplace, **kwargs)
   3086         kwargs['level'] = kwargs.pop('level', 0) + 1
   3087         kwargs['target'] = None
-> 3088         res = self.eval(expr, **kwargs)
   3089 
   3090         try:

~/anaconda3/lib/python3.7/site-packages/pandas/core/frame.py in eval(self, expr, inplace, **kwargs)
   3201             kwargs['target'] = self
   3202         kwargs['resolvers'] = kwargs.get('resolvers', ()) + tuple(resolvers)
-> 3203         return _eval(expr, inplace=inplace, **kwargs)
   3204 
   3205     def select_dtypes(self, include=None, exclude=None):

~/anaconda3/lib/python3.7/site-packages/pandas/core/computation/eval.py in eval(expr, parser, engine, truediv, local_dict, global_dict, resolvers, level, target, inplace)
    292 
    293         parsed_expr = Expr(expr, engine=engine, parser=parser, env=env,
--> 294                            truediv=truediv)
    295 
    296         # construct the engine and evaluate the parsed expression

~/anaconda3/lib/python3.7/site-packages/pandas/core/computation/expr.py in __init__(self, expr, engine, parser, env, truediv, level)
    747         self.env.scope['truediv'] = truediv
    748         self._visitor = _parsers[parser](self.env, self.engine, self.parser)
--> 749         self.terms = self.parse()
    750 
    751     @property

~/anaconda3/lib/python3.7/site-packages/pandas/core/computation/expr.py in parse(self)
    764     def parse(self):
    765         """Parse an expression"""
--> 766         return self._visitor.visit(self.expr)
    767 
    768     @property

~/anaconda3/lib/python3.7/site-packages/pandas/core/computation/expr.py in visit(self, node, **kwargs)
    329         method = 'visit_' + node.__class__.__name__
    330         visitor = getattr(self, method)
--> 331         return visitor(node, **kwargs)
    332 
    333     def visit_Module(self, node, **kwargs):

~/anaconda3/lib/python3.7/site-packages/pandas/core/computation/expr.py in visit_Module(self, node, **kwargs)
    335             raise SyntaxError('only a single expression is allowed')
    336         expr = node.body[0]
--> 337         return self.visit(expr, **kwargs)
    338 
    339     def visit_Expr(self, node, **kwargs):

~/anaconda3/lib/python3.7/site-packages/pandas/core/computation/expr.py in visit(self, node, **kwargs)
    329         method = 'visit_' + node.__class__.__name__
    330         visitor = getattr(self, method)
--> 331         return visitor(node, **kwargs)
    332 
    333     def visit_Module(self, node, **kwargs):

~/anaconda3/lib/python3.7/site-packages/pandas/core/computation/expr.py in visit_Expr(self, node, **kwargs)
    338 
    339     def visit_Expr(self, node, **kwargs):
--> 340         return self.visit(node.value, **kwargs)
    341 
    342     def _rewrite_membership_op(self, node, left, right):

~/anaconda3/lib/python3.7/site-packages/pandas/core/computation/expr.py in visit(self, node, **kwargs)
    329         method = 'visit_' + node.__class__.__name__
    330         visitor = getattr(self, method)
--> 331         return visitor(node, **kwargs)
    332 
    333     def visit_Module(self, node, **kwargs):

~/anaconda3/lib/python3.7/site-packages/pandas/core/computation/expr.py in visit_BoolOp(self, node, **kwargs)
    691 
    692         operands = node.values
--> 693         return reduce(visitor, operands)
    694 
    695 

~/anaconda3/lib/python3.7/site-packages/pandas/core/computation/expr.py in visitor(x, y)
    684         def visitor(x, y):
    685             lhs = self._try_visit_binop(x)
--> 686             rhs = self._try_visit_binop(y)
    687 
    688             op, op_class, lhs, rhs = self._maybe_transform_eq_ne(

~/anaconda3/lib/python3.7/site-packages/pandas/core/computation/expr.py in _try_visit_binop(self, bop)
    679         if isinstance(bop, (Op, Term)):
    680             return bop
--> 681         return self.visit(bop)
    682 
    683     def visit_BoolOp(self, node, **kwargs):

~/anaconda3/lib/python3.7/site-packages/pandas/core/computation/expr.py in visit(self, node, **kwargs)
    329         method = 'visit_' + node.__class__.__name__
    330         visitor = getattr(self, method)
--> 331         return visitor(node, **kwargs)
    332 
    333     def visit_Module(self, node, **kwargs):

~/anaconda3/lib/python3.7/site-packages/pandas/core/computation/expr.py in visit_Compare(self, node, **kwargs)
    664             op = self.translate_In(ops[0])
    665             binop = ast.BinOp(op=op, left=node.left, right=comps[0])
--> 666             return self.visit(binop)
    667 
    668         # recursive case: we have a chained comparison, a CMP b CMP c, etc.

~/anaconda3/lib/python3.7/site-packages/pandas/core/computation/expr.py in visit(self, node, **kwargs)
    329         method = 'visit_' + node.__class__.__name__
    330         visitor = getattr(self, method)
--> 331         return visitor(node, **kwargs)
    332 
    333     def visit_Module(self, node, **kwargs):

~/anaconda3/lib/python3.7/site-packages/pandas/core/computation/expr.py in visit_BinOp(self, node, **kwargs)
    435         op, op_class, left, right = self._maybe_transform_eq_ne(node)
    436         left, right = self._maybe_downcast_constants(left, right)
--> 437         return self._maybe_evaluate_binop(op, op_class, left, right)
    438 
    439     def visit_Div(self, node, **kwargs):

~/anaconda3/lib/python3.7/site-packages/pandas/core/computation/expr.py in _maybe_evaluate_binop(self, op, op_class, lhs, rhs, eval_in_python, maybe_eval_in_python)
    422         if res.op in eval_in_python:
    423             # "in"/"not in" ops are always evaluated in python
--> 424             return self._maybe_eval(res, eval_in_python)
    425         elif self.engine != 'pytables':
    426             if (getattr(lhs, 'return_type', None) == object or

~/anaconda3/lib/python3.7/site-packages/pandas/core/computation/expr.py in _maybe_eval(self, binop, eval_in_python)
    398         # call will be evaluated using isin (in python space)
    399         return binop.evaluate(self.env, self.engine, self.parser,
--> 400                               self.term_type, eval_in_python)
    401 
    402     def _maybe_evaluate_binop(self, op, op_class, lhs, rhs,

~/anaconda3/lib/python3.7/site-packages/pandas/core/computation/ops.py in evaluate(self, env, engine, parser, term_type, eval_in_python)
    398             # base cases
    399             if self.op in eval_in_python:
--> 400                 res = self.func(left.value, right.value)
    401             else:
    402                 res = pd.eval(self, local_dict=env, engine=engine,

~/anaconda3/lib/python3.7/site-packages/pandas/core/computation/ops.py in _in(x, y)
    239     """
    240     try:
--> 241         return x.isin(y)
    242     except AttributeError:
    243         if is_list_like(x):

~/anaconda3/lib/python3.7/site-packages/pandas/core/series.py in isin(self, values)
   4001         Name: animal, dtype: bool
   4002         """
-> 4003         result = algorithms.isin(self, values)
   4004         return self._constructor(result, index=self.index).__finalize__(self)
   4005 

~/anaconda3/lib/python3.7/site-packages/pandas/core/algorithms.py in isin(comps, values)
    429             comps = comps.astype(object)
    430 
--> 431     return f(comps, values)
    432 
    433 

~/anaconda3/lib/python3.7/site-packages/pandas/core/algorithms.py in <lambda>(x, y)
    405 
    406     # faster for larger cases to use np.in1d
--> 407     f = lambda x, y: htable.ismember_object(x, values)
    408 
    409     # GH16012

pandas/_libs/hashtable_func_helper.pxi in pandas._libs.hashtable.ismember_object()

SystemError: <built-in method view of numpy.ndarray object at 0x7f641452b800> returned a result with an error set
```

Dropping into this frame in pdb:

```Python
~/Projects/bids-standard/pybids/bids/variables/entities.py in get_nodes(self, level, entities, strict)
    151         query = ' and '.join(query)
    152 
--> 153         rows = self.index.query(query)
```

`query` is:

`"datatype == 'func' and subject == 'rid000005' and TaskName == 'raiders' and Manufacturer == 'Philips' and SliceTiming in [0.0, 0.427, 0.854, 1.28, 1.707, 2.134, 0.061, 0.488, 0.915, 1.341, 1.768, 2.195, 0.122, 0.549, 0.976, 1.402, 1.829, 2.256, 0.183, 0.61, 1.037, 1.463, 1.89, 2.317, 0.244, 0.671, 1.098, 1.524, 1.951, 2.378, 0.305, 0.732, 1.159, 1.585, 2.012, 2.439, 0.366, 0.793, 1.22, 1.646, 2.073] and RepetitionTime == 2.5 and extension == 'nii.gz' and dcmmeta_shape in [80, 80, 41] and level == 'run' and PhaseEncodingDirection == 'j-' and task == 'raiders' and ProcedureStepDescription == 'Haxby_Nastase_raiders' and ImageType in ['ORIGINAL', 'PRIMARY', 'FMRI', 'NONE', 'ND', 'NORM', 'MOSA'] and dcmmeta_version == 0.6 and FlipAngle == 90 and EchoTime == 0.035 and CogAtlasID == 'http://www.cognitiveatlas.org/task/film_viewing' and suffix == 'bold' and MagneticFieldStrength == 3 and acquisition == '8ch336vol' and ManufacturersModelName == 'Achieva' and dcmmeta_reorient_transform in [[0, -1, 0, 95], [1, 0, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]] and run == 1"`
What's the value of `entities` in `get_nodes()`?
```Python
{'CogAtlasID': 'http://www.cognitiveatlas.org/task/film_viewing',
 'EchoTime': 0.035,
 'FlipAngle': 90,
 'ImageType': ['ORIGINAL', 'PRIMARY', 'FMRI', 'NONE', 'ND', 'NORM', 'MOSA'],
 'MagneticFieldStrength': 3,
 'Manufacturer': 'Philips',
 'ManufacturersModelName': 'Achieva',
 'PhaseEncodingDirection': 'j-',
 'ProcedureStepDescription': 'Haxby_Nastase_raiders',
 'RepetitionTime': 2.5,
 'SliceTiming': [0.0,
                 0.427,
                 0.854,
                 1.28,
                 1.707,
                 2.134,
                 0.061,
                 0.488,
                 0.915,
                 1.341,
                 1.768,
                 2.195,
                 0.122,
                 0.549,
                 0.976,
                 1.402,
                 1.829,
                 2.256,
                 0.183,
                 0.61,
                 1.037,
                 1.463,
                 1.89,
                 2.317,
                 0.244,
                 0.671,
                 1.098,
                 1.524,
                 1.951,
                 2.378,
                 0.305,
                 0.732,
                 1.159,
                 1.585,
                 2.012,
                 2.439,
                 0.366,
                 0.793,
                 1.22,
                 1.646,
                 2.073],
 'TaskName': 'raiders',
 'acquisition': '8ch336vol',
 'datatype': 'func',
 'dcmmeta_reorient_transform': [[0, -1, 0, 95],
                                [1, 0, 0, 0],
                                [0, 0, 1, 0],
                                [0, 0, 0, 1]],
 'dcmmeta_shape': [80, 80, 41],
 'dcmmeta_version': 0.6,
 'extension': 'nii.gz',
 'level': 'run',
 'run': 1,
 'subject': 'rid000005',
 'suffix': 'bold',
 'task': 'raiders'}
```
I see. I think maybe the sensible solution here is to only pass the built-in BIDS entities forward to `get_nodes()`, rather than including the metadata fields. In practice I have a hard time seeing a use case where someone needs to do the latter; I think it just ends up this way because it wasn't an issue prior to the move to the DB (when entities and metadata were stored separately), and it didn't occur to me to guard against it.
Another alternative is to run the values of all the keys through `json.dumps` interally, and json.load when outputting the values. For all string values they would be essentially unaffected. Lists would get turned into strings (which we can recover later).
Except isn't one of the considerations that we might want to pass lists to select multiple values? That's certainly how `query` is operating. (Note the `in`s instead of `==`.)
I don't think it's the lists that are causing the problem here; I think it's the `dcmmeta_reorient_transform`, which is 2d. If you pass in a list for a standard entity (e.g., `subject`), it should be handled fine. So the only use case we'd be precluding under my proposal is where the user explicitly wants to retrieve only nodes that match one of several list values. I have a hard time seeing this actually arise in practice (or potentially even in principle), given that this is just picking out individual files, and I can't imagine under what situation you'd need something like `dcmmeta_reorient_transform` as your disambiguating field.
I'm happy to work up a fix (probably not today though), which will probably go inside `_load_time_variables`. My initial thought is to only pass forward BIDS-defined entities *unless* they're explicitly passed as extra selectors in `load_variables`, in which case they'd still be included.

That ensures that if you really, really want to use a metadata field as a selector, you can still do it. If you pass in a list of unhashable values (e.g., list of lists), it will crash and tell you not to do that—and I think I'm okay with that.
Yeah, I can't really see a use case for selecting on a specific slice timing sequence. If it's a meaningful sequence difference, it will also have to have some kind of filename distinction, such as `acq-`.
Oh, and to address the first issue that came up, are we all okay dropping the sorting on the non-basic entities, assuming there are no side effects?
> are we all okay dropping the sorting on the non-basic entities

i am ok with dropping sorting on anything that cannot be sorted (is unhashable) :)
Well this would be more inclusive than that. But keep in mind that we're not silently dropping columns that users have *asked* to sort on; there's no user option for sorting. This was just being done internally for the sake of... I don't actually know. There isn't really a good reason for it, once you get past the columns necessary for aligning things properly.

Resolving patch:
diff --git a/bids/utils.py b/bids/utils.py
--- a/bids/utils.py
+++ b/bids/utils.py
@@ -1,6 +1,5 @@
 import re
 import os
-from .external import six
 
 
 def listify(obj):
diff --git a/bids/variables/entities.py b/bids/variables/entities.py
--- a/bids/variables/entities.py
+++ b/bids/variables/entities.py
@@ -123,6 +123,19 @@ def get_collections(self, unit, names=None, merge=False,
         return results
 
     def get_nodes(self, level=None, entities=None, strict=False):
+        ''' Retrieves all nodes that match the specified criteria.
+
+        Args:
+            level (str): The level of analysis of nodes to return.
+            entities (dict): Entities to filter on. All nodes must have
+                matching values on all defined keys to be included.
+            strict (bool): If True, an exception will be raised if the entities
+                dict contains any keys that aren't contained in the current
+                index.
+
+        Returns:
+            A list of Node instances.
+        '''
 
         entities = {} if entities is None else entities.copy()
 
@@ -155,20 +168,47 @@ def get_nodes(self, level=None, entities=None, strict=False):
             return []
 
         # Sort and return
-        sort_cols = ['subject', 'session', 'task', 'run']
+        sort_cols = ['subject', 'session', 'task', 'run', 'node_index',
+                     'suffix', 'level', 'datatype']
         sort_cols = [sc for sc in sort_cols if sc in set(rows.columns)]
-        sort_cols += list(set(rows.columns) - set(sort_cols))
         rows = rows.sort_values(sort_cols)
         inds = rows['node_index'].astype(int)
         return [self.nodes[i] for i in inds]
 
+    def create_node(self, level, entities, *args, **kwargs):
+        ''' Creates a new child Node.
+
+        Args:
+            level (str): The level of analysis of the new Node.
+            entities (dict): Dictionary of entities belonging to Node
+            args, kwargs: Optional positional or named arguments to pass onto
+                class-specific initializers. These arguments are only used if
+                a Node that matches the passed entities doesn't already exist,
+                and a new one must be created.
+
+        Returns:
+            A Node instance.
+        '''
+
+        if level == 'run':
+            node = RunNode(entities, *args, **kwargs)
+        else:
+            node = Node(level, entities)
+
+        entities = dict(entities, node_index=len(self.nodes), level=level)
+        self.nodes.append(node)
+        node_row = pd.Series(entities)
+        self.index = self.index.append(node_row, ignore_index=True)
+        return node
+
     def get_or_create_node(self, level, entities, *args, **kwargs):
         ''' Retrieves a child Node based on the specified criteria, creating a
         new Node if necessary.
 
         Args:
-            entities (dict): Dictionary of entities specifying which Node to
-                return.
+            level (str): The level of analysis of the Node.
+            entities (dict): Dictionary of entities to include in newly-created
+                Nodes or filter existing ones.
             args, kwargs: Optional positional or named arguments to pass onto
                 class-specific initializers. These arguments are only used if
                 a Node that matches the passed entities doesn't already exist,
@@ -176,7 +216,6 @@ def get_or_create_node(self, level, entities, *args, **kwargs):
 
         Returns:
             A Node instance.
-
         '''
 
         result = self.get_nodes(level, entities)
@@ -189,15 +228,4 @@ def get_or_create_node(self, level, entities, *args, **kwargs):
                                  )
             return result[0]
 
-        # Create Node
-        if level == 'run':
-            node = RunNode(entities, *args, **kwargs)
-        else:
-            node = Node(level, entities)
-
-        entities = dict(entities, node_index=len(self.nodes), level=level)
-        self.nodes.append(node)
-        node_row = pd.Series(entities)
-        self.index = self.index.append(node_row, ignore_index=True)
-
-        return node
+        return self.create_node(level, entities, *args, **kwargs)
diff --git a/bids/variables/io.py b/bids/variables/io.py
--- a/bids/variables/io.py
+++ b/bids/variables/io.py
@@ -1,10 +1,13 @@
+from os.path import join
+import warnings
+import json
+
 import numpy as np
 import pandas as pd
-from os.path import join
+
 from bids.utils import listify
 from .entities import NodeIndex
 from .variables import SparseRunVariable, DenseRunVariable, SimpleVariable
-import warnings
 
 
 BASE_ENTITIES = ['subject', 'session', 'task', 'run']
@@ -165,8 +168,41 @@ def _load_time_variables(layout, dataset=None, columns=None, scan_length=None,
                        "available, or manually specify the scan duration.")
                 raise ValueError(msg)
 
-        run = dataset.get_or_create_node('run', entities, image_file=img_f,
-                                         duration=duration, repetition_time=tr)
+        # We don't want to pass all the image file's entities onto get_node(),
+        # as there can be unhashable nested slice timing values, and this also
+        # slows down querying unnecessarily. Instead, pick out files only based
+        # on the core BIDS entities and any entities explicitly passed as
+        # selectors.
+        # TODO: one downside of this approach is the stripped entities also
+        # won't be returned in the resulting node due to the way things are
+        # implemented. Consider adding a flag to control this.
+        select_on = {k: v for (k, v) in entities.items()
+                     if k in BASE_ENTITIES or k in selectors}
+
+        # If a matching node already exists, return it
+        result = dataset.get_nodes('run', select_on)
+
+        if result:
+            if len(result) > 1:
+                raise ValueError("More than one existing Node matches the "
+                                 "specified entities! You may need to pass "
+                                 "additional selectors to narrow the search.")
+            return result[0]
+
+        # Otherwise create a new node and use that.
+        # We first convert any entity values that are currently collections to
+        # JSON strings to prevent nasty hashing problems downstream. Note that
+        # isinstance() isn't as foolproof as actually trying to hash the
+        # value, but the latter is likely to be slower, and since values are
+        # coming from JSON or filenames, there's no real chance of encountering
+        # anything but a list or dict.
+        entities = {
+            k: (json.dumps(v) if isinstance(v, (list, dict)) else v)
+            for (k, v) in entities.items()
+        }
+
+        run = dataset.create_node('run', entities, image_file=img_f,
+                                  duration=duration, repetition_time=tr)
         run_info = run.get_info()
 
         # Process event files
@@ -395,6 +431,7 @@ def make_patt(x, regex_search=False):
 
         level = {'scans': 'session', 'sessions': 'subject',
                  'participants': 'dataset'}[suffix]
+
         node = dataset.get_or_create_node(level, f.entities)
 
         ent_cols = list(set(ALL_ENTITIES) & set(_data.columns))


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
@@ -43,19 +43,19 @@ def test_get_design_matrix_arguments(analysis):
                   sampling_rate='highest')
     result = analysis['run'].get_design_matrix(**kwargs)[0]
     assert result.sparse is None
-    assert result.dense.shape == (4800, 9)
+    assert result.dense.shape == (4800, 10)
 
     kwargs = dict(run=1, subject='01', mode='dense', force=True,
                   sampling_rate='TR')
     result = analysis['run'].get_design_matrix(**kwargs)[0]
     assert result.sparse is None
-    assert result.dense.shape == (240, 9)
+    assert result.dense.shape == (240, 10)
 
     kwargs = dict(run=1, subject='01', mode='dense', force=True,
                   sampling_rate=0.5)
     result = analysis['run'].get_design_matrix(**kwargs)[0]
     assert result.sparse is None
-    assert result.dense.shape == (240, 9)
+    assert result.dense.shape == (240, 10)
 
     # format='long' should be ignored for dense output
     kwargs = dict(run=1, subject='01', mode='dense', force=True,
diff --git a/bids/tests/data/ds005/task-mixedgamblestask_bold.json b/bids/tests/data/ds005/task-mixedgamblestask_bold.json
--- a/bids/tests/data/ds005/task-mixedgamblestask_bold.json
+++ b/bids/tests/data/ds005/task-mixedgamblestask_bold.json
@@ -1,4 +1,5 @@
 {
     "RepetitionTime": 2.0,
-    "TaskName": "mixed-gambles task"
-}
\ No newline at end of file
+    "TaskName": "mixed-gambles task",
+    "SliceTiming": [0.0, 0.0571, 0.1143, 0.1714, 0.2286, 0.2857]
+}
diff --git a/bids/variables/tests/test_collections.py b/bids/variables/tests/test_collections.py
--- a/bids/variables/tests/test_collections.py
+++ b/bids/variables/tests/test_collections.py
@@ -72,13 +72,13 @@ def test_run_variable_collection_to_df(run_coll):
 
     # All variables dense, wide format
     df = run_coll.to_df(sparse=False)
-    assert df.shape == (230400, 17)
-    extra_cols = {'TaskName', 'RepetitionTime', 'extension'}
+    assert df.shape == (230400, 18)
+    extra_cols = {'TaskName', 'RepetitionTime', 'extension', 'SliceTiming'}
     assert set(df.columns) == (wide_cols | extra_cols) - {'trial_type'}
 
     # All variables dense, wide format
     df = run_coll.to_df(sparse=False, format='long')
-    assert df.shape == (1612800, 12)
+    assert df.shape == (1612800, 13)
     assert set(df.columns) == (long_cols | extra_cols)