import numpy as np import pandas as pdimport osfrom pathlib import Pathimport tensorflow as tffrom tensorflow.keras import layers, models, regularizersfrom sklearn.metrics import hamming_loss, f1_score, average_precision_scoreimport matplotlib.pyplot as pltimport seaborn as snsimport randomfrom itertools import productfrom 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)exceptNameError: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 candidateraiseFileNotFoundError("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_DIROUTPUT_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 samplingSEED =888os.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.
# Load local split and attribute filespartition_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] ifnot p.exists()]if missing:raiseFileNotFoundError("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 tabledf = 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 notin ["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:raiseFileNotFoundError(f"{missing_images} images listed in the metadata were not found under {IMG_DIR}")# Data Split and clean up indextrain = 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 predictiondef 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 resultsdef 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 functionIMG_SIZE = (64, 64)BATCH_SIZE =128def 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.0return img, label_vecdef 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 dstrain_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])
# Take 1 batch from training datasetfor 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 vectorprint("First label vector shape:", labels[0].shape)print("First 10 labels:", labels[0].numpy()[:10])
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)
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.
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
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
# CNN Training vs Validation Lossplt.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 Accuracyplt.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 modelq3_loaded_model = load_model(MODEL_DIR /"Q3 Final model.keras")
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 predictionsy_val, y_val_prob = get_true_and_prob(q3_loaded_model, val_ds)thresholds = np.arange(0.1, 0.95, 0.05)# global threshold tuningthreshold_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 thresholdy_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]))
# find best threshold for each labelbest_attr_thresholds = []# loop through labelsfor j inrange(y_val.shape[1]): best_t =0.5 best_f1 =-1for 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)