Using SweetPea

So you’ve decided to design a factorial experiment. That’s excellent! Factorial experimental designs are a great way to build repeatable experiments with multiple independent variables. Let’s design our experiment in words first, and then build it in SweetPea.

Tip

If you aren’t familiar with factorial experimental design, that’s okay! Just check out our short primer first.

A Simple Stroop Experiment

For our example, we’ll be testing the Stroop effect. From the Wikipedia article:

In psychology, the Stroop effect is the delay in reaction time between congruent and incongruent stimuli.

One of the most well-known experiments to test the Stroop effect is to show a participant a series of words for colors that are also displayed in color. Sometimes, the color of the word is the same as the color in which the word is written — this is called congruence. Other times, the word and color are different, which is incongruence.

We have two apparent independent variables: the color and the text. We call independent variables factors in the realm of factorial design.

Note

There is also a third factor: whether the pairing of the color and text is congruent or incongruent. This is a form of derived factor, and we’ll come back to it later.

To test this effect, we can construct a series of trials to administer to a participant, where each trial is a single color+text pairing. For this experiment, we will use the three colors red, green, and blue, and we will also use the names of those colors as the text. All together, this gives us 9 possible values for each trial:

Stroop Effect Trials

Red (Text)

Green (Text)

Blue (Text)

Red (Color)

red

green

blue

Green (Color)

red

green

blue

Blue (Color)

red

green

blue

In the parlance of factorial design, these three colors constitute levels in each of the factors. That is to say that the color factor has three levels, and the text factor has three levels.

A trial will consist of showing one of the color and text pairs to a participant and asking them to identify the color in which the text is written. We will synthesize some trial sequences for our experiment now.

Building the Simple Stroop Experiment

To build this simple Stroop experiment, we import and use the following SweetPea language forms:

To put it together, we do:

>>> from sweetpea import Factor, CrossBlock, synthesize_trials
>>> text = Factor("text", ["red", "blue", "green"])
>>> color = Factor("color", ["red", "blue", "green"])
>>> block = CrossBlock([color, text], [color, text], [])
>>> experiments = synthesize_trials(block, 1)

The result of this synthesis is based on pseudo-random number generation, and so the output will not be the same every time. However, when we ran the code to write this tutorial, we saw the following output (your output should look similar, though probably not identical):

>>> from sweetpea import print_experiments
>>> print_experiments(block, experiments)
1 trial sequences found.
Experiment 0:
color green | text blue
color blue  | text green
color green | text red
color green | text green
color red   | text green
color red   | text blue
color blue  | text blue
color red   | text red
color blue  | text red

Tip

The print_experiments() function is useful for printing the results of synthesis.

We generated a fully-crossed experiment: all possible color-text pairs were generated, though their order was randomized. We can see this by sorting a simplified representation of the experiment:

>>> from sweetpea import experiments_to_tuples
>>> # We immediately access the first element of the returned list.
>>> # This is because we only generated one trial run.
>>> simple = experiments_to_tuples(block, experiments)[0]
>>> for pair in sorted(simple):
...     print(pair)
...
...
('blue', 'blue')
('blue', 'green')
('blue', 'red')
('green', 'blue')
('green', 'green')
('green', 'red')
('red', 'blue')
('red', 'green')
('red', 'red')

Because the color factor has \(3\) levels and the text factor has \(3\) levels, when we fully cross the factors we get \(3 \times 3 = 9\) resulting trials.

SweetPea Feature Recap

In building our simple Stroop experiment, we used a few of the most important SweetPea forms. Let’s review them now.

Simple Factors and Levels

Simple factors are factors that are composed only of simple levels. Simple levels are levels that are essentially just names and nothing more; they are not dependent on any other factors or levels.

While it is possible to import the Level class, it is usually not necessary (unless you want to assign weights to levels). Simple levels can only be put into simple factors, which in turn can only consist of simple levels, and we can create simple levels implicitly during Factor initialization.

When you construct a Factor, you also pass a list of levels to it. If those levels are not instances of the Level class, SweetPea will automatically convert them into instances of Level.

