#!/usr/bin/env python3 # Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved import unittest import numpy as np import torch import torch.nn as nn import torch.nn.functional as F from torch.utils.data import DataLoader from torchdp import PrivacyEngine, utils from torchdp.dp_model_inspector import IncompatibleModuleException from torchvision import models, transforms from torchvision.datasets import FakeData def get_grad_sample_aggregated(tensor: torch.Tensor, loss_type: str = "mean"): if tensor.grad_sample is None: raise ValueError( f"The input tensor {tensor} has grad computed, but missing grad_sample." f"Please attach PrivacyEngine" ) if loss_type not in ("sum", "mean"): raise ValueError(f"loss_type = {loss_type}. Only 'sum' and 'mean' supported") grad_sample_aggregated = torch.einsum("i...->...", tensor.grad_sample) if loss_type == "mean": b_sz = tensor.grad_sample.shape[0] grad_sample_aggregated /= b_sz return grad_sample_aggregated class SampleConvNet(nn.Module): def __init__(self): super().__init__() self.conv1 = nn.Conv2d(1, 16, 8, 3) self.gnorm1 = nn.GroupNorm(4, 16) self.conv2 = nn.Conv1d(16, 32, 3, 1) self.lnorm1 = nn.LayerNorm((32, 23)) self.conv3 = nn.Conv1d(32, 32, 3, 1) self.instnorm1 = nn.InstanceNorm1d(32, affine=True) self.convf = nn.Conv1d(32, 32, 1, 1) for p in self.convf.parameters(): p.requires_grad = False self.fc1 = nn.Linear(21, 17) self.lnorm2 = nn.LayerNorm(17) self.fc2 = nn.Linear(32 * 17, 10) for layer in (self.gnorm1, self.lnorm1, self.lnorm2, self.instnorm1): nn.init.uniform_(layer.weight) nn.init.uniform_(layer.bias) def forward(self, x): # x of shape [B, 1, 28, 28] x = self.conv1(x) # -> [B, 16, 10, 10] x = self.gnorm1(x) # -> [B, 16, 10, 10] x = F.relu(x) # -> [B, 16, 10, 10] x = F.max_pool2d(x, 2, 2) # -> [B, 16, 5, 5] x = x.view(x.shape[0], x.shape[1], x.shape[2] * x.shape[3]) # -> [B, 16, 25] x = self.conv2(x) # -> [B, 32, 23] x = self.lnorm1(x) # -> [B, 32, 23] x = F.relu(x) # -> [B, 32, 23] x = self.conv3(x) # -> [B, 32, 21] x = self.instnorm1(x) # -> [B, 32, 21] x = self.convf(x) # -> [B, 32, 21] x = self.fc1(x) # -> [B, 32, 17] x = self.lnorm2(x) # -> [B, 32, 17] x = x.view(-1, x.shape[-2] * x.shape[-1]) # -> [B, 32 * 17] x = self.fc2(x) # -> [B, 10] return x def name(self): return "SampleConvNet" class PrivacyEngine_test(unittest.TestCase): def setUp(self): self.DATA_SIZE = 64 self.BATCH_SIZE = 64 self.LR = 0.5 self.ALPHAS = [1 + x / 10.0 for x in range(1, 100, 10)] self.criterion = nn.CrossEntropyLoss() self.setUp_data() self.original_model, self.original_optimizer = self.setUp_init_model() self.private_model, self.private_optimizer = self.setUp_init_model( private=True, state_dict=self.original_model.state_dict(), noise_multiplier=1.3, max_grad_norm=1.0, ) self.original_grads_norms = self.setUp_model_step( self.original_model, self.original_optimizer ) self.private_grads_norms = self.setUp_model_step( self.private_model, self.private_optimizer ) self.privacy_default_params = {"noise_multiplier": 1.0, "max_grad_norm": 1} def setUp_data(self): self.ds = FakeData( size=self.DATA_SIZE, image_size=(1, 35, 35), num_classes=10, transform=transforms.Compose( [transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,))] ), ) self.dl = DataLoader(self.ds, batch_size=self.BATCH_SIZE) def setUp_init_model( self, private=False, state_dict=None, model=None, **privacy_engine_kwargs ): model = model or SampleConvNet() optimizer = torch.optim.SGD(model.parameters(), lr=self.LR, momentum=0) if state_dict: model.load_state_dict(state_dict) if private: if len(privacy_engine_kwargs) == 0: privacy_engine_kwargs = self.privacy_default_params privacy_engine = PrivacyEngine( model, batch_size=self.BATCH_SIZE, sample_size=self.DATA_SIZE, alphas=self.ALPHAS, **privacy_engine_kwargs, ) privacy_engine.attach(optimizer) return model, optimizer def setUp_model_step(self, model: nn.Module, optimizer: torch.optim.Optimizer): for x, y in self.dl: optimizer.zero_grad() logits = model(x) loss = self.criterion(logits, y) loss.backward() optimizer.step() return torch.stack( [p.grad.norm() for p in model.parameters() if p.requires_grad], dim=-1 ) def test_throws_on_bad_per_layer_maxnorm_size(self): model, optimizer = self.setUp_init_model( private=True, noise_multiplier=0.1, max_grad_norm=[999] * 10 ) # there are a total of 18 parameters sets, [bias, weight] * 9 layers # the provided max_grad_norm is not either a scalar or a list of size 18 with self.assertRaises(ValueError): self.setUp_model_step(model, optimizer) def test_throws_double_attach(self): model, optimizer = self.setUp_init_model(private=True) self.setUp_model_step(model, optimizer) with self.assertRaises(ValueError): model, optimizer = self.setUp_init_model(private=True, model=model) self.setUp_model_step(model, optimizer) def test_attach_delete_attach(self): model, optimizer = self.setUp_init_model(private=True) self.setUp_model_step(model, optimizer) del optimizer.privacy_engine model, optimizer = self.setUp_init_model(private=True, model=model) self.setUp_model_step(model, optimizer) def test_attach_delete_detach(self): model, optimizer = self.setUp_init_model(private=True) self.setUp_model_step(model, optimizer) pe = optimizer.privacy_engine pe.detach() try: pe.clipper.__del__() except ValueError as e: self.fail(f"Detaching hooks twice! {e}") def test_attach_detach_attach(self): model, optimizer = self.setUp_init_model(private=True) self.setUp_model_step(model, optimizer) optimizer.privacy_engine.detach() model, optimizer = self.setUp_init_model(private=True, model=model) self.setUp_model_step(model, optimizer) def test_privacy_analysis_alpha_in_alphas(self): target_delta = 1e-5 eps, alpha = self.private_optimizer.privacy_engine.get_privacy_spent( target_delta ) self.assertTrue(alpha in self.ALPHAS) def test_privacy_analysis_epsilon(self): target_delta = 1e-5 eps, alpha = self.private_optimizer.privacy_engine.get_privacy_spent( target_delta ) self.assertTrue(eps > 0) def test_gradients_change(self): """ Test that gradients are different after one step of SGD """ for layer_grad, private_layer_grad in zip( [p for p in self.original_model.parameters() if p.requires_grad], [p for p in self.private_model.parameters() if p.requires_grad], ): self.assertFalse(torch.allclose(layer_grad, private_layer_grad)) def test_model_weights_change(self): """ Test that the updated models are different after one step of SGD """ for layer, private_layer in zip( [p for p in self.original_model.parameters() if p.requires_grad], [p for p in self.private_model.parameters() if p.requires_grad], ): self.assertFalse(torch.allclose(layer, private_layer)) def test_grad_consistency(self): model, optimizer = self.setUp_init_model( private=True, state_dict=self.original_model.state_dict(), noise_multiplier=0, max_grad_norm=999, ) grad_sample_aggregated = {} for x, y in self.dl: optimizer.zero_grad() logits = model(x) loss = self.criterion(logits, y) loss.backward() # collect all per-sample gradients before we take the step for _, layer in model.named_modules(): if utils.get_layer_type(layer) == "SampleConvNet": continue grad_sample_aggregated[layer] = {} for p in layer.parameters(): if p.requires_grad: grad_sample_aggregated[layer][p] = get_grad_sample_aggregated(p) optimizer.step() for layer_name, layer in model.named_modules(): if utils.get_layer_type(layer) == "SampleConvNet": continue for p in layer.parameters(): if p.requires_grad: self.assertTrue( torch.allclose( p.grad, grad_sample_aggregated[layer][p], atol=10e-5, rtol=10e-2, ), f"grad_sample doesn't match grad. " f"Layer: {layer_name}, Tensor: {p.shape}", ) def test_grad_matches_original(self): original_model, orignial_optimizer = self.setUp_init_model() private_model, private_optimizer = self.setUp_init_model( private=True, state_dict=original_model.state_dict(), noise_multiplier=0, max_grad_norm=999, ) for _ in range(3): self.setUp_model_step(original_model, orignial_optimizer) self.setUp_model_step(private_model, private_optimizer) for layer_name, private_layer in private_model.named_children(): if not utils.requires_grad(private_layer): continue original_layer = getattr(original_model, layer_name) for layer, private_layer in zip( [p.grad for p in original_layer.parameters() if p.requires_grad], [p.grad for p in private_layer.parameters() if p.requires_grad], ): self.assertTrue( torch.allclose(layer, private_layer, atol=10e-4, rtol=10e-2), f"Layer: {layer_name}. Private gradients with noise 0 doesn't match original", ) def test_grad_matches_original_per_layer_clipping(self): original_model, orignial_optimizer = self.setUp_init_model() private_model, private_optimizer = self.setUp_init_model( private=True, state_dict=original_model.state_dict(), noise_multiplier=0, max_grad_norm=[999] * 18, clip_per_layer=True, ) for _ in range(3): self.setUp_model_step(original_model, orignial_optimizer) self.setUp_model_step(private_model, private_optimizer) for layer_name, private_layer in private_model.named_children(): if not utils.requires_grad(private_layer): continue original_layer = getattr(original_model, layer_name) for layer, private_layer in zip( [p.grad for p in original_layer.parameters() if p.requires_grad], [p.grad for p in private_layer.parameters() if p.requires_grad], ): self.assertTrue( torch.allclose(layer, private_layer, atol=10e-4, rtol=10e-2), f"Layer: {layer_name}. Private gradients with noise 0 doesn't match original", ) def test_noise_changes_every_time(self): """ Test that adding noise results in ever different model params. We disable clipping in this test by setting it to a very high threshold. """ model, optimizer = self.setUp_init_model( private=True, state_dict=self.original_model.state_dict(), noise_multiplier=1.3, max_grad_norm=999, ) self.setUp_model_step(model, optimizer) first_run_params = (p for p in model.parameters() if p.requires_grad) model, optimizer = self.setUp_init_model( private=True, state_dict=self.original_model.state_dict(), noise_multiplier=1.3, max_grad_norm=999, ) self.setUp_model_step(model, optimizer) second_run_params = (p for p in model.parameters() if p.requires_grad) for p0, p1 in zip(first_run_params, second_run_params): self.assertFalse(torch.allclose(p0, p1)) def test_model_validator(self): """ Test that the privacy engine throws on attach if there are unsupported modules """ privacy_engine = PrivacyEngine( models.resnet18(), batch_size=self.BATCH_SIZE, sample_size=self.DATA_SIZE, alphas=self.ALPHAS, noise_multiplier=1.3, max_grad_norm=1, ) with self.assertRaises(IncompatibleModuleException): privacy_engine.attach(self.private_optimizer) def test_deterministic_run(self): """ Tests that for 2 different models, secure seed can be fixed to produce same (deterministic) runs. """ model1, optimizer1 = self.setUp_init_model(private=True) model2, optimizer2 = self.setUp_init_model( private=True, state_dict=model1.state_dict() ) # assert the models are identical initially first_model_params = [p for p in model1.parameters() if p.requires_grad] second_model_params = [p for p in model2.parameters() if p.requires_grad] for p0, p1 in zip(first_model_params, second_model_params): self.assertTrue(torch.allclose(p0, p1)) optimizer1.privacy_engine._set_seed(10) self.setUp_model_step(model1, optimizer1) optimizer2.privacy_engine._set_seed(10) self.setUp_model_step(model2, optimizer2) # assert the models are identical after we did one step first_model_params = (p for p in model1.parameters() if p.requires_grad) second_model_params = (p for p in model2.parameters() if p.requires_grad) for p0, p1 in zip(first_model_params, second_model_params): self.assertTrue(torch.allclose(p0, p1)) def test_deterministic_noise_generation(self): """ Tests that when secure seed is set for a model, the sequence of the generated noise is the same. It performs the following test: 1- Initiate a model, do one step, set the seed, and save the noise sequence 2- Do 3 more steps, set the seed, and save the noise sequnece The two noise sequences should be the same, because the seed has been set prior to calling the noise generation each time """ max_norm = 5 model, optimizer = self.setUp_init_model(private=True) self.setUp_model_step(model, optimizer) # do one step so we have gradiants model_params = [p for p in model.parameters() if p.requires_grad] optimizer.privacy_engine._set_seed(20) noise_generated_before = [ optimizer.privacy_engine._generate_noise(max_norm, p).detach().numpy() for p in model_params ] for _ in range(3): self.setUp_model_step(model, optimizer) optimizer.privacy_engine._set_seed(20) noise_generated_after = [ optimizer.privacy_engine._generate_noise(max_norm, p).detach().numpy() for p in model_params ] np.testing.assert_equal(noise_generated_before, noise_generated_after)