data-privacy 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,7 @@
1
+
2
+ from . import dataset, measure, anonymity, channel, mechanism, strategy
3
+
4
+ __version__ = '0.1.0'
5
+
6
+ __all__ = ["dataset", "measure", "anonymity", "channel", "mechanism", "strategy"]
7
+
@@ -0,0 +1,19 @@
1
+ import pandas as pd
2
+
3
+ def max_kanonymity(D):
4
+ '''Outputs the maximum value of k for which D represents a k-anonymous dataset. D is
5
+ a pandas dataframe created using the make_channel function.'''
6
+ return D.astype(bool).sum(axis=0).min()
7
+
8
+ def is_kanonymous(D, k):
9
+ '''Outputs true if the dataset D satisfies k-anonymity. D is a pandas dataframe
10
+ created using the make_channel function.'''
11
+ max_k = max_kanonymity(D)
12
+ return max_k >= k
13
+
14
+ def vulnerable_identifiers(D, k):
15
+ '''Outputs the column names for which k-anonymity does not hold.'''
16
+ # k_values contains the k-anonymity value for each column
17
+ k_values = D.astype(bool).sum(axis=0)
18
+ # return the column names and k-anonymity values for which k_values are < k.
19
+ return k_values[k_values < k]
@@ -0,0 +1,79 @@
1
+ import pandas as pd
2
+ import numpy as np
3
+ import math
4
+ from itertools import product
5
+ import pyqif as pyq
6
+ import privacybook.strategy as strategy
7
+
8
+ def compose(C, D):
9
+ '''Constructs the perturbation channel corresponding to the composition of
10
+ the channels C and D. Assumes that the columns of C match the rows of D.'''
11
+ C_values = C.to_numpy()
12
+ D_values = D.to_numpy()
13
+ composition = np.matmul(C_values, D_values)
14
+ return pd.DataFrame(data=composition, index=C.index, columns=D.columns)
15
+
16
+ def kroneckerN(C, n):
17
+ '''Computes the n-fold Kronecker product of channel C with itself.'''
18
+ # Use underlying pyqif library to kronecker the channel
19
+ Ch = C.to_numpy()
20
+ ChN = pyq.compose.kroneckerN(Ch, n)
21
+
22
+ # Compute cartesian product of labels
23
+ states = list(product(C.index, repeat=n))
24
+ idx = pd.MultiIndex.from_tuples(
25
+ states,
26
+ names=[f"X{i+1}" for i in range(n)]
27
+ )
28
+ # Put channel back together with labels
29
+ Kn = pd.DataFrame(ChN, index=idx, columns=idx)
30
+ return Kn
31
+
32
+ def inputs_at_risk(C):
33
+ '''
34
+ Returns the row indices of the channel C for which the secrets can be identified completely.
35
+ A secret is fully identified if there exists a column for which this secret is the only non-zero
36
+ value in the column.
37
+ '''
38
+ # Assume C is a channel with secrets as row labels
39
+ # We first count the number of non-zero values in each column
40
+ counts = C.astype(bool).sum(axis=0)
41
+ # Now we find the secrets that correspond to single non-zero values in a column
42
+ inputs = counts.index[counts == 1]
43
+ return inputs
44
+
45
+ def inputs_at_high_risk(C, p):
46
+ '''Returns a list of row indices (secrets) for which the adversary can guess the secret
47
+ with probability ≥ p under a uniform prior.
48
+ '''
49
+ # Compute the posterior matrix under uniform prior
50
+ u = pyq.probab.uniform(C.shape[0])
51
+ P = pyq.channel.posteriors(u, C.to_numpy())
52
+ P_df = pd.DataFrame(data=P, index=C.index, columns=C.columns)
53
+ maxes = P_df.max()
54
+ return maxes.index[maxes > p]
55
+
56
+ def inputs_at_linkage_risk(C, attrs):
57
+ '''Returns the secrets which can be perfectly identified by the QIDs in attrs.
58
+ '''
59
+ # Assume that C is a channel returned by make_channel.
60
+ # Assume also that attrs is a subset of the QIDs.
61
+
62
+ # Group the secrets by the attrs
63
+ groups = C.T.groupby(attrs).sum()
64
+
65
+ # Count how many QIDs are unique to a secret
66
+ summary = groups.astype(bool).T.sum()
67
+
68
+ vulnerable_attrs = summary.index[summary == 1]
69
+ vulnerable_secrets = C[vulnerable_attrs].astype(bool).sum(axis=1)
70
+ return vulnerable_secrets.index[vulnerable_secrets == 1]
71
+
72
+ def inputs_at_inference_risk(C, D):
73
+ '''Returns the list of of secrets that are at risk from being inferred
74
+ through linking C and D.
75
+ '''
76
+ # Assume that C and D are channels created by make_channel.
77
+ # Assume that the outputs of C correspond with the inputs of D.
78
+ CD = strategy.linkage(C, D)
79
+ return inputs_at_risk(CD)
@@ -0,0 +1,33 @@
1
+ import pandas as pd
2
+
3
+ #
4
+ # make_channel
5
+ #
6
+ # Inputs:
7
+ # - inputTable: dataframe representing a dataset
8
+ # - secretsList: array of column names representing secrets
9
+ # - qidsList: array of column names representing QIDs
10
+ #
11
+ # Returns:
12
+ # - dataframe representing a channel: rows are labelled with
13
+ # secrets and columns are labelled with QIDs
14
+ #
15
+
16
+ def make_channel(inputTable, secretsList, qidsList):
17
+ '''Create a channel from a tabular datasource given as a pandas frame.
18
+ The row and column labels are also provided as secretsList and qidsList
19
+ respectively.'''
20
+
21
+ # Group by QIDs + secrets → counts
22
+ counts = inputTable.groupby(qidsList + secretsList).size()
23
+
24
+ # Reshape into QID (columns) × Secret (rows)
25
+ channel = counts.unstack(level=qidsList, fill_value=0)
26
+
27
+ # Convert counts into probabilities
28
+ row_sums = channel.sum(axis=1)
29
+ prob_channel = channel.div(row_sums, axis="index")
30
+
31
+ return prob_channel
32
+
33
+
@@ -0,0 +1,101 @@
1
+ from scipy.spatial.distance import pdist, squareform
2
+ import pyqif as pyq
3
+ import numpy as np
4
+
5
+ def quasi_identification_risk(D):
6
+ # Pre: D is a dataframe
7
+ c = D.to_numpy() # Convert DataFrame to NumPy array
8
+ u = pyq.probab.uniform(c.shape[0])
9
+
10
+ # Return average risk across all individuals
11
+ return pyq.measure.vg.posterior_bayes(u, c)
12
+
13
+ #
14
+ # This function computes the bayes capacity of the channel, which is the
15
+ # sum of the column maxima.
16
+ #
17
+ def reidentification_risk(D):
18
+ '''Computes the Bayes' capacity for the channel DBN, which corresponds to the
19
+ leakage due to the channel for an adversary who wants to perform a reidentification
20
+ attack (guessing the secret exactly in 1 try). Ranges from 1 (no leak) to #columns in
21
+ channel (full leakage)'''
22
+ return quasi_identification_risk(D)
23
+ #ch = D.to_numpy()
24
+ #return pyq.measure.bayes_capacity(ch)
25
+
26
+ #
27
+ # Computes the posterior vulnerability of the attribute with respect to
28
+ # a uniform prior. This gives the probability of a successful property
29
+ # inference against a given sensitive attribute for an adversary with no
30
+ # prior knowledge.
31
+ #
32
+ def property_inference_risk(D, attr):
33
+ '''Computes the risk of inferring a sensitive attribute in the dataset D.
34
+ This assumes an adversary who has a uniform prior over all secrets.'''
35
+ attr_i = D.index.names.index(attr)
36
+ # Combine all rows with the same sensitive attribute
37
+ # by computing their mean (equivalent to uniform prior)
38
+ D_by_attr = D.groupby(level=[attr_i]).mean()
39
+ u = pyq.probab.uniform(D_by_attr.shape[0])
40
+ return pyq.measure.vg.posterior_bayes(u, D_by_attr.to_numpy())
41
+
42
+ #
43
+ # Computes the risk of guessing the secret given knowledge of the attributes
44
+ # in the attrs array, under a uniform prior.
45
+ #
46
+ def linkability_risk(C, attrs):
47
+ '''Outputs the risk of reconstructing the full secret in channel C,
48
+ relative to linking via attributeList (quasi-identifiers).
49
+ A row is considered at risk if its projection on the attributeList is unique.
50
+ '''
51
+ # For this we assume C is a channel produced by make_channel.
52
+ # We assume attrs is a subset of the QIDs in C.
53
+
54
+ # Group the secrets by the attrs
55
+ groups = C.T.groupby(attrs).sum()
56
+
57
+ # Count how many QIDs are unique to a secret
58
+ summary = groups.astype(bool).T.sum()
59
+
60
+ # Identify the vulnerable secrets as ones which contain a single non-zero value
61
+ # Note that these are actually the QIDs since we have transposed the channel
62
+ vulnerable_attrs = summary.index[summary == 1]
63
+
64
+ # Add up all the probabilities for the vulnerable inputs.
65
+ prob = groups.loc[vulnerable_attrs, ].max(axis=1).sum()
66
+
67
+ # Now have to divide probability by size of secret space, equivalent to uniform prior
68
+ num_secrets = C.index.size
69
+ return prob / num_secrets
70
+
71
+
72
+ def __dist_arr(C, values):
73
+ val_arr = np.array([ [i] for i in values ])
74
+ dists = pdist(val_arr)
75
+ # create the loss function which gives the abs distance between values of attr
76
+ dists_arr = squareform(dists)
77
+ return dists_arr
78
+
79
+ def average_minimum_error(C, attr):
80
+ '''Computes the expected distance of the true value from the output value for
81
+ the given attribute in the channel D.'''
82
+ # C is a pandas dataframe representing a channel which takes secrets X to observations Y.
83
+ # We assume that the given attribute is numeric so we can compute a mean error
84
+ # between the guessed value of the attribute and the true value.
85
+ values = C.index.get_level_values(attr).to_numpy()
86
+ dists_arr = __dist_arr(C, values)
87
+ avg_uncertainty = pyq.measure.ul.posterior(pyq.probab.uniform(len(values)), C.to_numpy(), dists_arr)
88
+ return avg_uncertainty
89
+
90
+ def likely_interval(D, attr, c):
91
+ '''Computes the probability that an adversary cannot infer the value of attr
92
+ to within bound c of its true value.'''
93
+ # The implementation of this uses the loss function from average_minimum_error
94
+ # and converts the distances to bits depending on c.
95
+ values = D.index.get_level_values(attr).to_numpy()
96
+ dists_arr = __dist_arr(D, values)
97
+ # convert distance matrix to bit matrix
98
+ bits_arr = np.array( [ [ int(i<c) for i in row] for row in dists_arr])
99
+ avg_uncertainty = pyq.measure.ul.posterior(pyq.probab.uniform(len(values)), D.to_numpy(), bits_arr)
100
+ return avg_uncertainty
101
+
@@ -0,0 +1,119 @@
1
+ import pandas as pd
2
+ import numpy as np
3
+ import pyqif as pyq
4
+ import math
5
+
6
+ def bounded_noise(D, attr, noise):
7
+ '''Creates a customised bounded noise channel using the given noise
8
+ distribution. For example, noise which returns the same value with prob.
9
+ 1/2, and returns the value +- 1 with prob. 1/4 (each) would look like:
10
+ noise=pd.Series([ 1/4, 1/2, 1/4], index=[-1, 0, 1]).'''
11
+ try:
12
+ attr_i = D.columns.names.index(attr)
13
+ noise_labels = noise.index
14
+ df = pd.DataFrame()
15
+ for col_i in range(len(D.columns)):
16
+ v = D.columns[col_i][attr_i] # the value of the attr to add noise to
17
+ if int(v) != v:
18
+ raise Exception("The given attribute is not numeric")
19
+ # Create a new channel with labels as noisy attrs, values
20
+ # are the probabilities.
21
+ def get_noisy_qid(noise_i):
22
+ qid = list(D.columns[col_i])
23
+ qid[attr_i] += noise_labels[noise_i]
24
+ return qid
25
+ tuples = list([ get_noisy_qid(idx) for idx in range(len(noise_labels))])
26
+ result = pd.MultiIndex.from_tuples(tuples, names=D.columns.names)
27
+ multirow = pd.Series(noise.to_numpy(), index=result, name=D.columns[col_i])
28
+ df = pd.concat([df, multirow.to_frame().T])
29
+ df = df.fillna(0)
30
+ # Fix up the names for the rows (secrets)
31
+ df.index = df.index.set_names(D.columns.names)
32
+ return df
33
+ except ValueError:
34
+ print("Error: no such attribute name: ", attr)
35
+ except Exception as inst:
36
+ print(inst)
37
+ return None
38
+
39
+
40
+ def uniform_bounded_noise(D, attr, bound):
41
+ '''Computes a bounded noise channel for perturbing the attribute in a given channel D
42
+ using the given bound. For example, if bound=1 and attr="age", then each age in
43
+ the dataset D should be perturbed by either adding 1, returning unchanged or subtracting 1,
44
+ each with probability 1/3 (uniform).'''
45
+ noise_prob = 1.0 / (2 * bound + 1) # uniform prob over values
46
+ noise = pd.Series(noise_prob, index=list(range(-bound, bound+1)))
47
+ return bounded_noise(D, attr, noise)
48
+
49
+
50
+ def random_response(D, attr, K, eps):
51
+ '''Given the dataset channel D and an attribute attr, computes the random
52
+ response channel applied to attr with range K and epsilon eps. Note that attr
53
+ (and K) can be categorical (string valued) or numeric.'''
54
+ # RR channel has entries q = 1 / (len(K) - 1 + e **eps)
55
+ # and single entry p = e**eps/(len(K) - 1 + e ** eps)
56
+ q = 1 / (len(K) -1 + (math.e ** eps))
57
+ p = q * (math.e ** eps)
58
+ try:
59
+ attr_i = D.columns.names.index(attr)
60
+ df = pd.DataFrame()
61
+ for col_i in range(len(D.columns)):
62
+ v = D.columns[col_i][attr_i] # the value of the attr to add noise to
63
+ #if int(v) != v:
64
+ # raise Exception("The given attribute is not numeric")
65
+ qid = list(D.columns[col_i]) # the value of the QID for this column
66
+ # We want to replace the value at index attr_i with the range of K
67
+ tuples = [ qid[:attr_i] + [noise_i] + qid[attr_i+1:] for noise_i in K]
68
+ result = pd.MultiIndex.from_tuples(tuples, names=D.columns.names)
69
+ # Random response noise is almost all the same
70
+ noise = [q] * len(K)
71
+ # We need the index of v in K
72
+ if not v in K:
73
+ raise Exception("The attribute " + str(v) + " lies outside the range " + str(K))
74
+ noise[K.index(v)] = p
75
+ multirow = pd.Series(noise, index=result, name=D.columns[col_i])
76
+ df = pd.concat([df, multirow.to_frame().T])
77
+ df = df.fillna(0)
78
+ # Fix up the names for the rows (secrets)
79
+ df.index = df.index.set_names(D.columns.names)
80
+ return df
81
+ except ValueError:
82
+ print("Error: no such attribute name: ", attr)
83
+ except Exception as inst:
84
+ print(inst)
85
+ return None
86
+
87
+ def geometric(D, attr, K, eps):
88
+ '''Given the dataset channel D and a numeric attribute attr, computes the geometric
89
+ channel for adding noise to attr with range K and epsilon eps.'''
90
+ # We start by just generating geometric noise as a numpy array
91
+ G_channel = pyq.dp.ranged_geometric(K, eps)
92
+ try:
93
+ attr_i = D.columns.names.index(attr)
94
+ df = pd.DataFrame()
95
+ for col_i in range(len(D.columns)):
96
+ v = D.columns[col_i][attr_i] # the value of the attr to add noise to
97
+ if int(v) != v:
98
+ raise Exception("The given attribute is not numeric")
99
+ qid = list(D.columns[col_i]) # the value of the QID for this column
100
+ # We want to replace the value at index attr_i with the range of K
101
+ tuples = [ qid[:attr_i] + [noise_i] + qid[attr_i+1:] for noise_i in K]
102
+ result = pd.MultiIndex.from_tuples(tuples, names=D.columns.names)
103
+ # Get the noise from the correct row of the geometric mechanism
104
+ # We need the index of v in K
105
+ if not v in K:
106
+ raise Exception("The attribute " + str(v) + " lies outside the range " + str(K))
107
+ noise = G_channel[K.index(v)]
108
+ multirow = pd.Series(noise, index=result, name=D.columns[col_i])
109
+ df = pd.concat([df, multirow.to_frame().T])
110
+ df = df.fillna(0)
111
+ # Fix up the names for the rows (secrets)
112
+ df.index = df.index.set_names(D.columns.names)
113
+ return df
114
+ except ValueError:
115
+ print("Error: no such attribute name: ", attr)
116
+ except Exception as inst:
117
+ print(inst)
118
+ return None
119
+
@@ -0,0 +1,21 @@
1
+ import pandas as pd
2
+ import numpy as np
3
+ import pyqif as pyq
4
+
5
+ def intersection(C, D):
6
+ '''Constructs the intersection attack channel for an adversary who
7
+ has access to 2 channels C, D on the same inputs. Corresponds to a
8
+ parallel composition of channels.'''
9
+ #Pre: Channels C, D must have the same number of rows
10
+ CD = pyq.compose.parallel(C.to_numpy(), D.to_numpy())
11
+ # Recompute the columns
12
+ cols = np.array(np.meshgrid(C.columns.names, D.columns.names)).T.reshape(-1, 2)
13
+ return pd.DataFrame(data=CD, index=D.index, columns=cols)
14
+
15
+ def linkage(C, D):
16
+ '''Computes the linkage attack matrix which utilises the
17
+ correlation between observations on C and inputs on D.
18
+ '''
19
+ # Assume that C and D are channels created using make_channel
20
+ return C.dot(D)
21
+
@@ -0,0 +1,16 @@
1
+ Metadata-Version: 2.4
2
+ Name: data-privacy
3
+ Version: 0.1.0
4
+ Summary: Utilities for ...
5
+ Requires-Python: >=3.9
6
+ Description-Content-Type: text/markdown
7
+ License-File: LICENSE
8
+ Requires-Dist: numpy
9
+ Requires-Dist: pandas
10
+ Requires-Dist: scipy
11
+ Dynamic: license-file
12
+
13
+ # A First Course in Data Privacy
14
+
15
+ This is the codebase supporting the textbook on privacy for undergraduates.
16
+
@@ -0,0 +1,12 @@
1
+ data_privacy/__init__.py,sha256=5wK7gZFJryHPu7-Tq4tjw6cNwBW6Zg9EIZiCOmxj3Zs,180
2
+ data_privacy/anonymity.py,sha256=CRSNdCvbDsThDYFPF-S1Q-nx79_D-TAVKB-b2U-ArfY,780
3
+ data_privacy/channel.py,sha256=ZEXO1jW604ThQaG7FFyiL0M8DsSocJcVFd4UpFDVsrU,2970
4
+ data_privacy/dataset.py,sha256=g99DQUlUf3iqX96cjDFOvZaf8jn-1yv0c8cHVgIkI6E,934
5
+ data_privacy/measure.py,sha256=hpyb_OW96qwyAeMMvemHM3_HB8uhx9h36SMhInUYZIM,4315
6
+ data_privacy/mechanism.py,sha256=VTDgt9EoHzStvOzqIGgIDzGuCL2PdKMCTA-Sl5No0DE,5577
7
+ data_privacy/strategy.py,sha256=o8tEEcnen7rTFDVUU7U6zDPhhq6CsOi2stM0LeBOYLM,792
8
+ data_privacy-0.1.0.dist-info/licenses/LICENSE,sha256=ayCLTzh9dbPVd0dkMqrBEF-NgtcQ8s_UwPHsjpeHiEw,1063
9
+ data_privacy-0.1.0.dist-info/METADATA,sha256=_B3WqcJHQu9yUc00JSuLzfwdURn3AObMdJQW507iCGo,366
10
+ data_privacy-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
11
+ data_privacy-0.1.0.dist-info/top_level.txt,sha256=dFbMTVofI5JaKcs3t3ka489mQbJw9te7rECLaxQ4894,13
12
+ data_privacy-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Topete
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ data_privacy