【阅读笔记】联邦学习实战——联邦学习攻防实战

前言

FATE是微众银行开发的联邦学习平台,是全球首个工业级的联邦学习开源框架,在github上拥有近4000stars,可谓是相当有名气的,该平台为联邦学习提供了完整的生态和社区支持,为联邦学习初学者提供了很好的环境,否则利用python从零开发,那将会是一件非常痛苦的事情。本篇博客内容涉及《联邦学习实战》第十五章内容,使用的fate版本为1.6.0,fate的安装已经在这篇博客中介绍,有需要的朋友可以点击查阅。本章内容主要涉及联邦学习在训练过程中所遇到的网络安全问题,联邦学习因其设备间的独立性、数据间的异构性、数据分布的不平衡和安全隐私设计等特点,更容易受到对抗攻击的影响。与集中式的模型训练相比,FL场景防御更为困难。


1. 后门攻击

1.1 问题定义

攻击者意图让模型对具有某种特定的特征的数据做出错误的判断,但模型不会对主任务产生影响。本节讨论一种在横向联邦学习场景下的后门攻击行为,如下图所示:

在这里插入图片描述

在上图展示的场景中有m个客户端,记为 { C i } i = 1 m \left \{C_i\right \}^m_{i=1} {Ci}i=1m,假设有客户端 C m C_m Cm被攻击者挟持,即我们通常所说的恶意客户端,其他客户端都正常,所有客户端都包含本地数据 D c l n i D ^i_{cln} Dclni。对于恶意客户端 C m C_m Cm,除了包含正常数据 D c l n m D ^m_{cln} Dclnm,还包含被嵌入后门的篡改数据集 D a d v m D ^m_{adv} Dadvm
比如具有比较明显红色特征的小车,攻击者意图让带有红色的小车被标识为小鸟。攻击者会先通过挟持用户的客户端标签,将带有红色小车标注为小鸟,让模型重新开始训练。这样训练得到的最终模型在推断的时候,会将带有红色的小车判断为小鸟,但不会影响对其他图片的判断。
在这里插入图片描述

后门攻击的策略有很多种,这里介绍文献How To Backdoor Federated Learning提出的模型替换攻击策略,该策略在多个公开的数据集中都取得了不错的攻击效果。

1.2 后门攻击策略

带有后门攻击的联邦学习,其客户端可以分为恶意客户端和正常客户端,不同类型的客户端,其本地训练策略不同。正常客户端训练策略如下:
在这里插入图片描述
对于恶意客户端的本地训练,与普通客户端不同体现在两个方面:损失函数的设计和上传服务器端的模型权重。
首先分析损失函数的设计。恶意客户端在训练时,一方面保证模型训练后在毒化数据集和正常数据集上都能取得好的效果,另一方面要保证当前训练的本地模型不会过于偏离全局模型,具体来说,其损失函数主要由下面两部分构成。

  • 类别损失:恶意客户端既拥有正常的数据集 D c l n m D_{cln}^m Dclnm,也含有被篡改毒化的数据集 D a d v m D_{adv}^m Dadvm,因此训练的目标,一方面确保主任务性能不下降,另一方面保证模型在毒化数据上做出错误的判断。我们将这一部分损失值称为类别损失 L c l a s s _ l o s s L _{class\_loss} Lclass_loss,其计算公式如下所示: L c l a s s _ l o s s = L c l a s s _ l o s s _ c l n + L c l a s s _ l o s s _ a d v L _{class\_loss}=L _{class\_loss\_cln}+L _{class\_loss\_adv} Lclass_loss=Lclass_loss_cln+Lclass_loss_adv
  • 距离损失:在How To Backdoor Federated Learning中,如果仅用上式的损失还书对恶意客户端进行训练,那么服务器可以通过观察模型距离等异常检测的方法,判断上传的客户端模型是否为异常模型,如计算两个模型之间的欧氏距离。为此我们修改异常客户端的损失函数,在上式基础上添加当前模型与全局模型的距离损失。我们将两个模型的距离定义为它们对应参数的欧氏距离。修改后的损失函数定义为: L = L c l a s s _ l o s s + L d i s t a n c e _ l o s s L=L _{class\_loss}+L _{distance\_loss} L=Lclass_loss+Ldistance_loss

总结上述描述,恶意客户端的目标,一方面保证在正常数据集和毒化数据集上模型性能表现好,另一方面保证本地训练与全局模型之间的距离尽量小。
接下来分析恶意客户端模型的权重。前面提及在联邦学习场景进行后门攻击比较困难,其中一个原因是模型聚合运算时,平均化后会很大程度消除恶意客户端模型的影响。另外由于服务端的选择机制,我们并不能保证被挟持的客户端能够在每一轮中被选中,而这进一步降低了后门攻击的风险。
为了有效解决这个问题,先来回顾传统的联邦学习聚合过程。假设当前在进行第t轮的模型聚合, G t G^t Gt表示第t轮后的全局模型, L i t + 1 L^{t+1}_i Lit+1表示第t+1轮后客户端 C i C_i Ci的最新本地模型。此时可以列出模型聚合公式:
G t + 1 = G t + η n ∑ i = 1 m ( L i t + 1 − G t ) G^{t+1}=G^t+\frac{\eta}{n}\sum_{i=1}^{m}(L^{t+1}_i-G^t) Gt+1=Gt+nηi=1m(Lit+1Gt)

对于被毒化的客户端,其最理想的模型是X,在理想情况下,我们期望聚合后的结果就是模型X,也就是等价于只有恶意参与方参与,这样上式就可以改写成:
X = G t + η n ∑ i = 1 m ( L i t + 1 − G t ) X=G^t+\frac{\eta}{n}\sum_{i=1}^{m}(L^{t+1}_i-G^t) X=Gt+nηi=1m(Lit+1Gt)

其中对于正常的客户端 C i , i = 1 , 2 , . . . , m − 1 C_i,i=1,2,...,m-1 Ci,i=1,2,...,m1,当模型接近于收敛时,等式:
∑ i = 1 m − 1 ( L i t + 1 − G t ) ≈ 0 \sum_{i=1}^{m-1}(L^{t+1}_i-G^t)\approx0 i=1m1(Lit+1Gt)0

