Q1 Baseline Model


import numpy as np 
import pandas as pd

import os
from pathlib import Path

import tensorflow as tf
from tensorflow.keras import layers, models, regularizers
from sklearn.metrics import hamming_loss, f1_score, average_precision_score
import matplotlib.pyplot as plt
import seaborn as sns
import random
from itertools import product
from tensorflow.keras.models import load_model

# Local project paths. This replaces Kaggle-specific input/working paths.
def find_project_dir():
    candidates = []
    try:
        candidates.append(Path(__file__).resolve().parent)
    except NameError:
        pass

    cwd = Path.cwd().resolve()
    candidates.extend([
        cwd,
        cwd / "python" / "multilabel-image-classification",
        cwd.parent / "python" / "multilabel-image-classification",
    ])

    for candidate in candidates:
        if (candidate / "Dataset" / "list_attr_celeba.csv").exists():
            return candidate

    raise FileNotFoundError(
        "Could not find the multi-label project folder. "
        "Run the notebook from the repo root or from python/multilabel-image-classification."
    )

BASE_DIR = find_project_dir()
DATA_DIR = BASE_DIR / "Dataset"
IMG_DIR = DATA_DIR / "img_align_celeba" / "img_align_celeba"
MODEL_DIR = BASE_DIR
OUTPUT_DIR = BASE_DIR / "outputs"
OUTPUT_DIR.mkdir(exist_ok=True)

print("Project folder:", BASE_DIR)
print("Dataset folder:", DATA_DIR)
print("Image folder exists:", IMG_DIR.exists())
print("Models folder:", MODEL_DIR)

# setting up of seeding sampling
SEED = 888

os.environ["PYTHONHASHSEED"] = str(SEED)
random.seed(SEED)
np.random.seed(SEED)
tf.keras.utils.set_random_seed(SEED)
tf.config.experimental.enable_op_determinism()

print("TF version:", tf.__version__)
print("GPUs:", tf.config.list_physical_devices("GPU"))
2026-03-25 14:53:07.517241: E external/local_xla/xla/stream_executor/cuda/cuda_fft.cc:467] Unable to register cuFFT factory: Attempting to register factory for plugin cuFFT when one has already been registered
WARNING: All log messages before absl::InitializeLog() is called are written to STDERR
E0000 00:00:1774450387.553326    5687 cuda_dnn.cc:8579] Unable to register cuDNN factory: Attempting to register factory for plugin cuDNN when one has already been registered
E0000 00:00:1774450387.565322    5687 cuda_blas.cc:1407] Unable to register cuBLAS factory: Attempting to register factory for plugin cuBLAS when one has already been registered
W0000 00:00:1774450387.585967    5687 computation_placer.cc:177] computation placer already registered. Please check linkage and avoid linking the same target more than once.
W0000 00:00:1774450387.585996    5687 computation_placer.cc:177] computation placer already registered. Please check linkage and avoid linking the same target more than once.
W0000 00:00:1774450387.586000    5687 computation_placer.cc:177] computation placer already registered. Please check linkage and avoid linking the same target more than once.
W0000 00:00:1774450387.586004    5687 computation_placer.cc:177] computation placer already registered. Please check linkage and avoid linking the same target more than once.
TF version: 2.19.0
GPUs: [PhysicalDevice(name='/physical_device:GPU:0', device_type='GPU'), PhysicalDevice(name='/physical_device:GPU:1', device_type='GPU')]

# Load local split and attribute files
partition_path = DATA_DIR / "list_eval_partition.csv"
attr_path = DATA_DIR / "list_attr_celeba.csv"

missing = [str(p) for p in [partition_path, attr_path, IMG_DIR] if not p.exists()]
if missing:
    raise FileNotFoundError("Missing required local CelebA files/folders:\n" + "\n".join(missing))

partition_df = pd.read_csv(partition_path)
attr_df = pd.read_csv(attr_path)

# Create master table
df = partition_df.merge(attr_df, on="image_id", how="left")

# Conversion of labels: CelebA uses 1 for present and -1 for absent.
attr_col = [c for c in df.columns if c not in ["image_id", "partition"]]
df[attr_col] = (df[attr_col] == 1).astype(np.float32)

# Map local image paths.
df["image_path"] = df["image_id"].apply(lambda x: str(IMG_DIR / x))

missing_images = (~df["image_path"].map(lambda x: Path(x).exists())).sum()
if missing_images:
    raise FileNotFoundError(f"{missing_images} images listed in the metadata were not found under {IMG_DIR}")

# Data Split and clean up index
train = df[df["partition"] == 0].reset_index(drop=True)
validate = df[df["partition"] == 1].reset_index(drop=True)
test = df[df["partition"] == 2].reset_index(drop=True)

print("Train/validation/test shapes:", train.shape, validate.shape, test.shape)
print("Number of labels:", len(attr_col))
# Helper to perform evaluation
# y-tru >> actual label, y_prob>>model prediction
def evaluate_metrics(y_true, y_prob, threshold=0.5):

    # retun 1 is probability is more than threshold set
    y_pred = (y_prob >= threshold).astype(int)

    results = {
        "Binary Accuracy": (y_true == y_pred).mean(),
        "Hamming Loss": hamming_loss(y_true, y_pred),
        "Micro-F1": f1_score(y_true, y_pred, average="micro", zero_division=0),
        "Macro-F1": f1_score(y_true, y_pred, average="macro", zero_division=0),
        "Micro-mAP": average_precision_score(y_true, y_prob, average="micro"),
        "Macro-mAP": average_precision_score(y_true, y_prob, average="macro"),
    }

    return results

def get_true_and_prob(model, dataset):
    
    # extract probability form predictions
    y_prob = model.predict(dataset, verbose=0)
    
    # ignore x and collect all y and return in array
    y_true = np.concatenate([np.array(y) for _, y in dataset], axis=0)
    return y_true, y_prob
# Image Preprocesing function


IMG_SIZE = (64, 64)
BATCH_SIZE = 128

def load_and_preprocess(path, label_vec):
    # read raw bytes
    img_bytes = tf.io.read_file(path)
    # turn bytes into an image tensor
    img = tf.image.decode_jpeg(img_bytes, channels=3)
    # resize based on parameter
    img = tf.image.resize(img, IMG_SIZE)
    # Normalise
    img = tf.cast(img, tf.float32) / 255.0
    return img, label_vec

def make_dataset(split_df, training=False):
    paths = split_df['image_path'].values
    labels = split_df[attr_col].values.astype(np.float32)

    # turn array into dataset (path to 1 image, its 40 labels)
    ds = tf.data.Dataset.from_tensor_slices((paths, labels))
    # applies image decoding, resizing, normalization to each element
    ds = ds.map(load_and_preprocess, num_parallel_calls=tf.data.AUTOTUNE)

    ds = ds.cache()

    if training:
        ds = ds.shuffle(20000, reshuffle_each_iteration=True)

    ds = ds.batch(BATCH_SIZE).prefetch(tf.data.AUTOTUNE)
    return ds


