This article is translated from CKKS EXPLAINED, PART 2: FULL ENCODING AND DECODING , this paper mainly introduces how to encode and decode in CKKS scheme (how to convert complex vector into integer polynomial and how to inverse operation)

## introduce

In the previous article< CKKS: Part 1, general encoding and decoding >In, we learned that in order to realize the calculation of encrypted complex vector in CKKS scheme, we must first construct an encoding and decoding to convert the complex vector into polynomial.

This encoding / decoding step is necessary because encryption, decryption and other mechanisms work on polynomial rings. Therefore, it is necessary to find a method to convert complex vectors into polynomials.

We also learned that by using standard embedding σ， That is, by calculating the polynomial on the root of \ (X^N+1 \), we can decode the polynomial in \ (ℂ ^ N - > ℂ [X] / (X^N+1) \). However, because we want our encoder to output polynomials \ (ℤ [X] / (X^N+1) \), in order to take advantage of the structure of polynomial integer rings, we need to modify the ordinary encoder in the previous article to output "polynomials of right rings". (i don't quite understand. It should be able to output polynomials without i)

Therefore, in this paper, we will discuss how to realize the encoding and decoding used in the original paper "Homomorphic Encryption for Arithmetic of Approximate Numbers", which will be the first step for us to realize CKK from scratch.

## CKKS code

The difference from the previous article is that the plaintext space of the encoded polynomial is now \ (r = Z \ left [x \ right] / x ^ {n} + 1 \) instead of \ (\ Mbox {C} \ left [x \ right] / x ^ {n} + 1 \), so the coefficients of the encoded value polynomial must be integer coefficients. However, when we encode a vector into \ (C^N \), We have learned that its encoding is not necessarily integer coefficients (some are complex coefficients).

To solve this problem, let's look at standard embedding σ Image on R.

Because the polynomial R is an integer coefficient, that is, a real coefficient, we calculate them on the complex root, half of which is the conjugate term of the other half (see the previous chapter). We have \ (\ Sigma \ left (R \ right) \ in H = Z \ in \ mbox{C} ^ {n}: Z_ {j}=\neg z_ {-j}\).

Like M=8 in the previous chapter:

From the picture above, \ (\ omega ^{1}=-\omega ^{7}\; and\; \omega ^{3}=-\omega ^{5} \), generally speaking, we use the root of \ (X^N+1 \) to calculate a polynomial. For any polynomial \ (m \ left (x \ right) \ in R, m \ left (\ Xi ^ {J} \ right) = - M \ left (\ Xi ^ {- J} \ right) = m \ left (- \ Xi ^ {- J} \ right)), therefore\( σ (R) Any element on \) is actually in an N/2 space, not n. Therefore, if we use a complex vector with the size of N/2 when encoding a vector in CKKS, we need to expand its other half by copying its conjugate root.

This operation needs to project \ (ℍ \) to \ (ℂ ^ {N/2} \), which is called π in CKKS paper. Note that this also defines isomorphism.

Now we can start with \ (Z ∈ ℂ ^ {N/2} \) and expand with \ (π ^ {− 1} \) (note that π is a mapping and \ (π ^ {− 1} \) is an extension). We can get \ (π ^ {− 1} (z) ∈ ℍ \)

One of the problems we face is that we can't use directly\( σ: R=ℤ[X]/(X^N+1)→ σ (R) ⊆ ℍ \), because ℍ is not necessarily in σ (R) Medium σ Isomorphism is indeed defined, but only from R to σ (R). To prove σ (R) Not equal to ℍ, you can notice that R is countable (?) therefore σ (R) Yes, but ℍ is not because it is isomorphic with ℂ.

This detail is important because it means that we must find a way to σ (R) For this purpose, we will use a technique called "coordinate wise random rounding", which is A Toolkit for Ring-LWE Cryptography Defined in. This rounding technique allows the real number x to be rounded to ⌊ x ⌋ or ⌊ x ⌋ + 1. We will not discuss the details of this algorithm in depth, although we will implement it.

The idea is very simple. There is an orthogonal basis ℤ: \ (1, x,..., x ^ {n − 1} \), assuming σ Is isomorphic, σ (R) There is an orthogonal basis:\( β= (b1，b2，…，bN)=( σ (1)， σ (X)，...， σ (X^{N−1}))\). Therefore, for any Z ∈ ℍ, we will simply project it to β Up: $$z = \ sum_ {i=1}^{N}{z_{i}b_ {i},z_ {i}=\frac{<z,b_{i}>}{\left| \left| b_{i} \right| \right|^{2}}}$$

Because the basis is either orthogonal or not orthogonal, so \ (Z {I} = \ frac {< Z, B {I} >} {\ left {B {I} \ right | ^ {2} \), please note that we use hermitian product (hermitian product): \ (< x, Y > = \ sum {I = 1} ^ {N} {x {I} \ left (- y {I} \ right)} \), hermitian product gives the real output, because it is on ℍ, You can prove it by calculation, or notice that you can find isomorphism between ℍ and \ (ℝ ^ N \), so the inner product on ℍ will be the actual output.

Finally, once we have \ (z_i \), we just need to use "coordinate wise random rounding" to randomly round them to the nearest integer higher or lower. So we get a polynomial whose base coordinates are integers\(( σ (1)， σ (X)，...， σ (X^N) − 1)) \), so the polynomial will belong to σ (R) .

Once we have a mapping relationship σ (R) , we can use\( σ^ The output of {− 1} \), which is exactly what we want!

Last detail: because rounding may destroy some important numbers, we actually need to multiply them in the code Δ> 0, divided by in decoding Δ To keep 1/ Δ Accuracy. To understand how it works, suppose you want to round x=1.4, but don't want to round it to the nearest integer, but round it to the nearest 0.25 times to maintain a certain accuracy. Then you need to set the scale Δ= 4. Its accuracy is 1 Δ= 0.25. Indeed, now when we \ (\ left\lfloor \Delta x \right\rfloor=\left\lfloor 4\cdot 1.4 \right\rfloor=\left\lfloor 5.6 \right\rfloor=6 \) once we divide it by the same Δ， We get 1.5, which is actually the closest multiple of x=1.4, 0.25.

So the final coding process is:

Take \ (z ∈ ℂ ^ {N/2} \) as an example

Extend it to \ (π ^ {- 1} ∈ H \);

Multiply it by Δ To ensure accuracy

Mapping: \ (\ left \ lfloor \ Delta \ PI ^ {- 1} \ left (Z \ right) \ right \ rfloor_ {\sigma \left( R \right)}\in \sigma \left( R \right)\)

use σ:\ (m\left( x \right)=\sigma ^{-1}\left( \left\lfloor \Delta \pi ^{-1}\left( z \right) \right\rfloor_ {\ Sigma \ left (R \ right)} \ right) \ in R \) encode it

The decoding process is much simpler. From the polynomial m (X), we only get \ (z = π∘) σ ( Δ^ {−1}.m)\).

## realization

Now we finally see how the complete CKKS encoding and decoding works. Let's implement it! We will use the code previously used for the Vanilla encoder and decoder. The code can be in here.

In the rest of this article, let's refactor and build the CKKSEncoder class we created in the previous article. In a laptop environment, we don't need to redefine classes every time we add or change methods, but just use Fastai Patch in fastcore package of_ to. This allows us to patch objects that have been defined. Using patch_to is purely for convenience. You can redefine CKKSEncoder in each unit using the added method.

# !pip3 install fastcore from fastcore.foundation import patch_to

@patch_to(CKKSEncoder) def pi(self, z: np.array) -> np.array: """Projects a vector of H into C^{N/2}.""" N = self.M // 4 return z[:N] @patch_to(CKKSEncoder) def pi_inverse(self, z: np.array) -> np.array: """Expands a vector of C^{N/2} by expanding it with its complex conjugate.""" z_conjugate = z[::-1] z_conjugate = [np.conjugate(x) for x in z_conjugate] return np.concatenate([z, z_conjugate]) # We can now initialize our encoder with the added methods encoder = CKKSEncoder(M)

z = np.array([0,1]) encoder.pi_inverse(z)

Output: array([0, 1, 1, 0])

@patch_to(CKKSEncoder) def create_sigma_R_basis(self): """Creates the basis (sigma(1), sigma(X), ..., sigma(X** N-1)).""" self.sigma_R_basis = np.array(self.vandermonde(self.xi, self.M)).T @patch_to(CKKSEncoder) def __init__(self, M): """Initialize with the basis""" self.xi = np.exp(2 * np.pi * 1j / M) self.M = M self.create_sigma_R_basis() encoder = CKKSEncoder(M)

We can now look at the base: \ (\ Sigma \ left (1 \ right), \ Sigma \ left (x \ right), \ Sigma \ left (x ^ {2} \ right), \ Sigma \ left (x ^ {3} \ right) \)

encoder.sigma_R_basis

\(array([[ 1.00000000e+00+0.j, 1.00000000e+00+0.j,1.00000000e+00+0.j, 1.00000000e+00+0.j],[ 7.07106781e-01+0.70710678j, -7.07106781e-01+0.70710678j, -7.07106781e-01-0.70710678j, 7.07106781e-01-0.70710678j],[ 2.22044605e-16+1.j, -4.44089210e-16-1.j, 1.11022302e-15+1.j, -1.38777878e-15-1.j], [-7.07106781e-01+0.70710678j, 7.07106781e-01+0.70710678j,7.07106781e-01-0.70710678j, -7.07106781e-01-0.70710678j]])\)

Here we will check( σ (1), σ (X), σ (X2), σ Whether the elements of (X3)) are encoded as integer polynomials.

# Here we simply take a vector whose coordinates are (1,1,1,1) in the lattice basis coordinates = [1,1,1,1] b = np.matmul(encoder.sigma_R_basis.T, coordinates) b

\(array([1.+2.41421356j, 1.+0.41421356j, 1.-0.41421356j, 1.-2.41421356j])\)

Now we can check whether it is encoded as an integer polynomial.

p = encoder.sigma_inverse(b) p

\(x↦(1+2.220446049250313e-16j)+((1+0j))x+((0.9999999999999998+2.7755575615628716e-17j))x^2+((1+2.220446049250313e-16j))x^3\)

@patch_to(CKKSEncoder) def compute_basis_coordinates(self, z): """Computes the coordinates of a vector with respect to the orthogonal lattice basis.""" output = np.array([np.real(np.vdot(z, b) / np.vdot(b,b)) for b in self.sigma_R_basis]) return output def round_coordinates(coordinates): """Gives the integral rest.""" coordinates = coordinates - np.floor(coordinates) return coordinates def coordinate_wise_random_rounding(coordinates): """Rounds coordinates randonmly.""" r = round_coordinates(coordinates) f = np.array([np.random.choice([c, c-1], 1, p=[1-c, c]) for c in r]).reshape(-1) rounded_coordinates = coordinates - f rounded_coordinates = [int(coeff) for coeff in rounded_coordinates] return rounded_coordinates @patch_to(CKKSEncoder) def sigma_R_discretization(self, z): """Projects a vector on the lattice using coordinate wise random rounding.""" coordinates = self.compute_basis_coordinates(z) rounded_coordinates = coordinate_wise_random_rounding(coordinates) y = np.matmul(self.sigma_R_basis.T, rounded_coordinates) return y encoder = CKKSEncoder(M)

Finally, because precision may be lost in the rounding step, we use the scale parameter Δ To achieve a fixed level of accuracy.

@patch_to(CKKSEncoder) def __init__(self, M:int, scale:float): """Initializes with scale.""" self.xi = np.exp(2 * np.pi * 1j / M) self.M = M self.create_sigma_R_basis() self.scale = scale @patch_to(CKKSEncoder) def encode(self, z: np.array) -> Polynomial: """Encodes a vector by expanding it first to H, scale it, project it on the lattice of sigma(R), and performs sigma inverse. """ pi_z = self.pi_inverse(z) scaled_pi_z = self.scale * pi_z rounded_scale_pi_zi = self.sigma_R_discretization(scaled_pi_z) p = self.sigma_inverse(rounded_scale_pi_zi) # We round it afterwards due to numerical imprecision coef = np.round(np.real(p.coef)).astype(int) p = Polynomial(coef) return p @patch_to(CKKSEncoder) def decode(self, p: Polynomial) -> np.array: """Decodes a polynomial by removing the scale, evaluating on the roots, and project it on C^(N/2)""" rescaled_p = p / self.scale z = self.sigma(rescaled_p) pi_z = self.pi(z) return pi_z scale = 64 encoder = CKKSEncoder(M, scale)

We can now see it immediately. The complete encoder used by CKKS:

z = np.array([3 +4j, 2 - 1j]) z

Output: array([3.+4.j, 2.-1.j])

Now we have an integer polynomial as our code.

p = encoder.encode(z) p

\(x↦160.0+90.0x+160.0x^2+45.0x^3\)

And it actually decodes well!

encoder.decode(p)

array([2.99718446+3.99155337j, 2.00281554-1.00844663j])

I hope you like this introduction about encoding complex vectors into polynomials for homomorphic encryption. We will discuss this further in the following article. Please look forward to it!