- Authors

- Name
- Youngju Kim
- @fjvbn20031
联邦学习(Federated Learning)完全指南:隐私保护分布式AI
现代 AI 最大的悖论之一在于:模型性能越高,需要的数据就越多;收集的数据越多,隐私泄露的风险就越大。医院无法共享患者数据,智能手机厂商无法把用户的输入习惯发送到服务器,金融机构也无法与竞争对手共享交易记录。
联邦学习(Federated Learning,FL)正是解决这一困境的范式。它不把数据发送到中央服务器,而是把模型送到数据所在的地方进行训练,再只汇聚模型的更新(权重变化)。这就是"数据不动,只有智能在移动"的理念。
本指南将完整覆盖 FL 从理论基础到实战实现的方方面面。
1. 联邦学习基础
1.1 传统中心化学习的问题
设想一个传统的机器学习流水线:从数千家医院收集患者数据,存储到中央服务器,再用这些数据训练诊断模型。这种方式存在什么问题?
数据隐私问题
患者数据、金融交易记录、个人通信内容等都是高度敏感的信息。把这些数据传输到中央服务器会带来以下风险。
- 传输过程中的窃听(eavesdropping)风险
- 服务器被黑导致的大规模数据泄露
- 数据所属机构信任度的丧失
- 违反监管而招致的法律制裁
法律法规
全球范围内的数据隐私监管正在收紧。
- GDPR(General Data Protection Regulation):欧盟的个人信息保护法,规定了数据处理目的的明示、同意要件、数据最小化原则等。
- HIPAA(Health Insurance Portability and Accountability Act):美国的医疗信息保护法,保护患者的健康信息(PHI)。
- 《个人信息保护法》(韩国):韩国的个人信息保护法对个人信息的收集、使用、提供设有严格限制。
通信成本
要把数以百万计的边缘设备(智能手机、IoT 传感器)上的数据传输到中央,需要巨大的网络带宽。图像、音频等大容量数据会让这个问题更加突出。
1.2 联邦学习的核心思想
联邦学习是 2016 年 Google 的 McMahan 等人提出的概念,基于以下原则。
"数据留在本地,只有知识(模型更新)在移动"
FL 的基本流程如下。
- 初始化:中央服务器初始化一个全局模型。
- 分发:服务器把当前的全局模型分发给被选中的客户端。
- 本地训练:每个客户端用自己的本地数据训练模型。
- 上传:客户端把模型更新(梯度或权重差异)发送给服务器。
- 聚合:服务器聚合(如取平均)这些更新,以更新全局模型。
- 重复:重复第 2-5 步,直到收敛。
在这种方式下,原始数据永远不会离开客户端设备,被传输的只有模型参数的更新。
1.3 联邦学习的应用领域
移动 / 边缘设备
Google 把 FL 应用在了 Gboard(移动键盘)上。用户的输入习惯不会被发送到服务器,而是直接在设备上改进下一词预测模型。既能利用数百万用户的数据,个人信息又不会离开设备。
医疗领域
不共享多家医院的患者数据,也能打造更好的诊断 AI。例如在罕见病场景中,单一医院的数据难以支撑模型训练,但 FL 可以整合多家医院的知识。
金融
在欺诈检测、信用评估等场景中,多家金融机构可以在不共享客户数据的前提下协作。尤其是在跨境金融交易中,可以在守住各国数据主权的同时,共同训练模型。
自动驾驶
多家汽车制造商可以在不共享各自行驶数据的前提下,共同改进道路危险状况检测模型。
2. 联邦学习架构
2.1 客户端-服务器结构
最常见的 FL 架构由一个中央聚合服务器(aggregation server)和多个客户端(client)组成。
┌─────────────────┐
│ 中央服务器 │
│ (Aggregator) │
└────────┬────────┘
│ 模型分发 / 更新聚合
┌────────┼────────┐
↓ ↓ ↓
┌─────────┐ ┌─────────┐ ┌─────────┐
│客户端 1 │ │客户端 2 │ │客户端 3 │
│(用本地 │ │(用本地 │ │(用本地 │
│数据训练)│ │数据训练)│ │数据训练)│
└─────────┘ └─────────┘ └─────────┘
服务器的职责
- 维护和管理全局模型
- 选择每一轮参与的客户端
- 聚合客户端更新
- 把聚合后的模型分发给客户端
客户端的职责
- 持有本地数据
- 用本地数据微调从服务器收到的模型
- 发送更新后的模型(或梯度)
2.2 横向联邦学习(Horizontal Federated Learning)
横向 FL 适用于所有客户端拥有相同特征(feature)空间、但持有不同数据样本的场景。例如多家医院测量相同的诊断项目(血压、血糖、年龄等),但各自拥有不同的患者。
客户端 1: [特征1, 特征2, 特征3] x [样本 1~1000]
客户端 2: [特征1, 特征2, 特征3] x [样本 1001~2000]
客户端 3: [特征1, 特征2, 特征3] x [样本 2001~3000]
相同的特征空间,不同的样本空间。这是最常见的 FL 形态。
2.3 纵向联邦学习(Vertical Federated Learning)
纵向 FL 适用于客户端持有相同用户(数据样本)、但特征不同的场景。例如银行拥有用户的金融信息,医院拥有同一用户的医疗信息。
客户端 A(银行): [金融特征] x [用户 1~10000]
客户端 B(医院): [医疗特征] x [用户 1~10000]
相同的样本空间,不同的特征空间。
纵向 FL 需要更复杂的协议。持有标签的客户端与只持有特征的客户端之间的协作,需要用到加密技术。
2.4 联邦迁移学习(Federated Transfer Learning)
当客户端的样本和特征空间部分重叠时,可以结合迁移学习(Transfer Learning)技术。即便数据重叠很少,也能应用 FL。
3. FedAvg 算法
3.1 McMahan 等(2017)的原始算法
FedAvg(Federated Averaging)是 FL 的基础算法,由 2017 年 Google 的 McMahan 等人发表。核心思想是让每个客户端执行多次本地 SGD 更新后,再由服务器对权重取平均。
算法概览
服务器执行:
初始化 w_0
for 轮次 t = 1, 2, ..., T:
m = max(C x K, 1) // C: 参与比例, K: 客户端总数
S_t = 随机选择 m 个客户端
for 每个客户端 k in S_t (并行):
w_{t+1}^k = ClientUpdate(k, w_t)
w_{t+1} = sum (n_k / n) x w_{t+1}^k // 加权平均
客户端 k 执行:
B = 把本地数据分成若干批次
for 本地 epoch e = 1, ..., E:
for 批次 b in B:
w = w - lr x grad_loss(w; b)
return w
核心参数
- C: 每一轮参与的客户端比例(大于 0 且小于等于 1)
- E: 每个客户端的本地 epoch 数
- B: 本地 mini-batch 大小
- lr: 学习率
当 E=1 且 B 为全部数据时,等价于 FedSGD。E 越大,通信次数越少,但客户端漂移(drift)风险越大。
3.2 完整的 FedAvg 实现
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader, Subset
import numpy as np
from copy import deepcopy
from typing import List, Dict, Tuple
import random
# ========== 模型定义 ==========
class SimpleNet(nn.Module):
"""简单的分类网络"""
def __init__(self, input_dim: int, hidden_dim: int, num_classes: int):
super().__init__()
self.net = nn.Sequential(
nn.Linear(input_dim, hidden_dim),
nn.ReLU(),
nn.Dropout(0.2),
nn.Linear(hidden_dim, hidden_dim // 2),
nn.ReLU(),
nn.Linear(hidden_dim // 2, num_classes)
)
def forward(self, x):
return self.net(x)
# ========== 客户端 ==========
class FLClient:
"""联邦学习客户端"""
def __init__(
self,
client_id: int,
dataset,
device: str = 'cpu'
):
self.client_id = client_id
self.dataset = dataset
self.device = device
def local_train(
self,
model: nn.Module,
local_epochs: int,
batch_size: int,
lr: float
) -> Tuple[Dict, float]:
"""
用本地数据训练模型
Returns: (更新后的权重, 本地损失)
"""
model = deepcopy(model).to(self.device)
model.train()
loader = DataLoader(
self.dataset,
batch_size=batch_size,
shuffle=True
)
optimizer = optim.SGD(model.parameters(), lr=lr, momentum=0.9)
criterion = nn.CrossEntropyLoss()
total_loss = 0.0
n_batches = 0
for epoch in range(local_epochs):
for X, y in loader:
X, y = X.to(self.device), y.to(self.device)
optimizer.zero_grad()
output = model(X)
loss = criterion(output, y)
loss.backward()
optimizer.step()
total_loss += loss.item()
n_batches += 1
avg_loss = total_loss / max(n_batches, 1)
return model.state_dict(), avg_loss
def evaluate(self, model: nn.Module) -> Tuple[float, float]:
"""用本地数据评估模型"""
model = deepcopy(model).to(self.device)
model.eval()
loader = DataLoader(self.dataset, batch_size=64)
criterion = nn.CrossEntropyLoss()
total_loss = 0.0
correct = 0
total = 0
with torch.no_grad():
for X, y in loader:
X, y = X.to(self.device), y.to(self.device)
output = model(X)
loss = criterion(output, y)
total_loss += loss.item()
_, predicted = output.max(1)
total += y.size(0)
correct += predicted.eq(y).sum().item()
return total_loss / len(loader), correct / total
# ========== 服务器 ==========
class FedAvgServer:
"""FedAvg 服务器"""
def __init__(
self,
global_model: nn.Module,
clients: List[FLClient],
fraction: float = 0.1, # C
device: str = 'cpu'
):
self.global_model = global_model.to(device)
self.clients = clients
self.fraction = fraction
self.device = device
self.round_history = []
def select_clients(self) -> List[FLClient]:
"""在每一轮选择客户端"""
m = max(int(self.fraction * len(self.clients)), 1)
return random.sample(self.clients, m)
def aggregate(
self,
client_weights: List[Dict],
client_sizes: List[int]
) -> Dict:
"""
用加权平均聚合模型(FedAvg)
按 n_k / n 的比例做加权平均
"""
total_size = sum(client_sizes)
aggregated = {}
for key in client_weights[0].keys():
aggregated[key] = torch.zeros_like(
client_weights[0][key], dtype=torch.float32
)
for w, size in zip(client_weights, client_sizes):
weight = size / total_size
aggregated[key] += weight * w[key].float()
return aggregated
def train_round(
self,
local_epochs: int = 5,
batch_size: int = 32,
lr: float = 0.01
) -> Dict:
"""执行一轮 FL"""
selected = self.select_clients()
client_weights = []
client_sizes = []
client_losses = []
for client in selected:
weights, loss = client.local_train(
self.global_model, local_epochs, batch_size, lr
)
client_weights.append(weights)
client_sizes.append(len(client.dataset))
client_losses.append(loss)
# 聚合
new_weights = self.aggregate(client_weights, client_sizes)
self.global_model.load_state_dict(new_weights)
round_info = {
'num_clients': len(selected),
'avg_local_loss': np.mean(client_losses),
'client_losses': client_losses
}
self.round_history.append(round_info)
return round_info
def evaluate_global(self, test_loader: DataLoader) -> Tuple[float, float]:
"""评估全局模型"""
self.global_model.eval()
criterion = nn.CrossEntropyLoss()
total_loss = 0.0
correct = 0
total = 0
with torch.no_grad():
for X, y in test_loader:
X, y = X.to(self.device), y.to(self.device)
output = self.global_model(X)
loss = criterion(output, y)
total_loss += loss.item()
_, predicted = output.max(1)
total += y.size(0)
correct += predicted.eq(y).sum().item()
return total_loss / len(test_loader), correct / total
def federated_train(
self,
num_rounds: int,
local_epochs: int = 5,
batch_size: int = 32,
lr: float = 0.01,
test_loader: DataLoader = None
):
"""完整的 FL 训练循环"""
print(f"联邦学习开始: {num_rounds} 轮, {len(self.clients)} 个客户端")
for round_num in range(1, num_rounds + 1):
round_info = self.train_round(local_epochs, batch_size, lr)
if test_loader and round_num % 10 == 0:
test_loss, test_acc = self.evaluate_global(test_loader)
print(
f"轮次 {round_num:3d}/{num_rounds} | "
f"客户端: {round_info['num_clients']} | "
f"本地损失: {round_info['avg_local_loss']:.4f} | "
f"测试准确率: {test_acc:.4f}"
)
print("联邦学习完成!")
# ========== 数据分布(Non-IID 模拟) ==========
def create_non_iid_partition(
dataset,
num_clients: int,
num_classes: int,
alpha: float = 0.5
) -> List[List[int]]:
"""
使用 Dirichlet 分布进行 Non-IID 数据划分
alpha 越低,分布越不均衡
"""
labels = np.array([dataset[i][1] for i in range(len(dataset))])
client_indices = [[] for _ in range(num_clients)]
for cls in range(num_classes):
cls_indices = np.where(labels == cls)[0]
np.random.shuffle(cls_indices)
# 用 Dirichlet 分布分配给各客户端
proportions = np.random.dirichlet(
[alpha] * num_clients
)
proportions = (proportions * len(cls_indices)).astype(int)
proportions[-1] = len(cls_indices) - proportions[:-1].sum()
start = 0
for k, prop in enumerate(proportions):
client_indices[k].extend(
cls_indices[start:start + prop].tolist()
)
start += prop
return client_indices
# ========== 主程序 ==========
def run_fedavg_demo():
"""运行 FedAvg 演示"""
import torchvision
import torchvision.transforms as transforms
# 加载 MNIST 数据集
transform = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize((0.1307,), (0.3081,))
])
train_dataset = torchvision.datasets.MNIST(
'./data', train=True, download=True, transform=transform
)
test_dataset = torchvision.datasets.MNIST(
'./data', train=False, transform=transform
)
# Non-IID 数据划分
num_clients = 20
client_indices = create_non_iid_partition(
train_dataset, num_clients, num_classes=10, alpha=0.5
)
# 创建客户端
clients = []
for k in range(num_clients):
subset = Subset(train_dataset, client_indices[k])
clients.append(FLClient(k, subset))
print(f"客户端数量: {num_clients}")
print(f"每个客户端的平均样本数: {np.mean([len(c.dataset) for c in clients]):.1f}")
# 创建全局模型
global_model = SimpleNet(784, 256, 10)
# 创建服务器(选择 10% 的客户端)
server = FedAvgServer(global_model, clients, fraction=0.1)
# 测试数据加载器
test_loader = DataLoader(test_dataset, batch_size=256)
# FL 训练
server.federated_train(
num_rounds=100,
local_epochs=5,
batch_size=32,
lr=0.01,
test_loader=test_loader
)
# 最终评估
_, final_acc = server.evaluate_global(test_loader)
print(f"\n最终测试准确率: {final_acc:.4f}")
if __name__ == '__main__':
run_fedavg_demo()
4. FL 面临的挑战
4.1 数据异质性(Non-IID 问题)
FL 中最大的技术挑战是 Non-IID(Non-Independent and Identically Distributed,非独立同分布) 数据。在真实环境中,每个客户端的数据分布各不相同。
Non-IID 的类型
- 特征分布差异:客户端之间的输入分布不同(例如各地区不同的天气模式)
- 标签分布差异:客户端之间的类别比例不同(不同智能手机用户常用的词汇不同)
- 概念漂移:相同输入对应不同标签(不同地区的文化背景不同)
- 数量不均衡:客户端之间的数据量差异极大
在 Non-IID 数据上使用 FedAvg 会引发 客户端漂移(client drift) 问题。当每个客户端的本地最优点偏离全局最优点时,聚合就会失效。
4.2 系统异质性
在真实的 FL 系统中,设备性能、电池状态、网络连接状况千差万别。
- 计算异质性:配备 GPU 的服务器与低配移动设备同时参与
- 网络异质性:高速有线网络与不稳定的移动网络并存
- 内存异质性:部分客户端可能无法加载完整模型
4.3 掉队者(Straggler)问题
如果部分客户端速度很慢或没有响应,整个训练就会被拖慢。在 同步 FL(Synchronous FL) 中,必须等待所有被选中的客户端都返回更新。应对方案包括:
- 异步 FL(Asynchronous FL):立即聚合已响应客户端的更新
- 设置超时:只使用在规定时间内响应的客户端
- FedProx:在本地更新中加入近端项,以容忍掉队者
5. 进阶 FL 算法
5.1 FedProx:解决 Non-IID 问题
FedProx 是 Li 等人于 2020 年提出的算法,在本地优化中加入了 近端项(proximal term)。这一项能限制本地模型不会偏离全局模型太远。
FedProx 目标函数
在本地目标函数中加入近端项。
h_k(w; w^t) = F_k(w) + (mu/2) x ||w - w^t||^2
其中 mu 是控制近端项强度的超参数。当 mu=0 时,等价于 FedAvg。
class FedProxClient(FLClient):
"""FedProx 客户端:添加近端项"""
def local_train_prox(
self,
model: nn.Module,
global_weights: Dict,
local_epochs: int,
batch_size: int,
lr: float,
mu: float = 0.01 # 近端项权重
) -> Tuple[Dict, float]:
"""
包含近端项的本地训练
h_k(w) = F_k(w) + (mu/2) * ||w - w^t||^2
"""
model = deepcopy(model).to(self.device)
model.train()
# 把全局权重保存为参照点
global_model = deepcopy(model)
global_model.load_state_dict(global_weights)
for param in global_model.parameters():
param.requires_grad = False
loader = DataLoader(
self.dataset,
batch_size=batch_size,
shuffle=True
)
optimizer = optim.SGD(model.parameters(), lr=lr)
criterion = nn.CrossEntropyLoss()
total_loss = 0.0
n_batches = 0
for epoch in range(local_epochs):
for X, y in loader:
X, y = X.to(self.device), y.to(self.device)
optimizer.zero_grad()
output = model(X)
task_loss = criterion(output, y)
# 近端项: (mu/2) * ||w - w_global||^2
prox_loss = 0.0
for w, w_global in zip(
model.parameters(),
global_model.parameters()
):
prox_loss += (mu / 2) * torch.norm(w - w_global) ** 2
total_batch_loss = task_loss + prox_loss
total_batch_loss.backward()
optimizer.step()
total_loss += task_loss.item()
n_batches += 1
avg_loss = total_loss / max(n_batches, 1)
return model.state_dict(), avg_loss
5.2 SCAFFOLD:修正客户端漂移
SCAFFOLD(Stochastic Controlled Averaging for Federated Learning)通过控制变量(control variates)直接修正客户端漂移。每个客户端和服务器都维护控制变量 c_k 和 c,用于校正梯度偏差。
class ScaffoldClient:
"""SCAFFOLD 客户端"""
def __init__(self, client_id, dataset, device='cpu'):
self.client_id = client_id
self.dataset = dataset
self.device = device
# 客户端控制变量初始化
self.c_k = None
def init_control_variate(self, model: nn.Module):
"""初始化控制变量"""
self.c_k = {
name: torch.zeros_like(param)
for name, param in model.named_parameters()
}
def local_train_scaffold(
self,
model: nn.Module,
server_control: Dict,
local_epochs: int,
batch_size: int,
lr: float
) -> Tuple[Dict, Dict, float]:
"""
SCAFFOLD 本地训练
Returns: (更新后的权重, 控制变量更新, 损失)
"""
if self.c_k is None:
self.init_control_variate(model)
model = deepcopy(model).to(self.device)
model.train()
initial_weights = deepcopy(model.state_dict())
loader = DataLoader(self.dataset, batch_size=batch_size, shuffle=True)
criterion = nn.CrossEntropyLoss()
total_loss = 0.0
n_batches = 0
total_steps = local_epochs * len(loader)
for epoch in range(local_epochs):
for X, y in loader:
X, y = X.to(self.device), y.to(self.device)
# 手动更新参数(包含 SCAFFOLD 校正)
output = model(X)
loss = criterion(output, y)
loss.backward()
with torch.no_grad():
for name, param in model.named_parameters():
if param.grad is not None:
# SCAFFOLD 校正: g - c_k + c
correction = (
self.c_k[name].to(self.device)
- server_control[name].to(self.device)
)
param -= lr * (param.grad + correction)
param.grad.zero_()
total_loss += loss.item()
n_batches += 1
final_weights = model.state_dict()
# 更新控制变量
# c_k+ = c_k - c + (1 / (K * lr)) * (w_0 - w_K)
new_c_k = {}
c_k_diff = {}
for name in self.c_k:
w_diff = (
initial_weights[name].float() - final_weights[name].float()
)
new_c_k[name] = (
self.c_k[name]
- server_control[name]
+ w_diff / (total_steps * lr)
)
c_k_diff[name] = new_c_k[name] - self.c_k[name]
self.c_k = new_c_k
avg_loss = total_loss / max(n_batches, 1)
return final_weights, c_k_diff, avg_loss
6. 差分隐私(Differential Privacy)
6.1 DP 的数学定义
差分隐私(DP)是保护个人信息的数学框架。直观地说,它保证"即使增加或删除一个数据点,输出的分布也不会发生显著变化"。
Epsilon-Delta DP 定义
如果一个随机化机制 M 满足以下条件,就称其满足(epsilon,delta)-DP。
对所有相邻数据集 D、D' 及输出集合 S:
Pr[M(D) ∈ S] ≤ exp(ε) x Pr[M(D') ∈ S] + δ
- ε(epsilon):隐私预算。值越低,隐私保证越强
- δ(delta):失败概率。通常设为 1/|数据集| 以下
6.2 高斯机制与裁剪
要在 FL 中应用 DP,需要在每个客户端的更新中加入噪声。
梯度裁剪:先把梯度的 L2 范数裁剪到最大值 C。
g_clipped = g x min(1, C / ||g||_2)
添加噪声:给裁剪后的梯度加入高斯噪声。
g_dp = g_clipped + N(0, σ^2 x C^2 x I)
其中 σ 是噪声倍率(noise multiplier)。
6.3 DP-FL 实现
import torch
import torch.nn as nn
from opacus import PrivacyEngine
from opacus.validators import ModuleValidator
def make_private_model(model: nn.Module) -> nn.Module:
"""转换为 Opacus 兼容模型(BatchNorm -> GroupNorm)"""
model = ModuleValidator.fix(model)
return model
class DPFLClient:
"""应用了差分隐私的 FL 客户端"""
def __init__(
self,
client_id: int,
dataset,
target_epsilon: float = 1.0, # 隐私预算
target_delta: float = 1e-5, # 失败概率
max_grad_norm: float = 1.0, # 梯度裁剪阈值
device: str = 'cpu'
):
self.client_id = client_id
self.dataset = dataset
self.target_epsilon = target_epsilon
self.target_delta = target_delta
self.max_grad_norm = max_grad_norm
self.device = device
def dp_local_train(
self,
model: nn.Module,
local_epochs: int,
batch_size: int,
lr: float
) -> Tuple[Dict, float, float]:
"""
差分隐私本地训练
Returns: (权重, 损失, 消耗的 epsilon)
"""
model = make_private_model(deepcopy(model)).to(self.device)
model.train()
loader = DataLoader(
self.dataset,
batch_size=batch_size,
shuffle=True,
drop_last=True # Opacus 的要求
)
optimizer = optim.SGD(model.parameters(), lr=lr)
criterion = nn.CrossEntropyLoss()
# 挂载 Opacus PrivacyEngine
privacy_engine = PrivacyEngine()
model, optimizer, loader = privacy_engine.make_private_with_epsilon(
module=model,
optimizer=optimizer,
data_loader=loader,
epochs=local_epochs,
target_epsilon=self.target_epsilon,
target_delta=self.target_delta,
max_grad_norm=self.max_grad_norm
)
total_loss = 0.0
n_batches = 0
for epoch in range(local_epochs):
for X, y in loader:
X, y = X.to(self.device), y.to(self.device)
optimizer.zero_grad()
output = model(X)
loss = criterion(output, y)
loss.backward()
optimizer.step()
total_loss += loss.item()
n_batches += 1
epsilon_used = privacy_engine.get_epsilon(self.target_delta)
avg_loss = total_loss / max(n_batches, 1)
# 去除 Opacus 包装,返回纯净权重
clean_weights = {
k.replace('_module.', ''): v
for k, v in model.state_dict().items()
}
return clean_weights, avg_loss, epsilon_used
class DPFLServer:
"""DP-FL 服务器: 服务器端 DP 聚合"""
def __init__(
self,
global_model: nn.Module,
clients: List['DPFLClient'],
noise_multiplier: float = 0.5,
max_grad_norm: float = 1.0,
device: str = 'cpu'
):
self.global_model = global_model.to(device)
self.clients = clients
self.noise_multiplier = noise_multiplier
self.max_grad_norm = max_grad_norm
self.device = device
def clip_and_aggregate(
self,
client_weights: List[Dict],
reference_weights: Dict
) -> Dict:
"""
服务器端裁剪与加噪
先裁剪每个更新,再加入高斯噪声
"""
n_clients = len(client_weights)
# 计算更新量(delta_w = w_k - w_global)
updates = []
for w in client_weights:
delta = {
k: w[k].float() - reference_weights[k].float()
for k in reference_weights
}
updates.append(delta)
# L2 范数裁剪
clipped_updates = []
for delta in updates:
total_norm = torch.sqrt(
sum(torch.norm(v) ** 2 for v in delta.values())
)
clip_factor = min(1.0, self.max_grad_norm / (total_norm + 1e-8))
clipped = {k: v * clip_factor for k, v in delta.items()}
clipped_updates.append(clipped)
# 求和
summed = {}
for key in reference_weights:
summed[key] = sum(u[key] for u in clipped_updates)
# 加入高斯噪声
sigma = self.noise_multiplier * self.max_grad_norm
noisy = {}
for key in summed:
noise = torch.randn_like(summed[key]) * sigma
noisy[key] = (summed[key] + noise) / n_clients
# 应用到全局模型
aggregated = {
k: reference_weights[k].float() + noisy[k]
for k in reference_weights
}
return aggregated
7. 安全聚合(Secure Aggregation)
7.1 加密聚合的必要性
DP 能提供统计学意义上的隐私保证,但服务器依然能单独看到每个客户端的更新。安全聚合(SecAgg)是一种加密协议,能让服务器 只看到聚合后的结果。
7.2 基于秘密分享(Secret Sharing)的 SecAgg
Bonawitz 等人(2017)提出的 Secure Aggregation 协议使用了以下原理。
- 客户端之间互相交换掩码(一对随机数)。
- 每个客户端把掩码加到自己的更新上后再发送给服务器。
- 服务器求和后,掩码相互抵消,得到纯粹的求和更新。
import secrets
import hashlib
class SecureAggregation:
"""
简化版的安全聚合模拟
实际实现会使用 Diffie-Hellman 密钥交换
"""
def __init__(self, num_clients: int, seed_length: int = 32):
self.num_clients = num_clients
self.seed_length = seed_length
def generate_pairwise_masks(
self,
client_id: int,
model_shape: Dict[str, torch.Size],
shared_seeds: Dict
) -> Dict:
"""
生成客户端两两之间的掩码
在 client i 与 j 之间: i>j 则相加, i<j 则相减
"""
mask = {k: torch.zeros(v) for k, v in model_shape.items()}
for other_id, seed in shared_seeds.items():
# 用共享种子生成伪随机掩码
torch.manual_seed(seed)
direction = 1 if client_id > other_id else -1
for key in mask:
mask[key] += direction * torch.randn(model_shape[key])
return mask
def client_mask_update(
self,
local_update: Dict,
mask: Dict
) -> Dict:
"""把掩码应用到更新上"""
return {k: local_update[k] + mask[k] for k in local_update}
def server_aggregate_masked(
self,
masked_updates: List[Dict]
) -> Dict:
"""对带掩码的更新求和(掩码会相互抵消)"""
aggregated = {}
for key in masked_updates[0]:
aggregated[key] = sum(u[key] for u in masked_updates)
aggregated[key] /= len(masked_updates)
return aggregated
7.3 同态加密(Homomorphic Encryption)概述
同态加密(HE)是一种能在加密状态下直接进行运算的密码体系。在 FL 中使用 HE,服务器无需解密客户端更新即可完成聚合。
部分同态加密(PHE):只支持加法或乘法中的一种(Paillier 加密) 完全同态加密(FHE):支持任意运算(CKKS、BGV) — 计算成本极高
在实用的 FL 场景中,即便只支持加法的 PHE,也足以实现聚合(加权平均)。
8. Flower 框架
8.1 Flower 简介
Flower(flwr)是一个用于联邦学习的 Python 框架,与具体的 ML 框架无关。可以与 PyTorch、TensorFlow、JAX 等多种 ML 框架配合使用。
pip install flwr
pip install flwr[simulation]
8.2 Flower 客户端实现
import flwr as fl
import torch
import torch.nn as nn
from torch.utils.data import DataLoader
from typing import Dict, List, Tuple
import numpy as np
class FlowerClient(fl.client.NumPyClient):
"""Flower FL 客户端"""
def __init__(
self,
model: nn.Module,
train_loader: DataLoader,
val_loader: DataLoader,
device: str = 'cpu'
):
self.model = model.to(device)
self.train_loader = train_loader
self.val_loader = val_loader
self.device = device
self.criterion = nn.CrossEntropyLoss()
def get_parameters(self, config: Dict) -> List[np.ndarray]:
"""把当前模型参数以 NumPy 数组形式返回"""
return [
val.cpu().numpy()
for _, val in self.model.state_dict().items()
]
def set_parameters(self, parameters: List[np.ndarray]):
"""用 NumPy 数组设置模型参数"""
params_dict = zip(self.model.state_dict().keys(), parameters)
state_dict = {
k: torch.tensor(v)
for k, v in params_dict
}
self.model.load_state_dict(state_dict, strict=True)
def fit(
self,
parameters: List[np.ndarray],
config: Dict
) -> Tuple[List[np.ndarray], int, Dict]:
"""用服务器发来的参数进行本地训练"""
# 设置全局参数
self.set_parameters(parameters)
# 解析配置参数
local_epochs = int(config.get('local_epochs', 1))
lr = float(config.get('lr', 0.01))
# 本地训练
self.model.train()
optimizer = torch.optim.SGD(
self.model.parameters(), lr=lr, momentum=0.9
)
train_loss = 0.0
for epoch in range(local_epochs):
for X, y in self.train_loader:
X, y = X.to(self.device), y.to(self.device)
optimizer.zero_grad()
loss = self.criterion(self.model(X), y)
loss.backward()
optimizer.step()
train_loss += loss.item()
return (
self.get_parameters(config={}),
len(self.train_loader.dataset),
{'train_loss': train_loss}
)
def evaluate(
self,
parameters: List[np.ndarray],
config: Dict
) -> Tuple[float, int, Dict]:
"""用服务器发来的参数进行评估"""
self.set_parameters(parameters)
self.model.eval()
val_loss = 0.0
correct = 0
total = 0
with torch.no_grad():
for X, y in self.val_loader:
X, y = X.to(self.device), y.to(self.device)
output = self.model(X)
val_loss += self.criterion(output, y).item()
_, predicted = output.max(1)
total += y.size(0)
correct += predicted.eq(y).sum().item()
accuracy = correct / total
return (
val_loss / len(self.val_loader),
len(self.val_loader.dataset),
{'accuracy': accuracy}
)
8.3 Flower 服务器策略实现
from flwr.server.strategy import FedAvg
from flwr.common import Parameters, FitIns, FitRes, EvaluateIns, EvaluateRes
from flwr.server.client_proxy import ClientProxy
class CustomFedAvgStrategy(FedAvg):
"""自定义 FedAvg 策略"""
def __init__(
self,
fraction_fit: float = 0.1,
fraction_evaluate: float = 0.1,
min_fit_clients: int = 2,
min_evaluate_clients: int = 2,
min_available_clients: int = 2,
initial_parameters=None,
):
super().__init__(
fraction_fit=fraction_fit,
fraction_evaluate=fraction_evaluate,
min_fit_clients=min_fit_clients,
min_evaluate_clients=min_evaluate_clients,
min_available_clients=min_available_clients,
initial_parameters=initial_parameters,
)
self.round_metrics = []
def configure_fit(
self,
server_round: int,
parameters: Parameters,
client_manager
) -> List[Tuple[ClientProxy, FitIns]]:
"""配置每一轮的训练"""
# 轮次越多,学习率越低
lr = 0.01 * (0.99 ** server_round)
config = {
'local_epochs': 5,
'lr': lr,
'round': server_round,
}
fit_ins = FitIns(parameters, config)
clients = client_manager.sample(
num_clients=max(
int(client_manager.num_available() * self.fraction_fit), 1
),
min_num_clients=self.min_fit_clients
)
return [(client, fit_ins) for client in clients]
def aggregate_evaluate(
self,
server_round: int,
results: List[Tuple[ClientProxy, EvaluateRes]],
failures
):
"""聚合并记录评估结果"""
aggregated_loss, metrics = super().aggregate_evaluate(
server_round, results, failures
)
if metrics:
print(f"轮次 {server_round}: 聚合准确率 = {metrics.get('accuracy', 0):.4f}")
self.round_metrics.append({
'round': server_round,
'loss': aggregated_loss,
'metrics': metrics
})
return aggregated_loss, metrics
def run_flower_simulation():
"""运行 Flower 模拟"""
import torchvision
import torchvision.transforms as transforms
from torch.utils.data import Subset
# 准备数据
transform = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize((0.1307,), (0.3081,))
])
train_dataset = torchvision.datasets.MNIST(
'./data', train=True, download=True, transform=transform
)
test_dataset = torchvision.datasets.MNIST(
'./data', train=False, transform=transform
)
num_clients = 10
def client_fn(cid: str):
"""客户端创建函数"""
cid = int(cid)
# 数据划分
n = len(train_dataset) // num_clients
start = cid * n
indices = list(range(start, min(start + n, len(train_dataset))))
train_subset = Subset(train_dataset, indices)
val_indices = list(range(cid * 100, (cid + 1) * 100))
val_subset = Subset(test_dataset, val_indices)
model = SimpleNet(784, 256, 10)
return FlowerClient(
model=model,
train_loader=DataLoader(train_subset, batch_size=32),
val_loader=DataLoader(val_subset, batch_size=64)
).to_client()
# 初始化全局模型参数
global_model = SimpleNet(784, 256, 10)
initial_params = fl.common.ndarrays_to_parameters(
[val.numpy() for val in global_model.state_dict().values()]
)
# 配置策略
strategy = CustomFedAvgStrategy(
fraction_fit=0.5,
fraction_evaluate=0.5,
min_fit_clients=2,
min_evaluate_clients=2,
min_available_clients=num_clients,
initial_parameters=initial_params
)
# 运行模拟
history = fl.simulation.start_simulation(
client_fn=client_fn,
num_clients=num_clients,
config=fl.server.ServerConfig(num_rounds=50),
strategy=strategy,
)
print("\n=== Flower 模拟完成 ===")
print(f"最终分布式损失: {history.losses_distributed[-1]}")
9. 实战 FL 项目:医院联邦学习
9.1 多医院胸部 X 光诊断
多家医院在不共享胸部 X 光数据的前提下,共同训练肺部疾病诊断模型的场景。
import torch
import torch.nn as nn
import torchvision.models as models
from torch.utils.data import Dataset, DataLoader
from PIL import Image
import os
import pandas as pd
class ChestXRayDataset(Dataset):
"""胸部 X 光数据集(按医院划分)"""
def __init__(
self,
data_dir: str,
labels_file: str,
transform=None,
hospital_id: int = 0
):
self.data_dir = data_dir
self.labels = pd.read_csv(labels_file)
self.transform = transform
self.hospital_id = hospital_id
# 只筛选该医院的数据
self.labels = self.labels[
self.labels['hospital_id'] == hospital_id
].reset_index(drop=True)
self.classes = [
'Normal', 'Pneumonia', 'COVID-19', 'Tuberculosis'
]
def __len__(self):
return len(self.labels)
def __getitem__(self, idx):
img_path = os.path.join(
self.data_dir, self.labels.iloc[idx]['filename']
)
image = Image.open(img_path).convert('RGB')
label = self.labels.iloc[idx]['label']
if self.transform:
image = self.transform(image)
return image, label
def build_chest_model(num_classes: int = 4, pretrained: bool = True):
"""基于 ResNet50 的胸部 X 光分类模型"""
model = models.resnet50(pretrained=pretrained)
# 替换最后一层
in_features = model.fc.in_features
model.fc = nn.Sequential(
nn.Dropout(0.3),
nn.Linear(in_features, 256),
nn.ReLU(),
nn.Linear(256, num_classes)
)
return model
class HospitalFLClient(FlowerClient):
"""面向医院的 FL 客户端(处理类别不均衡)"""
def __init__(
self,
hospital_id: int,
model: nn.Module,
train_loader: DataLoader,
val_loader: DataLoader,
class_weights: torch.Tensor = None,
device: str = 'cpu'
):
super().__init__(model, train_loader, val_loader, device)
self.hospital_id = hospital_id
if class_weights is not None:
self.criterion = nn.CrossEntropyLoss(
weight=class_weights.to(device)
)
else:
self.criterion = nn.CrossEntropyLoss()
def compute_class_weights(self, dataset) -> torch.Tensor:
"""计算用于校正类别不均衡的权重"""
labels = [dataset[i][1] for i in range(len(dataset))]
class_counts = torch.bincount(torch.tensor(labels))
weights = 1.0 / class_counts.float()
weights = weights / weights.sum()
return weights
def setup_hospital_federation(num_hospitals: int = 5):
"""
配置多医院联邦学习
实际场景中,每家医院的数据都保存在本地
"""
import torchvision.transforms as transforms
# 数据预处理
train_transform = transforms.Compose([
transforms.Resize((224, 224)),
transforms.RandomHorizontalFlip(),
transforms.RandomRotation(10),
transforms.ColorJitter(brightness=0.2, contrast=0.2),
transforms.ToTensor(),
transforms.Normalize(
mean=[0.485, 0.456, 0.406],
std=[0.229, 0.224, 0.225]
)
])
val_transform = transforms.Compose([
transforms.Resize((224, 224)),
transforms.ToTensor(),
transforms.Normalize(
mean=[0.485, 0.456, 0.406],
std=[0.229, 0.224, 0.225]
)
])
hospitals = []
for h_id in range(num_hospitals):
# 实际场景中会在各医院自己的服务器上运行
# 这里为了模拟,统一放在同一台服务器上运行
print(f"正在准备医院 {h_id} 的数据...")
# 生成占位数据(实际实现时应替换为真实数据)
# 这里只展示示例结构
return hospitals
# 联邦学习训练循环
def train_hospital_federation():
"""运行医院联邦学习"""
print("=== 多医院联邦学习开始 ===")
print("各医院的患者数据不会被发送到外部。")
print("只会聚合模型参数的更新。")
global_model = build_chest_model(num_classes=4)
print(f"\n全局模型参数数量: {sum(p.numel() for p in global_model.parameters()):,}")
print(f"通信成本(FP32): {sum(p.numel() for p in global_model.parameters()) * 4 / 1024 / 1024:.1f} MB/轮")
10. 结语:联邦学习的未来
联邦学习正在成为化解 AI 与隐私之间冲突的核心技术。尤其值得关注以下趋势。
跨设备(Cross-Device) 与跨机构(Cross-Silo)
- 跨设备:数以百万计的移动设备参与其中(Google 的 Gboard)
- 跨机构:少数机构(医院、银行)参与 — 更值得信赖,但规模较小
FL + LLM
大语言模型(LLM)的出现让 FL 变得更加重要。在用用户的对话内容微调模型时,使用 FL 可以确保对话内容不会被发送到服务器。结合参数高效微调(PEFT、LoRA),还能进一步降低通信成本。
监管友好型 AI
随着 GDPR、HIPAA 等监管趋严,FL 正在成为满足合规要求的实用方案。预计 FL 在医疗、金融、法律领域的应用会迅速扩大。
参考资料
- McMahan, H. B., et al. (2017). Communication-Efficient Learning of Deep Networks from Decentralized Data. AISTATS 2017.
- Li, T., et al. (2020). Federated Optimization in Heterogeneous Networks (FedProx). MLSys 2020.
- Karimireddy, S. P., et al. (2020). SCAFFOLD: Stochastic Controlled Averaging for Federated Learning. ICML 2020.
- Bonawitz, K., et al. (2017). Practical Secure Aggregation for Privacy-Preserving Machine Learning. CCS 2017.
- Flower Framework: https://flower.ai/docs/
- Opacus (PyTorch DP): https://opacus.ai/