Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion docs/CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ Changelog
This is a log of the latest changes and improvements to KLibs.


0.8.0a1
0.7.8a1
-------

(Unreleased)
Expand All @@ -18,6 +18,10 @@ Runtime Changes:
*all* remaining trials, which could unexpectedly affect the even distribution
of trial factors within blocks.
* Practice blocks are no longer added when `P.run_practice_blocks` is False.
* During runtime, `P.trials_per_block` is now updated at the start of each
block to reflect the actual number of trials in the current block.
* Added new parameter `P.max_trials_per_block` for temporarily limiting the
number of trials to run per block for testing and development purposes.

API Changes:

Expand All @@ -36,6 +40,17 @@ API Changes:
* Added a new method :method:`~klibs.KLExperiment.write_trials_txt` for
exporting the full sequence of generated trials and blocks to a human
readable text file.
* Added a new flexible API for specifying custom block structures using the new
:class:`~klibs.KLStructure.Block` class. If a variable named `structure`
exists in a project's `_independent_variables.py` file and is defined as a
list of :class:`~klibs.KLStructure.Block` objects, the KLibs runtime will
use the specified structure to define the block/trial sequence for the
experiment.
* Added a new exception type :class:`~klibs.KLExceptions.TerminateBlock` that
can be raised to end a block early, allowing for blocks with flexible lengths
based on participant performance (e.g. practice blocks that continue until a
minimum performance threshold is met).


Fixed Bugs:

Expand All @@ -48,6 +63,7 @@ Fixed Bugs:
and 1000 ms already elapsed on the clock, making stimuli appear sooner than
expected.


0.7.7b1
-------

Expand Down
15 changes: 15 additions & 0 deletions klibs/KLExceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,21 @@ def __init__(self, msg):
def __str__(self):
return self.message

class TerminateBlock(Exception):
"""Exception that ends a block of trials early when raised.

Intended for studies using titration or other situations where block length is
based on participant performance and should end when a threshold is met.

.. note:: When TermiateBlock is raised the block will end immediately, meaning that
if raised within a trial any data from that trial will not be saved.
To ensure that data for the final trial is recorded, you can check for the
termination criteria and raise the exception within `self.trial_prep()`.

"""
def __init__(self, msg=""):
self.message = msg

class EyeTrackerError(Exception):
"""Raised when a problem relating to an :obj:`~klibs.KLEyeTracking.KLEyeTracker.EyeTracker`
object or the misuse of eye event inspect/report types is encountered.
Expand Down
54 changes: 41 additions & 13 deletions klibs/KLExperiment.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

from klibs import P
from klibs.KLEnvironment import EnvAgent
from klibs.KLExceptions import TrialException
from klibs.KLExceptions import TrialException, TerminateBlock
from klibs.KLInternal import full_trace, iterable
from klibs.KLInternal import colored_stdout as cso

Expand Down Expand Up @@ -37,9 +37,8 @@ def __init__(self):
self._evm = EventManager()

self._exp_factors = self._get_exp_factors()
self._exp_structure = self._get_exp_structure()
self.trial_factory = TrialFactory(self._exp_factors)
if P.manual_trial_generation is False:
self.trial_factory.generate()


def _get_exp_factors(self):
Expand All @@ -56,6 +55,22 @@ def _get_exp_factors(self):
return factors


def _get_exp_structure(self):
# Reads in the block structure for the study (if provided)
from klibs.KLTrialFactory import _load_structure

# Load experiment structure from the project's _independent_variables.py file(s)
structure = _load_structure(P.ind_vars_file_path)
if structure and os.path.exists(P.ind_vars_file_local_path):
if not P.dm_ignore_local_overrides:
# If structure exists in overrides, use that instead
local = _load_structure(P.ind_vars_file_local_path)
if local:
structure = local

return structure


def __execute_experiment__(self, *args, **kwargs):
"""For internal use, actually runs the blocks/trials of the experiment in sequence.

