Data Preprocessing and Network Building in CNN | by Tanya Dayanand | Towards Data Science

In this article, we will go through the end-to-end pipeline of training convolution neural networks, i.e. organizing the data into directories, preprocessing, data augmentation, model building, etc.
We will spend a good amount of time on data preprocessing techniques commonly used with image processing. This is because preprocessing takes about 50–80% of your time in most deep learning projects, and knowing some useful tricks will help you a lot in your projects. We will be using the flowers dataset from Kaggle to demonstrate the key concepts. To get into the codes directly, an accompanying notebook is published on Kaggle(Please use a CPU for running the initial parts of the code and GPU for model training).
www.kaggle.com
Let’s begin with importing the necessary libraries and loading the dataset. This is a requisite step in every data analysis process.
# Importing necessary librariesimport kerasimport tensorflowfrom skimage import ioimport osimport globimport numpy as npimport randomimport matplotlib.pyplot as plt%matplotlib inline# Importing and Loading the data into data frame#class 1 - Rose, class 0- DaisyDATASET_PATH = '../input/flowers-recognition/flowers/'flowers_cls = ['daisy', 'rose']# glob through the directory (returns a list of all file paths)flower_path = os.path.join(DATASET_PATH, flowers_cls[1], '*')flower_path = glob.glob(flower_path)# access some element (a file) from the listimage = io.imread(flower_path[251])
Images come in different shapes and sizes. They also come through different sources. For example, some images are what we call “natural images”, which means they are taken in color, in the real world. For example:
A picture of a flower is a natural image.
An X-ray image is not a natural image.
Taking all these variations into consideration, we need to perform some pre-processing on any image data. RGB is the most popular encoding format, and most “natural images” we encounter are in RGB. Also, among the first step of data pre-processing is to make the images of the same size. Let’s move on to how we can change the shape and form of images.
# plotting the original image and the RGB channelsf, (ax1, ax2, ax3, ax4) = plt.subplots(1, 4, sharey=True)f.set_figwidth(15)ax1.imshow(image)# RGB channels# CHANNELID : 0 for Red, 1 for Green, 2 for Blue. ax2.imshow(image[:, : , 0]) #Redax3.imshow(image[:, : , 1]) #Greenax4.imshow(image[:, : , 2]) #Bluef.suptitle('Different Channels of Image')
The term morphological transformation refers to any modification involving the shape and form of the images. These are very often used in image analysis tasks. Although they are used with all types of images, they are especially powerful for images that are not natural (come from a source other than a picture of the real world). The typical transformations are erosion, dilation, opening, and closing. Let’s now look at some code to implement these morphological transformations.
1.Thresholding
One of the simpler operations where we take all the pixels whose intensities are above a certain threshold and convert them to ones; the pixels having value less than the threshold are converted to zero. This results in a binary image.
# bin_image will be a (240, 320) True/False array#The range of pixel varies between 0 to 255#The pixel having black is more close to 0 and pixel which is white is more close to 255# 125 is Arbitrary heuristic measure halfway between 1 and 255 (the range of image pixel) bin_image = image[:, :, 0] > 125plot_image([image, bin_image], cmap='gray')
2.Erosion, Dilation, Opening & Closing
Erosion shrinks bright regions and enlarges dark regions. Dilation on the other hand is exact opposite side — it shrinks dark regions and enlarges the bright regions.
Opening is erosion followed by dilation. Opening can remove small bright spots (i.e. “salt”) and connect small dark cracks. This tends to “open” up (dark) gaps between (bright) features.
Closing is dilation followed by erosion. Closing can remove small dark spots (i.e. “pepper”) and connect small bright cracks. This tends to “close” up (dark) gaps between (bright) features.
All these can be done using the skimage.morphology module. The basic idea is to have a circular disk of a certain size (3 below) move around the image and apply these transformations using it.
from skimage.morphology import binary_closing, binary_dilation, binary_erosion, binary_openingfrom skimage.morphology import selem# use a disk of radius 3selem = selem.disk(3)# oprning and closingopen_img = binary_opening(bin_image, selem)close_img = binary_closing(bin_image, selem)# erosion and dilationeroded_img = binary_erosion(bin_image, selem)dilated_img = binary_dilation(bin_image, selem)plot_image([bin_image, open_img, close_img, eroded_img, dilated_img], cmap='gray')
Normalisation is the most crucial step in the pre-processing part. This refers to rescaling the pixel values so that they lie within a confined range. One of the reasons to do this is to help with the issue of propagating gradients. There are multiple ways to normalize images that we will be talking about.
#way1-this is common technique followed in case of RGB images norm1_image = image/255#way2-in case of medical Images/non natural images norm2_image = image - np.min(image)/np.max(image) - np.min(image)#way3-in case of medical Images/non natural images norm3_image = image - np.percentile(image,5)/ np.percentile(image,95) - np.percentile(image,5)plot_image([image, norm1_image, norm2_image, norm3_image], cmap='gray')
This brings us to the next aspect of data pre-processing — data augmentation. Many times, the quantity of data that we have is not sufficient to perform the task of classification well enough. In such cases, we perform data augmentation. As an example, if we are working with a dataset of classifying gemstones into their different types, we may not have enough number of images (since high-quality images are difficult to obtain). In this case, we can perform augmentation to increase the size of your dataset. Augmentation is often used in image-based deep learning tasks to increase the amount and variance of training data. Augmentation should only be done on the training set, never on the validation set.
As you know that pooling increases the invariance. If a picture of a dog is in the top left corner of an image, with pooling, you would be able to recognize if the dog is in little bit left/right/up/down around the top left corner. But with training data consisting of data augmentation like flipping, rotation, cropping, translation, illumination, scaling, adding noise, etc., the model learns all these variations. This significantly boosts the accuracy of the model. So, even if the dog is there at any corner of the image, the model will be able to recognize it with high accuracy.
There are multiple types of augmentations possible. The basic ones transform the original image using one of the following types of transformations:
Linear transformationsAffine transformations
Linear transformations
Affine transformations
from skimage import transform as tf# flip left-right, up-downimage_flipr = np.fliplr(image)image_flipud = np.flipud(image)plot_image([image, image_flipr, image_flipud])
# specify x and y coordinates to be used for shifting (mid points)shift_x, shift_y = image.shape[0]/2, image.shape[1]/2# translation by certain unitsmatrix_to_topleft = tf.SimilarityTransform(translation=[-shift_x, -shift_y])matrix_to_center = tf.SimilarityTransform(translation=[shift_x, shift_y])# rotationrot_transforms =  tf.AffineTransform(rotation=np.deg2rad(45))rot_matrix = matrix_to_topleft + rot_transforms + matrix_to_centerrot_image = tf.warp(image, rot_matrix)# scaling scale_transforms = tf.AffineTransform(scale=(2, 2))scale_matrix = matrix_to_topleft + scale_transforms + matrix_to_centerscale_image_zoom_out = tf.warp(image, scale_matrix)scale_transforms = tf.AffineTransform(scale=(0.5, 0.5))scale_matrix = matrix_to_topleft + scale_transforms + matrix_to_centerscale_image_zoom_in = tf.warp(image, scale_matrix)# translationtransaltion_transforms = tf.AffineTransform(translation=(50, 50))translated_image = tf.warp(image, transaltion_transforms)plot_image([image, rot_image, scale_image_zoom_out, scale_image_zoom_in, translated_image])
# shear transformsshear_transforms = tf.AffineTransform(shear=np.deg2rad(45))shear_matrix = matrix_to_topleft + shear_transforms + matrix_to_centershear_image = tf.warp(image, shear_matrix)bright_jitter = image*0.999 + np.zeros_like(image)*0.001plot_image([image, shear_image, bright_jitter])
Let’s now build and train the model.
We will use ‘ResNet’ architecture in this section. Since ResNets have become quite prevalent in the industry, it is worth spending some time to understand the important elements of their architecture. Let’s start with the original architecture proposed here. Also, in 2016, the ResNet team had proposed some improvements in the original architecture here. Using these modifications, they had trained nets of more than 1000 layers (e.g. ResNet-1001).
The ‘ResNet builder’ module which is used here is basically a Python module containing all the building blocks of ResNet. We will use this module to import the variants of ResNets (ResNet-18, ResNet-34, etc.). The resnet.py module is taken from here. Its biggest upside is that the ‘skip connections’ mechanism allows very deep networks.
Data generator supports preprocessing — it normalizes the images (dividing by 255) and crops the center (100 x 100) portion of the image.
There was no specific reason to include 100 as the dimension but it has been chosen so that we can process all the images which are of greater than 100*100 dimension. If any dimension (height or width) of an image is less than 100 pixels, then that image is deleted automatically. You can change it to 150 or 200 according to your need.
Let’s now set up the data generator. The code below sets up a custom data generator which is slightly different than the one that comes with the keras API. The reason to use a custom generator is to be able to modify it according to the problem at hand (customizability).
import numpy as npimport kerasclass DataGenerator(keras.utils.Sequence):    'Generates data for Keras'        def __init__(self, mode='train', ablation=None, flowers_cls=['daisy', 'rose'],                  batch_size=32, dim=(100, 100), n_channels=3, shuffle=True):        """        Initialise the data generator        """        self.dim = dim        self.batch_size = batch_size        self.labels = {}        self.list_IDs = []                # glob through directory of each class         for i, cls in enumerate(flowers_cls):            paths = glob.glob(os.path.join(DATASET_PATH, cls, '*'))            brk_point = int(len(paths)*0.8)            if mode == 'train':                paths = paths[:brk_point]            else:                paths = paths[brk_point:]            if ablation is not None:                paths = paths[:ablation]            self.list_IDs += paths            self.labels.update({p:i for p in paths})                    self.n_channels = n_channels        self.n_classes = len(flowers_cls)        self.shuffle = shuffle        self.on_epoch_end()    def __len__(self):        'Denotes the number of batches per epoch'        return int(np.floor(len(self.list_IDs) / self.batch_size))    def __getitem__(self, index):        'Generate one batch of data'        # Generate indexes of the batch        indexes = self.indexes[index*self.batch_size:(index+1)*self.batch_size]        # Find list of IDs        list_IDs_temp = [self.list_IDs[k] for k in indexes]        # Generate data        X, y = self.__data_generation(list_IDs_temp)        return X, y    def on_epoch_end(self):        'Updates indexes after each epoch'        self.indexes = np.arange(len(self.list_IDs))        if self.shuffle == True:            np.random.shuffle(self.indexes)    def __data_generation(self, list_IDs_temp):        'Generates data containing batch_size samples' # X : (n_samples, *dim, n_channels)        # Initialization        X = np.empty((self.batch_size, *self.dim, self.n_channels))        y = np.empty((self.batch_size), dtype=int)                delete_rows = []        # Generate data        for i, ID in enumerate(list_IDs_temp):            # Store sample            img = io.imread(ID)            img = img/255            if img.shape[0] > 100 and img.shape[1] > 100:                h, w, _ = img.shape                img = img[int(h/2)-50:int(h/2)+50, int(w/2)-50:int(w/2)+50, : ]            else:                delete_rows.append(i)                continue                        X[i,] = img                      # Store class            y[i] = self.labels[ID]                X = np.delete(X, delete_rows, axis=0)        y = np.delete(y, delete_rows, axis=0)        return X, keras.utils.to_categorical(y, num_classes=self.n_classes)
To start with, we have the training data stored in nn directories (if there are nn classes). For a given batch size, we want to generate batches of data points and feed them to the model.
The first for loop 'globs' through each of the classes (directories). For each class, it stores the path of each image in the list paths. In training mode, it subsets paths to contain the first 80% images; in validation mode it subsets the last 20%. In the special case of an ablation experiment, it simply subsets the first ablation images of each class.
We store the paths of all the images (of all classes) in a combined list self.list_IDs. The dictionary self.labels contains the labels (as key:value pairs of path: class_number (0/1)).
After the loop, we call the method on_epoch_end(), which creates an array self.indexes of length self.list_IDs and shuffles them (to shuffle all the data points at the end of each epoch).
The _getitem_ method uses the (shuffled) array self.indexes to select a batch_size number of entries (paths) from the path list self.list_IDs.
Finally, the method __data_generation returns the batch of images as the pair X, y where X is of shape (batch_size, height, width, channels) and y is of shape (batch size, ). Note that __data_generation also does some preprocessing - it normalises the images (divides by 255) and crops the center 100 x 100 portion of the image. Thus, each image has the shape (100, 100, num_channels). If any dimension (height or width) of an image less than 100 pixels, that image is deleted.
These refer to taking a small chunk of data and running your model on it — this helps in figuring out if the model is running at all. This is called an ablation experiment.
The first part of building a network is to get it to run on your dataset. Let’s try fitting the net on only a few images and just one epoch. Note that since ablation=100 is specified, 100 images of each class are used, so total number of batches is np.floor(200/32) = 6.
Note that the DataGenerator class 'inherits' from the keras.utils.Sequence class, so it has all the functionalities of the base keras.utils.Sequence class (such as the model.fit_generator method).
# using resnet 18model = resnet.ResnetBuilder.build_resnet_18((img_channels, img_rows, img_cols), nb_classes)model.compile(loss='categorical_crossentropy', optimizer='SGD',              metrics=['accuracy'])# create data generator objects in train and val mode# specify ablation=number of data points to train ontraining_generator = DataGenerator('train', ablation=100)validation_generator = DataGenerator('val', ablation=100)# fit: this will fit the net on 'ablation' samples, only 1 epochmodel.fit_generator(generator=training_generator,                    validation_data=validation_generator,                    epochs=1,)
The next step is trying to overfit the model on the training data. Why would we want to intentionally overfit on our data? Simply put, this will tell us whether the network is capable of learning the patterns in the training set. This tells you whether the model is behaving as expected or not.
We’ll use ablation=100 (i.e. training on 100 images of each class), so it is still a very small dataset, and we will use 20 epochs. In each epoch, 200/32=6 batches will be used.
# resnet 18model = resnet.ResnetBuilder.build_resnet_18((img_channels, img_rows, img_cols), nb_classes)model.compile(loss='categorical_crossentropy',optimizer='SGD',              metrics=['accuracy'])# generatorstraining_generator = DataGenerator('train', ablation=100)validation_generator = DataGenerator('val', ablation=100)# fitmodel.fit_generator(generator=training_generator,                    validation_data=validation_generator,                    epochs=20)
The results show that the training accuracy increases consistently with each epoch. The validation accuracy also increases and then plateaus out — this is a sign of ‘good fit’, i.e. we know that the model is at least able to learn from a small dataset, so we can hope that it will be able to learn from the entire set as well.
To summarise, a good test of any model is to check whether it can overfit on the training data (i.e. the training loss consistently reduces along epochs). This technique is especially useful in deep learning because most deep learning models are trained on large datasets, and if they are unable to overfit a small version, then they are unlikely to learn from the larger version.
we trained the model on a small chunk of the dataset and confirmed that the model can learn from the dataset (indicated by overfitting). After fixing the model and data augmentation, we now need to find the learning rate for the optimizer (here SGD). First, let’s make a list of the hyper-parameters we want to tune:
Learning Rate & Variation + OptimisersAugmentation Techniques
Learning Rate & Variation + Optimisers
Augmentation Techniques
The basic idea is to track the validation loss with increasing epochs for various values of a hyperparameter.
Keras Callbacks
Before you move ahead, let’s discuss a bit about callbacks. Callbacks are basically actions that you want to perform at specific instances of training. For example, we want to perform the action of storing the loss at the end of every epoch (the instance here is the end of an epoch).
Formally, a callback is simply a function (if you want to perform a single action), or a list of functions (if you want to perform multiple actions), which are to be executed at specific events (end of an epoch, start of every batch, when the accuracy plateaus out, etc.). Keras provides some very useful callback functionalities through the class keras.callbacks.Callback.
Keras has many builtin callbacks (listed here). The generic way to create a custom callback in keras is:
from keras import optimizersfrom keras.callbacks import *# range of learning rates to tunehyper_parameters_for_lr = [0.1, 0.01, 0.001]# callback to append lossclass LossHistory(keras.callbacks.Callback):    def on_train_begin(self, logs={}):        self.losses = []    def on_epoch_end(self, epoch, logs={}):        self.losses.append(logs.get('loss'))# instantiate a LossHistory() object to store historieshistory = LossHistory()plot_data = {}# for each hyperparam: train the model and plot loss historyfor lr in hyper_parameters_for_lr:    print ('\n\n'+'=='*20 + '   Checking for LR={}  '.format(lr) + '=='*20 )    sgd = optimizers.SGD(lr=lr, clipnorm=1.)        # model and generators    model = resnet.ResnetBuilder.build_resnet_18((img_channels, img_rows, img_cols), nb_classes)    model.compile(loss='categorical_crossentropy',optimizer= sgd,                  metrics=['accuracy'])    training_generator = DataGenerator('train', ablation=100)    validation_generator = DataGenerator('val', ablation=100)    model.fit_generator(generator=training_generator,                        validation_data=validation_generator,                        epochs=3, callbacks=[history])        # plot loss history    plot_data[lr] = history.losses
In the code above, we have created a custom callback to append the loss to a list at the end of every epoch. Note that logs is an attribute (a dictionary) of keras.callbacks.Callback, and we are using it to get the value of the key 'loss'. Some other keys of this dict are acc, val_loss etc.
To tell the model that we want to use a callback, we create an object of LossHistory called history and pass it to model.fit_generator using callbacks=[history]. In this case, we only have one callback history, though you can pass multiple callback objects through this list (an example of multiple callbacks is in the section below - see the code block of DecayLR()).
Here, we tuned the learning rate hyperparameter and observed that a rate of 0.1 is the optimal learning rate when compared to 0.01 and 0.001. However, using such a high learning rate for the entire training process is not a good idea since the loss may start to oscillate around the minima later. So, at the start of the training, we use a high learning rate for the model to learn fast, but as we train further and proceed towards the minima, we decrease the learning rate gradually.
# plot loss history for each value of hyperparameterf, axes = plt.subplots(1, 3, sharey=True)f.set_figwidth(15)plt.setp(axes, xticks=np.arange(0, len(plot_data[0.01]), 1)+1)for i, lr in enumerate(plot_data.keys()):    axes[i].plot(np.arange(len(plot_data[lr]))+1, plot_data[lr])
The results above show that a learning rate of 0.1 is the best, though using such a high learning rate for the entire training is usually not a good idea. Thus, we should use learning rate decay — starting from a high learning rate and decaying it with every epoch.
We use another custom callback (DecayLR) to decay the learning rate at the end of every epoch. The decay rate is specified as 0.5 ^ epoch. Also, note that this time we are telling the model to use two callbacks (passed as a list callbacks=[history, decay] to model.fit_generator).
Although we have used our own custom decay implementation here, you can use the ones built into keras optimisers (using the decay argument).
# learning rate decayclass DecayLR(keras.callbacks.Callback):    def __init__(self, base_lr=0.001, decay_epoch=1):        super(DecayLR, self).__init__()        self.base_lr = base_lr        self.decay_epoch = decay_epoch         self.lr_history = []            # set lr on_train_begin    def on_train_begin(self, logs={}):        K.set_value(self.model.optimizer.lr, self.base_lr)    # change learning rate at the end of epoch    def on_epoch_end(self, epoch, logs={}):        new_lr = self.base_lr * (0.5 ** (epoch // self.decay_epoch))        self.lr_history.append(K.get_value(self.model.optimizer.lr))        K.set_value(self.model.optimizer.lr, new_lr)# to store loss historyhistory = LossHistory()plot_data = {}# start with lr=0.1decay = DecayLR(base_lr=0.1)# modelsgd = optimizers.SGD()model = resnet.ResnetBuilder.build_resnet_18((img_channels, img_rows, img_cols), nb_classes)model.compile(loss='categorical_crossentropy',optimizer= sgd,              metrics=['accuracy'])training_generator = DataGenerator('train', ablation=100)validation_generator = DataGenerator('val', ablation=100)model.fit_generator(generator=training_generator,                    validation_data=validation_generator,                    epochs=3, callbacks=[history, decay])plot_data[lr] = decay.lr_history
plt.plot(np.arange(len(decay.lr_history)), decay.lr_history)
Augmentation Techniques
Let’s now write some code to implement data augmentation. Augmentation is usually done with data generators, i.e. the augmented data is generated batch-wise, on the fly. You can either use the built-in keras ImageDataGenerator or write your own data generator (for some custom features etc if you want). The below code show how to implement these.
import numpy as npimport keras# data generator with augmentationclass AugmentedDataGenerator(keras.utils.Sequence):    'Generates data for Keras'    def __init__(self, mode='train', ablation=None, flowers_cls=['daisy', 'rose'],                  batch_size=32, dim=(100, 100), n_channels=3, shuffle=True):        'Initialization'        self.dim = dim        self.batch_size = batch_size        self.labels = {}        self.list_IDs = []        self.mode = mode                for i, cls in enumerate(flowers_cls):            paths = glob.glob(os.path.join(DATASET_PATH, cls, '*'))            brk_point = int(len(paths)*0.8)            if self.mode == 'train':                paths = paths[:brk_point]            else:                paths = paths[brk_point:]            if ablation is not None:                paths = paths[:ablation]            self.list_IDs += paths            self.labels.update({p:i for p in paths})                            self.n_channels = n_channels        self.n_classes = len(flowers_cls)        self.shuffle = shuffle        self.on_epoch_end()    def __len__(self):        'Denotes the number of batches per epoch'        return int(np.floor(len(self.list_IDs) / self.batch_size))    def __getitem__(self, index):        'Generate one batch of data'        # Generate indexes of the batch        indexes = self.indexes[index*self.batch_size:(index+1)*self.batch_size]        # Find list of IDs        list_IDs_temp = [self.list_IDs[k] for k in indexes]        # Generate data        X, y = self.__data_generation(list_IDs_temp)        return X, y    def on_epoch_end(self):        'Updates indexes after each epoch'        self.indexes = np.arange(len(self.list_IDs))        if self.shuffle == True:            np.random.shuffle(self.indexes)    def __data_generation(self, list_IDs_temp):        'Generates data containing batch_size samples' # X : (n_samples, *dim, n_channels)        # Initialization        X = np.empty((self.batch_size, *self.dim, self.n_channels))        y = np.empty((self.batch_size), dtype=int)                delete_rows = []        # Generate data        for i, ID in enumerate(list_IDs_temp):            # Store sample            img = io.imread(ID)            img = img/255            if img.shape[0] > 100 and img.shape[1] > 100:                h, w, _ = img.shape                img = img[int(h/2)-50:int(h/2)+50, int(w/2)-50:int(w/2)+50, : ]            else:                delete_rows.append(i)                continue                        X[i,] = img                      # Store class            y[i] = self.labels[ID]                X = np.delete(X, delete_rows, axis=0)        y = np.delete(y, delete_rows, axis=0)                # data augmentation        if self.mode == 'train':            aug_x = np.stack([datagen.random_transform(img) for img in X])            X = np.concatenate([X, aug_x])            y = np.concatenate([y, y])        return X, keras.utils.to_categorical(y, num_classes=self.n_classes)
Metrics to optimize
Depending on the situation, we choose the appropriate metrics. For binary classification problems, AUC is usually the best metric.
AUC is often a better metric than accuracy. So instead of optimising for accuracy, let’s monitor AUC and choose the best model based on AUC on validation data. We’ll use the callbacks on_train_begin and on_epoch_end to initialize (at the start of each epoch) and store the AUC (at the end of epoch).
from sklearn.metrics import roc_auc_scoreclass roc_callback(Callback):        def on_train_begin(self, logs={}):        logs['val_auc'] = 0    def on_epoch_end(self, epoch, logs={}):        y_p = []        y_v = []        for i in range(len(validation_generator)):            x_val, y_val = validation_generator[i]            y_pred = self.model.predict(x_val)            y_p.append(y_pred)            y_v.append(y_val)        y_p = np.concatenate(y_p)        y_v = np.concatenate(y_v)        roc_auc = roc_auc_score(y_v, y_p)        print ('\nVal AUC for epoch{}: {}'.format(epoch, roc_auc))        logs['val_auc'] = roc_auc
Let’s now train the final model. Note that we will keep saving the best model’s weights at models/best_models.hdf5, so you will need to create a directory models. Note that model weights are usually saved in hdf5 files.
Saving the best model is done using the callback functionality that comes with ModelCheckpoint. We basically specify the filepath where the model weights are to be saved, monitor='val_auc' specifies that you are choosing the best model based on validation accuracy, save_best_only=True saves only the best weights, and mode='max' specifies that the validation accuracy is to be maximized.
# modelmodel = resnet.ResnetBuilder.build_resnet_18((img_channels, img_rows, img_cols), nb_classes)model.compile(loss='categorical_crossentropy',optimizer= sgd,              metrics=['accuracy'])training_generator = AugmentedDataGenerator('train', ablation=32)validation_generator = AugmentedDataGenerator('val', ablation=32)# checkpoint filepath = 'models/best_model.hdf5'checkpoint = ModelCheckpoint(filepath, monitor='val_auc', verbose=1, save_best_only=True, mode='max')auc_logger = roc_callback()# fit model.fit_generator(generator=training_generator,                    validation_data=validation_generator,                    epochs=3, callbacks=[auc_logger, history, decay, checkpoint])
plt.imshow(image)
#standardizing image#moved the origin to the centre of the imageh, w, _ = image.shapeimg = image[int(h/2)-50:int(h/2)+50, int(w/2)-50:int(w/2)+50, : ]model.predict(img[np.newaxis,: ])
Hey, we have got a very high probability for class 1 i.e. rose. If you remember, class 0 was daisy and class 1 was rose(on top of the blog). So, the model has learnt perfectly. We have built a model with a good AUC at the end of 3 epochs. If you train this using more epochs, you should be able to reach a better AUC value.
If you have any questions, recommendations or critiques, I can be reached via LinkedIn or the comment section.