train_ds = make_dataset(train, training=True)
val_ds   = make_dataset(validate, training=False)
test_ds  = make_dataset(test, training=False)
I0000 00:00:1774450398.181199    5687 gpu_device.cc:2019] Created device /job:localhost/replica:0/task:0/device:GPU:0 with 13757 MB memory:  -> device: 0, name: Tesla T4, pci bus id: 0000:00:04.0, compute capability: 7.5
I0000 00:00:1774450398.186293    5687 gpu_device.cc:2019] Created device /job:localhost/replica:0/task:0/device:GPU:1 with 13757 MB memory:  -> device: 1, name: Tesla T4, pci bus id: 0000:00:05.0, compute capability: 7.5
for x_batch, y_batch in train_ds.take(1):
    print("X:", x_batch.shape, x_batch.dtype, "min/max:", tf.reduce_min(x_batch).numpy(), tf.reduce_max(x_batch).numpy())
    print("Y:", y_batch.shape, y_batch.dtype, "unique:", np.unique(y_batch.numpy())[:5])
X: (128, 64, 64, 3) <dtype: 'float32'> min/max: 0.0 1.0
Y: (128, 40) <dtype: 'float32'> unique: [0. 1.]
# Take 1 batch from training dataset
for images, labels in train_ds.take(1):
    print("Image batch shape:", images.shape)
    print("Label batch shape:", labels.shape)
    print("Pixel range:", tf.reduce_min(images).numpy(), "to", tf.reduce_max(images).numpy())

    # show first image
    plt.figure(figsize=(4,4))
    plt.imshow(images[0].numpy())
    plt.title("Sample training image")
    plt.axis("off")
    plt.show()

    # show first label vector
    print("First label vector shape:", labels[0].shape)
    print("First 10 labels:", labels[0].numpy()[:10])
Image batch shape: (128, 64, 64, 3)
Label batch shape: (128, 40)
Pixel range: 0.0 to 1.0

First label vector shape: (40,)
First 10 labels: [0. 0. 0. 0. 0. 0. 0. 0. 0. 1.]
mlp = models.Sequential([
    layers.Input(shape=(64, 64, 3)),
    # convert to 64*64*3
    layers.Flatten(),
    layers.Dense(512, activation="relu"),
    layers.Dense(256, activation="relu"),
    # 40 facial attribute
    layers.Dense(40, activation="sigmoid")
])

mlp.summary()

mlp.compile(
    # Control how weights are updated
    optimizer=tf.keras.optimizers.Adam(),
    # Loss for multi-label with sigmoid (prob)
    # loss="tf.keras.losses.BinaryCrossentropy()",
    loss="binary_crossentropy",
    # Provide human readable signal whether model is improving during training
    metrics=[tf.keras.metrics.BinaryAccuracy(name="bin_acc")]
)

early_stop = tf.keras.callbacks.EarlyStopping(
    monitor="val_loss",
    patience=5,
    restore_best_weights=True
)
Model: "sequential"
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┓
┃ Layer (type)                     Output Shape                  Param # ┃
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━┩
│ flatten (Flatten)               │ (None, 12288)          │             0 │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ dense (Dense)                   │ (None, 512)            │     6,291,968 │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ dense_1 (Dense)                 │ (None, 256)            │       131,328 │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ dense_2 (Dense)                 │ (None, 40)             │        10,280 │
└─────────────────────────────────┴────────────────────────┴───────────────┘
 Total params: 6,433,576 (24.54 MB)
 Trainable params: 6,433,576 (24.54 MB)
 Non-trainable params: 0 (0.00 B)
q1_train = mlp.fit(
    train_ds,
    validation_data=val_ds,
    epochs=50,
    callbacks=[early_stop]
)
Epoch 1/50

1272/1272 ━━━━━━━━━━━━━━━━━━━━ 124s 90ms/step - bin_acc: 0.8424 - loss: 0.3746 - val_bin_acc: 0.8711 - val_loss: 0.2951

Epoch 2/50

1272/1272 ━━━━━━━━━━━━━━━━━━━━ 11s 8ms/step - bin_acc: 0.8728 - loss: 0.2922 - val_bin_acc: 0.8802 - val_loss: 0.2750

Epoch 3/50

1272/1272 ━━━━━━━━━━━━━━━━━━━━ 10s 8ms/step - bin_acc: 0.8771 - loss: 0.2818 - val_bin_acc: 0.8798 - val_loss: 0.2755

Epoch 4/50

1272/1272 ━━━━━━━━━━━━━━━━━━━━ 11s 8ms/step - bin_acc: 0.8803 - loss: 0.2743 - val_bin_acc: 0.8847 - val_loss: 0.2649

Epoch 5/50

1272/1272 ━━━━━━━━━━━━━━━━━━━━ 11s 8ms/step - bin_acc: 0.8821 - loss: 0.2695 - val_bin_acc: 0.8826 - val_loss: 0.2700

Epoch 6/50

1272/1272 ━━━━━━━━━━━━━━━━━━━━ 10s 8ms/step - bin_acc: 0.8829 - loss: 0.2675 - val_bin_acc: 0.8853 - val_loss: 0.2641

Epoch 7/50

1272/1272 ━━━━━━━━━━━━━━━━━━━━ 10s 8ms/step - bin_acc: 0.8846 - loss: 0.2635 - val_bin_acc: 0.8831 - val_loss: 0.2683

Epoch 8/50

1272/1272 ━━━━━━━━━━━━━━━━━━━━ 11s 8ms/step - bin_acc: 0.8851 - loss: 0.2622 - val_bin_acc: 0.8874 - val_loss: 0.2572

Epoch 9/50

1272/1272 ━━━━━━━━━━━━━━━━━━━━ 10s 8ms/step - bin_acc: 0.8859 - loss: 0.2598 - val_bin_acc: 0.8869 - val_loss: 0.2596

Epoch 10/50

1272/1272 ━━━━━━━━━━━━━━━━━━━━ 10s 8ms/step - bin_acc: 0.8872 - loss: 0.2572 - val_bin_acc: 0.8856 - val_loss: 0.2648

Epoch 11/50

1272/1272 ━━━━━━━━━━━━━━━━━━━━ 10s 8ms/step - bin_acc: 0.8875 - loss: 0.2564 - val_bin_acc: 0.8882 - val_loss: 0.2560

Epoch 12/50

1272/1272 ━━━━━━━━━━━━━━━━━━━━ 10s 8ms/step - bin_acc: 0.8878 - loss: 0.2557 - val_bin_acc: 0.8874 - val_loss: 0.2587

Epoch 13/50

