Skip to content

Advanced Examples

This page is dedicated to more complex FHE use-cases. It introduces a few key concepts, such as data rotation or data extraction.

A. Sum All Elements

As explained before, it is possible to encrypt a vector in a ciphertext, for instance data = [1, 2, 3, 4, 5, 6, 7, 8]. In this advanced example, we want to sum all those eight elements together. This use-case requires to rotate data and compute an homomorphic addition in order to build the desired result at the 8-th element of the output encrypted vector.

from desilofhe import Engine

engine = Engine()

secret_key = engine.create_secret_key()
public_key = engine.create_public_key(secret_key)
relinearization_key = engine.create_relinearization_key(secret_key)
rotation_key = engine.create_rotation_key(secret_key)

data = [1, 2, 3, 4, 5, 6, 7, 8]
encrypted = engine.encrypt(data, public_key)

rotated_data = encrypted
added = rotated_data
for i in range(7):
    rotated_data = engine.rotate(rotated_data, rotation_key, delta=1)
    added = engine.add(added, rotated_data)

decrypted = engine.decrypt(added, secret_key)

# read the result in the 8-th coefficient
print((decrypted[7])) # ~36

Optimized Strategy

Here we propose an optimized version of the previous example. Indeed, in the previous example we performed 7 homomorphic rotations of 1 position to the right, and 7 homomorphic additions in order to get the desired result (i.e. adding the first 8 encrypted elements together). However, the same operation can be performed in a more efficient way, by simply performing 3 rotations (of 1, 2, and 4 positions to the right) and 3 additions.

from desilofhe import Engine

engine = Engine()

secret_key = engine.create_secret_key()
public_key = engine.create_public_key(secret_key)
relinearization_key = engine.create_relinearization_key(secret_key)
rotation_key = engine.create_rotation_key(secret_key)

data = [1, 2, 3, 4, 5, 6, 7, 8]
encrypted = engine.encrypt(data, public_key)

added = encrypted

# rotate by 1 and add
rotated_data = engine.rotate(added, rotation_key, 1)
added = engine.add(added, rotated_data)

# rotate by 2 and add
rotated_data = engine.rotate(added, rotation_key, 2)
added = engine.add(added, rotated_data)

# rotate by 4 and add
rotated_data = engine.rotate(added, rotation_key, 4)
added = engine.add(added, rotated_data)

decrypted = engine.decrypt(added, secret_key)

# read the result in the 8-th coefficient
print((decrypted[7])) # ~36

B. Data Extraction and Reorganization

A ciphertext encrypts a vector of numbers, and thanks to the SIMD feature of the scheme, it is pretty easy to add vectors together or to compute coefficient-wise multiplications between two encrypted vectors.

The previous example described a non-SIMD use-case where one adds all the elements of the encrypted vector in the first coefficient of the output ciphertext. The other coefficients around are filled with partial sums of the original vector's elements which are values we do not want to keep.

Let's see how to extract some pieces of data from ciphertexts and recombine them into a new clean vector only filled with needed values and zeros everywhere else.

In particular, in this example we extract pieces of information form 4 encrypted vectors and re-organize them to obtain the vector [1, 2, 3, 4, 5, 6, 7, 8] as result. This is be done by performing multiplications between the encrypted vector and a cleartext mask allowing to extract the right element, followed by an homomorphic rotation to bring the extracted element in the right position. A final homomorphic addition allows to merge all the information into a single ciphertext.

from desilofhe import Engine

engine = Engine()

secret_key = engine.create_secret_key()
public_key = engine.create_public_key(secret_key)
relinearization_key = engine.create_relinearization_key(secret_key)
rotation_key = engine.create_rotation_key(secret_key)

# values to extract is marked by its index
# index         0         1
data1 = [12, 7, 1, 15, 9, 2, 11, 10]
# index  2  3
data2 = [3, 4, 20, 11, 17, 6, 9, 16]
# index         5     4
data3 = [9, 18, 6, 9, 5, 11, 13, 8]
# index                  6          7
data4 = [20, 19, 18, 17, 7, 14, 15, 8]

encrypted1 = engine.encrypt(data1, public_key)
encrypted2 = engine.encrypt(data2, public_key)
encrypted3 = engine.encrypt(data3, public_key)
encrypted4 = engine.encrypt(data4, public_key)

# Extract the 3rd element in data1
mask = [0, 0, 1, 0, 0, 0, 0, 0]
multiplied = engine.multiply(encrypted1, mask)
# Rotate to bring it in 1st position
rotated1 = engine.rotate(multiplied, rotation_key, -2)

# Extract the 6th element in data1
mask = [0, 0, 0, 0, 0, 1, 0, 0]
multiplied = engine.multiply(encrypted1, mask)
# Rotate to bring it in 2nd position
rotated2 = engine.rotate(multiplied, rotation_key, -4)

