Opacus
  • 简介
  • 常见问题
  • 教程
  • API 参考
  • GitHub

›

教程

  • 概览

使用 Opacus

  • 使用快速梯度裁剪 DP-SGD 构建文本分类器
  • 使用差分隐私构建图像分类器
  • 训练用于姓名分类的差分隐私 LSTM 模型
  • 深入了解 Opacus 的高级功能
  • 模块验证器 (Module Validator) 和修复器 (Fixer) 指南
  • 梯度采样器 (Grad Samplers) 指南
  • 使用 DistributedDataParallel 进行多 GPU 训练

使用差分隐私构建文本分类器¶

在本教程中,我们将通过获取一个在公共文本数据上预训练的模型,并针对不同的任务进行微调,来训练一个具有差分隐私(Differential Privacy)的文本分类器。

在使用差分隐私训练模型时,我们几乎总是在模型大小和任务准确度之间面临权衡。具体细节取决于具体问题,但一个经验法则是:模型的参数越少,就越容易通过 DP 获得良好的性能。

大多数先进的 NLP 模型都非常深且庞大(例如 BERT-base 拥有超过 1 亿个参数),这使得在隐私数据集上训练文本模型的任务相当具有挑战性。

解决此问题的一种方法是将训练过程分为两个阶段。首先,我们将在公共数据集上对模型进行预训练,让模型接触通用的文本数据。假设通用文本数据是公开的,我们在此步骤中将不使用差分隐私。然后,我们冻结大部分层,仅保留少数高层,使用 DP-SGD 在隐私数据集上进行训练。通过这种方式,我们可以兼得两者的优点——我们既拥有深度且强大的文本理解模型,同时又只使用差分隐私算法训练少量参数。

在本教程中,我们将使用预训练的 BERT-base 模型,并对其进行微调,以识别 SNLI 数据集上的文本蕴涵(textual entailment)。

我们还将进一步展示以下技术的微调结果:

  • Ghost Clipping DP-SGD:一种内存高效的 DP-SGD 实现,支持使用大批量(batch size)。
  • LoRA(低秩自适应):一种参数高效的微调方法,可与 DP-SGD 结合使用,以进一步减少可训练参数的数量。

数据集¶

首先,我们需要下载数据集(我们将使用斯坦福 NLP 镜像)。

In [ ]
STANFORD_SNLI_URL = "https://nlp.stanford.edu/projects/snli/snli_1.0.zip"
DATA_DIR = "data"
In [ ]
import zipfile
import urllib.request
import os

import warnings
warnings.simplefilter("ignore")

def download_and_extract(dataset_url, data_dir):
    print("Downloading and extracting ...")
    filename = "snli_1.0.zip"
    urllib.request.urlretrieve(dataset_url, filename)
    with zipfile.ZipFile(filename) as zip_ref:
        zip_ref.extractall(data_dir)
    os.remove(filename)
    print("Completed!")

download_and_extract(STANFORD_SNLI_URL, DATA_DIR)
Downloading and extracting ...
Completed!

该数据集有两种格式(tsv 和 json),并且已经划分为训练集/开发集/测试集(train/dev/test)。让我们验证一下情况是否如此。

In [ ]
snli_folder = os.path.join(DATA_DIR, "snli_1.0")
os.listdir(snli_folder)
Out[ ]
['snli_1.0_test.jsonl',
 'snli_1.0_train.txt',
 '.DS_Store',
 'snli_1.0_dev.jsonl',
 'Icon\r',
 'README.txt',
 'snli_1.0_dev.txt',
 'snli_1.0_test.txt',
 'snli_1.0_train.jsonl']

现在让我们查看一下内部内容。SNLI 数据集 提供了丰富的句法元数据,但我们只使用原始输入文本。因此,我们唯一感兴趣的字段是 sentence1(前提)、sentence2(假设)和 gold_label(由大多数标注者选择的标签)。

标签定义了前提与假设之间的关系:矛盾(contradiction)、中立(neutral) 或 蕴涵(entailment)。

