Skip to content

WIP: Allow dicts as Interface input and output specs #2083

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 2 commits into from

Conversation

mwaskom
Copy link
Member

@mwaskom mwaskom commented Jun 20, 2017

See #2081 for motivation. Here is a basic proof of principle:

import os
import nibabel as nib
from nipype.interfaces.base import BaseInterface, traits


class MeanImage(BaseInterface):

    input_spec = dict(in_file=traits.File())
    output_spec = dict(mean_val=traits.Float())

    def _list_outputs(self):

        outputs = self._outputs().get()
        outputs["mean_val"] = self._mean_val
        return outputs

    def _run_interface(self, runtime):

        data = nib.load(self.inputs.in_file).get_data()
        self._mean_val = data.mean()
        return runtime


if __name__ == "__main__":

    fname = "/home/mwaskom/code/nipype/nipype/testing/data/tpm_00.nii.gz"
    interface = MeanImage(in_file=fname)
    print(interface.run().outputs)

I did run into some issues in type() with __future__.unicode_literals and then what I am guessing is whatever cross 2/3 magic nipype does to str() that will need to be sorted out...

@mwaskom
Copy link
Member Author

mwaskom commented Jun 20, 2017

Note how much boilerplate remains in my "lightweight" custom interface, though!

@effigies
Copy link
Member

Regarding the remaining boilerplate, we use the following SimpleInterface as the base for a lot of our interfaces:

class SimpleInterface(BaseInterface):
    """ An interface pattern that allows outputs to be set in a dictionary """
    def __init__(self, **inputs):
        super(SimpleInterface, self).__init__(**inputs)
        self._results = {}

    def _list_outputs(self):
        return self._results

With your additions, the class you described could be shortened to:

class MeanImage(SimpleInterface):
    input_spec = dict(in_file=traits.File())
    output_spec = dict(mean_val=traits.Float())

    def _run_interface(self, runtime):
        data = nib.load(self.inputs.in_file).get_data()
        self._results['mean_val'] = data.mean()
        return runtime

This isn't necessarily suggesting you add anything to the PR, but it seems relevant to the overall discussion of how downstream projects work with/around nipype boilerplate.

@mwaskom
Copy link
Member Author

mwaskom commented Jun 21, 2017

That's nice. So why is it normally necessary to initialize the outputs directory with outputs = self._outputs().get()? What functionality are you losing?

@mwaskom mwaskom closed this Jun 21, 2017
@mwaskom mwaskom reopened this Jun 21, 2017
@effigies
Copy link
Member

Probably automatic population/derivation of output values from corresponding inputs, such as the common idiom that takes out_name or out_file as an input. But I'm not sure, TBH.

@satra
Copy link
Member

satra commented Jun 21, 2017

@mwaskom - the _outputs().get() populates a dictionary with the expected outputs.

when we first started nipype, _list_outputs was callable without running the interface. it would tell you what the predicted outputs were given the inputs. this was mostly to handle filenames that would be generated:

https://github.com/nipy/nipype/blob/master/nipype/interfaces/base.py#L1155

while this still holds for many interfaces, for several this pattern breaks down.

@mwaskom
Copy link
Member Author

mwaskom commented Jun 21, 2017

That makes sense, but it's very unclear from the name! (And not obvious to me which method call is doing the work). It's tangental to this pull request, but worth thinking about transitioning that functionality to a method with a more clear name.

@mwaskom
Copy link
Member Author

mwaskom commented Jun 21, 2017

So CI failures seem mostly related to my removal of the BaseInterface.BaseInterfaceInputSpec attribute. That's not essential to the functionality here so I can put it back. But I will note that the docstring for BaseInterface says:

This class does not implement aggregate_outputs, input_spec or output_spec. These should be defined by derived classes.

which appears functionally inaccurate in practice.

@mwaskom mwaskom force-pushed the interface_spec_dicts branch from 07b3a2b to 1439222 Compare June 26, 2017 19:39
@codecov-io
Copy link

codecov-io commented Jun 26, 2017

Codecov Report

Merging #2083 into master will increase coverage by <.01%.
The diff coverage is 60%.

Impacted file tree graph

@@            Coverage Diff             @@
##           master    #2083      +/-   ##
==========================================
+ Coverage   72.16%   72.16%   +<.01%     
==========================================
  Files        1144     1144              
  Lines       57510    57514       +4     
  Branches     8244     8246       +2     
