From 9285ede90a7bdb206d47c791d1d571b51c6f6d07 Mon Sep 17 00:00:00 2001 From: Tinsae Date: Fri, 1 May 2026 15:28:10 +0200 Subject: [PATCH 01/20] Initial commit --- .gitignore | 27 +++++++++ Data.py | 81 +++++++++++++++++++++++++ IdentitySubset.py | 13 ++++ Predict.py | 67 +++++++++++++++++++++ ReadME.md | 42 +++++++++++++ SetUp.py | 14 +++++ Test.py | 0 Tune.py | 112 +++++++++++++++++++++++++++++++++++ architectures/DenseNet121.py | 15 +++++ architectures/Inception.py | 47 +++++++++++++++ architectures/Model.py | 99 +++++++++++++++++++++++++++++++ architectures/ResNet18.py | 22 +++++++ architectures/ResNet50.py | 22 +++++++ dependencies.txt | 5 ++ 14 files changed, 566 insertions(+) create mode 100644 .gitignore create mode 100644 Data.py create mode 100644 IdentitySubset.py create mode 100644 Predict.py create mode 100644 ReadME.md create mode 100644 SetUp.py create mode 100644 Test.py create mode 100644 Tune.py create mode 100644 architectures/DenseNet121.py create mode 100644 architectures/Inception.py create mode 100644 architectures/Model.py create mode 100644 architectures/ResNet18.py create mode 100644 architectures/ResNet50.py create mode 100644 dependencies.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..408a3ac --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ + +# Python cache +__pycache__/ +*.pyc +*.pyo + +# Virtual environment +venv/ +.venv/ +bin/ +lib/ +lib64/ +include/ +share/ +pyvenv.cfg + +# Data & datasets +data/ +bin/ + +# Model weights +*.pth + +# System / logs +.DS_Store +*.log +*.tmp \ No newline at end of file diff --git a/Data.py b/Data.py new file mode 100644 index 0000000..3e20860 --- /dev/null +++ b/Data.py @@ -0,0 +1,81 @@ +from torchvision import datasets, transforms, models +import torch +import numpy as np + +# transform images to size +def transform(res): + return transforms.Compose([ + # ResNet expects 224 x 224 res + transforms.Resize((res, res)), + transforms.RandomHorizontalFlip(), + transforms.ToTensor(), + # normalise to + transforms.Normalize( + mean=[0.485, 0.456, 0.406], + std=[0.229, 0.224, 0.225] + ) + ]) + +# Load data with 'identity' as target and transform it +def get_set(res): + return datasets.CelebA( + root='./data', + split='all', + target_type='identity', + download=True, + transform=transform(res) + ) + + +def get_ids_and_counts(dataset): + return torch.unique( + dataset.identity, + return_counts=True + ) + +# filter selected identities from dataset +# How many classes, how many images per class +def select_ids( dataset, sample_size, class_size): + ids, counts = get_ids_and_counts(dataset=dataset) + eligible_mask = counts >= sample_size + eligible_ids = ids[eligible_mask].numpy() + + if len(eligible_ids) < class_size: + raise ValueError( + f"Only found {len(eligible_ids)} identities with {sample_size}+ images." + ) + + # Randomly select 50 identities + return np.random.choice(eligible_ids, class_size, replace=False) + +# optional function to get max amount of samples per class +def select_balanced_ids(dataset, class_size): + ids, counts = get_ids_and_counts(dataset=dataset) + + # sort by number of images (descending) + sorted_indices = torch.argsort(counts, descending=True) + + top_ids = ids[sorted_indices][:class_size].numpy() + + return np.array(top_ids, dtype=int) + + +# split class images to train and test set. +def get_indices(dataset, identities, split_at): + train_indices = [] + test_indices = [] + + #training_sample = int(sample_size * training_ratio) + + for person_id in identities: + # Get all indices for this specific person + indices = torch.where(dataset.identity == person_id)[0].numpy() + + # Shuffle the indices for this person + np.random.shuffle(indices) + + # split data to testing and training + train_indices.extend(indices[:split_at]) + test_indices.extend(indices[split_at:]) + + return train_indices, test_indices diff --git a/IdentitySubset.py b/IdentitySubset.py new file mode 100644 index 0000000..655b393 --- /dev/null +++ b/IdentitySubset.py @@ -0,0 +1,13 @@ + +import torch + +class IdentitySubset(torch.utils.data.Dataset): + def __init__(self, full_ds, indices, id_mapping): + self.full_ds = full_ds + self.indices = indices + self.id_mapping = id_mapping + def __getitem__(self, idx): + img, old_id = self.full_ds[self.indices[idx]] + return img, self.id_mapping[old_id.item()] + def __len__(self): + return len(self.indices) \ No newline at end of file diff --git a/Predict.py b/Predict.py new file mode 100644 index 0000000..554d887 --- /dev/null +++ b/Predict.py @@ -0,0 +1,67 @@ + + + +import torch +import numpy as np + +@torch.inference_mode() # More memory-efficient than no_grad() +def get_loss_per_sample(model, data_loader, device): + """ + Returns a list of individual losses for every sample in the loader. + Useful for MIA to see how 'certain' the model is about specific images. + """ + model.eval() + criterion = torch.nn.CrossEntropyLoss(reduction='none') # Crucial: returns loss per image + all_losses = [] + + for inputs, labels in data_loader: + inputs, labels = inputs.to(device), labels.to(device) + + outputs = model(inputs) + + # Calculate loss for each image in the batch individually + loss = criterion(outputs, labels) + + all_losses.extend(loss.cpu().numpy()) + + return all_losses + + +@torch.inference_mode() +def get_losses_by_class(model, data_loader, device): + """ + Returns a dictionary: { class_id: [list_of_losses_for_this_class] } + """ + model.eval() + criterion = torch.nn.CrossEntropyLoss(reduction='none') + + class_losses = {} + + for inputs, labels in data_loader: + inputs, labels = inputs.to(device), labels.to(device) + outputs = model(inputs) + + # Get individual losses + losses = criterion(outputs, labels).cpu().numpy() + labels_np = labels.cpu().numpy() + + for i, class_id in enumerate(labels_np): + if class_id not in class_losses: + class_losses[class_id] = [] + class_losses[class_id].append(losses[i]) + + return class_losses + + +# evaluate MIA +def eval_MIA(forgotten_losses, never_seen_losses): + avg_f_loss = np.mean(forgotten_losses) + avg_ns_loss = np.mean(never_seen_losses) + + print(f"Average Loss on Forgotten Identity: {avg_f_loss:.4f}") + print(f"Average Loss on Unknown Identities: {avg_ns_loss:.4f}") + + if avg_f_loss < avg_ns_loss * 0.8: + print("MIA Warning: Model still shows high certainty on forgotten data.") + else: + print("MIA Success: Model treats forgotten data like unknown data.") diff --git a/ReadME.md b/ReadME.md new file mode 100644 index 0000000..4b4a774 --- /dev/null +++ b/ReadME.md @@ -0,0 +1,42 @@ +# Python venv +Start a python environment here in this directory +```py +python -m venv . +``` + +Then we start the env using +```py +source ./bin/activate +``` + +We can then install whats needed with `pip`. for exampe +we can put all dependencies in some text file. say dependencies.txt +```py +# pip install +# already added dependencies.txt +pip install -r dependencies.txt + +``` + +Downloading the data from google drive was impossible. So Downloaded them manualy +and They need to be put in the a ./data directory +The download url was available in the error log. +`https://drive.google.com/uc?id=0B7EVK8r0v71pZjFTYXZWM3FlRnM` +this is the same location thats available in the official site + +``` + +``` +Root_dir/ +└── data/ + └── celeba/ + ├── img_align_celeba.zip + ├── list_attr_celeba.txt + ├── list_bbox_celeba.txt + ├── list_eval_partition.txt + └── list_landmarks_align_celeba.txt + +``` + +once this is done manually + diff --git a/SetUp.py b/SetUp.py new file mode 100644 index 0000000..939998c --- /dev/null +++ b/SetUp.py @@ -0,0 +1,14 @@ +## +import torch +from torchvision import datasets, transforms, models + +def get_device(): + + if torch.cuda.is_available(): + # clear cach to boost memory + # for new round + torch.cuda.empty_cache() + return torch.device("cuda") + else: + return torch.device("cpu") + diff --git a/Test.py b/Test.py new file mode 100644 index 0000000..e69de29 diff --git a/Tune.py b/Tune.py new file mode 100644 index 0000000..52d670a --- /dev/null +++ b/Tune.py @@ -0,0 +1,112 @@ +import torch +from torch.utils.data import DataLoader +from sklearn.metrics import classification_report +import SetUp +from Data import * +from IdentitySubset import IdentitySubset +# models +from architectures.Model import Model, Architecture + +# numbre of classes +CLASS_SIZE = 30 +# batch +BATCH_SIZE = 16 + +# size of images per class trainset + testset +# 30 works best, more than that and we dont have enough data +SAMPLE_SIZE = 30 + +# this is then full sample - test sample +TRAINING_SMPLE = 28 + +# learning rate +LR_RATE = 0.0001 +EPOCHS = 20 + +# depends on model architecture +# ResNet, DenseNet = 224 +# Inception = 299 +RESOLUTION = 299 + +# model architecture +arch = Architecture.INCEPTION + +# DATA PREPARATION +# load data set and prepare +dataset = get_set(res = RESOLUTION) +# select identities for experiment +selected_identities = select_ids( + dataset = dataset, + sample_size = SAMPLE_SIZE, + class_size = CLASS_SIZE + ) + +print(f'> Selected {CLASS_SIZE} random identity classes from CelebA dataset.') +print(f'> A class has {TRAINING_SMPLE} train and {SAMPLE_SIZE-TRAINING_SMPLE} test samples') + +# split class images to train/test indices +train_indices, test_indices = get_indices( + dataset = dataset, + identities = selected_identities, + split_at = TRAINING_SMPLE +) + +# helps map class id to index +id_map = {old_id: new_id for new_id, old_id in enumerate(selected_identities)} + +# we remap identities because crossEntropyLoss requires in indices 0 -> (n-1) +# where n = class size. +train_data = IdentitySubset( + dataset, + train_indices, + id_map) + +train_loader = DataLoader( + train_data, + batch_size = BATCH_SIZE, + shuffle = True) + +print(f"> Total training images: {len(train_data)}") + +print(f'> Constants : Classes = {CLASS_SIZE}, Batch = {BATCH_SIZE}, epochs = {EPOCHS}') + +# MODEL PREPARATION +# cuda if exists (it does here) +device = SetUp.get_device() +# Create model using Factory +model = Model.create( + arch = arch, + device = device, + size = CLASS_SIZE) + +# FINETUNING +model.train( + epochs = EPOCHS, + loader = train_loader, + rate = LR_RATE) + +# save. +torch.save( + model.get().state_dict(), + f'{arch.name}.pth') + +# done tuning +print('Model saved!') + +# EVALUATE +# Testing +test_data = IdentitySubset( + dataset, + test_indices, + id_map) + +test_loader = DataLoader( + test_data, + batch_size=BATCH_SIZE, + shuffle=False) + +print(f"Total test images for these {CLASS_SIZE} classes: {len(test_data)}") + +# Evaluate +model.evaluate( + loader = test_loader) \ No newline at end of file diff --git a/architectures/DenseNet121.py b/architectures/DenseNet121.py new file mode 100644 index 0000000..71a2576 --- /dev/null +++ b/architectures/DenseNet121.py @@ -0,0 +1,15 @@ + +import torch.nn as nn +from torchvision import models +from architectures.Model import Model + +class DenseNet121(Model): + def get(self): + + # load pretrained + m = models.densenet121(weights=models.DenseNet121_Weights.DEFAULT) + # will modify only the final layers + num_ftrs = m.classifier.in_features + m.classifier = nn.Linear(num_ftrs, self.size) + + return m.to(self.device) \ No newline at end of file diff --git a/architectures/Inception.py b/architectures/Inception.py new file mode 100644 index 0000000..aff33b8 --- /dev/null +++ b/architectures/Inception.py @@ -0,0 +1,47 @@ + +import torch +import torch.nn as nn +import torch.optim as optim +from torchvision import models +import time + +# Base model +from architectures.Model import Model + +class Inception(Model): + def get(self): + model = models.inception_v3(weights=models.Inception_V3_Weights.DEFAULT) + #for param in model.parameters(): + # param.requires_grad = False + model.fc = nn.Linear(model.fc.in_features, self.size) + model.AuxLogits.fc = nn.Linear(model.AuxLogits.fc.in_features, self.size) + return model.to(self.device) + + def train(self, epochs, loader, rate): + # Override because Inception returns a tuple (main, aux) + criterion = nn.CrossEntropyLoss() + optimizer = optim.Adam(filter(lambda p: p.requires_grad, self.model.parameters()), lr=rate) + + print(f"Starting training on {self.device}...") + start_time = time.time() + + self.model.train() + + for epoch in range(epochs): + total_loss = 0.0 + for inputs, labels in loader: + + inputs, labels = inputs.to(self.device), labels.to(self.device) + optimizer.zero_grad() + + outputs, aux_outputs = self.model(inputs) + loss = criterion(outputs, labels) + 0.3 * criterion(aux_outputs, labels) + + loss.backward() + optimizer.step() + total_loss += loss.item() + + print(f"Epoch {epoch+1}/{epochs} | Loss: {total_loss/len(loader):.4f}") + + if self.device.type == 'cuda': torch.cuda.synchronize() + print(f"Training completed in: {time.time() - start_time:.2f}s") diff --git a/architectures/Model.py b/architectures/Model.py new file mode 100644 index 0000000..aaf7f55 --- /dev/null +++ b/architectures/Model.py @@ -0,0 +1,99 @@ +from abc import ABC, abstractmethod +import torch +import torch.nn as nn +import torch.optim as optim +import time +import numpy as np +from sklearn.metrics import classification_report + +class Model(ABC): + def __init__(self, device, size): + self.device = device + self.size = size + self.model = self.get() + + @abstractmethod + def get(self): + # return the model + return self.model + + def train(self, epochs, loader, rate): + criterion = nn.CrossEntropyLoss() + optimizer = optim.Adam(filter(lambda p: p.requires_grad, self.model.parameters()), lr=rate) + + print(f"Starting training on {self.device}...") + start_time = time.time() + self.model.train() + + for epoch in range(epochs): + total_loss = 0.0 + for inputs, labels in loader: + inputs, labels = inputs.to(self.device), labels.to(self.device) + optimizer.zero_grad() + outputs = self.model(inputs) + loss = criterion(outputs, labels) + loss.backward() + optimizer.step() + total_loss += loss.item() + + print(f"Epoch {epoch+1}/{epochs} | Loss: {total_loss / len(loader):.4f}") + + if self.device.type == 'cuda': torch.cuda.synchronize() + print(f"Training completed in: {time.time() - start_time:.2f}s") + + def evaluate(self, loader): + self.model.eval() + all_preds, all_labels = [], [] + print("\nEvaluating...") + + with torch.no_grad(): + for inputs, labels in loader: + inputs, labels = inputs.to(self.device), labels.to(self.device) + outputs = self.model(inputs) + _, predicted = torch.max(outputs, 1) + all_preds.extend(predicted.cpu().numpy()) + all_labels.extend(labels.cpu().numpy()) + + accuracy = 100 * (np.array(all_preds) == np.array(all_labels)).sum() / len(all_labels) + print(f"Test Accuracy: {accuracy:.2f}%") + print(classification_report(all_labels, all_preds, zero_division=0)) + + + # Using the factory patern here + @staticmethod + def create(arch, device, size): + print(f'>> MODEL ARCHITECTURE >> {arch.name}.') + + match arch: + + # ResNet18 + case Architecture.RESNET18: + from architectures.ResNet18 import ResNet18 + return ResNet18(device, size) + + # ResNet50 + case Architecture.RESNET50: + from architectures.ResNet18 import ResNet18 + return ResNet18(device, size) + + # INCEPTION + case Architecture.INCEPTION: + from architectures.Inception import Inception + return Inception(device, size) + + # DENSENET121 + case Architecture.DENSENET121: + from architectures.DenseNet121 import DenseNet121 + return DenseNet121(device, size) + case _: + raise ValueError(f"Unknown model: {arch}") + + +# model architectures +from enum import Enum, auto + +class Architecture(Enum): + RESNET18 = auto() + RESNET50 = auto() + INCEPTION = auto() + DENSENET121 = auto() \ No newline at end of file diff --git a/architectures/ResNet18.py b/architectures/ResNet18.py new file mode 100644 index 0000000..4d6cbab --- /dev/null +++ b/architectures/ResNet18.py @@ -0,0 +1,22 @@ + +import torch.nn as nn +from torchvision import models + +# Base model +from architectures.Model import Model + +class ResNet18(Model): + + def get(self): + m = models.resnet18(weights=models.ResNet18_Weights.DEFAULT) + + # freez all layers + for param in m.parameters(): + param.requires_grad = False + + # unfreez the last two + for param in m.layer3.parameters(): param.requires_grad = True + for param in m.layer4.parameters(): param.requires_grad = True + + m.fc = nn.Linear(m.fc.in_features, self.size) + return m.to(self.device) \ No newline at end of file diff --git a/architectures/ResNet50.py b/architectures/ResNet50.py new file mode 100644 index 0000000..a039ef1 --- /dev/null +++ b/architectures/ResNet50.py @@ -0,0 +1,22 @@ + +import torch.nn as nn +from torchvision import models + +# Base model +from architectures.Model import Model + +class ResNet50(Model): + + def get(self): + m = models.resnet50(weights=models.ResNet50_Weights.DEFAULT) + + # freez all layers + for param in m.parameters(): + param.requires_grad = False + + # unfreez the last two + for param in m.layer3.parameters(): param.requires_grad = True + for param in m.layer4.parameters(): param.requires_grad = True + + m.fc = nn.Linear(m.fc.in_features, self.size) + return m.to(self.device) \ No newline at end of file diff --git a/dependencies.txt b/dependencies.txt new file mode 100644 index 0000000..1033f55 --- /dev/null +++ b/dependencies.txt @@ -0,0 +1,5 @@ +torch +torchvision +gdown +numpy +scikit-learn \ No newline at end of file From 07e8563c5d803e86055e37acbe532e686882ca67 Mon Sep 17 00:00:00 2001 From: Tinsae Date: Fri, 1 May 2026 23:36:25 +0200 Subject: [PATCH 02/20] separated train and test transformation --- Data.py | 29 +++++++++++++++++++++++------ IdentitySubset.py | 13 ++++++++++--- Tune.py | 35 ++++++++++++++++++++++------------- lib64 | 1 + 4 files changed, 56 insertions(+), 22 deletions(-) create mode 120000 lib64 diff --git a/Data.py b/Data.py index 3e20860..53eaf95 100644 --- a/Data.py +++ b/Data.py @@ -2,12 +2,18 @@ from torchvision import datasets, transforms, models import torch import numpy as np -# transform images to size -def transform(res): +# train set transform +def train_transform(res): return transforms.Compose([ # ResNet expects 224 x 224 res + # Inception expects 299 x 299 transforms.Resize((res, res)), - transforms.RandomHorizontalFlip(), + transforms.RandomHorizontalFlip(p=0.5), + transforms.ColorJitter( + brightness=0.2, + contrast=0.2, + saturation=0.1 + ), transforms.ToTensor(), # normalise to transforms.Normalize( @@ -16,14 +22,26 @@ def transform(res): ) ]) +# test set transform +def test_transform(res): + return transforms.Compose([ + # Just standard resize to 224x224 + transforms.Resize((res, res)), + transforms.ToTensor(), + transforms.Normalize( + mean=[0.485, 0.456, 0.406], + std=[0.229, 0.224, 0.225] + ) + ]) + # Load data with 'identity' as target and transform it -def get_set(res): +def get_set(): return datasets.CelebA( root='./data', split='all', target_type='identity', download=True, - transform=transform(res) + transform=None ) @@ -66,7 +84,6 @@ def get_indices(dataset, identities, split_at): test_indices = [] #training_sample = int(sample_size * training_ratio) - for person_id in identities: # Get all indices for this specific person indices = torch.where(dataset.identity == person_id)[0].numpy() diff --git a/IdentitySubset.py b/IdentitySubset.py index 655b393..5db7685 100644 --- a/IdentitySubset.py +++ b/IdentitySubset.py @@ -2,12 +2,19 @@ import torch class IdentitySubset(torch.utils.data.Dataset): - def __init__(self, full_ds, indices, id_mapping): - self.full_ds = full_ds + def __init__(self, dataset, indices, id_mapping, transform=None): + self.dataset = dataset self.indices = indices self.id_mapping = id_mapping + self.transform = transform + def __getitem__(self, idx): - img, old_id = self.full_ds[self.indices[idx]] + img, old_id = self.dataset[self.indices[idx]] + + if self.transform: + img = self.transform(img) + return img, self.id_mapping[old_id.item()] + def __len__(self): return len(self.indices) \ No newline at end of file diff --git a/Tune.py b/Tune.py index 52d670a..845bfd4 100644 --- a/Tune.py +++ b/Tune.py @@ -8,15 +8,15 @@ from IdentitySubset import IdentitySubset from architectures.Model import Model, Architecture # numbre of classes -CLASS_SIZE = 30 +CLASS_SIZE = 20 # batch -BATCH_SIZE = 16 +BATCH_SIZE = 8 # size of images per class trainset + testset # 30 works best, more than that and we dont have enough data SAMPLE_SIZE = 30 -# this is then full sample - test sample +# this is then (full_sample - test_sample) TRAINING_SMPLE = 28 # learning rate @@ -26,14 +26,18 @@ EPOCHS = 20 # depends on model architecture # ResNet, DenseNet = 224 # Inception = 299 -RESOLUTION = 299 +RESOLUTION = 224 -# model architecture -arch = Architecture.INCEPTION +# model architecture options are +# - RESNET18 +# - RESNET50 +# - DENSENET121 +# - INCEPTION +arch = Architecture.RESNET18 # DATA PREPARATION # load data set and prepare -dataset = get_set(res = RESOLUTION) +dataset = get_set() # select identities for experiment selected_identities = select_ids( dataset = dataset, @@ -56,10 +60,12 @@ id_map = {old_id: new_id for new_id, old_id in enumerate(selected_identities)} # we remap identities because crossEntropyLoss requires in indices 0 -> (n-1) # where n = class size. +tr_transform = train_transform(res = RESOLUTION) train_data = IdentitySubset( - dataset, - train_indices, - id_map) + dataset=dataset, + indices=train_indices, + id_mapping=id_map, + transform=tr_transform) train_loader = DataLoader( train_data, @@ -94,11 +100,14 @@ torch.save( print('Model saved!') # EVALUATE + +te_transform = test_transform(RESOLUTION) # Testing test_data = IdentitySubset( - dataset, - test_indices, - id_map) + dataset = dataset, + indices=test_indices, + id_mapping=id_map, + transform=te_transform) test_loader = DataLoader( test_data, diff --git a/lib64 b/lib64 new file mode 120000 index 0000000..7951405 --- /dev/null +++ b/lib64 @@ -0,0 +1 @@ +lib \ No newline at end of file From e93dfe10178d344502994f656265d17ba39860bd Mon Sep 17 00:00:00 2001 From: Tinsae Date: Fri, 1 May 2026 23:47:08 +0200 Subject: [PATCH 03/20] readme edited --- ReadME.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/ReadME.md b/ReadME.md index 4b4a774..ca2373b 100644 --- a/ReadME.md +++ b/ReadME.md @@ -24,8 +24,6 @@ The download url was available in the error log. `https://drive.google.com/uc?id=0B7EVK8r0v71pZjFTYXZWM3FlRnM` this is the same location thats available in the official site -``` - ``` Root_dir/ └── data/ From a80d996b0cf2c275b3282fde795bb735588e668c Mon Sep 17 00:00:00 2001 From: Tinsae Date: Sat, 2 May 2026 00:08:38 +0200 Subject: [PATCH 04/20] readme --- ReadME.md | 43 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/ReadME.md b/ReadME.md index ca2373b..f44ec5d 100644 --- a/ReadME.md +++ b/ReadME.md @@ -36,5 +36,46 @@ Root_dir/ ``` -once this is done manually +once this is manually done, We can run finetunning a selected model. For now, 4 models are implemented. +- ResNet-18 +- ResNet-50 +- DenseNet121 +- Inception + +## Fine tuning +### Preparation + +Lets say we want to finetune **Inception**. In `Tune.py` we have to adjust the variables accordingly like so: +```py +# Set the class size. e.g +CLASS_SIZE = 50 +# set the batch eg. +BATCH_SIZE = 16 +# set the Tuning epochs. e.g +EPOCH = 20 +# set the correct image size +# if ResNet or DenseNet, we set this to 224 +RESOLUTION = 299 +# set the model architecture +arch = Architecture.INCEPTION +``` +Other variable that we can change are those that are related to data size. Namely Training sample size and full sample size. +```py +# full sample size per class +SAMPLE_SIZE = 30 + +# Training sample size is then (full_sample - test_sample) +TRAINING_SMPLE = 28 + +# while at it, we can also set the learning rate +LR_RATE = 0.0001 +``` + + +### Rune the process +After we have set all necessary variables to our liking, we run the process by running Tune.py with python interpreter +```shell +# open terminal, cd to project root and run +python Tune.py +``` From 1c04344ad6ab8e6b631e8e10f0cd459eef534656 Mon Sep 17 00:00:00 2001 From: Tinsae Date: Sun, 3 May 2026 17:48:51 +0200 Subject: [PATCH 05/20] added two modfels --- .gitignore | 29 ++--------------------------- Tune.py | 9 +++++---- architectures/Model.py | 14 ++++++++++++-- architectures/ResNet18.py | 8 ++++---- 4 files changed, 23 insertions(+), 37 deletions(-) diff --git a/.gitignore b/.gitignore index 408a3ac..f514b74 100644 --- a/.gitignore +++ b/.gitignore @@ -1,27 +1,2 @@ - -# Python cache -__pycache__/ -*.pyc -*.pyo - -# Virtual environment -venv/ -.venv/ -bin/ -lib/ -lib64/ -include/ -share/ -pyvenv.cfg - -# Data & datasets -data/ -bin/ - -# Model weights -*.pth - -# System / logs -.DS_Store -*.log -*.tmp \ No newline at end of file +# Created by venv; see https://docs.python.org/3/library/venv.html +* diff --git a/Tune.py b/Tune.py index 845bfd4..2f2a164 100644 --- a/Tune.py +++ b/Tune.py @@ -10,7 +10,7 @@ from architectures.Model import Model, Architecture # numbre of classes CLASS_SIZE = 20 # batch -BATCH_SIZE = 8 +BATCH_SIZE = 16 # size of images per class trainset + testset # 30 works best, more than that and we dont have enough data @@ -33,7 +33,8 @@ RESOLUTION = 224 # - RESNET50 # - DENSENET121 # - INCEPTION -arch = Architecture.RESNET18 +# - GOOGLENET +arch = Architecture.EFFICIENTNET # DATA PREPARATION # load data set and prepare @@ -94,7 +95,7 @@ model.train( # save. torch.save( model.get().state_dict(), - f'{arch.name}.pth') + f'{arch.name.lower()}.pth') # done tuning print('Model saved!') @@ -118,4 +119,4 @@ print(f"Total test images for these {CLASS_SIZE} classes: {len(test_data)}") # Evaluate model.evaluate( - loader = test_loader) \ No newline at end of file + loader = test_loader) diff --git a/architectures/Model.py b/architectures/Model.py index aaf7f55..53e7aa5 100644 --- a/architectures/Model.py +++ b/architectures/Model.py @@ -1,3 +1,4 @@ + from abc import ABC, abstractmethod import torch import torch.nn as nn @@ -85,10 +86,17 @@ class Model(ABC): case Architecture.DENSENET121: from architectures.DenseNet121 import DenseNet121 return DenseNet121(device, size) + # googleNet + case Architecture.GOOGLENET: + from architectures.GoogleNet import GoogleNet + return GoogleNet(device, size) + # EfficientNet + case Architecture.EFFICIENTNET: + from architectures.EfficentNet import EfficientNet + return EfficientNet(device, size) case _: raise ValueError(f"Unknown model: {arch}") - # model architectures from enum import Enum, auto @@ -96,4 +104,6 @@ class Architecture(Enum): RESNET18 = auto() RESNET50 = auto() INCEPTION = auto() - DENSENET121 = auto() \ No newline at end of file + DENSENET121 = auto() + GOOGLENET = auto() + EFFICIENTNET = auto() \ No newline at end of file diff --git a/architectures/ResNet18.py b/architectures/ResNet18.py index 4d6cbab..e0c1d9d 100644 --- a/architectures/ResNet18.py +++ b/architectures/ResNet18.py @@ -11,12 +11,12 @@ class ResNet18(Model): m = models.resnet18(weights=models.ResNet18_Weights.DEFAULT) # freez all layers - for param in m.parameters(): - param.requires_grad = False + #for param in m.parameters(): + # param.requires_grad = False # unfreez the last two - for param in m.layer3.parameters(): param.requires_grad = True - for param in m.layer4.parameters(): param.requires_grad = True + #for param in m.layer3.parameters(): param.requires_grad = True + #for param in m.layer4.parameters(): param.requires_grad = True m.fc = nn.Linear(m.fc.in_features, self.size) return m.to(self.device) \ No newline at end of file From 4cc9fa2bac8946268b322e89fa7d355ed5e54edf Mon Sep 17 00:00:00 2001 From: Tinsae Date: Tue, 5 May 2026 21:04:33 +0200 Subject: [PATCH 06/20] added more models for testing --- .gitignore | 16 ++++++++++++++-- Tune.py | 11 ++++++++--- architectures/DenseNet121.py | 2 +- architectures/EfficentNet.py | 19 +++++++++++++++++++ architectures/GoogleNet.py | 14 ++++++++++++++ architectures/Inception.py | 8 ++++---- architectures/Model.py | 36 +++++++++++++++++++++++++++++++----- architectures/ResNet18.py | 10 +++++----- architectures/ResNet50.py | 16 +++++++++++++++- architectures/ShuffleNet.py | 17 +++++++++++++++++ architectures/WideResNet.py | 15 +++++++++++++++ 11 files changed, 143 insertions(+), 21 deletions(-) create mode 100644 architectures/EfficentNet.py create mode 100644 architectures/GoogleNet.py create mode 100644 architectures/ShuffleNet.py create mode 100644 architectures/WideResNet.py diff --git a/.gitignore b/.gitignore index f514b74..037bf3a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,14 @@ -# Created by venv; see https://docs.python.org/3/library/venv.html -* +# Virtual Environment (the folders Git saw) +bin/ +lib/ +share/ +pyvenv.cfg +include/ + +# Data and Models +data/ +trained_models/ + +# Python cache +__pycache__/ +*.py[cod] diff --git a/Tune.py b/Tune.py index 2f2a164..402e658 100644 --- a/Tune.py +++ b/Tune.py @@ -8,7 +8,7 @@ from IdentitySubset import IdentitySubset from architectures.Model import Model, Architecture # numbre of classes -CLASS_SIZE = 20 +CLASS_SIZE = 50 # batch BATCH_SIZE = 16 @@ -34,7 +34,9 @@ RESOLUTION = 224 # - DENSENET121 # - INCEPTION # - GOOGLENET -arch = Architecture.EFFICIENTNET +# - EFFICIENTNET +# - SHUFFLENET +arch = Architecture.GOOGLENET # DATA PREPARATION # load data set and prepare @@ -93,9 +95,12 @@ model.train( rate = LR_RATE) # save. +model.save(filename=arch.name.lower()) +''' torch.save( model.get().state_dict(), - f'{arch.name.lower()}.pth') + f'trained/{arch.name.lower()}.pth' +)''' # done tuning print('Model saved!') diff --git a/architectures/DenseNet121.py b/architectures/DenseNet121.py index 71a2576..68a6735 100644 --- a/architectures/DenseNet121.py +++ b/architectures/DenseNet121.py @@ -12,4 +12,4 @@ class DenseNet121(Model): num_ftrs = m.classifier.in_features m.classifier = nn.Linear(num_ftrs, self.size) - return m.to(self.device) \ No newline at end of file + return m \ No newline at end of file diff --git a/architectures/EfficentNet.py b/architectures/EfficentNet.py new file mode 100644 index 0000000..9fbfc97 --- /dev/null +++ b/architectures/EfficentNet.py @@ -0,0 +1,19 @@ + +import torch.nn as nn +from torchvision import models + +# Base model +from architectures.Model import Model + +class EfficientNet(Model): + + def get(self): + + m = models.efficientnet_b1(weights=models.EfficientNet_B1_Weights.DEFAULT) + + # Unfreeze the last block for a lighter touch + for param in m.features[-1].parameters(): param.requires_grad = True + + # Standard classifier fix + m.classifier[1] = nn.Linear(m.classifier[1].in_features, self.size) + return m \ No newline at end of file diff --git a/architectures/GoogleNet.py b/architectures/GoogleNet.py new file mode 100644 index 0000000..33a535e --- /dev/null +++ b/architectures/GoogleNet.py @@ -0,0 +1,14 @@ + +import torch.nn as nn +from torchvision import models + +# Base model +from architectures.Model import Model + +class GoogleNet(Model): + + def get(self): + + m = models.googlenet(weights = models.GoogLeNet_Weights.DEFAULT) + m.fc = nn.Linear(m.fc.in_features, self.size) + return m \ No newline at end of file diff --git a/architectures/Inception.py b/architectures/Inception.py index aff33b8..b55997f 100644 --- a/architectures/Inception.py +++ b/architectures/Inception.py @@ -10,12 +10,12 @@ from architectures.Model import Model class Inception(Model): def get(self): - model = models.inception_v3(weights=models.Inception_V3_Weights.DEFAULT) + m = models.inception_v3(weights=models.Inception_V3_Weights.DEFAULT) #for param in model.parameters(): # param.requires_grad = False - model.fc = nn.Linear(model.fc.in_features, self.size) - model.AuxLogits.fc = nn.Linear(model.AuxLogits.fc.in_features, self.size) - return model.to(self.device) + m.AuxLogits.fc = nn.Linear(m.AuxLogits.fc.in_features, self.size) + m.fc = nn.Linear(m.fc.in_features, self.size) + return m def train(self, epochs, loader, rate): # Override because Inception returns a tuple (main, aux) diff --git a/architectures/Model.py b/architectures/Model.py index 53e7aa5..1c20997 100644 --- a/architectures/Model.py +++ b/architectures/Model.py @@ -6,17 +6,18 @@ import torch.optim as optim import time import numpy as np from sklearn.metrics import classification_report +from pathlib import Path class Model(ABC): def __init__(self, device, size): self.device = device self.size = size - self.model = self.get() + self.model = self.get().to(self.device) @abstractmethod def get(self): # return the model - return self.model + pass def train(self, epochs, loader, rate): criterion = nn.CrossEntropyLoss() @@ -60,6 +61,21 @@ class Model(ABC): print(classification_report(all_labels, all_preds, zero_division=0)) + def save(self, filename=None): + """ + Saves the model state_dict. Creates the directory if it doesn't exist. + """ + save_dir = Path("trained_models") + save_dir.mkdir(parents=True, exist_ok=True) + + # 2. Determine filename (Default to class name if not provided) + if filename is None: + filename = f"{self.__class__.__name__.lower()}.pth" + + save_path = save_dir / filename + torch.save(self.model.state_dict(), save_path) + + # Using the factory patern here @staticmethod def create(arch, device, size): @@ -74,8 +90,8 @@ class Model(ABC): # ResNet50 case Architecture.RESNET50: - from architectures.ResNet18 import ResNet18 - return ResNet18(device, size) + from architectures.ResNet50 import ResNet50 + return ResNet50(device, size) # INCEPTION case Architecture.INCEPTION: @@ -94,6 +110,14 @@ class Model(ABC): case Architecture.EFFICIENTNET: from architectures.EfficentNet import EfficientNet return EfficientNet(device, size) + #ShuffleNet + case Architecture.SHUFFLENET: + from architectures.ShuffleNet import ShuffleNet + return ShuffleNet(device, size) + # wide ResNet + case Architecture.WIDE_RESNET: + from architectures.WideResNet import WideResNet + return WideResNet(device, size) case _: raise ValueError(f"Unknown model: {arch}") @@ -106,4 +130,6 @@ class Architecture(Enum): INCEPTION = auto() DENSENET121 = auto() GOOGLENET = auto() - EFFICIENTNET = auto() \ No newline at end of file + EFFICIENTNET = auto() + SHUFFLENET = auto() + WIDE_RESNET = auto() \ No newline at end of file diff --git a/architectures/ResNet18.py b/architectures/ResNet18.py index e0c1d9d..3c11924 100644 --- a/architectures/ResNet18.py +++ b/architectures/ResNet18.py @@ -11,12 +11,12 @@ class ResNet18(Model): m = models.resnet18(weights=models.ResNet18_Weights.DEFAULT) # freez all layers - #for param in m.parameters(): - # param.requires_grad = False + for param in m.parameters(): + param.requires_grad = False # unfreez the last two - #for param in m.layer3.parameters(): param.requires_grad = True - #for param in m.layer4.parameters(): param.requires_grad = True + for param in m.layer3.parameters(): param.requires_grad = True + for param in m.layer4.parameters(): param.requires_grad = True m.fc = nn.Linear(m.fc.in_features, self.size) - return m.to(self.device) \ No newline at end of file + return m \ No newline at end of file diff --git a/architectures/ResNet50.py b/architectures/ResNet50.py index a039ef1..74a4df4 100644 --- a/architectures/ResNet50.py +++ b/architectures/ResNet50.py @@ -6,6 +6,18 @@ from torchvision import models from architectures.Model import Model class ResNet50(Model): + # NOTE: + # This model had it's best performance with the following configs + # numbre of classes + # CLASS_SIZE = 20 + # BATCH_SIZE = 16 + # SAMPLE_SIZE = 30 + # TRAINING_SMPLE = 28 + # LR_RATE = 0.0001 + # EPOCHS = 15 + # RESOLUTION = 224 + # NOTE: But it may be a one time thing. + # because testing again didn't repeat def get(self): m = models.resnet50(weights=models.ResNet50_Weights.DEFAULT) @@ -15,8 +27,10 @@ class ResNet50(Model): param.requires_grad = False # unfreez the last two + # NOTE: Freezing everything and unfrizing the last 3 yeilded the best performance + for param in m.layer2.parameters(): param.requires_grad = True for param in m.layer3.parameters(): param.requires_grad = True for param in m.layer4.parameters(): param.requires_grad = True m.fc = nn.Linear(m.fc.in_features, self.size) - return m.to(self.device) \ No newline at end of file + return m \ No newline at end of file diff --git a/architectures/ShuffleNet.py b/architectures/ShuffleNet.py new file mode 100644 index 0000000..fc2a52d --- /dev/null +++ b/architectures/ShuffleNet.py @@ -0,0 +1,17 @@ + + + +import torch.nn as nn +from torchvision import models + +# Base model +from architectures.Model import Model + +class ShuffleNet(Model): + + def get(self): + m = models.shufflenet_v2_x1_0(weights=models.ShuffleNet_V2_X1_0_Weights.DEFAULT) + + num_ftrs = m.fc.in_features + m.fc = nn.Linear(num_ftrs, self.size) + return m \ No newline at end of file diff --git a/architectures/WideResNet.py b/architectures/WideResNet.py new file mode 100644 index 0000000..b645096 --- /dev/null +++ b/architectures/WideResNet.py @@ -0,0 +1,15 @@ + + +import torch.nn as nn +from torchvision import models + +# Base model +from architectures.Model import Model + +class WideResNet(Model): + + def get(self): + # wide_resnet50_2 is a common high-performance choice + m = models.wide_resnet50_2(weights=models.Wide_ResNet50_2_Weights.DEFAULT) + m.fc = nn.Linear(m.fc.in_features, self.size) + return m \ No newline at end of file From 7531afcbd7c0f7f408f2213af1449bda6303ef65 Mon Sep 17 00:00:00 2001 From: Tinsae Date: Tue, 5 May 2026 21:08:32 +0200 Subject: [PATCH 07/20] Remove lib64 symlink from tracking --- .gitignore | 1 + lib64 | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) delete mode 120000 lib64 diff --git a/.gitignore b/.gitignore index 037bf3a..5f25f49 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ trained_models/ # Python cache __pycache__/ *.py[cod] +lib64 diff --git a/lib64 b/lib64 deleted file mode 120000 index 7951405..0000000 --- a/lib64 +++ /dev/null @@ -1 +0,0 @@ -lib \ No newline at end of file From cfa4da697eb7f8ceb969e0a8021224049c9f6a5d Mon Sep 17 00:00:00 2001 From: Tinsae Date: Tue, 5 May 2026 22:42:27 +0200 Subject: [PATCH 08/20] added loading and testing the model again --- ReadME.md | 6 +++++- Tune.py | 34 +++++++++++++++++++++------------- architectures/GoogleNet.py | 19 ++++++++++++++++++- architectures/Model.py | 25 ++++++++++++++++++++++--- 4 files changed, 66 insertions(+), 18 deletions(-) diff --git a/ReadME.md b/ReadME.md index f44ec5d..080db7d 100644 --- a/ReadME.md +++ b/ReadME.md @@ -36,11 +36,15 @@ Root_dir/ ``` -once this is manually done, We can run finetunning a selected model. For now, 4 models are implemented. +once this is manually done, We can run finetunning a selected model. For now, 8 models are implemented. - ResNet-18 - ResNet-50 - DenseNet121 - Inception +- GoogleNet +- ShuffleNet +- EfficientNet +- WideResNet ## Fine tuning ### Preparation diff --git a/Tune.py b/Tune.py index 402e658..8cc66e4 100644 --- a/Tune.py +++ b/Tune.py @@ -8,7 +8,7 @@ from IdentitySubset import IdentitySubset from architectures.Model import Model, Architecture # numbre of classes -CLASS_SIZE = 50 +CLASS_SIZE = 20 # batch BATCH_SIZE = 16 @@ -21,7 +21,7 @@ TRAINING_SMPLE = 28 # learning rate LR_RATE = 0.0001 -EPOCHS = 20 +EPOCHS = 16 # depends on model architecture # ResNet, DenseNet = 224 @@ -36,7 +36,7 @@ RESOLUTION = 224 # - GOOGLENET # - EFFICIENTNET # - SHUFFLENET -arch = Architecture.GOOGLENET +arch = Architecture.RESNET50 # DATA PREPARATION # load data set and prepare @@ -88,19 +88,15 @@ model = Model.create( device = device, size = CLASS_SIZE) -# FINETUNING +# we may need to load existing model or finetune model.train( - epochs = EPOCHS, - loader = train_loader, - rate = LR_RATE) + epochs = EPOCHS, + loader = train_loader, + rate = LR_RATE) -# save. + # save. model.save(filename=arch.name.lower()) -''' -torch.save( - model.get().state_dict(), - f'trained/{arch.name.lower()}.pth' -)''' + # done tuning print('Model saved!') @@ -125,3 +121,15 @@ print(f"Total test images for these {CLASS_SIZE} classes: {len(test_data)}") # Evaluate model.evaluate( loader = test_loader) + +# test again +reloaded = Model.create( + arch=arch, + device = device, + size = CLASS_SIZE + ) +reloaded.load(arch = arch) +print("Evaluating loaded") +reloaded.evaluate( + loader = test_loader +) diff --git a/architectures/GoogleNet.py b/architectures/GoogleNet.py index 33a535e..8dd9c9c 100644 --- a/architectures/GoogleNet.py +++ b/architectures/GoogleNet.py @@ -9,6 +9,23 @@ class GoogleNet(Model): def get(self): - m = models.googlenet(weights = models.GoogLeNet_Weights.DEFAULT) + m = models.googlenet(weights=models.GoogLeNet_Weights.DEFAULT) + + # 1. Handle the two Auxiliary Classifiers + # GoogLeNet has aux1 and aux2 to help training converge + #if m.aux_logits: + #m.aux1.fc = nn.Linear(m.aux1.fc.in_features, self.size) + #m.aux2.fc = nn.Linear(m.aux2.fc.in_features, self.size) + + # 2. Handle the Main Classifier m.fc = nn.Linear(m.fc.in_features, self.size) + + #for param in m.parameters(): + # param.requires_grad = False + + # Unfreezing the final stages for identity recognition + #for name, param in m.named_parameters(): + # if "inception5" in name or "fc" in name: + # param.requires_grad = True + return m \ No newline at end of file diff --git a/architectures/Model.py b/architectures/Model.py index 1c20997..50d506d 100644 --- a/architectures/Model.py +++ b/architectures/Model.py @@ -62,9 +62,7 @@ class Model(ABC): def save(self, filename=None): - """ - Saves the model state_dict. Creates the directory if it doesn't exist. - """ + save_dir = Path("trained_models") save_dir.mkdir(parents=True, exist_ok=True) @@ -72,8 +70,29 @@ class Model(ABC): if filename is None: filename = f"{self.__class__.__name__.lower()}.pth" + if not filename.endswith('.pth'): + filename += '.pth' + save_path = save_dir / filename torch.save(self.model.state_dict(), save_path) + print(f'Model saved to {save_path}') + + + + def load(self, arch): + + file_path = Path("trained_models") / f'{arch.name.lower()}.pth' + # does file exist + if not file_path.exists(): + raise FileNotFoundError(f'No checkpoint found at: {file_path}') + + # Load the weights + state_dict = torch.load(file_path, map_location=self.device, weights_only=True) + + self.model.load_state_dict(state_dict) + self.model.to(self.device) + print(f'Model loaded from {file_path}') + # Using the factory patern here From a4191fa00c7ab7a678be9036fb86966eced80f55 Mon Sep 17 00:00:00 2001 From: Tinsae Date: Tue, 5 May 2026 22:43:57 +0200 Subject: [PATCH 09/20] added loading and testing the model again --- architectures/Model.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/architectures/Model.py b/architectures/Model.py index 50d506d..256b050 100644 --- a/architectures/Model.py +++ b/architectures/Model.py @@ -16,7 +16,6 @@ class Model(ABC): @abstractmethod def get(self): - # return the model pass def train(self, epochs, loader, rate): @@ -66,7 +65,7 @@ class Model(ABC): save_dir = Path("trained_models") save_dir.mkdir(parents=True, exist_ok=True) - # 2. Determine filename (Default to class name if not provided) + # Determine filename (Default to class name if not provided) if filename is None: filename = f"{self.__class__.__name__.lower()}.pth" From 770b7be936e5c3daa84ff2154d4a8ca53f7aaf6e Mon Sep 17 00:00:00 2001 From: Tinsae Date: Sat, 9 May 2026 23:01:15 +0200 Subject: [PATCH 10/20] played around with CASIA-WEB-FACE --- .gitignore | 1 + Data.py | 9 +++---- DataAnalyser.py | 50 +++++++++++++++++++++++++++++++++++++++ Tune.py | 30 ++++++++++++++++------- architectures/ResNet18.py | 8 +++---- dependencies.txt | 3 ++- 6 files changed, 83 insertions(+), 18 deletions(-) create mode 100644 DataAnalyser.py diff --git a/.gitignore b/.gitignore index 5f25f49..11b32ea 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ include/ # Data and Models data/ +datasets/ trained_models/ # Python cache diff --git a/Data.py b/Data.py index 53eaf95..abab889 100644 --- a/Data.py +++ b/Data.py @@ -2,6 +2,7 @@ from torchvision import datasets, transforms, models import torch import numpy as np + # train set transform def train_transform(res): return transforms.Compose([ @@ -25,7 +26,6 @@ def train_transform(res): # test set transform def test_transform(res): return transforms.Compose([ - # Just standard resize to 224x224 transforms.Resize((res, res)), transforms.ToTensor(), transforms.Normalize( @@ -67,7 +67,7 @@ def select_ids( dataset, sample_size, class_size): return np.random.choice(eligible_ids, class_size, replace=False) # optional function to get max amount of samples per class -def select_balanced_ids(dataset, class_size): +def select_top_ids(dataset, class_size): ids, counts = get_ids_and_counts(dataset=dataset) # sort by number of images (descending) @@ -79,11 +79,12 @@ def select_balanced_ids(dataset, class_size): # split class images to train and test set. -def get_indices(dataset, identities, split_at): +def get_indices(dataset, identities, split_at, size = 30): train_indices = [] test_indices = [] #training_sample = int(sample_size * training_ratio) + np.random.seed(42) for person_id in identities: # Get all indices for this specific person indices = torch.where(dataset.identity == person_id)[0].numpy() @@ -93,6 +94,6 @@ def get_indices(dataset, identities, split_at): # split data to testing and training train_indices.extend(indices[:split_at]) - test_indices.extend(indices[split_at:]) + test_indices.extend(indices[split_at:size]) return train_indices, test_indices diff --git a/DataAnalyser.py b/DataAnalyser.py new file mode 100644 index 0000000..1c994a7 --- /dev/null +++ b/DataAnalyser.py @@ -0,0 +1,50 @@ + +#from Data import * +from datasets.Casia import * + +''' +Because the size of samples per class had the biggest impact +on training outcome, I decided to check the maximum amount of data +I can get from a class. +The highest I can get is +Rank | Identity ID |Count +----------------------------------- +1 | 3782 | 35 +2 | 2820 | 35 +3 | 3227 | 35 +4 | 3745 | 34 +5 | 3699 | 34 +6 | 8968 | 32 +7 | 9152 | 32 +8 | 9256 | 32 +9 | 2114 | 31 +... | ... | ... +17 | 4126 | 31 +18 | 3185 | 30 +... | ... | ... +50 | 3186 | 30 + +as can be seen, 3 classes have 35, 2 have 34, 3 have 32 and the rest have 30. +''' +def print_top_identity_stats(dataset, top_n=50): + # we get data + ids, counts = get_ids_and_counts(dataset) + # sort in descending order + sorted_counts, sorted_indices = torch.sort(counts, descending=True) + + # coresponding sorted ids + sorted_ids = ids[sorted_indices] + + # 4. Slice the first 'top_n' and print + print(f"{'Rank':<8} | {'Identity ID':<12} | {'Image Count':<12}") + print("-" * 35) + + for i in range(top_n): + identity_id = sorted_ids[i].item() + count = sorted_counts[i].item() + print(f"{i+1:<8} | {identity_id:<12} | {count:<12}") + +# Usage: +dataset = get_set() +print_top_identity_stats(dataset, 50) + diff --git a/Tune.py b/Tune.py index 8cc66e4..d968b44 100644 --- a/Tune.py +++ b/Tune.py @@ -1,16 +1,21 @@ -import torch +# Finetuning a selected model +# on a selected dataset +# using selected parameters + from torch.utils.data import DataLoader from sklearn.metrics import classification_report import SetUp from Data import * -from IdentitySubset import IdentitySubset +#from datasets.Casia import * +#from IdentitySubset import IdentitySubset +from datasets.UniversalIdentitySubset import UniversalIdentitySubset as IdentitySubset # models from architectures.Model import Model, Architecture # numbre of classes CLASS_SIZE = 20 # batch -BATCH_SIZE = 16 +BATCH_SIZE = 32 # size of images per class trainset + testset # 30 works best, more than that and we dont have enough data @@ -21,7 +26,7 @@ TRAINING_SMPLE = 28 # learning rate LR_RATE = 0.0001 -EPOCHS = 16 +EPOCHS = 20 # depends on model architecture # ResNet, DenseNet = 224 @@ -42,11 +47,18 @@ arch = Architecture.RESNET50 # load data set and prepare dataset = get_set() # select identities for experiment -selected_identities = select_ids( - dataset = dataset, - sample_size = SAMPLE_SIZE, - class_size = CLASS_SIZE - ) +#selected_identities = select_ids( +# dataset = dataset, +# sample_size = SAMPLE_SIZE, +# class_size = CLASS_SIZE +# ) + +# this selects the top 50 based on sample size +# that way repeated calls return the same classes +selected_identities = select_top_ids( + dataset=dataset, + class_size= CLASS_SIZE, +) print(f'> Selected {CLASS_SIZE} random identity classes from CelebA dataset.') print(f'> A class has {TRAINING_SMPLE} train and {SAMPLE_SIZE-TRAINING_SMPLE} test samples') diff --git a/architectures/ResNet18.py b/architectures/ResNet18.py index 3c11924..84cb19e 100644 --- a/architectures/ResNet18.py +++ b/architectures/ResNet18.py @@ -11,12 +11,12 @@ class ResNet18(Model): m = models.resnet18(weights=models.ResNet18_Weights.DEFAULT) # freez all layers - for param in m.parameters(): - param.requires_grad = False + #for param in m.parameters(): + # param.requires_grad = False # unfreez the last two - for param in m.layer3.parameters(): param.requires_grad = True - for param in m.layer4.parameters(): param.requires_grad = True + #for param in m.layer3.parameters(): param.requires_grad = True + #for param in m.layer4.parameters(): param.requires_grad = True m.fc = nn.Linear(m.fc.in_features, self.size) return m \ No newline at end of file diff --git a/dependencies.txt b/dependencies.txt index 1033f55..e2c6522 100644 --- a/dependencies.txt +++ b/dependencies.txt @@ -2,4 +2,5 @@ torch torchvision gdown numpy -scikit-learn \ No newline at end of file +scikit-learn +kagglehub \ No newline at end of file From e90480adbed8049ff5f9df3b08cc300278180b28 Mon Sep 17 00:00:00 2001 From: Tinsae Date: Sun, 31 May 2026 22:22:38 +0200 Subject: [PATCH 11/20] started unlearning setup --- Data.py | 4 ++ OOP.py | 48 ++++++++++++++ Tune.py | 16 +++++ architectures/Model.py | 16 +++++ architectures/ResNet50.py | 10 +-- js_evaluator/JS_Evaluator.py | 111 +++++++++++++++++++++++++++++++++ unlearning/CertifiedRemoval.py | 0 unlearning/LinearFiltration.py | 29 +++++++++ unlearning/Strategy.py | 0 unlearning/WeightFiltration.py | 0 10 files changed, 229 insertions(+), 5 deletions(-) create mode 100644 OOP.py create mode 100644 js_evaluator/JS_Evaluator.py create mode 100644 unlearning/CertifiedRemoval.py create mode 100644 unlearning/LinearFiltration.py create mode 100644 unlearning/Strategy.py create mode 100644 unlearning/WeightFiltration.py diff --git a/Data.py b/Data.py index abab889..e64a440 100644 --- a/Data.py +++ b/Data.py @@ -80,6 +80,10 @@ def select_top_ids(dataset, class_size): # split class images to train and test set. def get_indices(dataset, identities, split_at, size = 30): + + if split_at >= size: # debug safety + raise ValueError(f"Split point ({split_at}) must be less than total size ({size}).") + train_indices = [] test_indices = [] diff --git a/OOP.py b/OOP.py new file mode 100644 index 0000000..a7f69c6 --- /dev/null +++ b/OOP.py @@ -0,0 +1,48 @@ + +# This is from wikipedia pseudocode implementation of a single +# ThresholdLogic Unit. +# done to make me understand OOP the Python way + + +# no need for brackets if not inheriting +class ThresholdLogicUnit: + + # define members in init + def __init__(self, threshold, weights): + self.threshold = threshold + self.weights = weights + + + # If a function has to make use of member variables + # it has to have self as param + def fire(self,inputs): + tots = 0 + #for i in range(0,inputs.size()): + for val, weight in zip(inputs, self.weights): + if val: + tots+= weight + + return tots > self.threshold + + +def main(): + # data + weights = [0.5, -0.2, 0.8] + threshold = 1.0 + + # Instantiate the class + tlu = ThresholdLogicUnit(threshold, weights) + + # Test + test_inputs = [1, 1, 0] + result = tlu.fire(test_inputs) + + print(f"The unit fired: {result}") + +# The "Guard" +if __name__ == "__main__": + main() + + + + \ No newline at end of file diff --git a/Tune.py b/Tune.py index d968b44..789bc85 100644 --- a/Tune.py +++ b/Tune.py @@ -12,6 +12,8 @@ from datasets.UniversalIdentitySubset import UniversalIdentitySubset as Identity # models from architectures.Model import Model, Architecture +from unlearning import LinearFiltration, WeightFiltration, CertifiedRemoval + # numbre of classes CLASS_SIZE = 20 # batch @@ -145,3 +147,17 @@ print("Evaluating loaded") reloaded.evaluate( loader = test_loader ) + + +strategies_to_test = [ + LinearFiltration(target_class_idx=12), + WeightFiltration(target_class_idx=12), + CertifiedRemoval(target_class_idx=12) +] + +# Run the comparative benchmark seamlessly +execution_profiles = {} +for strategy in strategies_to_test: + # Each iteration clones weights back to fine-tuned state before running + runtime = my_model.unlearn(strategy, forget_loader, retain_loader) + execution_profiles[strategy.__class__.__name__] = runtime diff --git a/architectures/Model.py b/architectures/Model.py index 256b050..f5292e2 100644 --- a/architectures/Model.py +++ b/architectures/Model.py @@ -7,6 +7,7 @@ import time import numpy as np from sklearn.metrics import classification_report from pathlib import Path +from unlearning.Strategy import Strategy class Model(ABC): def __init__(self, device, size): @@ -92,6 +93,21 @@ class Model(ABC): self.model.to(self.device) print(f'Model loaded from {file_path}') + + def unlearn(self, strategy: Strategy, forget_loader, retain_loader): + """ Executes a targeted unlearning strategy and profiles efficiency """ + print(f"Executing: {strategy.__class__.__name__}...") + + start_time = time.time() + + # Delegate the actual algorithmic weight/logit manipulation to the strategy + strategy.apply(self.network, forget_loader, retain_loader) + + elapsed_time = time.time() - start_time + print(f"{strategy.__class__.__name__} completed in {elapsed_time:.4f} seconds.") + + return elapsed_time + # Using the factory patern here diff --git a/architectures/ResNet50.py b/architectures/ResNet50.py index 74a4df4..860271b 100644 --- a/architectures/ResNet50.py +++ b/architectures/ResNet50.py @@ -23,14 +23,14 @@ class ResNet50(Model): m = models.resnet50(weights=models.ResNet50_Weights.DEFAULT) # freez all layers - for param in m.parameters(): - param.requires_grad = False + #for param in m.parameters(): + #param.requires_grad = False # unfreez the last two # NOTE: Freezing everything and unfrizing the last 3 yeilded the best performance - for param in m.layer2.parameters(): param.requires_grad = True - for param in m.layer3.parameters(): param.requires_grad = True - for param in m.layer4.parameters(): param.requires_grad = True + #for param in m.layer2.parameters(): param.requires_grad = True + #for param in m.layer3.parameters(): param.requires_grad = True + #for param in m.layer4.parameters(): param.requires_grad = True m.fc = nn.Linear(m.fc.in_features, self.size) return m \ No newline at end of file diff --git a/js_evaluator/JS_Evaluator.py b/js_evaluator/JS_Evaluator.py new file mode 100644 index 0000000..3961e9b --- /dev/null +++ b/js_evaluator/JS_Evaluator.py @@ -0,0 +1,111 @@ +import torch +import torch.nn as nn +import torch.nn.functional as F +from torch.utils.data import DataLoader +import torchvision.models as models + +class ZeroRetrainForgettingEvaluator: + def __init__(self, unlearned_model: nn.Module, num_classes: int): + """ + Initializes the ZRF Evaluator. + + Args: + unlearned_model (nn.Module): Your fine-tuned & unlearned ResNet-50. + num_classes (int): Number of classes used in your CelebA task. + """ + # select device + if torch.cuda.is_available(): + self.device = torch.device("cuda") + elif hasattr(torch, "xpu") and torch.xpu.is_available(): + self.device = torch.device("xpu") # For Intel GPUs using IPEX + else: + self.device = torch.device("cpu") + + print(f"[INFO] Using device: {self.device}") + + # prepare the unlearned model + self.unlearned_model = unlearned_model.to(self.device) + self.unlearned_model.eval() + + # Instantiate a structurally matching, completely random model + print(f"[INFO] Initializing random baseline ResNet-50 with {num_classes} classes...") + self.random_model = self.get_random_model(num_classes) + self.random_model = self.random_model.to(self.device) + self.random_model.eval() + + # gets randomly initialised model + # for comparison with unlearned model + def get_random_model(num_classes): + print(f"[INFO] Initializing random baseline ResNet-50 with {num_classes} classes...") + model = models.resnet50(weights=None) + model.fc = nn.Linear(model.fc.in_features, num_classes) + return model + + + # compute divergence + def _compute_js_divergence(self, p: torch.Tensor, q: torch.Tensor) -> float: + """ + Computes the Jensen-Shannon (JS) Divergence between two probability distributions. + + Args: + p, q (Tensor): Tensors of shape (batch_size, num_classes) containing probabilities. + """ + # Avoid log(0) issues by adding a tiny epsilon + eps = 1e-12 + p = torch.clamp(p, eps, 1.0) + q = torch.clamp(q, eps, 1.0) + + # Calculate the midpoint distribution + m = 0.5 * (p + q) + + # Compute KL Divergence natively: KL(P || M) and KL(Q || M) + kl_pm = torch.sum(p * (torch.log(p) - torch.log(m)), dim=1) + kl_qm = torch.sum(q * (torch.log(q) - torch.log(m)), dim=1) + + # JS Divergence is the average of both KL divergences + js_div = 0.5 * (kl_pm + kl_qm) + + # Return the mean divergence across the entire batch + return js_div.mean().item() + + def evaluate_forget_class(self, dataset, batch_size: int = 32) -> float: + """ + Evaluates the unlearned model against the random model using images + from the forgotten class/identity. + + Args: + dataset (Dataset): A PyTorch Dataset containing images of the forget set. + batch_size (int): Batch size for evaluation. + + Returns: + float: The ZRF score (JS Divergence). A lower divergence means + the unlearned model is behaving exactly like a random model. + """ + dataloader = DataLoader(dataset, batch_size=batch_size, shuffle=False) + + total_js_div = 0.0 + total_samples = 0 + + # No gradients needed for evaluation + with torch.no_grad(): + for images, _ in dataloader: + images = images.to(self.device) + batch_len = images.size(0) + + # Get raw outputs (logits) + unlearned_logits = self.unlearned_model(images) + random_logits = self.random_model(images) + + # Convert logits to probability distributions via Softmax + unlearned_probs = F.softmax(unlearned_logits, dim=1) + random_probs = F.softmax(random_logits, dim=1) + + # Calculate JS divergence for this batch + batch_js = self._compute_js_divergence(unlearned_probs, random_probs) + + # Weighted average based on batch size (handles final smaller batches perfectly) + total_js_div += batch_js * batch_len + total_samples += batch_len + + final_zrf_score = total_js_div / total_samples + return final_zrf_score \ No newline at end of file diff --git a/unlearning/CertifiedRemoval.py b/unlearning/CertifiedRemoval.py new file mode 100644 index 0000000..e69de29 diff --git a/unlearning/LinearFiltration.py b/unlearning/LinearFiltration.py new file mode 100644 index 0000000..abb8306 --- /dev/null +++ b/unlearning/LinearFiltration.py @@ -0,0 +1,29 @@ + +import torch +from Strategy import Strategy + +class NormalizingLinearFiltration(Strategy): + def __init__(self, target_class_idx): + self.target_class_idx = target_class_idx + + def apply(self, model, forget_loader, retain_loader): + model.eval() + # Freeze parameters structurally + for param in model.parameters(): + param.requires_grad = False + + with torch.no_grad(): + # we modify only classification head + # Shape: [num_classes, feature_dim] + W = model.fc.weight.data + + # Compute the normalization transformation projection matrix (A) + # (In your full code, calculate A here matching Baumhauer et al.'s equations) + num_classes = W.shape[0] + A = torch.eye(num_classes, device=W.device) + # Mask/blend target class index distribution configurations here... + A[self.target_class_idx, :] = 0.0 + + # 3. Direct weight matrix override: W_filtered = A * W + sanitized_W = torch.mm(A, W) + model.fc.weight.copy_(sanitized_W) \ No newline at end of file diff --git a/unlearning/Strategy.py b/unlearning/Strategy.py new file mode 100644 index 0000000..e69de29 diff --git a/unlearning/WeightFiltration.py b/unlearning/WeightFiltration.py new file mode 100644 index 0000000..e69de29 From 61c34471508075c79eb5e3b78b44ffee3b59e572 Mon Sep 17 00:00:00 2001 From: Tinsae Date: Sun, 7 Jun 2026 10:36:03 +0200 Subject: [PATCH 12/20] unlearning LF --- Data.py | 38 +++++++++ LinearFiltration_metrics.txt | 7 ++ Tune.py | 120 +++++++++++++++------------ architectures/Model.py | 107 +++++++++++++++++++++++- reports/LinearFiltration_metrics.txt | 35 ++++++++ reports/resnet50-finetunned.csv | 21 +++++ reports/resnet50-forget.csv | 21 +++++ reports/resnet50-retain.csv | 21 +++++ unlearning/LinearFiltration.py | 51 ++++++++---- unlearning/Strategy.py | 47 +++++++++++ 10 files changed, 395 insertions(+), 73 deletions(-) create mode 100644 LinearFiltration_metrics.txt create mode 100644 reports/LinearFiltration_metrics.txt create mode 100644 reports/resnet50-finetunned.csv create mode 100644 reports/resnet50-forget.csv create mode 100644 reports/resnet50-retain.csv diff --git a/Data.py b/Data.py index e64a440..8ec35c7 100644 --- a/Data.py +++ b/Data.py @@ -1,8 +1,10 @@ from torchvision import datasets, transforms, models +from torch.utils.data import Dataset, DataLoader, Subset import torch import numpy as np + # train set transform def train_transform(res): return transforms.Compose([ @@ -101,3 +103,39 @@ def get_indices(dataset, identities, split_at, size = 30): test_indices.extend(indices[split_at:size]) return train_indices, test_indices + + + +def get_forget_retain_loaders(dataset: Dataset, forget_class_idx: int, batch_size: int = 32) -> tuple[DataLoader, DataLoader]: + """ + Splits an IdentitySubset or standard Dataset into forget and retain sets + based on a remapped target class index. + """ + # 1. Safely extract targets whether it's a standard dataset or a Subset wrapper + if hasattr(dataset, 'targets'): + targets = dataset.targets + elif hasattr(dataset, 'identity'): # Raw CelebA support + targets = dataset.identity + else: + # If it's an IdentitySubset or standard Subset, extract mapped targets sequentially + # This guarantees we get the 0 -> (n-1) remapped labels + targets = [dataset[i][1] for i in range(len(dataset))] + + if not isinstance(targets, torch.Tensor): + targets = torch.tensor(targets) + + # 2. Generate mask indices local to this subset + forget_indices = torch.where(targets == forget_class_idx)[0].tolist() + retain_indices = torch.where(targets != forget_class_idx)[0].tolist() + + # 3. Create PyTorch Subsets + forget_subset = Subset(dataset, forget_indices) + retain_subset = Subset(dataset, retain_indices) + + # 4. Wrap into clean DataLoaders + forget_loader = DataLoader(forget_subset, batch_size=batch_size, shuffle=False) + retain_loader = DataLoader(retain_subset, batch_size=batch_size, shuffle=True) + + print(f"[Data Split] Local Class {forget_class_idx}: {len(forget_subset)} samples | Remaining Classes: {len(retain_subset)} samples.") + + return forget_loader, retain_loader \ No newline at end of file diff --git a/LinearFiltration_metrics.txt b/LinearFiltration_metrics.txt new file mode 100644 index 0000000..ac054a6 --- /dev/null +++ b/LinearFiltration_metrics.txt @@ -0,0 +1,7 @@ +execution_time_sec +0.000996 +0.030071 +0.001182 +0.001176 +0.001229 +0.001257 diff --git a/Tune.py b/Tune.py index 789bc85..e975f72 100644 --- a/Tune.py +++ b/Tune.py @@ -7,12 +7,13 @@ from sklearn.metrics import classification_report import SetUp from Data import * #from datasets.Casia import * -#from IdentitySubset import IdentitySubset -from datasets.UniversalIdentitySubset import UniversalIdentitySubset as IdentitySubset +from IdentitySubset import IdentitySubset +#from datasets.UniversalIdentitySubset import UniversalIdentitySubset as IdentitySubset # models from architectures.Model import Model, Architecture -from unlearning import LinearFiltration, WeightFiltration, CertifiedRemoval +from unlearning.LinearFiltration import LinearFiltration +# WeightFiltration, CertifiedRemoval # numbre of classes CLASS_SIZE = 20 @@ -24,7 +25,7 @@ BATCH_SIZE = 32 SAMPLE_SIZE = 30 # this is then (full_sample - test_sample) -TRAINING_SMPLE = 28 +TRAINING_SMPLE = 27 # learning rate LR_RATE = 0.0001 @@ -96,68 +97,79 @@ print(f'> Constants : Classes = {CLASS_SIZE}, Batch = {BATCH_SIZE}, epochs = {EP # MODEL PREPARATION # cuda if exists (it does here) device = SetUp.get_device() + +for i in range(0,CLASS_SIZE): # Create model using Factory -model = Model.create( - arch = arch, - device = device, - size = CLASS_SIZE) + model = Model.create( + arch = arch, + device = device, + size = CLASS_SIZE) -# we may need to load existing model or finetune -model.train( - epochs = EPOCHS, - loader = train_loader, - rate = LR_RATE) + # we may need to load existing model or finetune + model.train( + epochs = EPOCHS, + loader = train_loader, + rate = LR_RATE) - # save. -model.save(filename=arch.name.lower()) + # save. + model.save(filename=arch.name.lower()) -# done tuning -print('Model saved!') + # done tuning -# EVALUATE + # EVALUATE + te_transform = test_transform(RESOLUTION) + # Testing + test_data = IdentitySubset( + dataset = dataset, + indices=test_indices, + id_mapping=id_map, + transform=te_transform) -te_transform = test_transform(RESOLUTION) -# Testing -test_data = IdentitySubset( - dataset = dataset, - indices=test_indices, - id_mapping=id_map, - transform=te_transform) + test_loader = DataLoader( + test_data, + batch_size=BATCH_SIZE, + shuffle=False) -test_loader = DataLoader( - test_data, - batch_size=BATCH_SIZE, - shuffle=False) + print(f"Total test images for these {CLASS_SIZE} classes: {len(test_data)}") -print(f"Total test images for these {CLASS_SIZE} classes: {len(test_data)}") + # Evaluate + model.evaluate( + loader = test_loader, + mode="finetunned" + ) -# Evaluate -model.evaluate( - loader = test_loader) + # test again + reloaded = Model.create( + arch=arch, + device = device, + size = CLASS_SIZE + ) + reloaded.load(arch = arch) + print("fine tunned model loaded") + # reloaded.evaluate( + # loader = test_loader + #) -# test again -reloaded = Model.create( - arch=arch, - device = device, - size = CLASS_SIZE + # Unlearning + FORGET_CLASS_IDX = i + + forget_test_loader, retain_test_loader = get_forget_retain_loaders( + dataset=test_data, + forget_class_idx=FORGET_CLASS_IDX, + batch_size=BATCH_SIZE ) -reloaded.load(arch = arch) -print("Evaluating loaded") -reloaded.evaluate( - loader = test_loader -) + + #retain_test_loader = DataLoader(retain_test_loader.dataset, batch_size=BATCH_SIZE, shuffle=False) + + # 3. Instantiate and apply the Linear Filtration rule + filtration = LinearFiltration(target_class_idx=FORGET_CLASS_IDX) + filtration.apply(reloaded.model) -strategies_to_test = [ - LinearFiltration(target_class_idx=12), - WeightFiltration(target_class_idx=12), - CertifiedRemoval(target_class_idx=12) -] + # 4. Final Performance Analysis + print("\n--- Performance on Retained Classes") + reloaded.evaluate(loader=retain_test_loader, mode="retain") -# Run the comparative benchmark seamlessly -execution_profiles = {} -for strategy in strategies_to_test: - # Each iteration clones weights back to fine-tuned state before running - runtime = my_model.unlearn(strategy, forget_loader, retain_loader) - execution_profiles[strategy.__class__.__name__] = runtime + print("\n--- Performance on Forgotten Class") + reloaded.evaluate(loader=forget_test_loader,mode="forget") \ No newline at end of file diff --git a/architectures/Model.py b/architectures/Model.py index f5292e2..2d5978e 100644 --- a/architectures/Model.py +++ b/architectures/Model.py @@ -43,10 +43,13 @@ class Model(ABC): if self.device.type == 'cuda': torch.cuda.synchronize() print(f"Training completed in: {time.time() - start_time:.2f}s") + def evaluate(self, loader): self.model.eval() all_preds, all_labels = [], [] print("\nEvaluating...") + + with torch.no_grad(): for inputs, labels in loader: @@ -56,9 +59,11 @@ class Model(ABC): all_preds.extend(predicted.cpu().numpy()) all_labels.extend(labels.cpu().numpy()) - accuracy = 100 * (np.array(all_preds) == np.array(all_labels)).sum() / len(all_labels) + classes = sorted(list(set(all_labels))) + + accuracy = 100 * (np.array(all_preds) == np.array(all_labels)).sum() / len(classes) print(f"Test Accuracy: {accuracy:.2f}%") - print(classification_report(all_labels, all_preds, zero_division=0)) + print(classification_report(all_labels, all_preds, labels=classes, zero_division=0)) def save(self, filename=None): @@ -101,13 +106,109 @@ class Model(ABC): start_time = time.time() # Delegate the actual algorithmic weight/logit manipulation to the strategy - strategy.apply(self.network, forget_loader, retain_loader) + strategy.apply(self.model, forget_loader, retain_loader) elapsed_time = time.time() - start_time print(f"{strategy.__class__.__name__} completed in {elapsed_time:.4f} seconds.") return elapsed_time + def evaluate(self, loader, mode="eval"): + """ + Evaluates the model, prints terminal reports, and routes metrics to + a file logger based on the current context mode. + """ + self.model.eval() + all_preds, all_labels = [], [] + print(f"\nEvaluating Domain: [{mode}]...") + + with torch.no_grad(): + for inputs, labels in loader: + inputs, labels = inputs.to(self.device), labels.to(self.device) + outputs = self.model(inputs) + _, predicted = torch.max(outputs, 1) + all_preds.extend(predicted.cpu().numpy()) + all_labels.extend(labels.cpu().numpy()) + + # Extract only the active classes evaluated in this loader slice + classes = sorted(list(set(all_labels))) + accuracy = 100 * (np.array(all_preds) == np.array(all_labels)).sum() / len(all_labels) + + print(f"Test Accuracy: {accuracy:.2f}%") + + # 1. Print standard text report to terminal + print(classification_report(all_labels, all_preds, labels=classes, zero_division=0)) + + # 2. Extract structured dictionary metrics + report_dict = classification_report( + all_labels, + all_preds, + labels=classes, + output_dict=True, + zero_division=0 + ) + + # 3. Delegate file tracking to isolated helper method + self._log_to_csv(mode, accuracy, classes, report_dict) + + + def _log_to_csv(self, mode, accuracy, classes, report_dict): + """Handles directory structures, file setups, and distinct CSV column formatting.""" + arch_name = self.__class__.__name__.lower() + save_dir = Path("reports") + save_dir.mkdir(parents=True, exist_ok=True) + csv_path = save_dir / f"{arch_name}-{mode}.csv" + + file_exists = csv_path.exists() + ''' + # Structure payload and headers based on evaluation slice type + if mode == "forget": + headers = ["accuracy", "precision", "recall", "f1-score"] + target_cls_str = str(classes[0]) + metrics = report_dict[target_cls_str] + row = [ + f"{accuracy / 100.0:.4f}", + f"{metrics['precision']:.4f}", + f"{metrics['recall']:.4f}", + f"{metrics['f1-score']:.4f}" + ] + else: + headers = [ + "accuracy", + "macro_precision", "macro_recall", "macro_f1", + "weighted_precision", "weighted_recall", "weighted_f1" + ] + row = [ + f"{accuracy / 100.0:.4f}", + f"{report_dict['macro avg']['precision']:.4f}", + f"{report_dict['macro avg']['recall']:.4f}", + f"{report_dict['macro avg']['f1-score']:.4f}", + f"{report_dict['weighted avg']['precision']:.4f}", + f"{report_dict['weighted avg']['recall']:.4f}", + f"{report_dict['weighted avg']['f1-score']:.4f}" + ]''' + headers = [ + "accuracy", + "macro_precision", "macro_recall", "macro_f1", + "weighted_precision", "weighted_recall", "weighted_f1" + ] + row = [ + f"{accuracy / 100.0:.4f}", + f"{report_dict['macro avg']['precision']:.4f}", + f"{report_dict['macro avg']['recall']:.4f}", + f"{report_dict['macro avg']['f1-score']:.4f}", + f"{report_dict['weighted avg']['precision']:.4f}", + f"{report_dict['weighted avg']['recall']:.4f}", + f"{report_dict['weighted avg']['f1-score']:.4f}" + ] + + with open(csv_path, "a") as f: + if not file_exists: + f.write(",".join(headers) + "\n") + f.write(",".join(row) + "\n") + + print(f">> Direct CSV metrics appended to {csv_path}") + # Using the factory patern here diff --git a/reports/LinearFiltration_metrics.txt b/reports/LinearFiltration_metrics.txt new file mode 100644 index 0000000..335dc33 --- /dev/null +++ b/reports/LinearFiltration_metrics.txt @@ -0,0 +1,35 @@ +execution_time_sec +0.001269 +0.001227 +0.001298 +0.001281 +0.001178 +0.001392 +0.001391 +0.001338 +0.001162 +0.001355 +0.001361 +0.001241 +0.001210 +0.001152 +0.001358 +0.001250 +0.001467 +0.001248 +0.001411 +0.001470 +0.001241 +0.001366 +0.001206 +0.001339 +0.001268 +0.002847 +0.001245 +0.001299 +0.001222 +0.001274 +0.001351 +0.001401 +0.001286 +0.001214 diff --git a/reports/resnet50-finetunned.csv b/reports/resnet50-finetunned.csv new file mode 100644 index 0000000..0a2eab6 --- /dev/null +++ b/reports/resnet50-finetunned.csv @@ -0,0 +1,21 @@ +accuracy,macro_precision,macro_recall,macro_f1,weighted_precision,weighted_recall,weighted_f1 +0.9333,0.9500,0.9333,0.9264,0.9500,0.9333,0.9264 +0.9167,0.9425,0.9167,0.9111,0.9425,0.9167,0.9111 +0.9333,0.9500,0.9333,0.9264,0.9500,0.9333,0.9264 +0.8667,0.9050,0.8667,0.8625,0.9050,0.8667,0.8625 +0.9000,0.9208,0.9000,0.8976,0.9208,0.9000,0.8976 +0.9000,0.9208,0.9000,0.8926,0.9208,0.9000,0.8926 +0.9667,0.9750,0.9667,0.9657,0.9750,0.9667,0.9657 +0.9000,0.9308,0.9000,0.9012,0.9308,0.9000,0.9012 +0.9833,0.9875,0.9833,0.9829,0.9875,0.9833,0.9829 +0.9500,0.9625,0.9500,0.9486,0.9625,0.9500,0.9486 +0.8667,0.9008,0.8667,0.8551,0.9008,0.8667,0.8551 +0.9167,0.9375,0.9167,0.9093,0.9375,0.9167,0.9093 +0.9000,0.9250,0.9000,0.8921,0.9250,0.9000,0.8921 +0.9000,0.9333,0.9000,0.9024,0.9333,0.9000,0.9024 +0.9500,0.9625,0.9500,0.9486,0.9625,0.9500,0.9486 +0.9167,0.9333,0.9167,0.9148,0.9333,0.9167,0.9148 +0.9167,0.9375,0.9167,0.9143,0.9375,0.9167,0.9143 +0.9667,0.9750,0.9667,0.9657,0.9750,0.9667,0.9657 +0.9333,0.9500,0.9333,0.9314,0.9500,0.9333,0.9314 +0.9000,0.9350,0.9000,0.8957,0.9350,0.9000,0.8957 diff --git a/reports/resnet50-forget.csv b/reports/resnet50-forget.csv new file mode 100644 index 0000000..90ee200 --- /dev/null +++ b/reports/resnet50-forget.csv @@ -0,0 +1,21 @@ +accuracy,macro_precision,macro_recall,macro_f1,weighted_precision,weighted_recall,weighted_f1 +0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000 +0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000 +0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000 +0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000 +0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000 +0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000 +0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000 +0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000 +0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000 +0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000 +0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000 +0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000 +0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000 +0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000 +0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000 +0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000 +0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000 +0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000 +0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000 +0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000 diff --git a/reports/resnet50-retain.csv b/reports/resnet50-retain.csv new file mode 100644 index 0000000..8f740fc --- /dev/null +++ b/reports/resnet50-retain.csv @@ -0,0 +1,21 @@ +accuracy,macro_precision,macro_recall,macro_f1,weighted_precision,weighted_recall,weighted_f1, +0.9474,0.9605,0.9474,0.9459,0.9605,0.9474,0.9459 +0.9123,0.9395,0.9123,0.9064,0.9395,0.9123,0.9064 +0.9298,0.9526,0.9298,0.9244,0.9526,0.9298,0.9244 +0.8596,0.9000,0.8596,0.8553,0.9000,0.8596,0.8553 +0.9123,0.9298,0.9123,0.9103,0.9298,0.9123,0.9103 +0.9123,0.9342,0.9123,0.9045,0.9342,0.9123,0.9045 +0.9649,0.9737,0.9649,0.9639,0.9737,0.9649,0.9639 +0.8947,0.9272,0.8947,0.8960,0.9272,0.8947,0.8960 +1.0000,1.0000,1.0000,1.0000,1.0000,1.0000,1.0000 +0.9649,0.9737,0.9649,0.9639,0.9737,0.9649,0.9639 +0.8772,0.9132,0.8772,0.8650,0.9132,0.8772,0.8650 +0.9123,0.9342,0.9123,0.9045,0.9342,0.9123,0.9045 +0.9123,0.9342,0.9123,0.9045,0.9342,0.9123,0.9045 +0.9298,0.9605,0.9298,0.9328,0.9605,0.9298,0.9328 +0.9649,0.9737,0.9649,0.9639,0.9737,0.9649,0.9639 +0.9298,0.9430,0.9298,0.9283,0.9430,0.9298,0.9283 +0.9298,0.9474,0.9298,0.9278,0.9474,0.9298,0.9278 +0.9649,0.9737,0.9649,0.9639,0.9737,0.9649,0.9639 +0.9298,0.9474,0.9298,0.9278,0.9474,0.9298,0.9278 +0.8947,0.9316,0.8947,0.8902,0.9316,0.8947,0.8902 diff --git a/unlearning/LinearFiltration.py b/unlearning/LinearFiltration.py index abb8306..ca3652b 100644 --- a/unlearning/LinearFiltration.py +++ b/unlearning/LinearFiltration.py @@ -1,29 +1,48 @@ import torch -from Strategy import Strategy +import torch.nn as nn +from .Strategy import Strategy -class NormalizingLinearFiltration(Strategy): - def __init__(self, target_class_idx): +class LinearFiltration(Strategy): + def __init__(self, target_class_idx: int): + super().__init__() # Automatically configures 'NormalizingLinearFiltration_metrics.txt' self.target_class_idx = target_class_idx - def apply(self, model, forget_loader, retain_loader): + def _run(self, model: nn.Module) -> nn.Module: model.eval() - # Freeze parameters structurally for param in model.parameters(): param.requires_grad = False with torch.no_grad(): - # we modify only classification head - # Shape: [num_classes, feature_dim] - W = model.fc.weight.data - - # Compute the normalization transformation projection matrix (A) - # (In your full code, calculate A here matching Baumhauer et al.'s equations) + W = model.fc.weight.data.clone() num_classes = W.shape[0] - A = torch.eye(num_classes, device=W.device) - # Mask/blend target class index distribution configurations here... - A[self.target_class_idx, :] = 0.0 - # 3. Direct weight matrix override: W_filtered = A * W + A = self._calculate_filtration_matrix(num_classes, self.target_class_idx, W.device) sanitized_W = torch.mm(A, W) - model.fc.weight.copy_(sanitized_W) \ No newline at end of file + model.fc.weight.copy_(sanitized_W) + + return model + + '''@staticmethod + def _calculate_filtration_matrix(num_classes: int, forget_class: int, device: torch.device) -> torch.Tensor: + A = torch.eye(num_classes, device=device) + num_remaining_classes = num_classes - 1 + for j in range(num_classes): + if j == forget_class: + A[forget_class, j] = 0.0 + else: + A[forget_class, j] = 1.0 / num_remaining_classes + return A''' + @staticmethod + def _calculate_filtration_matrix(num_classes: int, forget_class: int, device: torch.device) -> torch.Tensor: + A = torch.eye(num_classes, device=device) + num_remaining = num_classes - 1 + + # The row of the forgotten class should average the inputs of all other classes + for j in range(num_classes): + if j == forget_class: + A[forget_class, j] = 0.0 + else: + A[forget_class, j] = 1.0 / num_remaining + + return A \ No newline at end of file diff --git a/unlearning/Strategy.py b/unlearning/Strategy.py index e69de29..c41821d 100644 --- a/unlearning/Strategy.py +++ b/unlearning/Strategy.py @@ -0,0 +1,47 @@ + +import torch.nn as nn +import time +import os + +class Strategy: + """Abstract base class for unlearning algorithms with automated, strategy-specific logging.""" + + def __init__(self): + # Dynamically set file name based on the class name (e.g., 'NormalizingLinearFiltration.txt') + self.strategy_name = self.__class__.__name__ + self.log_file = f"reports/{self.strategy_name}_metrics.txt" + self._initialize_log_file() + + def _initialize_log_file(self): + """Creates a unique log file for this strategy with a header if it doesn't exist.""" + if not os.path.exists(self.log_file): + with open(self.log_file, "w") as f: + f.write("execution_time_sec\n") + + def log_metric(self, execution_time: float): + """Appends the execution time to this strategy's specific file.""" + with open(self.log_file, "a") as f: + f.write(f"{execution_time:.6f}\n") + + def apply(self, model: nn.Module) -> nn.Module: + """ + Wraps the unlearning execution with automated timing and strategy-specific logging. + DO NOT override this method in subclasses. Override _run instead. + """ + start_time = time.perf_counter() + + # Execute core unlearning logic + processed_model = self._run(model) + + end_time = time.perf_counter() + execution_time = end_time - start_time + + # Log to the strategy's specific file + self.log_metric(execution_time) + print(f"[{self.strategy_name}] Completed in {execution_time:.6f} seconds. Saved to {self.log_file}") + + return processed_model + + def _run(self, model: nn.Module) -> nn.Module: + """Subclasses implement their core unlearning logic here.""" + raise NotImplementedError \ No newline at end of file From bc7fd3850d76876d1a69f2c4c3fce6d3608aa96f Mon Sep 17 00:00:00 2001 From: Tinsae Date: Sun, 7 Jun 2026 13:49:28 +0200 Subject: [PATCH 13/20] unlearning CR --- Tune.py | 17 +++++--- Util.py | 34 +++++++++++++++ architectures/Model.py | 4 +- reports/resnet50-finetunned.csv | 1 + unlearning/CertifiedRemoval.py | 77 +++++++++++++++++++++++++++++++++ unlearning/Strategy.py | 2 +- 6 files changed, 126 insertions(+), 9 deletions(-) create mode 100644 Util.py diff --git a/Tune.py b/Tune.py index e975f72..017991a 100644 --- a/Tune.py +++ b/Tune.py @@ -13,6 +13,8 @@ from IdentitySubset import IdentitySubset from architectures.Model import Model, Architecture from unlearning.LinearFiltration import LinearFiltration + +import Util # WeightFiltration, CertifiedRemoval # numbre of classes @@ -134,10 +136,11 @@ for i in range(0,CLASS_SIZE): print(f"Total test images for these {CLASS_SIZE} classes: {len(test_data)}") # Evaluate - model.evaluate( - loader = test_loader, - mode="finetunned" - ) + mode, accuracy, report_dict = model.evaluate( + loader = test_loader, + mode="finetunned" + ) + Util._log_to_csv(model=reloaded, mode = "finetuned", accuracy=accuracy, report_dict=report_dict, strategy="base") # test again reloaded = Model.create( @@ -169,7 +172,9 @@ for i in range(0,CLASS_SIZE): # 4. Final Performance Analysis print("\n--- Performance on Retained Classes") - reloaded.evaluate(loader=retain_test_loader, mode="retain") + mode, accuracy, report_dict = reloaded.evaluate(loader=retain_test_loader, mode="retain") + Util._log_to_csv(model=reloaded, mode = "retain", accuracy=accuracy, report_dict=report_dict, strategy="linearFiltration") print("\n--- Performance on Forgotten Class") - reloaded.evaluate(loader=forget_test_loader,mode="forget") \ No newline at end of file + mode, accuracy, report_dict = reloaded.evaluate(loader=forget_test_loader,mode="forget") + Util._log_to_csv(model=reloaded, mode = "forgotten", accuracy=accuracy, report_dict=report_dict, strategy="linearFiltration") \ No newline at end of file diff --git a/Util.py b/Util.py new file mode 100644 index 0000000..b2f4b0b --- /dev/null +++ b/Util.py @@ -0,0 +1,34 @@ + +from pathlib import Path +from architectures.Model import Model + +def _log_to_csv(model:Model, mode, accuracy, report_dict, strategy): + """Handles directory structures, file setups, and distinct CSV column formatting.""" + arch_name = model.__class__.__name__.lower() + save_dir = Path(f"reports/{strategy}") + save_dir.mkdir(parents=True, exist_ok=True) + csv_path = save_dir / f"{arch_name}-{mode}.csv" + + file_exists = csv_path.exists() + + headers = [ + "accuracy", + "macro_precision", "macro_recall", "macro_f1", + "weighted_precision", "weighted_recall", "weighted_f1" + ] + row = [ + f"{accuracy / 100.0:.4f}", + f"{report_dict['macro avg']['precision']:.4f}", + f"{report_dict['macro avg']['recall']:.4f}", + f"{report_dict['macro avg']['f1-score']:.4f}", + f"{report_dict['weighted avg']['precision']:.4f}", + f"{report_dict['weighted avg']['recall']:.4f}", + f"{report_dict['weighted avg']['f1-score']:.4f}" + ] + + with open(csv_path, "a") as f: + if not file_exists: + f.write(",".join(headers) + "\n") + f.write(",".join(row) + "\n") + + print(f">> Direct CSV metrics appended to {csv_path}") \ No newline at end of file diff --git a/architectures/Model.py b/architectures/Model.py index 2d5978e..29a4a3d 100644 --- a/architectures/Model.py +++ b/architectures/Model.py @@ -149,10 +149,10 @@ class Model(ABC): ) # 3. Delegate file tracking to isolated helper method - self._log_to_csv(mode, accuracy, classes, report_dict) + self._log_to_csv(mode, accuracy,report_dict) - def _log_to_csv(self, mode, accuracy, classes, report_dict): + def _log_to_csv(self, mode, accuracy, report_dict): """Handles directory structures, file setups, and distinct CSV column formatting.""" arch_name = self.__class__.__name__.lower() save_dir = Path("reports") diff --git a/reports/resnet50-finetunned.csv b/reports/resnet50-finetunned.csv index 0a2eab6..751fc10 100644 --- a/reports/resnet50-finetunned.csv +++ b/reports/resnet50-finetunned.csv @@ -19,3 +19,4 @@ accuracy,macro_precision,macro_recall,macro_f1,weighted_precision,weighted_recal 0.9667,0.9750,0.9667,0.9657,0.9750,0.9667,0.9657 0.9333,0.9500,0.9333,0.9314,0.9500,0.9333,0.9314 0.9000,0.9350,0.9000,0.8957,0.9350,0.9000,0.8957 +0.9000,0.9350,0.9000,0.9007,0.9350,0.9000,0.9007 diff --git a/unlearning/CertifiedRemoval.py b/unlearning/CertifiedRemoval.py index e69de29..6c27d1a 100644 --- a/unlearning/CertifiedRemoval.py +++ b/unlearning/CertifiedRemoval.py @@ -0,0 +1,77 @@ +import torch +import numpy as np +from scipy.optimize import minimize +from .Strategy import Strategy +import torch.nn as nn + +class CertifiedRemoval(Strategy): + """Implements Certified Removal for machine unlearning.""" + + def __init__(self, model, data, labels, removal_bound, epsilon): + super().__init__() + self.model = model + self.data = data + self.labels = labels + self.removal_bound = removal_bound + self.epsilon = epsilon + + def _run(self, model: nn.Module) -> nn.Module: + """Runs the certified removal algorithm.""" + # 1. Linear Model Creation + # This is a simplification for demonstration purposes. In a real implementation, + # you'd use more sophisticated methods to learn the parameters of the + # 'removal' model based on the example being removed. + + def linear_model(x): + return torch.dot(x, torch.tensor([1, 1])) # Simplified Linear Model + + # 2. Optimization for Parameter Adjustment + # Optimize the parameter values to minimize the loss while staying within bounds. + original_params = torch.tensor([0.0, 0.0]) # Initial parameters for linear model + + def objective_function(params): + new_model = linear_model #use same function as defined above + return torch.sum(((new_model(self.data[0]) - self.labels)**2)) + + result = minimize(objective_function, original_params, method='L-BFGS-B', bounds=[(-self.removal_bound, self.removal_bound)], options={'maxiter': 100}) + + if not result.success: + print("Warning: Optimization failed!") + print(result.message) + return model #Return original if optimization fails + + new_params = result.x + # 3. New Model Creation + + new_model = lambda x: torch.dot(x, new_params) + return new_model + + +if __name__ == '__main__': + # Example Usage - Synthetic Data for Demonstration + np.random.seed(42) # For reproducibility + n_samples = 100 + X = np.random.randn(n_samples, 2) + y = (X[:, 0] + X[:, 1] > 0).astype(int) + + # Create a simple linear model for demonstration + model = nn.Linear(2, 1) # Simple linear classifier - PyTorch Version + optimizer = torch.optim.SGD(model.parameters(), lr=0.01) # Optimizer for training the linear model + + # Train a Linear Model + for _ in range(100): #training loop + optimizer.zero_grad() + predictions = model(X) + loss = torch.sum((predictions - y)**2) + loss.backward() + optimizer.step() + + # Define parameters for Certified Removal + removal_bound = 1.0 + epsilon = 0.1 + + # Create the CertifiedRemoval object with the trained model, data and labels + certified_removal_obj = CertifiedRemoval(model, X, y, removal_bound, epsilon) + + # Run Certified Removal + new_model = certified_removal_obj.apply(model) \ No newline at end of file diff --git a/unlearning/Strategy.py b/unlearning/Strategy.py index c41821d..99525a9 100644 --- a/unlearning/Strategy.py +++ b/unlearning/Strategy.py @@ -9,7 +9,7 @@ class Strategy: def __init__(self): # Dynamically set file name based on the class name (e.g., 'NormalizingLinearFiltration.txt') self.strategy_name = self.__class__.__name__ - self.log_file = f"reports/{self.strategy_name}_metrics.txt" + self.log_file = f"reports/{self.strategy_name}/metrics.txt" self._initialize_log_file() def _initialize_log_file(self): From e5bddd5ed2017dd4a21d6e0175c04ad764e2b270 Mon Sep 17 00:00:00 2001 From: Tinsae Date: Sun, 7 Jun 2026 14:03:47 +0200 Subject: [PATCH 14/20] unlearning CR --- architectures/Model.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/architectures/Model.py b/architectures/Model.py index 29a4a3d..443f903 100644 --- a/architectures/Model.py +++ b/architectures/Model.py @@ -149,7 +149,8 @@ class Model(ABC): ) # 3. Delegate file tracking to isolated helper method - self._log_to_csv(mode, accuracy,report_dict) + #self._log_to_csv(mode, accuracy,report_dict) + return mode, accuracy, report_dict def _log_to_csv(self, mode, accuracy, report_dict): From 5f0901745661110983758bd6c43629bc841f88d3 Mon Sep 17 00:00:00 2001 From: Tinsae Date: Sun, 14 Jun 2026 11:53:31 +0200 Subject: [PATCH 15/20] strategies tested --- .gitignore | 6 + IdentitySubset.py | 20 --- Note.md | 40 ++++++ Tune.py | 150 ++++++++++++++------ Util.py | 24 +++- architectures/Model.py | 95 ++----------- reports/LinearFiltration_metrics.txt | 35 ----- reports/resnet50-finetunned.csv | 22 --- reports/resnet50-forget.csv | 21 --- reports/resnet50-retain.csv | 21 --- sets/Casia.py | 167 ++++++++++++++++++++++ sets/CasiaFace.py | 21 +++ sets/CelebA.py | 20 +++ Data.py => sets/Data.py | 95 +++++++++---- sets/Data_OOP.py | 174 +++++++++++++++++++++++ sets/Extractor.py | 131 ++++++++++++++++++ sets/IdentitySubset.py | 34 +++++ unlearning/CertifiedRemoval.py | 198 ++++++++++++++++++--------- unlearning/LastK_Certified.py | 125 +++++++++++++++++ unlearning/LinearFiltration.py | 26 ++-- unlearning/Strategy.py | 30 ++-- unlearning/WeightFiltration.py | 140 +++++++++++++++++++ 22 files changed, 1228 insertions(+), 367 deletions(-) delete mode 100644 IdentitySubset.py create mode 100644 Note.md delete mode 100644 reports/LinearFiltration_metrics.txt delete mode 100644 reports/resnet50-finetunned.csv delete mode 100644 reports/resnet50-forget.csv delete mode 100644 reports/resnet50-retain.csv create mode 100644 sets/Casia.py create mode 100644 sets/CasiaFace.py create mode 100644 sets/CelebA.py rename Data.py => sets/Data.py (68%) create mode 100644 sets/Data_OOP.py create mode 100644 sets/Extractor.py create mode 100644 sets/IdentitySubset.py create mode 100644 unlearning/LastK_Certified.py diff --git a/.gitignore b/.gitignore index 11b32ea..2a3ec8c 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,9 @@ trained_models/ __pycache__/ *.py[cod] lib64 +/reports +*.bin +*.idx +*.rec +*.lst +property \ No newline at end of file diff --git a/IdentitySubset.py b/IdentitySubset.py deleted file mode 100644 index 5db7685..0000000 --- a/IdentitySubset.py +++ /dev/null @@ -1,20 +0,0 @@ - -import torch - -class IdentitySubset(torch.utils.data.Dataset): - def __init__(self, dataset, indices, id_mapping, transform=None): - self.dataset = dataset - self.indices = indices - self.id_mapping = id_mapping - self.transform = transform - - def __getitem__(self, idx): - img, old_id = self.dataset[self.indices[idx]] - - if self.transform: - img = self.transform(img) - - return img, self.id_mapping[old_id.item()] - - def __len__(self): - return len(self.indices) \ No newline at end of file diff --git a/Note.md b/Note.md new file mode 100644 index 0000000..67e3557 --- /dev/null +++ b/Note.md @@ -0,0 +1,40 @@ +Not at all! You are still completely faithful to Guo et al. Your current implementation does **not** break their framework. + +In fact, your setup matches the exact methodology of the paper. There is a common misconception about what Guo et al. mean when they caution against calculating the Hessian inverse, and understanding how your feature extraction relates to their theory explains why your code remains completely valid. + +--- + +## 1. What Guo et al. *Actually* Said + +In the original paper (*"Certified Removal from Linear Models"*), Guo et al. state that explicitly calculating and inverting the Hessian matrix becomes prohibitively expensive when the parameter count $d$ scales up. + +$$\text{Time Complexity to Invert } H = O(d^3)$$ + +However, the authors explicitly implemented and verified their approach on **linear classifiers** (like logistic regression) where the input feature dimension $d$ was small enough to handle directly. + +When you strip out the heavy ResNet50 convolutional layers and turn the backbone into a static feature extractor, **you transform your deep network into a linear classifier.** ``` +[Images] ──> [Frozen ResNet Backbone] ──> Extracted Feature Vector (d = 2048) ──> [Linear Head (model.fc)] + + +Because your feature vector is exactly 2,048 dimensions, your Hessian matrix is a modest $2048 \times 2048$. + +Inverting a $2048 \times 2048$ matrix takes your CPU less than **0.5 seconds** ($2048^3$ operations is tiny for a modern processor). You are executing the exact mathematics Guo et al. prescribed for linear systems. You haven't broken their implementation; you have successfully reduced a non-convex deep learning problem into their exact convex linear domain. + + +## 2. Where Hessian-Free Approximations (Like LiSSA) Apply + +The reason alternative methods like LiSSA or Conjugate Gradient exist is for scenarios where you *cannot* reduce the model to a small linear head. + +If you decided to apply Certified Removal to the **entire ResNet50 network** (all 23.5 million parameters open), then you would be forced to abandon your exact matrix calculation. Inverting a $23.5\text{M} \times 23.5\text{M}$ matrix is impossible. In that specific scenario, you would have to use a Hessian-free approximation method to avoid breaking the budget. + +--- + +## 3. The Core Alignment with the Paper + +Your script implements the three pillars that define Guo et al.’s Certified Removal: + +1. **The Optimization Target:** It uses an $L_2$-regularized objective function (`self.l2_reg`). +2. **The Newton Step:** It takes the exact second-order curvature correction ($H^{-1} \nabla$) to adjust the parameters. +3. **The Indistinguishability Guarantee:** It applies a privacy perturbation boundary check (`self.removal_bound`). + +Your implementation is an elegant, academically sound adaptation of their linear model theory for a deep learning architecture. By handling the feature extraction step first, you made their exact algorithm run efficiently within a 4GB VRAM envelope. diff --git a/Tune.py b/Tune.py index 017991a..3fb1b6f 100644 --- a/Tune.py +++ b/Tune.py @@ -5,14 +5,17 @@ from torch.utils.data import DataLoader from sklearn.metrics import classification_report import SetUp -from Data import * -#from datasets.Casia import * -from IdentitySubset import IdentitySubset -#from datasets.UniversalIdentitySubset import UniversalIdentitySubset as IdentitySubset +#from Data import * +# from datasets.Casia import * +#from IdentitySubset import IdentitySubset +from sets.Data import * +from sets.IdentitySubset import IdentitySubset # models from architectures.Model import Model, Architecture from unlearning.LinearFiltration import LinearFiltration +from unlearning.CertifiedRemoval import CertifiedRemoval +from unlearning.WeightFiltration import WeightFiltration import Util # WeightFiltration, CertifiedRemoval @@ -20,7 +23,7 @@ import Util # numbre of classes CLASS_SIZE = 20 # batch -BATCH_SIZE = 32 +BATCH_SIZE = 16 # size of images per class trainset + testset # 30 works best, more than that and we dont have enough data @@ -50,7 +53,11 @@ arch = Architecture.RESNET50 # DATA PREPARATION # load data set and prepare -dataset = get_set() +dataset_name = Set_Name.CELEBA +set = Set_Name.CELEBA + +dataset = get_set(set_name=dataset_name) +print(f"> {dataset.__class__.__name__} dataset loaded") # select identities for experiment #selected_identities = select_ids( # dataset = dataset, @@ -62,7 +69,7 @@ dataset = get_set() # that way repeated calls return the same classes selected_identities = select_top_ids( dataset=dataset, - class_size= CLASS_SIZE, + class_size=CLASS_SIZE ) print(f'> Selected {CLASS_SIZE} random identity classes from CelebA dataset.') @@ -72,7 +79,8 @@ print(f'> A class has {TRAINING_SMPLE} train and {SAMPLE_SIZE-TRAINING_SMPLE} te train_indices, test_indices = get_indices( dataset = dataset, identities = selected_identities, - split_at = TRAINING_SMPLE + split_at = TRAINING_SMPLE, + size= SAMPLE_SIZE ) # helps map class id to index @@ -80,7 +88,7 @@ id_map = {old_id: new_id for new_id, old_id in enumerate(selected_identities)} # we remap identities because crossEntropyLoss requires in indices 0 -> (n-1) # where n = class size. -tr_transform = train_transform(res = RESOLUTION) +tr_transform = train_transform(RESOLUTION) train_data = IdentitySubset( dataset=dataset, indices=train_indices, @@ -100,8 +108,10 @@ print(f'> Constants : Classes = {CLASS_SIZE}, Batch = {BATCH_SIZE}, epochs = {EP # cuda if exists (it does here) device = SetUp.get_device() + for i in range(0,CLASS_SIZE): -# Create model using Factory + FORGET_CLASS_IDX = i + # Create model using Factory model = Model.create( arch = arch, device = device, @@ -136,45 +146,101 @@ for i in range(0,CLASS_SIZE): print(f"Total test images for these {CLASS_SIZE} classes: {len(test_data)}") # Evaluate - mode, accuracy, report_dict = model.evaluate( - loader = test_loader, - mode="finetunned" - ) - Util._log_to_csv(model=reloaded, mode = "finetuned", accuracy=accuracy, report_dict=report_dict, strategy="base") + current_mode = "Finetuned" + accuracy, report_dict = model.evaluate( + loader = test_loader, + mode=current_mode + ) - # test again - reloaded = Model.create( - arch=arch, - device = device, - size = CLASS_SIZE - ) - reloaded.load(arch = arch) - print("fine tunned model loaded") - # reloaded.evaluate( - # loader = test_loader - #) + Util._log_to_csv( + arch=model.__class__.__name__, + mode = current_mode, + accuracy=accuracy, + report_dict=report_dict, + strategy="base" + ) - # Unlearning - FORGET_CLASS_IDX = i + # unlearning algorithms + linear_filtration = LinearFiltration(target_class_idx=FORGET_CLASS_IDX) + #filtration.apply(reloaded.model) - forget_test_loader, retain_test_loader = get_forget_retain_loaders( - dataset=test_data, + weight_filtration = WeightFiltration(num_classes = CLASS_SIZE,target_class_idx=FORGET_CLASS_IDX) + #weight_filtration.apply(reloaded.model) + + certified_removal = CertifiedRemoval(removal_bound=0.05, epsilon=0.5, l2_reg=0.1) + #certified_removal.apply(reloaded.model) + + # to be unlearned + forget_train_loader, retain_train_loader = get_unlearning_loaders( + dataset=train_data, forget_class_idx=FORGET_CLASS_IDX, batch_size=BATCH_SIZE ) - #retain_test_loader = DataLoader(retain_test_loader.dataset, batch_size=BATCH_SIZE, shuffle=False) - - # 3. Instantiate and apply the Linear Filtration rule - filtration = LinearFiltration(target_class_idx=FORGET_CLASS_IDX) - filtration.apply(reloaded.model) + # to evaluate + forget_test_loader, retain_test_loader = get_unlearning_loaders( + dataset=test_data, + forget_class_idx=FORGET_CLASS_IDX, + batch_size=BATCH_SIZE + ) - # 4. Final Performance Analysis - print("\n--- Performance on Retained Classes") - mode, accuracy, report_dict = reloaded.evaluate(loader=retain_test_loader, mode="retain") - Util._log_to_csv(model=reloaded, mode = "retain", accuracy=accuracy, report_dict=report_dict, strategy="linearFiltration") + strategies = [linear_filtration, weight_filtration, certified_removal] + #strategies = [linear_filtration] + for strategy in strategies: + # test again + reloaded = Model.create( + arch=arch, + device = device, + size = CLASS_SIZE + ) + reloaded.load(arch = arch) + print("fine tunned model loaded") + # reloaded.evaluate( + # loader = test_loader + #) - print("\n--- Performance on Forgotten Class") - mode, accuracy, report_dict = reloaded.evaluate(loader=forget_test_loader,mode="forget") - Util._log_to_csv(model=reloaded, mode = "forgotten", accuracy=accuracy, report_dict=report_dict, strategy="linearFiltration") \ No newline at end of file + # Unlearning + # train loaders passed here + strategy.apply(reloaded.model, forget_train_loader, retain_train_loader) + # Performance Analysis + strategy_in_use = strategy.__class__.__name__ + + # evaluation on retain Test_set + current_mode = "retain" + print("\n--- Performance on Retained Classes") + accuracy, report_dict = reloaded.evaluate(loader=retain_test_loader, mode=current_mode) + + Util._log_to_csv( + arch=reloaded.__class__.__name__, + mode = current_mode, + accuracy=accuracy, + report_dict=report_dict, + strategy=strategy_in_use + ) + + + # evaluation on forget Test_set + print("\n--- Performance on Forgotten Class") + current_mode = "forget" + accuracy, report_dict = reloaded.evaluate(loader=forget_test_loader,mode=current_mode) + Util._log_to_csv( + arch=reloaded.__class__.__name__, + mode = current_mode, + accuracy=accuracy, + report_dict=report_dict, + strategy=strategy_in_use + ) + + # evaluation on forget Train_set + # we expect this to be equal or highr than accuracy on Forget Test_set + current_mode = "forget_train" + print("\n--- Performance on Forgotten Class (Train Set - Verifying Unlearning)") + accuracy, report_dict = reloaded.evaluate(loader=forget_train_loader, mode=current_mode) + Util._log_to_csv( + arch= reloaded.__class__.__name__, + mode = current_mode, + accuracy=accuracy, + report_dict=report_dict, + strategy=strategy_in_use + ) \ No newline at end of file diff --git a/Util.py b/Util.py index b2f4b0b..66d302f 100644 --- a/Util.py +++ b/Util.py @@ -1,13 +1,14 @@ from pathlib import Path -from architectures.Model import Model +import time +import os -def _log_to_csv(model:Model, mode, accuracy, report_dict, strategy): +def _log_to_csv(arch, mode, accuracy, report_dict, strategy): """Handles directory structures, file setups, and distinct CSV column formatting.""" - arch_name = model.__class__.__name__.lower() + #arch_name = model.__class__.__name__.lower() save_dir = Path(f"reports/{strategy}") save_dir.mkdir(parents=True, exist_ok=True) - csv_path = save_dir / f"{arch_name}-{mode}.csv" + csv_path = save_dir / f"{arch}-{mode}.csv" file_exists = csv_path.exists() @@ -31,4 +32,17 @@ def _log_to_csv(model:Model, mode, accuracy, report_dict, strategy): f.write(",".join(headers) + "\n") f.write(",".join(row) + "\n") - print(f">> Direct CSV metrics appended to {csv_path}") \ No newline at end of file + print(f">> Direct CSV metrics appended to {csv_path}") + + +def _initialize_log_file(log_file): + """Creates a unique log file for this strategy with a header if it doesn't exist.""" + log_file.parent.mkdir(parents=True, exist_ok=True) + if not os.path.exists(log_file): + with open(log_file, "w") as f: + f.write("execution_time_sec\n") + +def log_metric(log_file, execution_time: float): + """Appends the execution time to this strategy's specific file.""" + with open(log_file, "a") as f: + f.write(f"{execution_time:.6f}\n") \ No newline at end of file diff --git a/architectures/Model.py b/architectures/Model.py index 443f903..50e7771 100644 --- a/architectures/Model.py +++ b/architectures/Model.py @@ -8,8 +8,11 @@ import numpy as np from sklearn.metrics import classification_report from pathlib import Path from unlearning.Strategy import Strategy +import copy +from torch.optim.lr_scheduler import CosineAnnealingLR class Model(ABC): + # need to add a weight decay here def __init__(self, device, size): self.device = device self.size = size @@ -21,8 +24,10 @@ class Model(ABC): def train(self, epochs, loader, rate): criterion = nn.CrossEntropyLoss() - optimizer = optim.Adam(filter(lambda p: p.requires_grad, self.model.parameters()), lr=rate) + optimizer = optim.Adam(filter(lambda p: p.requires_grad, self.model.parameters()), lr=rate, weight_decay=0.1) + scheduler = CosineAnnealingLR(optimizer, T_max=epochs, eta_min=1e-6) + print(f"Starting training on {self.device}...") start_time = time.time() self.model.train() @@ -37,41 +42,21 @@ class Model(ABC): loss.backward() optimizer.step() total_loss += loss.item() - + scheduler.step() + print(f"Epoch {epoch+1}/{epochs} | Loss: {total_loss / len(loader):.4f}") + if self.device.type == 'cuda': torch.cuda.synchronize() print(f"Training completed in: {time.time() - start_time:.2f}s") - def evaluate(self, loader): - self.model.eval() - all_preds, all_labels = [], [] - print("\nEvaluating...") - - - - with torch.no_grad(): - for inputs, labels in loader: - inputs, labels = inputs.to(self.device), labels.to(self.device) - outputs = self.model(inputs) - _, predicted = torch.max(outputs, 1) - all_preds.extend(predicted.cpu().numpy()) - all_labels.extend(labels.cpu().numpy()) - - classes = sorted(list(set(all_labels))) - - accuracy = 100 * (np.array(all_preds) == np.array(all_labels)).sum() / len(classes) - print(f"Test Accuracy: {accuracy:.2f}%") - print(classification_report(all_labels, all_preds, labels=classes, zero_division=0)) - - def save(self, filename=None): save_dir = Path("trained_models") save_dir.mkdir(parents=True, exist_ok=True) - # Determine filename (Default to class name if not provided) + # Filename (Default to class name if not provided) if filename is None: filename = f"{self.__class__.__name__.lower()}.pth" @@ -150,65 +135,7 @@ class Model(ABC): # 3. Delegate file tracking to isolated helper method #self._log_to_csv(mode, accuracy,report_dict) - return mode, accuracy, report_dict - - - def _log_to_csv(self, mode, accuracy, report_dict): - """Handles directory structures, file setups, and distinct CSV column formatting.""" - arch_name = self.__class__.__name__.lower() - save_dir = Path("reports") - save_dir.mkdir(parents=True, exist_ok=True) - csv_path = save_dir / f"{arch_name}-{mode}.csv" - - file_exists = csv_path.exists() - ''' - # Structure payload and headers based on evaluation slice type - if mode == "forget": - headers = ["accuracy", "precision", "recall", "f1-score"] - target_cls_str = str(classes[0]) - metrics = report_dict[target_cls_str] - row = [ - f"{accuracy / 100.0:.4f}", - f"{metrics['precision']:.4f}", - f"{metrics['recall']:.4f}", - f"{metrics['f1-score']:.4f}" - ] - else: - headers = [ - "accuracy", - "macro_precision", "macro_recall", "macro_f1", - "weighted_precision", "weighted_recall", "weighted_f1" - ] - row = [ - f"{accuracy / 100.0:.4f}", - f"{report_dict['macro avg']['precision']:.4f}", - f"{report_dict['macro avg']['recall']:.4f}", - f"{report_dict['macro avg']['f1-score']:.4f}", - f"{report_dict['weighted avg']['precision']:.4f}", - f"{report_dict['weighted avg']['recall']:.4f}", - f"{report_dict['weighted avg']['f1-score']:.4f}" - ]''' - headers = [ - "accuracy", - "macro_precision", "macro_recall", "macro_f1", - "weighted_precision", "weighted_recall", "weighted_f1" - ] - row = [ - f"{accuracy / 100.0:.4f}", - f"{report_dict['macro avg']['precision']:.4f}", - f"{report_dict['macro avg']['recall']:.4f}", - f"{report_dict['macro avg']['f1-score']:.4f}", - f"{report_dict['weighted avg']['precision']:.4f}", - f"{report_dict['weighted avg']['recall']:.4f}", - f"{report_dict['weighted avg']['f1-score']:.4f}" - ] - - with open(csv_path, "a") as f: - if not file_exists: - f.write(",".join(headers) + "\n") - f.write(",".join(row) + "\n") - - print(f">> Direct CSV metrics appended to {csv_path}") + return accuracy, report_dict diff --git a/reports/LinearFiltration_metrics.txt b/reports/LinearFiltration_metrics.txt deleted file mode 100644 index 335dc33..0000000 --- a/reports/LinearFiltration_metrics.txt +++ /dev/null @@ -1,35 +0,0 @@ -execution_time_sec -0.001269 -0.001227 -0.001298 -0.001281 -0.001178 -0.001392 -0.001391 -0.001338 -0.001162 -0.001355 -0.001361 -0.001241 -0.001210 -0.001152 -0.001358 -0.001250 -0.001467 -0.001248 -0.001411 -0.001470 -0.001241 -0.001366 -0.001206 -0.001339 -0.001268 -0.002847 -0.001245 -0.001299 -0.001222 -0.001274 -0.001351 -0.001401 -0.001286 -0.001214 diff --git a/reports/resnet50-finetunned.csv b/reports/resnet50-finetunned.csv deleted file mode 100644 index 751fc10..0000000 --- a/reports/resnet50-finetunned.csv +++ /dev/null @@ -1,22 +0,0 @@ -accuracy,macro_precision,macro_recall,macro_f1,weighted_precision,weighted_recall,weighted_f1 -0.9333,0.9500,0.9333,0.9264,0.9500,0.9333,0.9264 -0.9167,0.9425,0.9167,0.9111,0.9425,0.9167,0.9111 -0.9333,0.9500,0.9333,0.9264,0.9500,0.9333,0.9264 -0.8667,0.9050,0.8667,0.8625,0.9050,0.8667,0.8625 -0.9000,0.9208,0.9000,0.8976,0.9208,0.9000,0.8976 -0.9000,0.9208,0.9000,0.8926,0.9208,0.9000,0.8926 -0.9667,0.9750,0.9667,0.9657,0.9750,0.9667,0.9657 -0.9000,0.9308,0.9000,0.9012,0.9308,0.9000,0.9012 -0.9833,0.9875,0.9833,0.9829,0.9875,0.9833,0.9829 -0.9500,0.9625,0.9500,0.9486,0.9625,0.9500,0.9486 -0.8667,0.9008,0.8667,0.8551,0.9008,0.8667,0.8551 -0.9167,0.9375,0.9167,0.9093,0.9375,0.9167,0.9093 -0.9000,0.9250,0.9000,0.8921,0.9250,0.9000,0.8921 -0.9000,0.9333,0.9000,0.9024,0.9333,0.9000,0.9024 -0.9500,0.9625,0.9500,0.9486,0.9625,0.9500,0.9486 -0.9167,0.9333,0.9167,0.9148,0.9333,0.9167,0.9148 -0.9167,0.9375,0.9167,0.9143,0.9375,0.9167,0.9143 -0.9667,0.9750,0.9667,0.9657,0.9750,0.9667,0.9657 -0.9333,0.9500,0.9333,0.9314,0.9500,0.9333,0.9314 -0.9000,0.9350,0.9000,0.8957,0.9350,0.9000,0.8957 -0.9000,0.9350,0.9000,0.9007,0.9350,0.9000,0.9007 diff --git a/reports/resnet50-forget.csv b/reports/resnet50-forget.csv deleted file mode 100644 index 90ee200..0000000 --- a/reports/resnet50-forget.csv +++ /dev/null @@ -1,21 +0,0 @@ -accuracy,macro_precision,macro_recall,macro_f1,weighted_precision,weighted_recall,weighted_f1 -0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000 -0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000 -0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000 -0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000 -0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000 -0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000 -0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000 -0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000 -0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000 -0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000 -0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000 -0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000 -0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000 -0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000 -0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000 -0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000 -0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000 -0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000 -0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000 -0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000 diff --git a/reports/resnet50-retain.csv b/reports/resnet50-retain.csv deleted file mode 100644 index 8f740fc..0000000 --- a/reports/resnet50-retain.csv +++ /dev/null @@ -1,21 +0,0 @@ -accuracy,macro_precision,macro_recall,macro_f1,weighted_precision,weighted_recall,weighted_f1, -0.9474,0.9605,0.9474,0.9459,0.9605,0.9474,0.9459 -0.9123,0.9395,0.9123,0.9064,0.9395,0.9123,0.9064 -0.9298,0.9526,0.9298,0.9244,0.9526,0.9298,0.9244 -0.8596,0.9000,0.8596,0.8553,0.9000,0.8596,0.8553 -0.9123,0.9298,0.9123,0.9103,0.9298,0.9123,0.9103 -0.9123,0.9342,0.9123,0.9045,0.9342,0.9123,0.9045 -0.9649,0.9737,0.9649,0.9639,0.9737,0.9649,0.9639 -0.8947,0.9272,0.8947,0.8960,0.9272,0.8947,0.8960 -1.0000,1.0000,1.0000,1.0000,1.0000,1.0000,1.0000 -0.9649,0.9737,0.9649,0.9639,0.9737,0.9649,0.9639 -0.8772,0.9132,0.8772,0.8650,0.9132,0.8772,0.8650 -0.9123,0.9342,0.9123,0.9045,0.9342,0.9123,0.9045 -0.9123,0.9342,0.9123,0.9045,0.9342,0.9123,0.9045 -0.9298,0.9605,0.9298,0.9328,0.9605,0.9298,0.9328 -0.9649,0.9737,0.9649,0.9639,0.9737,0.9649,0.9639 -0.9298,0.9430,0.9298,0.9283,0.9430,0.9298,0.9283 -0.9298,0.9474,0.9298,0.9278,0.9474,0.9298,0.9278 -0.9649,0.9737,0.9649,0.9639,0.9737,0.9649,0.9639 -0.9298,0.9474,0.9298,0.9278,0.9474,0.9298,0.9278 -0.8947,0.9316,0.8947,0.8902,0.9316,0.8947,0.8902 diff --git a/sets/Casia.py b/sets/Casia.py new file mode 100644 index 0000000..0c8e87c --- /dev/null +++ b/sets/Casia.py @@ -0,0 +1,167 @@ + + +from torchvision import datasets, transforms +from torch.utils.data import Dataset, DataLoader, Subset +import torch +import numpy as np +import os + +# train set transform +def train_transform(res): + return transforms.Compose([ + transforms.Resize((res, res)), + transforms.RandomHorizontalFlip(p=0.5), + transforms.ColorJitter( + brightness=0.2, + contrast=0.2, + saturation=0.1 + ), + transforms.ToTensor(), + transforms.Normalize( + mean=[0.485, 0.456, 0.406], + std=[0.229, 0.224, 0.225] + ) + ]) + +# test set transform +def test_transform(res): + return transforms.Compose([ + transforms.Resize((res, res)), + transforms.ToTensor(), + transforms.Normalize( + mean=[0.485, 0.456, 0.406], + std=[0.229, 0.224, 0.225] + ) + ]) + +# Load data using ImageFolder for CASIA-WebFace +''' +def get_set(): + # This will check local cache first, then download if missing + print("Checking for CASIA-WebFace dataset...") + path = kagglehub.dataset_download("debarghamitraroy/casia-webface") + + # Kagglehub often downloads a nested structure (e.g., path/casia-webface/casia-webface) + # We need the folder that directly contains the identity subfolders + # We'll check if there's a 'casia-webface' subfolder inside the downloaded path + sub_path = os.path.join(path, "casia-webface") + final_path = sub_path if os.path.exists(sub_path) else path + + print(f"Loading dataset from: {final_path}") + + return datasets.ImageFolder( + root=final_path, + transform=None + )''' +# Load data using ImageFolder for your UNPACKED images +def get_set(): + # This must point to the folder created by Extractor.py + # NOT the kagglehub cache path + final_path = os.path.abspath("./data/casia-set") + + if not os.path.exists(final_path): + raise FileNotFoundError( + f"Unpacked dataset not found at {final_path}. " + "Please run Extractor.py first!" + ) + + print(f"Loading unpacked CASIA dataset from: {final_path}") + + return datasets.ImageFolder( + root=final_path, + transform=None + ) + +def get_ids_and_counts(dataset): + # ImageFolder stores labels in .targets + targets = torch.tensor(dataset.targets) + return torch.unique( + input = targets, + return_counts=True + ) + +def select_ids(dataset, sample_size, class_size): + ids, counts = get_ids_and_counts(dataset=dataset) + eligible_mask = counts >= sample_size + eligible_ids = ids[eligible_mask].numpy() + + if len(eligible_ids) < class_size: + raise ValueError( + f"Only found {len(eligible_ids)} identities with {sample_size}+ images." + ) + + return np.random.choice(eligible_ids, class_size, replace=False) + +def select_balanced_ids(dataset, class_size): + ids, counts = get_ids_and_counts(dataset=dataset) + sorted_indices = torch.argsort(counts, descending=True) + top_ids = ids[sorted_indices][:class_size].numpy() + return np.array(top_ids, dtype=int) + +def get_indices(dataset, identities, split_at): + train_indices = [] + test_indices = [] + + # We convert to numpy for faster searching with np.where + all_targets = np.array(dataset.targets) + + for person_id in identities: + # Get all indices for this specific person + indices = np.where(all_targets == person_id)[0] + + # Shuffle the indices for this person + np.random.shuffle(indices) + + # Split data based on your split_at value + train_indices.extend(indices[:split_at]) + test_indices.extend(indices[split_at:]) + + return train_indices, test_indices + + + +# optional function to get max amount of samples per class +def select_top_ids(dataset, class_size): + ids, counts = get_ids_and_counts(dataset=dataset) + + # sort by number of images (descending) + sorted_indices = torch.argsort(counts, descending=True) + + top_ids = ids[sorted_indices][:class_size].numpy() + + return np.array(top_ids, dtype=int) + + +def get_forget_retain_loaders(dataset: Dataset, forget_class_idx: int, batch_size: int = 32) -> tuple[DataLoader, DataLoader]: + """ + Splits an IdentitySubset or standard Dataset into forget and retain sets + based on a remapped target class index. + """ + # 1. Safely extract targets whether it's a standard dataset or a Subset wrapper + if hasattr(dataset, 'targets'): + targets = dataset.targets + elif hasattr(dataset, 'identity'): # Raw CelebA support + targets = dataset.identity + else: + # If it's an IdentitySubset or standard Subset, extract mapped targets sequentially + # This guarantees we get the 0 -> (n-1) remapped labels + targets = [dataset[i][1] for i in range(len(dataset))] + + if not isinstance(targets, torch.Tensor): + targets = torch.tensor(targets) + + # 2. Generate mask indices local to this subset + forget_indices = torch.where(targets == forget_class_idx)[0].tolist() + retain_indices = torch.where(targets != forget_class_idx)[0].tolist() + + # 3. Create PyTorch Subsets + forget_subset = Subset(dataset, forget_indices) + retain_subset = Subset(dataset, retain_indices) + + # 4. Wrap into clean DataLoaders + forget_loader = DataLoader(forget_subset, batch_size=batch_size, shuffle=False) + retain_loader = DataLoader(retain_subset, batch_size=batch_size, shuffle=True) + + print(f"[Data Split] Local Class {forget_class_idx}: {len(forget_subset)} samples | Remaining Classes: {len(retain_subset)} samples.") + + return forget_loader, retain_loader \ No newline at end of file diff --git a/sets/CasiaFace.py b/sets/CasiaFace.py new file mode 100644 index 0000000..51e7449 --- /dev/null +++ b/sets/CasiaFace.py @@ -0,0 +1,21 @@ +import os +from torchvision import datasets +from torch.utils.data import Dataset +import torch +from .Data import Data + +class CasiaSet(Data): + def __init__(self, resolution: int = 224, sample_size = 190): + super().__init__(resolution = resolution, sample_size = sample_size) + + def get_set(self) -> Data: + path_str = "./datasets/casia-set" + path = os.path.abspath(path_str) + + if not os.path.exists(path): + raise FileNotFoundError(f"Unpacked dataset missing at {self.final_path}. Run Extractor.py first!") + print(f"Loading unpacked CASIA dataset from: {self.final_path}") + set = datasets.ImageFolder(root=path, transform=None) + # we set the target here + self.target = torch.tensor(set.targets) + return set diff --git a/sets/CelebA.py b/sets/CelebA.py new file mode 100644 index 0000000..aff1f24 --- /dev/null +++ b/sets/CelebA.py @@ -0,0 +1,20 @@ +from torchvision import datasets +from torch.utils.data import Dataset +import torch +from .Data import Data + +class CelebA(Data): + def __init__(self, resolution: int = 224, sample_size = 30): + super().__init__(resolution, sample_size = sample_size) + + def get_set(self): + set = datasets.CelebA( + root = "./data", + split='all', + target_type='identity', + download=True, + transform=None + ) + # set the target first + self.target = set.identity + return set diff --git a/Data.py b/sets/Data.py similarity index 68% rename from Data.py rename to sets/Data.py index 8ec35c7..36138a7 100644 --- a/Data.py +++ b/sets/Data.py @@ -1,9 +1,13 @@ -from torchvision import datasets, transforms, models +from torchvision import datasets, transforms from torch.utils.data import Dataset, DataLoader, Subset import torch import numpy as np +import os - +from enum import Enum, auto +class Set_Name(Enum): + CELEBA = auto() + CASIAFACES = auto() # train set transform def train_transform(res): @@ -37,7 +41,10 @@ def test_transform(res): ]) # Load data with 'identity' as target and transform it -def get_set(): +def get_set(set_name:Set_Name): + return fetch_celeb_a() if set_name == Set_Name.CELEBA else fetch_casia_faces() + +def fetch_celeb_a(): return datasets.CelebA( root='./data', split='all', @@ -46,17 +53,39 @@ def get_set(): transform=None ) +def fetch_casia_faces(): + # location of the data (path relative to project root) + final_path = os.path.abspath("./data/casia-set") + + if not os.path.exists(final_path): + raise FileNotFoundError( + f"Unpacked dataset not found at {final_path}. " + "Please run Extractor.py first!" + ) + + print(f"Loading unpacked CASIA dataset from: {final_path}") + + return datasets.ImageFolder( + root=final_path, + transform=None + ) + + def get_ids_and_counts(dataset): + + target = get_target(dataset=dataset) return torch.unique( - dataset.identity, - return_counts=True + input = target, + return_counts = True ) + + # filter selected identities from dataset # How many classes, how many images per class def select_ids( dataset, sample_size, class_size): - ids, counts = get_ids_and_counts(dataset=dataset) + ids, counts = get_ids_and_counts(dataset = dataset) eligible_mask = counts >= sample_size eligible_ids = ids[eligible_mask].numpy() @@ -65,21 +94,43 @@ def select_ids( dataset, sample_size, class_size): f"Only found {len(eligible_ids)} identities with {sample_size}+ images." ) - # Randomly select 50 identities + # Randomly select identities return np.random.choice(eligible_ids, class_size, replace=False) # optional function to get max amount of samples per class def select_top_ids(dataset, class_size): - ids, counts = get_ids_and_counts(dataset=dataset) + ids, counts = get_ids_and_counts(dataset = dataset) # sort by number of images (descending) - sorted_indices = torch.argsort(counts, descending=True) + sorted_indices = torch.argsort(counts, descending = True) top_ids = ids[sorted_indices][:class_size].numpy() return np.array(top_ids, dtype=int) +def get_target(dataset): + """ + Unified target extractor. + Instantly reads raw dataset arrays or safely scales down to unpack wrapped Subsets. + """ + if hasattr(dataset, 'identity'): + # celebA + targets = dataset.identity + elif hasattr(dataset, 'targets'): + # others + targets = dataset.targets + else: + # If it's an IdentitySubset or standard Subset, extract mapped targets sequentially + # This guarantees we get the 0 -> (n-1) remapped labels + targets = [dataset[i][1] for i in range(len(dataset))] + + if not isinstance(targets, torch.Tensor): + targets = torch.tensor(targets) + + return targets + + # split class images to train and test set. def get_indices(dataset, identities, split_at, size = 30): @@ -89,11 +140,13 @@ def get_indices(dataset, identities, split_at, size = 30): train_indices = [] test_indices = [] + target = get_target(dataset=dataset) + #training_sample = int(sample_size * training_ratio) np.random.seed(42) for person_id in identities: # Get all indices for this specific person - indices = torch.where(dataset.identity == person_id)[0].numpy() + indices = torch.where(target == person_id)[0].numpy() # Shuffle the indices for this person np.random.shuffle(indices) @@ -106,33 +159,23 @@ def get_indices(dataset, identities, split_at, size = 30): -def get_forget_retain_loaders(dataset: Dataset, forget_class_idx: int, batch_size: int = 32) -> tuple[DataLoader, DataLoader]: +def get_unlearning_loaders(dataset: Dataset, forget_class_idx: int, batch_size: int = 32) -> tuple[DataLoader, DataLoader]: """ Splits an IdentitySubset or standard Dataset into forget and retain sets based on a remapped target class index. """ - # 1. Safely extract targets whether it's a standard dataset or a Subset wrapper - if hasattr(dataset, 'targets'): - targets = dataset.targets - elif hasattr(dataset, 'identity'): # Raw CelebA support - targets = dataset.identity - else: - # If it's an IdentitySubset or standard Subset, extract mapped targets sequentially - # This guarantees we get the 0 -> (n-1) remapped labels - targets = [dataset[i][1] for i in range(len(dataset))] + # extract targets + targets = get_target(dataset=dataset) - if not isinstance(targets, torch.Tensor): - targets = torch.tensor(targets) - - # 2. Generate mask indices local to this subset + # mask indices local to this subset forget_indices = torch.where(targets == forget_class_idx)[0].tolist() retain_indices = torch.where(targets != forget_class_idx)[0].tolist() - # 3. Create PyTorch Subsets + # PyTorch Subsets forget_subset = Subset(dataset, forget_indices) retain_subset = Subset(dataset, retain_indices) - # 4. Wrap into clean DataLoaders + # DataLoaders forget_loader = DataLoader(forget_subset, batch_size=batch_size, shuffle=False) retain_loader = DataLoader(retain_subset, batch_size=batch_size, shuffle=True) diff --git a/sets/Data_OOP.py b/sets/Data_OOP.py new file mode 100644 index 0000000..25d6822 --- /dev/null +++ b/sets/Data_OOP.py @@ -0,0 +1,174 @@ +import torch +import numpy as np +from abc import ABC, abstractmethod +from torchvision import transforms, datasets +from torch.utils.data import Dataset, DataLoader, Subset + +class Data(ABC): + """ + Handles image pipelines, identity filtering, indexing, and unlearning splits. + """ + def __init__(self, res: int = 224, sample_size = 30, class_size = 20): + self.res = res + self.sample_size = sample_size + self.class_size = class_size + self.target = None # will have to be set in get_set() + + def train_transform(self): + return transforms.Compose([ + # ResNet expects 224 x 224 res + # Inception expects 299 x 299 + transforms.Resize((self.res, self.res)), + transforms.RandomHorizontalFlip(p=0.5), + transforms.ColorJitter( + brightness=0.2, + contrast=0.2, + saturation=0.1 + ), + transforms.ToTensor(), + transforms.Normalize( + mean=[0.485, 0.456, 0.406], + std=[0.229, 0.224, 0.225] + ) + ]) + + + def test_transform(self): + return transforms.Compose([ + transforms.Resize((self.res, self.res)), + transforms.ToTensor(), + transforms.Normalize( + mean=[0.485, 0.456, 0.406], + std=[0.229, 0.224, 0.225] + ) + ]) + + @abstractmethod + def get_set(self)-> datasets.TorchDataset: + """Loads and returns the raw underlying PyTorch Dataset instance.""" + pass + + def get_targets(self) -> torch.Tensor: + return self.target + + def get_ids_and_counts(self) -> tuple[torch.Tensor, torch.Tensor]: + if self.target is None: + raise ValueError ("This should be called after the 'target' variable has been set.") + return torch.unique( + self.target, + return_counts=True + ) + + def select_ids(self) -> np.ndarray: + ids, counts = self.get_ids_and_counts() + eligible_mask = counts >= self.sample_size + eligible_ids = ids[eligible_mask].numpy() + + if len(eligible_ids) < self.class_size: + raise ValueError( + f"Only found {len(eligible_ids)} identities with {self.sample_size}+ images." + ) + + return np.random.choice(eligible_ids, self.class_size, replace=False) + + # Function to get max amount of samples per class + def select_top_ids(self) -> np.ndarray: + + ids, counts = self.get_ids_and_counts() + # sort by number of images (descending) + sorted_indices = torch.argsort(counts, descending=True) + top_ids = ids[sorted_indices][:self.class_size].numpy() + return np.array(top_ids, dtype=int) + + def get_indices(self, identities: np.ndarray, split_at: int, max_size: int = None) -> tuple[list, list]: + '''train_indices = [] + test_indices = [] + max_size = self.sample_size if max_size is None else max_size + + # Pull raw target tensor array using concrete implementation rules + all_targets = np.array(self.get_targets().cpu()) + np.random.seed(42) + + for person_id in identities: + indices = np.where(all_targets == person_id)[0] + np.random.shuffle(indices) + + # Constrain total sample tracking size if requested (e.g. CelebA ceiling) + current_pool = indices[:max_size] if max_size else indices + + if split_at >= len(current_pool): + raise ValueError(f"Split point ({split_at}) exceeds slice size ({len(current_pool)}) for class {person_id}.") + + train_indices.extend(current_pool[:split_at]) + test_indices.extend(current_pool[split_at:]) + + return train_indices, test_indices''' + if split_at >= self.sample_size: # debug safety + raise ValueError(f"Split point ({split_at}) must be less than total size ({self.sample_size}).") + + train_indices = [] + test_indices = [] + + #training_sample = int(sample_size * training_ratio) + np.random.seed(42) + target = self.get_targets() + for person_id in identities: + # Get all indices for this specific person + indices = torch.where(target == person_id)[0].numpy() + + # Shuffle the indices for this person + np.random.shuffle(indices) + + # split data to testing and training + train_indices.extend(indices[:split_at]) + test_indices.extend(indices[split_at:self.sample_size]) + + return train_indices, test_indices + + @staticmethod + def get_unlearn_loaders( + dataset: Dataset, + forget_class_idx: int, + batch_size: int = 32 + ) -> tuple[DataLoader, DataLoader]: + + """Splits an IdentitySubset into forget/retain parts based on local class index.""" + if hasattr(dataset, 'targets'): + targets = dataset.targets + elif hasattr(dataset, 'identity'): + targets = dataset.identity + else: + targets = [dataset[i][1] for i in range(len(dataset))] + + if not isinstance(targets, torch.Tensor): + targets = torch.tensor(targets) + + forget_indices = torch.where(targets == forget_class_idx)[0].tolist() + retain_indices = torch.where(targets != forget_class_idx)[0].tolist() + + forget_subset = Subset(dataset, forget_indices) + retain_subset = Subset(dataset, retain_indices) + + forget_loader = DataLoader(forget_subset, batch_size=batch_size, shuffle=False) + retain_loader = DataLoader(retain_subset, batch_size=batch_size, shuffle=True) + + print(f"[Data Split] Local Class {forget_class_idx}: {len(forget_subset)} samples | Remaining Classes: {len(retain_subset)} samples.") + + return forget_loader, retain_loader + + + @staticmethod + def getDataSet(set:SetType, sample_size): + # some test + if set == SetType.CASIA: + from sets.CasiaFace import CasiaFace + return CasiaFace(sample_size = sample_size) + if set == SetType.CELEBA: + from sets.CelebA import CelebA + return CelebA(sample_size=sample_size) + + +from enum import Enum, auto +class SetType(Enum): + CASIA = auto() + CELEBA = auto() diff --git a/sets/Extractor.py b/sets/Extractor.py new file mode 100644 index 0000000..a3b2afd --- /dev/null +++ b/sets/Extractor.py @@ -0,0 +1,131 @@ +import os +import struct +from tqdm import tqdm +from collections import Counter +import hashlib + +def get_top_identities_binary(rec_path, idx_path, top_n=51): + """ + Pass 1: Scans the actual BINARY HEADERS in the .rec file. + This is the only way to be 100% sure which image belongs to whom. + """ + identity_counts = Counter() + + with open(idx_path, 'r') as f: + offsets = [int(line.strip().split('\t')[1]) for line in f.readlines()] + + print("Pass 1: Scanning binary headers to count identities...") + with open(rec_path, 'rb') as f: + for offset in tqdm(offsets): + f.seek(offset) + header_bin = f.read(32) # Read enough for the header + if len(header_bin) < 32: continue + + # MXNet Header format: [Flag, Label (float), ID, ID] + # The label is at offset 12 (float32) + label = int(struct.unpack('f', header_bin[12:16])[0]) + identity_counts[label] += 1 + + top_stats = identity_counts.most_common(top_n) + top_labels = {label for label, count in top_stats} + + print(f"\nTop {top_n} Identities by Binary Label:") + for label, count in top_stats: + print(f"ID: {label:<10} | Count: {count:<10}") + + return top_labels + +def extract_selected_binary(rec_path, idx_path, output_dir, top_labels): + if not os.path.exists(output_dir): + os.makedirs(output_dir) + + with open(idx_path, 'r') as f: + offsets = [int(line.strip().split('\t')[1]) for line in f.readlines()] + + print(f"\nPass 2: Extracting verified images...") + + # NEW: Keep track of how many images we've saved for each ID + # to avoid overwriting files. + save_counters = {label: 0 for label in top_labels} + total_extracted = 0 + + with open(rec_path, 'rb') as f: + for offset in tqdm(offsets): + f.seek(offset) + header_bin = f.read(32) + if len(header_bin) < 32: continue + + label = int(struct.unpack('f', header_bin[12:16])[0]) + + if label not in top_labels: + continue + + # Read image content + _, length_flag = struct.unpack('II', header_bin[:8]) + content_length = length_flag & ((1 << 31) - 1) + content = f.read(content_length) + + img_start = content.find(b'\xff\xd8') + if img_start == -1: continue + + target_folder = os.path.join(output_dir, str(label)) + os.makedirs(target_folder, exist_ok=True) + + # Use the counter for this specific label + current_count = save_counters[label] + img_filename = f"{current_count}.jpg" + img_path = os.path.join(target_folder, img_filename) + if(current_count > 200): + continue + + with open(img_path, 'wb') as img_f: + img_f.write(content[img_start:]) + + save_counters[label] += 1 + total_extracted += 1 + + print(f"\nDone! Extracted {total_extracted} total images.") + + + +def remove_duplicates(root_dir): + hashes = {} # hash -> first_filepath + duplicates_removed = 0 + + # Walk through every identity folder + for subdir, dirs, files in os.walk(root_dir): + for filename in tqdm(files, desc=f"Checking {os.path.basename(subdir)}"): + filepath = os.path.join(subdir, filename) + + # Calculate MD5 hash of the file + with open(filepath, 'rb') as f: + file_hash = hashlib.md5(f.read()).hexdigest() + + if file_hash in hashes: + # We've seen this image before! + os.remove(filepath) + duplicates_removed += 1 + else: + hashes[file_hash] = filepath + + print(f"\nClean-up complete. Removed {duplicates_removed} duplicate images.") + + +''' +if __name__ == "__main__": + # Point this to your unpacked Top 50 folder + target_dir = "./datasets/casia-set" + remove_duplicates(target_dir) +''' +if __name__ == "__main__": + base_dir = os.path.dirname(os.path.abspath(__file__)) + REC = os.path.join(base_dir, 'casia', 'train.rec') + IDX = os.path.join(base_dir, 'casia', 'train.idx') + OUT = os.path.join(base_dir, 'casia-set') + + # Step 1: Trust the binary, not the text file + top_verified_labels = get_top_identities_binary(REC, IDX, top_n=50) + + # Step 2: Extract + extract_selected_binary(REC, IDX, OUT, top_verified_labels) + \ No newline at end of file diff --git a/sets/IdentitySubset.py b/sets/IdentitySubset.py new file mode 100644 index 0000000..e0953af --- /dev/null +++ b/sets/IdentitySubset.py @@ -0,0 +1,34 @@ +import torch + +class IdentitySubset(torch.utils.data.Dataset): + def __init__(self, dataset, indices, id_mapping, transform=None): + """ + Args: + dataset: The base dataset (CelebA or ImageFolder). + indices: List of indices belonging to the selected identities. + id_mapping: Dictionary mapping {old_label: new_label_0_to_N}. + transform: Transformations to apply to the images. + """ + self.dataset = dataset + self.indices = indices + self.id_mapping = id_mapping + self.transform = transform + + def __getitem__(self, idx): + # Access the base dataset using the stored index + img, old_id = self.dataset[self.indices[idx]] + + # Apply transform if provided + if self.transform: + img = self.transform(img) + + # Handle Label Logic: + # CelebA returns a Tensor, ImageFolder returns an int. + # We convert to a standard Python int for the dictionary lookup. + clean_id = old_id.item() if torch.is_tensor(old_id) else old_id + + # Map the original identity to our new 0 -> N-1 range + return img, self.id_mapping[clean_id] + + def __len__(self): + return len(self.indices) \ No newline at end of file diff --git a/unlearning/CertifiedRemoval.py b/unlearning/CertifiedRemoval.py index 6c27d1a..8ef060a 100644 --- a/unlearning/CertifiedRemoval.py +++ b/unlearning/CertifiedRemoval.py @@ -1,77 +1,153 @@ import torch -import numpy as np -from scipy.optimize import minimize -from .Strategy import Strategy import torch.nn as nn +from torch.utils.data import DataLoader +from unlearning.Strategy import Strategy class CertifiedRemoval(Strategy): - """Implements Certified Removal for machine unlearning.""" - - def __init__(self, model, data, labels, removal_bound, epsilon): + """ + Implements Certified Removal (Guo et al.) adapted for deep architectures + like ResNet50 by isolating and updating the final classification layer. + """ + def __init__(self, removal_bound: float, epsilon: float, l2_reg: float = 0.1): super().__init__() - self.model = model - self.data = data - self.labels = labels - self.removal_bound = removal_bound - self.epsilon = epsilon + self.removal_bound = removal_bound # gamma in the paper + self.epsilon = epsilon # Privacy budget + self.l2_reg = l2_reg # Lambda regularization term - def _run(self, model: nn.Module) -> nn.Module: - """Runs the certified removal algorithm.""" - # 1. Linear Model Creation - # This is a simplification for demonstration purposes. In a real implementation, - # you'd use more sophisticated methods to learn the parameters of the - # 'removal' model based on the example being removed. - - def linear_model(x): - return torch.dot(x, torch.tensor([1, 1])) # Simplified Linear Model - - # 2. Optimization for Parameter Adjustment - # Optimize the parameter values to minimize the loss while staying within bounds. - original_params = torch.tensor([0.0, 0.0]) # Initial parameters for linear model - - def objective_function(params): - new_model = linear_model #use same function as defined above - return torch.sum(((new_model(self.data[0]) - self.labels)**2)) + def _get_features(self, backbone: nn.Module, loader: DataLoader, device: torch.device): + """Passes data through the frozen ResNet backbone to extract embedding features.""" + backbone.eval() + all_features = [] + all_labels = [] + + with torch.no_grad(): + for inputs, labels in loader: + inputs = inputs.to(device) + # Pass through backbone to get the 2048-dimensional feature vector + features = backbone(inputs) + all_features.append(features.cpu()) + all_labels.append(labels.cpu()) - result = minimize(objective_function, original_params, method='L-BFGS-B', bounds=[(-self.removal_bound, self.removal_bound)], options={'maxiter': 100}) + return torch.cat(all_features, dim=0), torch.cat(all_labels, dim=0) - if not result.success: - print("Warning: Optimization failed!") - print(result.message) - return model #Return original if optimization fails + def _run(self, model: nn.Module, forget_loader: DataLoader, retain_loader: DataLoader) -> nn.Module: + """ + Entry point expected by your Model.unlearn() architecture interface. + Applies Certified Removal strictly to the final linear layer (model.fc). + """ + device = next(model.parameters()).device + + # Isolate the final NN (Fully connected) layer from the model + linear_head = model.fc + # Temporarily turn the fc layer into a identity pass-through + model.fc = nn.Identity() + + print(">> Extracting deep features from model backbone...") + retain_features, retain_labels = self._get_features(model, retain_loader, device) + forget_features, forget_labels = self._get_features(model, forget_loader, device) + + # Restore the linear head back + model.fc = linear_head + + # Extract weights from the classification layer + # w shape: [num_classes, 2048] + w = model.fc.weight.data.clone().cpu() + + # Compute the Exact Hessian Matrix over the remaining (retained) features + # Formula: H = (X^T * X) / N + lambda * I + # this will be done on CPU. requires more ram so we cant afford to do it on VRAM + # print(">> Computing exact Hessian matrix...") + N_retain = retain_features.size(0) + # X_T_X = torch.matmul(retain_features.t(), retain_features) + # reg_matrix = self.l2_reg * torch.eye(retain_features.size(1)) + hessian = self._compute_hessian(retain_features=retain_features, retain_features_size = N_retain) + + # Compute the gradient of the loss with respect to the forgotten data + # print(">> Calculating forget set gradients...") + # num_classes = w.size(0) + # Pass features through linear layer weights to get logits + # logits_forget = torch.matmul(forget_features, w.t()) + # Apply softmax to get true class probabilities + # preds_softmax = torch.softmax(logits_forget, dim=1) + + # forget_labels_one_hot = torch.nn.functional.one_hot(forget_labels, num_classes=num_classes).float() + + #preds_forget = torch.matmul(forget_features, w.t()) + #error = preds_forget - forget_labels_one_hot + # error = preds_softmax - forget_labels_one_hot + # grad_forget shape: [num_classes, 2048] + grad_forget = self._compute_loss_gradient( + forget_labels=forget_labels, + forget_features=forget_features, + model_weights=w) + #torch.matmul(error.t(), forget_features) / forget_features.size(0) - new_params = result.x - # 3. New Model Creation + # Compute the Newton step update via solving: H * Delta_W^T = Grad_forget^T + delta_w = self._compute_newton_step( + tensor = hessian, + gradient= grad_forget + ) + # print(">> Solving Newton step via system optimization...") + # try: + # delta_w_t = torch.linalg.solve(Hessian, grad_forget.t()) + # delta_w = delta_w_t.t() + # except RuntimeError: + # print(">> Warning: Hessian matrix is singular. Falling back to pseudo-inverse.") + # delta_w = torch.matmul(grad_forget, torch.linalg.pinv(Hessian).t()) - new_model = lambda x: torch.dot(x, new_params) - return new_model + # Apply the Certified Removal update rule: W_new = W + Delta_W + new_w = w + delta_w + # Calibrate noise based on your epsilon budget + # (Guo et al. use a perturbation based on the regularization lambda and epsilon) + sigma = 2.0 / (self.l2_reg * self.epsilon) + noise = torch.randn_like(new_w) * (sigma / N_retain) + new_w = new_w + noise + # Theoretical Guarantee verification + norm_delta = torch.norm(delta_w).item() + if norm_delta > self.removal_bound: + print(f"!! Warning: Removal budget exceeded! Norm: {norm_delta:.4f} > Bound: {self.removal_bound}") + else: + print(f">> Certificate valid. Norm: {norm_delta:.4f} <= Bound: {self.removal_bound}") -if __name__ == '__main__': - # Example Usage - Synthetic Data for Demonstration - np.random.seed(42) # For reproducibility - n_samples = 100 - X = np.random.randn(n_samples, 2) - y = (X[:, 0] + X[:, 1] > 0).astype(int) + # Push updated parameters back into the model instance in-place + model.fc.weight.data = new_w.to(device) + + print(">> Certified Removal process completed successfully.") + return model + - # Create a simple linear model for demonstration - model = nn.Linear(2, 1) # Simple linear classifier - PyTorch Version - optimizer = torch.optim.SGD(model.parameters(), lr=0.01) # Optimizer for training the linear model + # computing the hessian matrix + def _compute_hessian(self, retain_features, retain_features_size): + print(">> Computing exact Hessian matrix...") + # N_retain = retain_features.size(0) + X_T_X = torch.matmul(retain_features.t(), retain_features) + reg_matrix = self.l2_reg * torch.eye(retain_features.size(1)) + return (X_T_X / retain_features_size) + reg_matrix + - # Train a Linear Model - for _ in range(100): #training loop - optimizer.zero_grad() - predictions = model(X) - loss = torch.sum((predictions - y)**2) - loss.backward() - optimizer.step() + def _compute_loss_gradient(self, forget_features, forget_labels, model_weights): + print(">> Calculating forget set gradients...") + num_classes = model_weights.size(0) + # Pass features through linear layer weights to get logits + logits_forget = torch.matmul(forget_features, model_weights.t()) + # Apply softmax to get true class probabilities + preds_softmax = torch.softmax(logits_forget, dim=1) + + forget_labels_one_hot = torch.nn.functional.one_hot(forget_labels, num_classes=num_classes).float() + - # Define parameters for Certified Removal - removal_bound = 1.0 - epsilon = 0.1 + error = preds_softmax - forget_labels_one_hot + # grad_forget shape: [num_classes, 2048] + return torch.matmul(error.t(), forget_features) / forget_features.size(0) + - # Create the CertifiedRemoval object with the trained model, data and labels - certified_removal_obj = CertifiedRemoval(model, X, y, removal_bound, epsilon) - - # Run Certified Removal - new_model = certified_removal_obj.apply(model) \ No newline at end of file + def _compute_newton_step(self,tensor, gradient): + print(">> Solving Newton step via system optimization...") + try: + delta_w_t = torch.linalg.solve(tensor, gradient.t()) + delta_w = delta_w_t.t() + except RuntimeError: + print(">> Warning: Hessian matrix is singular. Falling back to pseudo-inverse.") + delta_w = torch.matmul(gradient, torch.linalg.pinv(tensor).t()) + return delta_w \ No newline at end of file diff --git a/unlearning/LastK_Certified.py b/unlearning/LastK_Certified.py new file mode 100644 index 0000000..6aae56a --- /dev/null +++ b/unlearning/LastK_Certified.py @@ -0,0 +1,125 @@ +import torch +import torch.nn as nn +from torch.utils.data import DataLoader +from unlearning.Strategy import Strategy + +class LastKCertifiedRemoval(Strategy): + """ + Implements Certified Removal (Guo et al.) scaled up to the last K layers + of a ResNet50 network by flattening sub-graph parameters into a convex sub-problem. + """ + def __init__(self, removal_bound: float, epsilon: float, l2_reg: float = 0.1): + super().__init__() + self.removal_bound = removal_bound + self.epsilon = epsilon + self.l2_reg = l2_reg + + def _split_model(self, model: nn.Module): + """ + Splits ResNet50 into a frozen feature backbone and an active unlearning head. + Here, 'Last K Layers' includes layer4 and the fc classification head. + """ + # Feature Backbone: Everything up to layer3 + backbone = nn.Sequential( + model.conv1, + model.bn1, + model.relu, + model.maxpool, + model.layer1, + model.layer2, + model.layer3 + ) + + # Active Head: Layer4, global pooling, and the final linear layer + unlearning_head = nn.Sequential( + model.layer4, + model.avgpool, + nn.Flatten(1), + model.fc + ) + + return backbone, unlearning_head + + def _get_intermediate_features(self, backbone: nn.Module, loader: DataLoader, device: torch.device): + """Extracts features from the exit point of the frozen backbone (post-layer3).""" + backbone.eval() + all_features = [] + all_labels = [] + + with torch.no_grad(): + for inputs, labels in loader: + inputs = inputs.to(device) + features = backbone(inputs) + all_features.append(features.cpu()) + all_labels.append(labels.cpu()) + + return torch.cat(all_features, dim=0), torch.cat(all_labels, dim=0) + + def apply(self, model: nn.Module, forget_loader: DataLoader, retain_loader: DataLoader) -> nn.Module: + """ + Extracts intermediate features and updates the parameters of the last blocks + using the exact inverse-Hessian influence step. + """ + device = next(model.parameters()).device + + # 1. Slice the ResNet graph structural components + backbone, unlearning_head = self._split_model(model) + + print(">> Extracting intermediate structural features from layer3 exit...") + retain_feats, retain_labels = self._get_intermediate_features(backbone, retain_loader, device) + forget_feats, forget_labels = self._get_intermediate_features(backbone, forget_loader, device) + + # 2. Flatten target weights from the active head into a 1D optimization tensor + # For simplicity and mathematical stability, we isolate the final layer's weights + # inside the active head for the exact Hessian tracking step + target_layer = unlearning_head[-1] # This points straight to model.fc + w = target_layer.weight.data.clone().cpu() + + # 3. Compute Exact Hessian over intermediate embeddings + # ResNet50's layer4 expands channels to 2048, creating a 2048x2048 matrix context + print(">> Computing exact sub-graph Hessian matrix...") + N_retain = retain_feats.size(0) + + # Pool the feature maps if they haven't been flattened yet by the head module + if len(retain_feats.shape) > 2: + retain_flat = torch.mean(retain_feats, dim=[2, 3]) + forget_flat = torch.mean(forget_feats, dim=[2, 3]) + else: + retain_flat = retain_feats + forget_flat = forget_feats + + X_T_X = torch.matmul(retain_flat.t(), retain_flat) + reg_matrix = self.l2_reg * torch.eye(retain_flat.size(1)) + Hessian = (X_T_X / N_retain) + reg_matrix + + # 4. Calculate gradients relative to the forgotten target features + print(">> Calculating forget set gradients...") + num_classes = w.size(0) + forget_labels_one_hot = torch.nn.functional.one_hot(forget_labels, num_classes=num_classes).float() + + preds_forget = torch.matmul(forget_flat, w.t()) + error = preds_forget - forget_labels_one_hot + grad_forget = torch.matmul(error.t(), forget_flat) / forget_flat.size(0) + + # 5. Apply Newton Step optimization update + print(">> Inverting optimization subspace via system solver...") + try: + delta_w_t = torch.linalg.solve(Hessian, grad_forget.t()) + delta_w = delta_w_t.t() + except RuntimeError: + print(">> Warning: Subspace Hessian is singular. Using pseudo-inverse fallback.") + delta_w = torch.matmul(grad_forget, torch.linalg.pinv(Hessian).t()) + + # 6. Apply Weight Adjustment Bounds Check + new_w = w + delta_w + norm_delta = torch.norm(delta_w).item() + if norm_delta > self.removal_bound: + print(f"!! Warning: Removal budget exceeded! Norm: {norm_delta:.4f} > Bound: {self.removal_bound}") + else: + print(f">> Certificate valid. Subspace Norm: {norm_delta:.4f} <= Bound: {self.removal_bound}") + + # 7. Write weights directly back into the live ResNet50 instance + model.fc.weight.data = new_w.to(device) + + print(">> Last K Layers Certified Removal complete.") + return model \ No newline at end of file diff --git a/unlearning/LinearFiltration.py b/unlearning/LinearFiltration.py index ca3652b..6bd8de2 100644 --- a/unlearning/LinearFiltration.py +++ b/unlearning/LinearFiltration.py @@ -2,13 +2,14 @@ import torch import torch.nn as nn from .Strategy import Strategy +from torch.utils.data import DataLoader class LinearFiltration(Strategy): def __init__(self, target_class_idx: int): - super().__init__() # Automatically configures 'NormalizingLinearFiltration_metrics.txt' + super().__init__() self.target_class_idx = target_class_idx - def _run(self, model: nn.Module) -> nn.Module: + def _run(self, model: nn.Module, forget_loader: DataLoader, retain_loader: DataLoader) -> nn.Module: model.eval() for param in model.parameters(): param.requires_grad = False @@ -20,29 +21,28 @@ class LinearFiltration(Strategy): A = self._calculate_filtration_matrix(num_classes, self.target_class_idx, W.device) sanitized_W = torch.mm(A, W) model.fc.weight.copy_(sanitized_W) + # Filter the bias (if the layer uses one) + if model.fc.bias is not None: + b = model.fc.bias.data.clone() + # b is a 1D tensor of shape (num_classes), + # so we use torch.mv (matrix-vector multiplication) or unsqueeze it + sanitized_b = torch.mv(A, b) + model.fc.bias.copy_(sanitized_b) return model - '''@staticmethod - def _calculate_filtration_matrix(num_classes: int, forget_class: int, device: torch.device) -> torch.Tensor: - A = torch.eye(num_classes, device=device) - num_remaining_classes = num_classes - 1 - for j in range(num_classes): - if j == forget_class: - A[forget_class, j] = 0.0 - else: - A[forget_class, j] = 1.0 / num_remaining_classes - return A''' @staticmethod def _calculate_filtration_matrix(num_classes: int, forget_class: int, device: torch.device) -> torch.Tensor: A = torch.eye(num_classes, device=device) num_remaining = num_classes - 1 - # The row of the forgotten class should average the inputs of all other classes + # The row of the forgotten class should average all other classes for j in range(num_classes): if j == forget_class: + # we zero the forget class A[forget_class, j] = 0.0 else: + # and we distribute the output to the remaining A[forget_class, j] = 1.0 / num_remaining return A \ No newline at end of file diff --git a/unlearning/Strategy.py b/unlearning/Strategy.py index 99525a9..e96a932 100644 --- a/unlearning/Strategy.py +++ b/unlearning/Strategy.py @@ -2,6 +2,9 @@ import torch.nn as nn import time import os +from pathlib import Path +from torch.utils.data import DataLoader +import Util class Strategy: """Abstract base class for unlearning algorithms with automated, strategy-specific logging.""" @@ -9,21 +12,10 @@ class Strategy: def __init__(self): # Dynamically set file name based on the class name (e.g., 'NormalizingLinearFiltration.txt') self.strategy_name = self.__class__.__name__ - self.log_file = f"reports/{self.strategy_name}/metrics.txt" - self._initialize_log_file() + self.log_file = Path(f"reports/{self.strategy_name}/metrics.txt") + Util._initialize_log_file(log_file= self.log_file) - def _initialize_log_file(self): - """Creates a unique log file for this strategy with a header if it doesn't exist.""" - if not os.path.exists(self.log_file): - with open(self.log_file, "w") as f: - f.write("execution_time_sec\n") - - def log_metric(self, execution_time: float): - """Appends the execution time to this strategy's specific file.""" - with open(self.log_file, "a") as f: - f.write(f"{execution_time:.6f}\n") - - def apply(self, model: nn.Module) -> nn.Module: + def apply(self, model: nn.Module, forget_loader: DataLoader, retain_loader: DataLoader) -> nn.Module: """ Wraps the unlearning execution with automated timing and strategy-specific logging. DO NOT override this method in subclasses. Override _run instead. @@ -31,17 +23,21 @@ class Strategy: start_time = time.perf_counter() # Execute core unlearning logic - processed_model = self._run(model) + processed_model = self._run(model, forget_loader, retain_loader) end_time = time.perf_counter() execution_time = end_time - start_time # Log to the strategy's specific file - self.log_metric(execution_time) + Util.log_metric( + log_file=self.log_file, + execution_time=execution_time + ) + print(f"[{self.strategy_name}] Completed in {execution_time:.6f} seconds. Saved to {self.log_file}") return processed_model - def _run(self, model: nn.Module) -> nn.Module: + def _run(self, model: nn.Module, forget_loader: DataLoader, retain_loader: DataLoader) -> nn.Module: """Subclasses implement their core unlearning logic here.""" raise NotImplementedError \ No newline at end of file diff --git a/unlearning/WeightFiltration.py b/unlearning/WeightFiltration.py index e69de29..ecf49da 100644 --- a/unlearning/WeightFiltration.py +++ b/unlearning/WeightFiltration.py @@ -0,0 +1,140 @@ +import torch +import torch.nn as nn +import torch.nn.functional as F +import torch.optim as optim +from torch.utils.data import DataLoader +from unlearning.Strategy import Strategy + +class WeightFiltration(Strategy): + """ + Implements Poppi et al.'s Weight Filtering framework for linear layers. + Uses a standard functional hook to guarantee native PyTorch autograd tracking. + """ + def __init__(self, num_classes: int, target_class_idx: int, epochs: int = 10, lr: float = 0.2, gamma: float = 10.0): + super().__init__() + self.num_classes = num_classes + self.target_class_idx = target_class_idx + self.epochs = epochs + self.lr = lr + self.gamma = gamma + self.alpha = None + self.hook_handle = None + + + def _run(self, model: nn.Module, forget_loader: DataLoader, retain_loader: DataLoader) -> nn.Module: + device = next(model.parameters()).device + model.eval() + + # Locate layer4 for dynamic optimization + target_layer = model.layer4 if hasattr(model, 'layer4') else None + fc_layer = model.fc if hasattr(model, 'fc') and isinstance(model.fc, nn.Linear) else None + + if target_layer is None or fc_layer is None: + raise AttributeError("Model does not have the required layers.") + + # Match alpha dimensions to the channels outputted by layer4 + num_features = fc_layer.weight.shape[1] + self.alpha = nn.Parameter(torch.ones(self.num_classes, num_features, device=device) * 1.5) + + # Freeze everything except our channel mask + for p in model.parameters(): + p.requires_grad = False + self.alpha.requires_grad = True + + # Hook into layer4 dynamically to run the untraining optimization + self.hook_handle = target_layer.register_forward_hook(self._get_hook()) + # optimise the filter to maintain accuracy on retain set + # and decrease accuracy on forget set + self._optimise_filter(model, forget_loader, retain_loader, device) + + # Remove the runtime hook + self.hook_handle.remove() + + # Transfer the channel suppression permanently into model.fc + with torch.no_grad(): + mask = torch.sigmoid(self.alpha[self.target_class_idx]) # Shape: (num_features,) + + # Suppress the channels ONLY for the target class row in fc + fc_layer.weight[self.target_class_idx].copy_( + fc_layer.weight[self.target_class_idx] * mask + ) + print(f">> Baked deep channel filter into Class {self.target_class_idx} weights.") + + return model + + def _get_hook(self): + """ + Filters the internal feature map channels of layer4. + The mask scales the channels across the batch. + """ + def functional_hook(module, layer_input, layer_output): + # layer_output shape: (batch, channels, height, width) -> e.g., (16, 2048, 7, 7) + # self.alpha shape: (num_classes, channels) -> e.g., (20, 2048) + + # Extract 1D mask for the target class: (channels,) + mask = torch.sigmoid(self.alpha[self.target_class_idx]) + + # Reshape mask to (1, channels, 1, 1) so it broadcasts over batch, height, and width + mask = mask.view(1, -1, 1, 1) + + # Scale the internal feature maps before they move to the next layer + return layer_output * mask + + return functional_hook + + + def _optimise_filter(self, model, forget_loader, retain_loader, device): + optimizer = optim.Adam([self.alpha], lr=self.lr) + criterion = nn.CrossEntropyLoss() + + print(f"[{self.__class__.__name__}] Unlearning Class {self.target_class_idx} with gamma={self.gamma}...") + + # To optimise this loop we will watch improvements after each optimisation + temp_forget_loss = None + # this can be adjusted to optimise the best escape point + # it is the value we set to evaluate performance improvement after each itteration. + # if improvement is less than this, then we break itteration. + threshold = 0.05 + + for epoch in range(self.epochs): + forget_iter = iter(forget_loader) + t_loss_r, t_loss_f = 0.0, 0.0 + steps = 0 + + for r_inputs, r_labels in retain_loader: + r_inputs, r_labels = r_inputs.to(device), r_labels.to(device) + + try: + f_inputs, _ = next(forget_iter) + except StopIteration: + forget_iter = iter(forget_loader) + f_inputs, _ = next(forget_iter) + f_inputs = f_inputs.to(device) + + optimizer.zero_grad() + + # Compute Losses + # The hook handles the weight filtering smoothly behind the scenes + loss_r = criterion(model(r_inputs), r_labels) + loss_f = -torch.sum((torch.ones_like(model(f_inputs)) / self.num_classes) * torch.log_softmax(model(f_inputs), dim=-1)) + + total_loss = loss_r + (self.gamma * loss_f) + total_loss.backward() + optimizer.step() + + t_loss_r += loss_r.item() + t_loss_f += loss_f.item() + + steps += 1 + forget_loss = t_loss_f / steps + print(f" Epoch {epoch+1}/{self.epochs} | Retain Loss: {t_loss_r/steps:.4f} | Forget Loss: {forget_loss:.4f}") + + if temp_forget_loss is not None: + + improvement = temp_forget_loss - forget_loss + # if optimisation reaches a point of diminishing returns (improvements is less than threshold) + # we break the loop + if improvement < threshold: + break + # else we update the lasst recorded loss. + temp_forget_loss = forget_loss \ No newline at end of file From 207fcae6991d16cc9b1f8f3077d618db2e237746 Mon Sep 17 00:00:00 2001 From: Tinsae Date: Sun, 14 Jun 2026 23:13:33 +0200 Subject: [PATCH 16/20] facebook's implementation --- Tune.py | 40 +++---- Tune_new.py | 189 +++++++++++++++++++++++++++++++ unlearning/CertifiedRemoval.py | 26 ----- unlearning/Certified_facebook.py | 123 ++++++++++++++++++++ unlearning/LinearFiltration.py | 7 +- unlearning/Strategy.py | 3 +- unlearning/WeightFiltration.py | 18 ++- 7 files changed, 345 insertions(+), 61 deletions(-) create mode 100644 Tune_new.py create mode 100644 unlearning/Certified_facebook.py diff --git a/Tune.py b/Tune.py index 3fb1b6f..7f4d8e6 100644 --- a/Tune.py +++ b/Tune.py @@ -34,7 +34,7 @@ TRAINING_SMPLE = 27 # learning rate LR_RATE = 0.0001 -EPOCHS = 20 +EPOCHS = 10 # depends on model architecture # ResNet, DenseNet = 224 @@ -109,7 +109,7 @@ print(f'> Constants : Classes = {CLASS_SIZE}, Batch = {BATCH_SIZE}, epochs = {EP device = SetUp.get_device() -for i in range(0,CLASS_SIZE): +for i in range(0,1):#CLASS_SIZE): FORGET_CLASS_IDX = i # Create model using Factory model = Model.create( @@ -118,13 +118,13 @@ for i in range(0,CLASS_SIZE): size = CLASS_SIZE) # we may need to load existing model or finetune - model.train( - epochs = EPOCHS, - loader = train_loader, - rate = LR_RATE) + #model.train( + # epochs = EPOCHS, + # loader = train_loader, + # rate = LR_RATE) # save. - model.save(filename=arch.name.lower()) + #model.save(filename=arch.name.lower()) # done tuning @@ -147,10 +147,10 @@ for i in range(0,CLASS_SIZE): # Evaluate current_mode = "Finetuned" - accuracy, report_dict = model.evaluate( - loader = test_loader, - mode=current_mode - ) + #accuracy, report_dict = model.evaluate( + # loader = test_loader, + # mode=current_mode + #) Util._log_to_csv( arch=model.__class__.__name__, @@ -161,13 +161,13 @@ for i in range(0,CLASS_SIZE): ) # unlearning algorithms - linear_filtration = LinearFiltration(target_class_idx=FORGET_CLASS_IDX) + #linear_filtration = LinearFiltration(target_class_index=FORGET_CLASS_IDX) #filtration.apply(reloaded.model) - weight_filtration = WeightFiltration(num_classes = CLASS_SIZE,target_class_idx=FORGET_CLASS_IDX) + #weight_filtration = WeightFiltration(num_classes = CLASS_SIZE,target_class_idx=FORGET_CLASS_IDX) #weight_filtration.apply(reloaded.model) - certified_removal = CertifiedRemoval(removal_bound=0.05, epsilon=0.5, l2_reg=0.1) + certified_removal = CertifiedRemoval(target_class_index=FORGET_CLASS_IDX,removal_bound=0.05, epsilon=0.5, l2_reg=15) #certified_removal.apply(reloaded.model) # to be unlearned @@ -179,14 +179,14 @@ for i in range(0,CLASS_SIZE): # to evaluate forget_test_loader, retain_test_loader = get_unlearning_loaders( - dataset=test_data, - forget_class_idx=FORGET_CLASS_IDX, - batch_size=BATCH_SIZE - ) + dataset=test_data, + forget_class_idx=FORGET_CLASS_IDX, + batch_size=BATCH_SIZE + ) - strategies = [linear_filtration, weight_filtration, certified_removal] - #strategies = [linear_filtration] + #strategies = [linear_filtration, weight_filtration, certified_removal] + strategies = [certified_removal] for strategy in strategies: # test again reloaded = Model.create( diff --git a/Tune_new.py b/Tune_new.py new file mode 100644 index 0000000..1ca114a --- /dev/null +++ b/Tune_new.py @@ -0,0 +1,189 @@ +import torch +import torch.nn as nn +from torch.utils.data import DataLoader +from sklearn.metrics import classification_report + +# Framework and Utility Imports +import SetUp +import Util +from sets.Data import * +from sets.IdentitySubset import IdentitySubset +from architectures.Model import Model, Architecture +from unlearning.CertifiedRemoval import CertifiedRemoval + +# Global Hyperparameters +CLASS_SIZE = 20 +BATCH_SIZE = 16 +SAMPLE_SIZE = 30 +TRAINING_SAMPLE = 27 +RESOLUTION = 224 +ARCH = Architecture.RESNET50 + + +# Data preparation and model setup +def prepare_data_and_model_environment(): + """ + Handles environment discovery, downloads/loads datasets, generates + train-test class splits, and configures the architecture base. + """ + device = SetUp.get_device() + dataset_name = Set_Name.CELEBA + + dataset = get_set(set_name=dataset_name) + print(f"> {dataset.__class__.__name__} dataset loaded") + + # Select target identities (deterministic top sample identities) + selected_identities = select_top_ids(dataset=dataset, class_size=CLASS_SIZE) + print(f'> Selected {CLASS_SIZE} random identity classes from CelebA dataset.') + print(f'> A class has {TRAINING_SAMPLE} train and {SAMPLE_SIZE - TRAINING_SAMPLE} test samples') + + # Isolate sample index partitions + train_indices, test_indices = get_indices( + dataset=dataset, + identities=selected_identities, + split_at=TRAINING_SAMPLE, + size=SAMPLE_SIZE + ) + + # Remap identities to 0 -> (N-1) range required by CrossEntropyLoss + id_map = {old_id: new_id for new_id, old_id in enumerate(selected_identities)} + + # Build internal datasets using custom transforms + tr_transform = train_transform(RESOLUTION) + train_data = IdentitySubset( + dataset=dataset, + indices=train_indices, + id_mapping=id_map, + transform=tr_transform + ) + + te_transform = test_transform(RESOLUTION) + test_data = IdentitySubset( + dataset=dataset, + indices=test_indices, + id_mapping=id_map, + transform=te_transform + ) + + print(f"> Total training images: {len(train_data)}") + print(f'> Constants : Classes = {CLASS_SIZE}, Batch = {BATCH_SIZE}') + + # Create the base target model instance + base_model = Model.create(arch=ARCH, device=device, size=CLASS_SIZE) + + return { + "device": device, + "train_data": train_data, + "test_data": test_data, + "base_model": base_model + } + + +# Fine tunning and evaluation +def run_finetuning_or_baseline_eval(env_dict, run_training=False, lr_rate=0.0001, epochs=10): + """ + Handles model training (if flag is true) and logs the baseline fine-tuned + performance to file metrics. + """ + model = env_dict["base_model"] + train_data = env_dict["train_data"] + test_data = env_dict["test_data"] + + test_loader = DataLoader(test_data, batch_size=BATCH_SIZE, shuffle=False) + + # Optional training configuration switch + if run_training: + train_loader = DataLoader(train_data, batch_size=BATCH_SIZE, shuffle=True) + print(f"Starting training on {env_dict['device']}...") + model.train(epochs=epochs, loader=train_loader, rate=lr_rate) + model.save(filename=ARCH.name.lower()) + print(f"Model saved to trained_models/{ARCH.name.lower()}.pth") + + print(f"Total test images for these {CLASS_SIZE} classes: {len(test_data)}") + + # Evaluate original base checkpoint performance + current_mode = "Finetuned" + + # Check if weights exist or model was trained before evaluating + try: + accuracy, report_dict = model.evaluate(loader=test_loader, mode=current_mode) + Util._log_to_csv( + arch=model.__class__.__name__, + mode=current_mode, + accuracy=accuracy, + report_dict=report_dict, + strategy="base" + ) + except Exception as e: + print(f">> Skipping baseline log generation. Reason: {e}") + + +# Unlearning and strategy eval +def run_unlearning_and_strategy_eval(env_dict, forget_class_idx): + """ + Reloads a clean model state, applies the isolated unlearning framework, + and runs specific target evaluation domain checks. + """ + device = env_dict["device"] + train_data = env_dict["train_data"] + test_data = env_dict["test_data"] + + # Initialize the strategy hyperparameters matching standard settings + certified_removal = CertifiedRemoval( + target_class_index=forget_class_idx, + removal_bound=0.05, + epsilon=0.5, + l2_reg=15 + ) + + # Segment specific unlearning loaders using class index boundaries + forget_train_loader, retain_train_loader = get_unlearning_loaders( + dataset=train_data, forget_class_idx=forget_class_idx, batch_size=BATCH_SIZE + ) + forget_test_loader, retain_test_loader = get_unlearning_loaders( + dataset=test_data, forget_class_idx=forget_class_idx, batch_size=BATCH_SIZE + ) + + # Instantiate a clean copy of the model to keep weights isolated + reloaded = Model.create(arch=ARCH, device=device, size=CLASS_SIZE) + reloaded.load(arch=ARCH) + print("fine tunned model loaded into evaluation sandbox") + + # Execute strategic parameter unlearning step + certified_removal.apply(reloaded.model, forget_train_loader, retain_train_loader) + strategy_in_use = certified_removal.__class__.__name__ + + # Define validation tracking steps dynamically + evaluation_domains = [ + {"loader": retain_test_loader, "mode": "retain", "label": "\n--- Performance on Retained Classes"}, + {"loader": forget_test_loader, "mode": "forget", "label": "\n--- Performance on Forgotten Class"}, + {"loader": forget_train_loader, "mode": "forget_train", "label": "\n--- Performance on Forgotten Class (Train Set - Verifying Unlearning)"} + ] + + # Process and append metrics to target reporting paths + for domain in evaluation_domains: + print(domain["label"]) + accuracy, report_dict = reloaded.evaluate(loader=domain["loader"], mode=domain["mode"]) + Util._log_to_csv( + arch=reloaded.__class__.__name__, + mode=domain["mode"], + accuracy=accuracy, + report_dict=report_dict, + strategy=strategy_in_use + ) + + +# entry +if __name__ == "__main__": + + # Run Data Infrastructure and Architecture Builder + runtime_environment = prepare_data_and_model_environment() + + # Baseline Evaluation + # switch finetuning for tests on strategies only + run_finetuning_or_baseline_eval(runtime_environment, run_training=True) + + # Unlearning Iterations + for i in range(0, 1): + print(f"\n>>> Executing Unlearning Framework for Target Identity Index: {i} <<<") + run_unlearning_and_strategy_eval(runtime_environment, forget_class_idx=i) \ No newline at end of file diff --git a/unlearning/CertifiedRemoval.py b/unlearning/CertifiedRemoval.py index 8ef060a..1395654 100644 --- a/unlearning/CertifiedRemoval.py +++ b/unlearning/CertifiedRemoval.py @@ -55,27 +55,9 @@ class CertifiedRemoval(Strategy): # Compute the Exact Hessian Matrix over the remaining (retained) features # Formula: H = (X^T * X) / N + lambda * I - # this will be done on CPU. requires more ram so we cant afford to do it on VRAM - # print(">> Computing exact Hessian matrix...") N_retain = retain_features.size(0) - # X_T_X = torch.matmul(retain_features.t(), retain_features) - # reg_matrix = self.l2_reg * torch.eye(retain_features.size(1)) hessian = self._compute_hessian(retain_features=retain_features, retain_features_size = N_retain) - # Compute the gradient of the loss with respect to the forgotten data - # print(">> Calculating forget set gradients...") - # num_classes = w.size(0) - # Pass features through linear layer weights to get logits - # logits_forget = torch.matmul(forget_features, w.t()) - # Apply softmax to get true class probabilities - # preds_softmax = torch.softmax(logits_forget, dim=1) - - # forget_labels_one_hot = torch.nn.functional.one_hot(forget_labels, num_classes=num_classes).float() - - #preds_forget = torch.matmul(forget_features, w.t()) - #error = preds_forget - forget_labels_one_hot - # error = preds_softmax - forget_labels_one_hot - # grad_forget shape: [num_classes, 2048] grad_forget = self._compute_loss_gradient( forget_labels=forget_labels, forget_features=forget_features, @@ -87,14 +69,6 @@ class CertifiedRemoval(Strategy): tensor = hessian, gradient= grad_forget ) - # print(">> Solving Newton step via system optimization...") - # try: - # delta_w_t = torch.linalg.solve(Hessian, grad_forget.t()) - # delta_w = delta_w_t.t() - # except RuntimeError: - # print(">> Warning: Hessian matrix is singular. Falling back to pseudo-inverse.") - # delta_w = torch.matmul(grad_forget, torch.linalg.pinv(Hessian).t()) - # Apply the Certified Removal update rule: W_new = W + Delta_W new_w = w + delta_w # Calibrate noise based on your epsilon budget diff --git a/unlearning/Certified_facebook.py b/unlearning/Certified_facebook.py new file mode 100644 index 0000000..0b8bcd1 --- /dev/null +++ b/unlearning/Certified_facebook.py @@ -0,0 +1,123 @@ +import torch +import torch.nn as nn +import math +from torch.utils.data import DataLoader +from unlearning.Strategy import Strategy + +class CertifiedRemovalFacebook(Strategy): + """ + Implements Certified Removal (Guo et al.) mapped for Multi-Class models + by executing a single-class One-vs-Rest (OvR) block-removal update step. + Math matches the facebookresearch/certified-removal reference repository. + """ + def __init__(self, target_class_index: int, removal_bound: float, epsilon: float, l2_reg: float = 0.1): + super().__init__(target_class_index=target_class_index) + self.removal_bound = removal_bound # gamma in the paper + self.epsilon = epsilon # Privacy budget + self.l2_reg = l2_reg # Lambda (regularization term) + + def _get_features(self, backbone: nn.Module, loader: DataLoader, device: torch.device): + """Passes data through the frozen ResNet backbone to extract embedding features.""" + backbone.eval() + all_features = [] + + with torch.no_grad(): + for inputs, _ in loader: + inputs = inputs.to(device) + # Pass through frozen backbone to get the 2048-dimensional embedding + features = backbone(inputs) + all_features.append(features.cpu()) + + return torch.cat(all_features, dim=0) + + def _fb_lr_grad(self, w, X, y, lam): + """ + Replicates exact lr_grad calculation from Facebook's codebase. + Note: The resulting gradient has a flipped sign due to the structure of (z - 1). + """ + # X.mv(w) computes raw linear margins + z = torch.sigmoid(y * X.mv(w)) + # Gradient formula: X^T * ((z - 1) * y) + lambda * N * w + return X.t().mv((z - 1) * y) + lam * X.size(0) * w + + def _fb_lr_hessian_inv(self, w, X, y, lam, device, batch_size=50000): + """ + Replicates exact lr_hessian_inv calculation from Facebook's codebase. + Scales the L2 regularization matrix explicitly by dataset row count (N * lambda * I). + """ + z = torch.sigmoid(X.mv(w).mul_(y)) + D = z * (1 - z) # Element-wise variance vector + + H = None + num_batch = int(math.ceil(X.size(0) / batch_size)) + for i in range(num_batch): + lower = i * batch_size + upper = min((i + 1) * batch_size, X.size(0)) + X_i = X[lower:upper] + + # Stepwise feature weighting via element-wise variance columns + if H is None: + H = X_i.t().mm(D[lower:upper].unsqueeze(1) * X_i) + else: + H += X_i.t().mm(D[lower:upper].unsqueeze(1) * X_i) + + # Scale identity buffer by dataset split size: lambda * N_retain + reg_matrix = lam * X.size(0) * torch.eye(X.size(1), device=device).float() + return torch.linalg.inv(H + reg_matrix) + + def _run(self, model: nn.Module, forget_loader: DataLoader, retain_loader: DataLoader) -> nn.Module: + """ + Applies Certified Removal strictly to the target class parameters + belonging to the final fully connected layer (model.fc). + """ + device = next(model.parameters()).device + k = self.target_class_index + + # Isolate final layer and extract raw deep embeddings using frozen backbone + linear_head = model.fc + model.fc = nn.Identity() + + print(">> Extracting deep features from model backbone...") + X_retain = self._get_features(model, retain_loader, device).to(device) + X_forget = self._get_features(model, forget_loader, device).to(device) + + # Restore the classification head back + model.fc = linear_head + + # Extract current model weight row for the target class channel + w_k = model.fc.weight.data[k].clone().to(device) + + # Create One-vs-Rest binary target indicator arrays (+1.0 / -1.0) + # Retain dataset instances are negative labels (-1.0) for the target class channel + y_retain_binary = torch.full((X_retain.size(0),), -1.0, device=device) + # Forget dataset instances are positive labels (+1.0) for the target class channel + y_forget_binary = torch.full((X_forget.size(0),), 1.0, device=device) + + # Compute Inverse Hessian (on Retain Data) and Gradient (on Forget Data) + H_inv = self._fb_lr_hessian_inv(w_k, X_retain, y_retain_binary, self.l2_reg, device) + grad_forget = self._fb_lr_grad(w_k, X_forget, y_forget_binary, self.l2_reg) + + # 5. Compute the Weight Update Step Vector (Delta) + multiplier = 0.5 + delta_w_k = torch.mv(H_inv, grad_forget) * multiplier + + # Verify Theoretical Removal Bound Criteria + norm_delta = torch.norm(delta_w_k).item() + if norm_delta > self.removal_bound: + print(f"!! Warning: Removal budget exceeded! Norm: {norm_delta:.4f} > Bound: {self.removal_bound}") + else: + print(f">> Certificate valid. Norm: {norm_delta:.4f} <= Bound: {self.removal_bound}") + + # Apply Update (Using '+' since Facebook's grad calculation yields a negative sign output) + new_w_k = w_k + delta_w_k + + # Calibrate and Inject Perturbation Noise (Objective Perturbation Verification) + sigma = 2.0 / (self.l2_reg * self.epsilon) + noise = torch.randn_like(new_w_k, device=device) * (sigma / X_retain.size(0)) + new_w_k = new_w_k + noise + + # Commit updated weight vector row back into model head parameters in-place + model.fc.weight.data[k] = new_w_k + + print(">> Certified Removal process completed successfully.") + return model \ No newline at end of file diff --git a/unlearning/LinearFiltration.py b/unlearning/LinearFiltration.py index 6bd8de2..3218c3e 100644 --- a/unlearning/LinearFiltration.py +++ b/unlearning/LinearFiltration.py @@ -5,9 +5,8 @@ from .Strategy import Strategy from torch.utils.data import DataLoader class LinearFiltration(Strategy): - def __init__(self, target_class_idx: int): - super().__init__() - self.target_class_idx = target_class_idx + def __init__(self,target_class_index): + super().__init__(target_class_index = target_class_index) def _run(self, model: nn.Module, forget_loader: DataLoader, retain_loader: DataLoader) -> nn.Module: model.eval() @@ -18,7 +17,7 @@ class LinearFiltration(Strategy): W = model.fc.weight.data.clone() num_classes = W.shape[0] - A = self._calculate_filtration_matrix(num_classes, self.target_class_idx, W.device) + A = self._calculate_filtration_matrix(num_classes, self.target_class_index, W.device) sanitized_W = torch.mm(A, W) model.fc.weight.copy_(sanitized_W) # Filter the bias (if the layer uses one) diff --git a/unlearning/Strategy.py b/unlearning/Strategy.py index e96a932..1e29255 100644 --- a/unlearning/Strategy.py +++ b/unlearning/Strategy.py @@ -9,9 +9,10 @@ import Util class Strategy: """Abstract base class for unlearning algorithms with automated, strategy-specific logging.""" - def __init__(self): + def __init__(self, target_class_index): # Dynamically set file name based on the class name (e.g., 'NormalizingLinearFiltration.txt') self.strategy_name = self.__class__.__name__ + self.target_class_index = target_class_index self.log_file = Path(f"reports/{self.strategy_name}/metrics.txt") Util._initialize_log_file(log_file= self.log_file) diff --git a/unlearning/WeightFiltration.py b/unlearning/WeightFiltration.py index ecf49da..c806020 100644 --- a/unlearning/WeightFiltration.py +++ b/unlearning/WeightFiltration.py @@ -1,6 +1,5 @@ import torch import torch.nn as nn -import torch.nn.functional as F import torch.optim as optim from torch.utils.data import DataLoader from unlearning.Strategy import Strategy @@ -10,10 +9,9 @@ class WeightFiltration(Strategy): Implements Poppi et al.'s Weight Filtering framework for linear layers. Uses a standard functional hook to guarantee native PyTorch autograd tracking. """ - def __init__(self, num_classes: int, target_class_idx: int, epochs: int = 10, lr: float = 0.2, gamma: float = 10.0): - super().__init__() + def __init__(self, target_class_index,num_classes: int, epochs: int = 10, lr: float = 0.2, gamma: float = 10.0): + super().__init__(target_class_index = target_class_index) self.num_classes = num_classes - self.target_class_idx = target_class_idx self.epochs = epochs self.lr = lr self.gamma = gamma @@ -52,13 +50,13 @@ class WeightFiltration(Strategy): # Transfer the channel suppression permanently into model.fc with torch.no_grad(): - mask = torch.sigmoid(self.alpha[self.target_class_idx]) # Shape: (num_features,) + mask = torch.sigmoid(self.alpha[self.target_class_index]) # Shape: (num_features,) # Suppress the channels ONLY for the target class row in fc - fc_layer.weight[self.target_class_idx].copy_( - fc_layer.weight[self.target_class_idx] * mask + fc_layer.weight[self.target_class_index].copy_( + fc_layer.weight[self.target_class_index] * mask ) - print(f">> Baked deep channel filter into Class {self.target_class_idx} weights.") + print(f">> Baked deep channel filter into Class {self.target_class_index} weights.") return model @@ -72,7 +70,7 @@ class WeightFiltration(Strategy): # self.alpha shape: (num_classes, channels) -> e.g., (20, 2048) # Extract 1D mask for the target class: (channels,) - mask = torch.sigmoid(self.alpha[self.target_class_idx]) + mask = torch.sigmoid(self.alpha[self.target_class_index]) # Reshape mask to (1, channels, 1, 1) so it broadcasts over batch, height, and width mask = mask.view(1, -1, 1, 1) @@ -87,7 +85,7 @@ class WeightFiltration(Strategy): optimizer = optim.Adam([self.alpha], lr=self.lr) criterion = nn.CrossEntropyLoss() - print(f"[{self.__class__.__name__}] Unlearning Class {self.target_class_idx} with gamma={self.gamma}...") + print(f"[{self.__class__.__name__}] Unlearning Class {self.target_class_index} with gamma={self.gamma}...") # To optimise this loop we will watch improvements after each optimisation temp_forget_loss = None From 3c6ee9e12dfbec7b963a4d8a67a28a780a41acc7 Mon Sep 17 00:00:00 2001 From: Tinsae Date: Wed, 24 Jun 2026 21:05:06 +0200 Subject: [PATCH 17/20] certified --- .gitignore | 4 +- DataAnalyser.py | 2 +- Tune.py | 68 +++++--- Tune_new.py | 131 +++++++++++--- architectures/Model.py | 8 +- sets/CelebA.py | 4 +- sets/Extractor.py | 8 +- unlearning/CertifiedRemoval.py | 307 +++++++++++++++++++++------------ unlearning/LinearFiltration.py | 183 +++++++++++++++++--- unlearning/WeightFiltration.py | 176 +++++++++---------- 10 files changed, 610 insertions(+), 281 deletions(-) diff --git a/.gitignore b/.gitignore index 2a3ec8c..b100af8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +# Created by venv; see https://docs.python.org/3/library/venv.html +* # Virtual Environment (the folders Git saw) bin/ lib/ @@ -19,4 +21,4 @@ lib64 *.idx *.rec *.lst -property \ No newline at end of file +property diff --git a/DataAnalyser.py b/DataAnalyser.py index 1c994a7..4dc4c2a 100644 --- a/DataAnalyser.py +++ b/DataAnalyser.py @@ -1,6 +1,6 @@ #from Data import * -from datasets.Casia import * +from sets.Casia import * ''' Because the size of samples per class had the biggest impact diff --git a/Tune.py b/Tune.py index 7f4d8e6..3abb32d 100644 --- a/Tune.py +++ b/Tune.py @@ -41,6 +41,7 @@ EPOCHS = 10 # Inception = 299 RESOLUTION = 224 +FINETUNE = False # whether to fintune or just load finetuned model from dir # model architecture options are # - RESNET18 # - RESNET50 @@ -112,19 +113,24 @@ device = SetUp.get_device() for i in range(0,1):#CLASS_SIZE): FORGET_CLASS_IDX = i # Create model using Factory - model = Model.create( - arch = arch, - device = device, - size = CLASS_SIZE) - # we may need to load existing model or finetune - #model.train( - # epochs = EPOCHS, - # loader = train_loader, - # rate = LR_RATE) + model = None - # save. - #model.save(filename=arch.name.lower()) + if FINETUNE: + model = Model.create( + arch = arch, + device = device, + size = CLASS_SIZE) + + # we may need to load existing model or finetune + model.train( + epochs = EPOCHS, + loader = train_loader, + rate = LR_RATE) + + # save. + file_name = f"{arch.name.lower}_{dataset_name.name.lower()}" + model.save(filename=arch.name.lower()) # done tuning @@ -147,18 +153,21 @@ for i in range(0,1):#CLASS_SIZE): # Evaluate current_mode = "Finetuned" - #accuracy, report_dict = model.evaluate( - # loader = test_loader, - # mode=current_mode - #) + if FINETUNE: - Util._log_to_csv( - arch=model.__class__.__name__, - mode = current_mode, - accuracy=accuracy, - report_dict=report_dict, - strategy="base" - ) + #current_mode = "Finetuned" + accuracy, report_dict = model.evaluate( + loader = test_loader, + mode=current_mode + ) + + Util._log_to_csv( + arch=model.__class__.__name__, + mode = current_mode, + accuracy=accuracy, + report_dict=report_dict, + strategy="base" + ) # unlearning algorithms #linear_filtration = LinearFiltration(target_class_index=FORGET_CLASS_IDX) @@ -167,7 +176,14 @@ for i in range(0,1):#CLASS_SIZE): #weight_filtration = WeightFiltration(num_classes = CLASS_SIZE,target_class_idx=FORGET_CLASS_IDX) #weight_filtration.apply(reloaded.model) - certified_removal = CertifiedRemoval(target_class_index=FORGET_CLASS_IDX,removal_bound=0.05, epsilon=0.5, l2_reg=15) + certified_removal = CertifiedRemoval( + target_class_index=FORGET_CLASS_IDX, + s1=2, + s2=500, + unlearn_bs=2, + scale=100.0, # Drop scale to match lower s2 depth + std=0.00001) + #,removal_bound=0.05, epsilon=0.5, l2_reg=15) #certified_removal.apply(reloaded.model) # to be unlearned @@ -200,6 +216,12 @@ for i in range(0,1):#CLASS_SIZE): # loader = test_loader #) + if not FINETUNE: + reloaded.evaluate( + loader = test_loader, + mode=current_mode + ) + # Unlearning # train loaders passed here strategy.apply(reloaded.model, forget_train_loader, retain_train_loader) diff --git a/Tune_new.py b/Tune_new.py index 1ca114a..d8f155d 100644 --- a/Tune_new.py +++ b/Tune_new.py @@ -10,6 +10,9 @@ from sets.Data import * from sets.IdentitySubset import IdentitySubset from architectures.Model import Model, Architecture from unlearning.CertifiedRemoval import CertifiedRemoval +from unlearning.CertifiedUnlearning import CertifiedUnlearning +from unlearning.LinearFiltration import LinearFiltration +from unlearning.WeightFiltration import WeightFiltration # Global Hyperparameters CLASS_SIZE = 20 @@ -17,7 +20,7 @@ BATCH_SIZE = 16 SAMPLE_SIZE = 30 TRAINING_SAMPLE = 27 RESOLUTION = 224 -ARCH = Architecture.RESNET50 +ARCH = Architecture.RESNET18 # Data preparation and model setup @@ -27,14 +30,17 @@ def prepare_data_and_model_environment(): train-test class splits, and configures the architecture base. """ device = SetUp.get_device() - dataset_name = Set_Name.CELEBA + dataset_name = Set_Name.CASIAFACES + if dataset_name == Set_Name.CASIAFACES: + SAMPLE_SIZE = 400 + TRAINING_SAMPLE = 320 dataset = get_set(set_name=dataset_name) print(f"> {dataset.__class__.__name__} dataset loaded") # Select target identities (deterministic top sample identities) selected_identities = select_top_ids(dataset=dataset, class_size=CLASS_SIZE) - print(f'> Selected {CLASS_SIZE} random identity classes from CelebA dataset.') + print(f'> Selected {CLASS_SIZE} random identity classes from {dataset_name.name} dataset.') print(f'> A class has {TRAINING_SAMPLE} train and {SAMPLE_SIZE - TRAINING_SAMPLE} test samples') # Isolate sample index partitions @@ -81,6 +87,8 @@ def prepare_data_and_model_environment(): # Fine tunning and evaluation def run_finetuning_or_baseline_eval(env_dict, run_training=False, lr_rate=0.0001, epochs=10): + + """ Handles model training (if flag is true) and logs the baseline fine-tuned performance to file metrics. @@ -91,13 +99,15 @@ def run_finetuning_or_baseline_eval(env_dict, run_training=False, lr_rate=0.0001 test_loader = DataLoader(test_data, batch_size=BATCH_SIZE, shuffle=False) - # Optional training configuration switch - if run_training: - train_loader = DataLoader(train_data, batch_size=BATCH_SIZE, shuffle=True) - print(f"Starting training on {env_dict['device']}...") - model.train(epochs=epochs, loader=train_loader, rate=lr_rate) - model.save(filename=ARCH.name.lower()) - print(f"Model saved to trained_models/{ARCH.name.lower()}.pth") + + train_loader = DataLoader(train_data, batch_size=BATCH_SIZE, shuffle=True) + + if not run_training: + return + #print(f"Starting training on {env_dict['device']}...") + model.train(epochs=epochs, loader=train_loader, rate=lr_rate) + model.save(filename=ARCH.name.lower()) + print(f"Model saved to trained_models/{ARCH.name.lower()}.pth") print(f"Total test images for these {CLASS_SIZE} classes: {len(test_data)}") @@ -119,7 +129,7 @@ def run_finetuning_or_baseline_eval(env_dict, run_training=False, lr_rate=0.0001 # Unlearning and strategy eval -def run_unlearning_and_strategy_eval(env_dict, forget_class_idx): +def run_unlearning_and_strategy_eval(env_dict, forget_class_idx, strategy, evaluate = False): """ Reloads a clean model state, applies the isolated unlearning framework, and runs specific target evaluation domain checks. @@ -128,13 +138,34 @@ def run_unlearning_and_strategy_eval(env_dict, forget_class_idx): train_data = env_dict["train_data"] test_data = env_dict["test_data"] + # testing valuse * * + #--------------------------------------------------------------------------- + # S1 50 5 5 5 5 5 + # S2 1000 200 1000 500 200 300 + # BS 5 5 5 5 5 5 + # scale 2000 500 8000 5000 10000 8000 + # std 0.00001 0.00001 0.00001 0.00001 0.00001 0.00001 + # Initialize the strategy hyperparameters matching standard settings - certified_removal = CertifiedRemoval( - target_class_index=forget_class_idx, - removal_bound=0.05, - epsilon=0.5, - l2_reg=15 - ) + # increase s2, decrease scale ---sweet spot + '''certified_removal = CertifiedRemoval( + target_class_index=forget_class_idx, + s1=4, + s2=350, # 350 best + unlearn_bs=5, + scale=6000.0, # 6000 was good + std=0.00001 + )''' + '''certified_removal = CertifiedUnlearning( + target_class_index=0, + l2_reg=0.0005, + gamma=0.1, + scale=7000.0, + s1=2, + s2=350, + std=1e-5, + unlearn_bs=2 + )''' # Segment specific unlearning loaders using class index boundaries forget_train_loader, retain_train_loader = get_unlearning_loaders( @@ -147,11 +178,17 @@ def run_unlearning_and_strategy_eval(env_dict, forget_class_idx): # Instantiate a clean copy of the model to keep weights isolated reloaded = Model.create(arch=ARCH, device=device, size=CLASS_SIZE) reloaded.load(arch=ARCH) + + if evaluate: + reloaded.evaluate( + loader=retain_test_loader, mode="finetuned" + ) + print("fine tunned model loaded into evaluation sandbox") # Execute strategic parameter unlearning step - certified_removal.apply(reloaded.model, forget_train_loader, retain_train_loader) - strategy_in_use = certified_removal.__class__.__name__ + strategy.apply(reloaded.model, forget_train_loader, retain_train_loader) + strategy_in_use = strategy.__class__.__name__ # Define validation tracking steps dynamically evaluation_domains = [ @@ -180,10 +217,62 @@ if __name__ == "__main__": runtime_environment = prepare_data_and_model_environment() # Baseline Evaluation + finetuning = False # switch finetuning for tests on strategies only - run_finetuning_or_baseline_eval(runtime_environment, run_training=True) + run_finetuning_or_baseline_eval(runtime_environment, run_training=finetuning) + finetuning = True # Unlearning Iterations for i in range(0, 1): + + # strategies + # + #certified_removal = CertifiedRemoval( + # target_class_index=i, + # s1=4, + # s2=350, # 350 best + # unlearn_bs=5, + # scale=6000.0, # 6000 was good + # std=0.00009 + # ) + + + + certified_unlearning = CertifiedUnlearning( + target_class_index=i, + l2_reg=0.000002, + gamma=0.1, + scale= 20000,# 16400.0, # took ages to reach this sweet spot + s1=2, + s2=300, + std=0.00001, + unlearn_bs=16 + ) + + # works perfectly + linear_filtration = LinearFiltration( + + target_class_index=i + ) + + weight_filtration = WeightFiltration( + target_class_index=i, + epochs=3, + lr=0.5, + gamma=150 + ) + + strategies = [ + certified_unlearning, + # weight_filtration, + # linear_filtration + ] + print(f"\n>>> Executing Unlearning Framework for Target Identity Index: {i} <<<") - run_unlearning_and_strategy_eval(runtime_environment, forget_class_idx=i) \ No newline at end of file + for strategy in strategies: + run_unlearning_and_strategy_eval( + runtime_environment, + forget_class_idx=i, + strategy=strategy, + evaluate= not finetuning + ) diff --git a/architectures/Model.py b/architectures/Model.py index 50e7771..287be1e 100644 --- a/architectures/Model.py +++ b/architectures/Model.py @@ -139,7 +139,7 @@ class Model(ABC): - # Using the factory patern here + # factory @staticmethod def create(arch, device, size): print(f'>> MODEL ARCHITECTURE >> {arch.name}.') @@ -151,6 +151,11 @@ class Model(ABC): from architectures.ResNet18 import ResNet18 return ResNet18(device, size) + # ResNet34 + case Architecture.RESNET34: + from architectures.ResNet34 import ResNet34 + return ResNet34(device, size) + # ResNet50 case Architecture.RESNET50: from architectures.ResNet50 import ResNet50 @@ -190,6 +195,7 @@ from enum import Enum, auto class Architecture(Enum): RESNET18 = auto() RESNET50 = auto() + RESNET34 = auto() INCEPTION = auto() DENSENET121 = auto() GOOGLENET = auto() diff --git a/sets/CelebA.py b/sets/CelebA.py index aff1f24..481dcc5 100644 --- a/sets/CelebA.py +++ b/sets/CelebA.py @@ -9,10 +9,10 @@ class CelebA(Data): def get_set(self): set = datasets.CelebA( - root = "./data", + root = "../data", split='all', target_type='identity', - download=True, + download=False, transform=None ) # set the target first diff --git a/sets/Extractor.py b/sets/Extractor.py index a3b2afd..dafe426 100644 --- a/sets/Extractor.py +++ b/sets/Extractor.py @@ -75,7 +75,7 @@ def extract_selected_binary(rec_path, idx_path, output_dir, top_labels): current_count = save_counters[label] img_filename = f"{current_count}.jpg" img_path = os.path.join(target_folder, img_filename) - if(current_count > 200): + if(current_count > 405): continue with open(img_path, 'wb') as img_f: @@ -119,9 +119,9 @@ if __name__ == "__main__": ''' if __name__ == "__main__": base_dir = os.path.dirname(os.path.abspath(__file__)) - REC = os.path.join(base_dir, 'casia', 'train.rec') - IDX = os.path.join(base_dir, 'casia', 'train.idx') - OUT = os.path.join(base_dir, 'casia-set') + REC = os.path.join(base_dir, '../data/casia-set', 'train.rec') + IDX = os.path.join(base_dir, '../data/casia-set', 'train.idx') + OUT = os.path.join(base_dir, '../data/casia-set') # Step 1: Trust the binary, not the text file top_verified_labels = get_top_identities_binary(REC, IDX, top_n=50) diff --git a/unlearning/CertifiedRemoval.py b/unlearning/CertifiedRemoval.py index 1395654..058fd22 100644 --- a/unlearning/CertifiedRemoval.py +++ b/unlearning/CertifiedRemoval.py @@ -1,127 +1,214 @@ import torch import torch.nn as nn -from torch.utils.data import DataLoader +from torch.utils.data import DataLoader, RandomSampler +from torch.autograd import grad from unlearning.Strategy import Strategy class CertifiedRemoval(Strategy): """ - Implements Certified Removal (Guo et al.) adapted for deep architectures - like ResNet50 by isolating and updating the final classification layer. + Implements Certified Unlearning for non-convex DNNs (Zhang et al.). + Uses a modified, stabilized stochastic Newton step using Taylor-expansion + HVP estimation across the entire parameter space, capped with calibrated noise. """ - def __init__(self, removal_bound: float, epsilon: float, l2_reg: float = 0.1): - super().__init__() - self.removal_bound = removal_bound # gamma in the paper - self.epsilon = epsilon # Privacy budget - self.l2_reg = l2_reg # Lambda regularization term + def __init__(self, target_class_index: int, l2_reg: float = 0.0005, + gamma: float = 0.01, scale: float = 1000.0, + s1: int = 10, s2: int = 1000, std: float = 0.001, unlearn_bs: int = 2): + super().__init__(target_class_index) + self.l2_reg = l2_reg + self.gamma = gamma + self.scale = scale + self.s1 = s1 + self.s2 = s2 + self.std = std + self.unlearn_bs = unlearn_bs - def _get_features(self, backbone: nn.Module, loader: DataLoader, device: torch.device): - """Passes data through the frozen ResNet backbone to extract embedding features.""" - backbone.eval() - all_features = [] - all_labels = [] + ''' + def _compute_loss_gradient(self, model, loader, device: torch.device): + model.eval() + criterion = nn.CrossEntropyLoss(reduction='sum') + params = [p for p in model.parameters() if p.requires_grad] + grad_accumulator = [torch.zeros_like(p).cpu() for p in params] + total_samples = 0 - with torch.no_grad(): - for inputs, labels in loader: - inputs = inputs.to(device) - # Pass through backbone to get the 2048-dimensional feature vector - features = backbone(inputs) - all_features.append(features.cpu()) - all_labels.append(labels.cpu()) + for data, targets in loader: + total_samples += targets.shape[0] + data, targets = data.to(device), targets.to(device) + outputs = model(data) + + mini_grads = list(grad(criterion(outputs, targets), params)) + for i in range(len(grad_accumulator)): + grad_accumulator[i] += mini_grads[i].cpu().detach() + + for i in range(len(grad_accumulator)): + grad_accumulator[i] /= total_samples + + l2_reg_term = 0.0 + for param in model.parameters(): + l2_reg_term += torch.norm(param, p=2) + + reg_grads = list(grad(self.l2_reg * l2_reg_term, params)) + for i in range(len(grad_accumulator)): + grad_accumulator[i] += reg_grads[i].cpu().detach() + + return [p.to(device) for p in grad_accumulator]''' + def _compute_loss_gradient(self, model, loader, device: torch.device): + model.eval() + # Use reduction='sum' matching the original framework + criterion = nn.CrossEntropyLoss(reduction='sum') + params = [p for p in model.parameters() if p.requires_grad] + grad_accumulator = [torch.zeros_like(p).cpu() for p in params] + total_samples = 0 + + for data, targets in loader: + total_samples += targets.shape[0] + data, targets = data.to(device), targets.to(device) + outputs = model(data) + + loss = criterion(outputs, targets) + + # Incorporate L2 weight regularization directly inside the backprop graph + # to keep scaling bounded and aligned with the data volume + l2_reg_term = 0.0 + for param in model.parameters(): + if param.requires_grad: + l2_reg_term += torch.norm(param, p=2) + + total_loss = loss + (self.l2_reg * l2_reg_term) + + mini_grads = list(grad(total_loss, params, retain_graph=False)) + for i in range(len(grad_accumulator)): + grad_accumulator[i] += mini_grads[i].cpu().detach() + + for i in range(len(grad_accumulator)): + grad_accumulator[i] /= total_samples + + return [p.to(device) for p in grad_accumulator] + + + def grad_batch(batch_loader, lam, model, device): + model.eval() + criterion = nn.CrossEntropyLoss(reduction='sum') + params = [p for p in model.parameters() if p.requires_grad] + grad_batch = [torch.zeros_like(p).cpu() for p in params] + num = 0 + for batch_idx, (data, targets) in enumerate(batch_loader): + num += targets.shape[0] + data, targets = data.to(device), targets.to(device) + outputs = model(data) + + grad_mini = list(grad(criterion(outputs, targets), params)) + for i in range(len(grad_batch)): + grad_batch[i] += grad_mini[i].cpu().detach() + + for i in range(len(grad_batch)): + grad_batch[i] /= num + + l2_reg = 0 + for param in model.parameters(): + l2_reg += torch.norm(param, p=2) + grad_reg = list(grad(lam * l2_reg, params)) + for i in range(len(grad_batch)): + grad_batch[i] += grad_reg[i].cpu().detach() + return [p.to(device) for p in grad_batch] + + def _hvp(self, loss, params, v): + first_grads = grad(loss, params, retain_graph=True, create_graph=True) + elemwise_products = 0 + for grad_elem, v_elem in zip(first_grads, v): + elemwise_products += torch.sum(grad_elem * v_elem) + # FIX 1: Set create_graph to False to prevent massive nested graph accumulation + return grad(elemwise_products, params, create_graph=False) + + def _stochastic_newton_update(self, g, retain_dataset, model, device): + model.eval() + criterion = nn.CrossEntropyLoss() + params = [p for p in model.parameters() if p.requires_grad] + h_res = [torch.zeros_like(p) for p in g] + + for _ in range(self.s1): + h_estimate = [p.clone() for p in g] + sampler = RandomSampler(retain_dataset, replacement=True, num_samples=self.unlearn_bs * self.s2) + res_loader = DataLoader(retain_dataset, batch_size=self.unlearn_bs, sampler=sampler) + res_iter = iter(res_loader) + + for j in range(self.s2): + try: + data, target = next(res_iter) + except StopIteration: + res_iter = iter(res_loader) + data, target = next(res_iter) + + data, target = data.to(device), target.to(device) + outputs = model(data) - return torch.cat(all_features, dim=0), torch.cat(all_labels, dim=0) + loss = criterion(outputs, target) + l2_reg_term = 0.0 + for param in model.parameters(): + l2_reg_term += torch.norm(param, p=2) + loss += (self.l2_reg + self.gamma) * l2_reg_term + + h_s = self._hvp(loss, params, h_estimate) + + with torch.no_grad(): + for k in range(len(params)): + # FIX 2: Added .detach() to decouple history strings across iterative update blocks + #h_estimate[k] = (h_estimate[k] + g[k] - h_s[k] / self.scale).detach() + next_estimate = h_estimate[k].data + g[k].data - (h_s[k].data / self.scale) + h_estimate[k] = next_estimate.clone() + del h_s, loss, outputs + + for k in range(len(params)): + h_res[k] = h_res[k] + h_estimate[k] / self.scale + + return [p / self.s1 for p in h_res] - def _run(self, model: nn.Module, forget_loader: DataLoader, retain_loader: DataLoader) -> nn.Module: - """ - Entry point expected by your Model.unlearn() architecture interface. - Applies Certified Removal strictly to the final linear layer (model.fc). - """ + '''def _run(self, model: nn.Module, forget_loader: DataLoader, retain_loader: DataLoader) -> nn.Module: device = next(model.parameters()).device - - # Isolate the final NN (Fully connected) layer from the model - linear_head = model.fc - # Temporarily turn the fc layer into a identity pass-through - model.fc = nn.Identity() - - print(">> Extracting deep features from model backbone...") - retain_features, retain_labels = self._get_features(model, retain_loader, device) - forget_features, forget_labels = self._get_features(model, forget_loader, device) - - # Restore the linear head back - model.fc = linear_head - - # Extract weights from the classification layer - # w shape: [num_classes, 2048] - w = model.fc.weight.data.clone().cpu() - - # Compute the Exact Hessian Matrix over the remaining (retained) features - # Formula: H = (X^T * X) / N + lambda * I - N_retain = retain_features.size(0) - hessian = self._compute_hessian(retain_features=retain_features, retain_features_size = N_retain) - - grad_forget = self._compute_loss_gradient( - forget_labels=forget_labels, - forget_features=forget_features, - model_weights=w) - #torch.matmul(error.t(), forget_features) / forget_features.size(0) - # Compute the Newton step update via solving: H * Delta_W^T = Grad_forget^T - delta_w = self._compute_newton_step( - tensor = hessian, - gradient= grad_forget - ) - # Apply the Certified Removal update rule: W_new = W + Delta_W - new_w = w + delta_w - # Calibrate noise based on your epsilon budget - # (Guo et al. use a perturbation based on the regularization lambda and epsilon) - sigma = 2.0 / (self.l2_reg * self.epsilon) - noise = torch.randn_like(new_w) * (sigma / N_retain) - new_w = new_w + noise - - # Theoretical Guarantee verification - norm_delta = torch.norm(delta_w).item() - if norm_delta > self.removal_bound: - print(f"!! Warning: Removal budget exceeded! Norm: {norm_delta:.4f} > Bound: {self.removal_bound}") - else: - print(f">> Certificate valid. Norm: {norm_delta:.4f} <= Bound: {self.removal_bound}") - - # Push updated parameters back into the model instance in-place - model.fc.weight.data = new_w.to(device) + num_forget = len(forget_loader.dataset) + num_retain = len(retain_loader.dataset) + scaling_ratio = num_forget / num_retain - print(">> Certified Removal process completed successfully.") - return model - - - # computing the hessian matrix - def _compute_hessian(self, retain_features, retain_features_size): - print(">> Computing exact Hessian matrix...") - # N_retain = retain_features.size(0) - X_T_X = torch.matmul(retain_features.t(), retain_features) - reg_matrix = self.l2_reg * torch.eye(retain_features.size(1)) - return (X_T_X / retain_features_size) + reg_matrix - - - def _compute_loss_gradient(self, forget_features, forget_labels, model_weights): - print(">> Calculating forget set gradients...") - num_classes = model_weights.size(0) - # Pass features through linear layer weights to get logits - logits_forget = torch.matmul(forget_features, model_weights.t()) - # Apply softmax to get true class probabilities - preds_softmax = torch.softmax(logits_forget, dim=1) + print(">> Calculating base gradients over target FORGET set...") + # FIX 3: Base gradients MUST be evaluated from forget_loader to drop target class distributions + g = self._compute_loss_gradient(model, forget_loader, device) - forget_labels_one_hot = torch.nn.functional.one_hot(forget_labels, num_classes=num_classes).float() + print(">> Estimating non-convex inverse Hessian trajectories via Taylor series...") + retain_dataset = retain_loader.dataset + delta = self._stochastic_newton_update(g, retain_dataset, model, device) + print(">> Applying stabilized parameter adjustments and randomized certification noise...") + with torch.no_grad(): + for i, param in enumerate(model.parameters()): + if param.requires_grad: + noise = self.std * torch.randn(param.data.size(), device=device) + #param.data.add_(-delta[i] + noise) + param.data.add_(scaling_ratio * delta[i] + noise) + + print(">> Certified Unlearning process completed successfully across the complete landscape.") + return model''' + def _run(self, model: nn.Module, forget_loader: DataLoader, retain_loader: DataLoader) -> nn.Module: + device = next(model.parameters()).device - error = preds_softmax - forget_labels_one_hot - # grad_forget shape: [num_classes, 2048] - return torch.matmul(error.t(), forget_features) / forget_features.size(0) - - - def _compute_newton_step(self,tensor, gradient): - print(">> Solving Newton step via system optimization...") - try: - delta_w_t = torch.linalg.solve(tensor, gradient.t()) - delta_w = delta_w_t.t() - except RuntimeError: - print(">> Warning: Hessian matrix is singular. Falling back to pseudo-inverse.") - delta_w = torch.matmul(gradient, torch.linalg.pinv(tensor).t()) - return delta_w \ No newline at end of file + print(">> Calculating stable base gradients over the RETAIN set...") + # To match the author's snippet perfectly, g MUST be computed on the retain data. + # If this loader is too large for your VRAM, use a smaller batch size (e.g. 16 or 32) + # in your main training script when creating retain_loader. + g = self._compute_loss_gradient(model, retain_loader, device) + + print(">> Estimating non-convex inverse Hessian trajectories via Taylor series...") + retain_dataset = retain_loader.dataset + delta = self._stochastic_newton_update(g, retain_dataset, model, device) + + print(">> Applying parameter removal adjustments (-delta)...") + with torch.no_grad(): + for i, param in enumerate(model.parameters()): + if param.requires_grad: + noise = self.std * torch.randn(param.data.size(), device=device) + + # MATCHING THE SNIPPET: Subtract delta exactly as the authors do + # This removes the influence trace of the omitted data. + param.data.add_(-delta[i] + noise) + + print(">> Certified Unlearning process completed successfully.") + return model \ No newline at end of file diff --git a/unlearning/LinearFiltration.py b/unlearning/LinearFiltration.py index 3218c3e..ebf9343 100644 --- a/unlearning/LinearFiltration.py +++ b/unlearning/LinearFiltration.py @@ -1,47 +1,184 @@ - import torch import torch.nn as nn from .Strategy import Strategy from torch.utils.data import DataLoader class LinearFiltration(Strategy): - def __init__(self,target_class_index): - super().__init__(target_class_index = target_class_index) + def __init__(self, target_class_index): + super().__init__(target_class_index=target_class_index) def _run(self, model: nn.Module, forget_loader: DataLoader, retain_loader: DataLoader) -> nn.Module: model.eval() + # Freeze internal params for param in model.parameters(): param.requires_grad = False + + device = next(model.parameters()).device - with torch.no_grad(): - W = model.fc.weight.data.clone() - num_classes = W.shape[0] - - A = self._calculate_filtration_matrix(num_classes, self.target_class_index, W.device) - sanitized_W = torch.mm(A, W) - model.fc.weight.copy_(sanitized_W) - # Filter the bias (if the layer uses one) - if model.fc.bias is not None: - b = model.fc.bias.data.clone() - # b is a 1D tensor of shape (num_classes), - # so we use torch.mv (matrix-vector multiplication) or unsqueeze it - sanitized_b = torch.mv(A, b) - model.fc.bias.copy_(sanitized_b) - - return model + return self.normalise( + model=model, + retain_loader=retain_loader, + forget_loader=forget_loader, + device=device, + forget_index=self.target_class_index + ) + + # FIX: Added staticmethod decorator + @staticmethod + def get_features(model, inputs): + # For ResNet, pass through everything up to the fc layer + x = model.conv1(inputs) + x = model.bn1(x) + x = model.relu(x) + x = model.maxpool(x) + + x = model.layer1(x) + x = model.layer2(x) + x = model.layer3(x) + x = model.layer4(x) + + x = model.avgpool(x) + x = torch.flatten(x, 1) + return x @staticmethod def _calculate_filtration_matrix(num_classes: int, forget_class: int, device: torch.device) -> torch.Tensor: A = torch.eye(num_classes, device=device) num_remaining = num_classes - 1 - # The row of the forgotten class should average all other classes for j in range(num_classes): if j == forget_class: - # we zero the forget class A[forget_class, j] = 0.0 else: - # and we distribute the output to the remaining A[forget_class, j] = 1.0 / num_remaining - return A \ No newline at end of file + return A + + + @staticmethod + def _sums_and_counts(model, num_classes, retain_loader, forget_loader, device, forget_index, h_dim): + model.eval() + + sums = torch.zeros(num_classes, h_dim, device=device) + counts = torch.zeros(num_classes, device=device) + + # Generate values for retain + with torch.no_grad(): + for inputs, targets in retain_loader: + inputs = inputs.to(device) + targets = targets.to(device) + # FIX: Call get_features instead of model() directly + outputs = LinearFiltration.get_features(model, inputs) + + for j in range(num_classes): + if j == forget_index: + continue + mask = (targets == j) + + if mask.any(): + sums[j] += outputs[mask].sum(dim=0) + counts[j] += mask.sum() + + # Values for forget + with torch.no_grad(): + for inputs, targets in forget_loader: + inputs = inputs.to(device) + targets = targets.to(device) + # FIX: Call get_features instead of model() directly + outputs = LinearFiltration.get_features(model, inputs) + + mask = (targets == forget_index) + + if mask.any(): + sums[forget_index] += outputs[mask].sum(dim=0) + counts[forget_index] += mask.sum() + + return sums, counts + + @staticmethod + def _get_means(model, num_classes, retain_loader, forget_loader, device, forget_index): + h_dim = model.fc.in_features + + sums, counts = LinearFiltration._sums_and_counts( + model=model, + num_classes=num_classes, + retain_loader=retain_loader, + forget_loader=forget_loader, + device=device, + forget_index=forget_index, + h_dim=h_dim + ) + A = [] + + for i in range(num_classes): + if counts[i] > 0: + A.append(sums[i] / counts[i]) + else: + A.append(torch.zeros(h_dim, device=device)) + + # CORRECT: Stack along dim=0 to make it (num_classes, h_dim) + return torch.stack(A, dim=0) + + + @staticmethod + def _compute_z(tensor, forget_index): + # Now tensor has shape (num_classes, h_dim) -> tensor.shape[0] is num_classes + K = tensor.shape[0] + + # pi_a0 should match the feature space dimensions (h_dim) + pi_a0 = torch.zeros(tensor.shape[1], device=tensor.device) + + t_1 = pi_a0 + a0 = tensor[forget_index, :] # Extracting the row vector for the forgotten class + + mask_a0 = torch.ones( + a0.shape[0], + dtype=torch.bool, + device=tensor.device + ) + # We compute the target shift over features + t_2 = -(1.0 / (K - 1)) * a0[mask_a0].sum() + + mask_rows = torch.ones(K, dtype=torch.bool, device=tensor.device) + mask_rows[forget_index] = False + + r_A = tensor[mask_rows, :] + t_3 = (1.0 / ((K - 1)) ** 2) * r_A.sum() + + return t_1 + t_2 + t_3 + + + @staticmethod + def normalise(model, retain_loader, forget_loader, device, forget_index): + W = model.fc.weight.data.clone() + num_classes = W.shape[0] + + A = LinearFiltration._get_means( + model=model, + num_classes=num_classes, + retain_loader=retain_loader, + forget_loader=forget_loader, + device=device, + forget_index=forget_index + ) + + Z = LinearFiltration._compute_z(tensor=A, forget_index=forget_index) + B_Z_rows = [] + + for i in range(num_classes): + if i == forget_index: + B_Z_rows.append(Z) + else: + # Retained classes maintain their original ideal feature directions + B_Z_rows.append(A[i]) + + # Stack back along dim=0 to match (num_classes, h_dim) + B_Z = torch.stack(B_Z_rows, dim=0) + + A_inv = torch.linalg.pinv(A) + + W_Z = B_Z @ A_inv @ W + + model.fc.weight.copy_(W_Z) + + return model \ No newline at end of file diff --git a/unlearning/WeightFiltration.py b/unlearning/WeightFiltration.py index c806020..3dd03eb 100644 --- a/unlearning/WeightFiltration.py +++ b/unlearning/WeightFiltration.py @@ -3,97 +3,34 @@ import torch.nn as nn import torch.optim as optim from torch.utils.data import DataLoader from unlearning.Strategy import Strategy +from .wf.WF_Net import WF_Net class WeightFiltration(Strategy): """ - Implements Poppi et al.'s Weight Filtering framework for linear layers. - Uses a standard functional hook to guarantee native PyTorch autograd tracking. + Verbatim implementation of Poppi et al.'s WF-Net framework. + Directly filters the convolutional weights of a target layer using a learnable + channel mask, optimizing it via weight-space regularization. """ - def __init__(self, target_class_index,num_classes: int, epochs: int = 10, lr: float = 0.2, gamma: float = 10.0): - super().__init__(target_class_index = target_class_index) - self.num_classes = num_classes + def __init__(self, target_class_index: int, epochs: int = 10, lr: float = 0.2, gamma: float = 10.0): + super().__init__(target_class_index=target_class_index) self.epochs = epochs self.lr = lr self.gamma = gamma - self.alpha = None - self.hook_handle = None + #self.alpha = None - - def _run(self, model: nn.Module, forget_loader: DataLoader, retain_loader: DataLoader) -> nn.Module: - device = next(model.parameters()).device - model.eval() + + + + + def _optimise_filter(self, model: nn.Module, forget_loader: DataLoader, retain_loader: DataLoader, device): + # 1. Initialize the wrapper with your pre-trained model + num_classes = model.fc.out_features + wf_model = WF_Net(original_model=model, num_classes=num_classes).to(device) - # Locate layer4 for dynamic optimization - target_layer = model.layer4 if hasattr(model, 'layer4') else None - fc_layer = model.fc if hasattr(model, 'fc') and isinstance(model.fc, nn.Linear) else None - - if target_layer is None or fc_layer is None: - raise AttributeError("Model does not have the required layers.") - - # Match alpha dimensions to the channels outputted by layer4 - num_features = fc_layer.weight.shape[1] - self.alpha = nn.Parameter(torch.ones(self.num_classes, num_features, device=device) * 1.5) - - # Freeze everything except our channel mask - for p in model.parameters(): - p.requires_grad = False - self.alpha.requires_grad = True - - # Hook into layer4 dynamically to run the untraining optimization - self.hook_handle = target_layer.register_forward_hook(self._get_hook()) - # optimise the filter to maintain accuracy on retain set - # and decrease accuracy on forget set - self._optimise_filter(model, forget_loader, retain_loader, device) - - # Remove the runtime hook - self.hook_handle.remove() - - # Transfer the channel suppression permanently into model.fc - with torch.no_grad(): - mask = torch.sigmoid(self.alpha[self.target_class_index]) # Shape: (num_features,) - - # Suppress the channels ONLY for the target class row in fc - fc_layer.weight[self.target_class_index].copy_( - fc_layer.weight[self.target_class_index] * mask - ) - print(f">> Baked deep channel filter into Class {self.target_class_index} weights.") - - return model - - def _get_hook(self): - """ - Filters the internal feature map channels of layer4. - The mask scales the channels across the batch. - """ - def functional_hook(module, layer_input, layer_output): - # layer_output shape: (batch, channels, height, width) -> e.g., (16, 2048, 7, 7) - # self.alpha shape: (num_classes, channels) -> e.g., (20, 2048) - - # Extract 1D mask for the target class: (channels,) - mask = torch.sigmoid(self.alpha[self.target_class_index]) - - # Reshape mask to (1, channels, 1, 1) so it broadcasts over batch, height, and width - mask = mask.view(1, -1, 1, 1) - - # Scale the internal feature maps before they move to the next layer - return layer_output * mask - - return functional_hook - - - def _optimise_filter(self, model, forget_loader, retain_loader, device): - optimizer = optim.Adam([self.alpha], lr=self.lr) + # 2. ONLY optimize alpha (everything else is frozen inside the wrapper) + optimizer = optim.Adam([wf_model.alpha], lr=self.lr) criterion = nn.CrossEntropyLoss() - print(f"[{self.__class__.__name__}] Unlearning Class {self.target_class_index} with gamma={self.gamma}...") - - # To optimise this loop we will watch improvements after each optimisation - temp_forget_loss = None - # this can be adjusted to optimise the best escape point - # it is the value we set to evaluate performance improvement after each itteration. - # if improvement is less than this, then we break itteration. - threshold = 0.05 - for epoch in range(self.epochs): forget_iter = iter(forget_loader) t_loss_r, t_loss_f = 0.0, 0.0 @@ -102,6 +39,7 @@ class WeightFiltration(Strategy): for r_inputs, r_labels in retain_loader: r_inputs, r_labels = r_inputs.to(device), r_labels.to(device) + # Pull the matching forget batch input try: f_inputs, _ = next(forget_iter) except StopIteration: @@ -111,10 +49,19 @@ class WeightFiltration(Strategy): optimizer.zero_grad() - # Compute Losses - # The hook handles the weight filtering smoothly behind the scenes - loss_r = criterion(model(r_inputs), r_labels) - loss_f = -torch.sum((torch.ones_like(model(f_inputs)) / self.num_classes) * torch.log_softmax(model(f_inputs), dim=-1)) + # --- APPLY ALGORITHM 1 FORWARD PASS TO BOTH INPUTS --- + # Pass the input batch AND the target unlearn class index + outputs_r = wf_model(r_inputs, target_unlearn_class=self.target_class_index) + outputs_f = wf_model(f_inputs, target_unlearn_class=self.target_class_index) + + # Compute Losses using Poppi et al.'s temperature scaled entropy + loss_r = criterion(outputs_r, r_labels) + + temperature = 3.0 + logits_f_scaled = outputs_f / temperature + loss_f = -torch.sum( + (torch.ones_like(logits_f_scaled) / num_classes) * torch.log_softmax(logits_f_scaled, dim=-1) + ) total_loss = loss_r + (self.gamma * loss_f) total_loss.backward() @@ -122,17 +69,56 @@ class WeightFiltration(Strategy): t_loss_r += loss_r.item() t_loss_f += loss_f.item() - steps += 1 - forget_loss = t_loss_f / steps - print(f" Epoch {epoch+1}/{self.epochs} | Retain Loss: {t_loss_r/steps:.4f} | Forget Loss: {forget_loss:.4f}") + + print(f" Epoch {epoch+1}/{self.epochs} | Retain Loss: {t_loss_r/steps:.4f} | Forget Loss: {t_loss_f/steps:.4f}") - if temp_forget_loss is not None: + return wf_model + - improvement = temp_forget_loss - forget_loss - # if optimisation reaches a point of diminishing returns (improvements is less than threshold) - # we break the loop - if improvement < threshold: - break - # else we update the lasst recorded loss. - temp_forget_loss = forget_loss \ No newline at end of file + + def _run(self, model: nn.Module, forget_loader: DataLoader, retain_loader: DataLoader) -> nn.Module: + device = next(model.parameters()).device + model.eval() + + # In WF-Net, the mask targets the last major convolutional block + # For ResNet-18, that is the final conv layer in layer4 block 1 + if hasattr(model, 'layer4') and len(model.layer4) > 1: + target_conv = model.layer4[1].conv2 + else: + raise AttributeError("Model architecture does not match expected ResNet-18 structure.") + + # Store a pristine, non-grad copy of the original trained weights + # Shape of conv2.weight: (out_channels, in_channels, kernel_size, kernel_size) -> e.g., (512, 512, 3, 3) + original_weights = target_conv.weight.data.clone().detach() + out_channels = original_weights.shape[0] + + # Initialize alpha gate vector matching Poppi et al.'s initialization range + # Shape: (out_channels,) -> acting directly as a filter-level gate + #self.alpha = nn.Parameter(torch.ones(out_channels, device=device) * 1.5) + + # Freeze the global model graph; only optimize our filter parameter mask + for p in model.parameters(): + p.requires_grad = False + #self.alpha.requires_grad = True + + wf_model = self._optimise_filter( + model, + forget_loader=forget_loader, + retain_loader=retain_loader, + device=device, + ) + + # --- PERMANENT BAKING STEP --- + # Disconnect the dynamic parameter and freeze the optimal gated state permanently into the architecture + + with torch.no_grad(): + final_mask = torch.sigmoid(wf_model.alpha[self.target_class_index]).view(-1, 1, 1, 1) + target_conv.weight.copy_(original_weights * final_mask) + + # Re-enable model parameters for downstream evaluation processing + for p in model.parameters(): + p.requires_grad = True + + print(f">> Permanently altered {out_channels} convolutional filters in layer4 via WF-Net.") + return model \ No newline at end of file From c4fdc034b2a17f76fd0a0bf34682d7f7c702dcf3 Mon Sep 17 00:00:00 2001 From: Tinsae Date: Thu, 25 Jun 2026 06:49:14 +0200 Subject: [PATCH 18/20] wf_net --- Tune_new.py | 12 +++-- unlearning/WeightFiltration.py | 28 ++++++----- unlearning/wf/WF_Net.py | 86 ++++++++++++++++++++++++++++++++++ 3 files changed, 108 insertions(+), 18 deletions(-) create mode 100644 unlearning/wf/WF_Net.py diff --git a/Tune_new.py b/Tune_new.py index d8f155d..e9152f1 100644 --- a/Tune_new.py +++ b/Tune_new.py @@ -13,6 +13,8 @@ from unlearning.CertifiedRemoval import CertifiedRemoval from unlearning.CertifiedUnlearning import CertifiedUnlearning from unlearning.LinearFiltration import LinearFiltration from unlearning.WeightFiltration import WeightFiltration +from unlearning.WF import WeightF + # Global Hyperparameters CLASS_SIZE = 20 @@ -255,16 +257,16 @@ if __name__ == "__main__": target_class_index=i ) - weight_filtration = WeightFiltration( + weight_filtration = WeightF( #WeightFiltration( target_class_index=i, epochs=3, - lr=0.5, - gamma=150 + lr=0.05, + gamma=5 ) strategies = [ - certified_unlearning, - # weight_filtration, + # certified_unlearning, + weight_filtration, # linear_filtration ] diff --git a/unlearning/WeightFiltration.py b/unlearning/WeightFiltration.py index 3dd03eb..48a321e 100644 --- a/unlearning/WeightFiltration.py +++ b/unlearning/WeightFiltration.py @@ -59,20 +59,21 @@ class WeightFiltration(Strategy): temperature = 3.0 logits_f_scaled = outputs_f / temperature - loss_f = -torch.sum( - (torch.ones_like(logits_f_scaled) / num_classes) * torch.log_softmax(logits_f_scaled, dim=-1) - ) + # Compute uniform target entropy per-sample, then average over the batch + log_probs_f = torch.log_softmax(logits_f_scaled, dim=-1) + uniform_target = torch.ones_like(logits_f_scaled) / num_classes + loss_f = -torch.sum(uniform_target * log_probs_f, dim=-1).mean() + total_loss = loss_r + (self.gamma * loss_f) total_loss.backward() optimizer.step() - + t_loss_r += loss_r.item() t_loss_f += loss_f.item() steps += 1 - + print(f" Epoch {epoch+1}/{self.epochs} | Retain Loss: {t_loss_r/steps:.4f} | Forget Loss: {t_loss_f/steps:.4f}") - return wf_model @@ -110,15 +111,16 @@ class WeightFiltration(Strategy): ) # --- PERMANENT BAKING STEP --- - # Disconnect the dynamic parameter and freeze the optimal gated state permanently into the architecture - with torch.no_grad(): + # Grab the alpha mask vector for the forgotten class and cast to 4D tensor shape final_mask = torch.sigmoid(wf_model.alpha[self.target_class_index]).view(-1, 1, 1, 1) - target_conv.weight.copy_(original_weights * final_mask) - # Re-enable model parameters for downstream evaluation processing + # Apply filter masking permanently back onto the base layer + target_conv.weight.copy_(original_weights * final_mask) + + # Unfreeze architecture parameters for evaluations downstream for p in model.parameters(): p.requires_grad = True - - print(f">> Permanently altered {out_channels} convolutional filters in layer4 via WF-Net.") - return model \ No newline at end of file + + print(f">> Permanently altered {out_channels} convolutional filters in layer4 via WF-Net.") + return model diff --git a/unlearning/wf/WF_Net.py b/unlearning/wf/WF_Net.py new file mode 100644 index 0000000..a5dd690 --- /dev/null +++ b/unlearning/wf/WF_Net.py @@ -0,0 +1,86 @@ +import torch +import torch.nn as nn +import torch.nn.functional as F + +class WF_Net(nn.Module): + """ + Implements Poppi et al.'s WF Model structure. + Wraps a pre-trained ResNet-18 and dynamically applies + weight-space gating matrix multiplication during the forward step. + """ + def __init__(self, original_model: nn.Module, num_classes: int): + super().__init__() + # Extract the sequence of blocks/layers L from the original model + self.conv1 = original_model.conv1 + self.bn1 = original_model.bn1 + self.relu = original_model.relu + self.maxpool = original_model.maxpool + + self.layer1 = original_model.layer1 + self.layer2 = original_model.layer2 + self.layer3 = original_model.layer3 + self.layer4 = original_model.layer4 + + self.avgpool = original_model.avgpool + self.fc = original_model.fc + + # Target layer for filtering: layer4 block 1 conv2 + # We extract its static tensor data out of the autograd parameter pool + self.target_conv = self.layer4[1].conv2 + self.original_w = nn.Parameter(self.target_conv.weight.data.clone().detach(), requires_grad=False) + + # Require: Alpha gating matrix. Shape: (num_classes, out_channels) + # Initialized to 1.5 as per Poppi et al.'s verbatim specification + out_channels = self.original_w.shape[0] + #self.alpha = nn.Parameter(torch.ones(num_classes, out_channels) * 1.5) + self.alpha = nn.Parameter(torch.ones(num_classes, out_channels)) + + def forward(self, x: torch.Tensor, target_unlearn_class: int) -> torch.Tensor: + """ + Implements Algorithm 1: General forward step of a WF model + Inputs: + x: Input tensor (Xin) + target_unlearn_class: The class index we are actively filtering out (Yunl) + """ + # 1. Run through early sequence of layers undisturbed + x = self.maxpool(self.relu(self.bn1(self.conv1(x)))) + x = self.layer1(x) + x = self.layer2(x) + x = self.layer3(x) + + # Run layer4 block 0 and block 1 conv1 normally + x = self.layer4[0](x) + + identity = x + + + + x = self.layer4[1].conv1(x) + x = self.layer4[1].bn1(x) + x = self.layer4[1].relu(x) + + # 2. CORE WF-NET MATH: w_hat_l <- alpha_l[Yunl] ⊙ w_l + # Extract 1D vector for target class and reshape to (out_channels, 1, 1, 1) for 4D convolution broadcasting + mask = torch.sigmoid(self.alpha[target_unlearn_class]).view(-1, 1, 1, 1) + w_hat = self.original_w * mask + + # 3. Pass gated weights straight to functional forward pass: l(Xi, w_hat_l) + x = F.conv2d( + x, + weight=w_hat, + bias=self.target_conv.bias, + stride=self.target_conv.stride, + padding=self.target_conv.padding + ) + x = self.layer4[1].bn2(x) + + # Handle residual shortcut skip connection manually since we opened up block 1 + # In ResNet-18 layer4, block 1 has no downsample shortcut layer; it's a direct identity add + x = self.layer4[1].relu(x + identity) + + # 4. Final Classification Head Sequence + x = self.avgpool(x) + x = torch.flatten(x, 1) + y_out = self.fc(x) + + return y_out From 0680a920ff68b6d5e7df875c083fd8f87db11407 Mon Sep 17 00:00:00 2001 From: Tinsae Date: Sat, 27 Jun 2026 20:38:17 +0200 Subject: [PATCH 19/20] unlearning done --- Tune_new.py | 124 +++++++----------- Util.py | 5 +- architectures/Model.py | 5 +- sets/Data.py | 59 ++++++++- unlearning/CertifiedRemoval.py | 214 ------------------------------- unlearning/Certified_facebook.py | 123 ------------------ unlearning/LastK_Certified.py | 125 ------------------ unlearning/LinearFiltration.py | 141 ++++++++------------ unlearning/Strategy.py | 20 ++- unlearning/WeightFiltration.py | 177 +++++++++++++------------ unlearning/wf/WF_Net.py | 54 ++++---- 11 files changed, 307 insertions(+), 740 deletions(-) delete mode 100644 unlearning/CertifiedRemoval.py delete mode 100644 unlearning/Certified_facebook.py delete mode 100644 unlearning/LastK_Certified.py diff --git a/Tune_new.py b/Tune_new.py index e9152f1..0145b9a 100644 --- a/Tune_new.py +++ b/Tune_new.py @@ -9,11 +9,9 @@ import Util from sets.Data import * from sets.IdentitySubset import IdentitySubset from architectures.Model import Model, Architecture -from unlearning.CertifiedRemoval import CertifiedRemoval from unlearning.CertifiedUnlearning import CertifiedUnlearning from unlearning.LinearFiltration import LinearFiltration from unlearning.WeightFiltration import WeightFiltration -from unlearning.WF import WeightF # Global Hyperparameters @@ -140,40 +138,12 @@ def run_unlearning_and_strategy_eval(env_dict, forget_class_idx, strategy, evalu train_data = env_dict["train_data"] test_data = env_dict["test_data"] - # testing valuse * * - #--------------------------------------------------------------------------- - # S1 50 5 5 5 5 5 - # S2 1000 200 1000 500 200 300 - # BS 5 5 5 5 5 5 - # scale 2000 500 8000 5000 10000 8000 - # std 0.00001 0.00001 0.00001 0.00001 0.00001 0.00001 - - # Initialize the strategy hyperparameters matching standard settings - # increase s2, decrease scale ---sweet spot - '''certified_removal = CertifiedRemoval( - target_class_index=forget_class_idx, - s1=4, - s2=350, # 350 best - unlearn_bs=5, - scale=6000.0, # 6000 was good - std=0.00001 - )''' - '''certified_removal = CertifiedUnlearning( - target_class_index=0, - l2_reg=0.0005, - gamma=0.1, - scale=7000.0, - s1=2, - s2=350, - std=1e-5, - unlearn_bs=2 - )''' # Segment specific unlearning loaders using class index boundaries - forget_train_loader, retain_train_loader = get_unlearning_loaders( + retain_train_loader , forget_train_loader= get_unlearning_loaders( dataset=train_data, forget_class_idx=forget_class_idx, batch_size=BATCH_SIZE ) - forget_test_loader, retain_test_loader = get_unlearning_loaders( + retain_test_loader, forget_test_loader = get_unlearning_loaders( dataset=test_data, forget_class_idx=forget_class_idx, batch_size=BATCH_SIZE ) @@ -189,9 +159,16 @@ def run_unlearning_and_strategy_eval(env_dict, forget_class_idx, strategy, evalu print("fine tunned model loaded into evaluation sandbox") # Execute strategic parameter unlearning step - strategy.apply(reloaded.model, forget_train_loader, retain_train_loader) + unlearned = strategy.apply(reloaded.model, train_data) strategy_in_use = strategy.__class__.__name__ + if isinstance(unlearned,nn.Module): + reloaded.model = unlearned + else: + reloaded = unlearned + + + # Define validation tracking steps dynamically evaluation_domains = [ {"loader": retain_test_loader, "mode": "retain", "label": "\n--- Performance on Retained Classes"}, @@ -215,66 +192,63 @@ def run_unlearning_and_strategy_eval(env_dict, forget_class_idx, strategy, evalu # entry if __name__ == "__main__": - # Run Data Infrastructure and Architecture Builder - runtime_environment = prepare_data_and_model_environment() - - # Baseline Evaluation - finetuning = False - # switch finetuning for tests on strategies only - run_finetuning_or_baseline_eval(runtime_environment, run_training=finetuning) - - finetuning = True - # Unlearning Iterations - for i in range(0, 1): - # strategies - # - #certified_removal = CertifiedRemoval( - # target_class_index=i, - # s1=4, - # s2=350, # 350 best - # unlearn_bs=5, - # scale=6000.0, # 6000 was good - # std=0.00009 - # ) + try: + # Run Data Infrastructure and Architecture Builder + runtime_environment = prepare_data_and_model_environment() - - + # Baseline Evaluation + finetuning = False + # switch finetuning for tests on strategies only + run_finetuning_or_baseline_eval(runtime_environment, run_training = finetuning) + + + # strategies certified_unlearning = CertifiedUnlearning( - target_class_index=i, + target_class_index=0, l2_reg=0.000002, gamma=0.1, - scale= 20000,# 16400.0, # took ages to reach this sweet spot + scale= 16400.0,# 16400.0, # took ages to reach this sweet spot s1=2, s2=300, std=0.00001, - unlearn_bs=16 + unlearn_bs=8 ) # works perfectly linear_filtration = LinearFiltration( - target_class_index=i + target_class_index=0 ) - weight_filtration = WeightF( #WeightFiltration( - target_class_index=i, - epochs=3, - lr=0.05, - gamma=5 + weight_filtration = WeightFiltration( + target_class_index=0, + epochs=6, + lr=150.0, + gamma=0.001 ) strategies = [ - # certified_unlearning, + certified_unlearning, weight_filtration, - # linear_filtration + linear_filtration ] + # Unlearning Iteration + for i in range(0, CLASS_SIZE): - print(f"\n>>> Executing Unlearning Framework for Target Identity Index: {i} <<<") - for strategy in strategies: - run_unlearning_and_strategy_eval( - runtime_environment, - forget_class_idx=i, - strategy=strategy, - evaluate= not finetuning - ) + for strategy in strategies: + + # update target class to be unlearned + strategy.set_target_class(i) + print(f"Unlearning class {i} with {strategy.strategy_name}") + + # forget + run_unlearning_and_strategy_eval( + runtime_environment, + forget_class_idx=i, + strategy=strategy, + evaluate = not finetuning + ) + + except KeyboardInterrupt: + print("program interrupted. Exit!") diff --git a/Util.py b/Util.py index 66d302f..ee84cb0 100644 --- a/Util.py +++ b/Util.py @@ -45,4 +45,7 @@ def _initialize_log_file(log_file): def log_metric(log_file, execution_time: float): """Appends the execution time to this strategy's specific file.""" with open(log_file, "a") as f: - f.write(f"{execution_time:.6f}\n") \ No newline at end of file + f.write(f"{execution_time:.6f}\n") + + + diff --git a/architectures/Model.py b/architectures/Model.py index 287be1e..853f221 100644 --- a/architectures/Model.py +++ b/architectures/Model.py @@ -7,7 +7,7 @@ import time import numpy as np from sklearn.metrics import classification_report from pathlib import Path -from unlearning.Strategy import Strategy +#from unlearning.Strategy import Strategy import copy from torch.optim.lr_scheduler import CosineAnnealingLR @@ -84,7 +84,7 @@ class Model(ABC): print(f'Model loaded from {file_path}') - def unlearn(self, strategy: Strategy, forget_loader, retain_loader): + def unlearn(self, strategy: 'Strategy', forget_loader, retain_loader): """ Executes a targeted unlearning strategy and profiles efficiency """ print(f"Executing: {strategy.__class__.__name__}...") @@ -103,6 +103,7 @@ class Model(ABC): Evaluates the model, prints terminal reports, and routes metrics to a file logger based on the current context mode. """ + self.model.eval() all_preds, all_labels = [], [] print(f"\nEvaluating Domain: [{mode}]...") diff --git a/sets/Data.py b/sets/Data.py index 36138a7..ca2ff7e 100644 --- a/sets/Data.py +++ b/sets/Data.py @@ -1,5 +1,5 @@ from torchvision import datasets, transforms -from torch.utils.data import Dataset, DataLoader, Subset +from torch.utils.data import Dataset, DataLoader, Subset, ConcatDataset import torch import numpy as np import os @@ -181,4 +181,59 @@ def get_unlearning_loaders(dataset: Dataset, forget_class_idx: int, batch_size: print(f"[Data Split] Local Class {forget_class_idx}: {len(forget_subset)} samples | Remaining Classes: {len(retain_subset)} samples.") - return forget_loader, retain_loader \ No newline at end of file + return retain_loader, forget_loader + + +def vertical_split(dataset, batch_size,num_classes): + """ + Executes a class-wise vertical split. + Divides the samples of every single identity class exactly in half: + 50% of each class goes to the Retain Set, 50% goes to the Forget Set. + """ + + # 1. Group dataset indices by their respective ground-truth classes + class_to_indices = {c: [] for c in range(num_classes)} + + print(" [Vertical Split] Tracking class indices across the combined dataset...") + for idx in range(len(dataset)): + # Extract the label cleanly from the underlying dataset structure + _, label = dataset[idx] + if label in class_to_indices: + class_to_indices[label].append(idx) + + retain_indices = [] + forget_indices = [] + + # 2. Slice each class identity vertically (exactly 50/50) + for c, indices in class_to_indices.items(): + if len(indices) < 2: + print(f" Warning: Class {c} has fewer than 2 samples. Cannot split vertically.") + retain_indices.extend(indices) + continue + + # Deterministic shuffle per class to ensure honest distribution before splitting + np.random.shuffle(indices) + + mid = len(indices) // 2 + forget_indices.extend(indices[:mid]) # First half assigned to unlearning + retain_indices.extend(indices[mid:]) # Second half assigned to retention + + print(f" Vertical split complete: Retain Index Size = {len(retain_indices)} | Forget Index Size = {len(forget_indices)}") + + # 3. Construct lightweight PyTorch Subsets using our sliced index maps + retain_subset = Subset(dataset, retain_indices) + forget_subset = Subset(dataset, forget_indices) + + # 4. Return pristine, shuffled DataLoaders mirroring your environment's batch specifications + retain_loader = DataLoader(retain_subset, batch_size=batch_size, shuffle=True) + forget_loader = DataLoader(forget_subset, batch_size=batch_size, shuffle=True) + + return retain_loader, forget_loader + +def _combine_set(loader_one, loader_two): + full_train_dataset = ConcatDataset([loader_one.dataset, loader_two.dataset]) + return DataLoader( + full_train_dataset, + batch_size=loader_one.batch_size, + shuffle=True + ) \ No newline at end of file diff --git a/unlearning/CertifiedRemoval.py b/unlearning/CertifiedRemoval.py deleted file mode 100644 index 058fd22..0000000 --- a/unlearning/CertifiedRemoval.py +++ /dev/null @@ -1,214 +0,0 @@ -import torch -import torch.nn as nn -from torch.utils.data import DataLoader, RandomSampler -from torch.autograd import grad -from unlearning.Strategy import Strategy - -class CertifiedRemoval(Strategy): - """ - Implements Certified Unlearning for non-convex DNNs (Zhang et al.). - Uses a modified, stabilized stochastic Newton step using Taylor-expansion - HVP estimation across the entire parameter space, capped with calibrated noise. - """ - def __init__(self, target_class_index: int, l2_reg: float = 0.0005, - gamma: float = 0.01, scale: float = 1000.0, - s1: int = 10, s2: int = 1000, std: float = 0.001, unlearn_bs: int = 2): - super().__init__(target_class_index) - self.l2_reg = l2_reg - self.gamma = gamma - self.scale = scale - self.s1 = s1 - self.s2 = s2 - self.std = std - self.unlearn_bs = unlearn_bs - - ''' - def _compute_loss_gradient(self, model, loader, device: torch.device): - model.eval() - criterion = nn.CrossEntropyLoss(reduction='sum') - params = [p for p in model.parameters() if p.requires_grad] - grad_accumulator = [torch.zeros_like(p).cpu() for p in params] - total_samples = 0 - - for data, targets in loader: - total_samples += targets.shape[0] - data, targets = data.to(device), targets.to(device) - outputs = model(data) - - mini_grads = list(grad(criterion(outputs, targets), params)) - for i in range(len(grad_accumulator)): - grad_accumulator[i] += mini_grads[i].cpu().detach() - - for i in range(len(grad_accumulator)): - grad_accumulator[i] /= total_samples - - l2_reg_term = 0.0 - for param in model.parameters(): - l2_reg_term += torch.norm(param, p=2) - - reg_grads = list(grad(self.l2_reg * l2_reg_term, params)) - for i in range(len(grad_accumulator)): - grad_accumulator[i] += reg_grads[i].cpu().detach() - - return [p.to(device) for p in grad_accumulator]''' - def _compute_loss_gradient(self, model, loader, device: torch.device): - model.eval() - # Use reduction='sum' matching the original framework - criterion = nn.CrossEntropyLoss(reduction='sum') - params = [p for p in model.parameters() if p.requires_grad] - grad_accumulator = [torch.zeros_like(p).cpu() for p in params] - total_samples = 0 - - for data, targets in loader: - total_samples += targets.shape[0] - data, targets = data.to(device), targets.to(device) - outputs = model(data) - - loss = criterion(outputs, targets) - - # Incorporate L2 weight regularization directly inside the backprop graph - # to keep scaling bounded and aligned with the data volume - l2_reg_term = 0.0 - for param in model.parameters(): - if param.requires_grad: - l2_reg_term += torch.norm(param, p=2) - - total_loss = loss + (self.l2_reg * l2_reg_term) - - mini_grads = list(grad(total_loss, params, retain_graph=False)) - for i in range(len(grad_accumulator)): - grad_accumulator[i] += mini_grads[i].cpu().detach() - - for i in range(len(grad_accumulator)): - grad_accumulator[i] /= total_samples - - return [p.to(device) for p in grad_accumulator] - - - def grad_batch(batch_loader, lam, model, device): - model.eval() - criterion = nn.CrossEntropyLoss(reduction='sum') - params = [p for p in model.parameters() if p.requires_grad] - grad_batch = [torch.zeros_like(p).cpu() for p in params] - num = 0 - for batch_idx, (data, targets) in enumerate(batch_loader): - num += targets.shape[0] - data, targets = data.to(device), targets.to(device) - outputs = model(data) - - grad_mini = list(grad(criterion(outputs, targets), params)) - for i in range(len(grad_batch)): - grad_batch[i] += grad_mini[i].cpu().detach() - - for i in range(len(grad_batch)): - grad_batch[i] /= num - - l2_reg = 0 - for param in model.parameters(): - l2_reg += torch.norm(param, p=2) - grad_reg = list(grad(lam * l2_reg, params)) - for i in range(len(grad_batch)): - grad_batch[i] += grad_reg[i].cpu().detach() - return [p.to(device) for p in grad_batch] - - def _hvp(self, loss, params, v): - first_grads = grad(loss, params, retain_graph=True, create_graph=True) - elemwise_products = 0 - for grad_elem, v_elem in zip(first_grads, v): - elemwise_products += torch.sum(grad_elem * v_elem) - # FIX 1: Set create_graph to False to prevent massive nested graph accumulation - return grad(elemwise_products, params, create_graph=False) - - def _stochastic_newton_update(self, g, retain_dataset, model, device): - model.eval() - criterion = nn.CrossEntropyLoss() - params = [p for p in model.parameters() if p.requires_grad] - h_res = [torch.zeros_like(p) for p in g] - - for _ in range(self.s1): - h_estimate = [p.clone() for p in g] - sampler = RandomSampler(retain_dataset, replacement=True, num_samples=self.unlearn_bs * self.s2) - res_loader = DataLoader(retain_dataset, batch_size=self.unlearn_bs, sampler=sampler) - res_iter = iter(res_loader) - - for j in range(self.s2): - try: - data, target = next(res_iter) - except StopIteration: - res_iter = iter(res_loader) - data, target = next(res_iter) - - data, target = data.to(device), target.to(device) - outputs = model(data) - - loss = criterion(outputs, target) - l2_reg_term = 0.0 - for param in model.parameters(): - l2_reg_term += torch.norm(param, p=2) - loss += (self.l2_reg + self.gamma) * l2_reg_term - - h_s = self._hvp(loss, params, h_estimate) - - with torch.no_grad(): - for k in range(len(params)): - # FIX 2: Added .detach() to decouple history strings across iterative update blocks - #h_estimate[k] = (h_estimate[k] + g[k] - h_s[k] / self.scale).detach() - next_estimate = h_estimate[k].data + g[k].data - (h_s[k].data / self.scale) - h_estimate[k] = next_estimate.clone() - del h_s, loss, outputs - - for k in range(len(params)): - h_res[k] = h_res[k] + h_estimate[k] / self.scale - - return [p / self.s1 for p in h_res] - - '''def _run(self, model: nn.Module, forget_loader: DataLoader, retain_loader: DataLoader) -> nn.Module: - device = next(model.parameters()).device - - num_forget = len(forget_loader.dataset) - num_retain = len(retain_loader.dataset) - scaling_ratio = num_forget / num_retain - - print(">> Calculating base gradients over target FORGET set...") - # FIX 3: Base gradients MUST be evaluated from forget_loader to drop target class distributions - g = self._compute_loss_gradient(model, forget_loader, device) - - print(">> Estimating non-convex inverse Hessian trajectories via Taylor series...") - retain_dataset = retain_loader.dataset - delta = self._stochastic_newton_update(g, retain_dataset, model, device) - - print(">> Applying stabilized parameter adjustments and randomized certification noise...") - with torch.no_grad(): - for i, param in enumerate(model.parameters()): - if param.requires_grad: - noise = self.std * torch.randn(param.data.size(), device=device) - #param.data.add_(-delta[i] + noise) - param.data.add_(scaling_ratio * delta[i] + noise) - - print(">> Certified Unlearning process completed successfully across the complete landscape.") - return model''' - def _run(self, model: nn.Module, forget_loader: DataLoader, retain_loader: DataLoader) -> nn.Module: - device = next(model.parameters()).device - - print(">> Calculating stable base gradients over the RETAIN set...") - # To match the author's snippet perfectly, g MUST be computed on the retain data. - # If this loader is too large for your VRAM, use a smaller batch size (e.g. 16 or 32) - # in your main training script when creating retain_loader. - g = self._compute_loss_gradient(model, retain_loader, device) - - print(">> Estimating non-convex inverse Hessian trajectories via Taylor series...") - retain_dataset = retain_loader.dataset - delta = self._stochastic_newton_update(g, retain_dataset, model, device) - - print(">> Applying parameter removal adjustments (-delta)...") - with torch.no_grad(): - for i, param in enumerate(model.parameters()): - if param.requires_grad: - noise = self.std * torch.randn(param.data.size(), device=device) - - # MATCHING THE SNIPPET: Subtract delta exactly as the authors do - # This removes the influence trace of the omitted data. - param.data.add_(-delta[i] + noise) - - print(">> Certified Unlearning process completed successfully.") - return model \ No newline at end of file diff --git a/unlearning/Certified_facebook.py b/unlearning/Certified_facebook.py deleted file mode 100644 index 0b8bcd1..0000000 --- a/unlearning/Certified_facebook.py +++ /dev/null @@ -1,123 +0,0 @@ -import torch -import torch.nn as nn -import math -from torch.utils.data import DataLoader -from unlearning.Strategy import Strategy - -class CertifiedRemovalFacebook(Strategy): - """ - Implements Certified Removal (Guo et al.) mapped for Multi-Class models - by executing a single-class One-vs-Rest (OvR) block-removal update step. - Math matches the facebookresearch/certified-removal reference repository. - """ - def __init__(self, target_class_index: int, removal_bound: float, epsilon: float, l2_reg: float = 0.1): - super().__init__(target_class_index=target_class_index) - self.removal_bound = removal_bound # gamma in the paper - self.epsilon = epsilon # Privacy budget - self.l2_reg = l2_reg # Lambda (regularization term) - - def _get_features(self, backbone: nn.Module, loader: DataLoader, device: torch.device): - """Passes data through the frozen ResNet backbone to extract embedding features.""" - backbone.eval() - all_features = [] - - with torch.no_grad(): - for inputs, _ in loader: - inputs = inputs.to(device) - # Pass through frozen backbone to get the 2048-dimensional embedding - features = backbone(inputs) - all_features.append(features.cpu()) - - return torch.cat(all_features, dim=0) - - def _fb_lr_grad(self, w, X, y, lam): - """ - Replicates exact lr_grad calculation from Facebook's codebase. - Note: The resulting gradient has a flipped sign due to the structure of (z - 1). - """ - # X.mv(w) computes raw linear margins - z = torch.sigmoid(y * X.mv(w)) - # Gradient formula: X^T * ((z - 1) * y) + lambda * N * w - return X.t().mv((z - 1) * y) + lam * X.size(0) * w - - def _fb_lr_hessian_inv(self, w, X, y, lam, device, batch_size=50000): - """ - Replicates exact lr_hessian_inv calculation from Facebook's codebase. - Scales the L2 regularization matrix explicitly by dataset row count (N * lambda * I). - """ - z = torch.sigmoid(X.mv(w).mul_(y)) - D = z * (1 - z) # Element-wise variance vector - - H = None - num_batch = int(math.ceil(X.size(0) / batch_size)) - for i in range(num_batch): - lower = i * batch_size - upper = min((i + 1) * batch_size, X.size(0)) - X_i = X[lower:upper] - - # Stepwise feature weighting via element-wise variance columns - if H is None: - H = X_i.t().mm(D[lower:upper].unsqueeze(1) * X_i) - else: - H += X_i.t().mm(D[lower:upper].unsqueeze(1) * X_i) - - # Scale identity buffer by dataset split size: lambda * N_retain - reg_matrix = lam * X.size(0) * torch.eye(X.size(1), device=device).float() - return torch.linalg.inv(H + reg_matrix) - - def _run(self, model: nn.Module, forget_loader: DataLoader, retain_loader: DataLoader) -> nn.Module: - """ - Applies Certified Removal strictly to the target class parameters - belonging to the final fully connected layer (model.fc). - """ - device = next(model.parameters()).device - k = self.target_class_index - - # Isolate final layer and extract raw deep embeddings using frozen backbone - linear_head = model.fc - model.fc = nn.Identity() - - print(">> Extracting deep features from model backbone...") - X_retain = self._get_features(model, retain_loader, device).to(device) - X_forget = self._get_features(model, forget_loader, device).to(device) - - # Restore the classification head back - model.fc = linear_head - - # Extract current model weight row for the target class channel - w_k = model.fc.weight.data[k].clone().to(device) - - # Create One-vs-Rest binary target indicator arrays (+1.0 / -1.0) - # Retain dataset instances are negative labels (-1.0) for the target class channel - y_retain_binary = torch.full((X_retain.size(0),), -1.0, device=device) - # Forget dataset instances are positive labels (+1.0) for the target class channel - y_forget_binary = torch.full((X_forget.size(0),), 1.0, device=device) - - # Compute Inverse Hessian (on Retain Data) and Gradient (on Forget Data) - H_inv = self._fb_lr_hessian_inv(w_k, X_retain, y_retain_binary, self.l2_reg, device) - grad_forget = self._fb_lr_grad(w_k, X_forget, y_forget_binary, self.l2_reg) - - # 5. Compute the Weight Update Step Vector (Delta) - multiplier = 0.5 - delta_w_k = torch.mv(H_inv, grad_forget) * multiplier - - # Verify Theoretical Removal Bound Criteria - norm_delta = torch.norm(delta_w_k).item() - if norm_delta > self.removal_bound: - print(f"!! Warning: Removal budget exceeded! Norm: {norm_delta:.4f} > Bound: {self.removal_bound}") - else: - print(f">> Certificate valid. Norm: {norm_delta:.4f} <= Bound: {self.removal_bound}") - - # Apply Update (Using '+' since Facebook's grad calculation yields a negative sign output) - new_w_k = w_k + delta_w_k - - # Calibrate and Inject Perturbation Noise (Objective Perturbation Verification) - sigma = 2.0 / (self.l2_reg * self.epsilon) - noise = torch.randn_like(new_w_k, device=device) * (sigma / X_retain.size(0)) - new_w_k = new_w_k + noise - - # Commit updated weight vector row back into model head parameters in-place - model.fc.weight.data[k] = new_w_k - - print(">> Certified Removal process completed successfully.") - return model \ No newline at end of file diff --git a/unlearning/LastK_Certified.py b/unlearning/LastK_Certified.py deleted file mode 100644 index 6aae56a..0000000 --- a/unlearning/LastK_Certified.py +++ /dev/null @@ -1,125 +0,0 @@ -import torch -import torch.nn as nn -from torch.utils.data import DataLoader -from unlearning.Strategy import Strategy - -class LastKCertifiedRemoval(Strategy): - """ - Implements Certified Removal (Guo et al.) scaled up to the last K layers - of a ResNet50 network by flattening sub-graph parameters into a convex sub-problem. - """ - def __init__(self, removal_bound: float, epsilon: float, l2_reg: float = 0.1): - super().__init__() - self.removal_bound = removal_bound - self.epsilon = epsilon - self.l2_reg = l2_reg - - def _split_model(self, model: nn.Module): - """ - Splits ResNet50 into a frozen feature backbone and an active unlearning head. - Here, 'Last K Layers' includes layer4 and the fc classification head. - """ - # Feature Backbone: Everything up to layer3 - backbone = nn.Sequential( - model.conv1, - model.bn1, - model.relu, - model.maxpool, - model.layer1, - model.layer2, - model.layer3 - ) - - # Active Head: Layer4, global pooling, and the final linear layer - unlearning_head = nn.Sequential( - model.layer4, - model.avgpool, - nn.Flatten(1), - model.fc - ) - - return backbone, unlearning_head - - def _get_intermediate_features(self, backbone: nn.Module, loader: DataLoader, device: torch.device): - """Extracts features from the exit point of the frozen backbone (post-layer3).""" - backbone.eval() - all_features = [] - all_labels = [] - - with torch.no_grad(): - for inputs, labels in loader: - inputs = inputs.to(device) - features = backbone(inputs) - all_features.append(features.cpu()) - all_labels.append(labels.cpu()) - - return torch.cat(all_features, dim=0), torch.cat(all_labels, dim=0) - - def apply(self, model: nn.Module, forget_loader: DataLoader, retain_loader: DataLoader) -> nn.Module: - """ - Extracts intermediate features and updates the parameters of the last blocks - using the exact inverse-Hessian influence step. - """ - device = next(model.parameters()).device - - # 1. Slice the ResNet graph structural components - backbone, unlearning_head = self._split_model(model) - - print(">> Extracting intermediate structural features from layer3 exit...") - retain_feats, retain_labels = self._get_intermediate_features(backbone, retain_loader, device) - forget_feats, forget_labels = self._get_intermediate_features(backbone, forget_loader, device) - - # 2. Flatten target weights from the active head into a 1D optimization tensor - # For simplicity and mathematical stability, we isolate the final layer's weights - # inside the active head for the exact Hessian tracking step - target_layer = unlearning_head[-1] # This points straight to model.fc - w = target_layer.weight.data.clone().cpu() - - # 3. Compute Exact Hessian over intermediate embeddings - # ResNet50's layer4 expands channels to 2048, creating a 2048x2048 matrix context - print(">> Computing exact sub-graph Hessian matrix...") - N_retain = retain_feats.size(0) - - # Pool the feature maps if they haven't been flattened yet by the head module - if len(retain_feats.shape) > 2: - retain_flat = torch.mean(retain_feats, dim=[2, 3]) - forget_flat = torch.mean(forget_feats, dim=[2, 3]) - else: - retain_flat = retain_feats - forget_flat = forget_feats - - X_T_X = torch.matmul(retain_flat.t(), retain_flat) - reg_matrix = self.l2_reg * torch.eye(retain_flat.size(1)) - Hessian = (X_T_X / N_retain) + reg_matrix - - # 4. Calculate gradients relative to the forgotten target features - print(">> Calculating forget set gradients...") - num_classes = w.size(0) - forget_labels_one_hot = torch.nn.functional.one_hot(forget_labels, num_classes=num_classes).float() - - preds_forget = torch.matmul(forget_flat, w.t()) - error = preds_forget - forget_labels_one_hot - grad_forget = torch.matmul(error.t(), forget_flat) / forget_flat.size(0) - - # 5. Apply Newton Step optimization update - print(">> Inverting optimization subspace via system solver...") - try: - delta_w_t = torch.linalg.solve(Hessian, grad_forget.t()) - delta_w = delta_w_t.t() - except RuntimeError: - print(">> Warning: Subspace Hessian is singular. Using pseudo-inverse fallback.") - delta_w = torch.matmul(grad_forget, torch.linalg.pinv(Hessian).t()) - - # 6. Apply Weight Adjustment Bounds Check - new_w = w + delta_w - norm_delta = torch.norm(delta_w).item() - if norm_delta > self.removal_bound: - print(f"!! Warning: Removal budget exceeded! Norm: {norm_delta:.4f} > Bound: {self.removal_bound}") - else: - print(f">> Certificate valid. Subspace Norm: {norm_delta:.4f} <= Bound: {self.removal_bound}") - - # 7. Write weights directly back into the live ResNet50 instance - model.fc.weight.data = new_w.to(device) - - print(">> Last K Layers Certified Removal complete.") - return model \ No newline at end of file diff --git a/unlearning/LinearFiltration.py b/unlearning/LinearFiltration.py index ebf9343..1cc174c 100644 --- a/unlearning/LinearFiltration.py +++ b/unlearning/LinearFiltration.py @@ -2,6 +2,7 @@ import torch import torch.nn as nn from .Strategy import Strategy from torch.utils.data import DataLoader +from sets.Data import get_unlearning_loaders, _combine_set class LinearFiltration(Strategy): def __init__(self, target_class_index): @@ -23,40 +24,8 @@ class LinearFiltration(Strategy): forget_index=self.target_class_index ) - # FIX: Added staticmethod decorator - @staticmethod - def get_features(model, inputs): - # For ResNet, pass through everything up to the fc layer - x = model.conv1(inputs) - x = model.bn1(x) - x = model.relu(x) - x = model.maxpool(x) - - x = model.layer1(x) - x = model.layer2(x) - x = model.layer3(x) - x = model.layer4(x) - - x = model.avgpool(x) - x = torch.flatten(x, 1) - return x - - @staticmethod - def _calculate_filtration_matrix(num_classes: int, forget_class: int, device: torch.device) -> torch.Tensor: - A = torch.eye(num_classes, device=device) - num_remaining = num_classes - 1 - - for j in range(num_classes): - if j == forget_class: - A[forget_class, j] = 0.0 - else: - A[forget_class, j] = 1.0 / num_remaining - - return A - - @staticmethod - def _sums_and_counts(model, num_classes, retain_loader, forget_loader, device, forget_index, h_dim): + def _sums_and_counts(self, model, num_classes, loader, device, forget_index, h_dim): model.eval() sums = torch.zeros(num_classes, h_dim, device=device) @@ -64,11 +33,11 @@ class LinearFiltration(Strategy): # Generate values for retain with torch.no_grad(): - for inputs, targets in retain_loader: + for inputs, targets in loader: inputs = inputs.to(device) targets = targets.to(device) - # FIX: Call get_features instead of model() directly - outputs = LinearFiltration.get_features(model, inputs) + # predictions + outputs = model(inputs) for j in range(num_classes): if j == forget_index: @@ -79,65 +48,54 @@ class LinearFiltration(Strategy): sums[j] += outputs[mask].sum(dim=0) counts[j] += mask.sum() - # Values for forget - with torch.no_grad(): - for inputs, targets in forget_loader: - inputs = inputs.to(device) - targets = targets.to(device) - # FIX: Call get_features instead of model() directly - outputs = LinearFiltration.get_features(model, inputs) - - mask = (targets == forget_index) - - if mask.any(): - sums[forget_index] += outputs[mask].sum(dim=0) - counts[forget_index] += mask.sum() - return sums, counts - @staticmethod - def _get_means(model, num_classes, retain_loader, forget_loader, device, forget_index): - h_dim = model.fc.in_features - sums, counts = LinearFiltration._sums_and_counts( + # + def _get_means(self,model, num_classes, loader, device, forget_index): + h_dim = model.fc.out_features + + # all predictions + sums, counts = self._sums_and_counts( model=model, num_classes=num_classes, - retain_loader=retain_loader, - forget_loader=forget_loader, + loader=loader, device=device, forget_index=forget_index, h_dim=h_dim ) - A = [] - for i in range(num_classes): - if counts[i] > 0: - A.append(sums[i] / counts[i]) - else: - A.append(torch.zeros(h_dim, device=device)) - - # CORRECT: Stack along dim=0 to make it (num_classes, h_dim) - return torch.stack(A, dim=0) + #A = [] + + counts_safe = counts.unsqueeze(1) + A = torch.where( + counts_safe > 0, + sums / counts_safe, + torch.zeros_like(sums) + ) + # 6 + return A - @staticmethod - def _compute_z(tensor, forget_index): - # Now tensor has shape (num_classes, h_dim) -> tensor.shape[0] is num_classes + # 9 + def _compute_z(self, tensor, forget_index): + K = tensor.shape[0] - # pi_a0 should match the feature space dimensions (h_dim) - pi_a0 = torch.zeros(tensor.shape[1], device=tensor.device) + # pi_a_forget should match the feature space dimensions (h_dim) + pi_a_f = torch.zeros(tensor.shape[1], device=tensor.device) - t_1 = pi_a0 - a0 = tensor[forget_index, :] # Extracting the row vector for the forgotten class + t_1 = pi_a_f + # Extracting the row vector for the forgotten class + a_f = tensor[forget_index, :] - mask_a0 = torch.ones( - a0.shape[0], + mask_a_f = torch.ones( + a_f.shape[0], dtype=torch.bool, device=tensor.device ) # We compute the target shift over features - t_2 = -(1.0 / (K - 1)) * a0[mask_a0].sum() + t_2 = -(1.0 / (K - 1)) * a_f[mask_a_f].sum() mask_rows = torch.ones(K, dtype=torch.bool, device=tensor.device) mask_rows[forget_index] = False @@ -148,21 +106,23 @@ class LinearFiltration(Strategy): return t_1 + t_2 + t_3 - @staticmethod - def normalise(model, retain_loader, forget_loader, device, forget_index): + # Normalisation filtration + def normalise(self, model, retain_loader, forget_loader, device, forget_index): W = model.fc.weight.data.clone() num_classes = W.shape[0] - - A = LinearFiltration._get_means( + + # we combine the data so we can calculate the mean of prdictions + full_loader = _combine_set(retain_loader, forget_loader) + # 8 + A = self._get_means( model=model, num_classes=num_classes, - retain_loader=retain_loader, - forget_loader=forget_loader, + loader=full_loader, device=device, forget_index=forget_index ) - - Z = LinearFiltration._compute_z(tensor=A, forget_index=forget_index) + # 9 + Z = self._compute_z(tensor=A, forget_index=forget_index) B_Z_rows = [] for i in range(num_classes): @@ -172,13 +132,24 @@ class LinearFiltration(Strategy): # Retained classes maintain their original ideal feature directions B_Z_rows.append(A[i]) + # 10 # Stack back along dim=0 to match (num_classes, h_dim) + # to get mean B_Z = torch.stack(B_Z_rows, dim=0) A_inv = torch.linalg.pinv(A) - + # 11 W_Z = B_Z @ A_inv @ W + # 12 model.fc.weight.copy_(W_Z) - return model \ No newline at end of file + return model + + # overriden function + def _split_data(self, dataset): + return get_unlearning_loaders( + dataset=dataset, + forget_class_idx=self.target_class_index, + batch_size = 32 + ) \ No newline at end of file diff --git a/unlearning/Strategy.py b/unlearning/Strategy.py index 1e29255..4e03465 100644 --- a/unlearning/Strategy.py +++ b/unlearning/Strategy.py @@ -16,12 +16,20 @@ class Strategy: self.log_file = Path(f"reports/{self.strategy_name}/metrics.txt") Util._initialize_log_file(log_file= self.log_file) - def apply(self, model: nn.Module, forget_loader: DataLoader, retain_loader: DataLoader) -> nn.Module: + def set_target_class(self, target_class_index: int): + """Dynamically switch the unlearning target without retraining.""" + self.target_class_index = target_class_index + + + def apply(self, model: nn.Module, dataset) -> nn.Module: """ Wraps the unlearning execution with automated timing and strategy-specific logging. DO NOT override this method in subclasses. Override _run instead. """ start_time = time.perf_counter() + + + retain_loader, forget_loader = self._split_data(dataset) # Execute core unlearning logic processed_model = self._run(model, forget_loader, retain_loader) @@ -41,4 +49,12 @@ class Strategy: def _run(self, model: nn.Module, forget_loader: DataLoader, retain_loader: DataLoader) -> nn.Module: """Subclasses implement their core unlearning logic here.""" - raise NotImplementedError \ No newline at end of file + raise NotImplementedError + + ''' + different strategies split data in to different partitions differently. + So a strategy will implement its own and since this part is startegy specific. + not all should compute it the same. + ''' + def _split_data(self,dataset): + pass \ No newline at end of file diff --git a/unlearning/WeightFiltration.py b/unlearning/WeightFiltration.py index 48a321e..9678b5c 100644 --- a/unlearning/WeightFiltration.py +++ b/unlearning/WeightFiltration.py @@ -1,126 +1,135 @@ import torch import torch.nn as nn import torch.optim as optim -from torch.utils.data import DataLoader +from torch.utils.data import DataLoader, ConcatDataset, Subset from unlearning.Strategy import Strategy -from .wf.WF_Net import WF_Net +import numpy as np +from sklearn.metrics import classification_report +from architectures.WFNet import WF_Net_Model + +from sets.Data import vertical_split class WeightFiltration(Strategy): - """ - Verbatim implementation of Poppi et al.'s WF-Net framework. - Directly filters the convolutional weights of a target layer using a learnable - channel mask, optimizing it via weight-space regularization. - """ - def __init__(self, target_class_index: int, epochs: int = 10, lr: float = 0.2, gamma: float = 10.0): + def __init__(self, + target_class_index: int, + num_classes: int = 20, + epochs: int = 6, + lr: float = 100.0, + gamma: float = 0.01, + ): super().__init__(target_class_index=target_class_index) self.epochs = epochs self.lr = lr self.gamma = gamma - #self.alpha = None + self.num_classes = num_classes + self.wf_model = None + self.lambda_1 = 25 + + + def _optimise_filter(self, model: nn.Module, retain_loader: DataLoader, forget_loader: DataLoader, device) -> nn.Module: + # new WF_Model instance + wf_model = WF_Net_Model( + device=device, + size=self.num_classes, + original_model=model, + target_class_index=self.target_class_index + ) - - - - def _optimise_filter(self, model: nn.Module, forget_loader: DataLoader, retain_loader: DataLoader, device): - # 1. Initialize the wrapper with your pre-trained model - num_classes = model.fc.out_features - wf_model = WF_Net(original_model=model, num_classes=num_classes).to(device) - - # 2. ONLY optimize alpha (everything else is frozen inside the wrapper) - optimizer = optim.Adam([wf_model.alpha], lr=self.lr) + # a WF_net module to be trained (unlearned) to generate alpha + wf_net = wf_model.get() + optimizer = optim.SGD([wf_net.alpha], lr=self.lr) criterion = nn.CrossEntropyLoss() for epoch in range(self.epochs): - forget_iter = iter(forget_loader) t_loss_r, t_loss_f = 0.0, 0.0 steps = 0 - for r_inputs, r_labels in retain_loader: + # forget and retain + for (r_inputs, r_labels), (f_inputs, f_labels) in zip(retain_loader, forget_loader): r_inputs, r_labels = r_inputs.to(device), r_labels.to(device) - - # Pull the matching forget batch input - try: - f_inputs, _ = next(forget_iter) - except StopIteration: - forget_iter = iter(forget_loader) - f_inputs, _ = next(forget_iter) - f_inputs = f_inputs.to(device) + f_inputs, f_labels = f_inputs.to(device), f_labels.to(device) optimizer.zero_grad() - # --- APPLY ALGORITHM 1 FORWARD PASS TO BOTH INPUTS --- - # Pass the input batch AND the target unlearn class index - outputs_r = wf_model(r_inputs, target_unlearn_class=self.target_class_index) - outputs_f = wf_model(f_inputs, target_unlearn_class=self.target_class_index) + # retain data paired with randomly selected rows of alpha to compute the retaining loss + random_rows = [] + for label in r_labels: + allowed = [i for i in range(self.num_classes) if i != label.item()] + random_rows.append(np.random.choice(allowed)) - # Compute Losses using Poppi et al.'s temperature scaled entropy + gate_signals_r = torch.tensor(random_rows, dtype=torch.long, device=device) + outputs_r = wf_net(r_inputs, target_class_indices=gate_signals_r) loss_r = criterion(outputs_r, r_labels) + + # Forget set is paired with corresponding labels as row selectors for alpha + # and used to compute unlearning loss + outputs_f = wf_net(f_inputs, target_class_indices=f_labels) - temperature = 3.0 - logits_f_scaled = outputs_f / temperature + loss_f = 0.0 + classes_in_batch = 0 - # Compute uniform target entropy per-sample, then average over the batch - log_probs_f = torch.log_softmax(logits_f_scaled, dim=-1) - uniform_target = torch.ones_like(logits_f_scaled) / num_classes - loss_f = -torch.sum(uniform_target * log_probs_f, dim=-1).mean() + # every image of class c will unlearn over the same row of alpha_l (poppi et al page 5) + for c in range(self.num_classes): + class_mask = (f_labels == c) + if not class_mask.any(): + continue + + labels_c = f_labels[class_mask] + + # Slice the existing outputs instead of recalculating a forward pass + outputs_f_c = outputs_f[class_mask] + + loss_f_ce = criterion(outputs_f_c, labels_c) + + # Poppi et al. suggest employing reciprocal of the forget loss + # to avoid shortcomings of negative gradient approach + loss_f += 1.0 / (loss_f_ce + 1e-6) + classes_in_batch += 1 + + # Average forget loss by number of distinct classes seen in this batch + if classes_in_batch > 0: + loss_f = loss_f / classes_in_batch + + # Regilarisation penality + loss_reg = torch.sum(1.0 - torch.sigmoid(wf_net.alpha)) - total_loss = loss_r + (self.gamma * loss_f) + # back propagation + total_loss = loss_r + (self.lambda_1 * loss_f) + (self.gamma * loss_reg) total_loss.backward() optimizer.step() t_loss_r += loss_r.item() - t_loss_f += loss_f.item() + t_loss_f += loss_f.item() if classes_in_batch > 0 else 0.0 steps += 1 print(f" Epoch {epoch+1}/{self.epochs} | Retain Loss: {t_loss_r/steps:.4f} | Forget Loss: {t_loss_f/steps:.4f}") + return wf_model - - def _run(self, model: nn.Module, forget_loader: DataLoader, retain_loader: DataLoader) -> nn.Module: device = next(model.parameters()).device - model.eval() + model.eval() - # In WF-Net, the mask targets the last major convolutional block - # For ResNet-18, that is the final conv layer in layer4 block 1 - if hasattr(model, 'layer4') and len(model.layer4) > 1: - target_conv = model.layer4[1].conv2 + if self.wf_model is None: + print(">> Initializing and compiling global WF-Net matrix (Run Once for all classes)...") + + self.wf_model = self._optimise_filter( + model, + retain_loader=retain_loader, + forget_loader=forget_loader, + device=device + ) else: - raise AttributeError("Model architecture does not match expected ResNet-18 structure.") + print(f">> Gating matrix loaded. Switching layout to target class index: {self.target_class_index}") + self.wf_model.target_class_index = self.target_class_index - # Store a pristine, non-grad copy of the original trained weights - # Shape of conv2.weight: (out_channels, in_channels, kernel_size, kernel_size) -> e.g., (512, 512, 3, 3) - original_weights = target_conv.weight.data.clone().detach() - out_channels = original_weights.shape[0] - - # Initialize alpha gate vector matching Poppi et al.'s initialization range - # Shape: (out_channels,) -> acting directly as a filter-level gate - #self.alpha = nn.Parameter(torch.ones(out_channels, device=device) * 1.5) - - # Freeze the global model graph; only optimize our filter parameter mask - for p in model.parameters(): - p.requires_grad = False - #self.alpha.requires_grad = True - - wf_model = self._optimise_filter( - model, - forget_loader=forget_loader, - retain_loader=retain_loader, - device=device, + return self.wf_model + + def _split_data(self, dataset): + return vertical_split( + dataset= dataset, + batch_size=32, + num_classes=self.num_classes ) - - # --- PERMANENT BAKING STEP --- - with torch.no_grad(): - # Grab the alpha mask vector for the forgotten class and cast to 4D tensor shape - final_mask = torch.sigmoid(wf_model.alpha[self.target_class_index]).view(-1, 1, 1, 1) - - # Apply filter masking permanently back onto the base layer - target_conv.weight.copy_(original_weights * final_mask) - - # Unfreeze architecture parameters for evaluations downstream - for p in model.parameters(): - p.requires_grad = True - - print(f">> Permanently altered {out_channels} convolutional filters in layer4 via WF-Net.") - return model + diff --git a/unlearning/wf/WF_Net.py b/unlearning/wf/WF_Net.py index a5dd690..eb50180 100644 --- a/unlearning/wf/WF_Net.py +++ b/unlearning/wf/WF_Net.py @@ -35,52 +35,52 @@ class WF_Net(nn.Module): #self.alpha = nn.Parameter(torch.ones(num_classes, out_channels) * 1.5) self.alpha = nn.Parameter(torch.ones(num_classes, out_channels)) - def forward(self, x: torch.Tensor, target_unlearn_class: int) -> torch.Tensor: - """ - Implements Algorithm 1: General forward step of a WF model - Inputs: - x: Input tensor (Xin) - target_unlearn_class: The class index we are actively filtering out (Yunl) - """ + def forward(self, x: torch.Tensor, target_class_indices: torch.Tensor) -> torch.Tensor: # 1. Run through early sequence of layers undisturbed x = self.maxpool(self.relu(self.bn1(self.conv1(x)))) x = self.layer1(x) x = self.layer2(x) x = self.layer3(x) - - # Run layer4 block 0 and block 1 conv1 normally + + # Run layer4 block 0 normally x = self.layer4[0](x) - - identity = x - - - + + # ------------------------------------------------------------- + # HERE IT IS: Save the structural skip connection (identity) + # BEFORE modifying features via block 1's convolutions + # ------------------------------------------------------------- + identity = x + + # Now enter layer4 block 1 x = self.layer4[1].conv1(x) x = self.layer4[1].bn1(x) x = self.layer4[1].relu(x) + + # [Your Step 1 Masking Math happens right here...] + batch_alpha = self.alpha[target_class_indices] + mask = torch.sigmoid(batch_alpha).view(x.size(0), -1, 1, 1) - # 2. CORE WF-NET MATH: w_hat_l <- alpha_l[Yunl] ⊙ w_l - # Extract 1D vector for target class and reshape to (out_channels, 1, 1, 1) for 4D convolution broadcasting - mask = torch.sigmoid(self.alpha[target_unlearn_class]).view(-1, 1, 1, 1) - w_hat = self.original_w * mask - - # 3. Pass gated weights straight to functional forward pass: l(Xi, w_hat_l) + # Run the functional convolution x = F.conv2d( x, - weight=w_hat, + weight=self.original_w, bias=self.target_conv.bias, stride=self.target_conv.stride, padding=self.target_conv.padding ) + + # Apply your WF-Net channel mask + x = x * mask x = self.layer4[1].bn2(x) - - # Handle residual shortcut skip connection manually since we opened up block 1 - # In ResNet-18 layer4, block 1 has no downsample shortcut layer; it's a direct identity add + + # ------------------------------------------------------------- + # HERE IT IS USED: Add the pristine identity back to the gated output + # ------------------------------------------------------------- x = self.layer4[1].relu(x + identity) - - # 4. Final Classification Head Sequence + + # Final Classification Head Sequence x = self.avgpool(x) x = torch.flatten(x, 1) y_out = self.fc(x) - + return y_out From 0a7a2e1da5e089755dd046889f7c7505fe5863f5 Mon Sep 17 00:00:00 2001 From: Tinsae Date: Sun, 28 Jun 2026 01:48:49 +0200 Subject: [PATCH 20/20] WF net added --- Tune_new.py | 6 +- architectures/WFNet.py | 126 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 129 insertions(+), 3 deletions(-) create mode 100644 architectures/WFNet.py diff --git a/Tune_new.py b/Tune_new.py index 0145b9a..fdf5688 100644 --- a/Tune_new.py +++ b/Tune_new.py @@ -20,7 +20,7 @@ BATCH_SIZE = 16 SAMPLE_SIZE = 30 TRAINING_SAMPLE = 27 RESOLUTION = 224 -ARCH = Architecture.RESNET18 +ARCH = Architecture.GOOGLENET # Data preparation and model setup @@ -230,8 +230,8 @@ if __name__ == "__main__": strategies = [ certified_unlearning, - weight_filtration, - linear_filtration + #weight_filtration, + #linear_filtration ] # Unlearning Iteration for i in range(0, CLASS_SIZE): diff --git a/architectures/WFNet.py b/architectures/WFNet.py new file mode 100644 index 0000000..96ff3a6 --- /dev/null +++ b/architectures/WFNet.py @@ -0,0 +1,126 @@ +import torch +import torch.nn as nn +import torch.nn.functional as F +import torch.optim as optim +from torch.utils.data import DataLoader +import numpy as np +from sklearn.metrics import classification_report +from architectures.Model import Model + +class WF_Module(nn.Module): + """ + Pure PyTorch Neural Network module graph. + Keeps parameter registration and autograd tracking separate from + the framework's high-level Model abstractions to prevent recursion collisions. + """ + def __init__(self, original_model: nn.Module, num_classes: int): + super().__init__() + + self.original_model = original_model + + # Target layer for weight filtering (layer4 block 1 conv2 or conv3 depending on arch) + last_layer = original_model.layer4[1] + + # Some versions are limited to 2 convolutional layers + if hasattr(last_layer, "conv3"): + self.target_conv = last_layer.conv3 + else: + self.target_conv = last_layer.conv2 + + # Completely freeze the original ResNet parameters + for param in self.parameters(): + param.requires_grad = False + + # Initialize the alpha parameter matrix (Rows = Classes, Cols = Channels) + out_channels = self.target_conv.weight.shape[0] + self.alpha = nn.Parameter(torch.full((num_classes, out_channels), 3.0)) + + ''' + Poppi et_al's Single-shot multiclass unlearning. + This calculation happens only once to generate the mask. once the mask is generated, + Unlearning and remembering becomes a matter of switching gates on and off. + ''' + def forward(self, x: torch.Tensor, target_class_indices: torch.Tensor) -> torch.Tensor: + # we linearly loop through layers 1 to 4[block 1] (for ResNet) + # for i in M_{|L|} do l <- l[i] + x = self.original_model.maxpool(self.original_model.relu(self.original_model.bn1(self.original_model.conv1(x)))) + x = self.original_model.layer1(x) + x = self.original_model.layer2(x) + x = self.original_model.layer3(x) + x = self.original_model.layer4[0](x) + + # The second block execute its internal transformations natively + # This handles conv1->conv2 (ResNet18) or conv1->conv2->conv3 (ResNet50) automatically! + # Xi+1 <- l(Xi, ˆwl) + x = self.original_model.layer4[1](x) + + # Apply mask dynamically to the completed block feature map + # wl <- αl[Yunl] ⊙ ˆwl + batch_alpha = self.alpha[target_class_indices] + mask = torch.sigmoid(batch_alpha).view(x.size(0), -1, 1, 1) + x = x * mask + + # Remaining standard head steps + x = self.original_model.avgpool(x) + x = torch.flatten(x, 1) + # so here we are returning the output logits + # the result of classification is then + # argmax(x) + return self.original_model.fc(x) + + + +class WF_Net_Model(Model): + def __init__(self, device, size, original_model: nn.Module, target_class_index: int): + self.device = device + self.size = size + self.wf_module = WF_Module( + original_model = original_model, + num_classes = size + ).to(self.device) + + # this index indicates which row of the mask should be active (gate closed). + self.target_class_index = target_class_index + self.model = self.wf_module + + def get(self): + return self.wf_module + + ''' + We override the evaluate method from the base class, + because how we evaluate is different here from that of a normal torch nn.Module object + + ''' + def evaluate(self, loader, mode="eval"): + + self.wf_module.eval() + all_preds, all_labels = [], [] + print(f"\nEvaluating Domain: [{mode}]...") + + with torch.no_grad(): + for inputs, labels in loader: + inputs, labels = inputs.to(self.device), labels.to(self.device) + + # we apply the filter + gate_signals = torch.full((inputs.size(0),), self.target_class_index, dtype=torch.long, device=self.device) + + # pass prediction through the filter + outputs = self.wf_module(inputs, target_class_indices=gate_signals) + + # return argmax(x) + _, predicted = torch.max(outputs, 1) + all_preds.extend(predicted.cpu().numpy()) + all_labels.extend(labels.cpu().numpy()) + + classes = sorted(list(set(all_labels))) + accuracy = 100 * (np.array(all_preds) == np.array(all_labels)).sum() / len(all_labels) + + print(f"Test Accuracy: {accuracy:.2f}%") + print(classification_report(all_labels, all_preds, labels=classes, zero_division=0)) + report = classification_report(all_labels, all_preds, labels=classes, output_dict=True, zero_division=0) + + return accuracy, report + + def eval(self): + """Safely intercept any fallback base class calls targeting .eval()""" + self.wf_module.eval()