From 543fb7328882318bd11c87e053fc23cf9b6764df Mon Sep 17 00:00:00 2001 From: Pattio Date: Mon, 29 Apr 2019 20:28:36 +0100 Subject: [PATCH] Cleanup the code --- deepswarm/__init__.py | 25 +++-- deepswarm/aco.py | 219 ++++++++++++++++++++++++++++++----------- deepswarm/backends.py | 106 +++++++++++--------- deepswarm/deepswarm.py | 32 +++--- deepswarm/log.py | 17 +++- deepswarm/nodes.py | 53 +++++++--- deepswarm/storage.py | 123 ++++++++++++++++++++--- examples/context.py | 2 +- tests/test_aco.py | 7 +- tests/test_graph.py | 32 +++--- tests/test_nodes.py | 11 ++- 11 files changed, 449 insertions(+), 178 deletions(-) diff --git a/deepswarm/__init__.py b/deepswarm/__init__.py index e73b784..be7726d 100644 --- a/deepswarm/__init__.py +++ b/deepswarm/__init__.py @@ -2,34 +2,39 @@ # Licensed under MIT License import argparse -import os import operator +import os import sys -from yaml import load, Loader + from pathlib import Path from shutil import copyfile +from yaml import load, Loader -# Create argument parser which allows users to pass a custom script name -# If user didn't pass a custom script name then use sys.argv[0] + +# Create argument parser which allows users to pass a custom settings file name +# If the user didn't pass a custom script name then use sys.argv[0] parser = argparse.ArgumentParser() parser.add_argument('-s', '--settings_file_name', default=os.path.basename(sys.argv[0]), help='Settings file name. The default value is the name of invoked script without the .py extenstion') args, _ = parser.parse_known_args() -# Retrieve name without the extension +# Retrieve filename without the extension filename = os.path.splitext(args.settings_file_name)[0] -# If mnist yaml doesn't exist it means package was installed via pip in which -# case we should use current working directory as the base path +# If mnist.yaml doesn't exist it means that the package was installed via pip in +# which case we should use the current working directory as the base path base_path = Path(os.path.dirname(os.path.dirname(__file__))) if not (base_path / 'settings' / 'mnist.yaml').exists(): module_path = base_path - # Change the base path to current working directory + + # Change the base path to the current working directory base_path = Path(os.getcwd()) settings_directory = (base_path / 'settings') + # Create settings directory if it doesn't exist if not settings_directory.exists(): settings_directory.mkdir() + # If default settings file doesn't exist, copy one from the module directory module_default_config = module_path / 'settings/default.yaml' settings_default_config = settings_directory / 'default.yaml' @@ -37,11 +42,11 @@ copyfile(module_default_config, settings_default_config) # As the base path is now configured we try to load configuration file -# associated with the filename +# associated with the filename settings_directory = base_path / 'settings' settings_file_path = Path(settings_directory, filename).with_suffix('.yaml') -# If file doesn't exist fallback to default settings file +# If the file doesn't exist fallback to the default settings file if not settings_file_path.exists(): settings_file_path = Path(settings_directory, 'default').with_suffix('.yaml') diff --git a/deepswarm/aco.py b/deepswarm/aco.py index f4f582f..444e05c 100644 --- a/deepswarm/aco.py +++ b/deepswarm/aco.py @@ -1,14 +1,17 @@ # Copyright (c) 2019 Edvinas Byla # Licensed under MIT License -import random import math +import random + from . import cfg, left_cost_is_better from .log import Log from .nodes import Node, NeighbourNode class ACO: + """Class responsible for performing Ant Colony Optimization.""" + def __init__(self, backend, storage): self.graph = Graph() self.current_depth = 0 @@ -16,13 +19,13 @@ def __init__(self, backend, storage): self.storage = storage def search(self): - """ Performs ant colony system optimization over the graph. + """Performs neural architecture search using Ant colony optimization. Returns: - ant which found best network topology + ant which found the best network topology. """ - # Generate random ant only if search started from zero + # Generate random ant only if the search started from zero if not self.storage.loaded_from_save: Log.header("STARTING ACO SEARCH", type="GREEN") self.best_ant = Ant(self.graph.generate_path(self.random_select)) @@ -34,78 +37,113 @@ def search(self): while self.graph.current_depth <= cfg['max_depth']: Log.header("Current search depth is %i" % self.graph.current_depth, type="GREEN") ants = self.generate_ants() - # Sort ants depending on user selected metric + + # Sort ants using user selected metric ants.sort() if cfg['metrics'] == 'loss' else ants.sort(reverse=True) - # If any of the new solutions has lower cost than best solution, update best + + # Update the best ant if new better ant is found if left_cost_is_better(ants[0].cost, self.best_ant.cost): self.best_ant = ants[0] Log.header("NEW BEST ANT FOUND", type="GREEN") + # Log best ant information Log.header("BEST ANT DURING ITERATION") Log.info(self.best_ant) - # Do global pheromone update + + # Perform global pheromone update self.update_pheromone(ant=self.best_ant, update_rule=self.global_update) - # Print pheromone information and increase graph's depth + + # Print pheromone information and increase the graph's depth self.graph.show_pheromone() self.graph.increase_depth() - # Do a backup + + # Perform a backup self.storage.perform_backup() return self.best_ant def generate_ants(self): + """Generates a new ant population. + + Returns: + list containing different evaluated ants. + """ + ants = [] for ant_number in range(cfg['aco']['ant_count']): Log.header("GENERATING ANT %i" % (ant_number + 1)) ant = Ant() - # Generate ant's path using given ACO rule + # Generate ant's path using ACO selection rule ant.path = self.graph.generate_path(self.aco_select) - # Evaluate how good is new path + # Evaluate how good is the new path ant.evaluate(self.backend, self.storage) ants.append(ant) Log.info(ant) + # Perform local pheromone update self.update_pheromone(ant=ant, update_rule=self.local_update) return ants def random_select(self, neighbours): + """Randomly selects one neighbour node and its attributes. + + Args: + neighbours [NeighbourNode]: list of neighbour nodes. + Returns: + a randomly selected neighbour node. + """ + current_node = random.choice(neighbours).node current_node.select_random_attributes() return current_node def aco_select(self, neighbours): - # Transform list of NeighbourNode objects to list of tuples (Node, pheromone, heuristic) + """Selects one neighbour node and its attributes using ACO selection rule. + + Args: + neighbours [NeighbourNode]: list of neighbour nodes. + Returns: + selected neighbour node. + """ + + # Transform a list of NeighbourNode objects to list of tuples + # (Node, pheromone, heuristic) tuple_neighbours = [(n.node, n.pheromone, n.heuristic) for n in neighbours] - # Select node using ant colony select rule + # Select node using ant colony selection rule current_node = self.aco_select_rule(tuple_neighbours) - # Select custom attributes using ant colony select rule + # Select custom attributes using ant colony selection rule current_node.select_custom_attributes(self.aco_select_rule) return current_node def aco_select_rule(self, neighbours): - """Selects neighbour node based on ant colony system transition rule + """Selects neigbour using ACO transition rule. Args: neighbours [(Object, float, float)]: list of tuples, where each tuple - containts object to be selected object's pheromone value and object's heuristic value + contains: an object to be selected, object's pheromone value and + object's heuristic value. Returns: - selected object + selected object. """ + probabilities = [] denominator = 0.0 + # Calculate probability for each neighbour for (_, pheromone, heuristic) in neighbours: probability = pheromone * heuristic probabilities.append(probability) denominator += probability + # Try to perform greedy select: exploitation random_variable = random.uniform(0, 1) if random_variable <= cfg['aco']['greediness']: # Find max probability max_probability = max(probabilities) - # Gather indices of probabilities that are equal to max probability + # Gather the indices of probabilities that are equal to the max probability max_indices = [i for i, j in enumerate(probabilities) if j == max_probability] # From those max indices select random index neighbour_index = random.choice(max_indices) return neighbours[neighbour_index][0] + # Otherwise perform select using roulette wheel: exploration probabilities = [x / denominator for x in probabilities] probability_sum = sum(probabilities) @@ -117,20 +155,31 @@ def aco_select_rule(self, neighbours): return neighbours[neighbour_index][0] def update_pheromone(self, ant, update_rule): + """Updates the pheromone using given update rule. + + Args: + ant: ant which should perform the pheromone update. + update_rule: function which takes pheromone value and ant's cost, + and returns a new pheromone value. + """ + current_node = self.graph.input_node - # Skip input node as it's not connected to any previous node + # Skip the input node as it's not connected to any previous node for node in ant.path[1:]: - # Use node from the path to retrieve its corresponding instace from the graph + # Use a node from the path to retrieve its corresponding instance from the graph neighbour = next((x for x in current_node.neighbours if x.node.name == node.name), None) - # If path was closed using complete_path method, ignore rest of the path + + # If the path was closed using complete_path method, ignore the rest of the path if neighbour is None: break - # Update pheromone connecting to neighbour + + # Update pheromone connecting to a neighbour neighbour.pheromone = update_rule( old_value=neighbour.pheromone, cost=ant.cost ) - # Update attribute pheromone values + + # Update attribute's pheromone values for attribute in neighbour.node.attributes: # Find what attribute value was used for node attribute_value = getattr(node, attribute.name) @@ -141,15 +190,20 @@ def update_pheromone(self, ant, update_rule): old_value=old_pheromone_value, cost=ant.cost ) - # Advance current node + + # Advance the current node current_node = neighbour.node def local_update(self, old_value, cost): + """Performs local pheromone update.""" + decay = cfg['aco']['pheromone']['decay'] pheromone_0 = cfg['aco']['pheromone']['start'] return (1 - decay) * old_value + (decay * pheromone_0) def global_update(self, old_value, cost): + """Performs global pheromone update.""" + # Calculate solution cost based on metrics added_pheromone = (1 / (cost * 10)) if cfg['metrics'] == 'loss' else cost evaporation = cfg['aco']['pheromone']['evaporation'] @@ -157,6 +211,8 @@ def global_update(self, old_value, cost): class Ant: + """Class responsible for representing the ant.""" + def __init__(self, path=[]): self.path = path self.loss = math.inf @@ -164,27 +220,19 @@ def __init__(self, path=[]): self.path_description = None self.path_hash = None - @property - def cost(self): - return self.loss if cfg['metrics'] == 'loss' else self.accuracy - - def __lt__(self, other): - return self.cost < other.cost + def evaluate(self, backend, storage): + """Evaluates how good ant's path is. - def __str__(self): - return "======= \n Ant: %s \n Loss: %f \n Accuracy: %f \n Path: %s \n Hash: %s \n=======" % ( - hex(id(self)), - self.loss, - self.accuracy, - self.path_description, - self.path_hash, - ) + Args: + backend: Backend object. + storage: Storage object. + """ - def evaluate(self, backend, storage): # Extract path information self.path_description, path_hashes = storage.hash_path(self.path) self.path_hash = path_hashes[-1] - # Check if model already exists if yes, then just re-use it + + # Check if the model already exists if yes, then just re-use it existing_model, existing_model_hash = storage.load_model(backend, path_hashes, self.path) if existing_model is None: # Generate model @@ -192,18 +240,41 @@ def evaluate(self, backend, storage): else: # Re-use model new_model = existing_model + # Train model new_model = backend.train_model(new_model) # Evaluate model self.loss, self.accuracy = backend.evaluate_model(new_model) - # If new model was created from older model, record older model progress + + # If the new model was created from the older model, record older model progress if existing_model_hash is not None: storage.record_model_performance(existing_model_hash, self.cost) + # Save model storage.save_model(backend, new_model, path_hashes, self.cost) + @property + def cost(self): + """Returns value which represents ant's cost.""" + + return self.loss if cfg['metrics'] == 'loss' else self.accuracy + + def __lt__(self, other): + return self.cost < other.cost + + def __str__(self): + return "======= \n Ant: %s \n Loss: %f \n Accuracy: %f \n Path: %s \n Hash: %s \n=======" % ( + hex(id(self)), + self.loss, + self.accuracy, + self.path_description, + self.path_hash, + ) + class Graph: + """Class responsible for representing the graph.""" + def __init__(self, current_depth=0): self.topology = [] self.current_depth = current_depth @@ -211,54 +282,88 @@ def __init__(self, current_depth=0): self.increase_depth() def get_node(self, node, depth): - # If we are trying to insert node into not existing layer, we pad topology - # by adding empty dictionaries, untill required depth is reached + """Tries to retrieve a given node from the graph. If the node does not + exist then the node is inserted into the graph before being retrieved. + + Args: + node: Node which should be found in the graph. + depth: depth at which the node should be stored. + """ + + # If we are trying to insert the node into a not existing layer, we pad the + # topology by adding empty dictionaries, until the required depth is reached while depth > (len(self.topology) - 1): self.topology.append({}) - # If node already exists return it, otherwise add it to topology first + # If the node already exists return it, otherwise add it to the topology first return self.topology[depth].setdefault(node.name, node) def increase_depth(self): + """Increases the depth of the graph.""" + self.current_depth += 1 def generate_path(self, select_rule): - """Generates path trough the graph, based on given rule + """Generates path through the graph based on given selection rule. Args: - select_rule ([NeigbourNode]): function which receives list of neighbours + select_rule ([NeigbourNode]): function which receives a list of + neighbours. Returns: - path which contains Node objects + a path which contains Node objects. """ + current_node = self.input_node path = [current_node.create_deepcopy()] for depth in range(self.current_depth): - # If node doesn't have any neigbours stop expanding path + # If the node doesn't have any neighbours stop expanding the path if not self.has_neighbours(current_node, depth): break - # Select node using rule + + # Select node using given rule current_node = select_rule(current_node.neighbours) - # Add only copy of the node, so that original stays unmodified + # Add only the copy of the node, so that original stays unmodified path.append(current_node.create_deepcopy()) + completed_path = self.complete_path(path) return completed_path def has_neighbours(self, node, depth): - # Expand only if it haven't been expanded + """Checks if the node has any neighbours. + + Args: + node: Node that needs to be checked. + depth: depth at which the node is stored in the graph. + + Returns: + a boolean value which indicates if the node has any neighbours. + """ + + # Expand only if it hasn't been expanded if node.is_expanded is False: available_transitions = node.available_transitions for (transition_name, heuristic_value) in available_transitions: neighbour_node = self.get_node(Node(transition_name), depth + 1) node.neighbours.append(NeighbourNode(neighbour_node, heuristic_value)) node.is_expanded = True - # Return value indicating if node has neigbours after beign expanded + + # Return value indicating if the node has neighbours after being expanded return len(node.neighbours) > 0 def complete_path(self, path): - # If path is not completed, then complete it and return completed path + """Completes the path if it is not fully completed (i.e. missing OutputNode). + + Args: + path [Node]: list of nodes defining the path. + + Returns: + completed path which contains list of nodes. + """ + + # If the path is not completed, then complete it and return completed path # We intentionally don't add these ending nodes as neighbours to the last node - # in the path, because during first few iteration these nodes will always be part + # in the path, because during the first few iterations these nodes will always be part # of the best path (as it's impossible to close path automatically when it's so short) # this would result in bias pheromone received by these nodes during later iterations if path[-1].name in cfg['spatial_nodes']: @@ -268,7 +373,9 @@ def complete_path(self, path): return path def show_pheromone(self): - # If output is disabled by the user then don't log the pheromone + """Logs the pheromone information for the graph.""" + + # If the output is disabled by the user then don't log the pheromone if cfg['aco']['pheromone']['verbose'] is False: return @@ -279,9 +386,11 @@ def show_pheromone(self): for neighbour in node.neighbours: info.append("%s [%s] -> %f -> %s [%s]" % (node.name, hex(id(node)), neighbour.pheromone, neighbour.node.name, hex(id(neighbour.node)))) + # If neighbour node doesn't have any attributes skip attribute info if not neighbour.node.attributes: continue + info.append("\t%s [%s]:" % (neighbour.node.name, hex(id(neighbour.node)))) for attribute in neighbour.node.attributes: info.append("\t\t%s: %s" % (attribute.name, attribute.dict)) diff --git a/deepswarm/backends.py b/deepswarm/backends.py index ace5c92..ba3b0a0 100644 --- a/deepswarm/backends.py +++ b/deepswarm/backends.py @@ -2,16 +2,19 @@ # Licensed under MIT License import os +import tensorflow as tf import time -from abc import ABC, abstractmethod -import tensorflow as tf -from tensorflow.keras import backend as K +from abc import ABC, abstractmethod from sklearn.model_selection import train_test_split +from tensorflow.keras import backend as K + from . import cfg class Dataset: + """Class responsible for encapsulating all the required data.""" + def __init__(self, training_examples, training_labels, testing_examples, testing_labels, validation_data=None, validation_split=0.1): self.x_train = training_examples @@ -23,6 +26,8 @@ def __init__(self, training_examples, training_labels, testing_examples, testing class BaseBackend(ABC): + """Abstract class used to define Backend API.""" + def __init__(self, dataset, optimizer=None): self.dataset = dataset self.optimizer = optimizer @@ -32,27 +37,26 @@ def generate_model(self, path): """Create and return a backend model representation. Args: - path [Node]: list of nodes where each node represents single - network layer, path starts with InputNode and ends with EndNode + path [Node]: list of nodes where each node represents a single + network layer, the path starts with InputNode and ends with EndNode. Returns: model which represents neural network structure in the implemented - backend, this model can be evaluated using evaluate_model method - + backend, this model can be evaluated using evaluate_model method. """ @abstractmethod def reuse_model(self, old_model, new_model_path, distance): - """Create new model, by reusing layers (and their weights) from old model. + """Create a new model by reusing layers (and their weights) from the old model. Args: - old_model: old model which represents neural network structure - new_model_path [Node]: path representing new model + old_model: old model which represents neural network structure. + new_model_path [Node]: path representing new model. distance (int): distance which shows how many layers from old model need to be removed in order to create a base for new model i.e. if old model is - NodeA->NodeB->NodeC->NodeD and new model is NodeA->NodeB->NodeC->NodeE, distance = 1 + NodeA->NodeB->NodeC->NodeD and new model is NodeA->NodeB->NodeC->NodeE, + distance = 1. Returns: - model which represents neural network structure - + model which represents neural network structure. """ @abstractmethod @@ -60,25 +64,23 @@ def train_model(self, model): """Train model which was created using generate_model method. Args: - model: model which represents neural network structure + model: model which represents neural network structure. Returns: - model which represents neural network structure - + model which represents neural network structure. """ @abstractmethod def fully_train_model(self, model, epochs, augment): - """Fully trains the model without early stopping. At the end of - the training, model with the best performing weights on validation set - is returned + """Fully trains the model without early stopping. At the end of the + training, the model with the best performing weights on the validation + set is returned. Args: - model: model which represents neural network structure - epoch (int): for how many epoch train the model - augment (kwargs): augmentation arguments + model: model which represents neural network structure. + epochs (int): for how many epoch train the model. + augment (kwargs): augmentation arguments. Returns: - model which represents neural network structure - + model which represents neural network structure. """ @abstractmethod @@ -86,62 +88,66 @@ def evaluate_model(self, model): """Evaluate model which was created using generate_model method. Args: - model: model which represents neural network structure + model: model which represents neural network structure. Returns: - loss & accuracy tuple - + loss & accuracy tuple. """ @abstractmethod def save_model(self, model, path): - """Saves model on disk + """Saves model on disk. Args: - model: model which represents neural network structure - path: string which represents model location + model: model which represents neural network structure. + path: string which represents model location. """ @abstractmethod def load_model(self, path): - """Load model from disk, in case of fail should return None + """Load model from disk, in case of fail should return None. Args: - path: string which represents model location + path: string which represents model location. Returns: model: model which represents neural network structure, or in case - fail None + fail None. """ @abstractmethod def free_gpu(self): - """ Frees gpu memory - """ + """Frees GPU memory.""" class TFKerasBackend(BaseBackend): + """Backend based on TensorFlow Keras API""" + def __init__(self, dataset, optimizer=None): super().__init__(dataset, optimizer) self.data_format = K.image_data_format() def generate_model(self, path): - # Create input layer + # Create an input layer input_layer = self.create_layer(path[0]) layer = input_layer + # Convert each node to layer and then connect it to the previous layer for node in path[1:]: layer = self.create_layer(node)(layer) + # Return generated model model = tf.keras.Model(inputs=input_layer, outputs=layer) self.compile_model(model) return model def reuse_model(self, old_model, new_model_path, distance): - # Find starting point of new model + # Find the starting point of the new model starting_point = len(new_model_path) - distance last_layer = old_model.layers[starting_point - 1].output - # Append layers from new model to the old model + + # Append layers from the new model to the old model for node in new_model_path[starting_point:]: last_layer = self.create_layer(node)(last_layer) + # Return new model model = tf.keras.Model(inputs=old_model.inputs, outputs=last_layer) self.compile_model(model) @@ -160,9 +166,10 @@ def compile_model(self, model): model.compile(**optimizer_parameters) def create_layer(self, node): - # Workaround to prevent Keras from throwing an exception ("All layer names should be unique.") - # It happens when new layers are appended to an existing model, but Keras fails to increment - # repeating layer names i.e. conv_1 -> conv_2 + # Workaround to prevent Keras from throwing an exception ("All layer + # names should be unique.") It happens when new layers are appended to + # an existing model, but Keras fails to increment repeating layer names + # i.e. conv_1 -> conv_2 parameters = {'name': str(time.time())} if node.type == 'Input': @@ -233,8 +240,9 @@ def map_activation(self, activation): raise Exception('Not handled activation: %s' % str(activation)) def train_model(self, model): - # Create checkpoint path + # Create a checkpoint path checkpoint_path = 'temp-model' + # Setup training parameters fit_parameters = { 'x': self.dataset.x_train, @@ -248,16 +256,19 @@ def train_model(self, model): 'validation_split': self.dataset.validation_split, 'verbose': cfg['backend']['verbose'], } + # If validation data is given then override validation_split if self.dataset.validation_data is not None: fit_parameters['validation_data'] = self.dataset.validation_data - # Train and return model + + # Train model model.fit(**fit_parameters) + # Load model from checkpoint checkpoint_model = self.load_model(checkpoint_path) # Delete checkpoint os.remove(checkpoint_path) - # Return checkpoint model if it exists + # Return checkpoint model if it exists, otherwise return trained model return checkpoint_model if checkpoint_model is not None else model def fully_train_model(self, model, epochs, augment): @@ -274,9 +285,12 @@ def fully_train_model(self, model, epochs, augment): # Create checkpoint path checkpoint_path = 'temp-model' + # Create and fit data generator datagen = tf.keras.preprocessing.image.ImageDataGenerator(**augment) datagen.fit(x_train) + + # Train model model.fit_generator( generator=datagen.flow(x_train, y_train, batch_size=cfg['backend']['batch_size']), steps_per_epoch=len(self.dataset.x_train) / cfg['backend']['batch_size'], @@ -290,7 +304,7 @@ def fully_train_model(self, model, epochs, augment): checkpoint_model = self.load_model(checkpoint_path) # Delete checkpoint os.remove(checkpoint_path) - # Return checkpoint model if it exists + # Return checkpoint model if it exists, otherwise return trained model return checkpoint_model if checkpoint_model is not None else model def create_early_stop_callback(self): @@ -299,7 +313,6 @@ def create_early_stop_callback(self): 'verbose': cfg['backend']['verbose'], 'restore_best_weights': True, } - # Set user defined metrics early_stop_parameters['monitor'] = 'val_loss' if cfg['metrics'] == 'loss' else 'val_acc' return tf.keras.callbacks.EarlyStopping(**early_stop_parameters) @@ -309,7 +322,6 @@ def create_checkpoint_callback(self, checkpoint_path): 'verbose': cfg['backend']['verbose'], 'save_best_only': True, } - # Set user defined metrics checkpoint_parameters['monitor'] = 'val_loss' if cfg['metrics'] == 'loss' else 'val_acc' return tf.keras.callbacks.ModelCheckpoint(**checkpoint_parameters) diff --git a/deepswarm/deepswarm.py b/deepswarm/deepswarm.py index 5b1056a..3bcdf6c 100644 --- a/deepswarm/deepswarm.py +++ b/deepswarm/deepswarm.py @@ -1,36 +1,39 @@ # Copyright (c) 2019 Edvinas Byla # Licensed under MIT License +from . import settings, left_cost_is_better from .aco import ACO from .log import Log from .storage import Storage -from . import settings, left_cost_is_better - class DeepSwarm: + """Class responsible for providing user facing interface.""" + def __init__(self, backend): self.backend = backend self.storage = Storage(self) + # Enable logging and log current settings self.setup_logging() + # Try to load from the backup if self.storage.loaded_from_save: self.__dict__ = self.storage.backup.__dict__ def setup_logging(self): + """Enables logging and logs current settings.""" + Log.enable(self.storage) Log.header("DeepSwarm settings") Log.info(settings) def find_topology(self): - """Finds the neural network topology which has the lowest loss + """Finds the best neural network topology. - Args: - max_depth (int): maximum number of hidden layers - swarm_size(int): number of ants, which are searching for the topology Returns: - network model in the format of backend which was used during initialization + network model in the format of backend which was used during + initialization. """ # Create a new object only if there are no backups @@ -42,17 +45,18 @@ def find_topology(self): return best_model def train_topology(self, model, epochs, augment): - """Trains given neural network topology for specified number of epochs + """Trains given neural network topology for a specified number of epochs. Args: - model: model which represents neural network structure - epoch (int): for how many epoch train the model - augment (kwargs): augmentation arguments + model: model which represents neural network structure. + epochs (int): for how many epoch train the model. + augment (kwargs): augmentation arguments. Returns: - network model in the format of backend which was used during initialization + network model in the format of backend which was used during + initialization. """ - # Before training, make a copy of old weights in case performance + # Before training make a copy of old weights in case performance # degrades during the training loss, accuracy = self.backend.evaluate_model(model) old_weights = model.get_weights() @@ -79,6 +83,8 @@ def train_topology(self, model, epochs, augment): return self.storage.load_specified_model(self.backend, model_name) def evaluate_topology(self, model): + """Evaluates neural network performance.""" + Log.header('EVALUATING PERFORMANCE ON TEST SET') loss, accuracy = self.backend.evaluate_model(model) Log.info('Accuracy is %f and loss is %f' % (accuracy, loss)) diff --git a/deepswarm/log.py b/deepswarm/log.py index ec10347..0b6bffc 100644 --- a/deepswarm/log.py +++ b/deepswarm/log.py @@ -4,11 +4,14 @@ import json import logging import re + from colorama import init as colorama_init from colorama import Fore, Back, Style class Log: + """Class responsible for logging information.""" + # Define header styles HEADER_W = [Fore.BLACK, Back.WHITE, Style.BRIGHT] HEADER_R = [Fore.WHITE, Back.RED, Style.BRIGHT] @@ -16,6 +19,12 @@ class Log: @classmethod def enable(cls, storage): + """Initializes the logger. + + Args: + storage: Storage object. + """ + # Init colorama to enable colors colorama_init() # Get deepswarm logger @@ -76,16 +85,20 @@ def critical(cls, message, options=[Fore.RED, Style.BRIGHT]): @classmethod def create_message(cls, message, options): - # Convert dictionary to nicely formated JSON + # Convert dictionary to nicely formatted JSON if isinstance(message, dict): message = json.dumps(message, indent=4, sort_keys=True) - # Convert all objects that are not strings to string + + # Convert all objects that are not strings to strings if isinstance(message, str) is False: message = str(message) + return ''.join(options) + message + '\033[0m' class FileFormatter(logging.Formatter): + """Class responsible for removing ANSI characters from the log file.""" + def plain(self, string): # Regex code adapted from Martijn Pieters https://stackoverflow.com/a/14693789 ansi_escape = re.compile(r'\x1B\[[0-?]*[ -/]*[@-~]|[-]{2,}') diff --git a/deepswarm/nodes.py b/deepswarm/nodes.py index 0589390..16de25e 100644 --- a/deepswarm/nodes.py +++ b/deepswarm/nodes.py @@ -3,16 +3,21 @@ import copy import random + from . import cfg, nodes class NodeAttribute: + """Class responsible for encapsulating Node's attribute.""" + def __init__(self, name, options): self.name = name self.dict = {option: cfg['aco']['pheromone']['start'] for option in options} class NeighbourNode: + """Class responsible for encapsulating Node's neighbour.""" + def __init__(self, node, heuristic, pheromone=cfg['aco']['pheromone']['start']): self.node = node self.heuristic = heuristic @@ -20,6 +25,8 @@ def __init__(self, node, heuristic, pheromone=cfg['aco']['pheromone']['start']): class Node: + """Class responsible for representing Node.""" + def __init__(self, name): self.name = name self.neighbours = [] @@ -31,42 +38,62 @@ def __init__(self, name): @classmethod def create_using_type(cls, type): - """Create node instance using given type. + """Create Node's instance using given type. Args: - type (str): type defined in .yaml file + type (str): type defined in .yaml file. Returns: - Node instance - + Node's instance. """ + for node in nodes: if nodes[node]['type'] == type: return cls(node) raise Exception('Type does not exist: %s' % str(type)) def setup_attributes(self): + """Adds attributes from the settings file.""" + self.attributes = [] for attribute_name in nodes[self.name]['attributes']: attribute_value = nodes[self.name]['attributes'][attribute_name] self.attributes.append(NodeAttribute(attribute_name, attribute_value)) def setup_transitions(self): + """Adds transitions from the settings file.""" + self.available_transitions = [] for transition_name in nodes[self.name]['transitions']: heuristic_value = nodes[self.name]['transitions'][transition_name] self.available_transitions.append((transition_name, heuristic_value)) def select_attributes(self, custom_select): + """Selects attributes using a given select rule. + + Args: + custom_select: select function which takes dictionary containing + (attribute, value) pairs and returns selected value. + """ + selected_attributes = {} for attribute in self.attributes: value = custom_select(attribute.dict) selected_attributes[attribute.name] = value + # For each selected attribute create class attribute for key, value in selected_attributes.items(): setattr(self, key, value) def select_custom_attributes(self, custom_select): - # Define function which transforms attributes, before selecting them + """Wraps select_attributes method by converting the attribute dictionary + to list of tuples (attribute_value, pheromone, heuristic). + + Args: + custom_select: selection function which takes a list of tuples + containing (attribute_value, pheromone, heuristic). + """ + + # Define a function which transforms attributes before selecting them def select_transformed_custom_attributes(attribute_dictionary): # Convert to list of tuples containing (attribute_value, pheromone, heuristic) values = [(value, pheromone, 1.0) for value, pheromone in attribute_dictionary.items()] @@ -75,22 +102,26 @@ def select_transformed_custom_attributes(attribute_dictionary): self.select_attributes(select_transformed_custom_attributes) def select_random_attributes(self): + """Selects random attributes.""" + self.select_attributes(lambda dict: random.choice(list(dict.keys()))) - def __str__(self): - attributes = ', '.join([a.name + ":" + str(getattr(self, a.name)) for a in self.attributes]) - return self.name + "(" + attributes + ")" + def create_deepcopy(self): + """Returns a newly created copy of Node object.""" + + return copy.deepcopy(self) def __deepcopy__(self, memo): cls = self.__class__ result = cls.__new__(cls) memo[id(self)] = result for k, v in self.__dict__.items(): - # Skip unnecessary stuff to make copying more efficient + # Skip unnecessary stuff in order to make copying more efficient if k in ["neighbours", "available_transitions"]: v = [] setattr(result, k, copy.deepcopy(v, memo)) return result - def create_deepcopy(self): - return copy.deepcopy(self) + def __str__(self): + attributes = ', '.join([a.name + ":" + str(getattr(self, a.name)) for a in self.attributes]) + return self.name + "(" + attributes + ")" diff --git a/deepswarm/storage.py b/deepswarm/storage.py index 0fc2b4b..b93a856 100644 --- a/deepswarm/storage.py +++ b/deepswarm/storage.py @@ -3,11 +3,15 @@ import hashlib import pickle + from datetime import datetime + from . import base_path, cfg, left_cost_is_better class Storage: + """Class responsible for backups and weight reuse.""" + DIR = { "MODEL": "models", "OBJECT": "objects", @@ -25,11 +29,14 @@ def __init__(self, deepswarm): self.setup_directories() def setup_path(self): + """Loads existing backup or creates a new backup directory.""" + + # If storage directory doesn't exist create one storage_path = base_path / 'saves' - # If storage folder doesn't exist create one if not storage_path.exists(): storage_path.mkdir() - # Check if user specified save folder, which should be used to load data + + # Check if user specified save folder which should be used to load the data user_folder = cfg['save_folder'] if user_folder is not None and (storage_path / user_folder).exists(): self.current_path = storage_path / user_folder @@ -38,7 +45,8 @@ def setup_path(self): self.backup = self.load_object(Storage.ITEM["BACKUP"]) self.backup.storage.loaded_from_save = True return - # Otherwise create new directory + + # Otherwise create a new directory directory_path = storage_path / datetime.now().strftime('%Y-%m-%d-%H-%M-%S') if not directory_path.exists(): directory_path.mkdir() @@ -46,77 +54,145 @@ def setup_path(self): return def setup_directories(self): + """Creates all the required directories.""" + for directory in Storage.DIR.values(): directory_path = self.current_path / directory if not directory_path.exists(): directory_path.mkdir() def perform_backup(self): + """Saves DeepSwarm object to the backup directory.""" + self.save_object(self.deepswarm, Storage.ITEM["BACKUP"]) def save_model(self, backend, model, path_hashes, cost): + """Saves the model and adds its information to the dictionaries. + + Args: + backend: Backend object. + model: model which represents neural network structure. + path_hashes [string]: list of hashes, where each hash represents a + sub-path. + cost: cost associated with the model. + """ + sub_path_associated = False - # Last element describes whole path + # The last element describes the whole path model_hash = path_hashes[-1] - # For each sub-path find it's correpsonding entry in hash table + + # For each sub-path find it's corresponding entry in hash table for path_hash in path_hashes: # Check if there already exists model for this sub-path existing_model_hash = self.path_lookup.get(path_hash) model_info = self.models.get(existing_model_hash) - # If old model is better then skip this sub-path + + # If the old model is better then skip this sub-path if model_info is not None and left_cost_is_better(model_info[0], cost): continue - # Otherwise associated this sub-path with new model + + # Otherwise associated this sub-path with a new model self.path_lookup[path_hash] = model_hash sub_path_associated = True # Save model on disk only if it was associated with some sub-path if sub_path_associated: - # Add entry to models dictionary + # Add an entry to models dictionary self.models[model_hash] = (cost, 0) # Save to disk self.save_specified_model(backend, model_hash, model) def load_model(self, backend, path_hashes, path): - # Go trough all hashes backwards + """Loads model with the best weights. + + Args: + backend: Backend object. + path_hashes [string]: list of hashes, where each hash represents a + sub-path. + path [Node]: a path which represents the model. + Returns: + if the model exists returns a tuple containing model and its hash, + otherwise returns a tuple containing None values. + """ + + # Go through all hashes backwards for idx, path_hash in enumerate(path_hashes[::-1]): # See if particular hash is associated with some model model_hash = self.path_lookup.get(path_hash) model_info = self.models.get(model_hash) - # Don't reuse model if it haven't improved for longer than allowed in patience + + # Don't reuse model if it hasn't improved for longer than allowed in patience if model_hash is not None and model_info[1] < cfg['reuse_patience']: model = self.load_specified_model(backend, model_hash) # If failed to load model, skip to next hash if model is None: continue - # If there is no difference between models, just return old model, - # otherwise create a new model by reusing old model. Even though, + + # If there is no difference between models, just return the old model, + # otherwise create a new model by reusing the old model. Even though, # backend.reuse_model function could be called to handle both # cases, this approach saves some unnecessary computation new_model = model if idx == 0 else backend.reuse_model(model, path, idx) - # We also return base model (model which was used as a base to - # create new model) hash. This hash information is used later to - # track if base model is improving over time or is it stuck + + # We also return base model (a model which was used as a base to + # create a new model) hash. This hash information is used later to + # track if the base model is improving over time or is it stuck return (new_model, model_hash) return (None, None) def load_specified_model(self, backend, model_name): + """Loads specified model using its name. + + Args: + backend: Backend object. + model_name: name of the model. + Returns: + model which represents neural network structure. + """ + file_path = self.current_path / Storage.DIR["MODEL"] / model_name model = backend.load_model(file_path) return model def save_specified_model(self, backend, model_name, model): + """Saves specified model using its name without and adding its information + to the dictionaries. + + Args: + backend: Backend object. + model_name: name of the model. + model: model which represents neural network structure. + """ + save_path = self.current_path / Storage.DIR["MODEL"] / model_name backend.save_model(model, save_path) def record_model_performance(self, path_hash, cost): + """Records how many times the model cost didn't improve. + + Args: + path_hash: hash value associated with the model. + cost: cost value associated with the model. + """ + model_hash = self.path_lookup.get(path_hash) old_cost, no_improvements = self.models.get(model_hash) - # If cost haven't changed at all, increment no improvement count + + # If cost hasn't changed at all, increment no improvement count if old_cost is not None and old_cost == cost: self.models[model_hash] = (old_cost, (no_improvements + 1)) def hash_path(self, path): + """Takes a path and returns a tuple containing path description and + list of sub-path hashes. + + Args: + path [Node]: path which represents the model. + Returns: + tuple where the first element is a string representing the path + description and the second element is a list of sub-path hashes. + """ + hashes = [] path_description = str(path[0]) for node in path[1:]: @@ -126,10 +202,25 @@ def hash_path(self, path): return (path_description, hashes) def save_object(self, data, name): + """Saves given object to the object backup directory. + + Args: + data: object that needs to be saved. + name: string value representing the name of the object. + """ + with open(self.current_path / Storage.DIR["OBJECT"] / name, 'wb') as f: pickle.dump(data, f, pickle.HIGHEST_PROTOCOL) def load_object(self, name): + """Load given object from the object backup directory. + + Args: + name: string value representing the name of the object. + Returns: + object which has the same name as the given argument. + """ + with open(self.current_path / Storage.DIR["OBJECT"] / name, 'rb') as f: data = pickle.load(f) return data diff --git a/examples/context.py b/examples/context.py index d2dd2d4..2401cef 100644 --- a/examples/context.py +++ b/examples/context.py @@ -3,5 +3,5 @@ import os import sys -# sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) + sys.path.append(os.path.dirname(os.path.dirname(os.path.realpath(__file__)))) diff --git a/tests/test_aco.py b/tests/test_aco.py index 2627600..e0225e0 100644 --- a/tests/test_aco.py +++ b/tests/test_aco.py @@ -1,7 +1,8 @@ import math import unittest -from deepswarm.aco import ACO, Ant + from deepswarm import cfg +from deepswarm.aco import ACO, Ant class TestACO(unittest.TestCase): @@ -10,7 +11,7 @@ def setUp(self): self.aco = ACO(None, None) def test_ant_init(self): - # Test if ant is initialized properly + # Test if the ant is initialized properly ant = Ant() self.assertEqual(ant.loss, math.inf) self.assertEqual(ant.accuracy, 0.0) @@ -21,7 +22,7 @@ def test_ant_init(self): self.assertEqual(ant.cost, ant.accuracy) def test_ant_init_with_path(self): - # Test if ant is initialized properly when path is given + # Test if the ant is initialized properly when a path is given self.aco.graph.increase_depth() path = self.aco.graph.generate_path(self.aco.aco_select) ant = Ant(path) diff --git a/tests/test_graph.py b/tests/test_graph.py index 20aca7f..fa232be 100644 --- a/tests/test_graph.py +++ b/tests/test_graph.py @@ -1,4 +1,5 @@ import unittest + from deepswarm.aco import Graph from deepswarm.nodes import Node @@ -9,43 +10,44 @@ def setUp(self): self.graph = Graph() def test_graph_init(self): - # Test if newly created graph contains the input node + # Test if the newly created graph contains the input node self.assertEqual(len(self.graph.topology), 1) self.assertEqual(self.graph.current_depth, 1) input_node = self.graph.input_node self.assertIs(self.graph.topology[0][input_node.name], input_node) def test_depth_increase(self): - # Test if depth is increased correctly + # Test if the depth is increased correctly self.assertEqual(self.graph.current_depth, 1) self.graph.increase_depth() self.assertEqual(self.graph.current_depth, 2) def test_path_generation(self): - # Create rule which selects first available node + # Create a rule which selects first available node def select_rule(neighbours): return neighbours[0].node + # Generate the path path = self.graph.generate_path(select_rule) - # Test if path is not empty + # Test if the path is not empty self.assertNotEqual(path, []) - # Test if path start with input node + # Test if the path starts with an input node self.assertEqual(path[0].type, 'Input') - # Test if path ends with end node + # Test if path ends with output node self.assertEqual(path[-1].type, 'Output') def test_path_completion(self): - # Create path containing only the input node + # Create a path containing only the input node old_path = [self.graph.input_node] # Complete that path new_path = self.graph.complete_path(old_path) - # Test if path start with input node + # Test if path starts with an input node self.assertEqual(new_path[0].type, 'Input') - # Test if path ends with end node + # Test if path ends with output node self.assertEqual(new_path[-1].type, 'Output') def test_node_retrieval(self): - # Test if newly created graph contains the input node + # Test if the newly created graph contains the input node self.assertEqual(len(self.graph.topology), 1) # Retrieve first available transition from the input node available_transition = self.graph.input_node.available_transitions[0] @@ -53,21 +55,21 @@ def test_node_retrieval(self): available_transition_name = available_transition[0] available_transition_node = Node(available_transition_name) self.graph.get_node(available_transition_node, 1) - # Test if graph depth increased after adding new node + # Test if graph's depth increased after adding a new node self.assertEqual(len(self.graph.topology), 2) - # Test if node was added correctly + # Test if the node was added correctly self.assertIs(self.graph.topology[1][available_transition_name], available_transition_node) def test_node_expansion(self): - # Test if input node was not expanded yet + # Test if the input node was not expanded yet input_node = self.graph.input_node self.assertFalse(input_node.is_expanded) self.assertEqual(input_node.neighbours, []) # Try to expand it has_neighbours = self.graph.has_neighbours(input_node, 0) - # Test if input node was expanded successfully + # Test if the input node was expanded successfully self.assertTrue(input_node.is_expanded) - # Test if input node has neighbours + # Test if the input node has neighbours self.assertTrue(has_neighbours) self.assertNotEqual(input_node.neighbours, []) # Test if neighbour node was added to the topology diff --git a/tests/test_nodes.py b/tests/test_nodes.py index a54252f..99eda4b 100644 --- a/tests/test_nodes.py +++ b/tests/test_nodes.py @@ -1,4 +1,5 @@ import unittest + from deepswarm.nodes import Node, NodeAttribute, NeighbourNode @@ -14,7 +15,7 @@ def test_create_using_type(self): self.assertFalse(self.input_node.is_expanded) self.assertNotEqual(self.input_node.attributes, []) self.assertNotEqual(self.input_node.available_transitions, []) - # Test if generated desctiption is correct + # Test if generated description is correct description = self.input_node.name + '(' + 'shape:' + str(self.input_node.shape) + ')' self.assertEqual(description, str(self.input_node)) @@ -24,7 +25,7 @@ def test_init(self): self.assertEqual(input_node_new.type, self.input_node.type) def test_deepcopy(self): - # Test if copied object is instance of Node + # Test if the copied object is an instance of Node input_node_copy = self.input_node.create_deepcopy() self.assertIsInstance(input_node_copy, Node) # Test if unnecessary attributes were removed @@ -40,10 +41,10 @@ def test_available_transition(self): available_transition_name = available_transition[0] available_transition_node = Node(available_transition_name) self.assertIsInstance(available_transition_node, Node) - # Check if node was properly initialized + # Check if the node was properly initialized self.assertNotEqual(available_transition_node.attributes, []) self.assertNotEqual(available_transition_node.available_transitions, []) - # Check if available transition contains heuristic value + # Check if available transition contains a heuristic value self.assertIsInstance(available_transition[1], float) def test_custom_attribute_selection(self): @@ -88,5 +89,5 @@ def test_node_attributes_init(self): pheromone_values = list(set(attribute.dict.values())) # Because NodeAttribute object was just initialized and no changes to # pheromone values were performed, all pheromone values must be the same - # meaning that pheromone_values must containt only 1 element + # meaning that pheromone_values must contain only 1 element self.assertEqual(len(pheromone_values), 1)