==========================================
+ Hits        41501    41505       +4     
- Misses      14708    14709       +1     
+ Partials     1301     1300       -1
Flag Coverage Δ
#smoketests 72.16% <60%> (ø) ⬆️
#unittests 69.78% <60%> (ø) ⬆️
Impacted Files Coverage Δ
nipype/interfaces/base.py 83.13% <60%> (-0.13%) ⬇️
nipype/utils/misc.py 84.96% <0%> (+1.5%) ⬆️

Continue to review full report at Codecov.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 1a76182...1439222. Read the comment docs.

@mwaskom
Copy link
Member Author

mwaskom commented Aug 4, 2017

This turns out not to work as well as I would have liked in practice; my workflow dies with a pickling issue:

Traceback (most recent call last):
  File "/home/mwaskom/anaconda2/envs/py36/bin/lyman", line 6, in <module>
    exec(compile(open(__file__).read(), __file__, 'exec'))
  File "/home/mwaskom/code/lyman/scripts/lyman", line 60, in <module>
    execute_workflow(args)
  File "/home/mwaskom/code/lyman/lyman/frontend.py", line 234, in execute_workflow
    run_workflow(wf, args)
  File "/home/mwaskom/code/lyman/lyman/frontend.py", line 207, in run_workflow
    wf.run(plugin, plugin_args)
  File "/home/mwaskom/anaconda2/envs/py36/lib/python3.6/site-packages/nipype/pipeline/engine/workflows.py", line 590, in run
    runner.run(execgraph, updatehash=updatehash, config=self.config)
  File "/home/mwaskom/anaconda2/envs/py36/lib/python3.6/site-packages/nipype/pipeline/plugins/linear.py", line 43, in run
    node.run(updatehash=updatehash)
  File "/home/mwaskom/anaconda2/envs/py36/lib/python3.6/site-packages/nipype/pipeline/engine/nodes.py", line 368, in run
    savepkl(op.join(outdir, '_node.pklz'), self)
  File "/home/mwaskom/anaconda2/envs/py36/lib/python3.6/site-packages/nipype/utils/filemanip.py", line 604, in savepkl
    pickle.dump(record, pkl_file)
_pickle.PicklingError: Can't pickle <class 'traits.has_traits.AnonymousInputSpec'>: attribute lookup AnonymousInputSpec on traits.has_traits failed

Any thoughts?

@satra
Copy link
Member

satra commented Aug 4, 2017

@mwaskom - based on your workflow, can you create a test case that fails? it may help us debug this.

@mwaskom
Copy link
Member Author

mwaskom commented Aug 4, 2017

I can but it will take some time, so if there's something obvious I need to know about pickling and traits it would be helpful.

@mwaskom
Copy link
Member Author

mwaskom commented Aug 4, 2017

OK not a workflow example, but this demonstrates the behavior:

from nipype.interfaces import base
from nipype.interfaces.fsl import BET
from nipype.utils.filemanip import savepkl


class SimpleInterface(base.BaseInterface):

    def __init__(self, **inputs):

        if isinstance(self.input_spec, dict):
            self.input_spec = type("AnonymousInputSpec",
                                   (base.BaseInterfaceInputSpec,),
                                   self.input_spec)

        if isinstance(self.output_spec, dict):
            self.output_spec = type("AnonymousOutputSpec",
                                    (base.TraitedSpec,),
                                    self.output_spec)

        self.inputs = self.input_spec(**inputs)
        self.estimated_memory_gb = 1
        self.num_threads = 1

        self._results = {}

    def _list_outputs(self):

        return self._results


class Test(SimpleInterface):

    input_spec = dict(in_file=base.traits.File())
    output_spec = dict(out_file=base.traits.File())


if __name__ == "__main__":

    # works
    savepkl("bet_input.pkl", BET().input_spec)

    # fails
    savepkl("test_input.pkl", Test().input_spec)
(py36) [kianilab-cs1 desktop]$ python picklefoo.py 
Traceback (most recent call last):
  File "picklefoo.py", line 43, in <module>
    savepkl("test_input.pkl", Test().input_spec)
  File "/home/mwaskom/anaconda2/envs/py36/lib/python3.6/site-packages/nipype/utils/filemanip.py", line 604, in savepkl
    pickle.dump(record, pkl_file)
