4.1. Toy Example of MAS with Creamas

(full code)

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:

  1. The focus of the agents in the Creamas is on artifact creation.
  2. Environment is used for establishing communication route between agents. (You can also subclass it to have some additional responsibilities.)
  3. 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:
  • filename (str) – Path to the file to parse
  • encoding (str) – Encoding of the file (most probably ‘utf8’)
  • word_pattern – Compiled regex for acceptable words
  • wlen_limits (tuple) – Length limits for the acceptable words
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:
  • filename (str) – File to learn the words
  • encoding (str) – Encoding of the file (most probably ‘utf8’)
  • word_pattern – Compiled regex for acceptable words
  • wlen_limits (tuple) – Length limits for the acceptable words
  • n (int) – Number of words to return
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

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:artifactArtifact to be evaluated
Returns:(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:

  1. Each agent produces a number of artifacts and selects the best one
  2. Each agent adds its best artifact to candidate artifacts
  3. 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