In [ ]
import pandas as pd
train_path =  os.path.join(snli_folder, "snli_1.0_train.txt")
dev_path = os.path.join(snli_folder, "snli_1.0_dev.txt")

df_train = pd.read_csv(train_path, sep='\t')
df_test = pd.read_csv(dev_path, sep='\t')

df_train[['sentence1', 'sentence2', 'gold_label']][:5]
Out[ ]
sentence1 sentence2 gold_label
0 A person on a horse jumps over a broken down a... A person is training his horse for a competition. neutral
1 A person on a horse jumps over a broken down a... A person is at a diner, ordering an omelette. contradiction
2 A person on a horse jumps over a broken down a... A person is outdoors, on a horse. entailment
3 Children smiling and waving at camera They are smiling at their parents neutral
4 Children smiling and waving at camera There are children present entailment

模型¶

BERT(来自 Transformers 的双向编码器表示)是处理各种 NLP 任务的一种先进方法。它使用 Transformer 架构,并高度依赖预训练的概念。

我们将使用 huggingface transformers 库提供的预训练 BERT-base 模型。它为我们提供了经典 BERT 架构的 PyTorch 实现,以及在公共英语语料库(维基百科)上预训练的分词器(tokenizer)和权重。

在继续操作之前,请遵循这些 安装说明。

In [ ]
from transformers import BertConfig, BertTokenizer, BertForSequenceClassification

model_name = "bert-base-cased"
config = BertConfig.from_pretrained(
    model_name,
    num_labels=3,
)
tokenizer = BertTokenizer.from_pretrained(
    "bert-base-cased",
    do_lower_case=False,
)
model = BertForSequenceClassification.from_pretrained(
    "bert-base-cased",
    config=config,
)
config.json:   0%|          | 0.00/570 [00:00<?, ?B/s]
tokenizer_config.json:   0%|          | 0.00/49.0 [00:00<?, ?B/s]
vocab.txt:   0%|          | 0.00/213k [00:00<?, ?B/s]
tokenizer.json:   0%|          | 0.00/436k [00:00<?, ?B/s]
model.safetensors:   0%|          | 0.00/436M [00:00<?, ?B/s]
Some weights of BertForSequenceClassification were not initialized from the model checkpoint at bert-base-cased and are newly initialized: ['classifier.bias', 'classifier.weight']
You should probably TRAIN this model on a down-stream task to be able to use it for predictions and inference.

该模型具有以下结构:它结合使用词嵌入、位置嵌入和标记 嵌入(embeddings) 来创建序列表示,然后通过 12 个 Transformer 编码器(transformer encoders) 传递数据,最后使用 线性分类器(linear classifier) 生成最终标签。由于模型已经过预训练,且我们只打算微调少数高层,因此我们要冻结除最后一个编码器及以上层(BertPooler 和 Classifier)之外的所有层。

In [ ]
from IPython.display import Image
Image(filename='img/BERT.png')
Out[ ]
No description has been provided for this image
In [ ]
trainable_layers = [model.bert.encoder.layer[-1], model.bert.pooler, model.classifier]
total_params = 0
trainable_params = 0

for p in model.parameters():
        p.requires_grad = False
        total_params += p.numel()

for layer in trainable_layers:
    for p in layer.parameters():
        p.requires_grad = True
        trainable_params += p.numel()

print(f"Total parameters count: {total_params:,}") # ~108M
print(f"Trainable parameters count: {trainable_params:,}") # ~7M
Total parameters count: 108,312,579
Trainable parameters count: 7,680,771

因此,通过使用预训练模型,我们将可训练参数的数量从超过 1 亿减少到略高于 750 万。这将有助于在添加噪声的情况下提高性能和收敛性。

准备数据¶

在开始训练之前,我们需要预处理数据并将其转换为模型预期的格式。

(注意:在笔记本电脑上运行大约需要 5-10 分钟)