To put all this information together: you can create a simple factor composed of simple levels by just using the Factor initializer:

>>> from sweetpea import Factor
>>> factor = Factor("factor_name", ("one", 2, 3.0, True))
>>> len(factor.levels)
4
>>> factor["one"].name
'one'
>>> factor["one"].factor is factor
True

Warning

Although you can index into a factor by a level’s name to access the level, the indexing function expects its argument to be a string! This means that you cannot retrieve the second level of the above factor by the value we used to create it:

>>> factor[2].name
Traceback (most recent call last):
  ...
KeyError: 'Factor factor_name has no level named 2.'

Instead, you must use the string representation generated by the value’s __str__ method, which can be called using the str() function:

>>> factor[str(2)].name
'2'

We will discuss complex factors (also known as derived factors) a bit later.

Block Creation

After you get your factors and levels together, you can create an experimental design Block using one of the appropriate functions. We showed how it looks to use fully_cross_block() in our simple example above. The function takes a number of arguments, but in the simplest case you need only do:

>>> from sweetpea import Factor, fully_cross_block
>>> f1 = Factor("f1", (1, 2, 3))
>>> f2 = Factor("f2", ("a", "b", "c"))
>>> block = fully_cross_block([f1, f2], [f1, f2], [])

That is to say that when you’re only dealing with a simple experiment (an experiment comprised only of simple factors), you can probably just use a list of your factors as both the design and your crossing, and then hold the constraints empty with an empty list.

Trial Synthesis

Once you have a complete experimental design in the form of a Block, you’re ready to use it to synthesize trials. In the above example, we used the synthesize_trials() with the default parameter sampling_strategy as IterateGen, which conducts non-uniform SAT-sampling to synthesize the trials.

Working With Derived Levels

We’ve covered simple factors and levels, so now we move on to the more complex capabilities of SweetPea: derivations and constraints.

Derivation

Derivation is the process of creating new levels that depend in some way upon information contained in other levels from other factors — and sometimes other trials. In other words, derivation is what produces DerivedLevels.

Derivation is perhaps best explained through example. We resume the Stroop example from above, and return to the issue of congruency. Recall that we had produced two simple factors of three levels each. Now we would like to create a factor for congruency that has two levels: congruent and incongruent. A trial’s congruency is determined by the same trial’s color and text: if they align, then the congruency is congruent. Otherwise, the trial is incongruent.

Let’s create the congruency factor now. We start by recreating the color and text simple factors from before:

>>> from sweetpea import Factor
>>> text = Factor("text", ["red", "blue", "green"])
>>> color = Factor("color", ["red", "blue", "green"])

Next, we need to define the predicate functions that will be used to determine whether a color-text pair is congruent.

>>> def congruent(color: str, word: str) -> bool:
...     return color == word
...
>>> def incongruent(color: str, word: str) -> bool:
...     return not congruent(color, word)
...

Now, we can construct the derived levels. While simple levels can be constructed directly by the Factor during initialization, DerivedLevel instances must be manually instantiated. DerivedLevels also require a derivation window as an argument. We will discuss this more in-depth in a little bit, so for now just trust us that we want to use the WithinTrial for this particular job:

>>> from sweetpea import DerivedLevel, WithinTrial
>>> con_level = DerivedLevel("congruent", WithinTrial(congruent, [color, text]))
>>> inc_level = DerivedLevel("incongruent", WithinTrial(incongruent, [color, text]))

Finally, we can construct the congruency factor:

>>> congruency = Factor("congruency", [con_level, inc_level])

Now when we create a full crossing using CrossBlock, we will include the congruency factor with the rest of the design. However, it is not part of the crossing itself. The result of synthesizing trials from such a crossing will be a random arrangement of the following trials:

Stroop Effect Trials With Congruency

Color

Text

Congruency

red

red

congruent

red

green

incongruent

red

blue

incongruent

green

red

incongruent

green

green

congruent

green

blue

incongruent

blue

red

incongruent

blue

green

incongruent

blue

blue

congruent

We can verify this by using the experiments_to_tuples() function on the result of synthesizing one trial run from this design:

>>> from sweetpea import CrossBlock, synthesize_trials, experiments_to_tuples
>>> design = [color, text, congruency]
>>> crossing = [color, text]
>>> block = CrossBlock(design, crossing, [])
>>> experiments = synthesize_trials(block, 1)
>>> for pair in sorted(experiments_to_tuples(block, experiments)[0]):
...     print(pair)
...
...
('blue', 'blue', 'congruent')
('blue', 'green', 'incongruent')
('blue', 'red', 'incongruent')
('green', 'blue', 'incongruent')
('green', 'green', 'congruent')
('green', 'red', 'incongruent')
('red', 'blue', 'incongruent')
('red', 'green', 'incongruent')
('red', 'red', 'congruent')

Constraints

Sometimes when designing an experiment, you’d like to impose some constraints on the mechanisms that generate trial sequences. SweetPea has you covered.

Let’s say we look at the above list of trials and decide “Hmm, maybe we should ensure we don’t get too many incongruent trials in a row.” After all, there are six incongruent trials to just three congruent ones!

Arbitrarily, we will choose to limit trial sequences such that only two incongruent trials may appear in a row. This will be accomplished using the AtMostKInARow() function.

>>> # We resume from the previous session.
>>> from sweetpea import AtMostKInARow
>>> congruency_constraint = AtMostKInARow(2, congruency)
>>> block = CrossBlock(design, crossing, [congruency_constraint])
>>> experiments = synthesize_trials(block, 3)
Sampling 3 trial sequences
>>> print_experiments(block, experiments)  
3 trial sequences found.
Experiment 0:
color red   | text green | congruency incongruent
color green | text blue  | congruency incongruent
color red   | text red   | congruency congruent
color blue  | text green | congruency incongruent
color red   | text blue  | congruency incongruent
color green | text green | congruency congruent
color green | text red   | congruency incongruent
color blue  | text red   | congruency incongruent
color blue  | text blue  | congruency congruent

Experiment 1:
color red   | text green | congruency incongruent
color blue  | text red   | congruency incongruent
color red   | text red   | congruency congruent
color blue  | text green | congruency incongruent
color red   | text blue  | congruency incongruent
color green | text green | congruency congruent
color green | text red   | congruency incongruent
color blue  | text blue  | congruency congruent
color green | text blue  | congruency incongruent

Experiment 2:
color red   | text green | congruency incongruent
color blue  | text red   | congruency incongruent
color red   | text red   | congruency congruent
color blue  | text green | congruency incongruent
color red   | text blue  | congruency incongruent
color blue  | text blue  | congruency congruent
color green | text red   | congruency incongruent
color green | text blue  | congruency incongruent
color green | text green | congruency congruent

We can see from these outputs that we never get more than two trials in a row with the same congruency level selected. However, note that the constraint is not imposed across experiment boundaries: the final trial of the second experiment is incongruent, and the first two trials of the third experiment are also incongruent. This adds up to three consecutive trials! But this behavior is expected. The AtMostKInARow constraint only looks within a given experiment, not across experiments.

Working With Multiple Crossings

SweetPea supports multiple crossings for situations where different subsets of factors should be crossed independently. This is useful when building more complex experimental designs that consist of multiple fully-crossed components.

Defining MultiCrossBlock

Using MultiCrossBlock creates an experiment description as a block of trials based on multiple crossings. This allows you to combine different subsets of factors into a unified experiment while maintaining independent full crossings within each subset.

Multiple crossings are useful in complex experimental designs where some factors are fully crossed only within specific conditions. By passing a list of crossings to the crossing argument in MultiCrossBlock, each inner list defines a separate crossing.

The MultiCrossBlock constructor is a shorthand for :class:.`.Merge` of separately constructed blocks, each with its own crossing(s). The Merge constructor is more general, because it allows each merged block to supply constraints with the block’s original geometry.

Crossing Sizes in MultiCrossBlock