1272/1272 ━━━━━━━━━━━━━━━━━━━━ 10s 8ms/step - bin_acc: 0.8884 - loss: 0.2539 - val_bin_acc: 0.8871 - val_loss: 0.2571

Epoch 14/50

1272/1272 ━━━━━━━━━━━━━━━━━━━━ 10s 8ms/step - bin_acc: 0.8891 - loss: 0.2527 - val_bin_acc: 0.8886 - val_loss: 0.2554

Epoch 15/50

1272/1272 ━━━━━━━━━━━━━━━━━━━━ 10s 8ms/step - bin_acc: 0.8891 - loss: 0.2520 - val_bin_acc: 0.8890 - val_loss: 0.2542

Epoch 16/50

1272/1272 ━━━━━━━━━━━━━━━━━━━━ 10s 8ms/step - bin_acc: 0.8896 - loss: 0.2511 - val_bin_acc: 0.8877 - val_loss: 0.2587

Epoch 17/50

1272/1272 ━━━━━━━━━━━━━━━━━━━━ 10s 8ms/step - bin_acc: 0.8900 - loss: 0.2503 - val_bin_acc: 0.8877 - val_loss: 0.2577

Epoch 18/50

1272/1272 ━━━━━━━━━━━━━━━━━━━━ 10s 8ms/step - bin_acc: 0.8900 - loss: 0.2503 - val_bin_acc: 0.8899 - val_loss: 0.2518

Epoch 19/50

1272/1272 ━━━━━━━━━━━━━━━━━━━━ 10s 8ms/step - bin_acc: 0.8908 - loss: 0.2482 - val_bin_acc: 0.8873 - val_loss: 0.2583

Epoch 20/50

1272/1272 ━━━━━━━━━━━━━━━━━━━━ 10s 8ms/step - bin_acc: 0.8908 - loss: 0.2482 - val_bin_acc: 0.8862 - val_loss: 0.2615

Epoch 21/50

1272/1272 ━━━━━━━━━━━━━━━━━━━━ 10s 8ms/step - bin_acc: 0.8909 - loss: 0.2478 - val_bin_acc: 0.8899 - val_loss: 0.2518

Epoch 22/50

1272/1272 ━━━━━━━━━━━━━━━━━━━━ 11s 8ms/step - bin_acc: 0.8914 - loss: 0.2468 - val_bin_acc: 0.8844 - val_loss: 0.2643

Epoch 23/50

1272/1272 ━━━━━━━━━━━━━━━━━━━━ 10s 8ms/step - bin_acc: 0.8915 - loss: 0.2462 - val_bin_acc: 0.8899 - val_loss: 0.2515

Epoch 24/50

1272/1272 ━━━━━━━━━━━━━━━━━━━━ 11s 8ms/step - bin_acc: 0.8918 - loss: 0.2458 - val_bin_acc: 0.8880 - val_loss: 0.2557

Epoch 25/50

1272/1272 ━━━━━━━━━━━━━━━━━━━━ 10s 8ms/step - bin_acc: 0.8922 - loss: 0.2447 - val_bin_acc: 0.8907 - val_loss: 0.2498

Epoch 26/50

1272/1272 ━━━━━━━━━━━━━━━━━━━━ 10s 8ms/step - bin_acc: 0.8927 - loss: 0.2438 - val_bin_acc: 0.8883 - val_loss: 0.2561

Epoch 27/50

1272/1272 ━━━━━━━━━━━━━━━━━━━━ 10s 8ms/step - bin_acc: 0.8925 - loss: 0.2441 - val_bin_acc: 0.8887 - val_loss: 0.2552

Epoch 28/50

1272/1272 ━━━━━━━━━━━━━━━━━━━━ 10s 8ms/step - bin_acc: 0.8930 - loss: 0.2430 - val_bin_acc: 0.8883 - val_loss: 0.2555

Epoch 29/50

1272/1272 ━━━━━━━━━━━━━━━━━━━━ 10s 8ms/step - bin_acc: 0.8928 - loss: 0.2432 - val_bin_acc: 0.8887 - val_loss: 0.2539

Epoch 30/50

1272/1272 ━━━━━━━━━━━━━━━━━━━━ 10s 8ms/step - bin_acc: 0.8934 - loss: 0.2422 - val_bin_acc: 0.8898 - val_loss: 0.2514
# # Export trained model
# mlp.save(MODEL_DIR / "Q1 Final model.keras")
# print("Model saved successfully")

Plotting the results

# Training vs validation Loss
plt.figure(figsize=(8, 5))
plt.plot(q1_train.history["loss"], label="Training Loss")
plt.plot(q1_train.history["val_loss"], label="Validation Loss")
plt.xlabel("Epoch")
plt.ylabel("Loss")
plt.title("Training v Validation Loss")
plt.legend()
plt.show()

# Test
plt.figure(figsize=(8, 5))
plt.plot(q1_train.history["bin_acc"], label="Training Binary Accuracy")
plt.plot(q1_train.history["val_bin_acc"], label="Validation Binary Accuracy")
plt.xlabel("Epoch")
plt.ylabel("Binary Accuracy")
plt.title("Training vv Validation Binary Accuracy")
plt.legend()
plt.show()

def evaluate_multilabel_model(model, test_ds, threshold=0.5, model_name="Model"):
    y_test = np.concatenate([y.numpy() for _, y in test_ds], axis=0)
    y_prob = model.predict(test_ds, verbose=0)
    y_pred = (y_prob >= threshold).astype(np.int32)

    binary_acc = (y_pred == y_test).mean()
    ham_loss = hamming_loss(y_test, y_pred)
    micro_f1 = f1_score(y_test, y_pred, average="micro", zero_division=0)
    macro_f1 = f1_score(y_test, y_pred, average="macro", zero_division=0)
    micro_map = average_precision_score(y_test, y_prob, average="micro")
    macro_map = average_precision_score(y_test, y_prob, average="macro")

    results_df = pd.DataFrame({
        "Model": [model_name] * 6,
        "Metric": [
            "Binary Accuracy",
            "Hamming Loss",
            "Micro-F1",
            "Macro-F1",
            "Micro-mAP",
            "Macro-mAP"
        ],
        "Value": [
            binary_acc,
            ham_loss,
            micro_f1,
            macro_f1,
            micro_map,
            macro_map
        ]
    })

    return results_df, y_test, y_prob, y_pred
baseline_results, y_test_base, y_prob_base, y_pred_base = evaluate_multilabel_model(
    model=mlp,
    test_ds=test_ds,
    threshold=0.5,
    model_name="Baseline MLP"
)

baseline_results
Model Metric Value
0 Baseline MLP Binary Accuracy 0.885445
1 Baseline MLP Hamming Loss 0.114555
2 Baseline MLP Micro-F1 0.723956
3 Baseline MLP Macro-F1 0.547436
4 Baseline MLP Micro-mAP 0.845105
5 Baseline MLP Macro-mAP 0.657564
# load trained model
loaded_model = tf.keras.models.load_model(MODEL_DIR / "Q1 Final model.keras")
# load saved model
Q1_mlp = load_model(MODEL_DIR / "Q1 Final model.keras")

