Verwenden verschlüsselter Daten für maschinelles Lernen, ohne sie zu entschlüsseln


Verwenden verschlüsselter Daten für maschinelles Lernen, ohne sie zu entschlüsseln
Dieser Artikel beschreibt fortgeschrittene kryptographische Techniken. Dies ist nur ein Überblick über die von Julia Computing durchgeführten Forschungsarbeiten. Verwenden Sie die hier angegebenen Beispiele nicht für kommerzielle Anwendungen. Wenden Sie sich immer an Kryptografen, bevor Sie Kryptografie anwenden.

Hier können Sie das Paket herunterladen, das die ganze Magie implementiert, und hier ist der Code, der im Artikel beschrieben wird.

Einleitung


Angenommen , Sie haben gerade ein cooles neues Modell für maschinelles Lernen entwickelt (natürlich mit Flux.jl ). Und jetzt möchten Sie es für Ihre Benutzer bereitstellen. Wie wirst du das machen? Am einfachsten ist es wahrscheinlich, das Modell den Benutzern zu geben und es lokal auf ihren Daten laufen zu lassen. Dieser Ansatz hat jedoch Nachteile:

  1. Modelle für maschinelles Lernen sind groß und Benutzercomputer verfügen möglicherweise nicht über genügend Computer- oder Festplattenressourcen.
  2. Modelle für maschinelles Lernen werden häufig aktualisiert, und es ist möglicherweise nicht bequem, regelmäßig große Datenmengen über das Netzwerk zu senden.
  3. Die Modellentwicklung ist zeitaufwändig und erfordert eine große Menge an Rechenressourcen. Möglicherweise möchten Sie eine Entschädigung in Form einer Gebühr für die Verwendung Ihres Modells.

Dann erinnern sie sich normalerweise daran, dass das Modell über die API in der Cloud bereitgestellt werden kann. In den letzten Jahren sind viele solcher Dienste erschienen, und jede große Cloud-Plattform bietet Unternehmensentwicklern ähnliche Dienste. Potenzielle Benutzer stehen jedoch vor einem offensichtlichen Dilemma: Jetzt werden ihre Daten auf einem Remote-Server verarbeitet, der möglicherweise nicht vertrauenswürdig ist. Dies hat klare ethische und rechtliche Auswirkungen, die die Nutzung solcher Dienste einschränken. In regulierten Branchen, insbesondere im Gesundheits- und Finanzwesen, ist es häufig nicht möglich, Patienten- und Kundendaten zur Verarbeitung an Dritte zu senden.

Irgendwelche anderen Optionen?

Es stellt sich heraus, dass es gibt! Jüngste Entdeckungen in der Kryptographie ermöglichen das Rechnen mit Daten, ohne sie zu dekodieren . Beispielsweise sendet ein Benutzer verschlüsselte Daten (z. B. Bilder) an die Cloud-API, die ein Modell für maschinelles Lernen startet, und sendet dann eine verschlüsselte Antwort. Zu keinem Zeitpunkt werden die Daten entschlüsselt, der Cloud-Anbieter erhält keinen Zugriff auf die Quellbilder und kann die berechnete Prognose nicht entschlüsseln. Wie ist das möglich? Informieren Sie sich am Beispiel der Erstellung eines Dienstes zur Handschrifterkennung für verschlüsselte Bilder aus dem MNIST-Datensatz.

Über homomorphe Verschlüsselung


Die Fähigkeit, Berechnungen mit verschlüsselten Daten durchzuführen, wird allgemein als "sicheres Rechnen" bezeichnet. Dies ist ein großes Forschungsgebiet mit zahlreichen Ansätzen für die Kryptographie, die von allen Arten von Anwendungsszenarien abhängen. Wir werden uns auf eine Technik namens "homomorphe Verschlüsselung" konzentrieren. In einem solchen System stehen uns normalerweise die folgenden Operationen zur Verfügung:

  • pub_key, eval_key, priv_key = keygen()
  • encrypted = encrypt(pub_key, plaintext)
  • decrypted = decrypt(priv_key, encrypted)
  • encrypted′ = eval(eval_key, f, encrypted)

