library("torch") # v0.13.0
library("torchvision") #v0.6.0
library("luz") #v0.4.0 makes it "easier" to use "torch"
library("magick") #2.8.5.
## Linking to ImageMagick 6.9.11.60
## Enabled features: fontconfig, freetype, fftw, heic, lcms, pango, webp, x11
## Disabled features: cairo, ghostscript, raw, rsvg
## Using 8 threads
# Custom functions for viewing images and some of their details:
# view_random_image() and view_random_torch_sample()
source("utils.R")
NOTE: if your PC has CUDA compatible GPU, then most likely torch has been installed in a way
that it will run with GPU. Otherwise, it will run with CPU (slower). On Mac OS, if you later get a warning message about
MPS device, follow the instructions (involves setting a variable in
.Renviron file)
Here, we will use a dataset of 2000 image: arrows and vases (“pil” and “kar”)
# For running in Rstudio
view_random_image(data_dir="../torch_data/images/")
view_random_image(data_dir="../torch_data/images/", colour=TRUE)
# For knitting to Rmarkdown HTML document
# knitr::include_graphics(view_random_image(data_dir="../torch_data/images/", rmarkdown = TRUE))
# knitr::include_graphics(view_random_image(data_dir="../torch_data/images", rmarkdown = TRUE, colour=TRUE))
Columns used: “Gjenstand” and “Filnavn”
For coding simplicity in R, have ensured that each existing JPG image
is present in the metadata.csv file
metadata <- read.csv("../torch_data/metadata.csv")
print(head(metadata))
## C.Nr Gjenstand Aksesjonsnr Filnavn Fotoark_Id
## 1 C36679 kar cf21076_06.jpg 3580
## 2 C27765 kar cf21046_03.jpg 3588
## 3 C21303 kar cf21706_01.jpg 3614
## 4 C21945 kar cf21417_11.jpg 3730
## 5 C16688 kar cf03093_c15280_c16688a.jpg 3737
## 6 C24127 kar cf21417_17.jpg 3840
## Motivbeskrivelse
## 1 Leirkar
## 2 Leirkar
## 3 Leirkar
## 4 Enkelt-gjenstander
## 5 To leirkar med hank
## 6 Enkelt-gjenstander
# View(metadata)
Define torch::dataset with necessary preprocessed images and target labels (an R6 object)
create_musit_dataset <- torch::dataset(
name = "musit_dataset",
initialize = function(folder_path, metadata_path, colour=FALSE) {
self$folder_path <- folder_path
self$filenames <- list.files(folder_path, pattern = "\\.jpg$", full.names = TRUE)
# Read the metadata CSV file
self$metadata <- read.csv(metadata_path)
# Debug: Check initial metadata and filenames
cat("Metadata rows: ", nrow(self$metadata), "\n")
cat("Filenames count: ", length(self$filenames), "\n")
# Convert the string labels to factors, then to integers
self$labels <- as.factor(self$metadata$Gjenstand)
self$label_levels <- levels(self$labels)
self$file_to_int_label <- setNames(as.integer(self$labels), self$metadata$Filnavn)
# Different type of preprocessing if colour or not
self$colour <- colour
},
.getitem = function(index) {
img_path <- self$filenames[index]
img <- magick::image_read(img_path)
img <- torchvision::transform_resize(img, size = c(128, 128))
if (!self$colour){
channels = "gray"
img <- magick::image_convert(img, colorspace = channels)
} else {
channels = "rgb"
}
# Convert to numeric array
# NOTE: normally you would normalize (e.g. divide by 255), but
# magick::image_data already does this
img_array <- as.numeric(magick::image_data(img, channels = channels))
# Reshape to (channels, height, width) from (width, channels, height)
# as expected by torch.
img_tensor <- torch_tensor(img_array)$permute(c(3, 1, 2))
# Get the corresponding integer label for the image
int_label <- self$file_to_int_label[basename(img_path)]
# convert 1-2 to 0-1 (necessary for binary classification)
y <- torch_tensor(as.integer(int_label) - 1)
return(list(img = img_tensor, y = y))
},
.length = function() {
length(self$filenames)
},
print_class_distribution = function() {
print(table(self$labels))
}
)
# For use in rest of script (several areas)
use_colour = TRUE
# Create instance of defined torch::dataset()
dataset <- create_musit_dataset(folder_path = "../torch_data/images/",
metadata_path = "../torch_data/metadata.csv", colour=use_colour)
## Metadata rows: 2065
## Filenames count: 2065
In this HTML document, the model uses colour
why we do not normalize in this specific context
view_random_image(data_dir="../torch_data/images/", plot_hist = TRUE)
view_random_image(data_dir="../torch_data/images/", colour=TRUE, plot_hist = TRUE)
viewing images directly from our torch defined dataset
dataset_grey <- create_musit_dataset("../torch_data/images/", "../torch_data/metadata.csv", colour=FALSE)
## Metadata rows: 2065
## Filenames count: 2065
view_random_torch_sample(dataset=dataset_grey, label_levels=dataset_grey$label_levels)
dataset_colour <- create_musit_dataset("../torch_data/images/", "../torch_data/metadata.csv", colour=TRUE)
## Metadata rows: 2065
## Filenames count: 2065
view_random_torch_sample(dataset=dataset_colour, label_levels=dataset_colour$label_levels,
colour=TRUE)
Total size
num_samples <- length(dataset)
print(num_samples)
## [1] 2065
Print class distribution
dataset$print_class_distribution()
##
## kar pil
## 775 1290
train_ratio <- 0.7
valid_ratio <- 0.15
test_ratio <- 0.15
# Calculate number of samples per set
num_train <- floor(train_ratio * num_samples)
num_valid <- floor(valid_ratio * num_samples)
# Ensure the sum matches total samples
num_test <- num_samples - (num_train + num_valid)
# Sanity check
cat(sprintf("Total: %d, Train: %d, Valid: %d, Test: %d\n", num_samples, num_train, num_valid, num_test))
## Total: 2065, Train: 1445, Valid: 309, Test: 311
Generate shuffled indices for the dataset
set.seed(42) # For reproducibility, within same software+OS setup
indices <- sample(1:num_samples)
train_indices <- indices[1:num_train]
valid_indices <- indices[(num_train + 1):(num_train + num_valid)]
test_indices <- indices[(num_train + num_valid + 1):num_samples]
# Ensure no index is out of range
cat("Max index in train indices: ", max(train_indices), "\n")
## Max index in train indices: 2065
cat("Max index in valid indices: ", max(valid_indices), "\n")
## Max index in valid indices: 2062
cat("Max index in test indices: ", max(test_indices), "\n")
## Max index in test indices: 2061
train_dataset <- dataset_subset(dataset, indices = train_indices)
valid_dataset <- dataset_subset(dataset, indices = valid_indices)
test_dataset <- dataset_subset(dataset, indices = test_indices)
train_dl <- dataloader(train_dataset, batch_size = 32, shuffle = TRUE) # shuffled to reduce bias and variance during training
valid_dl <- dataloader(valid_dataset, batch_size = 32, shuffle = FALSE) #not shuffled to help consistency of evaluating performance
test_dl <- dataloader(test_dataset, batch_size = 32, shuffle = FALSE)
# Inspect number of batches
cat("Number of batches in training set: ", length(train_dl), "\n")
## Number of batches in training set: 46
cat("Number of batches in validation set: ", length(valid_dl), "\n")
## Number of batches in validation set: 10
cat("Number of batches in test set: ", length(test_dl), "\n")
## Number of batches in test set: 10
Note: modified model from MNIST
Image taken from: https://commons.wikimedia.org/wiki/File:ConvolutionAndPooling.svg Andreas Maier, CC BY 4.0 https://creativecommons.org/licenses/by/4.0, via Wikimedia Commons
net <- nn_module(
# Desired name
"CNN-classification",
# In the "initialize" method, you set up the architecture of the neural network
# (i.e. define layers and parameters of the model)
initialize = function(colour = FALSE, dropout_rate1 = 0.25, dropout_rate2 = 0.5) {
num_channels <- ifelse(!colour, 1, 3)
# 1st Convolutional Layer:
self$conv1 <- nn_conv2d(num_channels, 32, 3, 1)
# 2nd Convolutional layer:
self$conv2 <- nn_conv2d(32, 64, 3, 1)
# Randomly dropping features to prevent overfitting
self$dropout1 <- nn_dropout2d(dropout_rate1)
self$dropout2 <- nn_dropout(dropout_rate2)
# Fully connected Layers:
# (see max pooling / flatteing in forward pass)
self$fc1 <- nn_linear(64 * 62 * 62, 128)
# Output layer
self$fc2 <- nn_linear(128, 1)
},
# In the "forward" method, essentially, you define how the data is process through
# the model defined in the "initialize" method.
forward = function(x) {
x %>%
self$conv1() %>%
nnf_relu() %>% # Apply ReLU activation (replaces negative values with 0)
self$conv2() %>%
nnf_relu() %>%
nnf_max_pool2d(2) %>%
self$dropout1() %>%
torch_flatten(start_dim = 2) %>%
self$fc1() %>%
nnf_relu() %>% # Apply ReLU activation
self$dropout2() %>% # Apply regular dropout (after flattening)
self$fc2()
}
)
1st conv layer
num_channels <- ifelse(!use_colour, 1, 3)
x <- torch_randn(c(1, num_channels, 128, 128))
conv1 <- nn_conv2d(num_channels, 32, 3, 1)
x1 <- conv1(x)
x1$size()
## [1] 1 32 126 126
Here, we can see that the number of output channels is 32
ReLU doesn’t change dimensions, but let’s apply it to be thorough
x1 <- nnf_relu(x1)
Second conv layer
conv2 <- nn_conv2d(x1$size()[2], 64, 3, 1)
x2 <- conv2(x1)
print("After conv2:")
## [1] "After conv2:"
print(x2$size()) # Should be [1, 64, 124, 124]
## [1] 1 64 124 124
ReLU again (size does not change)
x2 <- nnf_relu(x2)
Max pooling
x3 <- nnf_max_pool2d(x2, 2)
print("After max_pool2d:")
## [1] "After max_pool2d:"
print(x3$size()) # Should be [1, 64, 62, 62]
## [1] 1 64 62 62
After flattening
x4 <- torch_flatten(x3, start_dim = 2)
print("After flattening:")
## [1] "After flattening:"
print(x4$size()) # Should be [1, 64 * 62 * 62]
## [1] 1 246016
print(64 * 64* 62)
## [1] 253952
First fully connected layer
fc1 <- nn_linear(64 * 62 * 62, 128)
x5 <- fc1(x4)
print("After fc1:")
## [1] "After fc1:"
print(x5$size()) # Should be [1, 128]
## [1] 1 128
Final fully connected layer
fc2 <- nn_linear(128, 1)
x6 <- fc2(x5)
print("After fc2:")
## [1] "After fc2:"
print(x6$size()) # Should be [1, 1]
## [1] 1 1
num_training_rounds = 11
exists_model <- file.exists("musit-binary-colour-epochs-10.pt")
if(!exists_model){
fitted <- net %>%
luz::setup(
# Choose loss function appropriate to the task (e.g. binary classification).
loss = nnf_binary_cross_entropy_with_logits,
# If multiclass, loss = nnf_cross_entropy,
# How to update weights of network during training
optimizer = optim_adam,
metrics = list(
# if multiclass, luz_metric_accuracy()
luz_metric_binary_accuracy_with_logits()
)
) %>%
# Set your `net` params in set_hparams
set_hparams(colour = use_colour) %>%
# set_opt_hparams(lr = 0.0001) %>%
# Specify additional training parameters
fit(train_dl,
epochs = num_training_rounds,
# Also common to evaluate on the test data during model fitting
valid_data = valid_dl,
callbacks = list(
luz_callback_early_stopping(patience = 5)
)
)
}
NOTE: no built in functions to plot training curves (i.e. loss)
Can extract
fitted$metrics$<valid or train>[[<epoch number>]]$loss
Save model
type <- ifelse(use_colour, "colour", "greyscale")
# luz_save(fitted, paste0("musit-binary-", type, "-epochs-", num_training_rounds, ".pt"))
fitted <- luz_load(paste0("musit-binary-", type, "-epochs-10.pt"))
if (exists_model)
print("The number of epochs in the loaded model is 10")
## [1] "The number of epochs in the loaded model is 10"
Note: if you use the shared, predefined model, there is a chance that some of the test set (and validation set)
will overlap with the training set. This is not ideal as it will inflate the model evaluation results,
but is OK for the purposes of this workshop (you can, on your own time, run a model with 10 or more epochs,
save it, and use that.
Note: the predictions are with respect to the “second” level. In our case, pil (“arrow”).
Can verify the “levels” with our custom function
dataset$print_class_distribution()
preds <- predict(fitted, test_dl)
Convert logits to probabilities using the sigmoid function
probs <- torch_sigmoid(preds)
# probs <- preds %>% nnf_softmax(dim = -1) # or `dim = 2` # Multiclass
convert to traditional array/matrix
preds <- probs$to(device = "cpu")$squeeze() %>% as.numeric()
# preds <- probs %>% torch_argmax(dim = -1) %>% as.numeric() # multiclass
Reinitialize test “dataloader” to be the whole dataset
test_dl <- dataloader(test_dataset, batch_size = length(test_indices))
targets <- (test_dl %>% dataloader_make_iter() %>% dataloader_next())$y %>% as.numeric()
# Alternatively, for very large test set, can loop over original test_dl,
# get the targets, and perform predictions for each batch, before collecting
# final result
Apply desired threshold
threshold = 0.5 # can adjust as needed (will affect model performance metrics)
predicted_class = ifelse(preds >= threshold, 1, 0)
# predicted_class=max.col(as.matrix(probs)) # multiclass
df_res <- data.frame(predicted_prob = preds, predicted_class = predicted_class, targets = targets)
Additional, for later plotting and tabulation
df_res$correct <- ifelse(df_res$predicted_class == df_res$targets, TRUE, FALSE)
df_res$Filnavn <- dataset$metadata$Filnavn[test_indices]
# Get original class labels
original_labels <- dataset$label_levels
Show random result
NOTE: here, need to specify colour parameter used in creating torch::dataset() (create_musit_dataset)
view_random_torch_sample(dataset=test_dataset, colour=use_colour, is_test_set=TRUE,
label_levels = original_labels,
df_res = df_res)
first parameter of table becomes the ROWS
i.e. columns are the predictions, rows are the observations
conf_mat <- table(df_res$targets, df_res$predicted_class)
Use these to rename the confusion matrix
colnames(conf_mat) <- paste0(original_labels)
rownames(conf_mat) <- paste0(original_labels)
conf_mat
##
## kar pil
## kar 92 19
## pil 9 191
Number of wrong results :
sum(conf_mat) - sum(diag(conf_mat))
## [1] 28
# or
# nrow(df_res) - sum(df_res$correct)
sum(diag(conf_mat))/sum(conf_mat)
## [1] 0.9099678
diag(conf_mat)/colSums(conf_mat)
## kar pil
## 0.9108911 0.9095238
diag(conf_mat)/rowSums(conf_mat)
## kar pil
## 0.8288288 0.9550000
Show random incorrect result
# NOTE: here, need to specify colour parameter used in creating torch::dataset() (create_musit_dataset())
view_random_torch_sample(dataset=test_dataset, colour=use_colour, is_test_set=TRUE,
label_levels = original_labels,
df_res = df_res, show_wrong_result = TRUE)