Repository: OpenMined/PySyft

Issue:
Implement Subtraction operation for FV HE Scheme
## Feature Description
Subtraction operations of FV Scheme need to be implemented.

It should Subtract 2 ciphertexts objects.
It should Subtract a ciphertext with plaintext objects.

In both cases, the result should be returned as a ciphertext object.




Discussion:

Resolving patch:
diff --git a/syft/frameworks/torch/he/fv/evaluator.py b/syft/frameworks/torch/he/fv/evaluator.py
--- a/syft/frameworks/torch/he/fv/evaluator.py
+++ b/syft/frameworks/torch/he/fv/evaluator.py
@@ -1,12 +1,39 @@
 import copy
+from enum import Enum
 
 from syft.frameworks.torch.he.fv.util.operations import poly_add_mod
 from syft.frameworks.torch.he.fv.util.operations import negate_mod
+from syft.frameworks.torch.he.fv.util.operations import poly_sub_mod
+from syft.frameworks.torch.he.fv.util.operations import poly_negate_mod
 from syft.frameworks.torch.he.fv.util.operations import multiply_add_plain_with_delta
+from syft.frameworks.torch.he.fv.util.operations import multiply_sub_plain_with_delta
 from syft.frameworks.torch.he.fv.ciphertext import CipherText
 from syft.frameworks.torch.he.fv.plaintext import PlainText
 
 
+class ParamTypes(Enum):
+    """Enumeration for type checking of parameters."""
+
+    CTCT = 1
+    PTPT = 2
+    CTPT = 3
+    PTCT = 4
+
+
+def _typecheck(op1, op2):
+    """Check the type of parameters used and return correct enum type."""
+    if isinstance(op1, CipherText) and isinstance(op2, CipherText):
+        return ParamTypes.CTCT
+    elif isinstance(op1, PlainText) and isinstance(op2, PlainText):
+        return ParamTypes.PTPT
+    elif isinstance(op1, CipherText) and isinstance(op2, PlainText):
+        return ParamTypes.CTPT
+    elif isinstance(op1, PlainText) and isinstance(op2, CipherText):
+        return ParamTypes.PTCT
+    else:
+        return None
+
+
 class Evaluator:
     def __init__(self, context):
         self.context = context
@@ -17,29 +44,59 @@ def add(self, op1, op2):
         """Adds two operands using FV scheme.
 
         Args:
-            op1 (Ciphertext/Plaintext): First argument.
-            op2 (Ciphertext/Plaintext): Second argument.
+            op1 (Ciphertext/Plaintext): First polynomial argument (Augend).
+            op2 (Ciphertext/Plaintext): Second polynomial argument (Addend).
 
         Returns:
             If both arguments are Plaintext elements then the result will be a Plaintext object
                 otherwise a Ciphertext object with value equivalent to the result of addition
                 operation of two provided arguments.
         """
-        if isinstance(op1, CipherText) and isinstance(op2, CipherText):
+
+        param_type = _typecheck(op1, op2)
+
+        if param_type == ParamTypes.CTCT:
             return self._add_cipher_cipher(op1, op2)
 
-        elif isinstance(op1, PlainText) and isinstance(op2, PlainText):
+        elif param_type == ParamTypes.PTPT:
             return self._add_plain_plain(op1, op2)
 
-        elif isinstance(op1, PlainText) and isinstance(op2, CipherText):
-            return self._add_plain_cipher(op1, op2)
+        elif param_type == ParamTypes.CTPT:
+            return self._add_cipher_plain(op1, op2)
 
-        elif isinstance(op1, CipherText) and isinstance(op2, PlainText):
-            return self._add_plain_cipher(op2, op1)
+        elif param_type == ParamTypes.PTCT:
+            return self._add_cipher_plain(op2, op1)
 
         else:
             raise TypeError(f"Addition Operation not supported between {type(op1)} and {type(op2)}")
 