When using multiple crossings, SweetPea ensures that each sub-crossing is internally fully crossed. The number of trials required for the block, T, is decide by the crossing size of larger crossing. When a crossing’s size S is smaller than the number of trials T, then the crossing’s combinations are replicated using the smallest multiple N such that so that S * N >= T. If S * N > T, then only the first T generated combinations will be used. There are two possible strategies for replicating a crossing, and mode selects between them. RepeatMode.WEIGHT weights combinations, so that up to N instances of a combination can appear anywhere in the T trials. RepeatMode.REPEAT ensures that each of the S combinations appears once in the first S trials, then once again in the next S trials, and so on, up to N times.

The difference of these two strategies for replication are shown in the following example. The first configurations adds weight to f2 so levels that "a", "b", and "c" do not necessarily appear in the first three trials. The second configuration repeats f2 so that "a", "b", and "c" all reliably appear within in the first three trials (and each subsequent group of three).

>>> from sweetpea import (Factor, MultiCrossBlock, RepeatMode, synthesize_trials,
>>> print_experiments, CMSGen, IterateGen, RandomGen, IterateSATGen)
>>> f1 = Factor("f1",   ["A", "B", "C", "D"])
>>> f2 = Factor("f2",   ["a", "b", "c"])
>>> f3 = Factor("f3", ['1', '2'])
>>> constraints=[]
>>> design = [f1, f2, f3]
>>> crossing = [[f1, f3], [f2]]
>>> constraints = []
>>> block = MultiCrossBlock(design, crossing, constraints, mode=RepeatMode.WEIGHT)
>>> experiments = synthesize_trials(block, 1, RandomGen)
>>> print_experiments(block, experiments)
Sampling 1 trial sequences using RandomGen.
Counting possible configurations...
Generating samples...

1 trial sequences found.

Experiment 0:
f1 C | f3 2 | f2 c
f1 D | f3 1 | f2 a
f1 D | f3 2 | f2 a
f1 B | f3 2 | f2 b
f1 A | f3 2 | f2 c
f1 A | f3 1 | f2 b
f1 C | f3 1 | f2 a
f1 B | f3 1 | f2 c
>>> block = MultiCrossBlock(design, crossing, constraints, mode=RepeatMode.REPEAT)
>>> experiments = synthesize_trials(block, 1, RandomGen)
>>> print_experiments(block, experiments)
Sampling 1 trial sequences using RandomGen.
Counting possible configurations...
Generating samples...

1 trial sequences found.

Experiment 0:
f1 C | f3 1 | f2 b
f1 C | f3 2 | f2 c
f1 A | f3 2 | f2 a
f1 D | f3 2 | f2 a
f1 B | f3 2 | f2 b
f1 D | f3 1 | f2 c
f1 A | f3 1 | f2 a
f1 B | f3 1 | f2 b

Preamble Trials in MultiCrossBlock

In a CrossBlock, if a derived factor in the crossing has a window size N > 1, then N – 1 preamble trials are added to ensure its level is defined on the first trial. This can be adjusted using the factor’s starting trial. When multiple derived factors are present in the crossing, the one with the latest starting trial determines the number of preamble trials.

When using MultiCrossBlock, if crossings require different numbers of preamble trials due to derived factors with varying window sizes, the alignment parameter controls how crossings are aligned. Use AlignmentMode.POST_PREAMBLE to start all crossings after the unified preamble trials, or AlignmentMode.PARALLEL_START to start individual crossing from its own required preamble trials.

The difference of these two strategies are shown in the following example. If AlignmentMode.PARALLEL_START is used in the MultiCrossBlock, the crossing [color, task_transition] would have one preamble trial because of derived factor task_transition, whereas the crossing [task] and [word] would have no preamble trials since it does not require preamble trials. Thus the first trial color green | word red   | task word  | task_transition is considered preamble trial for the crossing [color, task_transition], but the first trial for the crossing [task] and the crossing [word]. If AlignmentMode.POST_PREAMBLE is used in the MultiCrossBlock, the first trial color red | word blue | task color | task_transition would be the preamble trial for all crossings:

