diff --git a/.travis.yml b/.travis.yml index 620de4a..833ead2 100644 --- a/.travis.yml +++ b/.travis.yml @@ -13,7 +13,7 @@ install: - pip install coveralls script: - python setup.py develop - - coverage run --source=src -m unittest discover tests + - coverage run --source=src -m unittest discover tests -v - coverage report -m - python doctests.py after_success: diff --git a/docs/background/genetic_algorithm.rst b/docs/background/genetic_algorithm.rst index 7271b9e..1654817 100644 --- a/docs/background/genetic_algorithm.rst +++ b/docs/background/genetic_algorithm.rst @@ -92,3 +92,20 @@ The crossover and mutation are implemented in the following way: being changed to another random state of :math:`\delta \times 10^{-1} \times N` (where :math:`N` is the number of states). + +Cycler Sequence Calculator +-------------------------- + +A Cycler Sequence is the sequence of C & D actions that are passed to the cycler player to follow when playing their +tournament games. + +the sequence is found using genetic feature selection: + +- Crossover: By working with another cycler player, we take sections of each player and create a new cycler sequence +from the following formula: + let our two player being crossed be called p1 and p2 respectively. we then find the midpoint of both the sequences + and take the first half from p1 and the second half from p2 to combine into the new cycler sequence. + +- Mutation: we use a predictor :math:`\delta`to determine if we are going to mutate a +single element in the current sequence. The element, or gene, we change in the sequence is uniformly selected using +the random :code:`package`. diff --git a/docs/tutorial/index.rst b/docs/tutorial/index.rst index 0e6de44..9e20c9e 100644 --- a/docs/tutorial/index.rst +++ b/docs/tutorial/index.rst @@ -53,7 +53,8 @@ We can now evolve our population:: The :code:`run` command prints out the progress of the algorithm and this is also written to the output file (we passed :code:`output_filename` as an -argument earlier). +argument earlier). The printing can be turned off to keep logging to a minimum +by passing :code:`print_output=False` to the :code:`run`. The last best score is a finite state machine with representation :code:`0:C:0_C_0_C:0_D_1_D:1_C_1_D:1_D_1_D` which corresponds to a strategy that diff --git a/src/axelrod_dojo/__init__.py b/src/axelrod_dojo/__init__.py index 16b8256..f2622f6 100644 --- a/src/axelrod_dojo/__init__.py +++ b/src/axelrod_dojo/__init__.py @@ -2,6 +2,7 @@ from .archetypes.fsm import FSMParams from .archetypes.hmm import HMMParams from .archetypes.gambler import GamblerParams +from .archetypes.cycler import CyclerParams from .algorithms.genetic_algorithm import Population from .algorithms.particle_swarm_optimization import PSO from .utils import (prepare_objective, diff --git a/src/axelrod_dojo/algorithms/genetic_algorithm.py b/src/axelrod_dojo/algorithms/genetic_algorithm.py index 9decd99..a90e831 100644 --- a/src/axelrod_dojo/algorithms/genetic_algorithm.py +++ b/src/axelrod_dojo/algorithms/genetic_algorithm.py @@ -1,26 +1,30 @@ -from itertools import repeat +from itertools import repeat, starmap from multiprocessing import Pool, cpu_count from operator import itemgetter from random import randrange from statistics import mean, pstdev import axelrod as axl - from axelrod_dojo.utils import Outputer, PlayerInfo, score_params class Population(object): """Population class that implements the evolutionary algorithm.""" + def __init__(self, params_class, params_kwargs, size, objective, output_filename, bottleneck=None, mutation_probability=.1, opponents=None, processes=1, weights=None, sample_count=None, population=None): + self.print_output = True self.params_class = params_class self.bottleneck = bottleneck - if processes == 0: - processes = cpu_count() - self.pool = Pool(processes=processes) + self.processes = cpu_count() + else: + self.processes = processes + + self.pool = Pool(processes=self.processes) + self.outputer = Outputer(output_filename, mode='a') self.size = size self.objective = objective @@ -30,10 +34,10 @@ def __init__(self, params_class, params_kwargs, size, objective, output_filename self.bottleneck = bottleneck if opponents is None: self.opponents_information = [ - PlayerInfo(s, {}) for s in axl.short_run_time_strategies] + PlayerInfo(s, {}) for s in axl.short_run_time_strategies] else: self.opponents_information = [ - PlayerInfo(p.__class__, p.init_kwargs) for p in opponents] + PlayerInfo(p.__class__, p.init_kwargs) for p in opponents] self.generation = 0 self.params_kwargs = params_kwargs @@ -50,13 +54,16 @@ def __init__(self, params_class, params_kwargs, size, objective, output_filename self.sample_count = sample_count def score_all(self): - starmap_params = zip( + starmap_params_zip = zip( self.population, repeat(self.objective), repeat(self.opponents_information), repeat(self.weights), repeat(self.sample_count)) - results = self.pool.starmap(score_params, starmap_params) + if self.processes == 1: + results = list(starmap(score_params, starmap_params_zip)) + else: + results = self.pool.starmap(score_params, starmap_params_zip) return results def subset_population(self, indices): @@ -77,7 +84,8 @@ def crossover(population, num_variants): def evolve(self): self.generation += 1 - print("Scoring Generation {}".format(self.generation)) + if self.print_output: + print("Scoring Generation {}".format(self.generation)) # Score population scores = self.score_all() @@ -85,14 +93,18 @@ def evolve(self): results.sort(key=itemgetter(0), reverse=True) # Report - print("Generation", self.generation, "| Best Score:", results[0][0], - repr(self.population[results[0][1]])) + if self.print_output: + print("Generation", self.generation, "| Best Score:", results[0][0], repr(self.population[results[0][ + 1]])) # prints best result # Write the data + # Note: if using this for analysis, for reproducability it may be useful to + # pass type(opponent) for each of the opponents. This will allow verification of results post run + row = [self.generation, mean(scores), pstdev(scores), results[0][0], repr(self.population[results[0][1]])] - self.outputer.write(row) + self.outputer.write_row(row) - ## Next Population + # Next Population indices_to_keep = [p for (s, p) in results[0: self.bottleneck]] self.subset_population(indices_to_keep) # Add mutants of the best players @@ -106,7 +118,7 @@ def evolve(self): params_to_modify = [params.copy() for params in self.population] params_to_modify += random_params # Crossover - size_left = self.size - len(params_to_modify) + size_left = self.size - len(self.population) params_to_modify = self.crossover(params_to_modify, size_left) # Mutate for p in params_to_modify: @@ -119,7 +131,9 @@ def __iter__(self): def __next__(self): self.evolve() - def run(self, generations): + def run(self, generations, print_output=True): + self.print_output = print_output + for _ in range(generations): next(self) - self.outputer.close() + diff --git a/src/axelrod_dojo/archetypes/cycler.py b/src/axelrod_dojo/archetypes/cycler.py new file mode 100644 index 0000000..a091157 --- /dev/null +++ b/src/axelrod_dojo/archetypes/cycler.py @@ -0,0 +1,119 @@ +import axelrod as axl +from numpy import random + +from axelrod_dojo.utils import Params + +C, D = axl.Action + + +class CyclerParams(Params): + """ + Cycler params is a class to aid with the processes of calculating the best sequence of moves for any given set of + opponents. Each of the population in our algorithm will be an instance of this class for putting into our + genetic lifecycle. + """ + + def __init__(self, sequence=None, sequence_length: int = 200, mutation_probability=0.1, mutation_potency=1): + if sequence is None: + # generates random sequence if none is given + self.sequence = self.generate_random_sequence(sequence_length) + else: + # when passing a sequence, make a copy of the sequence to ensure mutation is for the instance only. + self.sequence = list(sequence) + + self.sequence_length = len(self.sequence) + self.mutation_probability = mutation_probability + self.mutation_potency = mutation_potency + + def __repr__(self): + return "{}".format(self.sequence) + + @staticmethod + def generate_random_sequence(sequence_length): + """ + Generate a sequence of random moves + + Parameters + ---------- + sequence_length - length of random moves to generate + + Returns + ------- + list - a list of C & D actions: list[Action] + """ + # D = axl.Action(0) | C = axl.Action(1) + return list(map(axl.Action, random.randint(0, 2, (sequence_length, 1)))) + + def crossover(self, other_cycler, in_seed=0): + """ + creates and returns a new CyclerParams instance with a single crossover point in the middle + + Parameters + ---------- + other_cycler - the other cycler where we get the other half of the sequence + + Returns + ------- + CyclerParams + + """ + seq1 = self.sequence + seq2 = other_cycler.sequence + + if not in_seed == 0: + # only seed for when we explicitly give it a seed + random.seed(in_seed) + + midpoint = int(random.randint(0, len(seq1)) / 2) + new_seq = seq1[:midpoint] + seq2[midpoint:] + return CyclerParams(sequence=new_seq) + + def mutate(self): + """ + Basic mutation which may change any random gene(s) in the sequence. + """ + if random.rand() <= self.mutation_probability: + mutated_sequence = self.sequence + for _ in range(self.mutation_potency): + index_to_change = random.randint(0, len(mutated_sequence)) + mutated_sequence[index_to_change] = mutated_sequence[index_to_change].flip() + self.sequence = mutated_sequence + + def player(self): + """ + Create and return a Cycler player with the sequence that has been generated with this run. + + Returns + ------- + Cycler(sequence) + """ + return axl.Cycler(self.get_sequence_str()) + + def copy(self): + """ + Returns a copy of the current cyclerParams + + Returns + ------- + CyclerParams - a separate instance copy of itself. + """ + # seq length will be provided when copying, no need to pass + return CyclerParams(sequence=self.sequence, mutation_probability=self.mutation_probability) + + def get_sequence_str(self): + """ + Concatenate all the actions as a string for constructing Cycler players + + [C,D,D,C,D,C] -> "CDDCDC" + [C,C,D,C,C,C] -> "CCDCCC" + [D,D,D,D,D,D] -> "DDDDDD" + + Returns + ------- + str + """ + string_sequence = "" + for action in self.sequence: + string_sequence += str(action) + + return string_sequence diff --git a/src/axelrod_dojo/utils.py b/src/axelrod_dojo/utils.py index 948e64a..0f2e85a 100644 --- a/src/axelrod_dojo/utils.py +++ b/src/axelrod_dojo/utils.py @@ -2,7 +2,6 @@ from functools import partial from statistics import mean import csv -import os import numpy as np import axelrod as axl @@ -11,17 +10,14 @@ ## Output Evolutionary Algorithm results class Outputer(object): - def __init__(self, filename, mode='w'): - self.output = open(filename, mode) - self.writer = csv.writer(self.output) + def __init__(self, filename, mode='a'): + self.file = filename + self.mode = mode - def write(self, row): - self.writer.writerow(row) - self.output.flush() - os.fsync(self.output.fileno()) - - def close(self): - self.output.close() + def write_row(self, row): + with open(self.file, self.mode, newline='') as file_writer: + writer = csv.writer(file_writer) + writer.writerow(row) ## Objective functions for optimization @@ -105,11 +101,13 @@ def objective_moran_win(me, other, turns, noise, repetitions, N=5, scores_for_this_opponent.append(0) return scores_for_this_opponent + # Evolutionary Algorithm class Params(object): """Abstract Base Class for Parameters Objects.""" + def mutate(self): pass @@ -147,6 +145,7 @@ def create_vector_bounds(self): """Creates the bounds for the decision variables.""" pass + PlayerInfo = namedtuple('PlayerInfo', ['strategy', 'init_kwargs']) @@ -193,4 +192,3 @@ def load_params(params_class, filename, num): for score, rep in all_params[:num]: best_params.append(parser(rep)) return best_params - diff --git a/tests/archetypes/test_cycler.py b/tests/archetypes/test_cycler.py new file mode 100644 index 0000000..f633c13 --- /dev/null +++ b/tests/archetypes/test_cycler.py @@ -0,0 +1,63 @@ +import unittest + +import axelrod as axl +from axelrod_dojo.archetypes.cycler import CyclerParams + +C, D = axl.Action + + +class TestCyclerParams(unittest.TestCase): + def setUp(self): + self.instance = None + + # Basic creation methods setting the correct params + + def test_creation_seqLen(self): + axl.seed(0) + test_length = 10 + self.instance = CyclerParams(sequence_length=test_length) + self.assertEqual(self.instance.sequence, [D, C, C, D, C, C, C, C, C, C]) + self.assertEqual(self.instance.sequence_length, test_length) + self.assertEqual(len(self.instance.sequence), test_length) + + def test_creation_seq(self): + test_seq = [C, C, D, C, C, D, D, C, D, D] + self.instance = CyclerParams(sequence=test_seq) + self.assertEqual(self.instance.sequence_length, len(test_seq)) + self.assertEqual(self.instance.sequence, test_seq) + + def test_crossover_even_length(self): + # Even test + test_seq_1 = [C] * 6 + test_seq_2 = [D] * 6 + result_seq = [C, C, D, D, D, D] + + self.instance = CyclerParams(sequence=test_seq_1) + instance_two = CyclerParams(sequence=test_seq_2) + out_cycler = self.instance.crossover(instance_two, in_seed=1) + self.assertEqual(result_seq, out_cycler.sequence) + + def test_crossover_odd_length(self): + # Odd Test + test_seq_1 = [C] * 7 + test_seq_2 = [D] * 7 + result_seq = [C, C, D, D, D, D, D] + + self.instance = CyclerParams(sequence=test_seq_1) + instance_two = CyclerParams(sequence=test_seq_2) + out_cycler = self.instance.crossover(instance_two, in_seed=1) + self.assertEqual(result_seq, out_cycler.sequence) + + def test_mutate(self): + test_seq = [C, D, D, C, C, D, D] + self.instance = CyclerParams(sequence=test_seq, mutation_probability=1) + self.instance.mutate() + # these are dependent on each other but testing both will show that we haven't just removed a gene + self.assertEqual(len(test_seq), self.instance.sequence_length) + self.assertNotEqual(test_seq, self.instance.sequence) + + def test_copy(self): + test_seq = [C, D, D, C, C, D, D] + self.instance = CyclerParams(sequence=test_seq) + instance_two = self.instance.copy() + self.assertFalse(self.instance is instance_two) diff --git a/tests/integration/test_cycler_integration.py b/tests/integration/test_cycler_integration.py new file mode 100644 index 0000000..93585e6 --- /dev/null +++ b/tests/integration/test_cycler_integration.py @@ -0,0 +1,45 @@ +import os +import tempfile +import unittest + +import axelrod as axl +import axelrod_dojo as axl_dojo + + +class TestCyclerParams(unittest.TestCase): + def setUp(self): + pass + + def test_default_single_opponent_e2e(self): + temp_file = tempfile.NamedTemporaryFile() + # we will set the objective to be + cycler_objective = axl_dojo.prepare_objective(name="score", turns=10, repetitions=1) + + # Lets use an opponent_list of just one: + opponent_list = [axl.TitForTat(), axl.Calculator()] + cycler = axl_dojo.CyclerParams + + # params to pass through + cycler_kwargs = { + "sequence_length": 10 + } + + # assert file is empty to start + self.assertEqual(temp_file.readline(), b'') # note that .readline() reads bytes hence b'' + + population = axl_dojo.Population(params_class=cycler, + params_kwargs=cycler_kwargs, + size=20, + objective=cycler_objective, + output_filename=temp_file.name, + opponents=opponent_list) + + generations = 5 + population.run(generations, print_output=False) + + # assert the output file exists and is not empty + self.assertTrue(os.path.exists(temp_file.name)) + self.assertNotEqual(temp_file.readline(), b'') # note that .readline() reads bytes hence b'' + + # close the temp file + temp_file.close() diff --git a/tests/integration/test_fsm.py b/tests/integration/test_fsm.py index d07b5c2..110b975 100644 --- a/tests/integration/test_fsm.py +++ b/tests/integration/test_fsm.py @@ -38,7 +38,7 @@ def test_score(self): generations = 4 axl.seed(0) - population.run(generations) + population.run(generations, print_output=False) self.assertEqual(population.generation, 4) # Manually read from tempo file to find best strategy @@ -74,7 +74,7 @@ def test_score(self): processes=1) generations = 4 axl.seed(0) - population.run(generations) + population.run(generations, print_output=False) self.assertEqual(population.generation, 4) def test_score_with_weights(self): @@ -104,7 +104,7 @@ def test_score_with_weights(self): generations = 4 axl.seed(0) - population.run(generations) + population.run(generations, print_output=False) self.assertEqual(population.generation, 4) # Manually read from tempo file to find best strategy @@ -156,7 +156,7 @@ def test_score_with_sample_count(self): generations = 4 axl.seed(0) - population.run(generations) + population.run(generations, print_output=False) self.assertEqual(population.generation, 4) # Manually read from tempo file to find best strategy @@ -209,7 +209,7 @@ def test_score_with_sample_count_and_weights(self): generations = 4 axl.seed(0) - population.run(generations) + population.run(generations, print_output=False) self.assertEqual(population.generation, 4) # Manually read from tempo file to find best strategy @@ -266,7 +266,7 @@ def test_score_with_particular_players(self): generations = 4 axl.seed(0) - population.run(generations) + population.run(generations, print_output=False) self.assertEqual(population.generation, 4) def test_population_init_with_given_rate(self): @@ -298,5 +298,5 @@ def test_population_init_with_given_rate(self): self.assertEqual(p.mutation_probability, .5) generations = 1 axl.seed(0) - population.run(generations) + population.run(generations, print_output=False) self.assertEqual(population.generation, 1) diff --git a/tests/integration/test_hmm.py b/tests/integration/test_hmm.py index d8e3ed3..0930c61 100644 --- a/tests/integration/test_hmm.py +++ b/tests/integration/test_hmm.py @@ -37,7 +37,7 @@ def test_score(self): generations = 4 axl.seed(0) - population.run(generations) + population.run(generations, print_output=False) self.assertEqual(population.generation, 4) # Manually read from temp file to find best strategy @@ -73,7 +73,7 @@ def test_score(self): processes=1) generations = 4 axl.seed(0) - population.run(generations) + population.run(generations, print_output=False) self.assertEqual(population.generation, 4) def test_score_with_weights(self): @@ -103,7 +103,7 @@ def test_score_with_weights(self): generations = 4 axl.seed(0) - population.run(generations) + population.run(generations, print_output=False) self.assertEqual(population.generation, 4) # Manually read from temp file to find best strategy @@ -155,7 +155,7 @@ def test_score_with_sample_count(self): generations = 4 axl.seed(0) - population.run(generations) + population.run(generations, print_output=False) self.assertEqual(population.generation, 4) # Manually read from temp file to find best strategy @@ -208,7 +208,7 @@ def test_score_with_sample_count_and_weights(self): generations = 4 axl.seed(0) - population.run(generations) + population.run(generations, print_output=False) self.assertEqual(population.generation, 4) # Manually read from tempo file to find best strategy @@ -259,7 +259,7 @@ def test_score_with_particular_players(self): generations = 4 axl.seed(0) - population.run(generations) + population.run(generations, print_output=False) self.assertEqual(population.generation, 4) def test_population_init_with_given_rate(self): @@ -291,7 +291,7 @@ def test_population_init_with_given_rate(self): self.assertEqual(p.mutation_probability, .5) generations = 1 axl.seed(0) - population.run(generations) + population.run(generations, print_output=False) self.assertEqual(population.generation, 1) def test_score_pso(self): @@ -381,7 +381,7 @@ def test_pso_to_ea(self): processes=1) axl.seed(0) - population.run(generations) + population.run(generations, print_output=False) # Block resource (?) with open(self.temporary_file.name, "w") as f: diff --git a/tests/integration/test_population_sizes.py b/tests/integration/test_population_sizes.py new file mode 100644 index 0000000..0032975 --- /dev/null +++ b/tests/integration/test_population_sizes.py @@ -0,0 +1,69 @@ +import tempfile +import unittest + +import axelrod as axl +import axelrod_dojo as axl_dojo + + +class TestPopulationSizes(unittest.TestCase): + + def test_basic_pop_size(self): + # Set up Tmp file + temp_file = tempfile.NamedTemporaryFile() + # we will set the objective to be + cycler_objective = axl_dojo.prepare_objective(name="score", turns=10, repetitions=1) + # Lets use an opponent_list of just one: + opponent_list = [axl.TitForTat()] + # params to pass through + cycler_kwargs = { + "sequence_length": 10 + } + + population_size = 20 + population = axl_dojo.Population(params_class=axl_dojo.CyclerParams, + params_kwargs=cycler_kwargs, + size=population_size, + objective=cycler_objective, + output_filename=temp_file.name, + opponents=opponent_list) + + # Before run + self.assertEqual(len(population.population), population_size) + + # After Run + population.run(generations=5, print_output=False) + self.assertEqual(len(population.population), population_size) + + # close the temp file + temp_file.close() + + def test_bottleneck_pop_size(self): + # Set up Tmp file + temp_file = tempfile.NamedTemporaryFile() + # we will set the objective to be + cycler_objective = axl_dojo.prepare_objective(name="score", turns=10, repetitions=1) + # Lets use an opponent_list of just one: + opponent_list = [axl.TitForTat()] + # params to pass through + cycler_kwargs = { + "sequence_length": 10 + } + + population_size = 20 + population = axl_dojo.Population(params_class=axl_dojo.CyclerParams, + params_kwargs=cycler_kwargs, + size=population_size, + bottleneck=1, + objective=cycler_objective, + output_filename=temp_file.name, + opponents=opponent_list) + + # Before run + self.assertEqual(len(population.population), population_size) + + # After Run + population.run(generations=5, print_output=False) + self.assertEqual(len(population.population), population_size) + + # close the temp file + temp_file.close() diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py index 8fbb812..1d8f914 100644 --- a/tests/unit/test_utils.py +++ b/tests/unit/test_utils.py @@ -10,17 +10,18 @@ class TestOutputer(unittest.TestCase): temporary_file = tempfile.NamedTemporaryFile() - outputer = utils.Outputer(filename=temporary_file.name) + outputer = utils.Outputer(filename=temporary_file.name, mode='a') def test_init(self): - self.assertIsInstance(self.outputer.output, io.TextIOWrapper) - self.assertEqual(str(type(self.outputer.writer)), - "") + self.assertEqual(self.outputer.file, self.temporary_file.name) + self.assertEqual(self.outputer.mode, 'a') - def test_write(self): - self.assertIsNone(self.outputer.write([1, 2, 3])) + def test_write_and_clear(self): + writing_line = [1, "something", 3.0] + self.outputer.write_row(writing_line) + self.outputer.write_row(writing_line) with open(self.temporary_file.name, "r") as f: - self.assertEqual("1,2,3\n", f.read()) + self.assertEqual("1,something,3.0\n1,something,3.0\n", f.read()) class TestPrepareObjective(unittest.TestCase): @@ -31,11 +32,11 @@ def test_incorrect_objective_name(self): def test_score(self): objective = utils.prepare_objective( - name="score", - turns=200, - noise=0, - match_attributes={"length": float("inf")}, - repetitions=5) + name="score", + turns=200, + noise=0, + match_attributes={"length": float("inf")}, + repetitions=5) self.assertIsInstance(objective, functools.partial) self.assertIn("objective_score ", str(objective)) @@ -181,6 +182,7 @@ class DummyParams(utils.Params): """ Dummy Params class for testing purposes """ + def player(self): return axl.Cooperator() diff --git a/training_output.csv b/training_output.csv index 690268d..84cc917 100644 --- a/training_output.csv +++ b/training_output.csv @@ -188,3 +188,15 @@ 2,1.92105263158,0.2545976131041648,2.1,0:C:0_C_0_C:0_D_1_C:1_C_1_D:1_D_1_D 3,2.02631578947,0.17624376638420158,2.2,0:C:0_C_0_C:0_D_1_D:1_C_1_D:1_D_1_D 4,2.05789473684,0.20515066643131208,2.2,0:C:0_C_0_C:0_D_1_D:1_C_1_D:1_D_1_D +1,1.77,0.19969421067666884,2.1,0:C:0_C_0_C:0_D_1_C:1_C_1_D:1_D_1_D +2,1.8166666666666667,0.23225943348859798,2.1,0:C:0_C_0_C:0_D_1_C:1_C_1_D:1_D_1_D +3,1.9366666666666668,0.21804688588568394,2.1,0:C:0_C_0_C:0_D_1_C:1_C_1_D:1_D_1_D +4,1.96,0.17876116903722566,2.1,0:C:0_C_0_C:0_D_1_C:1_C_1_D:1_D_1_D +1,1.77,0.19969421067666884,2.1,0:C:0_C_0_C:0_D_1_C:1_C_1_D:1_D_1_D +2,1.8166666666666667,0.23225943348859798,2.1,0:C:0_C_0_C:0_D_1_C:1_C_1_D:1_D_1_D +3,1.9366666666666668,0.21804688588568394,2.1,0:C:0_C_0_C:0_D_1_C:1_C_1_D:1_D_1_D +4,1.96,0.17876116903722566,2.1,0:C:0_C_0_C:0_D_1_C:1_C_1_D:1_D_1_D +1,1.77,0.19969421067666884,2.1,0:C:0_C_0_C:0_D_1_C:1_C_1_D:1_D_1_D +2,1.8166666666666667,0.23225943348859798,2.1,0:C:0_C_0_C:0_D_1_C:1_C_1_D:1_D_1_D +3,1.9366666666666668,0.21804688588568394,2.1,0:C:0_C_0_C:0_D_1_C:1_C_1_D:1_D_1_D +4,1.96,0.17876116903722566,2.1,0:C:0_C_0_C:0_D_1_C:1_C_1_D:1_D_1_D