Die ersten drei Vorgänge sind für alle Benutzer einfach und vertraut, die bereits asymmetrische Verschlüsselungsalgorithmen verwendet haben (z. B. wenn Sie eine Verbindung über TLS hergestellt haben). Alle Magie geschieht in der letzten Operation. Während der Verschlüsselung wertet es die Funktion f und gibt einen anderen verschlüsselten Wert zurück, der gemäß dem Ergebnis der Bewertung von f für den verschlüsselten Wert berechnet wurde. Dieses Feature gab seinem Ansatz seinen Namen. Die Bewertung bezieht sich auf den Verschlüsselungsvorgang:

 f(decrypt(priv_key, encrypted)) == decrypt(priv_key, eval(eval_key, f, encrypted)) 

Ebenso können wir mit einem verschlüsselten Wert beliebige Homomorphismen f auswerten.

Welche Funktionen f unterstützt werden, hängt von den Verschlüsselungsschemata und den unterstützten Operationen ab. Wenn nur ein f unterstützt wird (zum Beispiel f = + ), wird die Schaltung als "teilweise homomorph" bezeichnet. Wenn f ein vollständiger Satz von Gateways sein kann, auf deren Grundlage beliebige Schemata erstellt werden können, wird dies für eine begrenzte Größe eines Schemas eine andere Art einer teilweise homomorphen Berechnung genannt - "etwas homomorph" und für eine unbegrenzte Größe - "vollständig homomorph". Sie können mithilfe der Bootstrapping-Technik "auf irgendeine Weise" eine vollständig homomorphe Verschlüsselung erstellen, was jedoch den Rahmen unseres Artikels sprengt. Völlig homomorphe Verschlüsselung ist eine relativ junge Entdeckung. Das erste (wenn auch unpraktische) Arbeitsschema wurde 2009 von Craig Gentry veröffentlicht . Es gibt eine Reihe späterer (und praktischer) vollständig homomorpher Schemata. Es gibt auch Softwarepakete, die diese Schemata qualitativ implementieren. Am häufigsten verwenden sie Microsoft SEAL und PALISADE . Außerdem habe ich kürzlich den Implementierungscode für diese Pure Julia- Algorithmen geöffnet. In diesem Artikel verwenden wir die darin implementierte CKKS-Verschlüsselung.

CKS Übersicht


CKKS (unter den Namen der Autoren der wissenschaftlichen Arbeit Cheon-Kim-Kim-Song, die den Algorithmus 2016 vorgeschlagen hat) ist ein homomorphes Verschlüsselungsschema, das die homomorphe Auswertung der folgenden primitiven Operationen ermöglicht:

  • Die elementweise Addition der Längen von n Vektoren komplexer Zahlen.
  • Elementweise Multiplikation der Längen von n komplexen Vektoren.
  • Drehen Sie (im Kontext der circshift ) Elemente in einem Vektor.
  • Integrierte Paarung von Vektorelementen.

Der Parameter n hängt von der gewünschten Sicherheit und Genauigkeit ab und ist normalerweise ziemlich hoch. In unserem Beispiel ist es 4096 (ein höherer Wert erhöht die Sicherheit, ist aber auch schwieriger zu berechnen, da er ungefähr wie n log n skaliert).

Darüber hinaus sind Berechnungen mit CKKS verrauscht . Daher sind die Ergebnisse ungefähr und es muss darauf geachtet werden, dass die Ergebnisse mit ausreichender Genauigkeit ausgewertet werden, um die Richtigkeit des Ergebnisses nicht zu beeinträchtigen.