>>> from sweetpea import (Factor, MultiCrossBlock, RepeatMode, synthesize_trials,
>>> print_experiments, CMSGen, IterateGen, RandomGen, IterateSATGen, Repeat,
>>> DerivedLevel, Transition, MinimumTrials, Window, AlignmentMode, CrossBlock)
>>> color   = Factor("color",   ["red", "blue", "green"])
>>> word   = Factor("word",   ["red", "blue", "green"])
>>> task = Factor("task", ['color', 'word'])
>>> def task_repeat(task):
        return task[0] == task[-1]
>>> def task_switch(task):
        return not task_repeat(task)
>>> task_transition = Factor("task_transition", [
        DerivedLevel("repeat", Transition(task_repeat, [task])),
        DerivedLevel("switch", Transition(task_switch, [task]))
    ])
>>> design = [color, word, task, task_transition]
>>> crossing = [[color, task_transition], [task], [word]]
>>> constraints = []
>>> block = MultiCrossBlock(design, crossing, constraints, mode=RepeatMode.REPEAT, alignment=AlignmentMode.PARALLEL_START)
>>> experiments = synthesize_trials(block, 1, CMSGen)
>>> print_experiments(block, experiments)
Sampling 1 trial sequences using CMSGen.
Encoding experiment constraints...
Running CMSGen...

1 trial sequences found.

Experiment 0:
color green | word red   | task word  | task_transition
color red   | word blue  | task color | task_transition switch
color green | word green | task color | task_transition repeat
color green | word green | task word  | task_transition switch
color red   | word red   | task word  | task_transition repeat
color blue  | word blue  | task color | task_transition switch
color blue  | word blue  | task color | task_transition repeat
>>> block = MultiCrossBlock(design, crossing, constraints, mode=RepeatMode.REPEAT, alignment=AlignmentMode.POST_PREAMBLE)
>>> experiments = synthesize_trials(block, 1, CMSGen)
>>> print_experiments(block, experiments)
Sampling 1 trial sequences using CMSGen.
Encoding experiment constraints...
Running CMSGen...

1 trial sequences found.

Experiment 0:
color red   | word blue  | task color | task_transition
color green | word red   | task color | task_transition repeat
color blue  | word blue  | task word  | task_transition switch
color red   | word green | task word  | task_transition repeat
color red   | word blue  | task color | task_transition switch
color blue  | word green | task color | task_transition repeat
color green | word red   | task word  | task_transition switch

ContinuousFactor in SweetPea

In addition to factors with discrete levels, SweetPea also supports a ContinuousFactor, which can be initialized using a distribution. Unlike discrete factors, ContinuousFactor allows sampling values dynamically at runtime based on the pre-defined distribution.

Defining ContinuousFactor

A ContinuousFactor uses an input distribution to sample values at runtime. It does not have the finite number of levels as other factors. As a result, such factors can only be added to the design of the block instead of the crossing. The crossing needs to consist of factor(s) with finite levels.

To use ContinuousFactor, we import and use the following SweetPea language forms:

To put it together, we do:

>>> from sweetpea import Factor, CrossBlock, synthesize_trials,\
>>> print_experiments, ContinuousFactor, CustomDistribution
>>> import random
>>> def sample_continuous():
>>>   return random.uniform(0.5, 1.5)
>>> response_time = ContinuousFactor("response_time", [],\
>>> distribution=CustomDistribution(sample_continuous))
>>> factor_for_crossing = Factor("color", ["red", "blue", "green"])
>>> block = CrossBlock([factor_for_crossing, response_time], [factor_for_crossing], [])
>>> experiments = synthesize_trials(block, 1)
Sampling 1 trial sequences using NonUniformGen.
Encoding experiment constraints...
Running CryptoMiniSat...
>>> print_experiments(block, experiments)
1 trial sequences found.

Experiment 0:
color blue  | response_time 0.9695361854047209
color red   | response_time 1.2529270082663353
color green | response_time 1.4106548040758589

Windows for ContinuousFactor

Just like derivations on discrete levels can look at previous trials using windows, SweetPea also supports defining derived continuous factors using windows over past values.

