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:
- Modelle für maschinelles Lernen sind groß und Benutzercomputer verfügen möglicherweise nicht über genügend Computer- oder Festplattenressourcen.
- 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.
- 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
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:
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> ℛ
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(
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)
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
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}
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:
- 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.
- 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:
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)
, . ( ℛ, modswitch, keyswitch ..) , . , , , , .
Fazit
— . Julia . RAMPARTS (
paper ,
JuliaCon talk ) : Julia- - PALISADE. Julia Computing RAMPARTS Verona,
. , . . , , .
,
ToyFHE .
, , , .