Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

This git repository contains code and configurations for implementing a Convolutional Neural Network to classify images containing cats or dogs. The data was sourced from the [dogs-vs-cats](https://www.kaggle.com/competitions/dogs-vs-cats/overview) Kaggle competition, and also from [freeimages.com](https://www.freeimages.com/) using a web scraper.

Two models were trained to classify the images; an AlexNet8 model via Keras and a VGG16 model via Torch.
Three pretrained models were fine tuned to classify the images using PyTorch; AlexNet8, VGG16 and ResNet50.

Docker containers were used to deploy the application on an EC2 spot instances in order to scale up hardware and computation power.

Expand All @@ -16,15 +16,15 @@ The images were further normalised using rotations, scaling, zooming, flipping a

![Generator Plot](report/torch/generator_plot.jpg)

Models were trained across 10 to 25 epochs using stochastic gradient descent and cross entropy loss. Learning rate reduction on plateau and early stopping were implemented as part of training procedure.
The pretrained models were fine tuned across 10 epochs using stochastic gradient descent and cross entropy loss. Learning rate reduction on plateau and early stopping were implemented as part of training procedure.

![Predicted Images](report/torch/pred_images.jpg)

See the analysis results notebook for a further details on the analysis; including CNN architecture and model performance.

* https://nbviewer.org/github/oislen/CatClassifier/blob/main/report/torch_analysis_results.ipynb

Master serialised copies of the trainined models are available on Kaggle:
Master serialised copies of the fine tuned models are available on Kaggle:

* https://www.kaggle.com/models/oislen/cat-classifier-cnn-models

Expand Down
27 changes: 20 additions & 7 deletions model/arch/classify_image_torch.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@
import cons
from model.torch.VGG16_pretrained import VGG16_pretrained
from model.torch.CustomDataset import CustomDataset
from model.arch.load_image_v2 import TorchLoadImages
from model.utilities.TimeIt import TimeIt

# device configuration
device = torch.device('cuda' if torch.cuda.is_available() and cons.check_gpu else 'cpu')
Expand All @@ -45,13 +47,15 @@
])