To do this, use the ContinuousFactorWindow along with a CustomDistribution that takes windowed input.

Here’s a minimal example that demonstrates how to define a derived ContinuousFactor using a ContinuousFactorWindow over another ContinuousFactor. Specifically, it creates a reward_diff factor that calculates the difference between the current and previous reward values of reward:

>>> from sweetpea import ContinuousFactor, ContinuousFactorWindow, CustomDistribution, Factor, \
>>> CrossBlock, synthesize_trials, MinimumTrials, print_experiments
>>> import random
>>> def sample_continuous():
>>>     return random.uniform(0, 1)
>>> reward = ContinuousFactor("reward", distribution=CustomDistribution(sample_continuous))
>>> def difference(window):
>>>     return window[0] - window[-1]
>>> window = ContinuousFactorWindow([reward], width=2)
>>> reward_diff = ContinuousFactor("reward_diff", distribution=CustomDistribution(difference, [window]))
>>> color = Factor("color", ["red", "blue"])
>>> block = CrossBlock([color, reward, reward_diff], [color], [MinimumTrials(6)])
>>> experiments = synthesize_trials(block, 1)
Sampling 1 trial sequences using NonUniformGen.
Encoding experiment constraints...
Running CryptoMiniSat...
>>> print_experiments(block, experiments)
1 trial sequences found.

Experiment 0:
color blue | reward 0.9148493567759514  | reward_diff nan
color blue | reward 0.5751320373836653  | reward_diff -0.3397173193922861
color red  | reward 0.22538414657155603 | reward_diff -0.3497478908121092
color red  | reward 0.34749367375749685 | reward_diff 0.12210952718594081
color red  | reward 0.7889544775824884  | reward_diff 0.44146080382499153
color blue | reward 0.8445088438260279  | reward_diff 0.05555436624353949

Constraints for Design with ContinuousFactors

When designing an experiment using ContinuousFactor, you can also impose some constraints on the factor.

Let’s say we look at the above list of trials and decide “we should ensure that the response_time should be less than 1.”

In that case, we can add ContinuousConstraint to achieve that.

>>> from sweetpea import Factor, CrossBlock, synthesize_trials,\
>>> print_experiments, ContinuousConstraint, ContinuousFactor, CustomDistribution
>>> import random
>>> def sample_continuous():
>>>   return random.uniform(0.5, 1.5)
>>> response_time = ContinuousFactor("response_time", [],\
>>> distribution=CustomDistribution(sample_continuous))
>>> factor_for_crossing = Factor("color", ["red", "blue", "green"])
>>> def less_than_one(a):
>>>   return (a<1)
>>> cc = ContinuousConstraint([response_time], less_than_one)
>>> block = CrossBlock([factor_for_crossing, response_time], [factor_for_crossing], [cc])
>>> experiments = synthesize_trials(block, 1)
Sampling 1 trial sequences using NonUniformGen.
Encoding experiment constraints...
Running CryptoMiniSat...
Trial: 0, Sampling count to meet continuous constraints: 6
>>> print_experiments(block, experiments)
1 trial sequences found.

Experiment 0:
color blue  | response_time 0.5357413769177958
color red   | response_time 0.9859899610573284
color green | response_time 0.5929666932777036

Nesting Designs with NestedBlock

Instead of performing multiple crossings in parallel with MultiCrossBlock or Merge, some experiments need an inner crossing nested within each combination of an outer crossing.

The Nest constructor performans that nesting. Note that Nest is applied to existing blocks that are formed with, say, CrossBlock or MultiCrossBlock. The combined blocks can have overlapping sets of factors in their designs, but they must have distinct factors in their crossings.

Using NestedBlock

A Nest block keeps an outer block combination constant over multiple trials representing an instance of the inner block.

Let’s start with a 2×2 inner design:

>>> from sweetpea import Factor, CrossBlock
>>> A = Factor("A", ["a1", "a2"])
>>> B = Factor("B", ["b1", "b2"])
>>> inner = CrossBlock([A, B], [A, B], [])
>>> inner.trials_per_sample()
4

