Keras
¶Keras is a high-level neural networks API, written in Python and capable of running on top of TensorFlow, CNTK, or Theano. It was developed with a focus on enabling fast experimentation. Being able to go from idea to result with the least possible delay is key to doing good research.
Note 1: This is not an introduction to deep neural networks as this would explode the scope of this notebook. But we want to show you how you can implement a convoluted neural network to classify neuroimages, in our case fMRI images.
Note 2: We want to thank Anisha Keshavan as a lot of the content in this notebook is coming from here introduction notebook about Keras.
from nilearn import plotting
%matplotlib inline
import numpy as np
import nibabel as nb
import matplotlib.pyplot as plt
import seaborn as sns
sns.set(style="darkgrid")
Let's again load the dataset we prepared in the machine learning preparation notebook, plus the anatomical template image (we will need this for visualization).
anat = nb.load('/home/neuro/workshop/notebooks/data/templates/MNI152_T1_1mm.nii.gz')
func = nb.load('/home/neuro/workshop/notebooks/data/dataset_ML.nii.gz')
from nilearn.image import mean_img
from nilearn.plotting import plot_anat
plot_anat(mean_img(func), cmap='magma', colorbar=False, display_mode='x', vmax=2, annotate=False,
cut_coords=range(0, 49, 12), title='Mean value of machine learning dataset');
As a reminder, the shape of our machine learning dataset is as follows:
func.shape
As in the nilearn
and PyMVPA
notebook, we need some chunks and label variables to train the neural network. The labels are important so that we can predict what we want to classify. And the chunks are just an easy way to make sure that the training and test dataset are split in an equal/balanced way.
So, as before, we specify again which volumes of the dataset were recorded during eyes closed resting state and which ones were recorded during eyes open resting state recording.
From the Machine Learning Preparation notebook, we know that we have a total of 384 volumes in our dataset_ML.nii.gz
file and that it's always 4 volumes of the condition eyes closed
, followed by 4 volumes of the condition eyes open
, etc. Therefore our labels should be as follows:
labels = np.ravel([[['closed'] * 4, ['open'] * 4] for i in range(48)])
labels[:20]
*Second*, the chunks
variable should not switch between subjects. So, as before, we can again specify 6 chunks of 64 volumes (8 subjects), each:
chunks = np.ravel([[i] * 64 for i in range(6)])
chunks[:150]
Convoluted neural networks are very powerful (as you will see), but the computation power to train the models can be incredibly demanding. For this reason, it's sometimes recommended to try to reduce the input space if possible.
In our case, we could try to not train the neural network only on one very thin slab (a few slices) of the brain. So, instead of taking the data matrix of the whole brain, we just take 2 slices in the region that we think is most likely to be predictive for the question at hand.
We know (or suspect) that the regions with the most predictive power are probably somewhere around the eyes and in the visual cortex. So let's try to specify a few slices that cover those regions.
So, let's try to just take a few slices around the eyes:
plot_anat(mean_img(func).slicer[...,5:-25], cmap='magma', colorbar=False,
display_mode='x', vmax=2, annotate=False, cut_coords=range(0, 49, 12),
title='Slab of the machine learning mean image');
Hmm... That doesn't seem to work. We want to cover the eyes and the visual cortex. Like this, we're too far down in the back of the head (at the Cerebellum). One solution to this is to rotate the volume.
So let's do that:
# Rotation parameters
phi = 0.35
cos = np.cos(phi)
sin = np.sin(phi)
# Compute rotation matrix around x-axis
rotation_affine = np.array([[1, 0, 0, 0],
[0, cos, -sin, 0],
[0, sin, cos, 0],
[0, 0, 0, 1]])
new_affine = rotation_affine.dot(func.affine)
# Rotate and resample image to new orientation
from nilearn.image import resample_img
new_img = nb.Nifti1Image(func.get_data(), new_affine)
img_rot = resample_img(new_img, func.affine, interpolation='continuous')
# Delete zero-only rows and columns
from nilearn.image import crop_img
img_crop = crop_img(img_rot)
Let's check if the rotation worked.
plot_anat(mean_img(img_crop), cmap='magma', colorbar=False, display_mode='x', vmax=2, annotate=False,
cut_coords=range(-20, 30, 12), title='Rotated machine learning dataset');
Perfect! And which slab should we take? Let's try the slices 12 and 13.
from nilearn.plotting import plot_stat_map
img_slab = img_crop.slicer[..., 12:14, :]
plot_stat_map(mean_img(img_slab), cmap='magma', bg_img=mean_img(img_crop), colorbar=False,
display_mode='x', vmax=2, annotate=False, cut_coords=range(-20, 30, 12),
title='Slab of rotated machine learning dataset');
Perfect, the slab seems to contain exactly what we want. Now that the data is ready we can continue with the actual machine learning part.
First things first, we need to define a training and testing set. This is really important because we need to make sure that our model can generalize to new, unseen data. Here, we randomly shuffle our data, and reserve 80% of it for our training data, and the remaining 20% for testing.
So let's first get the data in the right structure for keras. For this, we need to swap some of the dimensions of our data matrix.
data = np.rollaxis(img_slab.get_data(), 3, 0)
data.shape
As you can see, the goal is to have in the first dimension, the different volumes, and then the volume itself. Keep in mind, that the last dimension (here of size 2), are considered as channels
in the keras model that we will be using below.
Note: To make this notebook reproducible, i.e. always leading to the "same" results. Let's set a seed point for the random split of the dataset. This should only be done for teaching purposes, but not for real research as randomness and chance are a crucial part of machine learning.
from numpy.random import seed
seed(0)
As a next step, let's create a index list that we can use to split the data and labels into training and test sets:
# Create list of indices and shuffle them
N = data.shape[0]
indices = np.arange(N)
np.random.shuffle(indices)
# Cut the dataset at 80% to create the training and test set
N_80p = int(0.8 * N)
indices_train = indices[:N_80p]
indices_test = indices[N_80p:]
# Split the data into training and test sets
X_train = data[indices_train, ...]
X_test = data[indices_test, ...]
print(X_train.shape, X_test.shape)
We need to define a variable that holds the outcome variable (1 or 0) that indicates whether or not the resting-state images were recorded with eyes opened or closed. Luckily we have this information already stored in the labels
variable above. So let's split these labels in training and test set:
y_train = labels[indices_train] == 'open'
y_test = labels[indices_test] == 'open'
We need to reformat the shape of our outcome variables, y_train
and y_test
, because Keras needs the labels as a 2D array. Keras provides a function to do this:
from keras.utils import to_categorical
y_train = to_categorical(y_train)
y_test = to_categorical(y_test)
And now we're good to go.
Now come the fun and tricky part. We need to specify the structure of our convoluted neural network. As a quick reminder, a convoluted neural network consists of some convolution layers, pooling layers, some flattening layers and some full connect layers:
So as a first step, let's import all modules that we need to create the keras model:
import tensorflow as tf
from keras.models import Sequential
from keras.layers import Dense, Flatten, Dropout
from keras.layers import Conv2D, MaxPooling2D, BatchNormalization
from keras.optimizers import Adam, SGD
from keras import backend as K
As a next step, we should specify some of the model parameters that we want to be identical throughout the model:
# Get shape of input data
data_shape = tuple(X_train.shape[1:])
# Specify shape of convolution kernel
kernel_size = (3, 3)
# Specify number of output categories
n_classes = 2
# Specify number of filters per layer
filters = 16
Now comes the big part... the model, i.e. the structure of the neural network! We want to make clear that we're no experts in deep neural networks and therefore, the model below might not necessarily be a good model. But we chose it as it can be rather quickly estimated and has rather few parameters to estimate.
K.clear_session()
model = Sequential()
model.add(Conv2D(filters, kernel_size, activation='relu', input_shape=data_shape))
model.add(BatchNormalization())
model.add(MaxPooling2D())
model.add(Conv2D(filters * 2, kernel_size, activation='relu'))
model.add(BatchNormalization())
model.add(MaxPooling2D())
model.add(Conv2D(filters * 4, kernel_size, activation='relu'))
model.add(BatchNormalization())
model.add(MaxPooling2D())
model.add(Flatten())
model.add(Dense(256, activation='relu'))
model.add(Dropout(0.5))
model.add(Dense(512, activation='relu'))
model.add(Dropout(0.5))
model.add(Dense(n_classes, activation='softmax'))
# optimizer
learning_rate = 1e-5
adam = Adam(lr=learning_rate)
sgd = SGD(lr=learning_rate)
model.compile(loss='categorical_crossentropy',
optimizer=adam, # swap out for sgd
metrics=['accuracy'])
model.summary()
That's what our model looks like! Cool!
The next step is now, of course, to fit our model to the training data. In our case we have two parameters that we can work with:
First: How many iterations of the model fitting should be computed
nEpochs = 100 # Increase this value for better results (i.e., more training)
Second: How many elements (volumes) should be considered at once for the updating of the weights?
batch_size = 16 # Increasing this value might speed up fitting
So let's test the model:
%time fit = model.fit(X_train, y_train, epochs=nEpochs, batch_size=batch_size)
Let's take a look at the loss and accuracy values during the different epochs:
fig = plt.figure(figsize=(10, 4))
epoch = np.arange(nEpochs) + 1
fontsize = 16
plt.plot(epoch, fit.history['acc'], marker="o", linewidth=2,
color="steelblue", label="accuracy")
plt.plot(epoch, fit.history['loss'], marker="o", linewidth=2,
color="orange", label="loss")
plt.xlabel('epoch', fontsize=fontsize)
plt.xticks(fontsize=fontsize)
plt.yticks(fontsize=fontsize)
plt.legend(frameon=False, fontsize=16);
Great, it seems that accuracy is constantly increasing and the loss is continuing to drop. But how well is our model doing on the test data?
evaluation = model.evaluate(X_test, y_test)
print('Loss in Test set: %.02f' % (evaluation[0]))
print('Accuracy in Test set: %.02f' % (evaluation[1] * 100))
Not bad for just a few iterations. But let's see what we reach if we iterate a few hundred times more?
nEpochs = 200
%time fit = model.fit(X_train, y_train, epochs=nEpochs, batch_size=batch_size)
fig = plt.figure(figsize=(10, 4))
epoch = np.arange(nEpochs) + 1
fontsize = 16
plt.plot(epoch, fit.history['acc'], marker="o", linewidth=2,
color="steelblue", label="accuracy")
plt.plot(epoch, fit.history['loss'], marker="o", linewidth=2,
color="orange", label="loss")
plt.xlabel('epoch', fontsize=fontsize)
plt.xticks(fontsize=fontsize)
plt.yticks(fontsize=fontsize)
plt.legend(frameon=False, fontsize=16);
Wow, much better! At least on the training data. What about the test data?
evaluation = model.evaluate(X_test, y_test)
print('Loss in Test set: %.02f' % (evaluation[0]))
print('Accuracy in Test set: %.02f' % (evaluation[1] * 100))
That's amazing. But keep in mind that overfitting is a constant problem. We try to prevent this with the Dropout layers and by using relu
activation function. Setting up the right model for convoluted neural networks can be a very tricky!
What are the predicted values of the test set?
y_pred = model.predict(X_test)
y_pred[:10,:]
As you can see, those values can be between 0 and 1.
fig = plt.figure(figsize=(6, 4))
fontsize = 16
plt.hist(y_pred[:,0], bins=16, label='eyes closed')
plt.hist(y_pred[:,1], bins=16, label='eyes open');
plt.xticks(fontsize=fontsize)
plt.yticks(fontsize=fontsize)
plt.legend(frameon=False, fontsize=16);
The more both distributions are distributed around chance level, the weaker your model is.
Note: Keep in mind that we trained the whole model only on one split of test and training data. Ideally, you would repeat this process many times so that your results become less dependent on what kind of split you did.
Finally, as a cool additional feature: We can now visualize the individual filters of the hidden layers. So let's get to it:
# Aggregate the layers
layer_dict = dict([(layer.name, layer) for layer in model.layers])
# Specify a function that visualized the layers
def show_activation(layer_name):
layer_output = layer_dict[layer_name].output
fn = K.function([model.input], [layer_output])
inp = X_train[0:1]
this_hidden = fn([inp])[0]
# plot the activations, 8 filters per row
plt.figure(figsize=(16,8))
nFilters = this_hidden.shape[-1]
nColumn = 8 if nFilters >= 8 else nFilters
for i in range(nFilters):
plt.subplot(nFilters / nColumn, nColumn, i+1)
plt.imshow(this_hidden[0,:,:,i], cmap='magma', interpolation='nearest')
plt.axis('off')
return
Now we can plot the filters of the hidden layers:
show_activation('conv2d_1')
show_activation('conv2d_2')
show_activation('conv2d_3')
The classification of the training set gets incredibly high, while the validation set also reaches a reasonable accuracy level above 80. Nonetheless, by only investigating a slab of our fMRI dataset, we might have missed out on some important additional parameters.
An alternative solution might be to use 3D convoluted neural networks. But keep in mind that they will have even more parameters and probably take much longer to fit the model to the training data. Having said so, let's get to it.
We could take the full brain for the 3D convoluted neural network. But the more voxels we include the longer the model takes to fit to the training data. Therefor it makes sense again to reduce the whole brain volume to a slab around the regions that we're interested in. But for this case, let's not just keep 3 slices, but a bit more:
from nilearn.plotting import plot_stat_map
img_slab = img_crop.slicer[5:-5, 3:-3, 6:24, :]
plot_stat_map(mean_img(img_slab), cmap='magma', bg_img=mean_img(img_crop), colorbar=False,
display_mode='x', vmax=2, annotate=False, cut_coords=range(-20, 30, 12),
title='Slab of rotated machine learning dataset');
This should do the trick. Now we just have to extract the data from this slab:
data = np.rollaxis(img_slab.get_data(), 3, 0)[...,None]
data.shape
Note: We added another dimension to our array with [...,None]
. This is needed for Keras and Tensorflow as this represents the channel dimension.
The splitting of the data into training and test set is exactly the same as before.
# Create list of indices and shuffle them
N = data.shape[0]
indices = np.arange(N)
np.random.shuffle(indices)
# Cut the dataset at 80% to create the training and test set
N_80p = int(0.8 * N)
indices_train = indices[:N_80p]
indices_test = indices[N_80p:]
# Split the data into training and test sets
X_train = data[indices_train, ...]
X_test = data[indices_test, ...]
print(X_train.shape, X_test.shape)
Also here, everything is the same as in the 2D example.
y_train = labels[indices_train] == 'open'
y_test = labels[indices_test] == 'open'
from keras.utils import to_categorical
y_train = to_categorical(y_train)
y_test = to_categorical(y_test)
So, first, let's again import the necessary modules. Notice that we swithced Conv2D
and MaxPooling2D
with their 3-dimensional counter part.
import tensorflow as tf
from keras.models import Sequential
from keras.layers import Dense, Flatten, Dropout
from keras.layers import Conv3D, MaxPooling3D, BatchNormalization
from keras.optimizers import Adam, SGD
from keras import backend as K
Now, we need again to identify the important model parameters. In this case we need to extend the kernel size to 3 dimension:
# Get shape of input data
data_shape = tuple(X_train.shape[1:])
# Specify shape of convolution kernel
kernel_size = (2, 2, 2)
# Specify number of output categories
n_classes = 2
# Specify number of filters per layer
filters = 32 # For better results, increase this value to 8
And we're good to go and can setup our model:
K.clear_session()
model = Sequential()
model.add(Conv3D(filters, kernel_size, activation='relu', input_shape=data_shape))
model.add(BatchNormalization())
model.add(MaxPooling3D(pool_size=(2, 3, 2)))
model.add(Conv3D(filters * 2, kernel_size, activation='relu'))
model.add(BatchNormalization())
model.add(MaxPooling3D(pool_size=(2, 3, 2)))
model.add(Conv3D(filters * 4, kernel_size, activation='relu'))
model.add(BatchNormalization())
model.add(MaxPooling3D())
model.add(Flatten())
model.add(Dense(256, activation='relu'))
model.add(Dropout(0.5))
model.add(Dense(512, activation='relu'))
model.add(Dropout(0.5))
model.add(Dense(n_classes, activation='softmax'))
# optimizer
learning_rate = 1e-5
adam = Adam(lr=learning_rate)
sgd = SGD(lr=learning_rate)
model.compile(loss='categorical_crossentropy',
optimizer=adam, # swap out for sgd
metrics=['accuracy'])
model.summary()
As before we need to specify how many epochs we want to run and what the batch size is. As you will see, the fitting of the 3D convoluted model takes much much longer than the one for the 2D model. Therefore we recommend to drastically decrease the number of epochs (at least for this workshop example).
nEpochs = 100 # Increase this value for better results (i.e., more training)
batch_size = 16 # Increasing this value might speed up fitting
And we're good to go!
*Please set run_3D_convnet
to True
if you want to fit the model and run the rest of the analysis.*
run_3D_convnet = False
%time
if run_3D_convnet:
fit = model.fit(X_train, y_train, epochs=nEpochs, batch_size=batch_size)
Let's take a look at the loss and accuracy values during the different epochs:
if run_3D_convnet:
fig = plt.figure(figsize=(10, 4))
epoch = np.arange(nEpochs) + 1
fontsize = 16
plt.plot(epoch, fit.history['acc'], marker="o", linewidth=2,
color="steelblue", label="accuracy")
plt.plot(epoch, fit.history['loss'], marker="o", linewidth=2,
color="orange", label="loss")
plt.xlabel('epoch', fontsize=fontsize)
plt.xticks(fontsize=fontsize)
plt.yticks(fontsize=fontsize)
plt.legend(frameon=False, fontsize=16);
How about the performance on the test data?
if run_3D_convnet:
evaluation = model.evaluate(X_test, y_test)
print('Loss in Test set: %.02f' % (evaluation[0]))
print('Accuracy in Test set: %.02f' % (evaluation[1] * 100))