@beartype
def classify_image_torch(image_fpath:str, model_fpath:str=cons.torch_model_pt_fpath):
def classify_image_torch(image_fpath:str, timeLogger:TimeIt, model_fpath:str=cons.torch_model_pt_fpath):
"""Classifies an input image using the torch model

Parameters
----------
image_fpath : str
The full filepath to the image to classify using the torch model
timeLogger : TimeIt
Timer object for logging execution time of process
model_fpath : str
The full filepath to the torch model to use for classification, default is cons.torch_model_pt_fpath

Expand All @@ -66,22 +70,29 @@ def classify_image_torch(image_fpath:str, model_fpath:str=cons.torch_model_pt_fp
#model = AlexNet8(num_classes=2).to(device)
model = VGG16_pretrained(num_classes=2).to(device)
model.load(input_fpath=model_fpath)
timeLogger.logTime(parentKey="Preparation", subKey="ModelLoad")

logging.info("Generating dataset...")
# prepare test data
dataframe = pd.DataFrame({'filepath': [image_fpath]})
torchLoadImages = TorchLoadImages(torch_transforms=torch_transforms, n_workers=None)
dataframe = pd.DataFrame.from_records(torchLoadImages.loadImages(filepaths=[image_fpath]))
dataframe["model_fpath"] = model_fpath
timeLogger.logTime(parentKey="Preparation", subKey="DataFrame")

logging.info("Creating dataloader...")
# set train data loader
dataset = CustomDataset(dataframe, transforms=torch_transforms, mode='test')
loader = DataLoader(dataset, batch_size=cons.batch_size, shuffle=False, num_workers=cons.num_workers, pin_memory=True)
dataset = CustomDataset(dataframe)
loader = DataLoader(dataset, batch_size=None, shuffle=False, num_workers=cons.num_workers, pin_memory=True, collate_fn=CustomDataset.collate_fn)
timeLogger.logTime(parentKey="Preparation", subKey="DataLoader")

logging.info("Classifying image...")
# make test set predictions
predict = model.predict(loader, device)
dataframe['category'] = np.argmax(predict, axis=-1)
dataframe["category"] = dataframe["category"].replace(cons.category_mapper)
response = dataframe.to_dict(orient="records")
dataframe["categoryname"] = dataframe["category"].replace(cons.category_mapper)
sub_cols = ["model_fpath", "filepaths", "categoryname"]
response = dataframe[sub_cols].to_dict(orient="records")
timeLogger.logTime(parentKey="Model", subKey="Classification")
logging.info(response)
return response

Expand All @@ -90,6 +101,7 @@ def classify_image_torch(image_fpath:str, model_fpath:str=cons.torch_model_pt_fp
# set up logging
lgr = logging.getLogger()
lgr.setLevel(logging.INFO)
timeLogger = TimeIt()

# define argument parser object
parser = argparse.ArgumentParser(description="Classify Image (Torch Model)")
Expand All @@ -100,5 +112,6 @@ def classify_image_torch(image_fpath:str, model_fpath:str=cons.torch_model_pt_fp
input_params_dict = {}
# extract input arguments
args = parser.parse_args()
timeLogger.logTime(parentKey="Initialisation", subKey="CommandlineArguments")
# classify image using torch model
response = classify_image_torch(image_fpath=args.image_fpath, model_fpath=args.model_fpath)
response = classify_image_torch(image_fpath=args.image_fpath, model_fpath=args.model_fpath, timeLogger=timeLogger)
17 changes: 8 additions & 9 deletions model/prg_torch_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@

# load custom scripts
import cons
from model.torch.ResNet50_pretrained import ResNet50_pretrained
from model.torch.VGG16_pretrained import VGG16_pretrained
from model.torch.AlexNet8 import AlexNet8
from model.torch.LeNet5 import LeNet5
from model.torch.AlexNet8_pretrained import AlexNet8_pretrained
from model.torch.CustomDataset import CustomDataset
from model.torch.EarlyStopper import EarlyStopper
from model.utilities.plot_model import plot_model_fit
Expand All @@ -33,6 +33,11 @@
# device configuration
device = torch.device('cuda' if torch.cuda.is_available() and cons.check_gpu else 'cpu')

# initialise model
#model = AlexNet8_pretrained(num_classes=2).to(device)
model = VGG16_pretrained(num_classes=2).to(device)
#model = ResNet50_pretrained(num_classes=2).to(device)

random_state = 42

torch_transforms = transforms.Compose([
Expand Down Expand Up @@ -65,7 +70,7 @@
image_filepaths=np.array([os.path.join(cons.train_fdir, x) for x in os.listdir(cons.train_fdir)])
np.random.shuffle(image_filepaths)
# create torch load images object
sample_size = 30000
sample_size = 20000
torchLoadImages = TorchLoadImages(torch_transforms=torch_transforms, n_workers=None)
df = pd.DataFrame.from_records(torchLoadImages.loadImages(image_filepaths[0:sample_size]))
# only consider images with 3 dimensions
Expand Down Expand Up @@ -116,9 +121,6 @@
logging.info("Initiate torch model...")
logging.info(f"device: {device}")
# initiate cnn architecture
#model = LeNet5(num_classes=2)
#model = AlexNet8(num_classes=2).to(device)
model = VGG16_pretrained(num_classes=2).to(device)
if device == "cuda":
model = nn.DataParallel(model)
model = model.to(device)
Expand Down Expand Up @@ -152,9 +154,6 @@

logging.info("Load fitted torch model from disk...")
# load model
#model = LeNet5(num_classes=2).to(device)
#model = AlexNet8(num_classes=2).to(device)
model = VGG16_pretrained(num_classes=2).to(device)
model.load(input_fpath=cons.torch_model_pt_fpath)
timeLogger.logTime(parentKey="ModelSerialisation", subKey="Load")

Expand Down
148 changes: 148 additions & 0 deletions model/torch/AlexNet8_pretrained.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import numpy as np
import torch
from torch import nn
from torchvision import models
# load custom modules
from model.torch.predict import predict as predict_module
from model.torch.save import save as save_module
from model.torch.load import load as load_module
from model.torch.fit import fit as fit_module
from model.torch.validate import validate as validate_module
from model.torch.EarlyStopper import EarlyStopper
from typing import Union
from beartype import beartype

class AlexNet8_pretrained(nn.Module):
def __init__(self, num_classes=1000):
super(AlexNet8_pretrained, self).__init__()
self.model_id = "AlexNet8_pretrained"
self.alexnet = models.alexnet(weights ="DEFAULT")
self.num_ftrs = self.alexnet.classifier[len(self.alexnet.classifier)-1].out_features
self.classifier = nn.Sequential(nn.Linear(in_features=self.num_ftrs, out_features=num_classes))

@beartype
def forward(self, x):
"""Applies a forward pass across an array x

Parameters
----------
x : array
The array to apply a forward pass to

Returns
-------
array
The output array from the forward pass
"""
x = self.alexnet(x)
x = self.classifier(x)
return x

@beartype
def fit(self, device:torch.device, criterion:torch.nn.CrossEntropyLoss, optimizer:torch.optim.SGD, train_dataloader:torch.utils.data.DataLoader, num_epochs:int=4, scheduler:Union[torch.optim.lr_scheduler.ReduceLROnPlateau,None]=None, valid_dataLoader:Union[torch.utils.data.DataLoader,None]=None, early_stopper:Union[EarlyStopper,None]=None, checkpoints_dir:Union[str,None]=None, load_epoch_checkpoint:Union[int,None]=None):
"""Fits model to specified data loader given the criterion and optimizer