+    def sub(self, op1, op2):
+        """Subtracts two operands using FV scheme.
+
+        Args:
+            op1 (Ciphertext/Plaintext): First polynomail argument (Minuend).
+            op2 (Ciphertext/Plaintext): Second polynomial argument (Subtrahend).
+
+        Returns:
+            A ciphertext object with the value equivalent to the result of the subtraction
+                of two operands.
+        """
+        param_type = _typecheck(op1, op2)
+
+        if param_type == ParamTypes.CTCT:
+            return self._sub_cipher_cipher(op1, op2)
+
+        elif param_type == ParamTypes.CTPT:
+            return self._sub_cipher_plain(op1, op2)
+
+        elif param_type == ParamTypes.PTCT:
+            return self._sub_cipher_plain(op2, op1)
+
+        else:
+            raise TypeError(
+                f"Subtraction Operation not supported between {type(op1)} and {type(op2)}"
+            )
+
     def negate(self, ct):
         """Negate a cipher i.e -(ct_value)
 
@@ -62,8 +119,8 @@ def _add_cipher_cipher(self, ct1, ct2):
         """Adds two ciphertexts.
 
         Args:
-            ct1 (Ciphertext): First argument.
-            ct2 (Ciphertext): Second argument.
+            ct1 (Ciphertext): First polynomail argument (Augend).
+            ct2 (Ciphertext): Second polynomial argument (Addend).
 
         Returns:
             A Ciphertext object with value equivalent to result of addition of two provided
@@ -78,12 +135,13 @@ def _add_cipher_cipher(self, ct1, ct2):
 
         return CipherText(result)
 
-    def _add_plain_cipher(self, pt, ct):
-        """Adds a ciphertext and a plaintext.
+    def _add_cipher_plain(self, ct, pt):
+        """Add a plaintext into a ciphertext.
 
         Args:
-            pt (Plaintext): First argument.
-            ct (Ciphertext): Second argument.
+            ct (Ciphertext): First polynomail argument (Augend).
+            pt (Plaintext): Second polynomial argument (Addend).
+
         Returns:
             A Ciphertext object with value equivalent to result of addition of two provided
                 arguments.
@@ -95,8 +153,8 @@ def _add_plain_plain(self, pt1, pt2):
         """Adds two plaintexts object.
 
         Args:
-            pt1 (Plaintext): First argument.
-            pt2 (Plaintext): Second argument.
+            pt1 (Plaintext): First polynomail argument (Augend).
+            pt2 (Plaintext): Second polynomial argument (Addend).
 
         Returns:
             A Plaintext object with value equivalent to result of addition of two provided
@@ -104,3 +162,42 @@ def _add_plain_plain(self, pt1, pt2):
         """
         pt1, pt2 = copy.deepcopy(pt1), copy.deepcopy(pt2)
         return PlainText(poly_add_mod(pt1.data, pt2.data, self.plain_modulus))
+
+    def _sub_cipher_plain(self, ct, pt):
+        """Subtract a plaintext from a ciphertext.
+
+        Args:
+            ct (Ciphertext): First polynomail argument (Minuend).
+            pt (Plaintext): Second polynomial argument (Subtrahend).
+
+        Returns:
+            A Ciphertext object with value equivalent to result of addition of two provided
+                arguments.
+        """
+        ct = copy.deepcopy(ct)
+        return multiply_sub_plain_with_delta(ct, pt, self.context)
+
+    def _sub_cipher_cipher(self, ct1, ct2):
+        """Subtract two ciphertexts.
+
+        Args:
+            ct1 (Ciphertext): First polynomail argument (Minuend).
+            ct2 (Ciphertext): Second polynomial argument (Subtrahend).
+
+        Returns:
+            A Ciphertext object with value equivalent to result of subtraction of two provided
+                arguments.
+        """
+        ct1, ct2 = copy.deepcopy(ct1.data), copy.deepcopy(ct2.data)
+        result = ct2 if len(ct2) > len(ct1) else ct1
+        min_size, max_size = min(len(ct1), len(ct2)), max(len(ct1), len(ct2))
+
+        for i in range(min_size):
+            for j in range(len(self.coeff_modulus)):
+                result[i][j] = poly_sub_mod(ct1[i][j], ct2[i][j], self.coeff_modulus[j])
+
+        for i in range(min_size + 1, max_size):
+            for j in range(len(self.coeff_modulus)):
+                result[i][j] = poly_negate_mod(result[i][j], self.coeff_modulus[j])
+
+        return CipherText(result)
diff --git a/syft/frameworks/torch/he/fv/util/operations.py b/syft/frameworks/torch/he/fv/util/operations.py
--- a/syft/frameworks/torch/he/fv/util/operations.py
+++ b/syft/frameworks/torch/he/fv/util/operations.py
@@ -57,9 +57,16 @@ def invert_mod(value, modulus):
         return gcd_tuple[1]
 
 
