diff --git a/README.md b/README.md index 0d23333..95c094e 100644 --- a/README.md +++ b/README.md @@ -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. @@ -16,7 +16,7 @@ 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) @@ -24,7 +24,7 @@ See the analysis results notebook for a further details on the analysis; includi * 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 diff --git a/model/arch/classify_image_torch.py b/model/arch/classify_image_torch.py index b794307..f730ec0 100644 --- a/model/arch/classify_image_torch.py +++ b/model/arch/classify_image_torch.py @@ -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') @@ -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 @@ -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 @@ -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)") @@ -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) \ No newline at end of file + response = classify_image_torch(image_fpath=args.image_fpath, model_fpath=args.model_fpath, timeLogger=timeLogger) \ No newline at end of file diff --git a/model/prg_torch_model.py b/model/prg_torch_model.py index 8941c43..3c762bf 100644 --- a/model/prg_torch_model.py +++ b/model/prg_torch_model.py @@ -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 @@ -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([ @@ -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 @@ -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) @@ -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") diff --git a/model/torch/AlexNet8_pretrained.py b/model/torch/AlexNet8_pretrained.py new file mode 100644 index 0000000..295887b --- /dev/null +++ b/model/torch/AlexNet8_pretrained.py @@ -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 \ No newline at end of file diff --git a/model/torch/CustomDataset.py b/model/torch/CustomDataset.py index 5084cb4..ebf32ca 100644 --- a/model/torch/CustomDataset.py +++ b/model/torch/CustomDataset.py @@ -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 diff --git a/model/torch/ResNet50_pretrained.py b/model/torch/ResNet50_pretrained.py index be33d6b..85a1f2d 100644 --- a/model/torch/ResNet50_pretrained.py +++ b/model/torch/ResNet50_pretrained.py @@ -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)) diff --git a/webscrapers/cons.py b/webscrapers/cons.py index 006d829..a91605c 100644 --- a/webscrapers/cons.py +++ b/webscrapers/cons.py @@ -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}") \ No newline at end of file diff --git a/webscrapers/prg_scrape_imgs.py b/webscrapers/prg_scrape_imgs.py index 1230038..ba9f239 100644 --- a/webscrapers/prg_scrape_imgs.py +++ b/webscrapers/prg_scrape_imgs.py @@ -50,7 +50,8 @@ 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 @@ -58,7 +59,8 @@ def scrape_imags( 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 diff --git a/webscrapers/utilities/download_comp_data.py b/webscrapers/utilities/download_comp_data.py index c46b524..9e0687d 100644 --- a/webscrapers/utilities/download_comp_data.py +++ b/webscrapers/utilities/download_comp_data.py @@ -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 diff --git a/webscrapers/utilities/webscraper.py b/webscrapers/utilities/webscraper.py index c2a43a7..9512633 100644 --- a/webscrapers/utilities/webscraper.py +++ b/webscrapers/utilities/webscraper.py @@ -7,6 +7,7 @@ from bs4 import BeautifulSoup from multiprocessing import Pool from beartype import beartype +from typing import Union import cons @beartype @@ -101,7 +102,7 @@ def download_src(src:str, output_dir:str, search:str): logging.error(e) @beartype -def multiprocess(func, args, ncpu:int=os.cpu_count()) -> list: +def multiprocess(func, args, ncpu:int) -> list: """This utility function applyies another function in parallel given a specified number of cpus Parameters @@ -111,7 +112,7 @@ def multiprocess(func, args, ncpu:int=os.cpu_count()) -> list: args : dict The arguments to pass to the function ncpu : int - The number of cpus to use for parallel processing, default is os.cpu_count() + The number of cpus to use for parallel processing Returns ------- @@ -124,7 +125,7 @@ def multiprocess(func, args, ncpu:int=os.cpu_count()) -> list: return results @beartype -def webscraper(search:str, n_images:int=cons.n_images, home_url:str=cons.home_url, output_dir:str=cons.train_fdir): +def webscraper(search:str, n_images:int=cons.n_images, home_url:str=cons.home_url, output_dir:str=cons.train_fdir, ncpu:Union[int,None]=None): """The main beautiful soup webscrapping programme Parameters @@ -137,6 +138,8 @@ def webscraper(search:str, n_images:int=cons.n_images, home_url:str=cons.home_ur The url for the home page to web scrape from, default is cons.home_url output_dir : str The output file directory to download the scraped images to, default is cons.train_fdir + ncpu : int + The number of cpus to use for parallel processing, default is None Returns ------- @@ -151,4 +154,8 @@ def webscraper(search:str, n_images:int=cons.n_images, home_url:str=cons.home_ur # run function and scrape srcs srcs = scrape_srcs(urls=urls, n_images=n_images, home_url=home_url) # run function to download src - multiprocess(download_src, [(src, output_dir, search) for src in srcs]) \ No newline at end of file + if ncpu == None: + for src in srcs: + download_src(src=src,output_dir=output_dir,search=search) + else: + multiprocess(func=download_src, args=[(src, output_dir, search) for src in srcs], ncpu=ncpu) \ No newline at end of file