Parameters
----------
device : torch.device
The torch device to use when fitting the model
criterion : torch.nn.CrossEntropyLoss
The criterion to use when fitting the model
optimizer : torch.optim.SGD
The torch optimizer to use when fitting the model
train_dataloader : torch.utils.data.DataLoader
The torch data loader to use when fitting the model
num_epochs : int
The number of training epochs, default is 4
scheduler : torch.optim.lr_scheduler.ReduceLROnPlateau
The torch scheduler to use when fitting the model, default is None
valid_dataLoader : torch.utils.data.DataLoader
The torch data loader to use for validation when fitting the model, default is None
early_stopper : EarlyStopper
The EarlyStopper object for halting fitting when performing validation, default is None
checkpoints_dir : str
The local folder location where model epoch checkpoints are to be read and wrote to, default is None
load_epoch_checkpoint : int
The epoch checkpoint to load and start from, default is None

Returns
-------
"""
self, self.model_fit = fit_module(self, device, criterion, optimizer, train_dataloader, num_epochs, scheduler, valid_dataLoader, early_stopper, checkpoints_dir, load_epoch_checkpoint)

@beartype
def validate(self, device:torch.device, dataloader:torch.utils.data.DataLoader, criterion:torch.nn.CrossEntropyLoss) -> tuple:
"""Calculates validation loss and accuracy

Parameters
----------
device : torch.device
The torch device to use when fitting the model
dataloader : torch.utils.data.DataLoader
The torch data loader to use when fitting the model
criterion : torch.nn.CrossEntropyLoss
The criterion to use when fitting the model

Returns
-------
tuple
The validation loss and accuracy
"""
valid_loss, valid_acc = validate_module(self, device, dataloader, criterion)
return (valid_loss, valid_acc)

@beartype
def predict(self, dataloader:torch.utils.data.DataLoader, device:torch.device) -> np.ndarray:
"""Predicts probabilities for a given data loader

Parameters
----------
dataloader : torch.utils.data.DataLoader
The torch data loader to use when fitting the model
device : torch.device
The torch device to use when fitting the model

Returns
-------
np.ndarry
The dataloader target probabilities
"""
proba = predict_module(self, dataloader, device)
return proba


@beartype
def save(self, output_fpath:str) -> str:
"""Writes a torch model to disk as a file

Parameters
----------
output_fpath : str
The output file location to write the torch model to disk

Returns
-------
str
The model data message status
"""
msg = save_module(self, output_fpath)
return msg

@beartype
def load(self, input_fpath:str, weights_only:bool=False) -> str:
"""Loads a torch model from disk as a file

Parameters
----------
input_fpath : str
The input file location to load the torch model from disk
weights_only : bool
Whether loading just the model weights or the full serialised model object, default is False

Returns
-------
str
The load model message status
"""
msg = load_module(self, input_fpath, weights_only=weights_only)
return msg
2 changes: 1 addition & 1 deletion model/torch/CustomDataset.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ def __len__(self):
return len(self.image_tensors)

def __getitem__(self, idx):
image_tensor = self.image_tensors[idx]
image_tensor = self.image_tensors[idx].unsqueeze(0)
category_tensor = self.category_tensors[idx]
return image_tensor, category_tensor

Expand Down
2 changes: 1 addition & 1 deletion model/torch/ResNet50_pretrained.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ class ResNet50_pretrained(nn.Module):
def __init__(self, num_classes=1000):
self.model_id = "ResNet50_pretrained"
super(ResNet50_pretrained, self).__init__()
self.resnet = models.resnet50(pretrained=True)
self.resnet = models.resnet50(weights ="DEFAULT")
self.num_ftrs = self.resnet.fc.out_features
self.classifier = nn.Sequential(nn.Linear(in_features=self.num_ftrs, out_features=num_classes))

Expand Down
3 changes: 2 additions & 1 deletion webscrapers/cons.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,10 @@
del_zip = True

# set kaggle model detailes
model_instance_url="oislen/cat-classifier-cnn-models/pyTorch/default/1"
model_instance_url="oislen/cat-classifier-cnn-models/pyTorch/default/2"

# webscraping constants
n_images = 6000
ncpu = os.cpu_count()-1
home_url = "https://free-images.com"
output_dir = os.path.join(data_fdir, "{search}")
6 changes: 4 additions & 2 deletions webscrapers/prg_scrape_imgs.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,15 +50,17 @@ def scrape_imags(
search="cat",
n_images=cons.n_images,
home_url=cons.home_url,
output_dir=cons.train_fdir
output_dir=cons.train_fdir,
ncpu=cons.ncpu
)
logging.info("Running dog image webscraper ...")
# run dog webscraper
webscraper(
search="dog",
n_images=cons.n_images,
home_url=cons.home_url,
output_dir=cons.train_fdir
output_dir=cons.train_fdir,
ncpu=cons.ncpu
)

# if running as main programme
Expand Down
2 changes: 1 addition & 1 deletion webscrapers/utilities/download_comp_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ def download_comp_data(
# if deleting zip file
if del_zip == True:
for zip_fpath in zip_fpaths_list:
logging.info("deleting zip file {zip_fpath} ...")
logging.info(f"deleting zip file {zip_fpath} ...")
os.remove(path = zip_fpath)

@beartype
Expand Down
Loading