Expand All @@ -69,11 +84,14 @@ def __execute_experiment__(self, *args, **kwargs):
for block in self.blocks:
P.recycle_count = 0
P.block_number += 1
P.trials_per_block = len(block)
P.practicing = block.practice
self.block_label = block.label
self.block()
P.trial_number = 1
remaining = list(block.trials)
if P.max_trials_per_block != False:
remaining = remaining[:P.max_trials_per_block]
while len(remaining):
trial = remaining.pop(0)
try:
Expand All @@ -83,6 +101,8 @@ def __execute_experiment__(self, *args, **kwargs):
except TrialException:
remaining = self._recycle_trial(remaining, trial)
P.recycle_count += 1
except TerminateBlock:
remaining = []
self.rc.reset()
self.clean_up()

Expand Down Expand Up @@ -119,13 +139,13 @@ def __trial__(self, trial):
self.evm.start_clock()

# Actually run the trial and log the data to the database
recycle = None
exc = None
try:
P.in_trial = True
self.__log_trial__(self.trial())
P.in_trial = False
except TrialException as e:
recycle = e
except (TrialException, TerminateBlock) as e:
exc = e

# Clean up after the trial
self.evm.stop_clock()
Expand All @@ -135,9 +155,9 @@ def __trial__(self, trial):
hide_cursor()
self.trial_clean_up()

# Recycle trial if TrialException encountered
if recycle:
raise recycle
# Raise TerminateBlock or TrialException if encountered
if exc:
raise exc


def __log_trial__(self, trial_data):
Expand Down Expand Up @@ -285,10 +305,11 @@ def insert_practice_block(self, block_nums, trial_counts=None, factor_mask=None)
# keyword, however.

if self.blocks:
# If setup has passed and trial execution has started, blocks have already been exported
# from trial_factory so this function will no longer work. If it is called after it is no
# longer useful, we throw a TrialException
raise RuntimeError("Cannot insert practice blocks after setup() is complete.")
if self._exp_structure:
e = "in setup() when using a custom block structure."
else:
e = "after setup() is complete."
raise RuntimeError("Cannot insert practice blocks " + e)

if not trial_counts:
trial_counts = P.trials_per_block
Expand Down Expand Up @@ -401,13 +422,20 @@ def run(self, *args, **kwargs):

"""
from klibs.KLGraphics.KLDraw import Ellipse
from klibs.KLTrialFactory import _parse_structure

if P.eye_tracking:
RED = (255, 0, 0)
WHITE = (255, 255, 255)
self.tracker_dot = Ellipse(8, stroke=[2, WHITE], fill=RED).render()
if not P.manual_eyelink_setup:
self.el.setup()

# Generate blocks of trials (from either custom structure or trial factory)
if self._exp_structure:
self.blocks = _parse_structure(self._exp_structure, self.exp_factors)
elif P.manual_trial_generation is False:
self.trial_factory.generate()

self.setup()
try:
Expand Down
1 change: 1 addition & 0 deletions klibs/KLParams.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
conditions = []
default_condition = None
table_defaults = {} # default column values for db tables when using EntryTemplate
max_trials_per_block = False
run_practice_blocks = True
color_output = False # whether cso() outputs colorized text or not

Expand Down
94 changes: 94 additions & 0 deletions klibs/KLStructure.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import random
import itertools
from copy import deepcopy
from collections import OrderedDict

import klibs.KLParams as P
from klibs.KLInternal import iterable


Expand Down Expand Up @@ -124,3 +126,95 @@ def names(self):
def set_length(self):
"""int: The number of trials required for the full factor set."""
return len(self._get_combinations())



class Block(object):
"""Defines a custom block of trials.

This class allows you to specify custom block structures for studies that
have different blocks types within the same session. For example, if your
study involves exposure, training, and test blocks, each with slightly
different factors levels and trial lengths, you can use this class to
define the block sequence accordingly::

exp_factors = FactorSet({
'sequence_type': ['practiced', 'unpracticed'],
})
practiced_only = exp_factors.override({'sequence_type': ['practiced']})

