How To Create an Interactive Experiment#

Step 1: Preparations#

To create an interactive experiment, you start by defining group specs. These are like blueprints that organize some important information about groups like the number of members and the names of the roles to allocate. Let’s define a ParallelSpec that lets us create groups of participants who interact in real-time with each other. Because we will need access to an initialized alfred3.experiment.ExperimentSession object later, we use the alfred3.Experiment.setup() decorator:

import alfred3 as al
import alfred3_interact as ali

exp = al.Experiment()

@exp.setup
def setup(exp):
    spec = ali.ParallelSpec("role1", "role2", "role3", nslots=5, name="myspec")

Here, we define a spec that will be used to create groups with three members, which will be assigned to the roles “role1”, “role2”, and “role3”. By setting nslots=5, we allocate five group slots to this spec. That means, we determine that our spec will stop being used for new groups as soon as we have five fully finished groups. The argument name="myspec" is a mandatory identifier that will allow us to associate each group with the spec that was used to create it. Spec names have to be unique within one MatchMaker and are your go-to solution if you want to place groups in different experimental conditions. There are some optional arguments that can be used to finetune the spec’s behavior with regard to managing group slots. These are explained in the API documentation of the individual classes.

The currently available specs are:

SequentialSpec

Interface for defining a sequential group.

ParallelSpec

Interface for defining a parallel group.

IndividualSpec

Interface for defining an 'individual group spec'.

The spec alone does not make the experiment interactive - for this, we need an instance of MatchMaker. The MatchMaker takes any number of specs and uses them to create groups. We need access to our spec object (or objects – you can use more than one spec, but more on that later), which is why we initialize the MatchMaker in the setup function aswell. We bind our matchmaker instance to the ExperimentSession instance exp in its dedicated place for “plugin stuff”, alfred3.ExperimentSession.plugins, because we want to have easy access to the MatchMaker later on:

import alfred3 as al
import alfred3_interact as ali

exp = al.Experiment()

@exp.setup
def setup(exp):
    spec = ali.ParallelSpec("role1", "role2", "role3", nslots=5, name="myspec")
    exp.plugins.mm = ali.MatchMaker(spec, exp=exp)

Step 2: Make a Match#

We are now ready to implement the actual matchmaking process. This process required a WaitingPage. This page allows us to repeatedly check whether we have enough active participants to form a group while greeting waiting participants with a pleasant (and customizable) waiting screen. Because we are creating a minimal demo experiment in this tutorial, our first page is a waiting page.

We define it much like we would define any ordinary page, but we use the special hook WaitingPage.wait_for() to define behavior that should be executed repeatedly until we reach a state of success. Inside the function, we signal success simply by returning True – once a return value of True is observed, the WaitingPage will automatically forward participants to the next page. The WaitingPage can be used not only for matchmaking, but also to implement points of synchronization in an experiment – but more on that later.

To make a match, we will use the MatchMaker’s MatchMaker.match() method, which returns a Group instance upon successful matching and raises a NoMatch exception otherwise. The NoMatch exception is a signal that gets handled by the WaitingPage.

We use a alfred3.ForwardOnlySection to limit movements to forward moves, because moing back to a WaitingPage does not make much sense here. Let’s see the code:

import alfred3 as al
import alfred3_interact as ali

exp = al.Experiment()

@exp.setup
def setup(exp):
    spec = ali.ParallelSpec("role1", "role2", "role3", nslots=5, name="myspec")
    exp.plugins.mm = ali.MatchMaker(spec, exp=exp)

exp += al.ForwardOnlySection(name="main")


@exp.member(of_section="main")
class Match(ali.WaitingPage):

    def wait_for(self):
        group = self.exp.plugins.mm.match()
        self.exp.plugins.group = group

        return True

Like the MatchMaker instance, we bind the group instance to our ExperimentSession’s plugin attribute for future reference. Because MatchMaker.match() raises an exception if there are not enough participants active, the function returns True only if a match was successful. By default, a WaitingPage will try to reach a successful call to its WaitingPage.wait_for() method for 20 minutes. If it does not reach a successful call, the experiment will be aborted. Let us use a shorter timeout of 10 minutes by overriding the page’s WaitingPage.wait_timeout attribute:

import alfred3 as al
import alfred3_interact as ali

exp = al.Experiment()

@exp.setup
def setup(exp):
    spec = ali.ParallelSpec("role1", "role2", "role3", nslots=5, name="myspec")
    exp.plugins.mm = ali.MatchMaker(spec, exp=exp)

exp += al.ForwardOnlySection(name="main")


@exp.member(of_section="main")
class Match(ali.WaitingPage):
    wait_timeout = 60 * 10 # timeout in seconds

    def wait_for(self):
        group = self.exp.plugins.mm.match()
        self.exp.plugins.group = group

        return True

To customize the content of your WaitingPage, you can override its default WaitingPage.on_exp_access() hook specification. If you simply want to display a different message but keep the overall design, you can also just override the WaitingPage.wait_msg. For more finetuning, take a look at the API documentation at WaitingPage.

We used MatchMaker.match() here to conduct the actual match, but you have more options that may be useful in pratice:

match_to

Matches participants to a group based on a specific spec.

match_random

Conducts a match to a random feasible group specification.

match_chain

Offers prioritized matchmaking based on multiple specs.

Step 3: Work with the Group#

The group object is your gateway to connecting data from multiple sessions. We will look at a few ways it can be used. First, let us add a page with a simple text input element for demonstration purposes. We add it before the WaitingPage to make sure that each member of our group has completed this element when matching:

import alfred3 as al
import alfred3_interact as ali

exp = al.Experiment()

@exp.setup
def setup(exp):
    spec = ali.ParallelSpec("role1", "role2", "role3", nslots=5, name="myspec")
    exp.plugins.mm = ali.MatchMaker(spec, exp=exp)

exp += al.ForwardOnlySection(name="main")


@exp.member(of_section="main")
class DemoInput(al.Page):
    title = "Match Successful"

    def on_exp_access(self):
        self += al.TextEntry(
            toplab="What is your favourite meal?", force_input=True, name="meal"
        )

        choices = ["not at all", "okay", "very much"]
        self += al.SingleChoiceButtons(
            *choices,
            toplab="How much do you think other people like this food?",
            name="rating"
        )


@exp.member(of_section="main")
class Match(ali.WaitingPage):
    wait_timeout = 60 * 10 # timeout in seconds

    def wait_for(self):
        group = self.exp.plugins.mm.match()
        self.exp.plugins.group = group

        return True

Now, we will add a third page on which we use the group instance to display some information about our group. We use Group.spec_name to identify the group’s spec. This can allow us to distinguish groups in different conditions. Apart from that, we will mainly access the group members’ GroupMember instances through the group. These objects in turn offer access to each member’s experiment data. We can always refer to the current session’s member object via Group.me. In a dyad (i.e. a group with exactly two members), we can refer to the other member via Group.you. Additionally, we can always refer to each group member by using its role name like an attribute with the group. For example, to reference the group member of role “role1”, we refer to group.role1, if group is our group instance. We can iterate over all members of the group with the generator Group.members() and over all members except Group.me via Group.other_members().

Now let us see these referencing steps in practice. Because the group object does not exist before the matchmaking on the WaitingPage has been completed, we cannot use a alfred3.Page.on_exp_access() hook. Instead, we use alfred3.Page.on_first_show(), which runs when a page is first shown:

import alfred3 as al
import alfred3_interact as ali

exp = al.Experiment()

@exp.setup
def setup(exp):
    spec = ali.ParallelSpec("role1", "role2", "role3", nslots=5, name="myspec")
    exp.plugins.mm = ali.MatchMaker(spec, exp=exp)

exp += al.ForwardOnlySection(name="main")


@exp.member(of_section="main")
class DemoInput(al.Page):
    title = "Match Successful"

    def on_exp_access(self):
        self += al.TextEntry(
            toplab="What is your favourite meal?", force_input=True, name="meal"
        )

        choices = ["not at all", "okay", "very much"]
        self += al.SingleChoiceButtons(
            *choices,
            toplab="How much do you think other people like this food?",
            name="rating"
        )