成立。因此,我们可以重新修改上上式,使得恶意客户端 C m C_m Cm提交的本地模型 L m t + 1 L^{t+1}_m Lmt+1满足:
L m t + 1 = n η X − ( n η − 1 ) G t − ∑ i = 1 m − 1 ( L i t + 1 − G t ) L^{t+1}_m=\frac{n}{\eta}X-(\frac{n}{\eta}-1)G^t-\sum_{i=1}^{m-1}(L^{t+1}_i-G^t) Lmt+1=ηnX(ηn1)Gti=1m1(Lit+1Gt)

将上上式带入到上式中,得到:
L m t + 1 ≈ n η ( X − G t ) + G t L^{t+1}_m\approx\frac{n}{\eta}(X-G^t)+G^t Lmt+1ηn(XGt)+Gt

上式表明,当恶意参与方上传模型是 L m t + 1 L^{t+1}_m Lmt+1时,攻击成功率将有明显提升,观察可以发现,通常n值要远大于 η \eta η,该式本质上通过增大异常客户端m的模型权重,使其在后面的聚合过程中,对全局模型的影响和贡献尽量持久。

恶意客户端算法如下所示:
在这里插入图片描述

1.3 详细实现

本节实现将复用第三章的代码框架,利用ResNet-18模型,对带有后门攻击、修改的cifar10数据集进行分类。代码可以在对应的github目录中找到。
首先模拟恶意客户端篡改数据,将具有特定特征的数据判定为特定类型。一般的方法是直接从数据集中挑选特定的数据更改其标签。本节采取另外一种引入后门方式,即在图像中植入特征方式篡改数据。

import matplotlib.pyplot as plt
import torch, copy
import numpy as np
from torchvision import datasets, transforms

# 获取cifar数据集
def get_dataset(dir):

    transform_train = transforms.Compose([
        transforms.RandomCrop(32, padding=4),
        transforms.RandomHorizontalFlip(),
        transforms.ToTensor(),
        transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2023, 0.1994, 0.2010)),
    ])

    transform_test = transforms.Compose([
        transforms.ToTensor(),
        transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2023, 0.1994, 0.2010)),
    ])

    train_dataset = datasets.CIFAR10(dir, train=True, download=True,
                                    transform=transform_train)
    eval_dataset = datasets.CIFAR10(dir, train=False, transform=transform_test)

    return train_dataset, eval_dataset
   
# 获取参与方id=0的数据集
dir = "./data/"
id = 0
train_dataset, eval_dataset = get_dataset(dir)
all_range = list(range(len(train_dataset)))
data_len = int(len(train_dataset) / 10)
train_indices = all_range[id * data_len: (id + 1) * data_len]
train_load = torch.utils.data.DataLoader(train_dataset, batch_size=32, sampler=torch.utils.data.sampler.SubsetRandomSampler(train_indices))
# 初始化标记的范围
pos = []
for i in range(2, 28):
    pos.append([i, 3])
    pos.append([i, 4])
    pos.append([i, 5])
for batch_id, batch in enumerate(train_load):
    images, target = batch
    img = images[0].numpy()
    new_img = copy.deepcopy(img)
    img = np.transpose(img, (1,2,0))
    for i in range(0, len(pos)):
        new_img[0][pos[i][0]][pos[i][1]] = 1.0
        new_img[1][pos[i][0]][pos[i][1]] = 0
        new_img[2][pos[i][0]][pos[i][1]] = 0
    new_img = np.transpose(new_img, (1,2,0))
plt.imshow(new_img) 

效果如下图所示:
在这里插入图片描述

  • 配置信息:模拟被毒化的样本数据之后,需要在配置文件中添加必要的字段来帮助我们完成训练。
{
	
	"model_name" : "resnet18", # 使用模型
	
	"no_models" : 10, # 参与方个数
	
	"type" : "cifar", # 数据集种类
	
	"global_epochs" : 20, # 全局迭代次数
	
	"local_epochs" : 3, # 本地迭代次数
	
	"k" : 3, # 每次随机选取3个参与方
	
	"batch_size" : 32, # 批大小
	
	"lr" : 0.001, # 学习率
	
	"momentum" : 0.0001, # momentum参数
	
	"lambda" : 0.3, # 正则化参数
	
	"eta" : 2, # 恶意客户端权重

	"alpha" : 1.0, # class_loss 和 dist_loss 之间的权重比例
	
	"poison_label" : 2, # 约定将被毒化的数据归为哪一类
	
	"poisoning_per_batch" : 4 # 当恶意客户端在本地训练时,有多少数据是被篡改的
}
  • 服务端:使用经典的FedAvg算法。事实上,针对后门攻击,有许多改进的算法如RFA,FoolsGold和FedProx等,具有更好的对抗后门攻击能力。
  • 客户端:训练代码改动都在客户端侧,对于正常的客户端,不需要改动代码,和第三章一样。在恶意客户端训练中,损失函数由分类损失和距离损失组成。其中距离损失用于衡量两个同构模型之间的距离。为此我们先添加两个模型的距离函数,如下所示:
def model_norm(model_1, model_2):
	squared_sum = 0
	for name, layer in model_1.named_parameters():
		squared_sum += torch.sum(torch.pow(layer.data - model_2.state_dict()[name].data, 2))
	return math.sqrt(squared_sum)

在客户端的本地训练中,我们添加一个函数用于恶意客户端的训练。参考算法5给出如下代码实现,主要改动在损失函数的构建和返回值上。

def local_train_malicious(self, model):

	for name, param in model.state_dict().items():
		self.local_model.state_dict()[name].copy_(param.clone())
  # 设置优化函数器
	optimizer = torch.optim.SGD(self.local_model.parameters(), lr=self.conf['lr'],
								momentum=self.conf['momentum'])
	# 设置毒化数据样式							
	pos = []
	for i in range(2, 28):
		pos.append([i, 3])
		pos.append([i, 4])
		pos.append([i, 5])
		
	self.local_model.train()
	for e in range(self.conf["local_epochs"]):
		
		for batch_id, batch in enumerate(self.train_loader):
			data, target = batch
			# 在线修改数据,模拟被攻击场景
			for k in range(self.conf["poisoning_per_batch"]):
				img = data[k].numpy()
				for i in range(0,len(pos)):
					img[0][pos[i][0]][pos[i][1]] = 1.0
					img[1][pos[i][0]][pos[i][1]] = 0
					img[2][pos[i][0]][pos[i][1]] = 0
				
			if torch.cuda.is_available():
				data = data.cuda()
				target = target.cuda()
		
			optimizer.zero_grad()
			output = self.local_model(data)
			# 类别损失和距离损失
			class_loss = torch.nn.functional.cross_entropy(output, target)
			dist_loss = models.model_norm(self.local_model, model)
			# 总的损失函数
			loss = self.conf["alpha"]*class_loss + (1-self.conf["alpha"])*dist_loss
			loss.backward()
		
			optimizer.step()
		print("Epoch %d done." % e)
		
	diff = dict()
	# 计算返回值
	for name, data in self.local_model.state_dict().items():
		diff[name] = self.conf["eta"]*(data - model.state_dict()[name])+model.state_dict()[name]
		
	return diff		

