概要
- 今回の記事では、以下の2つを扱う。
- DP-SGDの実装
- Opacusというライブラリを使った場合の実装
- スクラッチでの実装
- DP-SGDなど、勾配を利用した差分プライバシーの手法でなぜ勾配をクリップするのか
- DP-SGDの実装
- これらについて筆者が勉強/理解したことの備忘メモである。
- (ChatGPT)「DP-SGDは、機械学習の訓練過程で差分プライバシーを保証するアルゴリズムです。2016年にAbadi et al.によって提案され、現在では差分プライバシー機械学習のデファクトスタンダードとなっている」。
- 筆者が差分プライバシー関連のアルゴリズムを調査/勉強している時も、DP-SGDをベースにした手法を見かけることがあったので、メジャーな手法というのは筆者の肌感覚とも合う。
実装
Opacus
を使う場合
from opacus import PrivacyEngine
# DPSGDの標準実装
privacy_engine = PrivacyEngine()
model, optimizer, data_loader = privacy_engine.make_private(
module=model,
optimizer=optimizer,
data_loader=data_loader,
noise_multiplier=1.0, # σ
max_grad_norm=1.0, # C
)
スクラッチで実装する場合
実装のポイントは、
- データをマイクロバッチ化すること
1-1. ミニバッチをさらに細かく分けること。DP-SGDの場合は、1サンプル毎に処理を行う - 各サンプルごとに勾配を計算し、勾配のL2ノルムでクリップ
- 1つのミニバッチの全サンプルのクリップ後勾配を平均化し、その後ガウスノイズを付加して更新する。
# PyTorch で MNIST を使い、マイクロバッチサイズ=1 での実装例
# マイクロバッチサイズ=1
import torch
from torch import nn, optim
from torchvision import datasets, transforms
from torch.utils.data import DataLoader
# --- データセット ---
transform = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize((0.1307,), (0.3081,))
])
train_ds = datasets.MNIST('./data', train=True, download=True, transform=transform)
train_loader = DataLoader(train_ds, batch_size=64, shuffle=True)
# --- モデル定義 ---
class SimpleNet(nn.Module):
def __init__(self):
super().__init__()
self.fc = nn.Sequential(
nn.Flatten(),
nn.Linear(28*28, 100),
nn.ReLU(),
nn.Linear(100, 10)
)
def forward(self, x):
return self.fc(x)
model = SimpleNet()
optimizer = optim.SGD(model.parameters(), lr=0.1)
criterion = nn.CrossEntropyLoss()
# DP‑SGD パラメータ
max_grad_norm = 1.0
noise_mult = 1.0
microbatch_size = 1
for epoch in range(5):
model.train()
for X, y in train_loader:
accumulated = [torch.zeros_like(p) for p in model.parameters()]
for i in range(0, X.size(0), microbatch_size): # マイクロバッチループ
xi = X[i:i+1]; yi = y[i:i+1]
optimizer.zero_grad()
logits = model(xi)
loss = criterion(logits, yi)
loss.backward()
# 勾配クリップ
total_norm = torch.sqrt(sum(p.grad.norm()**2 for p in model.parameters()))
clip_coef = max_grad_norm / (total_norm + 1e-6)
if clip_coef < 1:
for p in model.parameters():
p.grad.detach().mul_(clip_coef) # NOTE: detach()して、clip_coef経由の計算グラフを引き継がずに、値だけinplaceするようにしてる.
# 勾配蓄積
for acc, p in zip(accumulated, model.parameters()):
acc += p.grad.detach() # NOTE: accumulatedとアドレス共有した形であるため、accumulatedも実質更新されている状態
# 平均 + ノイズ
for acc, p in zip(accumulated, model.parameters()):
acc /= X.size(0)
acc += torch.randn_like(acc) * (noise_mult * max_grad_norm / X.size(0))
p.grad = acc
optimizer.step() # ここで、`p.grad = acc`で計算しておいたp.gradの値で重みパラメータを更新する.
print(f"Epoch {epoch+1} done")
Opacus
とスクラッチ実装との違い
- スクラッチ実装でも
Opacus
上のDP-SGDと同等の機能(1サンプル毎の勾配計算、勾配のノルムによるクリップ、ミニバッチ毎の平均化)を有している。しかし、大きな違いは、計算効率である。Opacus
はPyTorchのベクトライズなどを利用して、効率的に計算できるようになっているが、今回のスクラッチ実装はforによるループ処理でマイクロバッチを計算するため、計算効率が悪い。
そもそもなぜ勾配のノルムによるクリッピングが必要なのか?
「DP-SGDで1サンプル毎に勾配のノルムによるクリッピングが必要ということがわかったけれども、なぜそもそもクリッピングするのだろうか?」という点が疑問に残ると思う。
簡潔に言えば、「勾配クリップがない場合、与えるべきノイズ量がデカくなりすぎる可能性があり、これを回避するために勾配クリップが必要である」
前提
例えば、差分プライバシーでノイズを付加するメカニズムとして、ラプラスメカニズムだと以下のような数式である。
M(D) = f(D) + Laplace(\frac{\triangle f}{\epsilon} )
勾配クリッピングが必要な理由を考えるときに注目すべきは、$\triangle f$、つまり、感度(sensitivity)である。
\triangle f
=max
\ |
f(D)
-
f(D')
\ |
要するに、1サンプルだけ異なるデータセットで関数 $f$ を計算した時の絶対値の差である。
この
- 絶対値の差が大きい場合は、与えるべきノイズが大きくなる
- 絶対値の差が小さい場合は、与えるべきノイズが小さくなる
という関係がある。
これを頭に入れておくと理解しやすい。
ここから、勾配クリップの必要性の話になる。
データセットの中にある1人のユーザ(サンプル)のデータセットで計算した勾配が(ほぼ)無限大に大きかったと仮定する。
そうすると、 $\triangle f =max |f(D)-f(D')|$ で考える感度がどうなるかというと、感度も無限大になる(そのサンプルの有無で勾配が無限大になるということなので)。
とすると、上記したように、感度が無限大になるということは、与えるべきノイズも無限大になってしまうということになるため、学習がうまく進まないことになってしまう。
これを防ぐためには、一定に大きさ以上の勾配(ノルムの大きさ)でクリッピングすることで、例外的なサンプルの影響を抑えることができる。
という議論があるため、「勾配クリッピングが必要である」となる。