# validation
y_val, y_val_prob = get_true_and_prob(Q1_mlp, val_ds)
val_results = evaluate_metrics(y_val, y_val_prob, threshold=0.5)

# test
y_test, y_test_prob = get_true_and_prob(Q1_mlp, test_ds)
test_results = evaluate_metrics(y_test, y_test_prob, threshold=0.5)

# Combine
comparison_df = pd.DataFrame([
    {"dataset": "validation", **val_results},
    {"dataset": "test", **test_results}
]).round(5)

comparison_df
dataset Binary Accuracy Hamming Loss Micro-F1 Macro-F1 Micro-mAP Macro-mAP
0 validation 0.89073 0.10927 0.72915 0.54928 0.84331 0.65465
1 test 0.88544 0.11456 0.72396 0.54744 0.84510 0.65756

Q1 Evaluation and Discussion:

Training/validation curves (loss + metric)

The training loss was observed to decrease as the number of epochs increased. This indicates that the model is able to learn patterns from the training dataset.

The validation loss is also seen to fluctuate across the number of epochs. However, overall the loss appears to stagnate once the epoch crosses the 20 mark. This suggests that the model is approaching its optimal point, and the validation loss might start to increase if more epochs are performed, as the model may begin to overfit the training data.

Looking at the binary accuracy curves, both the training and validation accuracy increase steadily during the early epochs before stabilising around the later stages of training (~0.89). The difference between the training and validation accuracy remains relatively small, which suggests that the model maintains reasonable generalisation performance on unseen data.

Final test performance

Binary Accuracy: 88%; Hamming Loss: 11%; Micro-F1: 72%; Macro-F1: 54%; Micro-mAP: 84%; Macro-mAP: 65%

Binary Accuracy & Hamming Loss:

The relatively high binary accuracy (88%) indicates that the model is able to correctly predict a large proportion of the facial attributes.

The Hamming Loss (11%) suggests that only a small portion of the attribute predictions are incorrect across all samples.

Macro & Micro-F1 score:

Macro-F1 score (54%) is noticeably lower than the Micro-F1 score (72%). This suggests that the model performs better on common attributes compared to rare attributes, which is expected given the class imbalance present in the CelebA dataset.

Similarly, the micro-mAP score is higher than the macro-mAP score, indicating that the model ranks predictions for common attributes more effectively than for less frequent ones.

Macro & Micro-mAP score: The higher mAP scores compared to the F1 scores suggest that the model is able to rank positive attributes reasonably well based on predicted probabilities. However, when these probabilities are converted into binary predictions using a fixed threshold of 0.5, some of this ranking information is lost, resulting in lower F1 scores.

The higher micro-mAP score (84 vs 65%) indicates that the model ranks predictions for common attributes more effectively than for less frequent ones.

Model fitting

Based on the training and validation trends, the model does not show severe overfitting. Although the training loss continues to decrease slightly in later epochs, the validation loss remains relatively stable and does not diverge that much from the training loss.

This suggests that the model is able to learn useful patterns from the data while still maintaining reasonable generalisation performance. However, the stagnation of the validation loss indicates that the model may be approaching its learning capacity.

Q2 Improving the MLP for Multi-Label Attribute Prediction

def train_mlp_for_search(
    model_type,
    train_ds,
    val_ds,
    learning_rate=1e-4,
    dropout_rate=0.1,
    use_batchnorm=False,
    use_lr_schedule=False,
    decay_steps=3000,
    decay_rate=0.95,
    weight_decay=1e-5,
    seed=888
):
    tf.keras.utils.set_random_seed(seed)

    architecture_map = {
        "baseline": [512, 256],
        "deep": [1024, 512, 256, 128],
        "wide": [1024, 1024, 512],
        "deep_wide_1": [2048, 2048, 1024],
        "deep_wide_2": [2048, 1024, 512],
        "deep_wide_3": [1024, 1024, 1024],
    }

    hidden_units = architecture_map[model_type]

    model = tf.keras.Sequential([
        tf.keras.layers.Input(shape=(64, 64, 3)),
        tf.keras.layers.Flatten()
    ])

    for i, units in enumerate(hidden_units):
        model.add(tf.keras.layers.Dense(units, use_bias=not use_batchnorm))

        if use_batchnorm:
            model.add(tf.keras.layers.BatchNormalization())

        model.add(tf.keras.layers.ReLU())

        if dropout_rate > 0 and i < 2:
            model.add(tf.keras.layers.Dropout(dropout_rate))

    model.add(tf.keras.layers.Dense(40, activation="sigmoid"))

    if use_lr_schedule:
        lr = tf.keras.optimizers.schedules.ExponentialDecay(
            initial_learning_rate=learning_rate,
            decay_steps=decay_steps,
            decay_rate=decay_rate
        )
    else:
        lr = learning_rate

    optimizer = tf.keras.optimizers.AdamW(
        learning_rate=lr,
        weight_decay=weight_decay
    )

    model.compile(
        optimizer=optimizer,
        loss="binary_crossentropy",
        metrics=[tf.keras.metrics.BinaryAccuracy(name="bin_acc")]
    )

    early_stop = tf.keras.callbacks.EarlyStopping(
        monitor="val_loss",
        patience=5,
        restore_best_weights=True
    )

    history = model.fit(
        train_ds,
        validation_data=val_ds,
        epochs=50,
        callbacks=[early_stop],
        verbose=0
    )

    return model, history
model, history = train_mlp_for_search(
    model_type="deep_wide_3",
    train_ds=train_ds,
    val_ds=val_ds,
    learning_rate=1e-4,
    dropout_rate=0.10,
    use_batchnorm=False,
    use_lr_schedule=True,
    weight_decay=7e-5
)
# predict trained model on test set
y_test_prob = model.predict(test_ds)
y_test = np.concatenate([y for _, y in test_ds], axis=0)
156/156 ━━━━━━━━━━━━━━━━━━━━ 1s 4ms/step
history_df = pd.DataFrame(history.history)

plt.figure(figsize=(8, 5))
plt.plot(history.history["loss"], label="Train Loss")
plt.plot(history.history["val_loss"], label="Val Loss")
plt.xlabel("Epoch")
plt.ylabel("Loss")
plt.title("Q2 MLP Training vs Validation Loss")
plt.legend()
plt.show()

plt.figure(figsize=(8, 5))
plt.plot(history.history["bin_acc"], label="Train Binary Accuracy")
plt.plot(history.history["val_bin_acc"], label="Val Binary Accuracy")
plt.xlabel("Epoch")
plt.ylabel("Binary Accuracy")
plt.title("Q2 MLP Training vs Validation Binary Accuracy")
plt.legend()
plt.show()

