使用加密的数据进行机器学习而不解密
本文讨论了高级加密技术。 这只是Julia Computing进行的研究的概述。 不要在商业应用中使用此处给出的示例。 在应用加密之前,请务必咨询加密专家。
在这里,您可以下载实现所有魔术的程序包,这是本文中讨论的代码。
引言
假设您刚刚开发了一个很酷的新机器学习模型(当然,使用
Flux.jl )。 现在,您想开始为您的用户部署它。 您将如何做? 可能最简单的方法是将模型提供给用户,并使其在其数据上本地运行。 但是这种方法有缺点:
- 机器学习模型很大,并且用户计算机可能没有足够的计算或磁盘资源。
- 机器学习模型通常会更新,因此可能无法方便地通过网络定期发送大量数据。
- 模型开发非常耗时,并且需要大量的计算资源。 您可能希望以使用模型的费用的形式对此进行补偿。
然后他们通常会回忆起可以通过API在云中提供该模型。 在过去的几年中,出现了许多此类服务;每个大型云平台都为企业开发人员提供了类似的服务。 但是潜在的用户面临一个明显的难题:现在,他们的数据在远程服务器上处理,这可能是不可信的。 这具有明显的道德和法律影响,限制了此类服务的使用。 在受监管的行业中,尤其是医疗保健和金融服务,通常无法将患者和客户数据发送给第三方进行处理。
还有其他选择吗?
原来有! 密码学的最新发现允许对数据进行计算而
无需对其进行解码 。 例如,用户将加密的数据(例如,图像)发送到云API,该API启动机器学习模型,然后发送加密的响应。 在任何阶段都不会解密数据,云提供商不会访问源图像,也无法解密计算出的预测。 这怎么可能? 让我们通过创建用于在MNIST数据集中的加密图像上进行手写识别的服务来找到答案。
关于同态加密
使用加密数据执行计算的能力通常称为“安全计算”。 这是一个很大的研究领域,根据各种应用场景,可以使用多种加密方法。 我们将专注于一种称为“同态加密”的技术。 在这样的系统中,通常可以进行以下操作:
pub_key, eval_key, priv_key = keygen()
encrypted = encrypt(pub_key, plaintext)
decrypted = decrypt(priv_key, encrypted)
encrypted′ = eval(eval_key, f, encrypted)
对于已经使用任何非对称加密算法的每个人(例如,如果您通过TLS进行连接),前三个操作都很简单且熟悉。 所有的魔术都发生在最后的操作中。 在加密过程中,它评估函数
f
并返回根据对加密值评估
f
的结果计算出的另一个加密值。 此功能为其方法命名。 评估与加密操作有关:
f(decrypt(priv_key, encrypted)) == decrypt(priv_key, eval(eval_key, f, encrypted))
类似地,使用加密的值,我们可以评估任意同构性
f
。
支持哪些功能
f
取决于加密方案和支持的操作。 如果仅支持一个
f
(例如
f = +
),则该电路称为“部分同态”。 如果
f
可以是网关的任何完整集合,可以在该网关的基础上创建任意方案,则对于方案的有限大小,这称为另一种部分同态计算-“有些同态”,而对于无限制大小,则称为“完全同构”计算。 您可以使用自举技术将“以某种方式”转换为完全同态加密,但这超出了本文的范围。 全同态加密是一个相对较新的发现,第一个工作方案(尽管不切实际)是由
Craig Gentry在2009年发布的 。 有许多后来的(和实际的)完全同构方案。 还有一些软件包可以定性地实现这些方案。 大多数情况下,他们使用
Microsoft SEAL和
PALISADE 。 另外,我最近打开了这些
Pure Julia算法的实现代码。 对于本文,我们将使用其中实现的CKKS加密。
CKS概述
CKKS(以2016年提出该算法的
科学著作 Cheon-Kim-Kim-Song的作者的名字命名)是一种同态加密方案,可以对以下原始操作进行同态评估:
n
个复数向量的长度的元素加法。
n
复数向量的长度的逐元素乘法。
- 旋转(在
circshift
的情况下)向量中的元素。
- 向量元素的集成配对。
参数
n
取决于所需的安全性和准确性,通常很高。 在我们的示例中,它将等于4096(较高的值可提高安全性,但在计算中也较困难,它的缩放比例近似为
n log n
)。
此外,使用CKKS进行的计算比较
嘈杂 。 因此,结果是近似值,必须注意以足够的准确性评估结果,以免影响结果的正确性。
另一方面,这样的限制对于机器学习包的开发人员来说并不罕见。 像GPU这样的特殊加速器通常也可以使用数字矢量运行。 另外,对于许多开发人员来说,由于选择算法,多线程等的影响,浮点数有时看起来很嘈杂。 我想强调一点,这里的主要区别在于,尽管CKKS原语确实很嘈杂,但由于实现的复杂性,尽管浮点数的算术计算最初是确定性的,即使这并不明显。 但这也许可以使用户理解噪音并不像看起来那样可怕。
现在,让我们看看如何在Julia中执行这些操作(注意:选择了非常不安全的参数,在这些操作中,我们仅说明了REPL中库的使用)。
julia> using ToyFHE
好简单! 细心的读者可能会注意到CSQ与以前的密文略有不同。 特别地,密文具有“长度3”并且规模更大。 关于什么是什么以及需要什么的解释超出了本文的范围。 足以说明我们需要在继续计算之前降低这些值,否则“位置”将以密文结尾。 幸运的是,我们可以减少两个增加的值中的每一个:
另外,modswitching(模数转换,模块转换的缩写)减小了密文模块的大小,因此我们不能无限期地继续这样做(我们使用了某种同态加密方案):
julia> ℛ
我们介绍了使用HE库的基础知识。 但是在继续使用这些原语来计算神经网络预测之前,让我们看一下学习它的过程。
机器学习模型
如果您不熟悉机器学习或Flux.jl库,那么建议您简要浏览一下
Flux.jl文档,或者免费阅读
机器学习简介 ,因为我们将仅讨论将模型应用于加密数据的更改。
让我们
从使用Flux动物园的卷积神经网络开始。 我们将执行相同的训练周期,并进行数据准备等,只需稍微建立一下模型即可。 这是:
function reshape_and_vcat(x) let y=reshape(x, 64, 4, size(x, 4)) vcat((y[:,i,:] for i=axes(y,2))...) end end model = Chain(
这与
“安全外包矩阵计算和应用于神经网络”工作中的模型相同,后者使用相同的密码方案,但有两个区别:1)为简单起见,我们未对模型本身进行加密; 2)在每一层之后使用贝叶斯向量(在Flux中默认完成),我不确定在提到的工作中是什么。 也许是由于第二点,我们模型的测试集的准确性原来更高(98.6%对98.1%),但是高参数差异也可能是原因。
x.^2
函数激活是不寻常的(对于那些拥有机器学习经验的人而言)。 在大多数情况下,他们使用
tanh
,
relu
或其他更古怪的东西。 但是,尽管这些函数(尤其是
relu
)很容易针对普通文本值进行计算,但是它们可能需要大量的计算资源才能以加密形式对其进行评估(我们通常估算多项式近似值)。 幸运的是,在这种情况下,
x.^2
非常有效。
学习周期的其余部分保持不变。 我们从损失函数对数
logitcrossentropy
模型中删除了
softmax
(您可以将其保留,并在客户端解密后评估softmax)。 训练模型的完整代码位于
GitHub上 ,可在几分钟内在任何新的视频卡上运行。
有效运作
现在我们知道我们需要执行哪些操作:
通过平方,一切都很简单,我们已经在上面进行了检查,因此我们将考虑另外两个操作。 我们假设数据包的长度为64(您可能会注意到选择模型参数和数据包大小是为了利用由于实际选择参数而获得的4096元素矢量)。
凝结
回想一下凝血是如何工作的。 取原始输入数组的一个窗口(在我们的例子中为7x7),每个窗口元素乘以卷积掩码元素。 然后,将窗口移至某个步骤(在本例中,该步骤为3,即,移动3个元素)并重复该过程(使用相同的卷积蒙版)。 下图显示了3x3卷积与步骤
(2, 2)
)的过程(
source )的动画(蓝色数组-输入,绿色-输出):
另外,我们在四个不同的“通道”中执行卷积(也就是说,我们使用不同的蒙版重复卷积3次以上)。
现在我们知道该怎么做了,仍然需要了解如何做。 幸运的是,卷积是模型中的第一个操作。 因此,为了节省资源,我们可以在客户端上预处理数据,然后对它们进行加密(不使用权重)。 让我们这样做:
- 首先,我们计算每个卷积窗口(即来自源图像的7x7样本),从而为每个输入图像提供64个7x7矩阵。 请注意,对于以2为增量的7x7窗口,将有8x8卷积窗口用于评估28x28输入图像。
- 让我们在一个向量中收集每个窗口中相同的位置。 也就是说,对于每个图像,对于大小为64的数据包(总共49个64x64矩阵),我们将具有64个元素的向量或64x64个元素的向量。
- 我们将加密。
然后,凝结简单地变成整个矩阵与相应的蒙版元素的标量乘法。 然后汇总所有49个元素,我们得到折叠的结果。 这是该策略的实现形式(以纯文本形式):
function public_preprocess(batch) ka = OffsetArray(0:7, 0:7)
这个(用于更改尺寸的模块)(以模数形式更改尺寸的顺序)给出的答案与操作
model.layers[1](batch)
。
添加加密操作:
Iᵢⱼ = public_preprocess(batch) C_Iᵢⱼ = map(Iᵢⱼ) do Iij plain = CKKSEncoding{Tscale}(zero(plaintext_space(ckks_params))) plain .= OffsetArray(vec(Iij), 0:(N÷2-1)) encrypt(kp, plain) end weights = model.layers[1].weight conv_weights = reverse(reverse(weights, dims=1), dims=2) conved3 = [sum(C_Iᵢⱼ[i,j]*conv_weights[i,j,1,channel] for i=1:7, j=1:7) for channel = 1:4] conved2 = map(((x,b),)->x .+ b, zip(conved3, model.layers[1].bias)) conved1 = map(ToyFHE.modswitch, conved2)
请注意,由于砝码是公共的,因此此处不需要按键开关。 因此,我们不会增加密文的长度。
矩阵乘法
继续矩阵乘法,我们可以使用向量中元素的旋转来更改乘法索引的顺序。 考虑向量中矩阵元素的逐行放置。 如果将向量移位行大小的倍数,则会得到列旋转的效果,这足以实现矩阵乘法(至少是平方矩阵)。 让我们尝试:
function matmul_square_reordered(weights, x) sum(1:size(weights, 1)) do k
当然,对于一般的矩阵乘法,需要做一些更复杂的事情,但是到目前为止,这已经足够了。
改进技术
现在,我们技术的所有组件都可以工作了。 这是完整的代码(设置选择选项和类似内容除外):
ek = keygen(EvalMultKey, kp.priv) gk = keygen(GaloisKey, kp.priv; steps=64) Iᵢⱼ = public_preprocess(batch) C_Iᵢⱼ = map(Iᵢⱼ) do Iij plain = CKKSEncoding{Tscale}(zero(plaintext_space(ckks_params))) plain .= OffsetArray(vec(Iij), 0:(N÷2-1)) encrypt(kp, plain) end weights = model.layers[1].weight conv_weights = reverse(reverse(weights, dims=1), dims=2) conved3 = [sum(C_Iᵢⱼ[i,j]*conv_weights[i,j,1,channel] for i=1:7, j=1:7) for channel = 1:4] conved2 = map(((x,b),)->x .+ b, zip(conved3, model.layers[1].bias)) conved1 = map(ToyFHE.modswitch, conved2) Csqed1 = map(x->x*x, conved1) Csqed1 = map(x->keyswitch(ek, x), Csqed1) Csqed1 = map(ToyFHE.modswitch, Csqed1) function encrypted_matmul(gk, weights, x::ToyFHE.CipherText) result = repeat(diag(weights), inner=64).*x rotated = x for k = 2:64 rotated = ToyFHE.rotate(gk, rotated) result += repeat(diag(circshift(weights, (0,(k-1)))), inner=64) .* rotated end result end fq1_weights = model.layers[3].W Cfq1 = sum(enumerate(partition(1:256, 64))) do (i,range) encrypted_matmul(gk, fq1_weights[:, range], Csqed1[i]) end Cfq1 = Cfq1 .+ OffsetArray(repeat(model.layers[3].b, inner=64), 0:4095) Cfq1 = modswitch(Cfq1) Csqed2 = Cfq1*Cfq1 Csqed2 = keyswitch(ek, Csqed2) Csqed2 = modswitch(Csqed2) function naive_rectangular_matmul(gk, weights, x) @assert size(weights, 1) < size(weights, 2) weights = vcat(weights, zeros(eltype(weights), size(weights, 2)-size(weights, 1), size(weights, 2))) encrypted_matmul(gk, weights, x) end fq2_weights = model.layers[4].W Cresult = naive_rectangular_matmul(gk, fq2_weights, Csqed2) Cresult = Cresult .+ OffsetArray(repeat(vcat(model.layers[4].b, zeros(54)), inner=64), 0:4095)
看起来不太整洁,但是如果您完成了所有这些步骤,则应该了解每个步骤。
现在,让我们考虑一下抽象可以简化我们的生活。 我们将离开制图学和机器学习领域,而转向编程语言的体系结构,因此让我们利用Julia允许您使用和创建强大的抽象这一事实。 例如,您可以将提取卷积的整个过程封装为数组类型:
using BlockArrays """ ExplodedConvArray{T, Dims, Storage} <: AbstractArray{T, 4} Represents a an `nxmx1xb` array of images, but rearranged into a series of convolution windows. Evaluating a convolution compatible with `Dims` on this array is achievable through a sequence of scalar multiplications and sums on the underling storage. """ struct ExplodedConvArray{T, Dims, Storage} <: AbstractArray{T, 4}
在这里,我们再次使用
BlockArrays
将
8x8x4x64
数组表示为四个
8x8x1x64
数组,如源代码中所示。 现在,至少使用未加密的数组,第一阶段的演示变得更加美观:
julia> cdims = DenseConvDims(batch, model.layers[1].weight; stride=(3,3), padding=(0,0,0,0), dilation=(1,1)) DenseConvDims: (28, 28, 1) * (7, 7) -> (8, 8, 4), stride: (3, 3) pad: (0, 0, 0, 0), dil: (1, 1), flip: false julia> a = ExplodedConvArray{eltype(batch)}(cdims, batch); julia> model(a) 10×64 Array{Float32,2}: [snip]
现在我们如何将其与加密连接起来? 为此,您需要:
- 加密结构(
ExplodedConvArray
),以便我们获取每个字段的密文。 具有这种加密结构的操作将验证函数将对原始结构执行的操作,并且同态地执行相同的操作。
- 拦截某些操作,以便在加密的上下文中不同地执行它们。
幸运的是,Julia为此提供了一个抽象:一个使用
Cassette.jl机制的编译器插件。 我不会告诉您它是什么以及它是如何工作的,我会简单地说一说它可以确定上下文(例如
Encrypted
,然后定义规则在该上下文中应如何工作。 例如,您可以为第二个要求编写此代码:
结果,用户将能够用最少的手工编写以上所有内容:
kp = keygen(ckks_params) ek = keygen(EvalMultKey, kp.priv) gk = keygen(GaloisKey, kp.priv; steps=64)
, . ( ℛ, modswitch, keyswitch ..) , . , , , , .
结论
— . Julia . RAMPARTS (
paper ,
JuliaCon talk ) : Julia- - PALISADE. Julia Computing RAMPARTS Verona,
. , . . , , .
,
ToyFHE .
, , , .