训练准确度如下图所示:
在这里插入图片描述
可以看到模型训练的效果并不理想,可以通过调参的方式提高模型准确度。注意在调整参数后,比如增加本地训练迭代轮次,增加每轮参与方个数,修改恶意方的权重等,在开始的全局训练中,输出的loss可能为nan,经过个人的分析,很可能是由于计算中出现了0结果导致了爆炸情况,但是这不影响经过几十轮的全局迭代后,训练准确度和loss恢复正常,因为首先cifar数据集本身就50000张数据,分发给各个参与方后没方只有几千张,然后本地训练的轮次又很少,这就导致前期聚合后的模型效果很不理想,但是随着全局模型性能的提高,本地训练的模型性能也随之提高,计算出的loss也变得有规律起来,所以后面轮次的结果会更加理想。
写到这里,我突然想到一个名词叫群体智慧,单一个体所做出的决策往往会比起多数决的决策来的不精准,群体智慧是一种共享的或者群体的智能,以及集结众人的意见进而转化为决策的一种过程。这个名词是我在看某站up主林亦LYi发布的五千人开一辆车的视频中了解的,那场面可谓相当震撼。五千多网友在线通过输入指令共同操作游戏中汽车的运行,在不排除高达数十秒的网络延迟的情况下,汽车由一开始的横冲直撞,无脑乱跑,到最后能够平稳的驰骋在大道上,并且还能躲避障碍物,这就是群体智慧的体现。无独有偶,联邦学习场景下的模型训练,其中根本的思想也即群体智慧,虽然参与方可能有因为数据集等问题出现模型性能不佳的情况,但是随着全局模型的迭代更新,模型会一步步朝着最优的方向提升,最终达到理想的效果。所以当一开始模型效果很差甚至出现nan的情况,别担心,参与方的群体智慧会指引模型向正确的方向提升的。

2. 差分隐私

差分隐私最初的应用场景主要包括数据库的查询操作、数据挖掘、数据统计等,本节介绍差分隐私如何应用到联邦学习场景。

2.1 集中式差分隐私

在集中式训练中应用差分隐私技术,主要通过加入噪声实现。
回顾差分隐私的定义,它建立在两个相邻数据集D和D’上,所谓相邻数据集,即使二者之间仅有一条数据不同,例如,二者满足:
D = { ( x 1 , y 1 ) , ( x 2 , y 2 ) , . . . , ( x n − 1 , y n − 1 ) } , D ′ = D ∪ ( x n , y n ) D=\{(x_1, y_1), (x_2, y_2), ...,(x_{n-1}, y_{n-1})\},D'=D \cup(x_{n}, y_{n}) D={(x1,y1),(x2,y2),...,(xn1,yn1)},D=D(xn,yn)

差分隐私技术使得用户无法从获取的输出数据中区分数据是来源于D还是D‘,从而达到保护数据隐私的目的,这种隐私保护强调数据层面的保护。
在传统的梯度下降算法SGD中,定义了损失函数和优化器后,可以利用反向传播求解,过程如下:

for i, data in enumerate(train_datasets):
	inputs, targets = data
	optimizer.zero_grad()
	outputs = model(inputs)
	loss = criterion(outputs, labels)
	loss.backward()
	optimizer.step()

DPSGD的迭代过程如下。在每轮迭代中,前面代码块部分基本一致,主要不同点在于梯度裁剪和添加高斯噪声。DPSGD修改损失函数的表示,然后按照损失函数进行求导,对每个样本的梯度进行裁剪,在聚合过程中添加高斯噪声,得到带有噪声的梯度 g t ~ \tilde{g_t} gt~,最后利用梯度下降更新模型参数。

for i, data in enumerate(train_datasets):
	inputs, targets = data
	optimizer.zero_grad()
	outputs = model(inputs)
	loss = criterion(outputs, labels)

	# 初始化记录裁剪和添加噪声的容器
	losses = torch.mean(loss.reshape(batch_size, -1), dim=1)
	gradients = dict()
	for tensor_name, tensor in model.named_parameters():
		gradients[tensor_name] = torch.zeros_like(tensor)
	
	for j in losses:
		j.backward(retain_graph=True)
		# 裁剪梯度,C为边界值,使得模型参数梯度在[-C,C]范围内
		torch.nn.utils.clip_grad_norm_(model.parameters(), C)
		# 存储裁剪后的梯度
		for tensor_name, tensor in model.named_parameters()
			gradients[tensor_name].add_(tensor.grad)
		model.zero_grad()

	for tensor_name, tensor in model.named_parameters():
		# 初始化噪声
		if torch.cuda.is_available():
			noise = torch.cuda.FloatTensor(tensor.grad.shape).normal_(0, sigma)
		else:
			noise = torch.FloatTensor(tensor.grad.shape).normal_(0, sigma)
		# 添加高斯噪声
		gradients[tensor_name].add_(noise)
		tensor.grad = gradients[tensor_name] / num_microbatches
	optimizer.step()
	

DPSGD算法流程如下所示。
在这里插入图片描述

2.2 联邦差分隐私