Andererseits sind solche Einschränkungen für Entwickler von maschinellen Lernpaketen nicht ungewöhnlich. Spezielle Beschleuniger wie die GPU arbeiten üblicherweise auch mit Zahlenvektoren. Darüber hinaus sehen Gleitkommazahlen für viele Entwickler aufgrund des Einflusses von Auswahlalgorithmen, Multithreading usw. manchmal verrauscht aus. Ich möchte betonen, dass der Hauptunterschied hier darin besteht, dass arithmetische Berechnungen mit Gleitkommazahlen anfänglich deterministisch sind, auch wenn dies aufgrund der Komplexität der Implementierung nicht offensichtlich ist, obwohl die CKKS-Grundelemente wirklich verrauscht sind. Aber vielleicht können Benutzer dadurch verstehen, dass das Geräusch nicht so furchterregend ist, wie es scheinen mag.

Nun wollen wir sehen, wie Sie diese Operationen in Julia ausführen können (Anmerkung: Es sind sehr unsichere Parameter ausgewählt, mit diesen Operationen wird nur die Verwendung der Bibliothek in REPL veranschaulicht).

 julia> using ToyFHE # Let's play with 8 element vectors julia> N = 8; # Choose some parameters - we'll talk about it later julia> ℛ = NegacyclicRing(2N, (40, 40, 40)) ℤ₁₃₂₉₂₂₇₉₉₇₅₆₈₀₈₁₄₅₇₄₀₂₇₀₁₂₀₇₁₀₄₂₄₈₂₅₇/(x¹⁶ + 1) # We'll use CKKS julia> params = CKKSParams(ℛ) CKKS parameters # We need to pick a scaling factor for a numbers - again we'll talk about that later julia> Tscale = FixedRational{2^40} FixedRational{1099511627776,T} where T # Let's start with a plain Vector of zeros julia> plain = CKKSEncoding{Tscale}(zero(ℛ)) 8-element CKKSEncoding{FixedRational{1099511627776,T} where T} with indices 0:7: 0.0 + 0.0im 0.0 + 0.0im 0.0 + 0.0im 0.0 + 0.0im 0.0 + 0.0im 0.0 + 0.0im 0.0 + 0.0im 0.0 + 0.0im # Ok, we're ready to get started, but first we'll need some keys julia> kp = keygen(params) CKKS key pair julia> kp.priv CKKS private key julia> kp.pub CKKS public key # Alright, let's encrypt some things: julia> foreach(i->plain[i] = i+1, 0:7); plain 8-element CKKSEncoding{FixedRational{1099511627776,T} where T} with indices 0:7: 1.0 + 0.0im 2.0 + 0.0im 3.0 + 0.0im 4.0 + 0.0im 5.0 + 0.0im 6.0 + 0.0im 7.0 + 0.0im 8.0 + 0.0im julia> c = encrypt(kp.pub, plain) CKKS ciphertext (length 2, encoding CKKSEncoding{FixedRational{1099511627776,T} where T}) # And decrypt it again julia> decrypt(kp.priv, c) 8-element CKKSEncoding{FixedRational{1099511627776,T} where T} with indices 0:7: 0.9999999999995506 - 2.7335193113350057e-16im 1.9999999999989408 - 3.885780586188048e-16im 3.000000000000205 + 1.6772825551165524e-16im 4.000000000000538 - 3.885780586188048e-16im 4.999999999998865 + 8.382500573679615e-17im 6.000000000000185 + 4.996003610813204e-16im 7.000000000001043 - 2.0024593503998215e-16im 8.000000000000673 + 4.996003610813204e-16im # Note that we had some noise. Let's go through all the primitive operations we'll need: julia> decrypt(kp.priv, c+c) 8-element CKKSEncoding{FixedRational{1099511627776,T} where T} with indices 0:7: 1.9999999999991012 - 5.467038622670011e-16im 3.9999999999978817 - 7.771561172376096e-16im 6.00000000000041 + 3.354565110233105e-16im 8.000000000001076 - 7.771561172376096e-16im 9.99999999999773 + 1.676500114735923e-16im 12.00000000000037 + 9.992007221626409e-16im 14.000000000002085 - 4.004918700799643e-16im 16.000000000001346 + 9.992007221626409e-16im julia> csq = c*c CKKS ciphertext (length 3, encoding CKKSEncoding{FixedRational{1208925819614629174706176,T} where T}) julia> decrypt(kp.priv, csq) 8-element CKKSEncoding{FixedRational{1208925819614629174706176,T} where T} with indices 0:7: 0.9999999999991012 - 2.350516767363621e-15im 3.9999999999957616 - 5.773159728050814e-15im 9.000000000001226 - 2.534464540987068e-15im 16.000000000004306 - 2.220446049250313e-15im 24.99999999998865 + 2.0903753311370056e-15im 36.00000000000222 + 4.884981308350689e-15im 49.000000000014595 + 1.0182491378134327e-15im 64.00000000001077 + 4.884981308350689e-15im 