In [ ]
import torch
import torch.nn as nn
import transformers
from torch.utils.data import TensorDataset
from transformers.data.processors.utils import InputExample
from transformers.data.processors.glue import glue_convert_examples_to_features
In [ ]
LABEL_LIST = ['contradiction', 'entailment', 'neutral']
MAX_SEQ_LENGHT = 128




def _create_examples(df, set_type):
    """ Convert raw dataframe to a list of InputExample. Filter malformed examples
    """
    examples = []
    for index, row in df.iterrows():
        if row['gold_label'] not in LABEL_LIST:
            continue
        if not isinstance(row['sentence1'], str) or not isinstance(row['sentence2'], str):
            continue

        guid = f"{index}-{set_type}"
        examples.append(
            InputExample(guid=guid, text_a=row['sentence1'], text_b=row['sentence2'], label=row['gold_label']))
    return examples

def _df_to_features(df, set_type):
    """ Pre-process text. This method will:
    1) tokenize inputs
    2) cut or pad each sequence to MAX_SEQ_LENGHT
    3) convert tokens into ids

    The output will contain:
    `input_ids` - padded token ids sequence
    `attention mask` - mask indicating padded tokens
    `token_type_ids` - mask indicating the split between premise and hypothesis
    `label` - label
    """
    examples = _create_examples(df, set_type)

    #backward compatibility with older transformers versions
    legacy_kwards = {}
    from packaging import version
    if version.parse(transformers.__version__) < version.parse("2.9.0"):
        legacy_kwards = {
            "pad_on_left": False,
            "pad_token": tokenizer.convert_tokens_to_ids([tokenizer.pad_token])[0],
            "pad_token_segment_id": 0,
        }

    return glue_convert_examples_to_features(
        examples=examples,
        tokenizer=tokenizer,
        label_list=LABEL_LIST,
        max_length=MAX_SEQ_LENGHT,
        output_mode="classification",
        **legacy_kwards,
    )

def _features_to_dataset(features):
    """ Convert features from `_df_to_features` into a single dataset
    """
    all_input_ids = torch.tensor([f.input_ids for f in features], dtype=torch.long)
    all_attention_mask = torch.tensor(
        [f.attention_mask for f in features], dtype=torch.long
    )
    all_token_type_ids = torch.tensor(
        [f.token_type_ids for f in features], dtype=torch.long
    )
    all_labels = torch.tensor([f.label for f in features], dtype=torch.long)
    dataset = TensorDataset(
        all_input_ids, all_attention_mask, all_token_type_ids, all_labels
    )

    return dataset

train_features = _df_to_features(df_train, "train")
test_features = _df_to_features(df_test, "test")

train_dataset = _features_to_dataset(train_features)
test_dataset = _features_to_dataset(test_features)

选择批量大小(Batch Size)¶

让我们聊聊批量大小。

除了在选择批量大小时通常考虑的所有因素外,使用 DP 训练模型还增加了一个因素——隐私成本。

由于我们假设的威胁模型以及我们向梯度添加噪声的方式,较大的批量大小(在一定范围内)通常有助于收敛。无论批量大小如何,我们都会向每次梯度更新添加相同量的噪声(按批次中一个样本的范数缩放)。这意味着随着批量大小的增加,添加的噪声相对量会减少,同时保持相同的 epsilon 保证。

然而,你应该记住,增加批量大小在 epsilon 方面是有代价的,随着训练的进行,epsilon 以 O(sqrt(batch_size)) 的速度增长(因此较大的批次会使其增长得更快)。这里的良好策略是尝试 batch_size 和 noise_multiplier(噪声倍数)的多种组合,以找到在可接受的隐私保证下提供最佳质量的组合。

这还有另一方面——内存。Opacus 计算并存储 每个样本(per sample) 的梯度,因此对于每个普通梯度,Opacus 在每一步都会存储 n=batch_size 个样本梯度,从而使内存占用至少增加 O(batch_size)。然而在现实中,与非隐私模型相比,峰值内存需求为 O(batch_size^2)。这是因为样本梯度计算中的某些中间步骤涉及对两个矩阵的操作,而每个矩阵都以 batch_size 作为其中一个维度。