-def poly_add_mod(op1, op2, modulus):
-    """return addition of two polynomials with all coefficients of
-    polynomial %q(coefficient modulus)"""
+def poly_add_mod(op1, op2, coeff_mod):
+    """Add two polynomails and modulo every coefficient with coeff_mod.
+
+    Args:
+        op1 (list): First Polynomail (Augend).
+        op2 (list): Second Polynomail (Addend).
+
+    Returns:
+        A list with polynomial coefficients.
+    """
 
     # For non same size polynomails we have to shift the polynomials because numpy consider right
     # side as lower order of polynomial and we consider right side as heigher order.
@@ -69,12 +76,40 @@ def poly_add_mod(op1, op2, modulus):
         else:
             op1 = op1 + [0] * (len(op2) - len(op1))
 
-    return np.mod(np.polyadd(op1, op2), modulus).tolist()
+    return np.mod(np.polyadd(op1, op2), coeff_mod).tolist()
+
 
+def poly_sub_mod(op1, op2, coeff_mod):
+    """Subtract two polynomails and modulo every coefficient with coeff_mod.
 
-def poly_mul_mod(op1, op2, modulus):
-    """return multiplication of two polynomials with all coefficients of
-    polynomial %q(coefficient modulus) and result polynomial % t(polynomial modulus)"""
+    Args:
+        op1 (list): First Polynomail (Minuend).
+        op2 (list): Second Polynomail (Subtrahend).
+
+    Returns:
+        A list with polynomial coefficients.
+    """
+    # For non same size polynomails we have to shift the polynomials because numpy consider right
+    # side as lower order of polynomial and we consider right side as heigher order.
+    if len(op1) != len(op2):
+        if len(op1) > len(op2):
+            op2 = op2 + [0] * (len(op1) - len(op2))
+        else:
+            op1 = op1 + [0] * (len(op2) - len(op1))
+
+    return np.mod(np.polysub(op1, op2), coeff_mod).tolist()
+
+
+def poly_mul_mod(op1, op2, coeff_mod):
+    """Multiply two polynomails and modulo every coefficient with coeff_mod.
+
+    Args:
+        op1 (list): First Polynomail (Multiplicand).
+        op2 (list): Second Polynomail (Multiplier).
+
+    Returns:
+        A list with polynomial coefficients.
+    """
 
     # For non same size polynomails we have to shift the polynomials because numpy consider right
     # side as lower order of polynomial and we consider right side as heigher order.
@@ -87,26 +122,34 @@ def poly_mul_mod(op1, op2, modulus):
     poly_mod = np.array([1] + [0] * (len(op1) - 1) + [1])
     result = (
         poly.polydiv(
-            poly.polymul(np.array(op1, dtype="object"), np.array(op2, dtype="object")) % modulus,
+            poly.polymul(np.array(op1, dtype="object"), np.array(op2, dtype="object")) % coeff_mod,
             poly_mod,
         )[1]
-        % modulus
+        % coeff_mod
     ).tolist()
     return [round(x) for x in result]
 
 
-def poly_negate_mod(op, modulus):
-    """returns negative of polynomial i.e (-1 * op)"""
+def poly_negate_mod(op, coeff_mod):
+    """Negate polynomail and modulo every coefficient with coeff_mod.
+
+    Args:
+        op1 (list): First Polynomail (Multiplicand).
+        op2 (list): Second Polynomail (Multiplier).
+
+    Returns:
+        A list with polynomial coefficients.
+    """
     coeff_count = len(op)
 
     result = [0] * coeff_count
     for i in range(coeff_count):