So einfach! Ein aufmerksamer Leser kann feststellen, dass sich CSQ geringfügig vom vorherigen Chiffretext unterscheidet. Insbesondere der Chiffretext hat die Länge 3 und der Maßstab ist viel größer. Eine Erklärung dessen, was dies ist und was benötigt wird, würde den Rahmen dieses Artikels sprengen. Es genügt zu sagen, dass wir die Werte senken müssen, bevor wir mit den Berechnungen fortfahren, sonst endet die "Stelle" im Chiffretext. Glücklicherweise können wir jeden der beiden erhöhten Werte reduzieren:

 # To get back down to length 2, we need to `keyswitch` (aka # relinerarize), which requires an evaluation key. Generating # this requires the private key. In a real application we would # have generated this up front and sent it along with the encrypted # data, but since we have the private key, we can just do it now. julia> ek = keygen(EvalMultKey, kp.priv) CKKS multiplication key julia> csq_length2 = keyswitch(ek, csq) CKKS ciphertext (length 2, encoding CKKSEncoding{FixedRational{1208925819614629174706176,T} where T}) # Getting the scale back down is done using modswitching. julia> csq_smaller = modswitch(csq_length2) CKKS ciphertext (length 2, encoding CKKSEncoding{FixedRational{1.099511626783e12,T} where T}) # And it still decrypts correctly (though note we've lost some precision) julia> decrypt(kp.priv, csq_smaller) 8-element CKKSEncoding{FixedRational{1.099511626783e12,T} where T} with indices 0:7: 0.9999999999802469 - 5.005163520332181e-11im 3.9999999999957723 - 1.0468514951188039e-11im 8.999999999998249 - 4.7588542623100616e-12im 16.000000000023014 - 1.0413447889166631e-11im 24.999999999955193 - 6.187833723406491e-12im 36.000000000002345 + 1.860733715346631e-13im 49.00000000001647 - 1.442396043149794e-12im 63.999999999988695 - 1.0722489563648028e-10im 

Darüber hinaus reduziert Modswitching (kurz für Modulumschaltung, Modulumschaltung) die Größe des Chiffretext-Moduls, sodass wir dies nicht unbegrenzt fortsetzen können (wir verwenden ein etwas homomorphes Verschlüsselungsschema):

 julia> ℛ # Remember the ring we initially created ℤ₁₃₂₉₂₂₇₉₉₇₅₆₈₀₈₁₄₅₇₄₀₂₇₀₁₂₀₇₁₀₄₂₄₈₂₅₇/(x¹⁶ + 1) julia> ToyFHE.ring(csq_smaller) # It shrunk! ℤ₁₂₀₈₉₂₅₈₂₀₁₄₄₅₉₃₇₇₉₃₃₁₅₅₃/(x¹⁶ + 1)</code>     —  (rotations).      keyswitch,       (evaluation key,     ): <source lang="julia">julia> gk = keygen(GaloisKey, kp.priv; steps=2) CKKS galois key (element 25) julia> decrypt(circshift(c, gk)) decrypt(kp, circshift(c, gk)) 8-element CKKSEncoding{FixedRational{1099511627776,T} where T} with indices 0:7: 7.000000000001042 + 5.68459112632516e-16im 8.000000000000673 + 5.551115123125783e-17im 0.999999999999551 - 2.308655353580721e-16im 1.9999999999989408 + 2.7755575615628914e-16im 3.000000000000205 - 6.009767921608429e-16im 4.000000000000538 + 5.551115123125783e-17im 4.999999999998865 + 4.133860996136768e-17im 6.000000000000185 - 1.6653345369377348e-16im # And let's compare to doing the same on the plaintext julia> circshift(plain, 2) 8-element OffsetArray(::Array{Complex{Float64},1}, 0:7) with eltype Complex{Float64} with indices 0:7: 7.0 + 0.0im 8.0 + 0.0im 1.0 + 0.0im 2.0 + 0.0im 3.0 + 0.0im 4.0 + 0.0im 5.0 + 0.0im 6.0 + 0.0im 

Wir haben die Grundlagen der Nutzung der HE-Bibliothek behandelt. Bevor wir jedoch mit der Berechnung von Vorhersagen für neuronale Netze fortfahren, schauen wir uns den Lernprozess an.

Modell des maschinellen Lernens


Wenn Sie nicht mit maschinellem Lernen oder der Flux.jl-Bibliothek vertraut sind, empfehle ich einen kurzen Durchlauf der Flux.jl-Dokumentation oder eine kostenlose Einführung in maschinelles Lernen , da wir nur Änderungen bei der Anwendung des Modells auf verschlüsselte Daten diskutieren.

Beginnen wir mit dem Faltungsnetzwerk aus dem Flux-Zoo . Wir werden den gleichen Trainingszyklus durchführen, mit Datenaufbereitung und so weiter, nur das Modell ein wenig einrichten. Da ist sie:

 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( # First convolution, operating upon a 28x28 image Conv((7, 7), 1=>4, stride=(3,3), x->x.^2), reshape_and_vcat, Dense(256, 64, x->x.^2), Dense(64, 10), ) 

Dies ist das gleiche Modell wie in der Arbeit „Sichere ausgelagerte Matrixberechnung und Anwendung auf neuronale Netze“ , die dasselbe kryptografische Schema mit zwei Unterschieden verwendet: 1) Der Einfachheit halber haben wir das Modell selbst nicht verschlüsselt und 2) nach jeder Schicht, die wir haben Bayesianische Vektoren werden verwendet (in Flux wird dies standardmäßig durchgeführt), ich bin mir nicht sicher, was es in der erwähnten Arbeit war. Möglicherweise stellte sich aufgrund des zweiten Punktes heraus, dass die Genauigkeit des Testsatzes unseres Modells geringfügig höher war (98,6% gegenüber 98,1%), aber auch hyperparametrische Unterschiede könnten der Grund sein.

Ungewöhnlich (für diejenigen, die Erfahrung im maschinellen Lernen haben) ist die x.^2 Aktivierung von Funktionen. Meistens benutzen sie in solchen Fällen relu , relu oder etwas Phantasievolleres. Obwohl diese Funktionen (insbesondere relu ) für gewöhnliche relu leicht berechnet werden können, erfordern sie möglicherweise eine Menge Rechenressourcen, um sie in verschlüsselter Form auszuwerten (wir schätzen normalerweise die Polynomapproximation). Zum Glück funktioniert in diesem Fall x.^2 sehr gut.

Der Rest des Lernzyklus blieb gleich. Wir haben softmax aus dem Modell für die Verlustfunktion logitcrossentropy (Sie können es verlassen und softmax nach der Entschlüsselung auf dem Client auswerten). Der komplette Code zum Trainieren des Modells liegt auf GitHub , es läuft in wenigen Minuten auf jeder neuen Grafikkarte.