_pickle.PicklingError: Can't pickle <class 'traits.has_traits.AnonymousInputSpec'>: attribute lookup AnonymousInputSpec on traits.has_traits failed

@effigies
Copy link
Member

effigies commented Aug 4, 2017

If this is causing problems, what about this style of input/output spec?

class MeanImage(SimpleInterface):
    class input_spec(TraitedSpec):
        in_file = traits.File()

    class output_spec(TraitedSpec):
        mean_val = traits.Float()

    def _run_interface(self, runtime):
        data = nib.load(self.inputs.in_file).get_data()
        self._results['mean_val'] = data.mean()
        return runtime

I haven't tried it, so I don't know if it has pickling troubles, but if it works and is an acceptable level of boilerplate, this PR might not be necessary.

@mwaskom
Copy link
Member Author

mwaskom commented Aug 4, 2017

That avoids the pickling error but it still smells like bad code to me. Inputs and outputs shouldn't need to be anything more than key, value pairs from the user perspective.

@mwaskom
Copy link
Member Author

mwaskom commented Aug 4, 2017

Simpler test case:

from nipype.interfaces import base
from nipype.utils.filemanip import savepkl


if __name__ == "__main__":

    spec = type("AnonymousInputSpec",
                (base.TraitedSpec,),
                dict(a="b"))

    savepkl("test_input.pkl", spec)

@mwaskom
Copy link
Member Author

mwaskom commented Aug 4, 2017

Not sure if informative, but:

In [18]: dill.detect.badobjects(spec)
Out[18]: traits.has_traits.AnonymousInputSpec

In [19]: dill.detect.badtypes(spec)
Out[19]: traits.has_traits.MetaHasTraits

@satra
Copy link
Member

satra commented Aug 4, 2017

likely some interaction with traits that's not giving it appropriate properties.

In [26]: spec().copyable_trait_names()
Out[26]: []

In [27]: spec().a
Out[27]: 'b'

@mwaskom
Copy link
Member Author

mwaskom commented Aug 4, 2017

Seems this is not a trait issue so much as a dynamic class issue:

In [39]: spec2 = type("mydict", (dict,), dict(a="B"))

In [40]: pickle.dumps(spec2)
---------------------------------------------------------------------------
PicklingError                             Traceback (most recent call last)
<ipython-input-40-b1bdcf244c26> in <module>()
----> 1 pickle.dumps(spec2)

PicklingError: Can't pickle <class '__main__.mydict'>: attribute lookup mydict on __main__ failed

@mwaskom
Copy link
Member Author

mwaskom commented Aug 4, 2017

Interestingly, dill can handle it:

In [42]: dill.dumps(spec2)
Out[42]: b'\x80\x03cdill.dill\n_create_type\nq\x00(cdill.dill\n_load_type\nq\x01X\x04\x00\x00\x00typeq\x02\x85q\x03Rq\x04X\x06\x00\x00\x00mydictq\x05h\x01X\x04\x00\x00\x00dictq\x06\x85q\x07Rq\x08\x85q\t}q\n(X\x01\x00\x00\x00aq\x0bX\x01\x00\x00\x00bq\x0cX\n\x00\x00\x00__module__q\rX\x08\x00\x00\x00__main__q\x0eX\x07\x00\x00\x00__doc__q\x0fNutq\x10Rq\x11.'

@mwaskom
Copy link
Member Author

mwaskom commented Aug 4, 2017

OK maybe here's an alternate approach that avoids dynamic metaclass stuff:

import pickle
from nipype.interfaces import base


class SimpleInterface(base.BaseInterface):

    def __init__(self, **inputs):

        if isinstance(self.input_spec, dict):

            input_spec = base.BaseInterfaceInputSpec()
            for key, val in self.input_spec.items():
                input_spec.add_trait(key, val)
            self.input_spec = input_spec


class Test(SimpleInterface):

    input_spec = dict(in_file=base.traits.File())


if __name__ == "__main__":

    pickle.dumps(Test().input_spec)

@mwaskom
Copy link
Member Author

mwaskom commented Aug 4, 2017

Wait that doesn't work as the InputSpec shouldn't be called in the constructor before setting the inputs (I guess?)

@satra
Copy link
Member

satra commented Aug 4, 2017

@mwaskom
Copy link
Member Author

mwaskom commented Oct 2, 2017

Closing as this ended up being more complicated and less useful than expected.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants