unlearning done

This commit is contained in:
2026-06-27 20:38:17 +02:00
parent c4fdc034b2
commit 0680a920ff
11 changed files with 307 additions and 740 deletions

View File

@@ -9,11 +9,9 @@ import Util
from sets.Data import * from sets.Data import *
from sets.IdentitySubset import IdentitySubset from sets.IdentitySubset import IdentitySubset
from architectures.Model import Model, Architecture from architectures.Model import Model, Architecture
from unlearning.CertifiedRemoval import CertifiedRemoval
from unlearning.CertifiedUnlearning import CertifiedUnlearning from unlearning.CertifiedUnlearning import CertifiedUnlearning
from unlearning.LinearFiltration import LinearFiltration from unlearning.LinearFiltration import LinearFiltration
from unlearning.WeightFiltration import WeightFiltration from unlearning.WeightFiltration import WeightFiltration
from unlearning.WF import WeightF
# Global Hyperparameters # 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"] train_data = env_dict["train_data"]
test_data = env_dict["test_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 # 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 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 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") print("fine tunned model loaded into evaluation sandbox")
# Execute strategic parameter unlearning step # 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__ strategy_in_use = strategy.__class__.__name__
if isinstance(unlearned,nn.Module):
reloaded.model = unlearned
else:
reloaded = unlearned
# Define validation tracking steps dynamically # Define validation tracking steps dynamically
evaluation_domains = [ evaluation_domains = [
{"loader": retain_test_loader, "mode": "retain", "label": "\n--- Performance on Retained Classes"}, {"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 # entry
if __name__ == "__main__": if __name__ == "__main__":
try:
# Run Data Infrastructure and Architecture Builder # Run Data Infrastructure and Architecture Builder
runtime_environment = prepare_data_and_model_environment() runtime_environment = prepare_data_and_model_environment()
# Baseline Evaluation # Baseline Evaluation
finetuning = False finetuning = False
# switch finetuning for tests on strategies only # switch finetuning for tests on strategies only
run_finetuning_or_baseline_eval(runtime_environment, run_training=finetuning) run_finetuning_or_baseline_eval(runtime_environment, run_training = finetuning)
finetuning = True
# Unlearning Iterations
for i in range(0, 1):
# strategies # 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( certified_unlearning = CertifiedUnlearning(
target_class_index=i, target_class_index=0,
l2_reg=0.000002, l2_reg=0.000002,
gamma=0.1, 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, s1=2,
s2=300, s2=300,
std=0.00001, std=0.00001,
unlearn_bs=16 unlearn_bs=8
) )
# works perfectly # works perfectly
linear_filtration = LinearFiltration( linear_filtration = LinearFiltration(
target_class_index=i target_class_index=0
) )
weight_filtration = WeightF( #WeightFiltration( weight_filtration = WeightFiltration(
target_class_index=i, target_class_index=0,
epochs=3, epochs=6,
lr=0.05, lr=150.0,
gamma=5 gamma=0.001
) )
strategies = [ strategies = [
# certified_unlearning, certified_unlearning,
weight_filtration, 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: 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( run_unlearning_and_strategy_eval(
runtime_environment, runtime_environment,
forget_class_idx=i, forget_class_idx=i,
strategy=strategy, strategy=strategy,
evaluate= not finetuning evaluate = not finetuning
) )
except KeyboardInterrupt:
print("program interrupted. Exit!")

View File

@@ -46,3 +46,6 @@ def log_metric(log_file, execution_time: float):
"""Appends the execution time to this strategy's specific file.""" """Appends the execution time to this strategy's specific file."""
with open(log_file, "a") as f: with open(log_file, "a") as f:
f.write(f"{execution_time:.6f}\n") f.write(f"{execution_time:.6f}\n")

View File

@@ -7,7 +7,7 @@ import time
import numpy as np import numpy as np
from sklearn.metrics import classification_report from sklearn.metrics import classification_report
from pathlib import Path from pathlib import Path
from unlearning.Strategy import Strategy #from unlearning.Strategy import Strategy
import copy import copy
from torch.optim.lr_scheduler import CosineAnnealingLR from torch.optim.lr_scheduler import CosineAnnealingLR
@@ -84,7 +84,7 @@ class Model(ABC):
print(f'Model loaded from {file_path}') 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 """ """ Executes a targeted unlearning strategy and profiles efficiency """
print(f"Executing: {strategy.__class__.__name__}...") print(f"Executing: {strategy.__class__.__name__}...")
@@ -103,6 +103,7 @@ class Model(ABC):
Evaluates the model, prints terminal reports, and routes metrics to Evaluates the model, prints terminal reports, and routes metrics to
a file logger based on the current context mode. a file logger based on the current context mode.
""" """
self.model.eval() self.model.eval()
all_preds, all_labels = [], [] all_preds, all_labels = [], []
print(f"\nEvaluating Domain: [{mode}]...") print(f"\nEvaluating Domain: [{mode}]...")

View File

@@ -1,5 +1,5 @@
from torchvision import datasets, transforms 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 torch
import numpy as np import numpy as np
import os 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.") print(f"[Data Split] Local Class {forget_class_idx}: {len(forget_subset)} samples | Remaining Classes: {len(retain_subset)} samples.")
return forget_loader, retain_loader 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
)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -2,6 +2,7 @@ import torch
import torch.nn as nn import torch.nn as nn
from .Strategy import Strategy from .Strategy import Strategy
from torch.utils.data import DataLoader from torch.utils.data import DataLoader
from sets.Data import get_unlearning_loaders, _combine_set
class LinearFiltration(Strategy): class LinearFiltration(Strategy):
def __init__(self, target_class_index): def __init__(self, target_class_index):
@@ -23,40 +24,8 @@ class LinearFiltration(Strategy):
forget_index=self.target_class_index 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) def _sums_and_counts(self, model, num_classes, loader, device, forget_index, h_dim):
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):
model.eval() model.eval()
sums = torch.zeros(num_classes, h_dim, device=device) sums = torch.zeros(num_classes, h_dim, device=device)
@@ -64,11 +33,11 @@ class LinearFiltration(Strategy):
# Generate values for retain # Generate values for retain
with torch.no_grad(): with torch.no_grad():
for inputs, targets in retain_loader: for inputs, targets in loader:
inputs = inputs.to(device) inputs = inputs.to(device)
targets = targets.to(device) targets = targets.to(device)
# FIX: Call get_features instead of model() directly # predictions
outputs = LinearFiltration.get_features(model, inputs) outputs = model(inputs)
for j in range(num_classes): for j in range(num_classes):
if j == forget_index: if j == forget_index:
@@ -79,65 +48,54 @@ class LinearFiltration(Strategy):
sums[j] += outputs[mask].sum(dim=0) sums[j] += outputs[mask].sum(dim=0)
counts[j] += mask.sum() 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 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, model=model,
num_classes=num_classes, num_classes=num_classes,
retain_loader=retain_loader, loader=loader,
forget_loader=forget_loader,
device=device, device=device,
forget_index=forget_index, forget_index=forget_index,
h_dim=h_dim h_dim=h_dim
) )
A = []
for i in range(num_classes): #A = []
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) counts_safe = counts.unsqueeze(1)
return torch.stack(A, dim=0) A = torch.where(
counts_safe > 0,
sums / counts_safe,
torch.zeros_like(sums)
)
# 6
return A
@staticmethod # 9
def _compute_z(tensor, forget_index): def _compute_z(self, tensor, forget_index):
# Now tensor has shape (num_classes, h_dim) -> tensor.shape[0] is num_classes
K = tensor.shape[0] K = tensor.shape[0]
# pi_a0 should match the feature space dimensions (h_dim) # pi_a_forget should match the feature space dimensions (h_dim)
pi_a0 = torch.zeros(tensor.shape[1], device=tensor.device) pi_a_f = torch.zeros(tensor.shape[1], device=tensor.device)
t_1 = pi_a0 t_1 = pi_a_f
a0 = tensor[forget_index, :] # Extracting the row vector for the forgotten class # Extracting the row vector for the forgotten class
a_f = tensor[forget_index, :]
mask_a0 = torch.ones( mask_a_f = torch.ones(
a0.shape[0], a_f.shape[0],
dtype=torch.bool, dtype=torch.bool,
device=tensor.device device=tensor.device
) )
# We compute the target shift over features # 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 = torch.ones(K, dtype=torch.bool, device=tensor.device)
mask_rows[forget_index] = False mask_rows[forget_index] = False
@@ -148,21 +106,23 @@ class LinearFiltration(Strategy):
return t_1 + t_2 + t_3 return t_1 + t_2 + t_3
@staticmethod # Normalisation filtration
def normalise(model, retain_loader, forget_loader, device, forget_index): def normalise(self, model, retain_loader, forget_loader, device, forget_index):
W = model.fc.weight.data.clone() W = model.fc.weight.data.clone()
num_classes = W.shape[0] 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, model=model,
num_classes=num_classes, num_classes=num_classes,
retain_loader=retain_loader, loader=full_loader,
forget_loader=forget_loader,
device=device, device=device,
forget_index=forget_index forget_index=forget_index
) )
# 9
Z = LinearFiltration._compute_z(tensor=A, forget_index=forget_index) Z = self._compute_z(tensor=A, forget_index=forget_index)
B_Z_rows = [] B_Z_rows = []
for i in range(num_classes): for i in range(num_classes):
@@ -172,13 +132,24 @@ class LinearFiltration(Strategy):
# Retained classes maintain their original ideal feature directions # Retained classes maintain their original ideal feature directions
B_Z_rows.append(A[i]) B_Z_rows.append(A[i])
# 10
# Stack back along dim=0 to match (num_classes, h_dim) # Stack back along dim=0 to match (num_classes, h_dim)
# to get mean
B_Z = torch.stack(B_Z_rows, dim=0) B_Z = torch.stack(B_Z_rows, dim=0)
A_inv = torch.linalg.pinv(A) A_inv = torch.linalg.pinv(A)
# 11
W_Z = B_Z @ A_inv @ W W_Z = B_Z @ A_inv @ W
# 12
model.fc.weight.copy_(W_Z) model.fc.weight.copy_(W_Z)
return model 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
)

View File

@@ -16,13 +16,21 @@ class Strategy:
self.log_file = Path(f"reports/{self.strategy_name}/metrics.txt") self.log_file = Path(f"reports/{self.strategy_name}/metrics.txt")
Util._initialize_log_file(log_file= self.log_file) 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. Wraps the unlearning execution with automated timing and strategy-specific logging.
DO NOT override this method in subclasses. Override _run instead. DO NOT override this method in subclasses. Override _run instead.
""" """
start_time = time.perf_counter() start_time = time.perf_counter()
retain_loader, forget_loader = self._split_data(dataset)
# Execute core unlearning logic # Execute core unlearning logic
processed_model = self._run(model, forget_loader, retain_loader) processed_model = self._run(model, forget_loader, retain_loader)
@@ -42,3 +50,11 @@ class Strategy:
def _run(self, model: nn.Module, forget_loader: DataLoader, retain_loader: DataLoader) -> nn.Module: def _run(self, model: nn.Module, forget_loader: DataLoader, retain_loader: DataLoader) -> nn.Module:
"""Subclasses implement their core unlearning logic here.""" """Subclasses implement their core unlearning logic here."""
raise NotImplementedError 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

View File

@@ -1,126 +1,135 @@
import torch import torch
import torch.nn as nn import torch.nn as nn
import torch.optim as optim 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 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): class WeightFiltration(Strategy):
""" def __init__(self,
Verbatim implementation of Poppi et al.'s WF-Net framework. target_class_index: int,
Directly filters the convolutional weights of a target layer using a learnable num_classes: int = 20,
channel mask, optimizing it via weight-space regularization. epochs: int = 6,
""" lr: float = 100.0,
def __init__(self, target_class_index: int, epochs: int = 10, lr: float = 0.2, gamma: float = 10.0): gamma: float = 0.01,
):
super().__init__(target_class_index=target_class_index) super().__init__(target_class_index=target_class_index)
self.epochs = epochs self.epochs = epochs
self.lr = lr self.lr = lr
self.gamma = gamma 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
)
# a WF_net module to be trained (unlearned) to generate alpha
def _optimise_filter(self, model: nn.Module, forget_loader: DataLoader, retain_loader: DataLoader, device): wf_net = wf_model.get()
# 1. Initialize the wrapper with your pre-trained model optimizer = optim.SGD([wf_net.alpha], lr=self.lr)
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)
criterion = nn.CrossEntropyLoss() criterion = nn.CrossEntropyLoss()
for epoch in range(self.epochs): for epoch in range(self.epochs):
forget_iter = iter(forget_loader)
t_loss_r, t_loss_f = 0.0, 0.0 t_loss_r, t_loss_f = 0.0, 0.0
steps = 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) r_inputs, r_labels = r_inputs.to(device), r_labels.to(device)
f_inputs, f_labels = f_inputs.to(device), f_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)
optimizer.zero_grad() optimizer.zero_grad()
# --- APPLY ALGORITHM 1 FORWARD PASS TO BOTH INPUTS --- # retain data paired with randomly selected rows of alpha to compute the retaining loss
# Pass the input batch AND the target unlearn class index random_rows = []
outputs_r = wf_model(r_inputs, target_unlearn_class=self.target_class_index) for label in r_labels:
outputs_f = wf_model(f_inputs, target_unlearn_class=self.target_class_index) 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) loss_r = criterion(outputs_r, r_labels)
temperature = 3.0 # Forget set is paired with corresponding labels as row selectors for alpha
logits_f_scaled = outputs_f / temperature # and used to compute unlearning loss
outputs_f = wf_net(f_inputs, target_class_indices=f_labels)
# Compute uniform target entropy per-sample, then average over the batch loss_f = 0.0
log_probs_f = torch.log_softmax(logits_f_scaled, dim=-1) classes_in_batch = 0
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) # 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))
# back propagation
total_loss = loss_r + (self.lambda_1 * loss_f) + (self.gamma * loss_reg)
total_loss.backward() total_loss.backward()
optimizer.step() optimizer.step()
t_loss_r += loss_r.item() 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 steps += 1
print(f" Epoch {epoch+1}/{self.epochs} | Retain Loss: {t_loss_r/steps:.4f} | Forget Loss: {t_loss_f/steps:.4f}") print(f" Epoch {epoch+1}/{self.epochs} | Retain Loss: {t_loss_r/steps:.4f} | Forget Loss: {t_loss_f/steps:.4f}")
return wf_model return wf_model
def _run(self, model: nn.Module, forget_loader: DataLoader, retain_loader: DataLoader) -> nn.Module: def _run(self, model: nn.Module, forget_loader: DataLoader, retain_loader: DataLoader) -> nn.Module:
device = next(model.parameters()).device device = next(model.parameters()).device
model.eval() model.eval()
# In WF-Net, the mask targets the last major convolutional block if self.wf_model is None:
# For ResNet-18, that is the final conv layer in layer4 block 1 print(">> Initializing and compiling global WF-Net matrix (Run Once for all classes)...")
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 self.wf_model = self._optimise_filter(
# 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, model,
forget_loader=forget_loader,
retain_loader=retain_loader, retain_loader=retain_loader,
device=device, forget_loader=forget_loader,
device=device
)
else:
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
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

View File

@@ -35,50 +35,50 @@ 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) * 1.5)
self.alpha = nn.Parameter(torch.ones(num_classes, out_channels)) self.alpha = nn.Parameter(torch.ones(num_classes, out_channels))
def forward(self, x: torch.Tensor, target_unlearn_class: int) -> torch.Tensor: def forward(self, x: torch.Tensor, target_class_indices: torch.Tensor) -> 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 # 1. Run through early sequence of layers undisturbed
x = self.maxpool(self.relu(self.bn1(self.conv1(x)))) x = self.maxpool(self.relu(self.bn1(self.conv1(x))))
x = self.layer1(x) x = self.layer1(x)
x = self.layer2(x) x = self.layer2(x)
x = self.layer3(x) x = self.layer3(x)
# Run layer4 block 0 and block 1 conv1 normally # Run layer4 block 0 normally
x = self.layer4[0](x) x = self.layer4[0](x)
# -------------------------------------------------------------
# HERE IT IS: Save the structural skip connection (identity)
# BEFORE modifying features via block 1's convolutions
# -------------------------------------------------------------
identity = x identity = x
# Now enter layer4 block 1
x = self.layer4[1].conv1(x) x = self.layer4[1].conv1(x)
x = self.layer4[1].bn1(x) x = self.layer4[1].bn1(x)
x = self.layer4[1].relu(x) x = self.layer4[1].relu(x)
# 2. CORE WF-NET MATH: w_hat_l <- alpha_l[Yunl] ⊙ w_l # [Your Step 1 Masking Math happens right here...]
# Extract 1D vector for target class and reshape to (out_channels, 1, 1, 1) for 4D convolution broadcasting batch_alpha = self.alpha[target_class_indices]
mask = torch.sigmoid(self.alpha[target_unlearn_class]).view(-1, 1, 1, 1) mask = torch.sigmoid(batch_alpha).view(x.size(0), -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 = F.conv2d(
x, x,
weight=w_hat, weight=self.original_w,
bias=self.target_conv.bias, bias=self.target_conv.bias,
stride=self.target_conv.stride, stride=self.target_conv.stride,
padding=self.target_conv.padding padding=self.target_conv.padding
) )
# Apply your WF-Net channel mask
x = x * mask
x = self.layer4[1].bn2(x) 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) x = self.layer4[1].relu(x + identity)
# 4. Final Classification Head Sequence # Final Classification Head Sequence
x = self.avgpool(x) x = self.avgpool(x)
x = torch.flatten(x, 1) x = torch.flatten(x, 1)
y_out = self.fc(x) y_out = self.fc(x)