Effektive Operationen


Jetzt wissen wir, welche Operationen wir durchführen müssen:

  • Koagulation.
  • Quadratur der Elemente.
  • Matrixmultiplikation.

Beim Quadrieren ist alles einfach, wir haben es bereits oben untersucht, also werden wir zwei andere Operationen betrachten. Wir nehmen an, dass die Länge des Datenpakets 64 beträgt (Sie werden möglicherweise bemerken, dass die Modellparameter und die Paketgröße so gewählt werden, dass der 4096-Elemente-Vektor, den wir als Ergebnis einer realistischen Auswahl von Parametern erhalten haben, ausgenutzt wird).

Koagulation


Denken Sie daran, wie die Gerinnung funktioniert. Nehmen Sie ein Fenster (in unserem Fall 7x7) des ursprünglichen Eingabearrays, und jedes Fensterelement wird mit einem Faltungsmaskenelement multipliziert. Dann bewegen wir das Fenster zu einem Schritt (in unserem Fall ist der Schritt 3, dh wir bewegen 3 Elemente) und wiederholen den Vorgang (mit der gleichen Faltungsmaske). Die Animation des Prozesses ( Quelle ) für die 3x3-Faltung mit dem Schritt (2, 2) unten dargestellt (blaues Array - Eingang, grüner Ausgang):


Außerdem führen wir die Faltung in vier verschiedenen „Kanälen“ durch (dh, wir wiederholen die Faltung dreimal mit verschiedenen Masken).

Jetzt wissen wir, was zu tun ist, es bleibt zu verstehen, wie. Wir haben das Glück, dass die Faltung die erste Operation in unserem Modell ist. Um Ressourcen zu sparen, können wir die Daten auf dem Client vorverarbeiten und dann verschlüsseln (ohne Gewichte zu verwenden). Lass uns das machen:

  • Zuerst berechnen wir jedes Faltungsfenster (dh ein 7x7-Sample aus den Quellbildern), wodurch wir 64 7x7-Matrizen für jedes Eingabebild erhalten. Beachten Sie, dass für ein 7x7-Fenster in Schritten von 2 8x8-Faltungsfenster zur Auswertung des 28x28-Eingabebilds vorhanden sind.
  • Sammeln wir in einem Vektor die gleichen Positionen in jedem Fenster. Das heißt, für jedes Bild haben wir einen 64-Element-Vektor oder einen Vektor von 64 × 64 Elementen für ein Paket der Größe 64 (insgesamt 49 64 × 64 Matrizen).
  • Wir werden verschlüsseln.

Die Koagulation wird dann einfach zu einer skalaren Multiplikation der gesamten Matrix mit dem entsprechenden Maskenelement. Wenn wir später alle 49 Elemente zusammenfassen, erhalten wir das Ergebnis der Faltung. So könnte die Umsetzung dieser Strategie aussehen (im Klartext):

 function public_preprocess(batch) ka = OffsetArray(0:7, 0:7) # Create feature extracted matrix I = [[batch[i′*3 .+ (1:7), j′*3 .+ (1:7), 1, k] for i′=ka, j′=ka] for k = 1:64] # Reshape into the ciphertext Iᵢⱼ = [[I[k][l...][i,j] for k=1:64, l=product(ka, ka)] for i=1:7, j=1:7] end Iᵢⱼ = public_preprocess(batch) # Evaluate the convolution weights = model.layers[1].weight conv_weights = reverse(reverse(weights, dims=1), dims=2) conved = [sum(Iᵢⱼ[i,j]*conv_weights[i,j,1,channel] for i=1:7, j=1:7) for channel = 1:4] conved = map(((x,b),)->x .+ b, zip(conved, model.layers[1].bias)) 