# Validation results
y_val, y_val_prob = get_true_and_prob(model, val_ds)
val_results = evaluate_metrics(y_val, y_val_prob, threshold=0.5)
# Test results
y_test, y_test_prob = get_true_and_prob(model, test_ds)
test_results = evaluate_metrics(y_test, y_test_prob, threshold=0.5)

# Combine into table
comparison_df = pd.DataFrame([
    {"dataset": "validation", **val_results},
    {"dataset": "test", **test_results}
]).round(5)

comparison_df
dataset Binary Accuracy Hamming Loss Micro-F1 Macro-F1 Micro-mAP Macro-mAP
0 validation 0.89486 0.10514 0.74782 0.60129 0.85561 0.68244
1 test 0.88975 0.11025 0.74244 0.59421 0.85704 0.68169
# model.save(MODEL_DIR / "Q2 Final model.keras")

Load Saved model and predict

# load saved model
Q2_mlp = load_model(MODEL_DIR / "Q2 Final model.keras")

# validation
y_val, y_val_prob = get_true_and_prob(Q2_mlp, val_ds)
val_results = evaluate_metrics(y_val, y_val_prob, threshold=0.5)

# test
y_test, y_test_prob = get_true_and_prob(Q2_mlp, test_ds)
test_results = evaluate_metrics(y_test, y_test_prob, threshold=0.5)

# Combine
comparison_df = pd.DataFrame([
    {"dataset": "validation", **val_results},
    {"dataset": "test", **test_results}
]).round(5)

comparison_df
dataset Binary Accuracy Hamming Loss Micro-F1 Macro-F1 Micro-mAP Macro-mAP
0 validation 0.89503 0.10497 0.74767 0.60301 0.85559 0.68253
1 test 0.88987 0.11013 0.74240 0.59990 0.85651 0.68244

Q2 Evaluation and Discussion:

To better understand the effect of the architectural tuning 4 models are used in comparison with:

  1. ReLu as the hidden layer activation fuction
  2. Sigmoid 40 units as the output layer
  • one_hidden: [512]
  • baseline: [512,256]
  • deep: [1024,512,256,128]
  • wide: [1024,1024,512]
  • deep_wide_1: [2048, 2048, 1024]
  • deep_wide_2: [2048, 1024, 512],
  • deep_wide_3: [1024, 1024, 1024]

The Result as as below:

Model used: deep_wide_3

Best Model * Validation:

Binary Accuracy: 0.8950 Hamming Loss: 0.1049 Micro-F1: 0.74767 Macro-F1: 0.6030 Micro-mAP: 0.85559 Macro-mAP: 0.68253

  • Test:

Binary Accuracy: 0.88987 Hamming Loss: 0.11013 Micro-F1: 0.74240 Macro-F1: 0.59990 Micro-mAP: 0.85651 Macro-mAP: 0.68244

  • Model Tuning:

Several modifications were explored relative to the baseline MLP model to understand the impact of each component on performance. These included:

Baseline + Batchnorm Baseline + learning scheduling Baseline + dropout Baseline + L2 Regularisation Various combinations of the above

Despite these adjustments, it was challenging to achieve meaningful improvements in Macro-F1.

Among all configurations, the most impactful change was increasing the model capacity by using a wider architecture. Increasing network width improved macro-F1, suggesting that the baseline model lacked sufficient capacity to capture diverse attribute patterns. Wider layers allow the model to learn more feature representations simultaneously, which is especially beneficial in multi-label settings where attributes are heterogeneous.

In contrast, incorporating Batch Normalisation resulted in a slight decline in Macro-F1. This is likely because normalisation reduces variation in activations, which may create less emphasis on the subtle but important attribute-specific signals required for multi-label classification.

Other techniques, such as learning rate scheduling, L2 regularisation, and increasing model depth, provided limited gains. This suggests that the performance of the MLP is approaching its capacity limit. Given that the model relies on fully connected (Dense) layers operating on flattened image inputs, it lacks the ability to effectively capture spatial structure. As a result, additional tuning yields diminishing returns, and further complexity does not translate into meaningful performance improvements.

Q3 Implementing a Convolutional Neural Network (CNN) for Multi-Label Attribute Prediction

Running 1st Generic Test

# # experiment 1
# import numpy as np
# import pandas as pd
# import tensorflow as tf

# def build_cnn_q3(model_version="basic", dropout_rate=0.0, weight_decay=0.0, use_sched=False):
#     """
#     model_version:
#         - 'basic'  : 1 conv per block
#         - 'deep'   : 2 convs per block
#         - 'gap'    : deep CNN with GlobalAveragePooling
#     """

#     model = tf.keras.Sequential()
#     model.add(tf.keras.layers.Input(shape=(64, 64, 3)))

#     if model_version == "basic":
#         # Basic CNN
#         for filters in [32, 64, 128]:
#             model.add(tf.keras.layers.Conv2D(filters, 3, padding="same", activation="relu"))
#             model.add(tf.keras.layers.MaxPooling2D((2, 2)))

#         model.add(tf.keras.layers.Flatten())
#         model.add(tf.keras.layers.Dense(256, activation="relu"))

#     elif model_version == "deep":
#         # Deeper CNN: 2 conv layers per block
#         for filters in [32, 64, 128]:
#             model.add(tf.keras.layers.Conv2D(filters, 3, padding="same", activation="relu"))
#             model.add(tf.keras.layers.Conv2D(filters, 3, padding="same", activation="relu"))
#             model.add(tf.keras.layers.MaxPooling2D((2, 2)))

#         model.add(tf.keras.layers.Flatten())
#         model.add(tf.keras.layers.Dense(512, activation="relu"))

#     elif model_version == "gap":
#         # Deeper CNN with GlobalAveragePooling
#         for filters in [32, 64, 128]:
#             model.add(tf.keras.layers.Conv2D(filters, 3, padding="same", activation="relu"))
#             model.add(tf.keras.layers.Conv2D(filters, 3, padding="same", activation="relu"))
#             model.add(tf.keras.layers.MaxPooling2D((2, 2)))

#         model.add(tf.keras.layers.GlobalAveragePooling2D())
#         model.add(tf.keras.layers.Dense(256, activation="relu"))

#     else:
#         raise ValueError("model_version must be 'basic', 'deep', or 'gap'")

#     if dropout_rate > 0:
#         model.add(tf.keras.layers.Dropout(dropout_rate))

#     model.add(tf.keras.layers.Dense(40, activation="sigmoid"))

#     if use_sched:
#         lr = tf.keras.optimizers.schedules.ExponentialDecay(
#             initial_learning_rate=1e-4,
#             decay_steps=7000,
#             decay_rate=0.95
#         )
#     else:
#         lr = 1e-4

