Skip to content

Commit 2af49a0

Browse files
authored
feat: add shuffle feature to NadaArray as function and method (#69)
1 parent 178a4f4 commit 2af49a0

File tree

6 files changed

+596
-7
lines changed

6 files changed

+596
-7
lines changed

nada_numpy/array.py

Lines changed: 280 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55

66
# pylint:disable=too-many-lines
77

8-
from typing import Any, Callable, Optional, Sequence, Union, get_args, overload
8+
from typing import (Any, Callable, Optional, Sequence, Tuple, Union, get_args,
9+
overload)
910

1011
import numpy as np
1112
from nada_dsl import (Boolean, Input, Integer, Output, Party, PublicInteger,
@@ -16,8 +17,9 @@
1617
from nada_numpy.nada_typing import (AnyNadaType, NadaBoolean,
1718
NadaCleartextType, NadaInteger,
1819
NadaRational, NadaUnsignedInteger)
19-
from nada_numpy.types import (Rational, SecretRational, fxp_abs, get_log_scale,
20-
public_rational, rational, secret_rational, sign)
20+
from nada_numpy.types import (Rational, SecretBoolean, SecretRational, fxp_abs,
21+
get_log_scale, public_rational, rational,
22+
secret_rational, sign)
2123
from nada_numpy.utils import copy_metadata
2224

2325

@@ -776,6 +778,176 @@ def random(
776778

777779
return NadaArray(np.array(NadaArray._create_list(dims, None, None, generator)))
778780

781+
def shuffle(self) -> "NadaArray":
782+
"""
783+
Shuffles a 1D array using the Benes network.
784+
785+
This function rearranges the elements of a 1-dimensional array in a deterministic but
786+
seemingly random order based on the Benes network, a network used in certain types of
787+
sorting and switching circuits. The Benes network requires the input array's length
788+
to be a power of two (e.g., 2, 4, 8, 16, ...).
789+
790+
Note: The resulting shuffled arrays contain the same elements as the input arrays.
791+
792+
Args:
793+
NadaArray: The input array to be shuffled. This must be a 1-dimensional NumPy array.
794+
The length of the array must be a power of two.
795+
796+
Returns:
797+
NadaArray: The shuffled version of the input array. The output is a new array where
798+
the elements have been rearranged according to the Benes network.
799+
800+
Raises:
801+
ValueError: If the length of the input array is not a power of two.
802+
803+
Example:
804+
```python
805+
import nada_numpy as na
806+
807+
# Example arrays with different data types
808+
parties = na.parties(2)
809+
a = na.array([8], parties[0], "A", na.Rational)
810+
b = na.array([8], parties[0], "B", na.SecretRational)
811+
c = na.array([8], parties[0], "C", PublicInteger)
812+
d = na.array([8], parties[0], "D", SecretInteger)
813+
814+
# Shuffling the arrays
815+
shuffled_a = a.shuffle()
816+
shuffled_b = b.shuffle()
817+
shuffled_c = c.shuffle()
818+
```
819+
820+
Frequency analysis:
821+
822+
This script performs a frequency analysis of a shuffle function implemented using a
823+
Benes network. It includes a function for shuffle, a test function for evaluating
824+
randomness, and an example of running the test. Below is an overview of the code and
825+
its output.
826+
827+
1. **Shuffle Function**:
828+
829+
The `shuffle` function shuffles a 1D array using a Benes network approach.
830+
The Benes network is defined by the function `_benes_network(n)`, which should provide
831+
the network stages required for the shuffle.
832+
833+
```python
834+
import numpy as np
835+
import random
836+
837+
def rand_bool():
838+
# Simulates a random boolean value
839+
return random.choice([0, 1]) == 0
840+
841+
def swap_gate(a, b):
842+
# Conditionally swaps two values based on a random boolean
843+
rbool = rand_bool()
844+
return (b, a) if rbool else (a, b)
845+
846+
def shuffle(array):
847+
# Applies Benes network shuffle to a 1D array
848+
if array.ndim != 1:
849+
raise ValueError("Input array must be a 1D array.")
850+
851+
n = array.size
852+
bnet = benes_network(n)
853+
swap_array = np.ones(n)
854+
855+
first_numbers = np.arange(0, n, 2)
856+
second_numbers = np.arange(1, n, 2)
857+
pairs = np.column_stack((first_numbers, second_numbers))
858+
859+
for stage in bnet:
860+
for ((i0, i1), (a, b)) in zip(pairs, stage):
861+
swap_array[i0], swap_array[i1] = swap_gate(array[a], array[b])
862+
array = swap_array.copy()
863+
864+
return array
865+
```
866+
867+
2. **Randomness Test Function:**:
868+
The test_shuffle_randomness function evaluates the shuffle function by performing
869+
multiple shuffles and counting the occurrences of each element at each position.
870+
871+
```python
872+
def test_shuffle_randomness(vector_size, num_shuffles):
873+
# Initializes vector and count matrix
874+
vector = np.arange(vector_size)
875+
counts = np.zeros((vector_size, vector_size), dtype=int)
876+
877+
# Performs shuffling and counts occurrences
878+
for _ in range(num_shuffles):
879+
shuffled_vector = shuffle(vector)
880+
for position, element in enumerate(shuffled_vector):
881+
counts[int(element), position] += 1
882+
883+
# Computes average counts and deviation
884+
average_counts = num_shuffles / vector_size
885+
deviation = np.abs(counts - average_counts)
886+
887+
return counts, average_counts, deviation
888+
```
889+
890+
891+
Running the `test_shuffle_randomness` function with a vector size of 8 and 100,000
892+
shuffles provides the following results:
893+
894+
```python
895+
vector_size = 8 # Size of the vector
896+
num_shuffles = 100000 # Number of shuffles to perform
897+
898+
counts, average_counts, deviation = test_shuffle_randomness(vector_size,
899+
num_shuffles)
900+
901+
print("Counts of numbers appearances at each position:")
902+
for i in range(vector_size):
903+
print(f"Number {i}: {counts[i]}")
904+
print("Expected count of number per slot:", average_counts)
905+
print("\nDeviation from the expected average:")
906+
for i in range(vector_size):
907+
print(f"Number {i}: {deviation[i]}")
908+
```
909+
```bash
910+
>>> Counts of numbers appearances at each position:
911+
>>> Number 0: [12477 12409 12611 12549 12361 12548 12591 12454]
912+
>>> Number 1: [12506 12669 12562 12414 12311 12408 12377 12753]
913+
>>> Number 2: [12595 12327 12461 12607 12492 12721 12419 12378]
914+
>>> Number 3: [12417 12498 12586 12433 12627 12231 12638 12570]
915+
>>> Number 4: [12370 12544 12404 12337 12497 12743 12588 12517]
916+
>>> Number 5: [12559 12420 12416 12791 12508 12489 12360 12457]
917+
>>> Number 6: [12669 12459 12396 12394 12757 12511 12423 12391]
918+
>>> Number 7: [12407 12674 12564 12475 12447 12349 12604 12480]
919+
>>> Expected count of number per slot: 12500.0
920+
>>>
921+
>>> Deviation from the expected average:
922+
>>> Number 0: [ 23. 91. 111. 49. 139. 48. 91. 46.]
923+
>>> Number 1: [ 6. 169. 62. 86. 189. 92. 123. 253.]
924+
>>> Number 2: [ 95. 173. 39. 107. 8. 221. 81. 122.]
925+
>>> Number 3: [ 83. 2. 86. 67. 127. 269. 138. 70.]
926+
>>> Number 4: [130. 44. 96. 163. 3. 243. 88. 17.]
927+
>>> Number 5: [ 59. 80. 84. 291. 8. 11. 140. 43.]
928+
>>> Number 6: [169. 41. 104. 106. 257. 11. 77. 109.]
929+
>>> Number 7: [ 93. 174. 64. 25. 53. 151. 104. 20.]
930+
```
931+
"""
932+
arr = self.copy()
933+
# Ensure the array is a 1D array
934+
if arr.ndim != 1:
935+
raise ValueError("Input array must be a 1D array.")
936+
937+
n = arr.size
938+
bnet = _benes_network(n)
939+
swap_arr = arr.copy()
940+
941+
evens = np.arange(0, n, 2)
942+
odds = np.arange(1, n, 2)
943+
pairs = np.column_stack((evens, odds))
944+
for stage in bnet:
945+
for (i0, i1), (a, b) in zip(pairs, stage):
946+
swap_arr[i0], swap_arr[i1] = _swap_gate(arr[a], arr[b])
947+
arr = swap_arr.copy()
948+
949+
return arr
950+
779951
def __len__(self):
780952
"""
781953
Overrides the default behavior of returning the length of the object.
@@ -1535,8 +1707,8 @@ def cossin(self, iterations: int = 10) -> "NadaArray":
15351707
iterations (int, optional): determines the number of iterations to run. Defaults to 10.
15361708
15371709
Returns:
1538-
Tuple[NadaArray, NadaArray]:
1539-
A tuple where the first element is cos and the second element is the sin.
1710+
NadaArray:
1711+
An array of tuples where the first element is cos and the second element is the sin.
15401712
"""
15411713
if self.is_rational:
15421714

@@ -1922,3 +2094,106 @@ def get_dtype(
19222094
if all(unique_type in get_args(base_dtype) for unique_type in unique_types):
19232095
return base_dtype
19242096
raise TypeError(f"Nada-incompatible dtypes detected in `{unique_types}`.")
2097+
2098+
2099+
# Shuffle
2100+
2101+
2102+
def _butterfly_block(base: int, step: int) -> np.ndarray:
2103+
"""
2104+
Generates a butterfly block of connections for a given base index and step.
2105+
2106+
Parameters:
2107+
base (int): The starting index for the butterfly block.
2108+
step (int): The step size used to calculate the indices for connections.
2109+
2110+
Returns:
2111+
np.ndarray: A 2D array of connections where each row represents a pair of
2112+
connected indices.
2113+
"""
2114+
# Create a range of indices
2115+
indices = np.arange(0, step, 2)
2116+
# First half
2117+
stage_i_1st_half = np.column_stack((base + indices, base + (indices + step)))
2118+
# Second half
2119+
stage_i_2nd_half = np.column_stack(
2120+
(base + (indices + step + 1), base + (indices + 1))
2121+
)
2122+
# Concatenate the two halves
2123+
return np.vstack((stage_i_1st_half, stage_i_2nd_half))
2124+
2125+
2126+
def _benes_network(n: int) -> np.ndarray:
2127+
"""
2128+
Constructs the Benes network for a given number of inputs/outputs.
2129+
2130+
Args:
2131+
n (int): The number of inputs/outputs. Must be a power of 2.
2132+
2133+
Returns:
2134+
np.ndarray: A 3D array where each 2D array represents the connections for a stage in the
2135+
network. Each row in the 2D array represents a pair of connected indices.
2136+
"""
2137+
if (n & (n - 1)) != 0 or n <= 0:
2138+
raise ValueError(
2139+
f"Benes network generation error. You asked for a benes network on {n} elemenst.\
2140+
The number of inputs must be a power of 2 and greater than 0. "
2141+
)
2142+
2143+
stages = []
2144+
log_n = int(np.log2(n))
2145+
2146+
# Stage 0: Initial connections (adjacent pairs)
2147+
indices = np.arange(0, n, 2)
2148+
stage_0 = np.column_stack((indices, indices + 1))
2149+
stages.append(stage_0)
2150+
2151+
# Stages 1 to log_n:
2152+
for stage_i in range(1, log_n):
2153+
step = n // 2**stage_i # index step between the first and second half of blocks
2154+
nr_of_halfs = 2**stage_i
2155+
stage_i_connections = []
2156+
2157+
for idx in range(0, nr_of_halfs, 2):
2158+
base = idx * step
2159+
halves = _butterfly_block(base, step)
2160+
stage_i_connections.append(halves)
2161+
2162+
# Combine the connections for the current stage
2163+
stage = np.vstack(stage_i_connections)
2164+
stages.append(stage)
2165+
2166+
# Reverse the stages for the second half
2167+
stages += stages[1:][::-1]
2168+
2169+
return np.array(stages)
2170+
2171+
2172+
def _rand_bool() -> SecretBoolean:
2173+
"""
2174+
Generates a random boolean.
2175+
"""
2176+
r = NadaArray.random((1,), SecretRational)[0]
2177+
return r > rational(0)
2178+
2179+
2180+
_SwapTypes = Union[
2181+
Rational,
2182+
SecretRational,
2183+
SecretInteger,
2184+
PublicInteger,
2185+
Integer,
2186+
PublicUnsignedInteger,
2187+
SecretUnsignedInteger,
2188+
]
2189+
2190+
2191+
def _swap_gate(a: _SwapTypes, b: _SwapTypes) -> Tuple[_SwapTypes, _SwapTypes]:
2192+
"""
2193+
Conditionally swaps two secret-shared rational numbers using a random boolean value.
2194+
"""
2195+
rbool = _rand_bool()
2196+
# swap
2197+
r1 = rbool.if_else(a, b)
2198+
r2 = rbool.if_else(b, a)
2199+
return r1, r2

0 commit comments

Comments
 (0)