好消息是,我们可以选择最合适的批量大小,而不受内存限制。Opacus 内置了对 虚拟(virtual) 批次的支持。使用它,我们可以分离物理步骤(梯度计算)和逻辑步骤(噪声添加和参数更新):在训练中使用较大的批次,同时保持较低的内存占用。下面我们将指定两个常量:

  • MAX_PHYSICAL_BATCH_SIZE:定义了从内存角度我们可以承受的最大批量大小,仅影响计算速度。
  • BATCH_SIZE:另一方面,将仅影响收敛和隐私保证。
In [ ]
BATCH_SIZE = 32
MAX_PHYSICAL_BATCH_SIZE = 8
In [ ]
from torch.utils.data import DataLoader, RandomSampler, SequentialSampler
from opacus.utils.uniform_sampler import UniformWithReplacementSampler

train_dataloader = DataLoader(train_dataset, batch_size=BATCH_SIZE)
test_dataloader = DataLoader(test_dataset, sampler=SequentialSampler(test_dataset), batch_size=BATCH_SIZE)

训练¶

In [ ]
# Move the model to appropriate device
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = model.to(device)

# Set the model to train mode (HuggingFace models load in eval mode)
model = model.train()
# Define optimizer
optimizer = torch.optim.AdamW(model.parameters(), lr=5e-4, eps=1e-8)

首先,我们指定一些训练参数,准备运行三个轮次(epochs)的训练循环。

In [ ]
EPOCHS = 3
LOGGING_INTERVAL = 5000 # once every how many steps we run evaluation cycle and report metrics
EPSILON = 7.5
DELTA = 1 / len(train_dataloader) # Parameter for privacy accounting. Probability of not achieving privacy guarantees

现在让我们定义评估循环。

In [ ]
import numpy as np
from tqdm.notebook import tqdm

def accuracy(preds, labels):
    return (preds == labels).mean()

# define evaluation cycle
def evaluate(model):
    model.eval()

    loss_arr = []
    accuracy_arr = []

    for batch in test_dataloader:
        batch = tuple(t.to(device) for t in batch)

        with torch.no_grad():
            inputs = {'input_ids':      batch[0],
                      'attention_mask': batch[1],
                      'token_type_ids': batch[2],
                      'labels':         batch[3]}

            outputs = model(**inputs)
            loss, logits = outputs[:2]

            preds = np.argmax(logits.detach().cpu().numpy(), axis=1)
            labels = inputs['labels'].detach().cpu().numpy()

            loss_arr.append(loss.item())
            accuracy_arr.append(accuracy(preds, labels))

    model.train()
    return np.mean(loss_arr), np.mean(accuracy_arr)

接下来,我们将定义并附加 PrivacyEngine。这里有两个你需要考虑的参数:

  • noise_multiplier。它定义了隐私与准确度之间的权衡。添加更多噪声将提供更强的隐私保证,但也会损害模型质量。在本次运行中,PrivacyEngine 将根据 EPSILON、DELTA 和 EPOCHS 的目标值确定此值。对于默认设置,这将使 noise_multiplier 设置为约 0.4。
  • max_grad_norm。定义了我们将样本梯度裁剪到的 L2 范数的最大量级。这个阈值存在一种博弈:一方面,低阈值意味着我们将裁剪许多梯度,从而损害收敛,因此我们可能倾向于提高它。但是,请记住我们添加的噪声标准差 std=noise_multiplier * max_grad_norm,因此我们将为增加的阈值付出更多噪声的代价。在大多数情况下,由于模型对裁剪具有相当的韧性(在最初的几次迭代后,模型往往会自行调整,使其梯度保持在裁剪阈值以下),因此你通常可以保留默认值(=1.0),而专注于调整 batch_size 和 noise_multiplier。尽管如此,有时裁剪确实会伤害模型,因此值得尝试不同的裁剪阈值,就像我们在本教程中所做的那样。

