anonymization-lib 1.4.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,618 @@
1
+ from pyspark.sql import DataFrame
2
+ from pyspark.sql import functions as F
3
+ from pyspark.sql.window import Window
4
+
5
+ from ..utils import EquivalenceGroups
6
+
7
+
8
+ class TCloseness(EquivalenceGroups):
9
+ """
10
+ Calculates t-closeness for a dataset based on quasi-identifiers
11
+ and one sensitive attribute.
12
+
13
+ T-closeness measures the distance between the distribution of a
14
+ sensitive attribute within each equivalence group and the distribution
15
+ of that attribute in the complete dataset.
16
+
17
+ An equivalence group satisfies t-closeness when its distance from the
18
+ global sensitive-attribute distribution does not exceed the configured
19
+ threshold.
20
+
21
+ Parameters
22
+ ----------
23
+ quasi_identifiers : list of str
24
+ Columns used to form equivalence groups (quasi-identifiers).
25
+ These are attributes that could potentially re-identify individuals.
26
+
27
+ sensitive_attribute : str
28
+ Column representing the sensitive attribute whose distribution
29
+ must be protected (e.g., disease, salary, diagnosis).
30
+
31
+ t_threshold : float
32
+ Maximum allowed distance between the local distribution of the
33
+ sensitive attribute within an equivalence group and its global
34
+ distribution in the dataset.
35
+
36
+ distance_metric : str, optional
37
+ Metric used to compare distributions. Supported values are:
38
+
39
+ - "l1":
40
+ Total variation distance. It is calculated as half of the
41
+ L1 distance between the local and global probability
42
+ distributions.
43
+
44
+ - "jsd":
45
+ Jensen-Shannon divergence. It is a symmetric and bounded
46
+ divergence derived from the Kullback-Leibler divergence.
47
+
48
+ Default is "l1".
49
+
50
+ Notes
51
+ -----
52
+ The implementation is based entirely on Spark DataFrame operations
53
+ and avoids collecting the dataset to the driver.
54
+
55
+ All values of the sensitive attribute that occur in the global
56
+ distribution are considered when evaluating each equivalence group.
57
+ If a sensitive value is absent from a particular group, its local
58
+ probability is treated as zero.
59
+ """
60
+
61
+ VALID_METRICS = {"l1", "jsd"}
62
+
63
+ def __init__(
64
+ self,
65
+ quasi_identifiers=None,
66
+ sensitive_attribute=None,
67
+ t_threshold=None,
68
+ distance_metric="l1"
69
+ ):
70
+ if quasi_identifiers is None:
71
+ quasi_identifiers = []
72
+
73
+ if not isinstance(quasi_identifiers, list):
74
+ raise ValueError(
75
+ "'quasi_identifiers' must be a list of column names."
76
+ )
77
+
78
+ if not quasi_identifiers:
79
+ raise ValueError(
80
+ "At least one quasi-identifier must be provided."
81
+ )
82
+
83
+ if not all(
84
+ isinstance(col, str) and col.strip()
85
+ for col in quasi_identifiers
86
+ ):
87
+ raise ValueError(
88
+ "'quasi_identifiers' must contain only non-empty strings."
89
+ )
90
+
91
+ if len(quasi_identifiers) != len(set(quasi_identifiers)):
92
+ raise ValueError(
93
+ "'quasi_identifiers' must not contain duplicate columns."
94
+ )
95
+
96
+ if (
97
+ not isinstance(sensitive_attribute, str)
98
+ or not sensitive_attribute.strip()
99
+ ):
100
+ raise ValueError(
101
+ "'sensitive_attribute' must be a non-empty column name."
102
+ )
103
+
104
+ if sensitive_attribute in quasi_identifiers:
105
+ raise ValueError(
106
+ "'sensitive_attribute' must not be included in "
107
+ "'quasi_identifiers'."
108
+ )
109
+
110
+ if (
111
+ not isinstance(t_threshold, (int, float))
112
+ or isinstance(t_threshold, bool)
113
+ ):
114
+ raise ValueError(
115
+ "'t_threshold' must be a numeric value."
116
+ )
117
+
118
+ if t_threshold <= 0:
119
+ raise ValueError(
120
+ "'t_threshold' must be greater than 0."
121
+ )
122
+
123
+ if distance_metric not in self.VALID_METRICS:
124
+ raise ValueError(
125
+ f"Unsupported metric. Use {sorted(self.VALID_METRICS)}"
126
+ )
127
+
128
+ self.quasi_identifiers = quasi_identifiers
129
+ self.sensitive_attribute = sensitive_attribute
130
+ self.t_threshold = t_threshold
131
+ self.distance_metric = distance_metric
132
+
133
+ def summary(self, df: DataFrame):
134
+ """
135
+ Computes t-closeness in a distributed manner.
136
+
137
+ The method compares the sensitive-attribute distribution of each
138
+ equivalence group with the global sensitive-attribute distribution.
139
+
140
+ The computation follows these steps:
141
+
142
+ 1. Compute the global distribution of the sensitive attribute.
143
+ 2. Build the equivalence groups defined by the quasi-identifiers.
144
+ 3. Compute the sensitive-attribute distribution within each group.
145
+ 4. Generate all combinations between equivalence groups and global
146
+ sensitive values so that values absent from a group receive a
147
+ local probability of zero.
148
+ 5. Compute the selected distance between each local distribution
149
+ and the global distribution.
150
+ 6. Identify groups whose distance exceeds the configured threshold.
151
+ 7. Generate aggregated statistics describing the resulting
152
+ t-closeness values.
153
+
154
+ Parameters
155
+ ----------
156
+ df : pyspark.sql.DataFrame
157
+ Input dataset containing the quasi-identifiers and the
158
+ sensitive attribute.
159
+
160
+ Returns
161
+ -------
162
+ TClosenessResult
163
+ Container object with:
164
+
165
+ - summary_df:
166
+ Aggregated statistics describing t-closeness across all
167
+ equivalence groups.
168
+
169
+ - violating_groups:
170
+ Equivalence groups whose t-closeness value exceeds the
171
+ configured threshold.
172
+
173
+ Raises
174
+ ------
175
+ ValueError
176
+ If the input DataFrame is None, is not Spark-compatible, or
177
+ does not contain all required columns.
178
+ """
179
+ if df is None:
180
+ raise ValueError(
181
+ "Input DataFrame cannot be None."
182
+ )
183
+
184
+ # Compatible with both standard Spark and Spark Connect.
185
+ if not hasattr(df, "groupBy"):
186
+ raise ValueError(
187
+ "Input must be a Spark-compatible DataFrame."
188
+ )
189
+
190
+ self._validate_columns(
191
+ df,
192
+ self.quasi_identifiers + [self.sensitive_attribute]
193
+ )
194
+
195
+ # ------------------------------------------------------
196
+ # GLOBAL SENSITIVE-ATTRIBUTE DISTRIBUTION
197
+ # ------------------------------------------------------
198
+
199
+ global_dist = (
200
+ df
201
+ .groupBy(self.sensitive_attribute)
202
+ .agg(
203
+ F.count("*").alias("global_count")
204
+ )
205
+ .withColumn(
206
+ "global_total",
207
+ F.sum("global_count").over(Window.partitionBy())
208
+ )
209
+ .withColumn(
210
+ "global_prob",
211
+ F.col("global_count") / F.col("global_total")
212
+ )
213
+ .select(
214
+ self.sensitive_attribute,
215
+ "global_prob"
216
+ )
217
+ )
218
+
219
+ # ------------------------------------------------------
220
+ # LOCAL SENSITIVE-ATTRIBUTE DISTRIBUTIONS
221
+ # ------------------------------------------------------
222
+
223
+ group_window = Window.partitionBy(
224
+ *self.quasi_identifiers
225
+ )
226
+
227
+ group_dist = (
228
+ df
229
+ .groupBy(
230
+ *self.quasi_identifiers,
231
+ self.sensitive_attribute
232
+ )
233
+ .agg(
234
+ F.count("*").alias("count")
235
+ )
236
+ .withColumn(
237
+ "group_total",
238
+ F.sum("count").over(group_window)
239
+ )
240
+ .withColumn(
241
+ "group_prob",
242
+ F.col("count") / F.col("group_total")
243
+ )
244
+ .select(
245
+ *self.quasi_identifiers,
246
+ self.sensitive_attribute,
247
+ "group_prob"
248
+ )
249
+ )
250
+
251
+ # ------------------------------------------------------
252
+ # EQUIVALENCE GROUPS
253
+ # ------------------------------------------------------
254
+
255
+ groups = (
256
+ group_dist
257
+ .select(*self.quasi_identifiers)
258
+ .distinct()
259
+ )
260
+
261
+ # ------------------------------------------------------
262
+ # COMPLETE DISTRIBUTION DOMAIN
263
+ # ------------------------------------------------------
264
+ #
265
+ # Every equivalence group must be compared against every
266
+ # sensitive value that exists globally.
267
+ #
268
+ # If a value does not occur in a particular group, its
269
+ # group probability is zero.
270
+ # ------------------------------------------------------
271
+
272
+ complete_domain = (
273
+ groups
274
+ .crossJoin(
275
+ F.broadcast(global_dist)
276
+ )
277
+ )
278
+
279
+ joined = (
280
+ complete_domain
281
+ .join(
282
+ group_dist,
283
+ on=(
284
+ self.quasi_identifiers
285
+ + [self.sensitive_attribute]
286
+ ),
287
+ how="left"
288
+ )
289
+ .fillna(
290
+ {"group_prob": 0.0}
291
+ )
292
+ )
293
+
294
+ # ------------------------------------------------------
295
+ # DISTANCE COMPUTATION
296
+ # ------------------------------------------------------
297
+
298
+ t_closeness_df = self._compute_distance(joined)
299
+
300
+ # ------------------------------------------------------
301
+ # VIOLATING GROUPS
302
+ # ------------------------------------------------------
303
+
304
+ violating_groups = (
305
+ t_closeness_df
306
+ .filter(
307
+ F.col("t_closeness") > self.t_threshold
308
+ )
309
+ )
310
+
311
+ # ------------------------------------------------------
312
+ # SUMMARY
313
+ # ------------------------------------------------------
314
+
315
+ summary_df = (
316
+ t_closeness_df
317
+ .agg(
318
+ F.count("*").alias(
319
+ "num_equivalence_groups"
320
+ ),
321
+ F.min("t_closeness").alias(
322
+ "min_t"
323
+ ),
324
+ F.max("t_closeness").alias(
325
+ "max_t"
326
+ ),
327
+ F.round(
328
+ F.avg("t_closeness"),
329
+ 5
330
+ ).alias(
331
+ "avg_t"
332
+ ),
333
+ F.sum(
334
+ F.when(
335
+ F.col("t_closeness")
336
+ > self.t_threshold,
337
+ 1
338
+ ).otherwise(0)
339
+ ).alias(
340
+ "num_violating_groups"
341
+ )
342
+ )
343
+ .withColumn(
344
+ "t_threshold",
345
+ F.lit(self.t_threshold)
346
+ )
347
+ .withColumn(
348
+ "satisfies_t_closeness",
349
+ F.col("max_t")
350
+ <= F.col("t_threshold")
351
+ )
352
+ )
353
+
354
+ return TClosenessResult(
355
+ summary_df=summary_df,
356
+ violating_groups=violating_groups
357
+ )
358
+
359
+ def _compute_distance(
360
+ self,
361
+ df: DataFrame
362
+ ) -> DataFrame:
363
+ """
364
+ Computes the selected distance between each equivalence group's
365
+ sensitive-attribute distribution and the global distribution.
366
+
367
+ Parameters
368
+ ----------
369
+ df : pyspark.sql.DataFrame
370
+ DataFrame containing one row for each combination of
371
+ equivalence group and globally observed sensitive value.
372
+
373
+ The DataFrame must contain:
374
+
375
+ - the configured quasi-identifiers,
376
+ - ``group_prob``: probability of the sensitive value inside
377
+ the equivalence group,
378
+ - ``global_prob``: probability of the sensitive value in the
379
+ complete dataset.
380
+
381
+ Returns
382
+ -------
383
+ pyspark.sql.DataFrame
384
+ DataFrame containing one row per equivalence group and a
385
+ ``t_closeness`` column with the calculated distance.
386
+
387
+ Notes
388
+ -----
389
+ For ``l1``, the implementation uses total variation distance:
390
+
391
+ 0.5 * sum(|P(x) - Q(x)|)
392
+
393
+ For ``jsd``, the implementation uses Jensen-Shannon divergence
394
+ based on logarithms in base 2.
395
+ """
396
+ if self.distance_metric == "l1":
397
+ return (
398
+ df
399
+ .withColumn(
400
+ "abs_diff",
401
+ F.abs(
402
+ F.col("group_prob")
403
+ - F.col("global_prob")
404
+ )
405
+ )
406
+ .groupBy(
407
+ *self.quasi_identifiers
408
+ )
409
+ .agg(
410
+ (
411
+ F.sum("abs_diff") / 2
412
+ ).alias("t_closeness")
413
+ )
414
+ )
415
+
416
+ if self.distance_metric == "jsd":
417
+ epsilon = F.lit(1e-12)
418
+
419
+ p = F.col("group_prob")
420
+ q = F.col("global_prob")
421
+
422
+ m = (p + q) / 2
423
+
424
+ # Terms with probability zero contribute zero to
425
+ # the corresponding KL divergence.
426
+ p_term = F.when(
427
+ p > 0,
428
+ p * F.log2((p + epsilon) / (m + epsilon))
429
+ ).otherwise(
430
+ F.lit(0.0)
431
+ )
432
+
433
+ q_term = F.when(
434
+ q > 0,
435
+ q * F.log2((q + epsilon) / (m + epsilon))
436
+ ).otherwise(
437
+ F.lit(0.0)
438
+ )
439
+
440
+ jsd_expr = (
441
+ F.lit(0.5) * p_term
442
+ + F.lit(0.5) * q_term
443
+ )
444
+
445
+ return (
446
+ df
447
+ .groupBy(
448
+ *self.quasi_identifiers
449
+ )
450
+ .agg(
451
+ F.sum(
452
+ jsd_expr
453
+ ).alias("t_closeness")
454
+ )
455
+ )
456
+
457
+ raise ValueError(
458
+ f"Unsupported distance metric: "
459
+ f"{self.distance_metric}"
460
+ )
461
+
462
+
463
+ class TClosenessResult:
464
+ """
465
+ Container for t-closeness results.
466
+
467
+ The object stores both the global summary generated by the
468
+ t-closeness analysis and the equivalence groups that violate the
469
+ configured privacy threshold.
470
+
471
+ Attributes
472
+ ----------
473
+ summary_df : pyspark.sql.DataFrame
474
+ Aggregated statistics of t-closeness values across all equivalence
475
+ groups, including minimum, maximum and average distances.
476
+
477
+ violating_groups : pyspark.sql.DataFrame
478
+ Subset of equivalence groups whose t-closeness value exceeds the
479
+ configured threshold, representing potential privacy risks.
480
+ """
481
+
482
+ def __init__(
483
+ self,
484
+ summary_df: DataFrame,
485
+ violating_groups: DataFrame
486
+ ):
487
+ """
488
+ Initializes the result object.
489
+
490
+ Parameters
491
+ ----------
492
+ summary_df : pyspark.sql.DataFrame
493
+ Aggregated statistics describing the t-closeness values of
494
+ the dataset.
495
+
496
+ violating_groups : pyspark.sql.DataFrame
497
+ Equivalence groups whose t-closeness value exceeds the
498
+ configured threshold.
499
+ """
500
+ self.summary_df = summary_df
501
+ self.violating_groups = violating_groups
502
+
503
+ def show_summary(self):
504
+ """
505
+ Displays aggregated statistics of t-closeness values.
506
+
507
+ The summary includes:
508
+
509
+ - num_equivalence_groups:
510
+ Total number of equivalence groups.
511
+
512
+ - min_t:
513
+ Minimum t-closeness distance among all equivalence groups.
514
+
515
+ - max_t:
516
+ Maximum t-closeness distance among all equivalence groups.
517
+
518
+ - avg_t:
519
+ Average t-closeness distance across all equivalence groups.
520
+
521
+ - num_violating_groups:
522
+ Number of equivalence groups whose distance exceeds the
523
+ configured t-threshold.
524
+
525
+ - t_threshold:
526
+ Maximum allowed distance configured by the user.
527
+
528
+ - satisfies_t_closeness:
529
+ Boolean indicating whether every equivalence group satisfies
530
+ the configured t-closeness requirement.
531
+
532
+ Interpretation
533
+ --------------
534
+ The dataset satisfies t-closeness when:
535
+
536
+ max_t <= t_threshold
537
+
538
+ and therefore:
539
+
540
+ num_violating_groups = 0
541
+ """
542
+ self.summary_df.show(
543
+ truncate=False
544
+ )
545
+
546
+ def show_violating_groups(
547
+ self,
548
+ n: int = 10,
549
+ sort: bool = False
550
+ ):
551
+ """
552
+ Displays equivalence groups that do not satisfy t-closeness.
553
+
554
+ Parameters
555
+ ----------
556
+ n : int, optional
557
+ Number of violating groups to display.
558
+ Default is 10.
559
+
560
+ sort : bool, optional
561
+ Whether to sort violating groups by t-closeness distance in
562
+ descending order.
563
+
564
+ Sorting may increase computational cost for large datasets
565
+ because it requires an additional distributed ordering
566
+ operation.
567
+
568
+ Default is False.
569
+
570
+ Notes
571
+ -----
572
+ The displayed t-closeness values are rounded to five decimal
573
+ places for readability. The underlying values stored in the
574
+ DataFrame are not modified.
575
+ """
576
+ df = self.violating_groups
577
+
578
+ if sort:
579
+ df = df.orderBy(
580
+ F.col("t_closeness").desc()
581
+ )
582
+
583
+ df = df.withColumn(
584
+ "t_closeness",
585
+ F.round(
586
+ F.col("t_closeness"),
587
+ 5
588
+ )
589
+ )
590
+
591
+ df.show(
592
+ n,
593
+ truncate=False
594
+ )
595
+
596
+ def get_summary_df(self) -> DataFrame:
597
+ """
598
+ Returns aggregated statistics of t-closeness values.
599
+
600
+ Returns
601
+ -------
602
+ pyspark.sql.DataFrame
603
+ DataFrame containing the global t-closeness assessment and
604
+ the configured privacy threshold.
605
+ """
606
+ return self.summary_df
607
+
608
+ def get_violating_groups(self) -> DataFrame:
609
+ """
610
+ Returns equivalence groups that do not satisfy t-closeness.
611
+
612
+ Returns
613
+ -------
614
+ pyspark.sql.DataFrame
615
+ DataFrame containing the equivalence groups whose t-closeness
616
+ value exceeds the configured threshold.
617
+ """
618
+ return self.violating_groups
@@ -0,0 +1,6 @@
1
+ from .substitution import Substitution
2
+ from .suppression import Suppression
3
+ from .generalization import Generalization
4
+ from .pipeline import transformation_pipeline
5
+
6
+ __all__ = ["Substitution", "Suppression", "Generalization", "transformation_pipeline"]