# Extract the 1st and 2nd elements in data2
mask = [1, 1, 0, 0, 0, 0, 0, 0]
multiplied = engine.multiply(encrypted2, mask)
# Rotate to bring them in 3rd and 4th position
rotated34 = engine.rotate(multiplied, rotation_key, 2)

# Extract the 3rd element in data3
mask = [0, 0, 1, 0, 0, 0, 0, 0]
multiplied = engine.multiply(encrypted3, mask)
# Rotate to bring it in 6th position
rotated6 = engine.rotate(multiplied, rotation_key, 3)

# Extract the 5rd element in data3
# (it is already in the right position so no rotation is needed)
mask = [0, 0, 0, 0, 1, 0, 0, 0]
rotated5 = engine.multiply(encrypted3, mask)

# Extract the 5th element in data4
mask = [0, 0, 0, 0, 1, 0, 0, 0]
multiplied = engine.multiply(encrypted4, mask)
# Rotate to bring it in 7th position
rotated7 = engine.rotate(multiplied, rotation_key, 2)

# Extract the 8th element in data4
# (it is already in the right position so no rotation is needed)
mask = [0, 0, 0, 0, 0, 0, 0, 1]
rotated8 = engine.multiply(encrypted4, mask)

# Add together all the rotated elements to obtain the expected result
added = engine.add(rotated1, rotated2)
added = engine.add(added, rotated34)
added = engine.add(added, rotated5)
added = engine.add(added, rotated6)
added = engine.add(added, rotated7)
added = engine.add(added, rotated8)

# decrypt and print the result
decrypted = engine.decrypt(added, secret_key)
print((decrypted[:8])) # [~1 ~2 ~3 ~4 ~5 ~6 ~7 ~8]

C. Polynomial Evaluation

In this example we want to evaluate in an SIMD fashion the following polynomial: x^3 - x^2 + sqrt(2)*x + 1. The input values for x will be stored in a ciphertext encrypting [1, 2, 3, 4, 5, 6, 7, 8]. It is indeed pretty simple to evaluate any polynomial with the DESILO FHE library: the homomorphic operations needed are addition, subtraction and multiplication.

from desilofhe import Engine
import math

engine = Engine()

secret_key = engine.create_secret_key()
public_key = engine.create_public_key(secret_key)
relinearization_key = engine.create_relinearization_key(secret_key)
rotation_key = engine.create_rotation_key(secret_key)

data = [1, 2, 3, 4, 5, 6, 7, 8]
encrypted = engine.encrypt(data, public_key)

# Polynomial evaluation p(x) = x^3 - x^2 + sqrt(2)*x + 1
coeff0 = 1
sqrt2 = math.sqrt(2)
# compute x^2
x2 = engine.square(encrypted, relinearization_key)
# compute x^3
x3 = engine.multiply(encrypted, x2, relinearization_key)
# compute sqrt(2)*x
x1 = engine.multiply(encrypted, sqrt2)
# compute the polynomial
polynomial = engine.subtract(x3, x2)
polynomial = engine.add(polynomial, x1)
polynomial = engine.add(polynomial, coeff0)

# decrypt and print the result
decrypted = engine.decrypt(polynomial, secret_key)
print((decrypted[:4])) # [~2.4142 ~7.8284 ~23.2426 ~54.6569 ~108.0711 ~189.4853 ~304.8995 ~460.3137]

D. Homomorphic Artificial Neuron

In this example we want to compute an artificial neuron in an SIMD fashion. The neuron we consider takes as input 4 real numbers, and computes an inner-product with a weight vector (size 4 as well) plus a bias. The last operation is the computation of the ReLU function on the result of the inner-product. The ReLU function takes as input a real number x and outputs x if it is positive, and zero otherwise. This function will be computed homomorphically through a polynomial approximation as described above.

Inner Product and Bias

Let's start with the homomorphic inner-product and the addition of the bias.

def inner_product_bias(encrypted_data, weight, bias):
    inner_product = bias
    for encrypted_data, weight in zip(encrypted, weights):
        product = engine.multiply(encrypted_data, weight)
        inner_product = engine.add(inner_product, product)
    return inner_product

ReLU

Now we can write a function computing a polynomial approximation of the ReLU function for the interval [-1,1]. The following strategy comes from this paper. Note that the difference between ReLU and its approximation is smaller than 0.008. It is possible to make this approximation more precise with a heavier homomorphic polynomial approximation.