这两个参数定义了我们向梯度添加的噪声尺度:噪声将从均值为 0、标准差 std=noise_multiplier * max_grad_norm 的高斯分布中采样。

In [ ]
from opacus import PrivacyEngine

MAX_GRAD_NORM = 0.1

privacy_engine = PrivacyEngine()

model, optimizer, train_dataloader = privacy_engine.make_private_with_epsilon(
    module=model,
    optimizer=optimizer,
    data_loader=train_dataloader,
    target_delta=DELTA,
    target_epsilon=EPSILON,
    epochs=EPOCHS,
    max_grad_norm=MAX_GRAD_NORM,
)

现在我们可以训练模型了。

In [ ]
from opacus.utils.batch_memory_manager import BatchMemoryManager

for epoch in range(1, EPOCHS+1):
    losses = []

    with BatchMemoryManager(
        data_loader=train_dataloader,
        max_physical_batch_size=MAX_PHYSICAL_BATCH_SIZE,
        optimizer=optimizer
    ) as memory_safe_data_loader:
        for step, batch in enumerate(tqdm(memory_safe_data_loader)):
            optimizer.zero_grad()

            batch = tuple(t.to(device) for t in batch)
            inputs = {'input_ids':      batch[0],
                    'attention_mask': batch[1],
                    'token_type_ids': batch[2],
                    'labels':         batch[3]}

            outputs = model(**inputs) # output = loss, logits, hidden_states, attentions

            loss = outputs[0]
            loss.backward()
            losses.append(loss.item())

            optimizer.step()

            if step > 0 and step % LOGGING_INTERVAL == 0:
                train_loss = np.mean(losses)
                eps = privacy_engine.get_epsilon(DELTA)

                eval_loss, eval_accuracy = evaluate(model)

                print(
                  f"Epoch: {epoch} | "
                  f"Step: {step} | "
                  f"Train loss: {train_loss:.3f} | "
                  f"Eval loss: {eval_loss:.3f} | "
                  f"Eval accuracy: {eval_accuracy:.3f} | "
                  f"ɛ: {eps:.2f}"
                )

关于测试准确度,在训练三个轮次后,你应该期望得到接近以下结果。

你可以看到,在 epsilon=7.5 的情况下,我们可以实现相当强的隐私保证,与在类似设置(仅限高层)下训练的非隐私模型相比,准确度损失适中,为 11 个百分点;与我们使用相同架构能达到的最佳结果相比,损失 16 个百分点。

注:未指明时,DP-SGD 仅针对高层进行训练。

模型 噪声倍数 批量大小 准确度 Epsilon
无 DP,训练全模型 不适用 32 90.1% 不适用
无 DP,仅训练高层 不适用 32 85.4% 不适用
DP-SGD 1.0 32 70.5% 0.7
DP-SGD(本教程) 0.4 32 74.3% 7.5
DP-SGD 0.3 32 75.8% 20.7
DP-SGD 0.1 32 78.3% 2865
DP-SGD 0.4 8 67.3% 5.9

Ghost Clipping¶

在本节中,我们将展示如何使用快速梯度裁剪(Fast Gradient Clipping)和 Ghost Clipping DP-SGD。训练循环与 Opacus 中现有的循环几乎完全相同,后者基于(非隐私的)PyTorch 训练循环。要使用快速梯度裁剪,我们需要在 make_private 函数中传递 grad_sample_mode = 'ghost'。

另一个变化是,隐私引擎的 make_private 函数也将损失准则(loss criterion)作为输入并进行处理。这允许我们重新利用 loss.backward 来执行两次反向传播,并在中间进行损失缩放。第一次反向传播计算每个样本的梯度范数,而对缩放后的损失进行的第二次反向传播则计算聚合后的裁剪梯度。

In [ ]
# Let's import the model again and freeze layers as before
model = BertForSequenceClassification.from_pretrained(
    "bert-base-cased",
    config=config,
)

