4.1. Toy Example of MAS with Creamas¶
Warning
(Sat 12.11. 10.04) There was a mistake in the example codes considering
evaluate()
function and the number of elements it should return. There
are now two full code examples toy_mas.py
and toy_mas2.py
in the
repository. The version that works for you is depended on the version of
Creamas: if you have Creamas 0.1.1 then toy_mas.py
should work, and
if you have Creamas 0.1.0 then toy_mas2.py
should work. You can check
which version you currently have by typing pip freeze
in the terminal while
your virtual environment is active.
The easiest way to get everything working is upgrading the Creamas and following
this example (and the full code toy_mas.py
). You can upgrade you
Creamas 0.1.0 to 0.1.1 by executing pip install --upgrade creamas
in the terminal.
If you are installing Creamas for the first time, then pip install creamas
should automatically install you the latest version (0.1.1).
This example is currently written for
Creamas 0.1.1, however there is very little that is changed between the two example
code version. The differences are in how many elements evaluate()
returns. With creamas==0.1.0, the evaluation function should return one element.
With creamas==0.1.1, the evaluation function should return two elements.
Changing the evaluate()
also forces some refactoring in invent()
to reflect these changes.
In this example we will create a simple multi-agent system with Creamas. Creamas uses vocabulary typical to MAS in CC: agents, environments, and artifacts. You can find an overview of the library (which should be enough for this example) from the documentation, but here are main points:
- The focus of the agents in the Creamas is on artifact creation.
- Environment is used for establishing communication route between agents. (You can also subclass it to have some additional responsibilities.)
- Artifacts are light wrappers for (any kind of) objects, where you can store some metadata about the artifact, e.g. its creator, evaluations and framings.
Note
Creamas should be installed in your virtual environment. If you get any
import errors while running the code, use pip install creamas==0.1.1
.
4.1.1. Parsing the Vocabulary¶
Our goal is to make agents which generate (random) words, and evaluate them based on their vocabulary. In this example all the agents will have the same vocabulary, which is generally not desirable, but will do for the sake of the example. To generate the vocabulary, we will define two straightforward parsing functions:
-
toy_mas.
parse_words
(filename, encoding, word_pattern, wlen_limits)[source]¶ Parse acceptable words from the file.
Parameters: Returns: Acceptable words as a list (may contain multiple entries of the same word).
-
toy_mas.
frequent_words
(filename, encoding, word_pattern, wlen_limits, n=20)[source]¶ Get the most frequent words from the given file.
The ‘word’ is used loosely here as a word is anything the
parse_words
function will recognize as a word.Parameters: Returns: a list of the most common (n) words in the file
And here is the actual code for both of the functions (docstrings stripped):
def parse_words(self, filename, encoding, word_pattern, wlen_limits):
# Filter function to define which words are accepted.
def is_word(w):
if not word_pattern.match(w.lower()):
return False
if not len(w) >= self.wlen_limits[0]:
return False
if not len(w) <= self.wlen_limits[1]:
return False
return True
with open(filename, 'r', encoding=encoding) as f:
content = f.read()
words = content.split()
ret = [w.lower() for w in words if is_word(w)]
return ret
def frequent_words(self, filename, encoding, word_pattern, wlen_limits, n=20):
words = parse_words(filename, encoding, word_pattern, wlen_limits)
# Count the number of times each element appears in the list
ctr = Counter(words)
common = ctr.most_common(n)
# return only the words, not their counts
return [e[0] for e in common]
We will use these two parsing functions in the next section where we actually implement our agents.
4.1.2. Creating an Agent Class¶
Now we create a new agent class by subclassing the
base class CreativeAgent
from Creamas. CreativeAgent
has a lot of predefined attributes, which we
will not touch in here. We will also create the initialization function
__init__()
. The initialization function has to call its superclass’
initialization function so that all required attributes get initialized. In our
initialization function, we will define the acceptable words as a compiled regex
pattern and word limits, and then “learn” the vocabulary from a given file. Here
is the documentation for our ToyAgent
(env
is the environment for
the agent, which we will define later in the example):
-
class
toy_mas.
ToyAgent
(env, filename, encoding='utf8', n=20, wlen_limits=(2, 11), chars='abcdefghijklmnopqrstuvwxyz')[source]¶ A sample agent implementation.
Agent invents new words be generating them at random and evaluating them with respect to its own vocabulary.
Agent learns its vocabulary from the file given at initialization.
Parameters: - env – subclass of
Environment
- filename (str) – Filename from which the words should be parsed.
- encoding (str) – Encoding of the file
- n (int) – The number of words the agent considers per
invent()
- wlen_limits (tuple) – (int, int)-tuple, acceptable word length limits
- chars (str) – acceptable characters in the words
- env – subclass of
And here is the code (docstrings stripped)e:
class ToyAgent(CreativeAgent):
def __init__(self, env, filename, encoding='utf8', n=20,
wlen_limits=(2,11), chars='abcdefghijklmnopqrstuvwxyz'):
super().__init__(env)
self.n = n
self.chars = chars
self.wlen_limits = wlen_limits
self.word_pattern = re.compile(r'^\w+$')
self.vocab = frequent_words(filename, encoding=encoding,
word_pattern=self.word_pattern,
wlen_limits=self.wlen_limits, n=20)
4.1.3. Generating words¶
As our aim is for a creative (well, at least generative) agent, we need a
function which generates us some words (or character strings). In this example
we will settle on generating random character strings from the chars
given
at agent’s initialization time. To this end, our generate
function is
very simple (N.B. this is actually a method inside the class ToyAgent
, hence
the self
parameter):
def generate(self):
'''Generate a new word.
Word is generated by uniformly drawing from ``chars``. Word length is
in ``wlen_limits``.
:returns: a word wrapped as :class:`~creamas.core.artifact.Artifact`
'''
word_length = random.randint(*self.wlen_limits)
word = [random.choice(self.chars) for _ in range(word_length)]
word = ''.join(word)
return Artifact(self, word, domain=str)
Here it is important to see that the generated word is wrapped as
Artifact,
which can hold some metadata considering the object. We will include some
metadata in the following sections, but for now it should be enough to know,
that the original object (in this case the string) can be accessed as obj
attribute of the artifact, i.e.:
>>> artifact = Artifact(self, 'blaa', domain=str)
>>> artifact.obj
'blaa'
The domain of the artifact is not relevant at this point. (It has to do with automatic feature evaluation based on the rules the agent has, but currently the functionality is not properly implemented.)
Note
Agents in Creamas should always pass each other Artifact
instances (or
subclasses of it), for all the library’s functionalities to work.
4.1.4. Evaluating Words¶
Our agent is now capable of generating some random character strings, but has
no notion of value (or novelty or surprisingness). To evaluate our strings,
we will use the vocab
which was learned from a file when the agent was
created, and Levenshtein distance.
For the sake of our purposes, it is sufficient to know that the Levenshtein
distance computes the minimum number of edit operations by which one of the strings
can be changed to another. Here is our evaluation function’s documentation:
-
ToyAgent.
evaluate
(artifact)[source]¶ Evaluate given artifact with respect to the words the agent knows.
Actual evaluation formula for a string \(s\) is:
\[e(s) = \max_{w \in \texttt{vocab}}\frac{1 - \texttt{lev}(s, w)} {\max(|s|, |w|)},\]where \(\texttt{lev}(s, w)\) is the Levenshtein distance between the two strings.
Parameters: artifact – Artifact
to be evaluatedReturns: (evaluation, word)-tuple, containing both the evaluation and the word giving the maximum evaluation
And its code (the code for the Levenshtein distance computation can be found from the full code example, but it is not included here for clarity):
def evaluate(self, artifact):
evaluation = 0.0
evaluation_word = artifact.obj
matching_word = self.vocab[0]
for word in self.vocab:
lev = levenshtein(evaluation_word, word)
mlen = max(len(evaluation_word), float(len(word)))
current_evaluation = 1.0 - (float(lev) / mlen)
if current_evaluation > evaluation:
evaluation = current_evaluation
matching_word = word
return evaluation, matching_word
Note
It is important for the evaluate()
method to return two elements
because of how the Creamas is built. The another element is so called
framing for the evaluation, but it can be left blank. If you do not need
the another element, put in its place None
.
4.1.5. Inventing New Words¶
With generation and evaluation of words in place, we can move to inventing new words. In this example we will simply try to create a number of words, and select the one with the highest evaluation. However, it could be justified to, e.g. start from a previous highly evaluated example and alter it and keep a list of already invented words so that we do not invent the same words repeatedly.
Our invent
function is very simple loop:
def invent(self, n=20):
best_artifact = self.generate()
max_evaluation, match_word = self.evaluate(best_artifact)
for _ in range(n-1):
artifact = self.generate()
evaluation, m_word = self.evaluate(artifact)
if evaluation > max_evaluation:
best_artifact = artifact
max_evaluation = evaluation
match_word = m_word
# Add evaluation and framing to the artifact
best_artifact.add_eval(self, max_evaluation, fr={'match' : match_word})
return best_artifact
Here, you can see that we add the evaluation and so called ‘framing’ to the artifact after we have selected the best artifact. The framing is something that was/is taken into consideration when creating/observing the artifact. Our framing is simply the word that created the best evaluation for our best artifact.
Now our agent is almost complete, however, as we want it to interact with other agents, we will make some additions.
4.1.6. Voting¶
Our interaction model between the agents is a voting procedure. We run the system in an iterative simulation where on each iteration:
- Each agent produces a number of artifacts and selects the best one
- Each agent adds its best artifact to candidate artifacts
- Agents vote the winner from the candidates
Note
As all the agents in our example will have the same words (if given the same file in initialization) in their vocabulary, it means that all agents have the same evaluation formula. We will use this naive model just for the example.
To run the simulation we will use
Simulation class. All
agents that run inside the simulation must implement act
function. For our
agents, we will simply call invent
on every act and then add the invented
word to candidates of the environment:
async def act(self):
artifact = self.invent(self.n)
self.env.add_candidate(artifact)
Note
The async
keyword before the function definition is of key importance here.
It tells Python that this function is supposed to be called inside an event
loop. We will not go very deeply into the asynchronicity during the course,
but if your code complains something about asyncio or event loop, you
probably have some kind of async-problems.
Next, we will define a subclass of
Environment where we
define only one function vote
, which is used as a callback on every
simulation step:
class ToyEnvironment(Environment):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def vote(self, age):
artifacts = self.perform_voting(method='mean')
if len(artifacts) > 0:
accepted = artifacts[0][0]
value = artifacts[0][1]
logger.info("Vote winner by {}: {} (val={})"
.format(accepted.creator, accepted.obj, value))
else:
logger.info("No vote winner!")
self.clear_candidates()
The vote
function performs the voting with a selected method (voting rule).
Here the method is to select the candidate with the best mean evaluation
across all the agents. Then we simply announce the winner for this iteration.
It is important to clear the candidates after the vote, so that we do not vote
repeatedly for same artifacts.
The voting functionality is done “under the hood” by some methods inherited
from Environment
and CreativeAgent
. However, it uses
the evaluate
function which we defined for the agents (that’s why it is
important that it returns two values!). You can check what perform_voting()
does from Creamas documentation.
4.1.7. Running the Simulation¶
Lastly, we will run our agents in the iterative simulation. Here, we will initialize all agents with the same parameters and run it for a short time:
if __name__ == "__main__":
filename = '../week1/alice.txt'
env = ToyEnvironment.create(('localhost', 5555))
for i in range(10):
agent = ToyAgent(env, filename=filename)
sim = Simulation(env, log_folder='logs', callback=env.vote)
sim.async_steps(10)
sim.end()
We create environment using its create
function, which needs the address
for it, and then create 10 agents into that environment. Then we initialize
our simulation with the environment, and set the callback (which is called after
each iteration) to our environment’s vote
function. Lastly, we run the
simulation for 10 steps and end it.
Note
If you do not remember to call end
after finishing the simulation, you
will have some nasty exceptions because the event loop is still running!
That’s it! Now we have implemented a simple multi-agent system