Dies (Modul zum Ändern der Abmessung) (Modulo - Ändern der model.layers[1](batch) ) gibt die gleiche Antwort wie die Operation model.layers[1](batch) .

Hinzufügen von Verschlüsselungsvorgängen:

 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) 

Bitte beachten Sie, dass hier kein Schlüsselschalter erforderlich ist, da die Gewichte öffentlich sind. Wir verlängern den Chiffretext also nicht.

Matrixmultiplikation


Um zur Matrixmultiplikation überzugehen, können wir die Rotation von Elementen im Vektor verwenden, um die Reihenfolge der Multiplikationsindizes zu ändern. Betrachten Sie die zeilenweise Platzierung von Matrixelementen in einem Vektor. Wenn wir den Vektor um ein Vielfaches der Zeilengröße verschieben, erhalten wir den Effekt der Spaltendrehung, was für die Implementierung der Matrixmultiplikation (mindestens quadratische Matrizen) ausreicht. Lass es uns versuchen:

 function matmul_square_reordered(weights, x) sum(1:size(weights, 1)) do k # We rotate the columns of the LHS and take the diagonal weight_diag = diag(circshift(weights, (0,(k-1)))) # We rotate the rows of the RHS x_rotated = circshift(x, (k-1,0)) # We do an elementwise, broadcast multiply weight_diag .* x_rotated end end function matmul_reorderd(weights, x) sum(partition(1:256, 64)) do range matmul_square_reordered(weights[:, range], x[range, :]) end end fc1_weights = model.layers[3].W x = rand(Float64, 256, 64) @assert (fc1_weights*x) ≈ matmul_reorderd(fc1_weights, x) 

Natürlich ist für die allgemeine Matrixmultiplikation etwas Komplizierteres erforderlich, aber für den Moment ist dies ausreichend.

Verbesserung der Technik


Jetzt funktionieren alle Komponenten unserer Technik. Hier ist der gesamte Code (außer zum Festlegen von Auswahloptionen und ähnlichen Dingen):

 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) 

Es sieht nicht allzu ordentlich aus, aber wenn Sie dies alles getan haben, sollten Sie jeden Schritt verstehen.
Überlegen wir uns nun, welche Abstraktionen unser Leben vereinfachen könnten. Wir verlassen das Gebiet der Kartografie und des maschinellen Lernens und wechseln zur Architektur der Programmiersprache. Nutzen wir also die Tatsache, dass Julia es Ihnen ermöglicht, leistungsstarke Abstraktionen zu verwenden und zu erstellen. Beispielsweise können Sie den gesamten Prozess des Extrahierens von Windungen in Ihren Array-Typ einkapseln:

 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} # sx*sy matrix of b*(dx*dy) matrices of extracted elements # where (sx, sy) = kernel_size(Dims) # (dx, dy) = output_size(DenseConvDims(...)) cdims::Dims x::Matrix{Storage} function ExplodedConvArray{T, Dims, Storage}(cdims::Dims, storage::Matrix{Storage}) where {T, Dims, Storage} @assert all(==(size(storage[1])), size.(storage)) new{T, Dims, Storage}(cdims, storage) end end Base.size(ex::ExplodedConvArray) = (NNlib.input_size(ex.cdims)..., 1, size(ex.x[1], 1)) function ExplodedConvArray{T}(cdims, batch::AbstractArray{T, 4}) where {T} x, y = NNlib.output_size(cdims) kx, ky = NNlib.kernel_size(cdims) stridex, stridey = NNlib.stride(cdims) kax = OffsetArray(0:x-1, 0:x-1) kay = OffsetArray(0:x-1, 0:x-1) I = [[batch[i′*stridex .+ (1:kx), j′*stridey .+ (1:ky), 1, k] for i′=kax, j′=kay] for k = 1:size(batch, 4)] Iᵢⱼ = [[I[k][l...][i,j] for k=1:size(batch, 4), l=product(kax, kay)] for (i,j) in product(1:kx, 1:ky)] ExplodedConvArray{T, typeof(cdims), eltype(Iᵢⱼ)}(cdims, Iᵢⱼ) end function NNlib.conv(x::ExplodedConvArray{<:Any, Dims}, weights::AbstractArray{<:Any, 4}, cdims::Dims) where {Dims<:ConvDims} blocks = reshape([ Base.ReshapedArray(sum(xx[i,j]*weights[i,j,1,channel] for i=1:7, j=1:7), (NNlib.output_size(cdims)...,1,size(x, 4)), ()) for channel = 1:4 ],(1,1,4,1)) BlockArrays._BlockArray(blocks, BlockArrays.BlockSizes([8], [8], [1,1,1,1], [64])) end 