trainable_layers = [model.bert.encoder.layer[-1], model.bert.pooler, model.classifier]

for p in model.parameters():
        p.requires_grad = False
        total_params += p.numel()

for layer in trainable_layers:
    for p in layer.parameters():
        p.requires_grad = True
        trainable_params += p.numel()
Some weights of BertForSequenceClassification were not initialized from the model checkpoint at bert-base-cased and are newly initialized: ['classifier.bias', 'classifier.weight']
You should probably TRAIN this model on a down-stream task to be able to use it for predictions and inference.
In [ ]
device = torch.device("cuda:0")
os.environ["CUDA_LAUNCH_BLOCKING"] = "1"

optimizer = torch.optim.AdamW(model.parameters(), lr=5e-4, eps=1e-8)
model = model.train()

privacy_engine = PrivacyEngine()
criterion = nn.CrossEntropyLoss(reduction="mean")

model_gc, optimizer_gc, criterion_gc, train_dataloader = (
    privacy_engine.make_private_with_epsilon(
        module=model,
        optimizer=optimizer,
        data_loader=train_dataloader,
        criterion=criterion,
        target_delta=DELTA,
        target_epsilon=EPSILON,
        epochs=EPOCHS,
        max_grad_norm=MAX_GRAD_NORM,
        grad_sample_mode="ghost",
    )
)

model_gc = model_gc.to(device)
model_gc = model_gc.train()

for epoch in range(1, EPOCHS + 1):
    losses = []
    for step, batch in enumerate(tqdm(train_dataloader)):
        optimizer_gc.zero_grad()
        batch = tuple(t.to(device) for t in batch)
        inputs = {
            "input_ids": batch[0],
            "attention_mask": batch[1],
            "token_type_ids": batch[2],
            "labels": batch[3],
        }
        outputs = model_gc(**inputs)  # output = loss, logits, hidden_states, attentions
        loss = criterion_gc(outputs[1], batch[3])
        loss.backward()
        optimizer_gc.step()
        losses.append(loss.item())

        if step > 0 and step % LOGGING_INTERVAL == 0:
            train_loss = np.mean(losses)
            eval_loss, eval_accuracy = evaluate(model_gc)
            eps = privacy_engine.get_epsilon(DELTA)
            print(
                f"Epoch: {epoch} | "
                f"Step: {step} | "
                f"Train loss: {train_loss:.3f} | "
                f"Eval loss: {eval_loss:.3f} | "
                f"Eval accuracy: {eval_accuracy:.3f} | "
                f"ɛ: {eps:.2f}"
            )

Epoch: 1 | Step: 5000 | Train loss: 1.559 | Eval loss: 1.508 | Eval accuracy: 0.683 | ɛ: 4.83

Epoch: 1 | Step: 10000 | Train loss: 1.625 | Eval loss: 1.635 | Eval accuracy: 0.723 | ɛ: 5.46

Epoch: 1 | Step: 15000 | Train loss: 1.655 | Eval loss: 1.649 | Eval accuracy: 0.735 | ɛ: 5.86

Epoch: 2 | Step: 5000 | Train loss: 1.742 | Eval loss: 1.676 | Eval accuracy: 0.739 | ɛ: 6.29

Epoch: 2 | Step: 10000 | Train loss: 1.746 | Eval loss: 1.681 | Eval accuracy: 0.743 | ɛ: 6.54

Epoch: 2 | Step: 15000 | Train loss: 1.759 | Eval loss: 1.683 | Eval accuracy: 0.745 | ɛ: 6.76

Epoch: 3 | Step: 5000 | Train loss: 1.784 | Eval loss: 1.769 | Eval accuracy: 0.745 | ɛ: 7.05

Epoch: 3 | Step: 10000 | Train loss: 1.789 | Eval loss: 1.695 | Eval accuracy: 0.748 | ɛ: 7.24