与集中差分隐私相比,在联邦场景下的差分隐私技术,除了需要考虑数据层面的隐私安全,还需要考虑用户层面的安全问题。
相邻数据集: 设有两个数据集D和D‘,若它们之间有且仅有一条数据不一样,那我们就称D和D’为相邻数据集。
用户相邻数据集: 设每个用户 c i c_i ci对应的本地数据集为 d i d_i di,D和D‘是两个用户数据的集合,我们定义D和D’为用户相邻数据集,当且仅当D去除或者添加某一个客户端 c i c_i ci的本地数据集 d i d_i di后变为D’。
在这里插入图片描述
如上是相邻数据集的,D和D’只差一个元素d6。用户相邻数据集如下图所示,数据集D包含用户c1,c2,c3本地数据,而数据集D’包括用户c2,c3的数据,因此D和D’是用户相邻的。
在这里插入图片描述
联邦差分隐私不但要求保证每个客户端的本地数据模型隐私安全,也要求客户端之间的信息安全,即用户在服务器接收到客户端的本地模型,既不能推断出由哪个客户端上传,也不能推断某个客户端是否参与了训练。
文献Learning Differentially Private Language Models Without Losing Accuracy介绍了一种DP-FedAvg的算法,它将联邦学习中经典的FedAvg算法和差分隐私训练相结合,并应用在语言模型上,取得不错的效果。DP-FedAvg的客户端本地训练算法如下所示:
在这里插入图片描述

与FedAvg客户端本地训练相比,DP-FedAvg需要在每一步本地迭代更新后,对参数进行裁剪。服务端侧算法如下面算法所示:
在这里插入图片描述
其主要工作包括以下几点:

  • 随机挑选参与训练的客户端集合 C t C^t Ct
  • 对挑选的客户端 k ∈ C t k \in C^t kCt,执行本地模型训练。
  • 服务端接收每一个客户端k的模型参数 Δ k t \Delta ^t_k Δkt,执行聚合操作,得到 Δ t \Delta ^t Δt
  • 求取高斯噪声分布的方差 σ \sigma σ,利用高斯分布 N ( 0 , I σ 2 ) N(0,I\sigma^2) N(0,Iσ2)生成噪声数据。
  • 在全局模型聚合操作中添加噪声数据,得到新的全局模型参数 θ t \theta_t θt
  • 重复上述步骤,直到收敛为止。

2.3 详细实现

本节给出DP-FedAvg的详细实现,复用第三章的代码框架,在其基础上加上差分隐私策略。在DP-FedAvg的实现中,需要求取两个相同结构的模型权重差值的范数,如下所示:

def model_norm(model_1, model_2):
	squared_sum = 0
	for name, layer in model_1.named_parameters():
		squared_sum += torch.sum(torch.pow(layer.data - model_2.state_dict()[name].data, 2))
	return math.sqrt(squared_sum)
	

配置信息如下:

{
	
	"model_name" : "resnet18", # 使用模型
	
	"no_models" : 10, # 参与方个数
	
	"type" : "cifar", # 数据集种类
	
	"global_epochs" : 100, # 全局迭代次数
	
	"local_epochs" : 3, # 本地迭代次数
	
	"k" : 2, # 每次随机选取2个参与方
	
	"batch_size" : 32, # 批大小
	
	"lr" : 0.01, # 学习率
	
	"momentum" : 0.0001, # momentum参数
	
	"lambda" : 0.5, # 正则化参数
	
	"dp" : true, # 使用差分隐私
	
	"C" : 1000,	# 裁剪边界值
	
	"sigma" : 0.001, # 差分隐私参数
	
	"q" : 0.1,	 # 源码未用
	
	"W" : 1	# 源码未用
}

客户端侧的修改,主要在本地训练过程中,在每一轮迭代完成后进行裁剪,主要过程如下,参数更新后,对参数的变化 θ − θ 0 \theta - \theta_0 θθ0进行裁剪,裁减系数为:
n o r m _ s c a l e = C ∥ θ − θ 0 ∥ 2 norm\_scale= \frac{C}{\left \|\theta - \theta_0 \right \|_2} norm_scale=θθ02C
经过多轮本地训练后,将最终的模型参数变化值 Δ k \Delta_k Δk上传到服务器。

if self.conf["dp"]:
	model_norm = models.model_norm(model, self.local_model)
	
	norm_scale = min(1, self.conf['C'] / (model_norm))
	for name, layer in self.local_model.named_parameters():
		clipped_difference = norm_scale * (layer.data - model.state_dict()[name])
		layer.data.copy_(model.state_dict()[name] + clipped_difference)
		

服务端侧的修改主要是对全局模型参数进行聚合时添加噪声,噪声由高斯分布生成。高斯分布的参数包括均值和标准差,这里取 μ = 0 , σ = z C q W \mu = 0, \sigma = \frac{zC}{qW} μ=0,σ=qWzC。事实上为了方便,可以直接在配置文件中设置 σ \sigma σ的值。

def model_aggregate(self, weight_accumulator):
	for name, data in self.global_model.state_dict().items():
		
		update_per_layer = weight_accumulator[name] * self.conf["lambda"]
		
		if self.conf['dp']:
			sigma = self.conf['sigma']
			if torch.cuda.is_available():
				noise = torch.cuda.FloatTensor(update_per_layer.shape).normal_(0, sigma)
			else:
				noise = torch.FloatTensor(update_per_layer.shape).normal_(0, sigma)
				
			update_per_layer.add_(noise)
		
		if data.type() != update_per_layer.type():
			data.add_(update_per_layer.to(torch.int64))
		else:
			data.add_(update_per_layer)

在baseline下,即单点训练的条件下,没有添加高斯噪声,训练的准确度为88%,如下图所示(PS:大概在20多轮的时候准确度就已经达到了87%,也就是说后面的训练并没有提高模型的性能,此时模型性能已经饱和):

在这里插入图片描述

在上述配置文件超参数的设置下,经过100轮训练,得到的准确度能达到86%,如下图所示:
在这里插入图片描述
在同等条件下,设置DP=false,得到的准确度为85%,可见在梯度参数上添加少量的噪声,并不会影响训练的准确度,同时也保证了数据隐私。
在这里插入图片描述

3. 模型压缩

模型压缩是深度学习领域常见技巧,主要减少模型参数和大小,调高模型的训练和推断速度。在联邦学习场景下,对模型进行压缩有以下好处:

  • 减少模型参数传输量。联邦学习在训练过程中需要服务端和客户端传输大量参数,因此对网络的稳定性要求比较高。减少模型参数传输量,可以减少对网络稳定性的依赖。
  • 提升安全性。模型压缩导致传输的不是原始的参数数据,因此,与差分隐私一样,即使恶意攻击者窃取了中间的模型参数,也很难将其还原。

