Repository: OpenMined/PySyft

Issue:
Test if we can handle `NaN` values in the publish system without revealing private data
Currently, in our test suite, we don't test with `NaN` values which can be a problem as we expect to spend `NaN` privacy budget when publishing arrays with at least one unknown value. 

Co-authored by @IshanMi 


Discussion:
Given the issues that `NaN` causes im inclined to mark this as a `bug` even though its more pre-emptive.
I tested this and indeed the RDP constants become `NaN` and you can leak a whole array with a single `NaN` value. I inspected the code for `compute_rdp_constant` in `data_subject_ledger.py` and I was thinking that maybe when we detect `NaN` values (or `Inf` values) we can call computer_rdp_constant with private=False to use the bonds when computing the budget spent. @IshanMi what do you think about this solution?

If there's a NaN or Inf value in our array, my gut feeling says there's a good chance the min_val or max_val might also be NaN or Inf so private=False might also fail
Perhaps we can fall back on `private=False`, and throw an exception if `min_vals.data` or `max_vals.data` is a NaN or Inf

Resolving patch:
diff --git a/packages/syft/src/syft/core/adp/vectorized_publish.py b/packages/syft/src/syft/core/adp/vectorized_publish.py
--- a/packages/syft/src/syft/core/adp/vectorized_publish.py
+++ b/packages/syft/src/syft/core/adp/vectorized_publish.py
@@ -129,6 +129,8 @@ def publish(
 
     # if we dont return below we will terminate if the tensor gets replaced with zeros
     prev_tensor = None
+    print("VALUE: ", value)
+    print("Tensor: ", tensor.child)
 
     while can_reduce_further(value=value, zeros_like=zeros_like):
         if prev_tensor is None:
@@ -155,7 +157,20 @@ def publish(
         # Step 2: Calculate the epsilon spend for this query
 
         # rdp_constant = all terms in Theorem. 2.7 or 2.8 of https://arxiv.org/abs/2008.11193 EXCEPT alpha
-        rdp_constants = compute_rdp_constant(rdp_params, private=private)
+        if any(np.isnan(l2_norms)):
+            if any(np.isnan(l2_norm_bounds)) or any(np.isinf(l2_norm_bounds)):
+                raise Exception(
+                    "NaN or Inf values in bounds not allowed in PySyft for safety reasons."
+                    "Please contact the OpenMined support team for help."
+                    "\nFor that you can either:"
+                    "\n * describe your issue on our Slack #support channel. To join: https://openmined.slack.com/"
+                    "\n * send us an email describing your problem at support@openmined.org"
+                    "\n * leave us an issue here: https://github.com/OpenMined/PySyft/issues"
+                )
+            rdp_constants = compute_rdp_constant(rdp_params, private=False)
+        else:
+            rdp_constants = compute_rdp_constant(rdp_params, private=private)
+        print("Rdp constants", rdp_constants)
         if any(rdp_constants < 0):
             raise Exception(
                 "Negative budget spend not allowed in PySyft for safety reasons."
@@ -165,14 +180,6 @@ def publish(
                 " * send us an email describing your problem at support@openmined.org"
                 " * leave us an issue here: https://github.com/OpenMined/PySyft/issues"
             )
-        if any(np.isnan(rdp_constants)) or any(np.isinf(rdp_constants)):
-            raise Exception(
-                "Invalid privacy budget spend. Please contact the OpenMined support team for help."
-                "For that you can either:"
-                " * describe your issue on our Slack #support channel. To join: https://openmined.slack.com/"
-                " * send us an email describing your problem at support@openmined.org"
-                " * leave us an issue here: https://github.com/OpenMined/PySyft/issues"
-            )
         all_epsilons = ledger._get_epsilon_spend(
             rdp_constants
         )  # This is the epsilon spend for ALL data subjects
@@ -180,10 +187,10 @@ def publish(
             raise Exception(
                 "Negative budget spend not allowed in PySyft for safety reasons."
                 "Please contact the OpenMined support team for help."
-                "For that you can either:"
-                " * describe your issue on our Slack #support channel. To join: https://openmined.slack.com/"
-                " * send us an email describing your problem at support@openmined.org"
-                " * leave us an issue here: https://github.com/OpenMined/PySyft/issues"
+                "\nFor that you can either:"
+                "\n * describe your issue on our Slack #support channel. To join: https://openmined.slack.com/"
+                "\n * send us an email describing your problem at support@openmined.org"
+                "\n * leave us an issue here: https://github.com/OpenMined/PySyft/issues"
             )
 
         epsilon_spend = max(
@@ -205,6 +212,8 @@ def publish(
 
         # Step 3: Check if the user has enough privacy budget for this query
         privacy_budget = get_budget_for_user(verify_key=ledger.user_key)
+        print(privacy_budget)
+        print(epsilon_spend)
         has_budget = epsilon_spend <= privacy_budget
 
         # if we see the same budget and spend twice in a row we have failed to reduce it
diff --git a/packages/syft/src/syft/core/tensor/lazy_repeat_array.py b/packages/syft/src/syft/core/tensor/lazy_repeat_array.py
--- a/packages/syft/src/syft/core/tensor/lazy_repeat_array.py
+++ b/packages/syft/src/syft/core/tensor/lazy_repeat_array.py
@@ -430,6 +430,27 @@ def clip(self, *args: Any, **kwargs: Any) -> lazyrepeatarray:
         return lazyrepeatarray(data=res, shape=res.shape)
 
 
+def has_nans_inf(min_val: lazyrepeatarray, max_val: lazyrepeatarray) -> bool:
+    """Helper function that detects if a LRA has NaNs or Inf, and raises exceptions.
+    This is so that we can raise Exceptions at the pointer level."""
+    raise_exception = False
+    if min_val.data.size == 1:
+        if np.isnan(min_val.data) or np.isinf(min_val.data):
+            raise_exception = True
+    else:
+        if np.isnan(min_val.data).any() or np.isnan(min_val.data).any():
+            raise_exception = True
+
+    if max_val.data.size == 1:
+        if np.isnan(max_val.data) or np.isinf(max_val.data):
+            raise_exception = True
+    else:
+        if np.isnan(max_val.data).any() or np.isinf(max_val.data).any():
+            raise_exception = True
+
+    return raise_exception
+
+
 # As the min and max values calculation is the same regardless of the tensor type,
 # We centralize this method as baseline for calculation for min/max values
 def compute_min_max(
@@ -802,4 +823,21 @@ def compute_min_max(
     else:
         raise ValueError(f"Invaid Operation for LazyRepeatArray: {op_str}")
 
-    return (min_vals, max_vals)
+    if has_nans_inf(min_vals, max_vals):
+        raise Exception(
+            "I'm sorry, but our DP Privacy Accountant can't yet handle NaNs or Infinite values."
+            "This was likely caused by dividing by zero. Please find a way to approximate your "
+            "computation without dividing by a private value which might be zero. "
+            "\n"
+            "This can usually be done by computing the function piecewise- by performing three "
+            "computations which are merged through masking & summing; one which addresses what happens"
+            "if the denominator is positive-definite, another if it is negative-definite, and finally"
+            "one which addresses what happens if the denominator is 0 exactly. Use comparison operators,"
+            "masking, and summing to accomplish this (not if statements- which don't work on private"
+            "values)."
+            "\n"
+            "Again, we apologize for the inconvenience and are working to extend support to this in "
+            "future versions of PySyft."
+        )
+
+    return min_vals, max_vals


Test patch:
diff --git a/packages/syft/tests/syft/core/tensor/lazy_repeat_array_test.py b/packages/syft/tests/syft/core/tensor/lazy_repeat_array_test.py
--- a/packages/syft/tests/syft/core/tensor/lazy_repeat_array_test.py
+++ b/packages/syft/tests/syft/core/tensor/lazy_repeat_array_test.py
@@ -4,6 +4,7 @@
 
 # syft absolute
 import syft as sy
+from syft.core.tensor.lazy_repeat_array import has_nans_inf
 from syft.core.tensor.lazy_repeat_array import lazyrepeatarray
 
 
@@ -94,3 +95,27 @@ def test_sum() -> None:
     lazyarray = lazyrepeatarray(data=np.array([1]), shape=array.shape)
 
     assert lazyarray.sum(axis=None).data == array.sum(axis=None)
+
+
+def test_nans() -> None:
+    shape = (5, 5)
+    good_minv = lazyrepeatarray(1, shape)
+    bad_minv = lazyrepeatarray(np.nan, shape)
+    good_maxv = lazyrepeatarray(1000, shape)
+    bad_maxv = lazyrepeatarray(np.nan, shape)
+    assert has_nans_inf(min_val=good_minv, max_val=good_maxv) is False
+    assert has_nans_inf(min_val=good_minv, max_val=bad_maxv) is True
+    assert has_nans_inf(min_val=bad_minv, max_val=good_maxv) is True
+    assert has_nans_inf(min_val=bad_minv, max_val=bad_maxv) is True
+
+
+def test_infs() -> None:
+    shape = (5, 5)
+    good_minv = lazyrepeatarray(1, shape)
+    bad_minv = lazyrepeatarray(np.inf, shape)
+    good_maxv = lazyrepeatarray(1000, shape)
+    bad_maxv = lazyrepeatarray(np.inf, shape)
+    assert has_nans_inf(min_val=good_minv, max_val=good_maxv) is False
+    assert has_nans_inf(min_val=good_minv, max_val=bad_maxv) is True
+    assert has_nans_inf(min_val=bad_minv, max_val=good_maxv) is True
+    assert has_nans_inf(min_val=bad_minv, max_val=bad_maxv) is True