#     optimizer = tf.keras.optimizers.AdamW(
#         learning_rate=lr,
#         weight_decay=weight_decay
#     )

#     model.compile(
#         optimizer=optimizer,
#         loss="binary_crossentropy",
#         metrics=[tf.keras.metrics.BinaryAccuracy(name="bin_acc")]
#     )

#     return model
# q3_configs = [
#     # experiment 1
#     # Baseline best 0.6593
#     # {"name": "cnn_basic",      "model_version": "basic", "dropout": 0.0, "weight_decay": 0.0,  "scheduler": False},
#     # {"name": "cnn_deep",       "model_version": "deep",  "dropout": 0.0, "weight_decay": 0.0,  "scheduler": False},
#     # {"name": "cnn_deep_d01",   "model_version": "deep",  "dropout": 0.1, "weight_decay": 0.0,  "scheduler": False},
#     # {"name": "cnn_deep_d02",   "model_version": "deep",  "dropout": 0.2, "weight_decay": 0.0,  "scheduler": False},
#     # {"name": "cnn_deep_adamw", "model_version": "deep",  "dropout": 0.1, "weight_decay": 1e-5, "scheduler": False},
#     # {"name": "cnn_gap",        "model_version": "gap",   "dropout": 0.1, "weight_decay": 1e-5, "scheduler": False},

#     {"name": "basic2",      "model_version": "basic2", "dropout": 0.0, "weight_decay": 0.0,  "scheduler": False},
#     {"name": "basic3",       "model_version": "basic3",  "dropout": 0.0, "weight_decay": 0.0,  "scheduler": False},
#     {"name": "basic4",   "model_version": "basic4",  "dropout": 0.1, "weight_decay": 0.0,  "scheduler": False},

# ]
# early_stop = tf.keras.callbacks.EarlyStopping(
#     monitor="val_loss",
#     patience=7,
#     restore_best_weights=True
# )

# q3_results = []
# q3_histories = {}

# for cfg in q3_configs:
#     print(f"Running {cfg['name']} ...")

#     model = build_cnn_q3(
#         model_version=cfg["model_version"],
#         dropout_rate=cfg["dropout"],
#         weight_decay=cfg["weight_decay"],
#         use_sched=cfg["scheduler"]
#     )

#     history = model.fit(
#         train_ds,
#         validation_data=val_ds,
#         epochs=50,
#         callbacks=[early_stop],
#         verbose=1
#     )

#     y_val_prob = model.predict(val_ds, verbose=0)
#     y_val = np.concatenate([y for _, y in val_ds], axis=0)

#     metrics = evaluate_metrics(y_val, y_val_prob, threshold=0.5)

#     q3_results.append({
#         "Model": cfg["name"],
#         "Architecture": cfg["model_version"],
#         "Dropout": cfg["dropout"],
#         "WeightDecay": cfg["weight_decay"],
#         "Scheduler": cfg["scheduler"],
#         "Binary Accuracy": metrics["Binary Accuracy"],
#         "Hamming Loss": metrics["Hamming Loss"],
#         "Micro-F1": metrics["Micro-F1"],
#         "Macro-F1": metrics["Macro-F1"],
#         "Micro-mAP": metrics["Micro-mAP"],
#         "Macro-mAP": metrics["Macro-mAP"],
#         "Best Val Loss": min(history.history["val_loss"]),
#         "Epochs Run": len(history.history["loss"])
#     })

#     q3_histories[cfg["name"]] = history.history
# q3_results_df = pd.DataFrame(q3_results)

# q3_results_df = q3_results_df.sort_values(
#     by=["Macro-F1", "Macro-mAP"],
#     ascending=False
# ).reset_index(drop=True)

# q3_results_df = q3_results_df.round(4)
# q3_results_df.insert(0, "Rank", range(1, len(q3_results_df) + 1))

# display(q3_results_df)

# best_q3_model_name = q3_results_df.loc[0, "Model"]
# print("Best Q3 model:", best_q3_model_name)

Final Model

def build_cnn_basic(lr=1e-3, wd = 1e-5, dropout_rate=0.1):
    
    data_augmentation = tf.keras.Sequential([
    tf.keras.layers.RandomFlip("horizontal"),
    tf.keras.layers.RandomRotation(0.03),
    tf.keras.layers.RandomZoom(0.05),
    ])


    model = tf.keras.Sequential([
        tf.keras.layers.Input(shape=(64, 64, 3)),
        data_augmentation,

        tf.keras.layers.Conv2D(32, 3, padding="same", activation="relu"),
        tf.keras.layers.MaxPooling2D((2, 2)),

        tf.keras.layers.Conv2D(64, 3, padding="same", activation="relu"),
        tf.keras.layers.MaxPooling2D((2, 2)),

        tf.keras.layers.Conv2D(128, 3, padding="same", activation="relu"),
        tf.keras.layers.MaxPooling2D((2, 2)),

        tf.keras.layers.Flatten(),
        # tf.keras.layers.GlobalAveragePooling2D(),
        tf.keras.layers.Dense(128, activation="relu"),
        tf.keras.layers.Dropout(dropout_rate),
        tf.keras.layers.Dense(40, activation="sigmoid")
    ])

    # optimizer = tf.keras.optimizers.Adam(learning_rate=lr)

    optimizer = tf.keras.optimizers.AdamW(learning_rate=lr,weight_decay=wd)

    model.compile(
        optimizer=optimizer,
        loss="binary_crossentropy",
        metrics=[tf.keras.metrics.BinaryAccuracy(name="bin_acc")]
    )

    return model
early_stop = tf.keras.callbacks.EarlyStopping(
    monitor="val_loss",
    patience=2,
    restore_best_weights=True
)

final_cnn = build_cnn_basic(lr=1e-3, dropout_rate=0.1)

history = final_cnn.fit(
    train_ds,
    validation_data=val_ds,
    epochs=30,
    callbacks=[early_stop],
    verbose=1
)
Epoch 1/30
I0000 00:00:1774451642.461523    5749 cuda_dnn.cc:529] Loaded cuDNN version 91002
1272/1272 ━━━━━━━━━━━━━━━━━━━━ 28s 20ms/step - bin_acc: 0.8438 - loss: 0.3529 - val_bin_acc: 0.8894 - val_loss: 0.2537

Epoch 2/30

1272/1272 ━━━━━━━━━━━━━━━━━━━━ 26s 20ms/step - bin_acc: 0.8854 - loss: 0.2613 - val_bin_acc: 0.8943 - val_loss: 0.2416

Epoch 3/30

1272/1272 ━━━━━━━━━━━━━━━━━━━━ 26s 20ms/step - bin_acc: 0.8907 - loss: 0.2489 - val_bin_acc: 0.8988 - val_loss: 0.2322

Epoch 4/30