3.1 参数稀疏化

稀疏化是模型压缩常用的技巧。
稀疏化思想与差分隐私的噪声机制类似,但是稀疏化操作更直接。假设当前模型结构为 G = { g 1 , g 2 , . . . , g L } G=\{g_1,g_2,...,g_L\} G={g1,g2,...,gL},这里的 g i g_i gi表示第i层。在第t轮中,客户端 c i c_i ci的本地迭代训练中,模型将从 G t G_t Gt变为 L i t + 1 L^{t+1}_i Lit+1。按照FedAvg的意思,客户端 c i c_i ci将向服务端上传模型参数 ( L i t + 1 − G t ) (L^{t+1}_i-G_t) (Lit+1Gt)
稀疏化思想是在每个客户端中保存一份掩码矩阵 { r 1 , r 2 , . . . , r L } \{r_1,r_2,...,r_L\} {r1,r2,...,rL} r i r_i ri是与 g i g_i gi形状大小相同的参数矩阵,且只由0和1构成。客户端将模型参数 L i t + 1 − G t L^{t+1}_i-G_t Lit+1Gt R R R结合,上传 ( L i t + 1 − G t ) ⊙ R (L^{t+1}_i-G_t)\odot R (Lit+1Gt)R

3.1.1 详细实现

首先在配置文件里添加“prop”,用来控制掩码矩阵中1的数量的。具体来说,prop越大,掩码矩阵中1的值越多,矩阵越稠密,相反,prop越小,1的值越少,矩阵越稀疏。

{
	
	"model_name" : "resnet50", # 使用模型
	
	"no_models" : 10, # 参与方个数
	
	"type" : "cifar", # 数据集种类
	
	"global_epochs" : 30, # 全局迭代次数
	
	"local_epochs" : 3, # 本地迭代次数
	
	"k" : 2, # 每次随机选取2个参与方
	
	"batch_size" : 32, # 批大小
	
	"lr" : 0.01, # 学习率
	
	"momentum" : 0.01, # momentum参数
	
	"lambda" : 0.5, # 正则化参数
	
	"prop" : 0.6	# 控制掩码矩阵1的数量
}

算法主要改动在客户端。我们先在客户端类构造函数中添加生成掩码矩阵mask的代码,掩码矩阵是用伯努利分布函数随机生成的。

self.mask = {}
for name, param in self.local_model.state_dict().items():
	p=torch.ones_like(param)*self.conf["prop"]
	if torch.is_floating_point(param):
		self.mask[name] = torch.bernoulli(p)
	else:
		self.mask[name] = torch.bernoulli(p).long()

在本地训练中,在最后一步上传模型的时,将模型参数与掩码矩阵相乘,掩码中0对应的参数相当于被隐藏了。

def local_train(self, model):

	for name, param in model.state_dict().items():
		self.local_model.state_dict()[name].copy_(param.clone())

	optimizer = torch.optim.SGD(self.local_model.parameters(), lr=self.conf['lr'],
								momentum=self.conf['momentum'])
		
	self.local_model.train()
	for e in range(self.conf["local_epochs"]):
		
		for batch_id, batch in enumerate(self.train_loader):
			data, target = batch

			if torch.cuda.is_available():
				data = data.cuda()
				target = target.cuda()
		
			optimizer.zero_grad()
			output = self.local_model(data)
			loss = torch.nn.functional.cross_entropy(output, target)
			loss.backward()
		
			optimizer.step()
					
		print("Epoch %d done." % e)	
		
	diff = dict()
	for name, data in self.local_model.state_dict().items():
		diff[name] = (data - model.state_dict()[name])
		# 模型参数与掩码相乘,隐藏部分参数值,达到防御目的
		diff[name] = diff[name]*self.mask[name]

	return diff

3.1.2 实验分析

在实验过程中,博主分别设置了 p r o p = 1 , 0.8 , 0.6 prop=1,0.8,0.6 prop=1,0.8,0.6,来评估经过参数稀疏化后模型的性能表现。下面分别是 p r o p = 1 , 0.8 , 0.6 prop=1,0.8,0.6 prop=1,0.8,0.6的训练截图(证明自己真实做了实验hh)。
在这里插入图片描述在这里插入图片描述在这里插入图片描述
只是最后的实验准确度不能够判断参数稀疏化后模型的性能表现变化过程,所以三次训练中的每轮acc和loss我都记录下来,制作成图表,便于观察,得出结论。
在这里插入图片描述在这里插入图片描述

由上述图表可以看出,模型一共训练了30轮,随着掩码矩阵中的0的数量越来越多,稀疏化处理后的模型性能在开始迭代时会有所下降,但随着迭代的进行,模型的性能会逐步恢复到正常状态。

3.2 按层敏感度传输

在联邦场景下,训练模型与集中训练一样,模型参数存在显著冗余。在文献Predicting Parameters in Deep Learning 中指出,大部分的神经网络中,仅使用很少的(5%)的权值,就可以达到和原来神经网络相近的性能,甚至优于原神经网络,这种思想类似于Dropout,丢弃的权值有的是没有意义的,甚至对模型有副作用。
这种网络权重重要性思想在模型压缩上起到很重要的作用,一方面它可以减少传输开销,另一方面由于只输出部分参数信息,攻击者很难通过反演攻击反推原始数据,从而有效提升系统安全性。本节将讲解在联邦学习上实现基于敏感度剪枝的防御技术。

层敏感度: 设当前的模型表示为 G = { g 1 , g 2 , . . . , g L } G=\{g_1,g_2,...,g_L\} G={g1,g2,...,gL},这里的 g i g_i gi表示第i层。在第t轮中,客户端 c j c_j cj进行联邦学习本地训练时,模型将从 G t = G G^t=G Gt=G变为 L i t + 1 = { g 1 , j t + 1 , g 2 , j t + 1 , . . . , g L , j t + 1 } L^{t+1}_i=\{g^{t+1}_{1,j},g^{t+1}_{2,j},...,g^{t+1}_{L,j}\} Lit+1={g1,jt+1,g2,jt+1,...,gL,jt+1}。我们将第i层的变化记为:

δ i , j t = ∣ m e a n ( g i , j t ) − m e a n ( g i , j t + 1 ) ∣ \delta^t_{i,j}=\left | mean(g^t_{i,j})-mean(g^{t+1}_{i,j}) \right | δi,jt=mean(gi,jt)mean(gi,jt+1)

其中 δ \delta δ是每一层的参数均值变化,成为敏感度。
基于按层敏感度剪枝的实现过程:对于任意被挑选的客户端 c j c_j cj,在模型本地训练结束后,按照上式计算模型每一层的均值变化量,将每层的变化量从大到小排序,变化越大,说明该层越敏感,算法将取高敏感的层上传。

3.2.1 详细实现

下面将继续复用第三章代码框架,利用ResNet-50模型对cifar10图像进行分类任务。
配置信息如下:

{
	
	"model_name" : "resnet50", # 使用模型
	
	"no_models" : 10, # 参与方个数
	
	"type" : "cifar", # 数据集种类
	
	"global_epochs" : 30, # 全局迭代次数
	
	"local_epochs" : 3, # 本地迭代次数
	
	"k" : 2, # 每次随机选取2个参与方
	
	"batch_size" : 32, # 批大小
	
	"lr" : 0.01, # 学习率
	
	"momentum" : 0.0001, # momentum参数
	
	"lambda" : 0.5, # 正则化参数
	
	"rate" : 0.95, # 传输比例
}

其中主要添加了rate字段,用来控制传输比例。通过上述公式求出每一层训练前后变化值,并对其排序。

def local_train(self, model):

	for name, param in model.state_dict().items():
		self.local_model.state_dict()[name].copy_(param.clone())

	#print("\n\nlocal model train ... ... ")
	#for name, layer in self.local_model.named_parameters():
	#	print(name, "->", torch.mean(layer.data))
		
	#print("\n\n")
	optimizer = torch.optim.SGD(self.local_model.parameters(), lr=self.conf['lr'],
								momentum=self.conf['momentum'])
	
	
	self.local_model.train()
	for e in range(self.conf["local_epochs"]):
		
		for batch_id, batch in enumerate(self.train_loader):
			data, target = batch
			if torch.cuda.is_available():
				data = data.cuda()
				target = target.cuda()
		
			optimizer.zero_grad()
			output = self.local_model(data)
			loss = torch.nn.functional.cross_entropy(output, target)
			loss.backward()
		
			optimizer.step()

		print("Epoch %d done." % e)	

	diff = dict()
	for name, data in self.local_model.state_dict().items():
		diff[name] = (data - model.state_dict()[name])
	# 按变化率排序
	diff = sorted(diff.items(), key=lambda item:abs(torch.mean(item[1].float())), reverse=True)
	sum1, sum2 = 0, 0
	for id, (name, data) in enumerate(diff):
		if id < 304:
			sum1 += torch.prod(torch.tensor(data.size()))
		else:
			sum2 += torch.prod(torch.tensor(data.size()))
	# 返回变化率最大的层
	ret_size = int(self.conf["rate"]*len(diff))

	return dict(diff[:ret_size])
		