def relu(x):
    # Polynomial approximation p(x) = 0.5 * (x + x * p_{7,2}(p_{7,1}(x)))
    p71 = [
        3.60471572275560*10**(-36), 7.30445164958251,
        -5.05471704202722*10**(-35), -3.46825871108659*10,
        1.16564665409095*10**(-34), 5.98596518298826*10,
        -6.54298492839531*10**(-35), -3.18755225906466*10
    ]
    p72 = [
        -9.46491402344260*10**(-49), 2.40085652217597,
        6.41744632725342*10**(-48), -2.63125454261783,
        -7.25338564676814*10**(-48), 1.54912674773593,
        2.06916466421812*10**(-48), -3.31172956504304*10**(-1)
    ]

    # compute p_{7,1}(x)
    power_x = []
    # 0: x1
    power_x.append(x)
    # 1: x2
    power_x.append(engine.square(power_x[0], relinearization_key))
    # 2: x3
    power_x.append(engine.multiply(power_x[0], power_x[1], relinearization_key))
    # 3: x4
    power_x.append(engine.square(power_x[1], relinearization_key))
    # 4: x5
    power_x.append(engine.multiply(power_x[3], power_x[0], relinearization_key))
    # 5: x6
    power_x.append(engine.multiply(power_x[3], power_x[1], relinearization_key))
    # 6: x7
    power_x.append(engine.multiply(power_x[5], power_x[0], relinearization_key))
    # Compose
    poly_p71 = [p71[0]] * 8
    for i in range (7):
        power_x[i] = engine.multiply(power_x[i], p71[i+1])
        poly_p71 = engine.add(poly_p71, power_x[i])

    # # compute p_{7,2}(p_{7,1}(x))
    power_x = []
    # 0: x1
    power_x.append(poly_p71)
    # 1: x2
    power_x.append(engine.square(power_x[0], relinearization_key))
    # 2: x3
    power_x.append(engine.multiply(power_x[0], power_x[1], relinearization_key))
    # 3: x4
    power_x.append(engine.square(power_x[1], relinearization_key))
    # 4: x5
    power_x.append(engine.multiply(power_x[3], power_x[0], relinearization_key))
    # 5: x6
    power_x.append(engine.multiply(power_x[3], power_x[1], relinearization_key))
    # 6: x7
    power_x.append(engine.multiply(power_x[5], power_x[0], relinearization_key))
    # Compose
    poly_p72 = [p72[0]] * 8
    for i in range (7):
        power_x[i] = engine.multiply(power_x[i], p72[i+1])
        poly_p72 = engine.add(poly_p72, power_x[i])

    # compute x * p_{7,2}(p_{7,1}(x))
    poly_result = engine.multiply(poly_p72, x, relinearization_key)

    # compute x + x * p_{7,2}(p_{7,1}(x))
    poly_result = engine.add(poly_result, x)

    # finally compute 0.5 * (x + x * p_{7,2}(p_{7,1}(x)))
    poly_result = engine.multiply(poly_result, 0.5)
    return poly_result

Artificial Neuron

Now we can write the final piece of code which will call the two functions we wrote in the previous steps. Let's use an engine with more multiplicative levels and a parallel CPU implementation to make the homomorphic computation faster.

from desilofhe import Engine
import math

# engine with more multiplicative levels and a parallel CPU implementation
engine = Engine(max_level=17, mode="parallel")

secret_key = engine.create_secret_key()
public_key = engine.create_public_key(secret_key)
relinearization_key = engine.create_relinearization_key(secret_key)
rotation_key = engine.create_rotation_key(secret_key)

# data stores enough data to evaluate 8 times our neuron in the SIMD fashion (i.e. at once)
# the first input is the first column [0.1, 0.9, 1.5, 0.8], the second is [0.2, 1.0, 0.3, 1.0] and so on.
data = [
    [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8,],
    [0.9, 1.0, 1.1, 1.2, 1.3, 1.4, 0.7, 1.6,],
    [1.5, 0.3, 0.0, 0.7, 1.1, 1.3, 0.2, 0.8,],
    [0.8, 1.0, 1.6, 1.2, 0.3, 0.7, 0.1, 1.1,],
]

# encrypt
encrypted = [engine.encrypt(d, public_key) for d in data]

# inner-product between the input data and the weights plus the bias
weights = [-0.4, -1.2, 0.6, 1.]
bias = 0.34
inner_product = inner_product_bias(encrypted, weights, bias) # [~0.92 ~0.24 ~0.5 ~0.36 ~-0.46 ~-0.1 ~-0.56 ~-0.32]

# ReLU homomorphic evaluation
neuron_output = relu(inner_product)

# decrypt and print the result
decrypted = engine.decrypt(neuron_output, secret_key)
print((decrypted[:8])) # [~0.92 ~0.24 ~0.5 ~0.36 ~0.0 ~0.0 ~0.0 ~0.0]