0% found this document useful (0 votes)
14 views16 pages

MNIST Handwritten Digit Recognition Algorithm

The document outlines three lab assignments involving machine learning techniques. The first assignment focuses on using multilayer logistic regression to identify handwritten digits from the MNIST dataset. The subsequent assignments involve applying convolutional layers for image feature extraction and using a simple RNN to analyze movie review sentiments.

Uploaded by

soumicsarkar
Copyright
© All Rights Reserved
We take content rights seriously. If you suspect this is your content, claim it here.
Available Formats
Download as DOCX, PDF, TXT or read online on Scribd
0% found this document useful (0 votes)
14 views16 pages

MNIST Handwritten Digit Recognition Algorithm

The document outlines three lab assignments involving machine learning techniques. The first assignment focuses on using multilayer logistic regression to identify handwritten digits from the MNIST dataset. The subsequent assignments involve applying convolutional layers for image feature extraction and using a simple RNN to analyze movie review sentiments.

Uploaded by

soumicsarkar
Copyright
© All Rights Reserved
We take content rights seriously. If you suspect this is your content, claim it here.
Available Formats
Download as DOCX, PDF, TXT or read online on Scribd

LAB ASSIGNMENT - 6

Write an algorithm to tackle MNIST dataset to identify hand written


digits from 28x28 black and white images utilizing multilayer
logistic regression.

SOURCE CODE:
from [Link] import mnist
(training_dataset_x, training_dataset_y), (test_dataset_x,
test_dataset_y) = mnist.load_data()
#downloading MNIST data set
import [Link] as plt

figure = [Link]()
figure.set_size_inches(10, 10)
for i in range(1, 10):
[Link](3, 3, i)
axis = [Link]()
axis.set_title(str(training_dataset_y[i]))
[Link](training_dataset_x[i].reshape(28, 28), cmap='gray')
[Link]()
#showing some pictures in the data set
training_dataset_x = training_dataset_x.reshape(-1, 28 * 28)
test_dataset_x = test_dataset_x.reshape(-1, 28 * 28)
#Shape the 28x28 three-dimensional image matrix to two-dimensional for
input to the neural network.
training_dataset_x = training_dataset_x / 255
test_dataset_x = test_dataset_x / 255
# Normalize the data with the MinMax method.
#The scaling method is done by dividing the pixel values directly by
255,
#since the lowest pixel value is 0 and the largest pixel value is 255.
import tensorflow
from [Link] import to_categorical

training_dataset_y = to_categorical(training_dataset_y)
test_dataset_y = to_categorical(test_dataset_y)
#Since the output of the model is a categorical data,
#we need to encode the data with one hot encoding before training.
#For one hot encoding, we can also use the OneHotEncoder object in the
[Link] module.
#Used here with the to_categorical function in Keras.
from [Link] import Sequential
from [Link] import Dense

model = Sequential()
[Link](Dense(512, input_dim=28 * 28, activation='relu',
name='Hidden-1'))
[Link](Dense(256, activation='relu', name='Hidden-2'))
[Link](Dense(10, activation='softmax', name='Output'))
[Link]('adam', loss='categorical_crossentropy',
metrics=['categorical_accuracy'])
hist = [Link](training_dataset_x, training_dataset_y, epochs=10,
batch_size=64,
validation_split=0.2)
#creating our model

import [Link] as plt

figure = [Link]()
figure.set_size_inches((15, 5))
[Link]('Loss - Epoch Graphics')
[Link]('Epoch')
[Link]('Loss')
[Link](range(1, len([Link]['loss']) + 1), [Link]['loss'])
[Link](range(1, len([Link]['val_loss']) + 1),
[Link]['val_loss'])
[Link](['Loss', 'Validation Loss'])
[Link]()

figure = [Link]()
figure.set_size_inches((15, 5))
[Link]('Categorical Accuracy - Epoch Graphics')
[Link]('Epoch')
[Link]('Categorical Accuracy')
[Link](range(1, len([Link]['categorical_accuracy']) + 1),
[Link]['categorical_accuracy'])
[Link](range(1, len([Link]['val_categorical_accuracy']) + 1),
[Link]['val_categorical_accuracy'])
[Link](['Categorical Accuracy', 'Validation Categorical Accuracy'])
[Link]()
#Let's look at some graphics