Epoch: 3 | Step: 15000 | Train loss: 1.792 | Eval loss: 1.714 | Eval accuracy: 0.749 | ɛ: 7.42

低秩自适应 (LoRA) 与 DP-SGD¶

在本节中,我们展示 DP-SGD 微调与 LoRA 及其他参数高效微调技术(PEFT)是兼容的。只需几行代码即可设置 LoRA,并且隐私分析在概念上没有变化。当大型模型的全量微调成本很高时,PEFT 方法通过仅训练少量额外参数来加速训练,同时保持与全量微调相当的准确度。在 DP-SGD 的背景下,PEFT 有潜力进一步提高准确度,因为训练较少的参数意味着在计算中注入的噪声较少。

我们将使用来自 HuggingFace 的 peft 库,该库与 transformers 库兼容。

In [ ]
# reset the model
model = BertForSequenceClassification.from_pretrained(
    "bert-base-cased",
    config=config,
)
Some weights of BertForSequenceClassification were not initialized from the model checkpoint at bert-base-cased and are newly initialized: ['classifier.bias', 'classifier.weight']
You should probably TRAIN this model on a down-stream task to be able to use it for predictions and inference.

回想一下,模型参数总数约为 1.08 亿。

In [ ]
total_params = sum(p.numel() for p in model.parameters() if p.requires_grad)
print(f"Total parameters count: {total_params:,}") # ~108M
Total parameters count: 108,312,579

启用 LoRA 后,可训练参数总数减少了 100 倍,降至约 100 万。

请注意使用 LoRA 时的一些关键超参数,例如分解矩阵的秩 $r$。这些参数可能需要调优。

In [ ]
from peft import get_peft_model, LoraConfig, TaskType

lora_config = LoraConfig(
    task_type=TaskType.SEQ_CLS,  # our particular task is sequence classification
    inference_mode=False,  # Enable training mode
    r=32,  # Low-rank dimension
    lora_alpha=32,  # Alpha scaling factor
    lora_dropout=0.05,  # Dropout for LoRA layers
)

model_with_lora = get_peft_model(model, lora_config)
trainable_params = sum(p.numel() for p in model_with_lora.parameters() if p.requires_grad)
print(f"Total trainable parameters with LoRA: {trainable_params:,}") # ~1M
Total trainable parameters with LoRA: 1,181,955

与之前类似,我们将冻结除模型最后一个注意力层之外的所有层。这进一步将可训练参数的数量减少到约 10 万,而没有 LoRA 时约为 700 万。

In [ ]
attention_layers_to_freeze = model_with_lora.base_model.bert.encoder.layer[:-1]

# Freeze the parameters in the first 11 attention layers
for param in attention_layers_to_freeze.parameters():
    param.requires_grad = False


trainable_params = sum(p.numel() for p in model_with_lora.parameters() if p.requires_grad)
print(f"Total trainable parameters with LoRA after freezing: {trainable_params:,}") # ~1M
Total trainable parameters with LoRA after freezing: 100,611

现在我们已经完成了 LoRA 设置的模型准备,接下来使用 DP-SGD 进行训练就如往常一样了。我们使用带有 Ghost Clipping 的 DP-SGD。

In [ ]
EPOCHS = 3
LOGGING_INTERVAL = 5000 # once every how many steps we run evaluation cycle and report metrics
DELTA = 1 / len(train_dataloader) # Parameter for privacy accounting. Probability of not achieving privacy guarantees
MAX_GRAD_NORM = 0.1
In [ ]
device = torch.device("cuda:0")
os.environ["CUDA_LAUNCH_BLOCKING"] = "1"

optimizer = torch.optim.AdamW(model_with_lora.parameters(), lr=5e-4, eps=1e-8)
model_with_lora = model_with_lora.train()

privacy_engine = PrivacyEngine()
criterion = nn.CrossEntropyLoss(reduction="mean")

