I’m currently working on implementing some multiplayer decision strategy games for different experiments in the field of Experimental Economics. We decided to use the excellent oTree framework as basis for our implementations. Since oTree itself is based on Django and provides comprehensive documentation and some good tutorials, it was quite straight forward for me to learn. In most cases, it provides just what you need for implementing an experiment while hiding a lot of unnecessary technical stuff by exposing only a limited API. The full power and functionality of Django is hidden to the programmer for the sake of clearness and simplicity, which is basically a good thing.
However, in some cases oTree’s API is too restrictive for implementing advanced functionality, the main issue being the limited set of data models: By default, you can only record non-complex information (i.e. numeric values, strings, etc.) per subsessions (i.e. round), group or player. What if you need, for example, to record an arbitrary number of (more or less complex) decisions made by a player per round? This is not really supported by oTree’s data models so you need to define and handle your own, custom Django models. I will explain how to do this in this post.
The problem
As said before, it is sometimes not possible to represent the information that you need to keep track of during your experiment by oTree’s limited set of data models. For example, when you know that a player has to make three decisions per round, you can easily add three model fields to your Player
model, e.g. decision_a
, decision_b
and decision_c
. This already becomes cumbersome when a player needs to make ten decisions per round. When the decisions are more complex than just a yes/no answer or a value the player has to type in, it becomes even more cumbersome (you may help yourself saving a JSON string to a CharField
or something like this). But when the number of decisions per round is dynamic, i.e. not fixed to a certain value, it’s basically impossible to implement with oTree’s data models without really ugly hacks (JSON strings again).
An elegant solution is to define a new custom model (e.g. Decision
) and connecting an oTree model (in our case Player
) with our custom model as an 1:m relation. In other words: We define that a single player in each round can make an arbitrary number (m) of complex decisions.
A basic example
Let’s make a simple example project in which each participant has to make a number of decisions per round. For sake of simplicity, the number of decisions will be fixed, but a said before, it could also easily be dynamic.
To start with, we modify the Constants
class in models.py
to set a number of rounds we want to play and the number of decisions each player has to make per round:
class Constants(BaseConstants):
# ...
num_rounds = 3
num_decisions_per_round = 5
Defining a custom model
Defining the model is the most important step. We set the properties of a decision and the relation to the Player
model. Let’s say that a complex decision records the following information per player (and per round since players are participant instances per round in oTree):
- a value that the player has to decide upon — this is only a randomly generated number for demonstration purposes
- a decision the player has to make — it will just be a yes/no answer for this simple example
- a reason for the player that she or he can provide, chosen from a set of options
To define this in our models.py
, we need to import some Django classes at first, because they’re not provided by otree.api
:
from otree.db.models import Model, ForeignKey
Now we can define our Decision
model:
class Decision(Model): # our custom model inherits from Django's base class "Model"
REASONS = (
('dont_know', "Don't know"),
('example_reason', "Example reason"),
('another_example_reason', "Another example reason"),
)
value = models.IntegerField() # will be randomly generated
player_decision = models.BooleanField()
reason = models.CharField(choices=REASONS)
player = ForeignKey(Player) # creates 1:m relation -> this decision was made by a certain player
By adding the foreign key field player
to Decision
, we say that each decision belongs to a certain player. Vice versa, players can have multiple decisions. These can be accessed in a Player
instance with a special field called decision_set
, which we’ll do now. Since the player has to decide upon a random value each round, we will need to generate these numbers beforehand in the before_session_starts()
method of Subsession
. This will be done by calling a method generate_decision_stubs()
on each Player
object which we’ll have to define.
class Subsession(BaseSubsession):
def before_session_starts(self): # called each round
"""For each player, create a fixed number of "decision stubs" with random values to be decided upon later."""
for p in self.get_players():
p.generate_decision_stubs()
Now to the generate_decision_stubs()
method. It will create a number of “stub” Decision
objects (the amount is defined before as Constants.num_decisions_per_round
) that only contain the randomly generated number. Later, when the participants make their decisions, these objects will be updated and hence filled with decision information. For now, they only contain the random value and hence I’m calling them “stubs”.
To create a Decision
object that is part of the player’s set of decisions, we can use self.decision_set.create()
. It is very important not to forget to call the save()
method of the Decision
object, otherwise the database won’t be filled. With default oTree model classes (Player, Group, Subsession), we don’t have to do it, because they’re managed by oTree and saved automatically. But whenever we work with our own custom models, we need to take care about this ourselves.
class Player(BasePlayer):
def generate_decision_stubs(self):
"""
Create a fixed number of "decision stubs", i.e. decision objects that only have a random "value" field on
which the player will base her or his decision later in the game.
"""
for _ in range(Constants.num_decisions_per_round):
decision = self.decision_set.create() # create a new Decision object as part of the player's decision set
decision.value = random.randint(1, 10) # don't forget to "import random" before!
decision.save() # important: save to DB!
We’re now finished with defining our models. When you run otree resetdb
(make sure that your app is in the settings.py
SESSION_CONFIGS
list) now, you will notice that it prints out “Creating table example_decisions_decision” — this is the database table for our new Decision
model.
Using our custom model
We can now define our view classes and use our custom model in view.py
. We will need the following imports first:
from .models import Constants, Decision
from django.forms import modelformset_factory
The latter import is a nice Django feature that we need in order to render the input forms for our decisions: Model Formsets. With them we can define a “factory class” that says that we want to render form inputs based on the Decision
model with the fields player_decision
and fields
(these are the only fields the player can provide input for). We also say that it is not possible for the player to create new decisions (only updating the existing “decision stubs” should be possible) by adding extra=0
:
DecisionFormSet = modelformset_factory(Decision, fields=('player_decision', 'reason'), extra=0)
We can now use our DecisionFormSet
for rending in the vars_for_template()
method of our new MakeDecisionsPage
class:
class MakeDecisionsPage(Page):
def vars_for_template(self):
# get decisions for this player
decision_qs = Decision.objects.filter(player__exact=self.player)
assert len(decision_qs) == Constants.num_decisions_per_round
decisions_formset = DecisionFormSet(queryset=decision_qs)
return {
'decision_formset': decisions_formset,
'decision_values_and_forms': zip([dec.value for dec in decision_qs], decisions_formset.forms),
}
At first, we create a QuerySet which loads the existing “decision stubs” for this player from the database (remember that in oTree, each player object only exists per round, hence we load the decisions per participant in the current round). We make an assertion in order to make sure that we really only loaded the number of decisions that we’re supposed to. We create a DecisionFormSet
instance by passing the QuerySet and provide the whole FormSet and a list of decision value → decision form pairs in decision_values_and_forms
. We can use them as follows in the HTML template of the page:
{{ decision_formset.management_form }}
<ul>
{% for val, form in decision_values_and_forms %}
<li>Value <b>{{ val }}</b> — {{ form }}</li>
{% endfor %}
</ul>
So we render a form header (decision_formset.management_form
) and then create a list that for each item shows the random value and then form inputs for that decision.
After we set MakeDecisionsPage
in the oTree’s page_sequence
, we can already have a look at the decision page of the experiment:
The inputs with their options get generated automatically, we now just have to save the data, that the player submits on clicking “Next”. We do so by handling self.form.data
in the before_next_page()
method. Each input field from the FormSet has a special name of the kind “form-NUMBER–FIELD” where NUMBER is a sequential integer representing the input row (starting with 0) and FIELD is the name of the according Decision
model field. We can use this to extract the data of the player_decision
and reason
fields for each decision and save it to the according “decision stub” object:
class MakeDecisionsPage(Page):
# ...
def before_next_page(self):
# get the raw submitted data as dict
submitted_data = self.form.data
# get all decisions belonging to this player and save as dict with decision ID lookup
decision_objs_by_id = {dec.pk: dec for dec in self.player.decision_set.all()}
assert len(decision_objs_by_id) == Constants.num_decisions_per_round
for i in range(Constants.num_decisions_per_round):
input_prefix = 'form-%d-' % i
# get the inputs
dec_id = int(submitted_data[input_prefix + 'id'])
player_decision = submitted_data[input_prefix + 'player_decision']
reason = submitted_data[input_prefix + 'reason']
# lookup by ID and save submitted data
dec = decision_objs_by_id[dec_id]
if player_decision != '':
dec.player_decision = player_decision == 'True'
else:
dec.player_decision = None
if reason != '':
dec.reason = reason
else:
dec.reason = None
# important: save to DB!
dec.save()
When we run the experiment now, each participant’s decisions per round will be recorded to the DB. We can check this be examining the example_decisions_decision
table:
Exporting data from our custom model
We’re basically done, because we achieved what we wanted: We can record an arbitrary number of complex decisions to our database. They only problem left is: How do we get the data out again for our analyses? All default oTree model classes get exported automatically using the Data page of the oTree admin interface. Our own custom models, however, won’t show up there.
One way to work around this would be to hack oTree’s export functions. But since I didn’t want to mess around with oTree’s core code, I decided to provide an own “export view” for the experiment app. We have a quite complex hierarchy of objects (Sessions → Subsessions → Groups → Players → Decisions) so I found that JSON is a better format for data export than CSV or Excel. The basic idea for the data export is that we fetch the whole set of hierarchically linked objects from the database with a QuerySet:
qs_results = models.Player.objects.select_related('subsession', 'subsession__session', 'group', 'participant')\
.prefetch_related('decision_set')\
.all()
Then we loop through each level of the hierarchy and construct a nested output data structure which will be rendered with Django’s JsonResponse. Finally we add a custom view URL in order to be able to download the data from a special URL (say, “/example_decisions/export/”). We restrict the view for admin access only using the login_required
decorator from django.contrib.auth.decorators
. The data from our experiment can then be downloaded as JSON and used for analyses:
The full code for the example project can be found on github.
Recent Comments