eval_result = [Link](test_dataset_x, test_dataset_y)


for i in range(len(eval_result)):
print(f'{model.metrics_names[i]} ---> {eval_result[i]}')

# testing our model

import numpy as np
import [Link] as plt

img_data = [Link]('[Link]')
#img_data = [Link]('/kaggle/input/testing/[Link]')
# Adjust weights to match the number of channels in the image
gray_img_data = [Link](img_data, weights=[0.3, 0.59, 0.11, 0.0],
axis=2) # Added a 0 weight for the 4th channel
[Link](1, 2, 1)
[Link](img_data)
[Link](1, 2, 2)
[Link](gray_img_data, cmap='gray')
[Link]()

gray_img_data = gray_img_data / 255


from [Link] import resize
gray_img_data = resize(gray_img_data, (28, 28))

gray_img_data = gray_img_data.reshape((1, 28 * 28))


predict_result = [Link](gray_img_data)
number = [Link](predict_result[0])
print(number)

OUTPUT
LAB ASSIGNMENT - 7
Consider an image and apply the convolution layer, activation layer
and pooling layer operation to extract the inside feature.

SOURCE CODE:
# import the necessary libraries
import numpy as np
import tensorflow as tf
import [Link] as plt
from itertools import product

# set the param


[Link]('figure', autolayout=True)
[Link]('image', cmap='magma')

# define the kernel


kernel = [Link]([[-1, -1, -1],
[-1, 8, -1],
[-1, -1, -1],
])

# load the image


image = [Link].read_file('[Link]')
image = [Link].decode_jpeg(image, channels=1)
image = [Link](image, size=[300, 300])

# plot the image


img = [Link](image).numpy()
[Link](figsize=(5, 5))
[Link](img, cmap='gray')
[Link]('off')
[Link]('Original Gray Scale image')
[Link]();

# Reformat
image = [Link].convert_image_dtype(image, dtype=tf.float32)
image = tf.expand_dims(image, axis=0)
kernel = [Link](kernel, [*[Link], 1, 1])
kernel = [Link](kernel, dtype=tf.float32)

# convolution layer
conv_fn = [Link].conv2d

image_filter = conv_fn(
input=image,
filters=kernel,
strides=1, # or (1, 1)
padding='SAME',
)

[Link](figsize=(15, 5))

# Plot the convolved image


[Link](1, 3, 1)

[Link](
[Link](image_filter)
)
[Link]('off')
[Link]('Convolution')

# activation layer
relu_fn = [Link]
# Image detection
image_detect = relu_fn(image_filter)

[Link](1, 3, 2)
[Link](
# Reformat for plotting
[Link](image_detect)
)

[Link]('off')
[Link]('Activation')

# Pooling layer
pool = [Link]
image_condense = pool(input=image_detect,
window_shape=(2, 2),
pooling_type='MAX',
strides=(2, 2),
padding='SAME',
)

[Link](1, 3, 3)
[Link]([Link](image_condense))
[Link]('off')
[Link]('Pooling')
[Link]()
OUTPUT
LAB ASSIGNMENT - 8
Determine whether a movie review expresses positive, negative or
neutral sentiment using simple RNN.

SOURCE CODE:

# Importing necessary libraries


from [Link] import SimpleRNN, LSTM, GRU,
Bidirectional, Dense, Embedding
from [Link] import imdb
from [Link] import Sequential
import numpy as np

# Getting reviews with words that come under 5000


# most occurring words in the entire corpus of textual review data
vocab_size = 5000
(x_train, y_train), (x_test, y_test) =
imdb.load_data(num_words=vocab_size)

# Printing the first review from the training set


print(x_train[0])

# Getting all the words from word_index dictionary


word_idx = imdb.get_word_index()

# Originally the index number of a value and not a key,


# hence converting the index as key and the words as values
word_idx = {i: word for word, i in word_idx.items()}

# Printing the first review from the training set in its original words
print([word_idx[i] for i in x_train[0]])

# Get the minimum and the maximum length of reviews


print("Max length of a review:: ", len(max((x_train+x_test), key=len)))
print("Min length of a review:: ", len(min((x_train+x_test), key=len)))

# Importing sequence module for padding sequences


from [Link] import sequence

# Keeping a fixed length of all reviews to max 400 words


max_words = 400