-        if modulus == 0:
+        if coeff_mod == 0:
             raise ValueError("Modulus cannot be 0")
-        if op[i] >= modulus:
+        if op[i] >= coeff_mod:
             raise OverflowError("operand cannot be greater than modulus")
         non_zero = op[i] != 0
-        result[i] = (modulus - op[i]) & (-int(non_zero))
+        result[i] = (coeff_mod - op[i]) & (-int(non_zero))
     return result
 
 
@@ -169,28 +212,55 @@ def xgcd(x, y):
     return [x, prev_a, prev_b]
 
 
-def multiply_add_plain_with_delta(phase, message, context):
+def multiply_add_plain_with_delta(ct, pt, context):
     """Add message into phase.
 
     Args:
-        phase (Ciphertext): phase is pre-computed carrier polynomial where we can add message data.
-        message (Plaintext): A plaintext representation of integer data to be encrypted.
+        ct (Ciphertext): ct is pre-computed carrier polynomial where we can add pt data.
+        pt (Plaintext): A plaintext representation of integer data to be encrypted.
+        context (Context): Context for extracting encryption parameters.
+
+    Returns:
+        A Ciphertext object with the encrypted result of encryption process.
+    """
+    coeff_modulus = context.param.coeff_modulus
+    pt = pt.data
+    plain_coeff_count = len(pt)
+    delta = context.coeff_div_plain_modulus
+    ct0, ct1 = ct.data  # here ct = pk * u * e
+
+    # Coefficients of plain m multiplied by coeff_modulus q, divided by plain_modulus t,
+    # and rounded to the nearest integer (rounded up in case of a tie). Equivalent to
+    for i in range(plain_coeff_count):
+        for j in range(len(coeff_modulus)):
+            temp = round(delta[j] * pt[i]) % coeff_modulus[j]
+            ct0[j][i] = (ct0[j][i] + temp) % coeff_modulus[j]
+
+    return CipherText([ct0, ct1])  # ct0 = pk0 * u * e + delta * pt
+
+
+def multiply_sub_plain_with_delta(ct, pt, context):
+    """Subtract plaintext from ciphertext.
+
+    Args:
+        ct (Ciphertext): ct is pre-computed carrier polynomial where we can add message data.
+        pt (Plaintext): A plaintext representation of integer data to be encrypted.
         context (Context): Context for extracting encryption parameters.
 
     Returns:
         A Ciphertext object with the encrypted result of encryption process.
     """
     coeff_modulus = context.param.coeff_modulus
-    message = message.data
-    plain_coeff_count = len(message)
+    pt = pt.data
+    plain_coeff_count = len(pt)
     delta = context.coeff_div_plain_modulus
-    phase0, phase1 = phase.data  # here phase = pk * u * e
+    ct0, ct1 = ct.data  # here ct = pk * u * e
 
     # Coefficients of plain m multiplied by coeff_modulus q, divided by plain_modulus t,
     # and rounded to the nearest integer (rounded up in case of a tie). Equivalent to
     for i in range(plain_coeff_count):
         for j in range(len(coeff_modulus)):
-            temp = round(delta[j] * message[i]) % coeff_modulus[j]
-            phase0[j][i] = (phase0[j][i] + temp) % coeff_modulus[j]
+            temp = round(delta[j] * pt[i]) % coeff_modulus[j]
+            ct0[j][i] = (ct0[j][i] - temp) % coeff_modulus[j]
 
-    return CipherText([phase0, phase1])  # phase0 = pk0 * u * e + delta * m
+    return CipherText([ct0, ct1])  # ct0 = pk0 * u * e - delta * pt


Test patch:
diff --git a/test/torch/tensors/test_fv.py b/test/torch/tensors/test_fv.py
--- a/test/torch/tensors/test_fv.py
+++ b/test/torch/tensors/test_fv.py
@@ -407,7 +407,7 @@ def test_fv_add_cipher_cipher(int1, int2):
 @pytest.mark.parametrize(
     "int1, int2", [(0, 0), (-1, 1), (100, -10), (1000, 100), (-1000, 100), (-100, -100)]
 )