model_lora, optimizer_lora, criterion_lora, train_dataloader = (
    privacy_engine.make_private_with_epsilon(
        module=model_with_lora,
        optimizer=optimizer,
        data_loader=train_dataloader,
        criterion=criterion,
        target_delta=DELTA,
        target_epsilon=EPSILON,
        epochs=EPOCHS,
        max_grad_norm=MAX_GRAD_NORM,
        grad_sample_mode="ghost",
    )
)

model_lora = model_lora.to(device)
model_lora = model_lora.train()

for epoch in range(1, EPOCHS + 1):
    losses = []
    for step, batch in enumerate(tqdm(train_dataloader)):
        optimizer_lora.zero_grad()
        batch = tuple(t.to(device) for t in batch)
        inputs = {
            "input_ids": batch[0],
            "attention_mask": batch[1],
            "token_type_ids": batch[2],
            "labels": batch[3],
        }
        outputs = model_lora(**inputs)  # output = loss, logits, hidden_states, attentions
        loss = criterion_lora(outputs[1], batch[3])
        loss.backward()
        optimizer_lora.step()
        losses.append(loss.item())

        if step > 0 and step % LOGGING_INTERVAL == 0:
            train_loss = np.mean(losses)
            eval_loss, eval_accuracy = evaluate(model_lora)
            eps = privacy_engine.get_epsilon(DELTA)
            print(
                f"Epoch: {epoch} | "
                f"Step: {step} | "
                f"Train loss: {train_loss:.3f} | "
                f"Eval loss: {eval_loss:.3f} | "
                f"Eval accuracy: {eval_accuracy:.3f} | "
                f"ɛ: {eps:.2f}"
            )

Epoch: 1 | Step: 5000 | Train loss: 1.370 | Eval loss: 1.208 | Eval accuracy: 0.512 | ɛ: 4.83

Epoch: 1 | Step: 10000 | Train loss: 1.551 | Eval loss: 1.584 | Eval accuracy: 0.621 | ɛ: 5.46

Epoch: 1 | Step: 15000 | Train loss: 1.601 | Eval loss: 1.523 | Eval accuracy: 0.661 | ɛ: 5.86

Epoch: 2 | Step: 5000 | Train loss: 1.713 | Eval loss: 1.556 | Eval accuracy: 0.708 | ɛ: 6.29

Epoch: 2 | Step: 10000 | Train loss: 1.720 | Eval loss: 1.573 | Eval accuracy: 0.712 | ɛ: 6.54

Epoch: 2 | Step: 15000 | Train loss: 1.723 | Eval loss: 1.510 | Eval accuracy: 0.725 | ɛ: 6.76

Epoch: 3 | Step: 5000 | Train loss: 1.704 | Eval loss: 1.492 | Eval accuracy: 0.735 | ɛ: 7.05

Epoch: 3 | Step: 10000 | Train loss: 1.705 | Eval loss: 1.535 | Eval accuracy: 0.734 | ɛ: 7.24

Epoch: 3 | Step: 15000 | Train loss: 1.704 | Eval loss: 1.492 | Eval accuracy: 0.740 | ɛ: 7.42

我们在训练参数减少约 100 倍的情况下,实现了与 Ghost Clipping(以及普通 DP-SGD)相当的准确度。

结语¶

请注意,DP 训练和非 DP 训练之间存在显著的模型准确度差距,约为 15 个百分点。

这一差距可以通过以下方式进一步缩小:

  • 使用更大的批量大小来克服噪声的影响。
  • 对学习率和裁剪范数进行超参数调优(结合更大的批量大小)。
  • 训练更多的注意力层(并探索训练更多参数所带来的噪声与模型容量之间的权衡)。这一策略与 LoRA 非常契合,因为我们可以用更少的参数训练更多的层。

我们邀请您亲自尝试并进一步提高 DP-SGD 训练的准确度。

下载教程 Jupyter Notebook
Opacus
文档
简介常见问题教程API 参考
Github
opacus
法律
隐私政策条款
Meta Open Source
Copyright © 2025 Meta Platforms, Inc.