# Padding sequences to a fixed length of 400 words


x_train = sequence.pad_sequences(x_train, maxlen=max_words)
x_test = sequence.pad_sequences(x_test, maxlen=max_words)
# Splitting the training set into training and validation sets
x_valid, y_valid = x_train[:64], y_train[:64]
x_train_, y_train_ = x_train[64:], y_train[64:]

# Fixing every word's embedding size to be 32


embd_len = 32

# Creating a Sequential model named "Simple_RNN"


RNN_model = Sequential(name="Simple_RNN")
RNN_model.add(Embedding(vocab_size,
embd_len,
input_length=max_words))

# Adding a SimpleRNN layer with 128 units and 'tanh' activation


function
# In case of a stacked (more than one layer of RNN), use
return_sequences=True
RNN_model.add(SimpleRNN(128,
activation='tanh',
return_sequences=False))
RNN_model.add(Dense(1, activation='sigmoid'))

# Printing the summary of the model architecture


print(RNN_model.summary())

# Compiling the model with binary cross-entropy loss, Adam optimizer,


and accuracy metric
RNN_model.compile(
loss="binary_crossentropy",
optimizer='adam',
metrics=['accuracy']
)

# Training the model on the training data


history = RNN_model.fit(x_train_, y_train_,
batch_size=64,
epochs=5,
verbose=1,
validation_data=(x_valid, y_valid))

# Printing model score on test data


print()
print("Simple_RNN Score---> ", RNN_model.evaluate(x_test, y_test,
verbose=0))

# Function to predict sentiment label


def predict_sentiment(review_text):
# Tokenize and preprocess the input review text
review_sequence = imdb.get_word_index()
words = review_text.split()
review_sequence = [review_sequence[word] if word in review_sequence
and review_sequence[word] < vocab_size else 0 for word in words]
review_sequence = sequence.pad_sequences([review_sequence],
maxlen=max_words)

# Predict sentiment label


sentiment_score = RNN_model.predict(review_sequence)[0][0]

# Interpret prediction
if sentiment_score < 0.4:
return "Negative"
elif sentiment_score > 0.6:
return "Positive"
else:
return "Neutral"

# Example usage
review = "This movie was fantastic! I loved every minute of it."
print("Review:", review)
print("Predicted sentiment:", predict_sentiment(review))