Here we repeat the 2×2 inner block for each level of an outer block that has a session Factor. During each 4-trial instance of the inner block, session is constant.

>>> from sweetpea import Nest, synthesize_trials, print_experiments
>>> session = Factor("session", ["s1", "s2"])
>>> outer = CrossBlock([session], [session], [])
>>> nb = Nest(outer_block=outer, inner_block=inner, constraints=[])
>>> nb.trials_per_sample()
8 # Total trials = #session levels × inner trials = 2 × 4 = 8.
>>> exps = synthesize_trials(nb, 1)
Sampling 1 trial sequences using NonUniformGen.
Encoding experiment constraints...
Running CryptoMiniSat...
>>> print_experiments(nb, exps)
1 trial sequences found.

Experiment 0:
session s2 | A a2 | B b2
session s2 | A a2 | B b1
session s2 | A a1 | B b1
session s2 | A a1 | B b2
session s1 | A a2 | B b1
session s1 | A a1 | B b2
session s1 | A a2 | B b2
session s1 | A a1 | B b1

Note that the outer session levels are not required to be in order. To force those levels to be in order, we could use Merge to add a Sequential constraint.

>>> from sweetpea import Merge, Sequential, RepeatMode
>>> nb2 = Merge([nb], constraints=[Sequential(session)])
>>> exps = synthesize_trials(nb2, 1)
Sampling 1 trial sequences using NonUniformGen.
Encoding experiment constraints...
Running CryptoMiniSat...
>>> print_experiments(nb2, exps)
1 trial sequences found.

Experiment 0:
session s1 | A a2 | B b2
session s1 | A a2 | B b1
session s1 | A a1 | B b2
session s1 | A a1 | B b1
session s2 | A a2 | B b2
session s2 | A a1 | B b1
session s2 | A a2 | B b1
session s2 | A a1 | B b2

Nesting inside nesting

You can create a Nest using another Nest as the inner block. Each outer level repeats the entire original inner block.

>>> day = Factor("day", ["d1", "d2"])
>>> outermost = CrossBlock(design=[day], crossing=[day], constraints=[])
>>> nnb = Nest(outer_block=outermost, inner_block=nb, constraints=[])
>>> exps = synthesize_trials(nnb, 1)
Sampling 1 trial sequences using NonUniformGen.
Encoding experiment constraints...
Running CryptoMiniSat...
>>> print_experiments(nnb, exps)
1 trial sequences found.

Experiment 0:
day d2 | session s1 | A a2 | B b2
day d2 | session s1 | A a1 | B b1
day d2 | session s1 | A a1 | B b2
day d2 | session s1 | A a2 | B b1
day d2 | session s2 | A a1 | B b2
day d2 | session s2 | A a2 | B b1
day d2 | session s2 | A a2 | B b2
day d2 | session s2 | A a1 | B b1
day d1 | session s1 | A a2 | B b2
day d1 | session s1 | A a1 | B b2
day d1 | session s1 | A a2 | B b1
day d1 | session s1 | A a1 | B b1
day d1 | session s2 | A a2 | B b2
day d1 | session s2 | A a2 | B b1
day d1 | session s2 | A a1 | B b2
day d1 | session s2 | A a1 | B b1

Latin Square Counterbalancing

A Latin Square is pattern that orders a crossing so that successive diagonals are first explored, which provides a more varierty for combinations within a trial subsequence than could be expected otherwise. A Latin Square can be particularly useful in an experiment with multiple participants, where each participant sees only a subset of the possible combinations, but still sees each level that could contribute to a combination—and all conbinations are generated across multiple participants. SweetPea supports Latin Square counterbalancing through the LatinSquare constraint, which expects a list of factors that are crossed.

A 2x2 Example

Suppose we have Font (small or big) and Color (red or green) to cross, and we don’t need every participant to see every combination. Since these factor each have 2 levels, there are 2 diagonals in a Latin Square for the factors.

2x2 Latin Square Diagonals

Color red

Color green

Font small

Diagonal 0

Diagonal 1

Font big

Diagonal 1

