From 76995e1b30a1cdf3fb1568f8aa468e68a15aad69 Mon Sep 17 00:00:00 2001 From: sshane Date: Wed, 14 Jul 2021 02:01:01 -0700 Subject: [PATCH] Better support for converted Keras models (#12) * support activation layers (with dense only) and ignore inputlayer * clean up * test new support * bump version --- konverter/__init__.py | 21 +++++--- konverter/__main__.py | 2 +- konverter/utils/konverter_support.py | 75 ++++++++++++++++------------ konverter/utils/model_attributes.py | 14 ++++++ pyproject.toml | 2 +- tests/build_test_models.py | 9 ++-- 6 files changed, 78 insertions(+), 45 deletions(-) diff --git a/konverter/__init__.py b/konverter/__init__.py index 16bd79a..789d429 100644 --- a/konverter/__init__.py +++ b/konverter/__init__.py @@ -1,4 +1,4 @@ -from konverter.utils.model_attributes import Activations, Layers, watermark +from konverter.utils.model_attributes import Activations, Layers, LAYERS_IGNORED, watermark from konverter.utils.konverter_support import KonverterSupport from konverter.utils.general import success, error, info, warning, COLORS import numpy as np @@ -45,7 +45,7 @@ def start(self): self.get_layers() if self.verbose: self.print_model_architecture() - self.remove_unused_layers() + self.remove_ignored_layers() self.parse_output_file() self.build_konverted_model() @@ -72,7 +72,7 @@ def build_konverted_model(self): if layer.name == Layers.Dense.name: model_line = f'l{idx} = {layer.string.format(prev_output, idx, idx)}' model_builder['model'].append(model_line) - if layer.info.has_activation: + if layer.info.has_activation and layer.info.activation.name != Activations.Linear.name: if layer.info.activation.needs_function: lyr_w_act = f'l{idx} = {layer.info.activation.alias.lower()}(l{idx})' else: # eg. tanh or relu @@ -165,8 +165,8 @@ def save_model(self, model_builder): with open(f'{self.output_file}.py', 'w') as f: f.write(output.replace('\t', self.indent)) - def remove_unused_layers(self): - self.layers = [layer for layer in self.layers if layer.name not in support.unused_layers] + def remove_ignored_layers(self): + self.layers = [layer for layer in self.layers if layer.name not in LAYERS_IGNORED] def parse_output_file(self): if self.output_file is None: # user hasn't supplied output file path, use input file name in same dir @@ -186,7 +186,8 @@ def parse_output_file(self): def print_model_architecture(self): success('\nSuccessfully got model architecture! 😄\n') info('Layers:') - to_print = [[COLORS.BASE.format(74) + f'name: {layer.alias}' + COLORS.ENDC] for layer in self.layers] + ignored_txt = {True: ' (ignored)', False: ''} + to_print = [[COLORS.BASE.format(74) + f'name: {layer.alias}{ignored_txt[layer.info.is_ignored]}' + COLORS.ENDC] for layer in self.layers] max_len = 0 indentation = ' ' for idx, layer in enumerate(self.layers): @@ -205,8 +206,12 @@ def print_model_architecture(self): print(COLORS.ENDC, end='') def get_layers(self): - for layer in self.model.layers: - layer = support.get_layer_info(layer) + for idx, layer in enumerate(self.model.layers): + next_layer = None + if idx < len(self.model.layers) - 1: + next_layer = self.model.layers[idx + 1] + + layer = support.get_layer_info(layer, next_layer) if layer.info.supported: self.layers.append(layer) else: diff --git a/konverter/__main__.py b/konverter/__main__.py index 7e3057f..c8e31bd 100644 --- a/konverter/__main__.py +++ b/konverter/__main__.py @@ -3,7 +3,7 @@ import konverter from konverter.utils.general import success, info, warning, error, COLORS, color_logo, blue_grad -KONVERTER_VERSION = "v0.2.4.1" # fixme: unify this +KONVERTER_VERSION = "v0.2.5" # fixme: unify this KONVERTER_LOGO_COLORED = color_logo(KONVERTER_VERSION) diff --git a/konverter/utils/konverter_support.py b/konverter/utils/konverter_support.py index e972352..d5fa8b1 100644 --- a/konverter/utils/konverter_support.py +++ b/konverter/utils/konverter_support.py @@ -1,4 +1,5 @@ -from konverter.utils.model_attributes import BaseLayerInfo, BaseModelInfo, Models, Activations, Layers +from konverter.utils.model_attributes import BaseLayerInfo, BaseModelInfo, Models, Activations, Layers, \ + LAYERS_NO_ACTIVATION, LAYERS_IGNORED, LAYERS_RECURRENT import numpy as np @@ -8,11 +9,6 @@ def __init__(self): self.layers = [getattr(Layers, i) for i in dir(Layers) if '_' not in i] self.activations = [getattr(Activations, i) for i in dir(Activations) if '_' not in i] - self.attrs_without_activations = [Layers.Dropout.name, Activations.Linear.name, Layers.BatchNormalization.name] - self.unused_layers = [Layers.Dropout.name] - self.recurrent_layers = [Layers.SimpleRNN.name, Layers.GRU.name] - self.ignored_layers = [Layers.Dropout.name] - def get_class_from_name(self, name, search_in): """ :param name: A name of an attribute, ex. keras.layers.Dense, keras.activations.relu @@ -73,45 +69,59 @@ def get_model_info(self, model): return model_class - def get_layer_info(self, layer): + @staticmethod + def _get_layer_name(layer): name = getattr(layer, '_keras_api_names_v1') if not len(name): name = getattr(layer, '_keras_api_names') + return name + + def _get_layer_activation(self, layer): + if hasattr(layer.activation, '_keras_api_names'): + activation = getattr(layer.activation, '_keras_api_names') + else: # fixme: TF 2.3 is missing _keras_api_names + activation = 'keras.activations.' + getattr(layer.activation, '__name__') + activation = (activation,) + + if len(activation) == 1: + return self.get_class_from_name(activation[0], 'activations') + else: + raise Exception('None or multiple activations?') + + def get_layer_info(self, layer, next_layer): + # Identify layer + name = self._get_layer_name(layer) layer_class = self.get_class_from_name(name[0], 'layers') # assume only one name layer_class.info = BaseLayerInfo() if not layer_class: layer_class = Layers.Unsupported() # add activation below to raise exception with layer_class.name = name - layer_class.info.is_ignored = layer_class.name in self.ignored_layers - - is_linear = False - if layer_class.name not in self.attrs_without_activations: - if hasattr(layer.activation, '_keras_api_names'): - activation = getattr(layer.activation, '_keras_api_names') - else: # fixme: TF 2.3 is missing _keras_api_names - activation = 'keras.activations.' + getattr(layer.activation, '__name__') - activation = (activation,) # fixme: expects this as a tuple - - if len(activation) == 1: - layer_class.info.activation = self.get_class_from_name(activation[0], 'activations') - if layer_class.info.activation.name not in self.attrs_without_activations: - layer_class.info.has_activation = True - else: - is_linear = True - else: - raise Exception('None or multiple activations?') - - if layer_class.info.has_activation: - if layer_class.info.activation.name == 'keras.layers.LeakyReLU': # set alpha + layer_class.info.is_ignored = layer_class.name in LAYERS_IGNORED + + # Handle layer activation + if layer_class.name not in LAYERS_NO_ACTIVATION: + layer_class.info.activation = self._get_layer_activation(layer) + layer_class.info.has_activation = True + + # Note: special case for when activation is a separate layer after dense + if layer_class.name == Layers.Dense.name and layer_class.info.activation.name == Activations.Linear.name: + if next_layer is not None and self._get_layer_name(next_layer)[0] == Layers.Activation.name: + layer_class.info.activation = self._get_layer_activation(next_layer) + + # Check if layer is supported given ignored status and activation + if layer_class.info.has_activation and not layer_class.info.is_ignored: + if layer_class.info.activation.name == Activations.LeakyReLU.name: # set alpha layer_class.info.activation.alpha = round(float(layer.activation.alpha), 5) # check layer activation against this layer's supported activations if layer_class.info.activation.name in self.attr_map(layer_class.supported_activations, 'name'): layer_class.info.supported = True - elif layer_class.info.is_ignored or is_linear: # skip activation check if layer has no activation (eg. dropout or linear) + + elif layer_class.info.is_ignored: layer_class.info.supported = True - elif layer_class.name in self.attrs_without_activations: + + elif layer_class.name in LAYERS_NO_ACTIVATION: # skip activation check if layer has no activation (eg. dropout) layer_class.info.supported = True # if not layer_class.info.supported or (not is_linear and not layer_class.info.has_activation): @@ -119,6 +129,7 @@ def get_layer_info(self, layer): if not layer_class.info.supported: return layer_class + # Parse weights and biases from layer if available try: wb = layer.get_weights() if len(wb) == 0: @@ -126,10 +137,10 @@ def get_layer_info(self, layer): except: return layer_class - if len(wb) == 2: + if len(wb) == 2: # Dense layer_class.info.weights = np.array(wb[0]) layer_class.info.biases = np.array(wb[1]) - elif len(wb) == 3 and layer_class.name in self.recurrent_layers: + elif len(wb) == 3 and layer_class.name in LAYERS_RECURRENT: layer_class.info.weights = np.array(wb[:2]) # input and recurrent weights layer_class.info.biases = np.array(wb[-1]) layer_class.info.returns_sequences = layer.return_sequences diff --git a/konverter/utils/model_attributes.py b/konverter/utils/model_attributes.py index 9d3d7c8..9d8858e 100644 --- a/konverter/utils/model_attributes.py +++ b/konverter/utils/model_attributes.py @@ -90,6 +90,14 @@ class Dropout(_BaseLayer): name = 'keras.layers.Dropout' alias = 'dropout' + class InputLayer(_BaseLayer): + name = 'keras.layers.InputLayer' + alias = 'InputLayer' + + class Activation(_BaseLayer): + name = 'keras.layers.Activation' + alias = 'Activation' + class BatchNormalization(_BaseLayer): name = 'keras.layers.BatchNormalization' alias = 'batch_norm' @@ -125,6 +133,12 @@ class Unsupported(_BaseLayer): # propogated with layer info and returned to Kon pass +# LAYERS_NO_ACTIVATION = [Layers.Dropout.name, Layers.InputLayer.name, Activations.Linear.name, Layers.BatchNormalization.name] +LAYERS_NO_ACTIVATION = [Layers.Dropout.name, Layers.InputLayer.name, Layers.BatchNormalization.name] +LAYERS_IGNORED = [Layers.Dropout.name, Layers.InputLayer.name, Layers.Activation.name] +LAYERS_RECURRENT = [Layers.SimpleRNN.name, Layers.GRU.name] + + class BaseModelInfo: supported = False input_shape = None # this will need to be moved if we support functional models diff --git a/pyproject.toml b/pyproject.toml index 9e571e2..c2b40f4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "keras-konverter" -version = "0.2.4.1" +version = "0.2.5" description = "A tool to convert simple Keras models to pure Python + NumPy" readme = "README.md" repository = "https://github.com/ShaneSmiskol/Konverter" diff --git a/tests/build_test_models.py b/tests/build_test_models.py index 7285c0b..eab0bf4 100644 --- a/tests/build_test_models.py +++ b/tests/build_test_models.py @@ -1,5 +1,5 @@ import numpy as np -from tensorflow.keras.layers import Dense, SimpleRNN, GRU, BatchNormalization +from tensorflow.keras.layers import Dense, SimpleRNN, GRU, BatchNormalization, InputLayer, Activation from tensorflow.keras.models import Sequential from tensorflow.keras.backend import clear_session @@ -14,9 +14,12 @@ def create_model(model_type): y_train = (np.mean(x_train, axis=1) ** 2) / 2 # half of squared mean of sample model = Sequential() - model.add(Dense(128, activation='relu', input_shape=x_train.shape[1:])) + model.add(InputLayer(input_shape=x_train.shape[1:])) + model.add(Dense(128)) + model.add(Activation(activation='relu')) model.add(BatchNormalization()) - model.add(Dense(64, activation='tanh')) + model.add(Dense(64)) + model.add(Activation(activation='tanh')) model.add(BatchNormalization()) model.add(Dense(32, activation='relu')) model.add(BatchNormalization())