OUTPUT
[1, 14, 22, 16, 43, 530, 973, 1622, 1385, 65, 458, 4468, 66, 3941, 4,
173, 36, 256, 5, 25, 100, 43, 838, 112, 50, 670, 2, 9, 35, 480, 284, 5,
150, 4, 172, 112, 167, 2, 336, 385, 39, 4, 172, 4536, 1111, 17, 546,
38, 13, 447, 4, 192, 50, 16, 6, 147, 2025, 19, 14, 22, 4, 1920, 4613,
469, 4, 22, 71, 87, 12, 16, 43, 530, 38, 76, 15, 13, 1247, 4, 22, 17,
515, 17, 12, 16, 626, 18, 2, 5, 62, 386, 12, 8, 316, 8, 106, 5, 4,
2223, 2, 16, 480, 66, 3785, 33, 4, 130, 12, 16, 38, 619, 5, 25, 124,
51, 36, 135, 48, 25, 1415, 33, 6, 22, 12, 215, 28, 77, 52, 5, 14, 407,
16, 82, 2, 8, 4, 107, 117, 2, 15, 256, 4, 2, 7, 3766, 5, 723, 36, 71,
43, 530, 476, 26, 400, 317, 46, 7, 4, 2, 1029, 13, 104, 88, 4, 381, 15,
297, 98, 32, 2071, 56, 26, 141, 6, 194, 2, 18, 4, 226, 22, 21, 134,
476, 26, 480, 5, 144, 30, 2, 18, 51, 36, 28, 224, 92, 25, 104, 4, 226,
65, 16, 38, 1334, 88, 12, 16, 283, 5, 16, 4472, 113, 103, 32, 15, 16,
2, 19, 178, 32]
['the', 'as', 'you', 'with', 'out', 'themselves', 'powerful', 'lets',
'loves', 'their', 'becomes', 'reaching', 'had', 'journalist', 'of',
'lot', 'from', 'anyone', 'to', 'have', 'after', 'out', 'atmosphere',
'never', 'more', 'room', 'and', 'it', 'so', 'heart', 'shows', 'to',
'years', 'of', 'every', 'never', 'going', 'and', 'help', 'moments',
'or', 'of', 'every', 'chest', 'visual', 'movie', 'except', 'her',
'was', 'several', 'of', 'enough', 'more', 'with', 'is', 'now',
'current', 'film', 'as', 'you', 'of', 'mine', 'potentially',
'unfortunately', 'of', 'you', 'than', 'him', 'that', 'with', 'out',
'themselves', 'her', 'get', 'for', 'was', 'camp', 'of', 'you', 'movie',
'sometimes', 'movie', 'that', 'with', 'scary', 'but', 'and', 'to',
'story', 'wonderful', 'that', 'in', 'seeing', 'in', 'character', 'to',
'of', '70s', 'and', 'with', 'heart', 'had', 'shadows', 'they', 'of',
'here', 'that', 'with', 'her', 'serious', 'to', 'have', 'does', 'when',
'from', 'why', 'what', 'have', 'critics', 'they', 'is', 'you', 'that',
"isn't", 'one', 'will', 'very', 'to', 'as', 'itself', 'with', 'other',
'and', 'in', 'of', 'seen', 'over', 'and', 'for', 'anyone', 'of', 'and',
'br', "show's", 'to', 'whether', 'from', 'than', 'out', 'themselves',
'history', 'he', 'name', 'half', 'some', 'br', 'of', 'and', 'odd',
'was', 'two', 'most', 'of', 'mean', 'for', '1', 'any', 'an', 'boat',
'she', 'he', 'should', 'is', 'thought', 'and', 'but', 'of', 'script',
'you', 'not', 'while', 'history', 'he', 'heart', 'to', 'real', 'at',
'and', 'but', 'when', 'from', 'one', 'bit', 'then', 'have', 'two',
'of', 'script', 'their', 'with', 'her', 'nobody', 'most', 'that',
'with', "wasn't", 'to', 'with', 'armed', 'acting', 'watch', 'an',
'for', 'with', 'and', 'film', 'want', 'an']
Max length of a review:: 2697
Min length of a review:: 70
Model: "Simple_RNN"
_________________________________________________________________
Layer (type) Output Shape Param #
=================================================================
embedding_5 (Embedding) (None, 400, 32) 160000

simple_rnn_5 (SimpleRNN) (None, 128) 20608

dense_5 (Dense) (None, 1) 129

=================================================================
Total params: 180737 (706.00 KB)
Trainable params: 180737 (706.00 KB)
Non-trainable params: 0 (0.00 Byte)
_________________________________________________________________
None
Epoch 1/5
390/390 [==============================] - 74s 185ms/step - loss:
0.6889 - accuracy: 0.5328 - val_loss: 0.6703 - val_accuracy: 0.6094
Epoch 2/5
390/390 [==============================] - 71s 181ms/step - loss:
0.6198 - accuracy: 0.6478 - val_loss: 0.7069 - val_accuracy: 0.5000
Epoch 3/5
390/390 [==============================] - 70s 181ms/step - loss:
0.5872 - accuracy: 0.6889 - val_loss: 0.6426 - val_accuracy: 0.6250
Epoch 4/5
390/390 [==============================] - 72s 186ms/step - loss:
0.4998 - accuracy: 0.7557 - val_loss: 0.7248 - val_accuracy: 0.5156
Epoch 5/5
390/390 [==============================] - 71s 182ms/step - loss:
0.5258 - accuracy: 0.7346 - val_loss: 0.7333 - val_accuracy: 0.5625

Simple_RNN Score---> [0.5999770164489746, 0.6954799890518188]


Review: This movie was fantastic! I loved every minute of it.
1/1 [==============================] - 0s 170ms/step
Predicted sentiment: Positive
LAB ASSIGNMENT - 9
Determine whether a movie review expresses positive, negative or
neutral sentiment using LSTM.

SOURCE CODE:
import numpy as np
import tensorflow as tf
from [Link] import pad_sequences
from [Link] import imdb
from [Link] import Sequential
from [Link] import Embedding, LSTM, Dense
from [Link] import Tokenizer# for tesing
user defined sentiment
#from [Link] import pad_sequences

#numpy as np: Imports the NumPy library and gives it the alias np.
NumPy is used
#for numerical operations and handling arrays.

