Usando dados criptografados para aprendizado de máquina sem descriptografá-los
Este artigo discute técnicas criptográficas avançadas. Esta é apenas uma visão geral das pesquisas conduzidas por Julia Computing. Não use os exemplos dados aqui em aplicativos comerciais. Sempre consulte os criptografadores antes de aplicar a criptografia.
Aqui você pode baixar o pacote que implementa toda a magia, e
aqui está o código discutido no artigo.
1. Introdução
Digamos que você acabou de desenvolver um novo modelo interessante de aprendizado de máquina (é claro, usando o
Flux.jl ). E agora você deseja começar a implantá-lo para seus usuários. Como você fará isso? Provavelmente, a maneira mais fácil é fornecer o modelo aos usuários e deixá-lo executar localmente em seus dados. Mas essa abordagem tem desvantagens:
- Os modelos de aprendizado de máquina são grandes e os computadores dos usuários podem não ter recursos suficientes de computação ou disco.
- Os modelos de aprendizado de máquina geralmente são atualizados e pode não ser conveniente enviar regularmente grandes quantidades de dados pela rede.
- O desenvolvimento do modelo é demorado e requer uma grande quantidade de recursos de computação. E você pode querer uma compensação por isso na forma de uma taxa pelo uso do seu modelo.
Em seguida, eles lembram que o modelo pode ser fornecido na nuvem por meio da API. Nos últimos anos, muitos desses serviços apareceram; cada grande plataforma em nuvem oferece serviços semelhantes aos desenvolvedores corporativos. Mas os usuários em potencial enfrentam um dilema óbvio: agora seus dados são processados em um servidor remoto, o que pode não ser confiável. Isso tem implicações éticas e legais claras que limitam o uso de tais serviços. Em setores regulamentados, especialmente serviços de saúde e financeiros, geralmente não é possível enviar dados de pacientes e clientes a terceiros para processamento.
Alguma outra opção?
Acontece que existe! Descobertas recentes em criptografia permitem a computação com dados
sem decodificá-los . Por exemplo, um usuário envia dados criptografados (por exemplo, imagens) para uma API da nuvem que inicia um modelo de aprendizado de máquina e envia uma resposta criptografada. Em nenhum momento os dados são descriptografados, o provedor de nuvem não obtém acesso às imagens de origem e não pode descriptografar a previsão calculada. Como isso é possível? Vamos descobrir o exemplo da criação de um serviço para reconhecimento de manuscrito em imagens criptografadas do conjunto de dados MNIST.
Sobre criptografia homomórfica
A capacidade de executar cálculos com dados criptografados é comumente referida como "computação segura". Essa é uma grande área para pesquisa, com inúmeras abordagens à criptografia, dependendo de todos os tipos de cenários de aplicativos. Vamos nos concentrar em uma técnica chamada "criptografia homomórfica". Nesse sistema, as seguintes operações geralmente estão disponíveis para nós:
pub_key, eval_key, priv_key = keygen()
encrypted = encrypt(pub_key, plaintext)
decrypted = decrypt(priv_key, encrypted)
encrypted′ = eval(eval_key, f, encrypted)
As três primeiras operações são simples e familiares para todos que já usaram algoritmos de criptografia assimétrica (por exemplo, se você se conectou via TLS). Toda mágica acontece na última operação. Durante a criptografia, ele avalia a função
f
e retorna outro valor criptografado calculado de acordo com o resultado da avaliação de
f
no valor criptografado. Esse recurso deu seu nome à abordagem. A avaliação está relacionada à operação de criptografia:
f(decrypt(priv_key, encrypted)) == decrypt(priv_key, eval(eval_key, f, encrypted))
Da mesma forma, usando um valor criptografado, podemos avaliar homomorfismos arbitrários
f
.
Quais funções
f
suportadas dependem de esquemas criptográficos e operações suportadas. Se apenas um
f
suportado (por exemplo,
f = +
), o circuito será chamado de "parcialmente homomórfico". Se
f
pode ser qualquer conjunto completo de gateways, com base nos quais esquemas arbitrários podem ser criados, então, para um tamanho limitado de um esquema, isso é chamado de outro tipo de cálculo parcialmente homomórfico - "um tanto homomórfico" e para um tamanho ilimitado - cálculo "completamente homomórfico". Você pode transformar "de alguma forma" em uma criptografia completamente homomórfica usando a técnica de inicialização, mas isso está além do escopo de nosso artigo. A criptografia totalmente homomórfica é uma descoberta relativamente recente; o primeiro esquema de trabalho (embora impraticável) foi publicado por
Craig Gentry em 2009 . Existem vários esquemas posteriores (e práticos) completamente homomórficos. Existem também pacotes de software que implementam qualitativamente esses esquemas. Na maioria das vezes, eles usam o
Microsoft SEAL e
PALISADE . Além disso, abri recentemente o código de implementação desses algoritmos
Pure Julia . Neste artigo, usaremos a criptografia CKKS implementada nele.
Visão geral do CKS
O CKKS (pelos nomes dos autores do
trabalho científico Cheon-Kim-Kim-Song, que propôs o algoritmo em 2016) é um esquema de criptografia homomórfica que permite a avaliação homomórfica das seguintes operações primitivas:
- A adição elementar dos comprimentos de
n
vetores de números complexos.
- Multiplicação por elemento dos comprimentos de
n
vetores complexos.
- Gire (no contexto de
circshift
de circshift
) elementos em um vetor.
- Emparelhamento integrado de elementos do vetor.
O parâmetro
n
depende do nível desejado de segurança e precisão, e geralmente é bastante alto. Em nosso exemplo, será igual a 4096 (um valor mais alto aumenta a segurança, mas também é mais difícil nos cálculos, é escalado aproximadamente como
n log n
).
Além disso, os cálculos usando CKKS são
barulhentos . Portanto, os resultados são aproximados e deve-se tomar cuidado para que os resultados sejam avaliados com precisão suficiente para não afetar a exatidão do resultado.
Por outro lado, essas restrições não são incomuns para os desenvolvedores de pacotes de aprendizado de máquina. Aceleradores especiais como a GPU também costumam operar com vetores numéricos. Além disso, para muitos desenvolvedores, os números de ponto flutuante às vezes parecem barulhentos devido à influência de algoritmos de seleção, multithreading etc. Quero enfatizar que a principal diferença aqui é que os cálculos aritméticos com números de ponto flutuante são inicialmente determinísticos, mesmo que isso não seja óbvio devido à complexidade da implementação, embora as primitivas do CKKS sejam realmente barulhentas. Mas talvez isso permita que os usuários entendam que o ruído não é tão assustador quanto pode parecer.
Agora vamos ver como você pode executar essas operações na Julia (nota: parâmetros muito inseguros são selecionados, com essas operações ilustramos apenas o uso da biblioteca no REPL).
julia> using ToyFHE
Tão simples! Um leitor atento pode perceber que o CSQ é um pouco diferente do texto cifrado anterior. Em particular, o texto cifrado tem "comprimento 3" e a escala é muito maior. Uma explicação do que é e do que é necessário está além do escopo deste artigo. Basta dizer que precisamos diminuir os valores antes de continuar com os cálculos; caso contrário, o "local" terminará no texto cifrado. Felizmente, podemos reduzir cada um dos dois valores aumentados:
Além disso, modswitching (abreviação de comutação de módulo, comutação de módulo) reduz o tamanho do módulo de texto cifrado, para que não possamos continuar fazendo isso indefinidamente (usamos um esquema de criptografia um tanto homomórfica):
julia> ℛ
Abordamos o básico do uso da biblioteca HE. Mas antes de passar a usar essas primitivas para calcular previsões de redes neurais, vejamos o processo de aprendê-las.
Modelo de aprendizado de máquina
Se você não estiver familiarizado com o aprendizado de máquina ou a biblioteca Flux.jl, recomendo uma rápida
pesquisa na documentação do
Flux.jl ou consulte uma
introdução gratuita
ao aprendizado de máquina , porque discutiremos apenas as mudanças na aplicação do modelo aos dados criptografados.
Vamos começar usando a rede neural convolucional
do zoológico Flux . Vamos realizar o mesmo ciclo de treinamento, com preparação de dados e assim por diante, apenas configurando um pouco o modelo. Aqui está:
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(
Esse é o mesmo modelo do trabalho
“Computação matricial terceirizada segura e aplicativo para redes neurais” , que usa o mesmo esquema criptográfico com duas diferenças: 1) por uma questão de simplicidade, não criptografamos o modelo em si e 2) após cada camada que temos São utilizados vetores bayesianos (isso é feito por padrão no Flux), sem ter certeza se isso estava no trabalho mencionado. Talvez, devido ao segundo ponto, a precisão no conjunto de testes de nosso modelo tenha sido ligeiramente maior (98,6% versus 98,1%), mas diferenças hiperparamétricas também possam ser o motivo.
Incomum (para quem tem experiência em aprendizado de máquina) é a ativação de funções
x.^2
. Na maioria dos casos, nesses casos, eles usam
tanh
,
relu
ou algo mais fantasioso. Porém, embora essas funções (especialmente
relu
) sejam facilmente calculadas para valores de texto comuns, no entanto, elas podem exigir muitos recursos computacionais para avaliá-las em forma criptografada (geralmente estimamos a aproximação polinomial). Felizmente, neste caso,
x.^2
funciona muito bem.
O restante do ciclo de aprendizado permaneceu o mesmo. Removemos o
softmax
do modelo para a função de perda através da
logitcrossentropy
(você pode deixá-lo e avaliar o softmax após a descriptografia no cliente). O código completo para o treinamento do modelo está
no GitHub , ele é executado em alguns minutos em qualquer nova placa de vídeo.
Operações efetivas
Agora sabemos quais operações precisamos executar:
- Coagulação.
- Elemento quadrado.
- Multiplicação de matrizes.
Ao quadrado tudo é simples, já o examinamos acima, portanto consideraremos duas outras operações. Assumimos que o comprimento do pacote de dados é 64 (você pode notar que os parâmetros do modelo e o tamanho do pacote são escolhidos de forma a tirar proveito do vetor de 4096 elementos que obtivemos como resultado de uma escolha realista dos parâmetros).
Coagulação
Lembre-se de como a coagulação funciona. Pegue uma janela (no nosso caso 7x7) da matriz de entrada original e cada elemento da janela é multiplicado por um elemento de máscara de convolução. Em seguida, movemos a janela para algum passo (no nosso caso, o passo é 3, ou seja, movemos 3 elementos) e repetimos o processo (com a mesma máscara de convolução). A animação do processo (
fonte ) para a convolução 3x3 com a etapa
(2, 2)
mostrada abaixo (matriz azul - entrada, verde - saída):
Além disso, realizamos a convolução em quatro "canais" diferentes (ou seja, repetimos a convolução mais três vezes com máscaras diferentes).
Agora sabemos o que fazer, resta entender como. Temos a sorte de que a convolução é a primeira operação em nosso modelo. Como resultado, para economizar recursos, podemos pré-processar os dados no cliente e depois criptografá-los (sem usar pesos). Vamos fazer o seguinte:
- Primeiro, calculamos cada janela de convolução (ou seja, uma amostra 7x7 das imagens de origem), o que fornece 64 matrizes 7x7 para cada imagem de entrada. Observe que, para uma janela 7x7 em incrementos de 2, haverá janelas de convolução 8x8 para avaliar a imagem de entrada 28x28.
- Vamos coletar em um vetor as mesmas posições em cada janela. Ou seja, para cada imagem, teremos um vetor de 64 elementos ou um vetor de 64x64 para um pacote de tamanho 64 (um total de 49 matrizes 64x64).
- Vamos criptografar.
Então a coagulação simplesmente se transforma em multiplicação escalar de toda a matriz com o elemento de máscara correspondente. E resumindo mais tarde todos os 49 elementos, obtemos o resultado da dobragem. Aqui está a aparência da implementação dessa estratégia (em texto simples):
function public_preprocess(batch) ka = OffsetArray(0:7, 0:7)
Este (módulo para alterar a dimensão) (módulo - alterar a ordem dos tamanhos) fornece a mesma resposta que a operação
model.layers[1](batch)
.
Adicione operações de criptografia:
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)
Observe que a chave não é necessária aqui porque os pesos são públicos. Portanto, não aumentamos o comprimento do texto cifrado.
Multiplicação de matrizes
Passando para a multiplicação de matrizes, podemos usar a rotação de elementos no vetor para alterar a ordem dos índices de multiplicação. Vamos considerar o layout da linha dos elementos da matriz em um vetor. Se mudarmos o vetor por um múltiplo do tamanho da linha, obtemos o efeito da rotação da coluna, que é uma primitiva suficiente para implementar a multiplicação de matrizes (pelo menos matrizes quadradas). Vamos tentar:
function matmul_square_reordered(weights, x) sum(1:size(weights, 1)) do k
Obviamente, para a multiplicação geral da matriz, é necessário algo mais complicado, mas por enquanto isso é suficiente.
Melhorando a técnica
Agora todos os componentes de nossa técnica funcionam. Aqui está o código inteiro (exceto para definir opções de seleção e coisas semelhantes):
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)
Não parece muito legal, mas se você fez tudo isso, deve entender cada passo.
Agora vamos pensar sobre quais abstrações poderiam simplificar nossas vidas. Estamos saindo do campo da cartografia e do aprendizado de máquina e passando para a arquitetura da linguagem de programação, então vamos aproveitar o fato de que Julia permite que você use e crie abstrações poderosas. Por exemplo, você pode encapsular todo o processo de extração de convoluções no seu tipo de matriz:
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}
Aqui, novamente usamos o
BlockArrays
para representar uma matriz
8x8x4x64
como quatro matrizes
8x8x1x64
como no código-fonte. Agora, a apresentação do primeiro estágio ficou muito mais bonita, pelo menos com matrizes não criptografadas:
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]
Agora, como conectamos isso à criptografia? Para fazer isso, você precisa:
- Criptografe a estrutura (
ExplodedConvArray
) para obter o texto cifrado para cada campo. Operações com uma estrutura criptografada verificarão o que a função faria com a estrutura original e farão a mesma coisa homomorficamente.
- Intercepte determinadas operações para executá-las de maneira diferente em um contexto criptografado.
Felizmente, Julia nos fornece uma abstração para isso: um plug-in de compilador que usa o mecanismo
Cassette.jl . Não vou lhe dizer o que é e como funciona, vou dizer brevemente que ele pode determinar o contexto, por exemplo,
Encrypted
e, em seguida, define as regras de funcionamento das operações nesse contexto. Por exemplo, você pode escrever isso para o segundo requisito:
Como resultado, o usuário poderá escrever todos os itens acima com uma quantidade mínima de trabalho manual:
kp = keygen(ckks_params) ek = keygen(EvalMultKey, kp.priv) gk = keygen(GaloisKey, kp.priv; steps=64)
, . ( ℛ, modswitch, keyswitch ..) , . , , , , .
Conclusão
— . Julia . RAMPARTS (
paper ,
JuliaCon talk ) : Julia- - PALISADE. Julia Computing RAMPARTS Verona,
. , . . , , .
,
ToyFHE .
, , , .