Diagonal 0

  • Diagonal 0: (small, red) and (big, green)

  • Diagonal 1: (small, green) and (big, red)

If we simply cross the factors, there’s no guarantee that both colors will show up in the first two trails, only that all Font-Color word combinations will show up over four trials.

>>> from sweetpea import Factor, CrossBlock, synthesize_trials, print_experiments
>>> font  = Factor("Font",  ["small", "big"])
>>> color = Factor("Color", ["red", "green"])
>>> b = CrossBlock(design=[font, color], crossing=[font, color], constraints=[])
>>> exps = synthesize_trials(b, 1)
Sampling 1 trial sequences using NonUniformGen.
Encoding experiment constraints...
Running CryptoMiniSat...
>>> print_experiments(b, exps)
1 trial sequences found.

Experiment 0:
Font big   | Color green
Font small | Color green
Font big   | Color red
Font small | Color red

We can force both Font levels and both Color levels to show up in the first two trials—without picking a specific color or font—by adding a LatinSquare constraint.

>>> from sweetpea import LatinSquare, Merge
>>> lsb = Merge(blocks=[b], constraints=[LatinSquare([font, color])])
>>> exps = synthesize_trials(lsb, 1)
Sampling 1 trial sequences using NonUniformGen.
Encoding experiment constraints...
Running CryptoMiniSat...
>>> print_experiments(lsb, exps)
1 trial sequences found.

Experiment 0:
Font big   | Color green
Font small | Color red
Font big   | Color red
Font small | Color green

After generating this trial sequence, we might use the first two trials for the first participant, and the second two trials for the second participant.

Synthesizing Experiments for Participants

Using just the output of print_experiments(lsb, exps), we have to figure out for ourselves where to draw the boundary between participants. If the block for an experiment has a LatinSquare constraint with a name for the diagonal, then printing breaks up each generated experiment into sections labelled by participant name.

>>> lsb = Merge(blocks=[b], constraints=[LatinSquare([font, color],
                                                     name="Participant")])
>>> exps = synthesize_trials(lsb, 2)
Sampling 1 trial sequences using NonUniformGen.
Encoding experiment constraints...
Running CryptoMiniSat...
>>> print_experiments(lsb, exps)
2 trial sequences found.

Experiment 0:

Participant 0:
Font big   | Color green
Font small | Color red

Participant 1:
Font big   | Color red
Font small | Color green

Experiment 1:

Participant 0:
Font big   | Color green
Font small | Color red

Participant 1:
Font small | Color green
Font big   | Color red

Latin Squares as Crossing

In the example with Font and Color, we crossed the factors to take sure that each generated experiment covers all combinations. The LatinSquare constraint does not require the factors that is is given to be crossed already. The constraint that it imposes is stronger than crossing in terms of trial sequences, but weaker than crossing because LatinSquare does not imply a number of trials. It can be imposed after the fact to a design that supplies enough trials.

>>> from sweetpea import (Factor, CrossBlock, MinimumTrials,
                          Nest, LatinSquare,
                          synthesize_trials, print_experiments)
>>> font  = Factor("Font",  ["small", "big"])
>>> color = Factor("Color", ["red", "green"])
>>> task  = Factor("Task",  ["read", "paint"])
>>> b = CrossBlock(design=[font, color],
                   crossing=[],
                   constraints=[MinimumTrials(2)])
>>> nb = Nest(outer_block=CrossBlock([task], [task], []),
              inner_block=b,
              constraints=[LatinSquare([font, color],
                                       name="Participant")])
>>> exps = synthesize_trials(nb, 2)
Sampling 1 trial sequences using NonUniformGen.
Encoding experiment constraints...
Running CryptoMiniSat...
>>> print_experiments(nb, exps)
2 trial sequences found.

Experiment 0:

Participant 0:
Task paint | Font big   | Color green
Task paint | Font small | Color red

Participant 1:
Task read | Font small | Color green
Task read | Font big   | Color red

Experiment 1:

Participant 0:
Task paint | Font small | Color red
Task paint | Font big   | Color green

Participant 1:
Task read | Font small | Color green
Task read | Font big   | Color red