#tensorflow as tf: Imports the TensorFlow library with the alias tf.
#TensorFlow is an open-source library for machine learning and deep
learning tasks.

#pad_sequences: A utility function from Keras (part of TensorFlow) that


pads
#sequences to the same length.
#imdb: Imports the IMDb dataset from Keras, which is a collection of
movie reviews
#labeled as positive or negative.
#Sequential: A Keras model type that allows you to build a neural
network layer by layer.
#Embedding, LSTM, Dense: Imports specific layers from Keras.
#Embedding is for creating word embeddings, LSTM is a type of recurrent
layer,
#and Dense is a fully connected layer.

# For reproducibility
[Link](42)
[Link].set_seed(42)
#Setting seeds for NumPy and TensorFlow to ensure the results are
reproducible.
#The value 42 is arbitrary and commonly used as a seed value.
#-------------------------importing necessary
libraries--------------------
# Load the IMDb dataset
max_features = 10000 # Number of words to consider as features
maxlen = 200 # Cut texts after this number of words (among top
max_features most common words)

(x_train, y_train), (x_test, y_test) =


imdb.load_data(num_words=max_features)

#max_features = 10000: Limits the dataset to the 10,000 most frequent


words.
#maxlen = 200: Sets the maximum length of each sequence (review) to 200
words.
#Longer reviews will be truncated, and shorter ones will be padded.
#imdb.load_data(num_words=max_features): Loads the IMDb dataset,
#considering only the top 10,000 most frequent words. Returns training
#and testing data split into inputs (x_train, x_test) and labels
(y_train, y_test).

# Pad sequences to ensure uniform input size


x_train = pad_sequences(x_train, maxlen=maxlen)
x_test = pad_sequences(x_test, maxlen=maxlen)
#pad_sequences: Ensures that all input sequences have the same length
(maxlen),
#which is required for batch processing. Shorter sequences are padded
with zeros, and longer ones are truncated.

#----------------------------------------------load and preprocess data


set-------------
model = Sequential()
[Link](Embedding(max_features, 128, input_length=maxlen)) #
Embedding layer
[Link](LSTM(128, return_sequences=False)) # LSTM layer
[Link](Dense(1, activation='sigmoid')) # Output layer

#model = Sequential(): Initializes a Sequential model, which allows


stacking layers linearly.
#[Link](Embedding(max_features, 128, input_length=maxlen)): Adds an
Embedding layer.
#This layer converts integer-encoded
#words into dense vectors of fixed size (128 in this case).
#max_features specifies the size of the vocabulary, and input_length is
the length of
#input sequences.
#[Link](LSTM(128, return_sequences=False)): Adds an LSTM layer with
128 units.
#LSTM is a type of recurrent neural network layer that can capture
temporal dependencies.
#return_sequences=False indicates that the LSTM layer will output the
last hidden state
#rather than the entire sequence of hidden states.
#[Link](Dense(1, activation='sigmoid')): Adds a Dense (fully
connected) output layer with a single unit. The sigmoid activation
function outputs a value between 0 and 1, suitable for binary
classification.

[Link](loss='binary_crossentropy', optimizer='adam',
metrics=['accuracy'])
[Link]()
#[Link]: Configures the model for training.
#loss='binary_crossentropy': Specifies the loss function for binary
classification.
#optimizer='adam': Specifies the optimizer (Adam), which is an
#efficient gradient descent algorithm.
#metrics=['accuracy']: Specifies that accuracy should be tracked during
training.
#[Link](): Prints a summary of the model architecture,
#showing the layers, output shapes, and number of parameters.
#-----------------------------build LSTM
model-----------------------------------------
batch_size = 32
epochs = 5

history = [Link](x_train, y_train, batch_size=batch_size,


epochs=epochs, validation_split=0.2)

#batch_size = 32: Sets the number of samples per gradient update.

#epochs = 3: Sets the number of complete passes through the training


dataset.
#[Link]: Trains the model.
#x_train, y_train: The training data and labels.
#batch_size=batch_size: Specifies the batch size.
#epochs=epochs: Specifies the number of epochs.
#validation_split=0.2: Uses 20% of the training data for validation.
#-------------------------------Train the
model----------------------------------------
score, acc = [Link](x_test, y_test, batch_size=batch_size)
print('Test score:', score)
print('Test accuracy:', acc)