-def test_fv_add_plain_cipher(int1, int2):
+def test_fv_add_cipher_plain(int1, int2):
     ctx = Context(EncryptionParams(1024, CoeffModulus().create(1024, [30, 30]), 1024))
     keys = KeyGenerator(ctx).keygen()
     encoder = IntegerEncoder(ctx)
@@ -415,12 +415,12 @@ def test_fv_add_plain_cipher(int1, int2):
     decryptor = Decryptor(ctx, keys[0])  # keys[0] = secret_key
     evaluator = Evaluator(ctx)
 
-    op1 = encoder.encode(int1)
-    op2 = encryptor.encrypt(encoder.encode(int2))
+    op1 = encryptor.encrypt(encoder.encode(int1))
+    op2 = encoder.encode(int2)
 
     assert (
         int1 + int2
-        == encoder.decode(decryptor.decrypt(evaluator._add_plain_cipher(op1, op2)))
+        == encoder.decode(decryptor.decrypt(evaluator._add_cipher_plain(op1, op2)))
         == encoder.decode(decryptor.decrypt(evaluator.add(op1, op2)))
         == encoder.decode(decryptor.decrypt(evaluator.add(op2, op1)))
     )
@@ -453,3 +453,46 @@ def test_fv_negate_cipher(val):
     decryptor = Decryptor(ctx, keys[0])  # keys[0] = secret_key
     op = encryptor.encrypt(encoder.encode(val))
     assert -val == encoder.decode(decryptor.decrypt(evaluator.negate(op)))
+
+
+@pytest.mark.parametrize(
+    "int1, int2", [(0, 0), (-1, 1), (100, -10), (1000, 100), (-1000, 100), (-100, -100)]
+)
+def test_fv_sub_cipher_cipher(int1, int2):
+    ctx = Context(EncryptionParams(1024, CoeffModulus().create(1024, [30, 30]), 1024))
+    keys = KeyGenerator(ctx).keygen()
+    encoder = IntegerEncoder(ctx)
+    encryptor = Encryptor(ctx, keys[1])  # keys[1] = public_key
+    decryptor = Decryptor(ctx, keys[0])  # keys[0] = secret_key
+    evaluator = Evaluator(ctx)
+
+    op1 = encryptor.encrypt(encoder.encode(int1))
+    op2 = encryptor.encrypt(encoder.encode(int2))
+    assert (
+        int1 - int2
+        == encoder.decode(decryptor.decrypt(evaluator._sub_cipher_cipher(op1, op2)))
+        == encoder.decode(decryptor.decrypt(evaluator.sub(op1, op2)))
+        == -encoder.decode(decryptor.decrypt(evaluator.sub(op2, op1)))
+    )
+
+
+@pytest.mark.parametrize(
+    "int1, int2", [(0, 0), (-1, 1), (100, -10), (1000, 100), (-1000, 100), (-100, -100)]
+)
+def test_fv_sub_cipher_plain(int1, int2):
+    ctx = Context(EncryptionParams(1024, CoeffModulus().create(1024, [30, 30]), 1024))
+    keys = KeyGenerator(ctx).keygen()
+    encoder = IntegerEncoder(ctx)
+    encryptor = Encryptor(ctx, keys[1])  # keys[1] = public_key
+    decryptor = Decryptor(ctx, keys[0])  # keys[0] = secret_key
+    evaluator = Evaluator(ctx)
+
+    op1 = encryptor.encrypt(encoder.encode(int1))
+    op2 = encoder.encode(int2)
+
+    assert (
+        int1 - int2
+        == encoder.decode(decryptor.decrypt(evaluator._sub_cipher_plain(op1, op2)))
+        == encoder.decode(decryptor.decrypt(evaluator.sub(op1, op2)))
+        == encoder.decode(decryptor.decrypt(evaluator.sub(op2, op1)))
+    )