同样对服务端聚合进行修改,由于客户端是按层上传的,因此聚合时也要按层进行。

	def model_aggregate(self, weight_accumulator, cnt):

		for name, data in self.global_model.state_dict().items():
			if name in weight_accumulator and cnt[name] > 0:
				#print(cnt[name])
				update_per_layer = weight_accumulator[name] * (1.0 / cnt[name]				
				if data.type() != update_per_layer.type():
					data.add_(update_per_layer.to(torch.int64))
				else:
					data.add_(update_per_layer)

3.2.2 实验分析

在这里插入图片描述

按照之前的配置文件信息,实验结果如上图所示,可以在经过三十轮的训练后,准确度能达到85%,几乎已经和单机训练的结果持平,再分析一波上传参数的比例,通过设定rate分别为1.0,0.95和0.9,最后得到的训练图像结果如下折线图所示(来自原文中github图片):
在这里插入图片描述

可以看出,在开始训练阶段,上传参数比例小的模型损失较大,结果不太准确,但随着模型的迭代,经过大约十五轮的训练,0.9的模型就和其余两个模型训练结果相差无几了,可以表明,通过层敏感度进行的模型压缩和原始模型性能
相比,几乎没有性能上的损失。

在这里插入图片描述

此外,根据观察,按层的变化排序后,参数较多的层变化一般都比较小,变化最小的后10%层的参数占整体参数的75%,如上图所示。

4. 同态加密

本节介绍如何用Paillier半同态加密算法来保护横向联邦学习过程中数据隐私问题。Paillier半同态加密算法是非对称算法的一种实现,说到半同态加密,就不得不提同态加密的三种形式,在这里作为补充。
全同态加密形式相当于域,域中的元素可以在域的范围内进行加法和乘法的运算,从而映射到域中的另外元素,第二种是半同态加密,相当于群,只能进行一种二元运算,使群中的元素映射到群中的另外元素,第三种是些许同态加密,指一同态加密方法中的一些运算操作(如加法和乘法)只能执行有限次,因为在运算过程中添加了噪声,所以一旦超过了限定次数,就无法得到正确的结果。由于目前的全同态加密建立在些许同态加密之上,并且代价高昂,所以大部分工作的重点都在些许同态加密。

4.1 Paillier半同态加密算法

回到Paillier半同态加密算法本身,它能够在加密的情况对加密数据进行操作,然后对加密结果进行解密,得到的结果与直接在明文下操作的结果相同。
为了方便讨论,使用x表示明文,使用 [ [ x ] ] [[x]] [[x]]表示其对应的明文。Paillier算法支持下面两种加密状态的运算:

  • 加法同态: [ [ u + v ] ] = [ [ u ] ] + [ [ v ] ] [[u+v]]=[[u]]+[[v]] [[u+v]]=[[u]]+[[v]]
  • 标量乘法同态:对于任意常数k。满足 [ [ k u ] ] = k ∗ [ [ u ] ] [[ku]]=k*[[u]] [[ku]]=k[[u]]

然而,该算法并不满足乘法同态,但是该算法计算效率高,在工业界广泛应用。

4.2 加密损失函数计算

求解机器学习模型时,通常定义一个损失函数 L ( θ ; X ) L(\theta;X) L(θ;X),然后使用SGD等方法最小化损失函数,得到最优解。我们以逻辑回归为例,设当前有n个样本数据集合为:
T = ( x 1 , y 1 ) , ( x 2 , y 2 ) , . . . , ( x n , y n ) T=(x_1,y_1),(x_2,y_2),...,(x_n,y_n) T=(x1,y1),(x2,y2),...,(xn,yn)
其中 x i ∈ R d x_i\in R^d xiRd y i ∈ { − 1 , 1 } y_i\in \{-1, 1\} yi{1,1},LR使用对数损失作为其目标损失函数:
L = 1 n ∑ i = 1 n l o g ( 1 + e − y i θ T x i ) L = \frac{1}{n}\sum_{i=1}^{n}log(1+e^{-y_i\theta^Tx_i}) L=n1i=1nlog(1+eyiθTxi)
对上式求导,求得损失函数的梯度,满足:
∂ L ∂ θ = 1 n ∑ i = 1 n { ( 1 1 + e − y i θ T x i − 1 ) y i x i } \frac{\partial L}{\partial \theta}=\frac{1}{n}\sum_{i=1}^{n}\{(\frac{1}{1+e^{-y_i\theta^Tx_i}}-1)y_ix_i\} θL=n1i=1n{(1+eyiθTxi11)yixi}
带入梯度下降,更新模型参数 θ \theta θ
θ = θ − l r ∗ ∂ L ∂ θ \theta=\theta-lr*\frac{\partial L}{\partial \theta} θ=θlrθL

循环上述过程,直到损失函数值不再下降或达到最大次数停止迭代。然而上述计算过程都在明文状态下计算的,基于HE的联邦学习,则要求在加密状态下进行参数求解。也就是说,传输的参数 θ \theta θ是加密后的值,损失函数可以写为:

L = 1 n ∑ i = 1 n l o g ( 1 + e − y i ∥ θ T ∥ x i ) L = \frac{1}{n}\sum_{i=1}^{n}log(1+e^{-y_i\left \|\theta^T \right \|x_i}) L=n1i=1nlog(1+eyiθTxi)

尽管上式涉及对加密数据的指数运算和对数运算,但是Paillier加密算法只支持加法同态和标量乘法同态,不支持乘法同态及其他复杂运算,所以无法在加密条件下求解该式。
文献Private federated learning on vertically partitioned data via entity resolution and additively homomorphic encryption 提出了一种Taylor损失来近似原始对数损失的方法,即通过对原始对数损失函数进行泰勒展开,通过多项式近似对数损失函数,此时损失函数转换为只有加法和标量乘法的运算,可以直接利用Paillier加密。
对于函数 f ( x ) f(x) f(x),其在x=0处的泰勒多项式展开为
f ( x ) = ∑ i = 0 ∞ f ′ ( 0 ) i ! x i f(x)=\sum_{i=0}^{\infty }\frac{f'(0)}{i!}x^i f(x)=i=0i!f(0)xi
对于损失函数 f ( z ) = l o g ( 1 + e − z ) f(z)=log(1+e^{-z}) f(z)=log(1+ez),在z=0处的泰勒展开为:
l o g ( 1 + e z ) ≈ l o g 2 − 1 2 z + 1 8 z 2 + O ( z 2 ) log(1+e^z)\approx log2-\frac{1}{2}z+\frac{1}{8}z^2+O(z^2) log(1+ez)log221z+81z2+O(z2)
取其中二阶多项式来近似对数损失函数,并将 z = y [ [ θ ] ] T x z=y[[\theta]]^Tx z=y[[θ]]Tx带入上式,得到:
l o g ( 1 + e − y θ T x ) ≈ l o g 2 − 1 2 y θ T x + 1 8 ( θ T x ) 2 log(1+e^{-y\theta^Tx})\approx log2-\frac{1}{2}y\theta^Tx+\frac{1}{8}(\theta^Tx)^2 log(1+eyθTx)log221yθTx+81(θTx)2
其中 y 2 = 1 y^2=1 y2=1,因此直接去除,最终得到的L为:
L = 1 n ∑ i = 1 n { l o g 2 − 1 2 y i θ T x + 1 8 ( θ T x ) 2 } L= \frac{1}{n}\sum_{i=1}^{n}\{ log2-\frac{1}{2}y_i\theta^Tx+\frac{1}{8}(\theta^Tx)^2\} L=n1i=1n{log221yiθTx+81(θTx)2}
对上式求导得到损失值L关于参数 θ \theta θ的导数:
∂ L ∂ θ = 1 n ∑ i = 1 n ( 1 4 θ T x i − 1 2 y i ) x i \frac{\partial L}{\partial \theta}=\frac{1}{n}\sum_{i=1}^{n}(\frac{1}{4}\theta^Tx_i-\frac{1}{2}y_i)x_i θL=n1i=1n(41θTxi21yi)xi
上式对应的加密梯度为:
[ [ ∂ L ∂ θ ] ] = 1 n ∑ i = 1 n ( 1 4 [ [ θ T ] ] x i − 1 2 [ [ − 1 ] ] y i ) x i [[\frac{\partial L}{\partial \theta}]]=\frac{1}{n}\sum_{i=1}^{n}(\frac{1}{4}[[\theta^T]]x_i-\frac{1}{2}[[-1]]y_i)x_i [[θL]]=n1i=1n(41[[θT]]xi21[[1]]yi)xi

4.3 详细实现

实现部分是使用Paillier算法实现横向联邦学习,数据集为乳腺癌数据,代码框架为第三章横向联邦学习代码。
定义模型类: 首先自定义模型类LR_Model,方便加密解密操作。

class LR_Model(object):

	def __init__ (self, public_key, w_size=None, w=None, encrypted=False):
		"""
		w_size: 权重参数数量
		w: 是否直接传递已有权重,w和w_size只需要传递一个即可
		encrypted: 是明文还是加密的形式
		"""
		self.public_key = public_key
		if w is not None:
			self.weights = w
		else:
			limit = -1.0/w_size 
			self.weights = np.random.uniform(-0.5, 0.5, (w_size,))
		# 如果是明文进行加密
		if encrypted==False:
			self.encrypt_weights = encrypt_vector(public_key, self.weights)
		else:
			self.encrypt_weights = self.weights	
			
	def set_encrypt_weights(self, w):
		for id, e in enumerate(w):
			self.encrypt_weights[id] = e 
		
	def set_raw_weights(self, w):
		for id, e in enumerate(w):
			self.weights[id] = e 
			

在上述类中,定义了权重向量weights和加密的权重向量encrypt_weights,还定义了两个类函数,分别用来更新明文和密文权重向量。
本地模型训练: 本地模型训练是在加密状态下进行的,首先给出本地模型训练的算法模块。

def local_train(self, weights):
# 用全局权重更新本地权重
	original_w = weights
	self.local_model.set_encrypt_weights(weights)
	neg_one = self.public_key.encrypt(-1)
	for e in range(self.conf["local_epochs"]):
		print("start epoch ", e)				
		idx = np.arange(self.data_x.shape[0])
		batch_idx = np.random.choice(idx, self.conf['batch_size'], replace=False)
		x = self.data_x[batch_idx]
		x = np.concatenate((x, np.ones((x.shape[0], 1))), axis=1)
		y = self.data_y[batch_idx].reshape((-1, 1))
		
		# 在加密状态下求取加密梯度,利用上面加密梯度公式求解
		batch_encrypted_grad = x.transpose() * (0.25 * x.dot(self.local_model.encrypt_weights) + 0.5 * y.transpose() * neg_one)
		encrypted_grad = batch_encrypted_grad.sum(axis=1) / y.shape[0]
		
		for j in range(len(self.local_model.encrypt_weights)):
			self.local_model.encrypt_weights[j] -= self.conf["lr"] * encrypted_grad[j]

	weight_accumulators = []
	for j in range(len(self.local_model.encrypt_weights)):
		weight_accumulators.append(self.local_model.encrypt_weights[j] - original_w[j])
	
	return weight_accumulators

这里需要注意的是,在使用Paillier算法进行加解密运算的时候,会涉及大量的大素数幂运算,因此中间可能会越界,所以需要要个有效的处理方法,即加密迭代到一定轮次,重新加密数据,如下所示:

if e > 0 and e%2 == 0:
	self.local_model.encrypt_weights = Server.re_encrypt(self.local_model.encrypt_weights)

生成公钥和私钥: 利用Paillier算法生成公钥和私钥,私钥保留在可信服务器,公钥分发给客户端。

public_key, private_ley = paillier.generate_paillier_leypair(n_length=1024)

重新加密的过程也在服务器上进行,先利用Paillier私钥解密,再重新加密。

@staticmethod
def re_encrypt(w):
	return models.encrypt_vector(Server.public_key, models.decrypt_vector(Server.private_key, w))

在Paillier加密下的横向联邦学习训练结果如下:
在这里插入图片描述在这里插入图片描述
观察上面的折线图可以看出,二阶近似的结果对模型性能的影响较小,算法在经过二十轮的迭代后就达到了很好的性能。

阅读总结

花费近一周的时间,终于认认真真看完了整个章节,本章内容可谓是整本书的核心,因为联邦学习最大的亮点就是可以保障用户的数据隐私安全,本章内容涉及了隐私保护方法中的差分隐私,同态加密,模型压缩,以及威胁联邦学习模型的后门攻击。通过完整的学习,我总算是在实战中演练了传统的联邦学习中的隐私保护算法,而不是只能在在各篇文献中看到。虽然目前来说还仅仅是简单实现,对于核心的构造部分还比较模糊,但是我有信心可以完全理解核心的部分,并改进为我想要的同态加密和差分隐私混合的加密机制。
当然了,阅读完这章内容,还是有很大的遗憾,就是书中并未提及如何在FATE环境中使用隐私保护算法,这部分的内容,我还得好好研究研究。

参考文献

https://arxiv.org/abs/1807.00459
https://github.com/FederatedAI/Practicing-Federated-Learning/tree/main/chapter15_Backdoor_Attack
https://arxiv.org/pdf/1306.0543.pdf
https://github.com/FederatedAI/Practicing-Federated-Learning/tree/main/chapter15_Compression
https://blog.csdn.net/qq_40258073/article/details/107939708

  • 9
    点赞
  • 88
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 20
    评论
JSP(JavaServer Pages)是一种服务器端的动态网页开发技术,它可以将 Java 代码嵌入 HTML 页面中,从而实现动态网页的生成。 JSP 的基本原理是将 JSP 页面翻译成 Servlet,在服务器端执行 Servlet 代码,再将执行结果返回给客户端。因此,我们在使用 JSP 开发网页时,需要先了解 Servlet 的相关知识。 JSP 的语法基本上就是 HTML 标签加上 Java 代码。以下是一些基本的 JSP 标签: 1. <% ... %>:嵌入 Java 代码,可以用于定义变量、写循环、判断语句等。 2. <%= ... %>:输出 Java 代码的执行结果。 3. <%-- ... --%>:注释,不会被翻译成 Servlet。 4. <jsp:include ... />:包含其他 JSP 页面或 HTML 页面。 5. <jsp:forward ... />:将请求转发到其他资源(JSP 页面、Servlet 或 HTML 页面)。 6. <jsp:useBean ... />:创建 JavaBean 对象。 7. <jsp:setProperty ... />:为 JavaBean 对象设置属性。 8. <jsp:getProperty ... />:取得 JavaBean 对象的属性值。 在 JSP 页面中,我们还可以使用 EL 表达式和 JSTL 标签库来简化代码编写,提高开发效率。 EL(Expression Language)表达式是一种简化的表达式语言,可以用于取值、赋值、计算等操作。例如,${name} 表示取得名为 name 的变量的值。 JSTL(JavaServer Pages Standard Tag Library)是一套标签库,提供了循环、条件判断、格式化、国际化等常用功能的标签。例如,<c:forEach> 标签可以用于循环遍历集合,<c:if> 标签可以用于条件判断。 除了以上标签库,JSP 还支持自定义标签库。我们可以通过编写标签处理器来扩展 JSP 的功能。 JSP 的优点是可以将 Java 代码嵌入 HTML 页面中,使得网页的开发更加灵活和方便。但是,由于 JSP 页面需要翻译成 Servlet,因此会增加服务器的负担和响应时间。此外,JSP 页面中夹杂着 Java 代码,也不利于代码的维护和调试。因此,在开发大型网站时,建议使用 MVC 设计模式,将业务逻辑和视图分离,使得代码更加清晰和易于维护。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 20
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

HERODING77

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值