|
|
|
"""S23_2.ipynb |
|
|
|
Automatically generated by Colab. |
|
|
|
Original file is located at |
|
https://colab.research.google.com/drive/1IOiMjJWrW8-JVMSNl5i77P6GjjuHiJ1i |
|
|
|
## Introduction |
|
|
|
#### I've also written a **TowardsDataScience** Blog post explaining my project. All the explanations are avialbe in the markdowns of this notebook too. |
|
|
|
It was in January of 2021 that **OpenAI** announced two new models: **DALL-E** and **CLIP**, both **multi-modality** models connecting **texts and images** in some way. In this article we are going to implement CLIP model from scratch in **PyTorch**. OpenAI has open-sourced some of the code relating to CLIP model but I found it intimidating and it was far from something short and simple. I also came across a good tutorial inspired by CLIP model on Keras code examples and I translated some parts of it into PyTorch to build this tutorial totally with our beloved PyTorch! |
|
|
|
### What does CLIP do? Why is it fun? |
|
|
|
In [Learning Transferable Visual Models From Natural Language Supervision paper](https://arxiv.org/abs/2103.00020), OpenAI introduces their new model which is called **CLIP**, for **Contrastive Language-Image Pre-training**. In a nutshell, this model learns the relationship between a whole sentence and the image it describes; in a sense that when the model is trained, given an input sentence it will be able to retrieve the most related images corresponding to that sentence. The important thing here is that it is trained on full sentences instead of single classes like car, dog, etc. The intuition is that when trained on whole sentences, the model can learn a lot more things and finds some pattern between images and texts. |
|
They also show that when this model is trained on a huge dataset of images and their corresponding texts, it can also act as a classifier too. I encourage you to study the paper to learn more about this exciting model and their astonishing results on benchmarking datasets . To mention just one, CLIP model trained with this strategy classifies ImageNet better than those SOTA models trained on the ImageNet itself optimized for the only task of classification! |
|
|
|
As a **teaser** (!), let's see what the final model that we will build in this article from scratch is capable of: given a query (raw text) like "a boy jumping with skateboard" or "a girl jumping from swing", the model will retrieve the most relevant images: |
|
|
|
![](https://i.ibb.co/9gdYqNP/teaser-cropped.png) |
|
""" |
|
|
|
import os |
|
import cv2 |
|
import gc |
|
import numpy as np |
|
import pandas as pd |
|
import itertools |
|
from tqdm.autonotebook import tqdm |
|
import albumentations as A |
|
import matplotlib.pyplot as plt |
|
|
|
import torch |
|
from torch import nn |
|
import torch.nn.functional as F |
|
import timm |
|
from transformers import DistilBertModel, DistilBertConfig, DistilBertTokenizer |
|
|
|
|
|
|
|
"""## Config |
|
|
|
*A note on config and CFG: I wrote the codes with python scripts and then converted it into a Jupyter Notebook. So, in case of python scripts, config is a normal python file where I put all the hyperparameters and in the case of Jupyter Notebook, its a class defined in the beginning of the notebook to keep all the hyperparameters.* |
|
""" |
|
|
|
image_path = "./Images" |
|
captions_path = "." |
|
|
|
class CFG: |
|
debug = False |
|
image_path = image_path |
|
captions_path = captions_path |
|
batch_size = 32 |
|
num_workers = 2 |
|
head_lr = 1e-3 |
|
image_encoder_lr = 1e-4 |
|
text_encoder_lr = 1e-5 |
|
weight_decay = 1e-3 |
|
patience = 1 |
|
factor = 0.8 |
|
epochs = 4 |
|
device = torch.device("cuda" if torch.cuda.is_available() else "cpu") |
|
|
|
model_name = 'resnet50' |
|
image_embedding = 2048 |
|
text_encoder_model = "distilbert-base-uncased" |
|
text_embedding = 768 |
|
text_tokenizer = "distilbert-base-uncased" |
|
max_length = 200 |
|
|
|
pretrained = True |
|
trainable = True |
|
temperature = 1.0 |
|
|
|
|
|
size = 224 |
|
|
|
|
|
num_projection_layers = 1 |
|
projection_dim = 256 |
|
dropout = 0.1 |
|
|
|
"""## Utils""" |
|
|
|
class AvgMeter: |
|
def __init__(self, name="Metric"): |
|
self.name = name |
|
self.reset() |
|
|
|
def reset(self): |
|
self.avg, self.sum, self.count = [0] * 3 |
|
|
|
def update(self, val, count=1): |
|
self.count += count |
|
self.sum += val * count |
|
self.avg = self.sum / self.count |
|
|
|
def __repr__(self): |
|
text = f"{self.name}: {self.avg:.4f}" |
|
return text |
|
|
|
def get_lr(optimizer): |
|
for param_group in optimizer.param_groups: |
|
return param_group["lr"] |
|
|
|
"""## Dataset |
|
|
|
As you can see in the tittle image of this article, we need to encode both images and their describing texts. So, the dataset needs to **return both images and texts**. Of course we are not going to feed raw text to our text encoder! We will use **DistilBERT** model (which is smaller than BERT but performs nearly as well as BERT) from **HuggingFace** library as our text encoder; so, we need to **tokenize** the sentences (captions) with DistilBERT tokenizer and then feed the token ids (input_ids) and the attention masks to DistilBERT. Therefore, the dataset needs to take care of the tokenization as well. Below you can see the dataset's code. Below that I'll explain the most important things that is happening in the code. |
|
|
|
In the **\_\_init\_\_** we receive a tokenizer object which is actually a HuggingFace tokinzer; this tokenizer will be loaded when running the model. We are padding and truncating the captions to a specified max_length. In the **\_\_getitem\_\_** we will first load an encoded caption which is a dictionary with keys input_ids and attention_mask, make tensors out of its values and after that we will load the corresponding image, transform and augment it (if there is any!) and then we make it a tensor and put it in the dictionary with "image" as the key. Finally we put the raw text of the caption with the key "caption" in the dictionary only for visualization purposes. |
|
|
|
I did not use additional data augmentations but you can add them if you want to improve the model's performance. |
|
""" |
|
|
|
class CLIPDataset(torch.utils.data.Dataset): |
|
def __init__(self, image_filenames, captions, tokenizer, transforms): |
|
""" |
|
image_filenames and cpations must have the same length; so, if there are |
|
multiple captions for each image, the image_filenames must have repetitive |
|
file names |
|
""" |
|
|
|
self.image_filenames = image_filenames |
|
self.captions = list(captions) |
|
self.encoded_captions = tokenizer( |
|
list(captions), padding=True, truncation=True, max_length=CFG.max_length |
|
) |
|
self.transforms = transforms |
|
|
|
def __getitem__(self, idx): |
|
item = { |
|
key: torch.tensor(values[idx]) |
|
for key, values in self.encoded_captions.items() |
|
} |
|
|
|
image = cv2.imread(f"{CFG.image_path}/{self.image_filenames[idx]}") |
|
image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) |
|
image = self.transforms(image=image)['image'] |
|
item['image'] = torch.tensor(image).permute(2, 0, 1).float() |
|
item['caption'] = self.captions[idx] |
|
|
|
return item |
|
|
|
|
|
def __len__(self): |
|
return len(self.captions) |
|
|
|
|
|
|
|
def get_transforms(mode="train"): |
|
if mode == "train": |
|
return A.Compose( |
|
[ |
|
A.Resize(CFG.size, CFG.size, always_apply=True), |
|
A.Normalize(max_pixel_value=255.0, always_apply=True), |
|
] |
|
) |
|
else: |
|
return A.Compose( |
|
[ |
|
A.Resize(CFG.size, CFG.size, always_apply=True), |
|
A.Normalize(max_pixel_value=255.0, always_apply=True), |
|
] |
|
) |
|
|
|
"""## Image Encoder |
|
|
|
The image encoder code is straight forward. I'm using PyTorch Image Models library (timm) here which makes a lot of different image models available from ResNets to EfficientNets and many more. Here we will use a ResNet50 as our image encoder. You can easily use torchvision library to use ResNets if you don't want to install a new library. |
|
|
|
The code encodes each image to a fixed size vector with the size of the model's output channels (in case of ResNet50 the vector size will be **2048**). This is the output after the nn.AdaptiveAvgPool2d() layer. |
|
""" |
|
|
|
class ImageEncoder(nn.Module): |
|
""" |
|
Encode images to a fixed size vector |
|
""" |
|
|
|
def __init__( |
|
self, model_name=CFG.model_name, pretrained=CFG.pretrained, trainable=CFG.trainable |
|
): |
|
super().__init__() |
|
self.model = timm.create_model( |
|
model_name, pretrained, num_classes=0, global_pool="avg" |
|
) |
|
for p in self.model.parameters(): |
|
p.requires_grad = trainable |
|
|
|
def forward(self, x): |
|
return self.model(x) |
|
|
|
"""## Text Encoder |
|
|
|
As I mentioned before, I'll use DistilBERT as the text encoder. Like its bigger brother BERT, two special tokens will be added to the actual input tokens: **CLS** and **SEP** which mark the start and end of a sentence. To grab the whole representation of a sentence (as the related BERT and DistilBERT papers point out) we use the final representations of the CLS token and we hope that this representation captures the overall meaning of the sentence (caption). Thinking it in this way, it is similar to what we did to images and converted them into a fixed size vector. |
|
|
|
In the case of DistilBERT (and also BERT) the output hidden representation for each token is a vector with size **768**. So, the whole caption will be encoded in the CLS token representation whose size is 768. |
|
""" |
|
|
|
class TextEncoder(nn.Module): |
|
def __init__(self, model_name=CFG.text_encoder_model, pretrained=CFG.pretrained, trainable=CFG.trainable): |
|
super().__init__() |
|
if pretrained: |
|
self.model = DistilBertModel.from_pretrained(model_name) |
|
else: |
|
self.model = DistilBertModel(config=DistilBertConfig()) |
|
|
|
for p in self.model.parameters(): |
|
p.requires_grad = trainable |
|
|
|
|
|
self.target_token_idx = 0 |
|
|
|
def forward(self, input_ids, attention_mask): |
|
output = self.model(input_ids=input_ids, attention_mask=attention_mask) |
|
last_hidden_state = output.last_hidden_state |
|
return last_hidden_state[:, self.target_token_idx, :] |
|
|
|
"""## Projection Head |
|
|
|
I used [Keras code example implementation](https://keras.io/examples/nlp/nl_image_search/) of projection head to write the following in PyTorch. |
|
Now that we have encoded both our images and texts into fixed size vectors (2048 for image and 768 for text) we need to bring (project) them into a **new world** (!) with **similar dimensions** for both images and texts in order to be able to compare them and push apart the non-relevant image and texts and pull together those that match. So, the following code will bring the 2048 and 768 dimensional vectors into a 256 (projection_dim) dimensional world, where we can **compare** them. |
|
|
|
"embedding_dim" is the size of the input vector (2048 for images and 768 for texts) and "projection_dim" is the the size of the output vector which will be 256 for our case. For understanding the details of this part you can refer to the CLIP paper. |
|
""" |
|
|
|
class ProjectionHead(nn.Module): |
|
def __init__( |
|
self, |
|
embedding_dim, |
|
projection_dim=CFG.projection_dim, |
|
dropout=CFG.dropout |
|
): |
|
super().__init__() |
|
self.projection = nn.Linear(embedding_dim, projection_dim) |
|
self.gelu = nn.GELU() |
|
self.fc = nn.Linear(projection_dim, projection_dim) |
|
self.dropout = nn.Dropout(dropout) |
|
self.layer_norm = nn.LayerNorm(projection_dim) |
|
|
|
def forward(self, x): |
|
projected = self.projection(x) |
|
x = self.gelu(projected) |
|
x = self.fc(x) |
|
x = self.dropout(x) |
|
x = x + projected |
|
x = self.layer_norm(x) |
|
return x |
|
|
|
"""## CLIP |
|
|
|
This part is where all the fun happens! I'll also talk about the loss function here. I translated some of the code from Keras code examples into PyTorch for writing this part. Take a look at the code and then read the explanation below this code block. |
|
|
|
Here we will use the previous modules that we built to implement the main model. The \_\_init\_\_ function is self-explanatory. In the forward function, we first encode the images and texts separately into fixed size vectors (with different dimensionalities). After that, using separate projection modules we project them to that shared world (space) that I talked about previously. Here the encodings will become of similar shape (256 in our case). After that we will compute the loss. Again I recommend reading CLIP paper to get it better but I'll try my best to explain this part. |
|
|
|
In **Linear Algebra**, one common way to measure if two vectors are of similar characteristics (they are like each other) is to calculate their **dot product** (multiplying the matching entries and take the sum of them); if the final number is big, they are alike and if it is small they are not (relatively speaking)! |
|
|
|
Okay! What I just said is the most important thing to have in mind to understand this loss function. Let's continue. We talked about two vectors, but, what do we have here? We have image_embeddings, a matrix with shape (batch_size, 256) and text_embeddings with shape (batch_size, 256). Easy enough! it means we have two groups of vectors instead of two single vectors. How do we measure how similar two groups of vectors (two matrices) are to each other? Again, with dot product (@ operator in PyTorch does the dot product or matrix multiplication in this case). To be able to multiply these two matrices together, we transpose the second one. Okay, we get a matrix with shape (batch_size, batch_size) which we will call logits. (temperature is equal to 1.0 in our case, so, it does not make a difference. You can play with it and see what difference it makes. Also look at the paper to see why it is here!). |
|
|
|
I hope you are still with me! If not it's okay, just review the code and check their shapes. Now that we have our logits, we need targets. I need to say that there is a more straight forward way to obtain targets but I had to do this for our case (I'll talk about why in a next paragraph). |
|
|
|
Let's consider what we hope that this model learns: **we want it to learn "similar representations (vectors)" for a given image and the caption describing it. Meaning that either we give it an image or the text describing it, we want it to produce same 256 sized vectors for both.** |
|
|
|
#### Check the cell below this code block for the continue of the explanations |
|
""" |
|
|
|
class CLIPModel(nn.Module): |
|
def __init__( |
|
self, |
|
temperature=CFG.temperature, |
|
image_embedding=CFG.image_embedding, |
|
text_embedding=CFG.text_embedding, |
|
): |
|
super().__init__() |
|
self.image_encoder = ImageEncoder() |
|
self.text_encoder = TextEncoder() |
|
self.image_projection = ProjectionHead(embedding_dim=image_embedding) |
|
self.text_projection = ProjectionHead(embedding_dim=text_embedding) |
|
self.temperature = temperature |
|
|
|
def forward(self, batch): |
|
|
|
image_features = self.image_encoder(batch["image"]) |
|
text_features = self.text_encoder( |
|
input_ids=batch["input_ids"], attention_mask=batch["attention_mask"] |
|
) |
|
|
|
image_embeddings = self.image_projection(image_features) |
|
text_embeddings = self.text_projection(text_features) |
|
|
|
|
|
logits = (text_embeddings @ image_embeddings.T) / self.temperature |
|
images_similarity = image_embeddings @ image_embeddings.T |
|
texts_similarity = text_embeddings @ text_embeddings.T |
|
targets = F.softmax( |
|
(images_similarity + texts_similarity) / 2 * self.temperature, dim=-1 |
|
) |
|
texts_loss = cross_entropy(logits, targets, reduction='none') |
|
images_loss = cross_entropy(logits.T, targets.T, reduction='none') |
|
loss = (images_loss + texts_loss) / 2.0 |
|
return loss.mean() |
|
|
|
|
|
def cross_entropy(preds, targets, reduction='none'): |
|
log_softmax = nn.LogSoftmax(dim=-1) |
|
loss = (-targets * log_softmax(preds)).sum(1) |
|
if reduction == "none": |
|
return loss |
|
elif reduction == "mean": |
|
return loss.mean() |
|
|
|
"""So, in the best case scenario, text_embeddings and image_embedding matricies should be the same because they are describing similar things. Let's think now: if this happens, what would the logits matrix be like? Let's see with a simple example!""" |
|
|
|
|
|
|
|
"""So logits, in the best case, will be a matrix that if we take its softmax, will have 1.0s in the diagonal (An identity matrix to call it with fancy words!). As the loss function's job is to make model's predictions similar to targets (at least in most cases!), we want such a matrix as our target. That's the reason why we are calculating images_similarity and texts_similarity matrices in the code block above. |
|
|
|
Now that we've got our targets matrix, we will use simple cross entropy to calculate the actual loss. I've written the full matrix form of cross entropy as a function which you can see in the bottom of the code block. Okay! We are done! Wasn't it simple?! Alright, you can ignore the next paragraph but if you are curious, there is an important note in that. |
|
|
|
**Here's why I didn't use a simpler approach**: I need to admit that there's a simpler way to calculate this loss in PyTorch; by doing this: nn.CrossEntropyLoss()(logits, torch.arange(batch_size)). Why I did not use it here? For 2 reasons. 1- The dataset we are using has multiple captions for a single image; so, there is the possibility that two identical images with their similar captions exist in a batch (it is rare but it can happen). Taking the loss with this easier method will ignore this possibility and the model learns to pull apart two representations (assume them different) that are actually the same. Obviously, we don't want this to happen so I calculated the whole target matrix in a way that takes care of these edge cases. 2- Doing it the way I did, gave me a better understanding of what is happening in this loss function; so, I thought it would give you a better intuition as well! |
|
|
|
## Train |
|
|
|
Here are some funtions to help us load train and valid dataloaders, our model and then train and evaluate our model on those. There's not much going on here; just simple training loop and utility functions |
|
""" |
|
|
|
def make_train_valid_dfs(): |
|
dataframe = pd.read_csv(f"{CFG.captions_path}/captions.csv") |
|
max_id = dataframe["id"].max() + 1 if not CFG.debug else 100 |
|
image_ids = np.arange(0, max_id) |
|
np.random.seed(42) |
|
valid_ids = np.random.choice( |
|
image_ids, size=int(0.2 * len(image_ids)), replace=False |
|
) |
|
train_ids = [id_ for id_ in image_ids if id_ not in valid_ids] |
|
train_dataframe = dataframe[dataframe["id"].isin(train_ids)].reset_index(drop=True) |
|
valid_dataframe = dataframe[dataframe["id"].isin(valid_ids)].reset_index(drop=True) |
|
return train_dataframe, valid_dataframe |
|
|
|
|
|
def build_loaders(dataframe, tokenizer, mode): |
|
transforms = get_transforms(mode=mode) |
|
dataset = CLIPDataset( |
|
dataframe["image"].values, |
|
dataframe["caption"].values, |
|
tokenizer=tokenizer, |
|
transforms=transforms, |
|
) |
|
dataloader = torch.utils.data.DataLoader( |
|
dataset, |
|
batch_size=CFG.batch_size, |
|
num_workers=CFG.num_workers, |
|
shuffle=True if mode == "train" else False, |
|
) |
|
return dataloader |
|
|
|
"""Here's a handy function to train our model. There's not much happening here; just loading the batches, feeding them to the model and stepping the optimizer and lr_scheduler.""" |
|
|
|
def train_epoch(model, train_loader, optimizer, lr_scheduler, step): |
|
loss_meter = AvgMeter() |
|
tqdm_object = tqdm(train_loader, total=len(train_loader)) |
|
for batch in tqdm_object: |
|
batch = {k: v.to(CFG.device) for k, v in batch.items() if k != "caption"} |
|
loss = model(batch) |
|
optimizer.zero_grad() |
|
loss.backward() |
|
optimizer.step() |
|
if step == "batch": |
|
lr_scheduler.step() |
|
|
|
count = batch["image"].size(0) |
|
loss_meter.update(loss.item(), count) |
|
|
|
tqdm_object.set_postfix(train_loss=loss_meter.avg, lr=get_lr(optimizer)) |
|
return loss_meter |
|
|
|
|
|
def valid_epoch(model, valid_loader): |
|
loss_meter = AvgMeter() |
|
|
|
tqdm_object = tqdm(valid_loader, total=len(valid_loader)) |
|
for batch in tqdm_object: |
|
batch = {k: v.to(CFG.device) for k, v in batch.items() if k != "caption"} |
|
loss = model(batch) |
|
|
|
count = batch["image"].size(0) |
|
loss_meter.update(loss.item(), count) |
|
|
|
tqdm_object.set_postfix(valid_loss=loss_meter.avg) |
|
return loss_meter |
|
|
|
|
|
"""## Inference |
|
|
|
Okay! We are done with training the model. Now, we need to do inference which in our case will be giving the model a piece of text and want it to retrieve the most relevant images from an unseen validation (or test) set. |
|
|
|
### Getting Image Embeddings |
|
|
|
In this function, we are loading the model that we saved after training, feeding it images in validation set and returning the image_embeddings with shape (valid_set_size, 256) and the model itself. |
|
""" |
|
|
|
def get_image_embeddings(valid_df, model_path): |
|
tokenizer = DistilBertTokenizer.from_pretrained(CFG.text_tokenizer) |
|
valid_loader = build_loaders(valid_df, tokenizer, mode="valid") |
|
|
|
model = CLIPModel().to(CFG.device) |
|
model.load_state_dict(torch.load(model_path, map_location=CFG.device, weights_only=True)) |
|
model.eval() |
|
|
|
valid_image_embeddings = [] |
|
with torch.no_grad(): |
|
for batch in tqdm(valid_loader): |
|
image_features = model.image_encoder(batch["image"].to(CFG.device)) |
|
image_embeddings = model.image_projection(image_features) |
|
valid_image_embeddings.append(image_embeddings) |
|
return model, torch.cat(valid_image_embeddings) |
|
|
|
"""### Finding Matches |
|
|
|
This function does the final task that we wished our model would be capable of: it gets the model, image_embeddings, and a text query. It will display the most relevant images from the validation set! Isn't it amazing? Let's see how it performs after all! |
|
""" |
|
|
|
def find_matches(model, image_embeddings, query, image_filenames, n=9): |
|
tokenizer = DistilBertTokenizer.from_pretrained(CFG.text_tokenizer) |
|
encoded_query = tokenizer([query]) |
|
batch = { |
|
key: torch.tensor(values).to(CFG.device) |
|
for key, values in encoded_query.items() |
|
} |
|
with torch.no_grad(): |
|
text_features = model.text_encoder( |
|
input_ids=batch["input_ids"], attention_mask=batch["attention_mask"] |
|
) |
|
text_embeddings = model.text_projection(text_features) |
|
|
|
image_embeddings_n = F.normalize(image_embeddings, p=2, dim=-1) |
|
text_embeddings_n = F.normalize(text_embeddings, p=2, dim=-1) |
|
dot_similarity = text_embeddings_n @ image_embeddings_n.T |
|
|
|
values, indices = torch.topk(dot_similarity.squeeze(0), n * 5) |
|
matches = [image_filenames[idx] for idx in indices[::5]] |
|
|
|
images = [] |
|
_, axes = plt.subplots(3, 3, figsize=(10, 10)) |
|
for match, ax in zip(matches, axes.flatten()): |
|
image = cv2.imread(f"{CFG.image_path}/{match}") |
|
image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) |
|
images.append(image) |
|
return images |
|
|
|
|
|
"""This is how we use this function. Aaaannnndddd the results: |
|
(The results in the blog post and the one at the beginning of the notebook were achieved with training on the 30k version) |
|
""" |
|
|
|
|
|
|
|
def inference_CLIP(query_text): |
|
_, valid_df = make_train_valid_dfs() |
|
model, image_embeddings = get_image_embeddings(valid_df, "best.pt") |
|
return find_matches(model, |
|
image_embeddings, |
|
query=query_text, |
|
|
|
image_filenames=valid_df['image'].values, |
|
n=9) |
|
|
|
|
|
|
|
|
|
"""## Final words |
|
|
|
I hope you have enjoyed this article. Implementing this paper was a really interesting experience for me. I want to thank Khalid Salama for the great Keras code example he provided which inspired me to write something similar in PyTorch. |
|
""" |