1272/1272 ━━━━━━━━━━━━━━━━━━━━ 26s 20ms/step - bin_acc: 0.8931 - loss: 0.2430 - val_bin_acc: 0.8999 - val_loss: 0.2300

Epoch 5/30

1272/1272 ━━━━━━━━━━━━━━━━━━━━ 25s 20ms/step - bin_acc: 0.8949 - loss: 0.2389 - val_bin_acc: 0.9000 - val_loss: 0.2295

Epoch 6/30

1272/1272 ━━━━━━━━━━━━━━━━━━━━ 26s 20ms/step - bin_acc: 0.8963 - loss: 0.2357 - val_bin_acc: 0.9016 - val_loss: 0.2259

Epoch 7/30

1272/1272 ━━━━━━━━━━━━━━━━━━━━ 25s 20ms/step - bin_acc: 0.8973 - loss: 0.2336 - val_bin_acc: 0.9015 - val_loss: 0.2263

Epoch 8/30

1272/1272 ━━━━━━━━━━━━━━━━━━━━ 25s 20ms/step - bin_acc: 0.8983 - loss: 0.2314 - val_bin_acc: 0.9019 - val_loss: 0.2251

Epoch 9/30

1272/1272 ━━━━━━━━━━━━━━━━━━━━ 25s 20ms/step - bin_acc: 0.8988 - loss: 0.2299 - val_bin_acc: 0.9025 - val_loss: 0.2240

Epoch 10/30

1272/1272 ━━━━━━━━━━━━━━━━━━━━ 26s 20ms/step - bin_acc: 0.8996 - loss: 0.2284 - val_bin_acc: 0.9037 - val_loss: 0.2215

Epoch 11/30

1272/1272 ━━━━━━━━━━━━━━━━━━━━ 27s 21ms/step - bin_acc: 0.9001 - loss: 0.2272 - val_bin_acc: 0.9040 - val_loss: 0.2212

Epoch 12/30

1272/1272 ━━━━━━━━━━━━━━━━━━━━ 27s 21ms/step - bin_acc: 0.9004 - loss: 0.2262 - val_bin_acc: 0.9027 - val_loss: 0.2232

Epoch 13/30

1272/1272 ━━━━━━━━━━━━━━━━━━━━ 26s 20ms/step - bin_acc: 0.9009 - loss: 0.2254 - val_bin_acc: 0.9047 - val_loss: 0.2192

Epoch 14/30

1272/1272 ━━━━━━━━━━━━━━━━━━━━ 26s 21ms/step - bin_acc: 0.9014 - loss: 0.2245 - val_bin_acc: 0.9035 - val_loss: 0.2217

Epoch 15/30

1272/1272 ━━━━━━━━━━━━━━━━━━━━ 26s 21ms/step - bin_acc: 0.9015 - loss: 0.2236 - val_bin_acc: 0.9038 - val_loss: 0.2207
y_val, y_val_prob = get_true_and_prob(final_cnn, val_ds)
val_results = evaluate_metrics(y_val, y_val_prob, threshold=0.5)

y_test, y_test_prob = get_true_and_prob(final_cnn, test_ds)
test_results = evaluate_metrics(y_test, y_test_prob, threshold=0.5)

comparison_df = pd.DataFrame([
    {"dataset": "validation", **val_results},
    {"dataset": "test", **test_results}
]).round(5)

comparison_df
dataset Binary Accuracy Hamming Loss Micro-F1 Macro-F1 Micro-mAP Macro-mAP
0 validation 0.90467 0.09533 0.77201 0.62745 0.87745 0.72531
1 test 0.89753 0.10247 0.76196 0.61827 0.87503 0.72253
# CNN Training vs Validation Loss
plt.figure(figsize=(8, 5))
plt.plot(history.history["loss"], label="Train Loss")
plt.plot(history.history["val_loss"], label="Val Loss")
plt.xlabel("Epoch")
plt.ylabel("Loss")
plt.title("CNN Training vs Validation Loss")
plt.legend()
plt.show()

# CNN Training vs Validation Binary Accuracy
plt.figure(figsize=(8, 5))
plt.plot(history.history["bin_acc"], label="Train Binary Accuracy")
plt.plot(history.history["val_bin_acc"], label="Val Binary Accuracy")
plt.xlabel("Epoch")
plt.ylabel("Binary Accuracy")
plt.title("CNN Training vs Validation Binary Accuracy")
plt.legend()
plt.show()

final_cnn.save(MODEL_DIR / "Q3 Final model.keras")
print("Model saved successfully")
Model saved successfully

Q3 Load and run saved model

# load saved model
q3_loaded_model = load_model(MODEL_DIR / "Q3 Final model.keras")
# Model performance
# validation
y_val, y_val_prob = get_true_and_prob(q3_loaded_model, val_ds)
val_results = evaluate_metrics(y_val, y_val_prob, threshold=0.5)

# test
y_test, y_test_prob = get_true_and_prob(q3_loaded_model, test_ds)
test_results = evaluate_metrics(y_test, y_test_prob, threshold=0.5)

# Combine
comparison_df = pd.DataFrame([
    {"dataset": "validation", **val_results},
    {"dataset": "test", **test_results}
]).round(5)

comparison_df
dataset Binary Accuracy Hamming Loss Micro-F1 Macro-F1 Micro-mAP Macro-mAP
0 validation 0.90467 0.09533 0.77201 0.62745 0.87745 0.72531
1 test 0.89753 0.10247 0.76196 0.61827 0.87503 0.72253

Q3 Evaluation and Discussion:

Test Result:

Binary Accuracy: 0.8981 Hamming Loss: 0.1018 Micro-F1: 0.7643 Macro-F1: 0.6270 Micro-mAP: 0.8758 Macro-mAP: 0.7270

The similarity between validation and test performance indicates strong generalisation, suggesting that overfitting has been effectively controlled.

Architecture:

  • The base CNN architecture consisted of three convolutional layers with filters [32, 64, 128], each using a kernel size of 3, same padding, and max pooling, followed by a dense layer of size 128.

  • Various architectural and hyperparameter experiments were conducted. Increasing the model depth and dense layer size did not improve performance and often reduced Macro-F1, indicating diminishing returns in feature extraction and potential overfitting. Batch normalisation and higher dropout rates were also found to negatively impact Macro-F1, suggesting that the model only required mild regularisation.

  • Light data augmentation (RandomFlip, RandomRotation, RandomZoom) was applied after the input layer, which provided a slight improvement in Macro-F1 by increasing data variability.

  • Early stopping with a patience of 2 was effective in preventing over-training, as validation loss plateaued after a few epochs (mostyl lesser than 13). Additionally, experiments on dropout rates showed that lower dropout (0.05) provided the best balance between generalisation and performance, while higher dropout led to underfitting and reduced Macro-F1.

  • The final model used a learning rate of 1e-3, dropout of 0.1, and patience of 2, achieving stable performance with minimal gap between validation and test results.

