gentnerlab / pyoperant

python package for operant conditioning
BSD 3-Clause "New" or "Revised" License
13 stars 15 forks source link

refactor behavior to isolate intra-trial logic from trial selection logic #104

Open neuromusic opened 8 years ago

neuromusic commented 8 years ago

every Trial instance should be a self-contained object that runs one trial of an experiment.

each trial will be initialized with the minimal set of conditions needed to provide a stimulus and consequate a response.

for example, a trial that plays stimulus 'a.wav' and provides a feed would be initialized with something like...

trial = Trial(panel=panel,
              stimulus='a.wav',
              consequences={
                  'left': {
                      'feed': 2.0,
                      'flash': 1.0,
                      'timeout': False,
                      },
                  'right': {
                      'feed': False,
                      'flash': False,
                      'timeout': 10.0
                      },
                  },
              )

The consequence argument takes a nested dictionary of the form {response:{consequence:value}}

This design relies on a couple of assumptions

This means that the Trial object needs to know:

Once a trial is initialized, it would be run with trial.run(), which would execute the trial and save the relevant outcomes to trial attributes. The rest of the script would then access these values in order to save the trial.

This structure has a few advantages:

  1. This structure moves the logic of checking the trial "type" (correction, probe, etc) OUT of the trial execution and into the experimental trial generation logic. This means that we will need to know how we will want to consequate this trial before this trial starts.
  2. Isolating the intra-trial execution should allow us to more readily design new Trial classes for more complex interactions while using existing trial generation logic or visa versa.
  3. Further down the line, this may even allow a Trial object to simply send of relevant parameters to an Arduino or other embedded system that maintains the trial logic.

Pushing the consequation logic out of the trial execution changes the way we think about consequences a little bit. For example, a "correction" trial (where there is no feed for a correct response, but still a secondary reinforcer) would be initialized like...

trial = Trial(panel=panel,
              stimulus='a.wav',
              consequences={
                  'left': {
                      'feed': False,
                      'flash': 1.0,
                      'timeout': False,
                      },
                  'right': {
                      'feed': False,
                      'flash': False,
                      'timeout': 10.0
                      },
                  },
              )

on the other hand, a probe trial might be reinforced with...

trial = Trial(panel=panel,
              stimulus='a.wav',
              consequences={
                  'left': {
                      'feed': 2.0,
                      'flash': 1.0,
                      'timeout': False,
                      },
                  'right': {
                      'feed': 2.0,
                      'flash': 1.0,
                      'timeout': False,
                      },
                  },
              )

More complex Trial objects might need a more complex set of arguments than just "stimulus"

trial = Trial(panel=panel,
              stim='a.wav',
              target='b.wav',
              cue='blue',
              consequences={
                  'left': {
                      'feed': 2.0,
                      'flash': 1.0,
                      'timeout': False,
                      },
                  'right': {
                      'feed': 2.0,
                      'flash': 1.0,
                      'timeout': False,
                      },
                  },
              )

Concerns:

@MarvinT wants callbacks. I really think that the trial object should not be calling back out of the trial, but there might be a need, e.g., to make a feed duration depend on reaction time in a graded fashion.

This could work by allowing a callback that takes the trial as an argument to be passed.

NORMAL_CORRECT = {'feed': 2.0,'flash': 1.0,'timeout': False}
NORMAL_WRONG = {'feed': False,'flash': False,'timeout': 10.0}

def normal_left_consequences(response,trial):
    if response =='left':
        return NORMAL_CORRECT
    elif response=='right':
        return NORMAL_WRONG

class Trial(object):
    def __init__(self, panel, stimulus, consequences):
        self.consequences = consequences
    def consequate(self):
        return self.consequences(self.response,self)
neuromusic commented 8 years ago

The Trial object needs to know about the possible "responses"

If they are discrete & named, then we can do something like...

CORRECT = {'feed': 2.0,'flash': 1.0,'timeout': False}
WRONG = {'feed': False,'flash': False,'timeout': 10.0}

trial = Trial(panel=panel,
              stimulus='a.wav',
              on_left=CORRECT,
              on_right=WRONG,
              )

or we could pass in a single callback that takes the trial as its sole argument:

def correct(trial):
    if trial.reaction_time > 0.5:
        return CORRECT.update({'feed'=1.0})
    else
        return CORRECT

trial = Trial(panel=panel,
              stimulus='a.wav',
              on_left=correct,
              on_right=WRONG,
              )

I know @MarvinT doesn't like this, but I find it to be much more readable and composable.

This will definitely break down if responses are not named (i.e. floats based on a joystick moving or pressure sensor), but the dictionary approach won't work there either.

neuromusic commented 8 years ago

Other Trial examples...

Additional parameters

trial = Trial(panel=panel,
              stimulus='a.wav',
              on_left=CORRECT,
              on_right=WRONG,
              response_window=3.0
              )

Shape Trials

trial = Shape2ACBlock3Trial(panel=panel,
                            response_window=None, # wait forever
                            on_left=CORRECT,
                            on_right=CORRECT,
                            )

...

trial = Shape2ACBlock4Trial(panel=panel,
                            response_window=None, # wait forever
                            stimulus='left'
                            on_left=CORRECT,
                            )
siriuslee commented 8 years ago

I definitely need more time to sit with the ideas you proposed, but am I correct in reading that you are wanting to move away from the Behavior being the commonly customized and subclassed object and toward the Trial being customized and subclassed?

neuromusic commented 8 years ago

I guess "common" is relative.

If you want to change what constitutes a trial, you subclass the Trial object. If you want to change logic about which stimuli get presented with which consequences in what order, you subclass the Behavior object.

In the past two years, we've had around a half dozen people with minimal programming understanding want to develop new behaviors. 80% of the time, this has simply meant putting new logic in the "get_stimuli" method. The other 20% of the time, the changes have to do with trial selection: block structures, etc.

So the goal is to draw a stronger line between within-trial logic and across-trial logic.

The proximal need is that @MarvinT needs to integrate probe trials, where reinforcement is non-differential. Perhaps he can comment more on why the current structuring would need refactoring to make this work.

neuromusic commented 8 years ago

here's a sketch (without the full Behavior class) of how I envision this working

https://gist.github.com/neuromusic/b59d6fe6f8a2e76d2e04

neuromusic commented 8 years ago

note: this should also help with #73, #14, #84