@exp.member(of_section="main")
class Match(ali.WaitingPage):
    wait_timeout = 60 * 10 # timeout in seconds

    def wait_for(self):
        group = self.exp.plugins.mm.match()
        self.exp.plugins.group = group

        return True


@exp.member(of_section="main")
class DemoAccess(al.Page):
    title = "Demo Page for Group Access"

    def on_first_show(self):
        g = self.exp.plugins.group # get group object

        self += al.Text(f"This group way created based on: {g.spec_name}")

        # access current session
        self += al.Text(f"My own role in this group is: {g.me.role}")

        self += al.VerticalSpace("20px")

        # iterate over other members
        for member in g.other_members():
            fav_meal = member.values.get("meal")
            self += al.Text(f"Member with role '{member.role}' entered '{fav_meal}' as their favourite meal.")

        self += al.VerticalSpace("20px")

        # access member via role
        r1_rating = g.role1.values("rating")
        self += al.Text(f"Role1's rating was: {r1_rating}")

Step 4: Use a WaitingPage for Syncing#

When programming an interactive experiment, you will repeatedly find yourself wanting to include a kind of check point where the experiment pauses for all participants until they have reached similar progress. This can be achieved by WaitingPages. We start with an earlier, smaller version of our demo experiment:

import alfred3 as al
import alfred3_interact as ali

exp = al.Experiment()

@exp.setup
def setup(exp):
    spec = ali.ParallelSpec("role1", "role2", "role3", nslots=5, name="myspec")
    exp.plugins.mm = ali.MatchMaker(spec, exp=exp)

exp += al.ForwardOnlySection(name="main")


@exp.member(of_section="main")
class Match(ali.WaitingPage):

    def wait_for(self):
        group = self.exp.plugins.mm.match()
        self.exp.plugins.group = group

        return True

To this experiment, we add a page with an input element after the waiting page and another page that accesses the inputs made by all group members. If we do not synchronize the experiment between the former and the latter page, we may encounter the following situation: Participants of “role1” and “role2” are still thinking about their input. Meanwhile, the participant of “role3” is quick and moves on to the next page. Because “role1” and “role2” have not commited their inputs, the display for “role3” cannot display their values, even though that is necessary for an orderly experiment session. The page may even crash, because we cannot access the values that we seek. To prevent these issues, we add a waiting page in between the input and the accessing calls. This will pause the experiment for faster participants until all required values are present:

import alfred3 as al
import alfred3_interact as ali

exp = al.Experiment()

@exp.setup
def setup(exp):
    spec = ali.ParallelSpec("role1", "role2", "role3", nslots=5, name="myspec")
    exp.plugins.mm = ali.MatchMaker(spec, exp=exp)

exp += al.ForwardOnlySection(name="main")


@exp.member(of_section="main")
class Match(ali.WaitingPage):

    def wait_for(self):
        group = self.exp.plugins.mm.match()
        self.exp.plugins.group = group

        return True


@exp.member(of_section="main")
class InputPage(al.Page):
    title = "Input Page"

    def on_exp_access(self):
        self += al.TextEntry("What's your favourite drink?", force_input=True, name="drink")


@exp.member(of_section="main")
class Sync(ali.WaitingPage):

    def wait_for(self):
        """
        Returns True, if a value for 'drink' is present for each
        group member.
        """
        g = self.exp.plugins.group
        drinks = [m.values.get("drink") for m in g.members()]
        return all(drinks)


@exp.member(of_section="main")
class View(al.Page):
    title = "View Inputs"

    def on_first_show(self):
        g = self.exp.plugins.group

        for m in g.members():
            self += al.Text(f"Member of role '{m.role}' entered '{m.values.get('drink')}' as their favourite drink.")

You are now ready to create your first interactive experiments. Make sure to check out the API documentation for the relevant classes, especially MatchMaker, Group, and GroupMember for more detailed information.