CNN vs MLP: * Compared to the MLP model from Question 2, the CNN achieved a higher Macro-F1 (approximately 0.63 vs 0.59), indicating improved performance on multi-label classification. * While Binary Accuracy showed smaller improvements, the CNN demonstrated better ability to capture minority class patterns. * In terms of convergence, the CNN required more epochs per run but stabilised quickly with early stopping. The close alignment between validation and test performance indicates that the CNN generalises well, whereas the MLP showed signs of overfitting due to its inability to preserve spatial information.

Sumamry Optimisation techniques such as learning rate tuning, dropout, data augmentation, and early stopping had a significant impact on performance. In particular, mild regularisation (dropout = 0.05) improved generalisation, while excessive regularisation degraded Macro-F1. Overall, the CNN outperformed the MLP by effectively capturing spatial features, making it more suitable for image-based multi-label classification.

Q4 Evaluation and Discussion:

Best Threshold = 0.3

  • A loop is run from the range 0 to 1 and each step of 0.05 are test.

  • A threshold of 0.5 is suboptimal in multi-label classification because different attributes have different probability distributions. Rare attributes often pr* oduce lower predicted probabilities, so using 0.5 leads to many false negatives and lowers F1 performance. In this case, a lower threshold (0.30) improved Macro-F1 by increasing recall.

  • Macro-F1 is more sensitive to rare attributes because it averages F1 scores equally across all labels. Poor performance on rare labels directly reduces Macro-F1. In contrast, Micro-F1 is dominated by common labels and is less affected by rare ones. (In our case we have some imbalance here)

  • The per-attribute thresholding approach performed better, particularly in terms of macro-F1 score, indicating improved balance across different attributes.

  • This improvement occurs because different attributes have varying distributions and levels of difficulty. A single global threshold cannot optimally separate all attributes, whereas per-attribute thresholds adapt to these differences.

  • Notably, mAP scores remain unchanged, as they are computed directly from predicted probabilities and do not depend on the chosen threshold.

# get validation predictions
y_val, y_val_prob = get_true_and_prob(q3_loaded_model, val_ds)

thresholds = np.arange(0.1, 0.95, 0.05)

# global threshold tuning
threshold_results = []
for t in thresholds:
    metrics = evaluate_metrics(y_val, y_val_prob, threshold=t)
    threshold_results.append({"Threshold": t, **metrics})

threshold_df = pd.DataFrame(threshold_results)
best_threshold = threshold_df.loc[threshold_df["Macro-F1"].idxmax(), "Threshold"]

display(threshold_df.sort_values("Macro-F1", ascending=False))
Threshold Binary Accuracy Hamming Loss Micro-F1 Macro-F1 Micro-mAP Macro-mAP
4 0.30 0.884213 0.115787 0.764986 0.677506 0.877452 0.725306
3 0.25 0.870898 0.129102 0.751501 0.675503 0.877452 0.725306
5 0.35 0.893706 0.106294 0.773536 0.673752 0.877452 0.725306
2 0.20 0.852577 0.147423 0.732432 0.666350 0.877452 0.725306
6 0.40 0.899755 0.100245 0.777111 0.663607 0.877452 0.725306
1 0.15 0.827078 0.172922 0.706173 0.649050 0.877452 0.725306
7 0.45 0.903176 0.096824 0.776426 0.648120 0.877452 0.725306
8 0.50 0.904670 0.095330 0.772005 0.627449 0.877452 0.725306
0 0.10 0.790835 0.209165 0.671004 0.620453 0.877452 0.725306
9 0.55 0.904700 0.095300 0.764432 0.602504 0.877452 0.725306
10 0.60 0.903376 0.096624 0.753544 0.574561 0.877452 0.725306
11 0.65 0.900907 0.099093 0.739244 0.540281 0.877452 0.725306
12 0.70 0.897520 0.102480 0.721917 0.502208 0.877452 0.725306
13 0.75 0.892986 0.107014 0.700398 0.459103 0.877452 0.725306
14 0.80 0.887565 0.112435 0.675145 0.413148 0.877452 0.725306
15 0.85 0.880814 0.119186 0.643780 0.365396 0.877452 0.725306
16 0.90 0.871941 0.128059 0.601671 0.315075 0.877452 0.725306
# final test evaluation on best validation threshold
y_test, y_test_prob = get_true_and_prob(q3_loaded_model, test_ds)

test_metrics_global = evaluate_metrics(y_test, y_test_prob, threshold=best_threshold)

print(pd.DataFrame([test_metrics_global]))
   Binary Accuracy  Hamming Loss  Micro-F1  Macro-F1  Micro-mAP  Macro-mAP
0         0.880373      0.119627  0.763539  0.670919   0.875026   0.722527
# find best threshold for each label
best_attr_thresholds = []

# loop through labels
for j in range(y_val.shape[1]):
    best_t = 0.5
    best_f1 = -1

    for t in thresholds:
        # compare prob against threshold and translate to binary
        y_pred_j = (y_val_prob[:, j] >= t).astype(int)
        f1 = f1_score(y_val[:, j], y_pred_j, zero_division=0)

        if f1 > best_f1:
            best_f1 = f1
            best_t = t

    best_attr_thresholds.append(best_t)

best_attr_thresholds = np.array(best_attr_thresholds)
print("Best attribute thresholds:")
print(best_attr_thresholds)
Best attribute thresholds:
[0.35 0.35 0.45 0.3  0.25 0.4  0.3  0.35 0.3  0.35 0.1  0.3  0.35 0.25
 0.25 0.45 0.4  0.35 0.55 0.45 0.6  0.5  0.25 0.25 0.45 0.3  0.25 0.3
 0.3  0.4  0.4  0.5  0.3  0.35 0.4  0.5  0.65 0.2  0.4  0.55]
# apply attribute thresholds on test set
y_test_pred_attr = (y_test_prob >= best_attr_thresholds.reshape(1, -1)).astype(int)

test_metrics_attr = {
    "Binary Accuracy": (y_test == y_test_pred_attr).mean(),
    "Hamming Loss": hamming_loss(y_test, y_test_pred_attr),
    "Micro-F1": f1_score(y_test, y_test_pred_attr, average="micro", zero_division=0),
    "Macro-F1": f1_score(y_test, y_test_pred_attr, average="macro", zero_division=0),
    "Micro-mAP": average_precision_score(y_test, y_test_prob, average="micro"),
    "Macro-mAP": average_precision_score(y_test, y_test_prob, average="macro"),
}

print("Test metrics using attribute thresholds:")
print(pd.DataFrame([test_metrics_attr]))
Test metrics using attribute thresholds:
   Binary Accuracy  Hamming Loss  Micro-F1  Macro-F1  Micro-mAP  Macro-mAP
0         0.883758      0.116242  0.763753  0.682515   0.875026   0.722527
Translate