Source code for experimentator.section

"""
This module contains the |ExperimentSection| class, which is imported in `__init__.py`.

"""
import logging
import collections
import itertools

logger = logging.getLogger(__name__)


[docs]class ExperimentSection(): """ A section of the experiment, at any level of the hierarchy. Single trials and groups of trials (blocks, sessions, participants, etc.) are represented as |ExperimentSection| instances. A complete experiment consists of |ExperimentSection| instances arranged in a tree. The root element should be an |Experiment| (a subclass of |ExperimentSection|); the rest of the sections can be reached via its descendants (see below on the sequence protocol). A new |ExperimentSection| instance is automatically populated with |ExperimentSection| descendants according to the |DesignTree| passed to its constructor. |ExperimentSection| implements Python's sequence protocol; its contents are |ExperimentSection| instances at the level below. In other words, children can be accessed using the ``[index]`` notation, as well as with slices (``[3:6]``) or iteration (``for section in experiment_section``). However, |ExperimentSection| breaks the Python convention of 0-based indexing, using 1-based indexing to match the convention in experimental science. Parameters ---------- tree : |DesignTree| Describes the design of the experiment hierarchy. data : |ChainMap| All data to be associated with the |ExperimentSection|, including the values of independent variables, the section numbers indicating the section's location in the experiment, and any results associated with this section, arising from either the run callback of the |Experiment| or from the method |ExperimentSection.add_data|. `data` should be a |collections.ChainMap|, which behaves like a dictionary but has a hierarchical organization such that children can access values from the parent but not vice-versa. Attributes ---------- tree : |DesignTree| data : |ChainMap| dataframe : |DataFrame| All data associated with the |ExperimentSection| and its descendants. heterogeneous_design_iv_name : str IV name determining which branch of the |DesignTree| to follow. level : str The level of the hierarchy at which this section lives. levels : list of str Level names below this section. local_levels : set Level names of this section's children. Usually a single-element set. is_bottom_level : bool If true, this is the lowest level of the hierarchy. has_start : bool Whether this section has started to be run. has_finished : bool Whether this section has finished running. Notes ------- Use 1-based indexing to refer to |ExperimentSection| children, both when when using indexing or slicing with an |ExperimentSection|, and when identifying sections in keyword arguments to methods such as |ExperimentSection.subsection|. This better corresponds to the language commonly used by scientists to identify participants, trials, etc. """ def __init__(self, tree, data): self.data = data self.tree = tree self.level = self.tree[0].name self.is_bottom_level = len(self.tree) == 1 self._children = collections.deque() self.has_started = False self.has_finished = False if not self.is_bottom_level: # Create the section tree. Creating any section also creates the sections below it. self.append_design_tree(self.get_next_tree(), _renumber=False) self._number_children() @property def heterogeneous_design_iv_name(self): return self.tree.levels_and_designs[0].design[0].heterogeneous_design_iv_name @property def dataframe(self): from pandas import DataFrame data = DataFrame(section.data for section in self.walk() if section.is_bottom_level) return data.set_index(self.levels) @property def levels(self): levels = [] for section in self.walk(): for level in section.local_levels: if level not in levels: levels.append(level) return levels @property def local_levels(self): return {child.level for child in self} def __repr__(self): return 'ExperimentSection({}, {})'.format(self.tree.__repr__(), self.data.__repr__()) def __eq__(self, other): if isinstance(other, type(self)): return self.__dict__ == other.__dict__
[docs] def get_next_tree(self): """ Get a tree to use for creating child |ExperimentSection| instances. Returns ------- |DesignTree| """ next_tree = next(self.tree) if isinstance(next_tree, dict): return next_tree[self.data[self.heterogeneous_design_iv_name]] return next_tree
[docs] def append_design_tree(self, tree, to_start=False, _renumber=True): """ Append all sections associated with the top level of a |DesignTree| (and therefore also create descendant sections) to the |ExperimentSection|. Parameters ---------- tree : |DesignTree| The tree to append. to_start : bool, optional If True, the sections will be inserted at the beginning of the section. If False (the default), they will be appended to the end. Notes ----- After calling this method, the section numbers in the children's |ExperimentSection.data| attributes will be automatically replaced with the correct numbers. """ level, designs = tree.levels_and_designs[0] if self.level == level: raise ValueError('DesignTree to be appended is at the same level as the current section') if to_start: for design in reversed(designs): for new_data in reversed(design.get_order(self.data)): self.append_child(new_data, tree=tree, to_start=True, _renumber=False) else: for design in designs: for new_data in design.get_order(self.data): self.append_child(new_data, tree=tree, _renumber=False) if _renumber: self._number_children()
[docs] def append_child(self, data, tree=None, to_start=False, _renumber=True): """ Create a new |ExperimentSection| (and its descendants) and append it as a child of the current |ExperimentSection|. Parameters ---------- data : dict Data to be included in the new section's |ExperimentSection.data| |ChainMap|. Should include values of IVs at the section's level, for example. tree : |DesignTree|, optional If given, the section will be appended from the top level of `tree`. If not passed, the tree of the current section will be used. Note that this does not affect IV values; IV values must still be included in `data`. to_start : bool, optional If True, the new |ExperimentSection| will be appended to the beginning of the current section. If False (the default), it will be appended to the end. Notes ----- After calling this method, the section numbers in the children's |ExperimentSection.data| attributes will be automatically replaced with the correct numbers. """ if not tree: tree = self.get_next_tree() child_data = self.data.new_child() child_data.update(data) level = tree.levels_and_designs[0].name logger.debug('Generating {} with data {}.'.format(level, child_data)) child = ExperimentSection(tree, child_data) if to_start: self._children.appendleft(child) else: self._children.append(child) if _renumber: self._number_children()
def _number_children(self): for level in self.local_levels: children_at_level = [child for child in self if child.level == level] for i, child in enumerate(children_at_level): child.data.update({level: i + 1})
[docs] def add_data(self, data): """ Update the |ExperimentSection.data| |ChainMap|. This data will apply to this section and all child sections. This can be used, for example, to manually record a participant's age. Parameters ---------- data : dict Elements to be added to |ExperimentSection.data|. """ self.data.update(data)
[docs] def subsection(self, **section_numbers): """ Find a single, descendant |ExperimentSection| based on section numbers. Parameters ---------- **section_numbers Keyword arguments describing which subsection to find Must include every level higher than the desired section. Returns ------- |ExperimentSection| See Also -------- ExperimentSection.all_subsections Examples -------- Assuming the levels of the experiment saved in ``'example.exp'`` are ``('participant', 'session', 'block', 'trial')``, this will return the third block of the second participant's first session: >>> from experimentator import Experiment >>> exp = Experiment.load('example.exp') >>> some_block = exp.subsection(participant=2, session=1, block=3) """ key = lambda node: all(level in node.data and node.data[level] == number for level, number in section_numbers.items()) result = self.depth_first_search(key) if result: return result[-1] raise ValueError('Could not find specified section.')
[docs] def all_subsections(self, **section_numbers): """ Find all subsections in the experiment matching the given section numbers. Yields specified |ExperimentSection| instances. The yielded sections will be at the lowest level given in `section_numbers`. If levels not in `section_numbers` are encountered before reaching its lowest level, all sections will be descended into. Parameters ---------- **section_numbers Keyword arguments describing what subsections to find. Keys are level names, values are ints or sequences of ints. See Also -------- ExperimentSection.subsection Examples -------- Assuming the levels of the experiment saved in ``'example.exp'`` are ``('participant', 'session', 'block', 'trial')``: >>> from experimentator import Experiment >>> exp = Experiment.load('example.exp') Get the first session of each participant: >>> all_first_sessions = list(exp.all_subsections(session=1)) Get the second trial of the first block in each session: >>> trials = list(exp.all_subsections(block=1, trial=2)) Get the first three trials of each block in the first session of each participant: >>> more_trials = list(exp.all_subsections(session=1, trial=[1, 2, 3])) """ # The state of the recursion is passed in the keyword argument '_section'. section = section_numbers.pop('_section', self) if not section.is_bottom_level and section.tree[1][0] in section_numbers: # Remove the section from section_numbers...it needs to be empty to signal completion. numbers = section_numbers.pop(section.tree[1][0]) if isinstance(numbers, int): # Only one number specified. if section_numbers: # We're not done. yield from self.all_subsections(_section=section[numbers], **section_numbers) else: # We're done. yield section[numbers] else: # Multiple numbers specified. if section_numbers: # We're not done. for n in numbers: yield from self.all_subsections(_section=section[n], **section_numbers) else: # We're done. yield from (section[n] for n in numbers) else: # Section not specified but we're not done; descend into every child. for child in section: yield from self.all_subsections(_section=child, **section_numbers)
[docs] def find_first_not_run(self, at_level, by_started=True): """ Search the experimental hierarchy, and return the first descendant |ExperimentSection| at `at_level` that has not yet been run. Parameters ---------- at_level : str Which level to search. by_started : bool, optional If True (default), returns the first section that has not been started. Otherwise, finds the first section that has not finished. Returns ------- |ExperimentSection| """ key = lambda node: node.level == at_level if by_started: path_key = lambda node: not node.has_started else: path_key = lambda node: not node.has_finished result = self.depth_first_search(key, path_key=path_key) if result: return result[-1]
[docs] def find_first_partially_run(self, at_level): """ Search the experimental hierarchy, and return the first descendant |ExperimentSection| at `at_level` that has been started but not finished. Parameters ---------- at_level : str Which level to search. Returns ------- |ExperimentSection| """ key = lambda node: node.level == at_level path_key = lambda node: node.has_started and not node.has_finished result = self.depth_first_search(key, path_key=path_key) if result: return result[-1]
[docs] def walk(self): """ Walk the tree depth-first, starting from here. Yields this section and every descendant section. """ yield self for child in self: yield from child.walk()
[docs] def parent(self, section): """ Find the parent of a section. Parameters ---------- section : |ExperimentSection| The section to find the parent of. Returns ------- |ExperimentSection| """ parents = self.parents(section) if parents: return parents[-1]
[docs] def parents(self, section): """ Find all parents of a section, in top-to-bottom order. Parameters ---------- section : |ExperimentSection| The section to find the parents of. Returns ------- list of |ExperimentSection| """ if section.level == '_base': return [] return self.breadth_first_search(lambda node: section in node)
def _convert_index_object(self, item): """ Change an indexing object (slice or int) from using 1-based indexing to using 0-based indexing. """ try: if isinstance(item, slice): return slice(self._convert_index(item.start), self._convert_index(item.stop), item.step) else: return self._convert_index(item) except IndexError: raise IndexError('Use 1-based indexing with ExperimentSection instances') @staticmethod def _convert_index(idx): if idx is None: return None if idx < 0: return idx if idx == 0: raise IndexError('Use 1-based indexing with ExperimentSection instances') return idx - 1 def __len__(self): return len(self._children) def __getitem__(self, item): item = self._convert_index_object(item) if isinstance(item, slice): return list(itertools.islice(self._children, *item.indices(len(self)))) else: return self._children[item] def __bool__(self): return True def __iter__(self): return self._children.__iter__() def __delitem__(self, key): del self._children[self._convert_index_object(key)] self._number_children() def __setitem__(self, key, value): self._children[self._convert_index_object(key)] = value self._number_children() def __reversed__(self): return reversed(self._children) def __contains__(self, item): return item in self._children