Networks¶
Depending on an experiment’s objectives, there are different ways that experiment participants can interact with each other and with the experiment’s stimuli. For some experiments, participants may receive the same initial stimuli and process it individually. For other experiments, they may sequentially interact with the stimuli. Some experiments may require participants to interact among themselves in various ways.
In Dallinger, these interactions among participants and stimuli are represented using networks. Each participant and each stimulus represent a node in a network. The way these nodes are connected to each other is known as a network topology. For brevity, we will use the term network from now on when discussing Dallinger network topologies.
Dallinger comes with a variety of networks that can be used by experimenters, and it’s possible both to extend these networks or create completely new ones as well. The networks included in Dallinger are:
- Empty
- Chain
- DelayedChain
- Star
- Burst
- FullyConnected
- DiscreteGenerational
- ScaleFree
- SequentialMicrosociety
- SplitSampleNetwork
Nodes and Sources¶
In these networks, each participant is considered as a node. There is also a special kind of node, known as a source, which transmits information to other nodes. Sources are used in Dallinger as a means to send the stimuli to the participants. Not all experiments have sources, though. A chatroom experiment, for example, could just rely on user interactions and not require any other stimuli.
All nodes have methods named transmit
and receive
, for sending and
receiving information to or from other nodes. These methods can be used when
adding a node to allow any specialized communication between nodes that an
experiment may require.
Nodes can have a fitness
property, which is a number that can be used in
some network models. The basic networks do not use this property.
Some networks require that nodes have other properties, so for properly using those networks, an experiment would need to add these properties to its nodes.
Node connections¶
A node can be connected to another node in three ways:
- “to” - a single direction connection to another node
- “from” - a single direction connection from another node
- “both” - a bidirectional connection to another node
A node can transmit information when connected to another node. It can receive information when connected from another node. If it is connected to another node in both directions, it can both receive and transmit.
Nodes have a connect
method that is used to connect them to other nodes.
This method can specify the direction of a connection:
my_node.connect(some_node, direction='both')
my_node.connect(another_node, direction='from')
The default direction is “to”. The following example will make a to connection:
my_node.connect(another_node)
Note that sources can only transmit information, so the only connection type allowed for a source node is to another node:
my_source.connect(receiver_node)
Using a network¶
To use a specific network, an experiment needs to define a create_network
method in its code. For example, to use a Chain
network:
from dallinger.experiment import Experiment
from dallinger.networks import Chain
class MyExperiment(Experiment):
def create_network(self):
return Chain(max_size=5)
Like the example shows, to use a network it’s necessary to import it from
dallinger.networks
using the network class name (the name from the list
given above). Once imported, it needs to be initialized as part of the
experiment, which is done using the create_network
method.
All networks accept the max_size
parameter, which is illustrated above. It
represents the maximum number of nodes that a network can have. In the
example above, maximum size is 5 nodes. The full
method of the network can
be used to check if a network is full.
Multiple networks¶
In experiments configured for a number of practice_repeats
or
experiment_repeats
higher than one, the create_network
method is called
multiple times, once for every repeat. This means that an experiment can have
multiple networks at the same time.
The experiment setup code assigns each network a role of practice or experiment, depending on how it was created. The experiment class allows experiment developers to query networks by role (practice, experiment), or by state (full, not full). For example:
all_networks = exp.networks()
full_networks = exp.networks(full=True)
not_full_networks = exp.networks(full=False)
practice_networks = exp.networks(role='practice')
full_experiment_networks = exp.networks(role='experiment', full=True)
Generally, the networks created at experiment setup will all be of the same type, but there’s nothing to stop an imaginative experimenter from creating a different network type based on some condition, thus having multiple networks of different types.
Common networks in Dallinger¶
Many experiments will be able to just use one of Dallinger’s existing networks, rather than defining their own. Lets look at the basic networks that can be used out of the box.
Empty¶
There are experiments where participants do not need to interact with each other at all. Generally, in this case, a source will be required. The Empty network does not connect any nodes with each other, which results in a series of isolated nodes. The only exception is, if a source node is added, it will be connected to all existing nodes, which means that it’s possible to send a stimulus to all network nodes, regardless of their isolation.
Chain¶
A Chain network, also known as line network, connects each new node to the previous one, so that nodes can receive information from their parent, but cannot send information back. In other words, it’s a one way transmission chain. In general, it’s useful to have a source as the first node, so that an initial experiment stimulus is transmitted to the each node through the chain. Note that this network explicitly prohibits a source to be added after any node, so the source has to come first.
This network can be useful for experiments where some piece of information, for example, a text, needs to be modified or interpreted by each participant in succession.
DelayedChain¶
DelayedChain is a special Chain network designed to work within the limits of MTurk configuration, which sometimes requires at least 10 participants from the start. In this case, for a Chain network, it would be impractical to make participants sign on from the beginning and then wait for their turn in the Chain for a long time. To avoid this, DelayedChain basically ignores the first 9 participants, and then starts the Chain from the 10th participant on.
This is intended to be used with a source, in order to form a long running chain where participants are recruited as soon as the previous participant has finished. If there’s no source, the first eleven nodes have no parent.
Star¶
A Star network uses its first node as a central node, and nodes created after that have a bidirectional connection (both) with that node. This means the central node can send and receive information from/to all nodes, but every other node in the network can only communicate with the central node.
A source can’t be used as a first node, since the connections to it need to be in both directions.
This network can be useful for experiments where one user has a supervisory role over others who are working individually, for example making a decision based on advice from the other players
Burst¶
A Burst network is very similar to a Star network, except the central node is connected to the other nodes using a to connection. In this case, a source can be used as a central node.
This type of network can be used for experiments where participants do not need to interact, but require the same stimuli or directions as the others.
FullyConnected¶
A FullyConnected network is one where all the nodes are connected to each other in both directions, thus allowing any node to transmit and receive from any other node. This can be very useful for cooperation experiments or chatrooms.
A source is allowed as a node in this network. However, it will use a to connection to the other nodes, so transmitting to it will not be allowed.
Other available networks¶
There are other, somewhat more specialized networks that an experiment can use. Here’s a quick rundown.
DiscreteGenerational¶
In this network, nodes are arranged into “generations”. This network accepts
some new parameters: generations
(number of generations), generation_size
(how many nodes in a generation) and initial_source
. If there is an initial
source, it will be used as the parent for all first generation nodes. After
the first generation, the parent from each new node will be selected from the
previous generation, using the fitness
attribute of the nodes to select it.
The higher the fitness, the higher the probability that a node will be a
parent.
Note that for this network to function correctly, the experiment nodes need
to have a generation
property defined.
ScaleFree¶
This network takes two parameters: m0
and m
. The first (m0) is the
number of initial nodes. These initial nodes will be connected in a fully
connected network among each other. The second parameter (m) is the number of
connections that every subsequent node will have. The nodes for this limited
number of connections will be chosen randomly, but nodes with more
connections will have a higher probability of being selected.
SequentialMicrosociety¶
A network in which each new node will be connected using a to connection to a limited set of its most recent predecessors. The number of recent predecessors is passed in as an argument (n) at network creation.
SplitSampleNetwork¶
This network helps when implementing split sample experiment designs. It
assigns a random boolean value to a property named exploratory
. When this
property is True, it means that the current network is part of the
exploratory data subset.
Creating a network¶
In addition to the available networks, it’s fairly simple to create a custom network, in case an experiment design calls for different node interconnections. To create one, we can subclass from the Network model:
from dallinger.models import Network
from dallinger.nodes import Source
class Ring(Network):
__mapper_args__ = {"polymorphic_identity": "ring"}
def add_node(self, node):
other_nodes = [n for n in self.nodes() if n.id != node.id]
if isinstance(node, Source):
raise Exception(
"Ring network cannot contain sources."
)
if other_nodes:
parent = max(other_nodes, key=attrgetter('creation_time'))
parent.connect(whom=node)
if len(self.nodes) == self.max_size:
parent = min(other_nodes, key=attrgetter('creation_time'))
node.connect(whom=parent)
In the above example, we create a simple ring network, where each node is connected in chain to the next one, until we get to the last one, which is connected back to the first, making a full circle (thus, the ‘ring’ name).
Our Ring
network is a subclass of dallinger.models.Network
, which contains the basic
network model and implementation. The __mapper_args__
assignment at the
top is for differentiating this network from others, so that data exports
don’t give incorrect results. Usually the safe thing is to use the same name
as the subclass, to avoid confusion.
Most simple networks will only need to override the add_node
method. This
method is called after a node is added, with the added node as a parameter.
This method then can decide how and when to connect this node to other nodes
in the network.
In our code, we first get all nodes in the network (except the new one). If the new node is a source, we raise an exception, because due to the circular nature of our network, there can be no sources (they don’t accept from connections and can only transmit).
After that, we take the most recent node and connect it to the new node. At this point, this is almost the same as a chain network, but when we get to the last node, we connect the new node to the first node, in addition to its connection to the previous node.
The code in the add_node
method can be as complex as needed, so very
complex networks are possible. In most cases, to create a more advanced
network it will be necessary to add custom properties to it. This is done by
overriding the __init__
method of the network to add the properties. The
following example shows how to do that:
def __init__(self, new_property1, new_property2):
self.property1 = repr(new_property1)
self.property2 = repr(new_property2)
The properties are added as parameters to the network on creation. A custom
property need not be persistent, but in general it’s better to save it as
part of the network using the persistent custom properties available in all
Dallinger models. If they are not stored, any calculations that rely on them
have to be performed at initialization time. Once they are stored, they can
be used in any part of the network code, like in the add_node
method.
In the code above, we use repr
when storing the property value. This is
because Dallinger custom properties are all of the text type, so even if a
custom property represents a number, it has to be stored as a string. If the
property is a string to begin with, it’s not necessary to convert it.