structure = [
Block(exp_factors, label='exposure', trials=18),
Block(practiced_only, label='training', trials=90),
Block(exp_factors, label='test', trials=60),
]

Different blocks can have different factor levels, but all blocks must
contain the same factors. If a factor is unneeded for a given block, it
should be given a dummy level (e.g. `None`).

If provided, the label for the block will be set as `self.block_label` within
the Experiment object during runtime. Labels are meant to allow for easy
handling of different block types within the experiment code::

# If first block or block type changed, show instructions
if self.last_block_label != self.block_label:
if self.block_label == "exo":
self.exo_instructions()
elif self.block_label == "endo":
self.endo_instructions()

self.last_block_label = self.block_label

If you identify a block as a practice block, the runtime parameter
`P.practicing` will be set to True during that block.

Args:
factors (:obj:`FactorSet` or dict): The factor set to use for the block.
label (str, optional): The label for the block. Defaults to None.
trials (int, optional): The trial count for the block. If not specified,
defaults to `P.trials_per_block`.
practice (bool, optional): Indicates whether the block is a practice
block. Defaults to False.

"""
def __init__(self, factors, label=None, trials=None, practice=False):
self.practice = practice
self.label = label
if not isinstance(factors, FactorSet):
factors = FactorSet(factors)
self._factors = factors
if trials:
self.trialcount = trials
elif P.trials_per_block > 0:
self.trialcount = P.trials_per_block
else:
self.trialcount = self._factors.set_length

def get_trials(self):
"""Generates a shuffled set of trials from the block.

Each complete set of factors is generated and shuffled sequentially
to prevent the possibility of all trials of the same type ending up
together (e.g. all trials with invalidly cued targets happening
consecutively).

Returns:
list: A list of dicts containing the trial factors for each trial.

"""
trials = []
while len(trials) < self.trialcount:
new = self._factors._get_combinations()
remaining = self.trialcount - len(trials)
random.shuffle(new)
if remaining < len(new):
new = new[:remaining]
trials += new

return trials

@property
def factors(self):
"""list: The names of all trial factors used in the block."""
return self._factors.names
55 changes: 54 additions & 1 deletion klibs/KLTrialFactory.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

from klibs import P
from klibs.KLInternal import load_source
from klibs.KLStructure import FactorSet
from klibs.KLStructure import FactorSet, Block


def _load_factors(path):
Expand All @@ -31,6 +31,59 @@ def _load_factors(path):
return factors


def _load_structure(path):
# Imports a custom task structure from a file, returning an empty list if
# a structure is not specified.

ind_vars = load_source(path)
if not 'structure' in ind_vars.keys():
return []

structure = ind_vars['structure']
if structure:
try:
structure = list(structure)
except Exception:
e = "If specified, task structure must be a list of Blocks."
raise TypeError(e)

return structure


def _parse_structure(structure, exp_factors):
# Parses/validates a custom task structure and returns a list of TrialSets

count = 0
blocks = []
for block in structure:

if not isinstance(block, Block):
raise TypeError("Task structure must be made of Blocks")

# Ensure all blocks have same factor levels
count += 1
extra = set(block.factors) - set(exp_factors)
missing = set(exp_factors) - set(block.factors)
if len(extra):
e = "Extra factors" if len(extra) > 1 else "Extra factor"
e += " in block {0} not present in exp_factors: {1}"
raise RuntimeError(e.format(count, str(list(extra))))
if len(missing):
e = "Missing the following factors in block {0}: {1}"
raise RuntimeError(e.format(count, str(list(missing))))

# Skip practice blocks if disabled
if block.practice and not P.run_practice_blocks:
continue

# Generate trials and add block to block sequence
trials = block.get_trials()
b = TrialSet(trials, block.practice, block.label)
blocks.append(b)

return blocks


def _generate_blocks(factors, block_count, trial_count):
# Generates a list of blocks (which are lists of trials, which are dicts of
# trial factors) based on a given factor set, trial count, & block count.
Expand Down
Loading