Hier haben wir wieder BlockArrays , um ein 8x8x4x64 Array als vier 8x8x1x64 Arrays wie im Quellcode 8x8x1x64 . Jetzt ist die Darstellung der ersten Stufe, zumindest bei unverschlüsselten Arrays, viel schöner geworden:

 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] 

Wie verbinden wir das nun mit Verschlüsselung? Dazu benötigen Sie:

  1. Verschlüsseln Sie die Struktur ( ExplodedConvArray ), damit wir den Chiffretext für jedes Feld erhalten. Operationen mit einer solchen verschlüsselten Struktur werden überprüfen, was die Funktion mit der ursprünglichen Struktur tun würde, und dasselbe homomorph tun.
  2. Fangen Sie bestimmte Vorgänge ab, um sie in einem verschlüsselten Kontext anders auszuführen.

Zum Glück liefert uns Julia dafür eine Abstraktion: ein Compiler-Plugin, das den Cassette.jl- Mechanismus verwendet. Ich werde Ihnen nicht sagen, was es ist und wie es funktioniert. Ich möchte kurz sagen, dass es den Kontext bestimmen kann, z. B. " Encrypted , und dann definiert es die Regeln, wie Vorgänge in diesem Kontext funktionieren sollen. Beispielsweise können Sie dies für die zweite Anforderung schreiben:

 # Define Matrix multiplication between an array and an encrypted block array function (*::Encrypted{typeof(*)})(a::Array{T, 2}, b::Encrypted{<:BlockArray{T, 2}}) where {T} sum(a*b for (i,range) in enumerate(partition(1:size(a, 2), size(b.blocks[1], 1)))) end # Define Matrix multiplication between an array and an encrypted array function (*::Encrypted{typeof(*)})(a::Array{T, 2}, b::Encrypted{Array{T, 2}}) where {T} result = repeat(diag(a), inner=size(a, 1)).*x rotated = b for k = 2:size(a, 2) rotated = ToyFHE.rotate(GaloisKey(*), rotated) result += repeat(diag(circshift(a, (0,(k-1)))), inner=size(a, 1)) .* rotated end result end 

Infolgedessen ist der Benutzer in der Lage, alles oben Genannte mit einem Minimum an manueller Arbeit zu schreiben:

 kp = keygen(ckks_params) ek = keygen(EvalMultKey, kp.priv) gk = keygen(GaloisKey, kp.priv; steps=64) # Create evaluation context ctx = Encrypted(ek, gk) # Do public preprocessing batch = ExplodedConvArray{eltype(batch)}(cdims, batch); # Run on encrypted data under the encryption context Cresult = ctx(model)(encrypt(kp.pub, batch)) # Decrypt the answer decrypt(kp, Cresult) 

, . ( ℛ, modswitch, keyswitch ..) , . , , , , .

Fazit


— . Julia . RAMPARTS ( paper , JuliaCon talk ) : Julia- - PALISADE. Julia Computing RAMPARTS Verona, . , . . , , .

, ToyFHE . , , , .

Source: https://habr.com/ru/post/de478514/


All Articles