#[Link]: Evaluates the model's performance on the test data.


#x_test, y_test: The test data and labels.
#batch_size=batch_size: Uses the specified batch size.
#score: The loss value on the test data.
#acc: The accuracy on the test data.
#print('Test score:', score): Prints the test loss.
#print('Test accuracy:', acc): Prints the test accuracy.
#-------------------------------Evaluate the
model---------------------------------

def predict_sentiment(text, model, tokenizer, maxlen=200):


# Preprocess the text
sequences = tokenizer.texts_to_sequences([text])
padded_sequences = pad_sequences(sequences, maxlen=maxlen)

# Predict sentiment
prediction = [Link](padded_sequences)

# Interpret the result


sentiment = 'Positive' if prediction[0][0] > 0.5 else 'Negative'
print(f'Text: {text}')
print(f'Sentiment: {sentiment} (Confidence: {prediction[0]
[0]:.2f})')

#text: The user-defined input text.


#model: The trained LSTM model.
#tokenizer: The tokenizer used to preprocess the text.
#maxlen: The maximum length of sequences (should be the same as during
training).
#Inside the function:

#tokenizer.texts_to_sequences([text]): Converts the input text into a


sequence
#of integers based on the tokenizer's word index.
#pad_sequences(sequences, maxlen=maxlen): Pads the sequence to ensure
#it matches the input length used during training.
#[Link](padded_sequences): Uses the model to predict the
sentiment of the input text.
#The prediction is interpreted as 'Positive' if the output is greater
than 0.5,
#otherwise 'Negative'.

tokenizer = Tokenizer(num_words=max_features)
tokenizer.fit_on_texts(imdb.get_word_index().keys())

#This step creates a tokenizer and fits it on the IMDb dataset's word
#index to ensure that the user-defined text is tokenized similarly to
the training data.

#user_input = "This movie was fantastic! I loved the acting and the
story."
#predict_sentiment(user_input, model, tokenizer)

#user_input = "I did not enjoy this movie at all. It was too slow and
boring."
user_input = "very bad movie."
predict_sentiment(user_input, model, tokenizer)

#user_input: Example texts for sentiment prediction.


#predict_sentiment(user_input, model, tokenizer): Calls the function
#to preprocess the input text, predict sentiment, and print the result.

OUTPUT
Downloading data from [Link]
keras-datasets/[Link]
17464789/17464789 [==============================] - 1s 0us/step
Model: "sequential"
_________________________________________________________________
Layer (type) Output Shape Param #
=================================================================
embedding (Embedding) (None, 200, 128) 1280000

lstm (LSTM) (None, 128) 131584

dense (Dense) (None, 1) 129

=================================================================
Total params: 1411713 (5.39 MB)
Trainable params: 1411713 (5.39 MB)
Non-trainable params: 0 (0.00 Byte)
_________________________________________________________________
Epoch 1/5
625/625 [==============================] - 232s 369ms/step - loss:
0.4469 - accuracy: 0.7880 - val_loss: 0.3340 - val_accuracy: 0.8568
Epoch 2/5
625/625 [==============================] - 227s 363ms/step - loss:
0.2634 - accuracy: 0.8944 - val_loss: 0.3350 - val_accuracy: 0.8570
Epoch 3/5
625/625 [==============================] - 228s 364ms/step - loss:
0.1766 - accuracy: 0.9330 - val_loss: 0.3724 - val_accuracy: 0.8490
Epoch 4/5
625/625 [==============================] - 229s 367ms/step - loss:
0.1210 - accuracy: 0.9577 - val_loss: 0.4116 - val_accuracy: 0.8626
Epoch 5/5
625/625 [==============================] - 229s 366ms/step - loss:
0.0937 - accuracy: 0.9678 - val_loss: 0.5145 - val_accuracy: 0.8646
782/782 [==============================] - 86s 111ms/step - loss:
0.5349 - accuracy: 0.8546
Test score: 0.5349175930023193
Test accuracy: 0.854640007019043
Downloading data from [Link]
keras-datasets/imdb_word_index.json
1641221/1641221 [==============================] - 1s 0us/step
1/1 [==============================] - 0s 478ms/step
Text: very bad movie.
Sentiment: Negative (Confidence: 0.38)

You might also like