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,730 @@
1
+ from pyspark.sql import DataFrame
2
+ from pyspark.sql import functions as F
3
+
4
+
5
+ class AnonymizationAdvisor:
6
+ """
7
+ Suggests anonymization improvements based on k-anonymity analysis.
8
+
9
+ The advisor evaluates the current level of k-anonymity of a dataset
10
+ and analyzes the influence of each quasi-identifier on the formation
11
+ of equivalence groups.
12
+
13
+ For each quasi-identifier, the advisor evaluates two complementary
14
+ properties:
15
+
16
+ 1. Cardinality frequency:
17
+ Measures the proportion of distinct values in the column relative
18
+ to the total number of records. High-cardinality attributes may
19
+ have strong individual identifying power.
20
+
21
+ 2. Equivalence-group reduction:
22
+ Estimates how the number of equivalence groups would change if
23
+ the attribute were removed from the quasi-identifier set. A large
24
+ reduction indicates that the attribute contributes significantly
25
+ to the fragmentation of the dataset.
26
+
27
+ Using both metrics avoids misleading recommendations when multiple
28
+ high-cardinality quasi-identifiers coexist. For example, removing
29
+ one unique identifier may produce no reduction in equivalence groups
30
+ if another unique identifier remains in the quasi-identifier set.
31
+
32
+ Parameters
33
+ ----------
34
+ quasi_identifiers : list of str
35
+ Columns used to form equivalence groups (quasi-identifiers).
36
+ These are attributes that could potentially re-identify
37
+ individuals when combined.
38
+
39
+ k : int
40
+ Target k-anonymity value. Each equivalence group should contain
41
+ at least ``k`` records for the dataset to satisfy the configured
42
+ k-anonymity requirement.
43
+
44
+ The value must be an integer greater than 1. Boolean values are
45
+ not accepted.
46
+
47
+ Notes
48
+ -----
49
+ The advisor does not modify the input dataset or automatically apply
50
+ anonymization techniques. It only analyzes the dataset and generates
51
+ recommendations that can guide subsequent anonymization decisions.
52
+
53
+ The analysis is performed using Spark DataFrame operations so that
54
+ large datasets can be processed in a distributed manner.
55
+
56
+ The generated recommendations combine two complementary indicators:
57
+
58
+ - ``cardinality_frequency``:
59
+ Ratio between the number of distinct values in a quasi-identifier
60
+ and the total number of records.
61
+
62
+ - ``group_reduction_frequency``:
63
+ Relative reduction in the number of equivalence groups when the
64
+ quasi-identifier is removed from the current quasi-identifier set.
65
+
66
+ The risk thresholds used by the advisor are heuristic and are intended
67
+ to provide practical guidance rather than a formal privacy guarantee.
68
+ The actual anonymization strategy should also consider the semantics
69
+ of each attribute and the intended use of the dataset.
70
+ """
71
+
72
+ HIGH_CARDINALITY_THRESHOLD = 0.50
73
+ MEDIUM_CARDINALITY_THRESHOLD = 0.10
74
+
75
+ HIGH_GROUP_REDUCTION_THRESHOLD = 0.50
76
+ MEDIUM_GROUP_REDUCTION_THRESHOLD = 0.20
77
+
78
+ def __init__(
79
+ self,
80
+ quasi_identifiers: list,
81
+ k: int,
82
+ ):
83
+ if (
84
+ not isinstance(quasi_identifiers, list)
85
+ or not quasi_identifiers
86
+ ):
87
+ raise ValueError(
88
+ "'quasi_identifiers' must be a non-empty list."
89
+ )
90
+
91
+ if not all(
92
+ isinstance(column, str) and column.strip()
93
+ for column in quasi_identifiers
94
+ ):
95
+ raise ValueError(
96
+ "All quasi-identifiers must be non-empty strings."
97
+ )
98
+
99
+ if len(quasi_identifiers) != len(set(quasi_identifiers)):
100
+ raise ValueError(
101
+ "'quasi_identifiers' must not contain duplicates."
102
+ )
103
+
104
+ if (
105
+ not isinstance(k, int)
106
+ or isinstance(k, bool)
107
+ or k <= 1
108
+ ):
109
+ raise ValueError(
110
+ "'k' must be an integer greater than 1."
111
+ )
112
+
113
+ self.quasi_identifiers = quasi_identifiers
114
+ self.k = k
115
+
116
+ def suggest(
117
+ self,
118
+ df: DataFrame,
119
+ ) -> "AnonymizationAdvisorResult":
120
+ """
121
+ Analyzes the dataset and generates recommendations to improve
122
+ k-anonymity.
123
+
124
+ Parameters
125
+ ----------
126
+ df : DataFrame
127
+ Input Spark DataFrame.
128
+
129
+ Returns
130
+ -------
131
+ AnonymizationAdvisorResult
132
+ Object containing the global privacy summary and the
133
+ column-level anonymization recommendations.
134
+
135
+ Raises
136
+ ------
137
+ ValueError
138
+ If the input is None, is not compatible with a Spark
139
+ DataFrame, or does not contain all configured
140
+ quasi-identifiers.
141
+ """
142
+ if df is None:
143
+ raise ValueError(
144
+ "Input DataFrame cannot be None."
145
+ )
146
+
147
+ # Compatible with standard Spark and Spark Connect.
148
+ if not hasattr(df, "groupBy"):
149
+ raise ValueError(
150
+ "Input must be a Spark-compatible DataFrame."
151
+ )
152
+
153
+ missing_columns = [
154
+ column
155
+ for column in self.quasi_identifiers
156
+ if column not in df.columns
157
+ ]
158
+
159
+ if missing_columns:
160
+ raise ValueError(
161
+ f"Columns not found in DataFrame: {missing_columns}"
162
+ )
163
+
164
+ equivalence_groups = self._build_equivalence_groups(
165
+ df,
166
+ self.quasi_identifiers,
167
+ )
168
+
169
+ (
170
+ summary_df,
171
+ current_groups,
172
+ total_records,
173
+ ) = self._build_summary(
174
+ df,
175
+ equivalence_groups,
176
+ )
177
+
178
+ suggestions_df = self._build_suggestions(
179
+ df,
180
+ current_groups,
181
+ total_records,
182
+ )
183
+
184
+ return AnonymizationAdvisorResult(
185
+ summary_df=summary_df,
186
+ suggestions_df=suggestions_df,
187
+ )
188
+
189
+ def _build_equivalence_groups(
190
+ self,
191
+ df: DataFrame,
192
+ columns: list,
193
+ ) -> DataFrame:
194
+ """
195
+ Builds equivalence groups using the selected quasi-identifiers.
196
+
197
+ Parameters
198
+ ----------
199
+ df : DataFrame
200
+ Input Spark DataFrame.
201
+
202
+ columns : list of str
203
+ Columns used to define the equivalence groups.
204
+
205
+ Returns
206
+ -------
207
+ DataFrame
208
+ DataFrame containing one row per equivalence group and a
209
+ ``group_size`` column with the number of records belonging
210
+ to each group.
211
+ """
212
+ return (
213
+ df
214
+ .groupBy(*columns)
215
+ .agg(
216
+ F.count("*").alias("group_size")
217
+ )
218
+ )
219
+
220
+ def _build_summary(
221
+ self,
222
+ df: DataFrame,
223
+ equivalence_groups: DataFrame,
224
+ ):
225
+ """
226
+ Builds the global k-anonymity summary.
227
+
228
+ The total number of records is calculated from the sizes of the
229
+ already generated equivalence groups instead of executing an
230
+ additional ``df.count()`` action over the original dataset.
231
+
232
+ Parameters
233
+ ----------
234
+ df : DataFrame
235
+ Input Spark DataFrame. It is used to create the resulting
236
+ summary DataFrame.
237
+
238
+ equivalence_groups : DataFrame
239
+ DataFrame containing the equivalence groups and their sizes.
240
+
241
+ Returns
242
+ -------
243
+ tuple
244
+ A tuple containing:
245
+
246
+ - summary_df:
247
+ Spark DataFrame with the global k-anonymity statistics.
248
+
249
+ - total_groups:
250
+ Number of equivalence groups in the dataset.
251
+
252
+ - total_records:
253
+ Number of records in the dataset.
254
+ """
255
+ stats = (
256
+ equivalence_groups
257
+ .agg(
258
+ F.count("*").alias(
259
+ "total_groups"
260
+ ),
261
+
262
+ F.sum("group_size").alias(
263
+ "total_records"
264
+ ),
265
+
266
+ F.sum(
267
+ F.when(
268
+ F.col("group_size") < self.k,
269
+ 1,
270
+ ).otherwise(0)
271
+ ).alias(
272
+ "risky_groups"
273
+ ),
274
+
275
+ F.sum(
276
+ F.when(
277
+ F.col("group_size") < self.k,
278
+ F.col("group_size"),
279
+ ).otherwise(0)
280
+ ).alias(
281
+ "risky_records"
282
+ ),
283
+
284
+ F.min("group_size").alias(
285
+ "current_k"
286
+ ),
287
+ )
288
+ .first()
289
+ )
290
+
291
+ total_groups = stats["total_groups"] or 0
292
+ total_records = stats["total_records"] or 0
293
+ risky_groups = stats["risky_groups"] or 0
294
+ risky_records = stats["risky_records"] or 0
295
+ current_k = stats["current_k"]
296
+
297
+ risky_records_frequency = (
298
+ round(
299
+ risky_records / total_records,
300
+ 4,
301
+ )
302
+ if total_records > 0
303
+ else 0.0
304
+ )
305
+
306
+ summary_df = df.sparkSession.createDataFrame(
307
+ [(
308
+ self.k,
309
+ current_k,
310
+ total_records,
311
+ total_groups,
312
+ risky_groups,
313
+ risky_records,
314
+ risky_records_frequency,
315
+ )],
316
+ [
317
+ "target_k",
318
+ "current_k",
319
+ "total_records",
320
+ "total_equivalence_groups",
321
+ "risky_groups",
322
+ "risky_records",
323
+ "risky_records_frequency",
324
+ ],
325
+ )
326
+
327
+ return (
328
+ summary_df,
329
+ total_groups,
330
+ total_records,
331
+ )
332
+
333
+ def _build_suggestions(
334
+ self,
335
+ df: DataFrame,
336
+ current_groups: int,
337
+ total_records: int,
338
+ ) -> DataFrame:
339
+ """
340
+ Builds column-level anonymization suggestions.
341
+
342
+ Each quasi-identifier is evaluated using two complementary
343
+ indicators:
344
+
345
+ ``cardinality_frequency``
346
+ Ratio between the number of distinct values in the column
347
+ and the total number of records.
348
+
349
+ ``group_reduction_frequency``
350
+ Relative reduction in the number of equivalence groups that
351
+ would occur if the column were removed from the current
352
+ quasi-identifier set.
353
+
354
+ Combining both indicators allows the advisor to detect
355
+ high-cardinality attributes even when their marginal removal
356
+ produces little or no reduction because another identifying
357
+ attribute remains in the quasi-identifier set.
358
+
359
+ Parameters
360
+ ----------
361
+ df : DataFrame
362
+ Input Spark DataFrame.
363
+
364
+ current_groups : int
365
+ Number of equivalence groups generated using all configured
366
+ quasi-identifiers.
367
+
368
+ total_records : int
369
+ Total number of records in the dataset.
370
+
371
+ Returns
372
+ -------
373
+ DataFrame
374
+ Spark DataFrame containing cardinality information,
375
+ equivalence-group impact, risk level, and a suggested
376
+ anonymization action for each quasi-identifier.
377
+ """
378
+ qis = self.quasi_identifiers
379
+
380
+ # Calculate all column cardinalities in a single Spark action.
381
+ cardinalities = (
382
+ df
383
+ .agg(*[
384
+ F.countDistinct(
385
+ F.col(column)
386
+ ).alias(column)
387
+ for column in qis
388
+ ])
389
+ .first()
390
+ )
391
+
392
+ # Calculate the number of equivalence groups that would remain
393
+ # after removing each quasi-identifier.
394
+ #
395
+ # When there is only one quasi-identifier, removing it leaves
396
+ # all records in one equivalence group.
397
+ if len(qis) == 1:
398
+ groups_without = {
399
+ qis[0]: 1
400
+ }
401
+
402
+ else:
403
+ groups_without = (
404
+ df
405
+ .agg(*[
406
+ F.countDistinct(
407
+ F.struct(*[
408
+ F.col(other_column)
409
+ for other_column in qis
410
+ if other_column != column
411
+ ])
412
+ ).alias(column)
413
+ for column in qis
414
+ ])
415
+ .first()
416
+ )
417
+
418
+ rows = []
419
+
420
+ for column in qis:
421
+ cardinality = (
422
+ cardinalities[column]
423
+ or 0
424
+ )
425
+
426
+ groups_without_column = (
427
+ groups_without[column]
428
+ or 0
429
+ )
430
+
431
+ cardinality_frequency = (
432
+ cardinality / total_records
433
+ if total_records > 0
434
+ else 0.0
435
+ )
436
+
437
+ group_reduction = (
438
+ current_groups
439
+ - groups_without_column
440
+ )
441
+
442
+ group_reduction_frequency = (
443
+ group_reduction / current_groups
444
+ if current_groups > 0
445
+ else 0.0
446
+ )
447
+
448
+ rows.append((
449
+ column,
450
+ cardinality,
451
+ cardinality_frequency,
452
+ current_groups,
453
+ groups_without_column,
454
+ group_reduction,
455
+ group_reduction_frequency,
456
+ ))
457
+
458
+ result = df.sparkSession.createDataFrame(
459
+ rows,
460
+ [
461
+ "column",
462
+ "cardinality",
463
+ "cardinality_frequency",
464
+ "current_equivalence_groups",
465
+ "groups_without_column",
466
+ "group_reduction",
467
+ "group_reduction_frequency",
468
+ ],
469
+ )
470
+
471
+ high_risk_condition = (
472
+ (
473
+ F.col("cardinality_frequency")
474
+ >= self.HIGH_CARDINALITY_THRESHOLD
475
+ )
476
+ |
477
+ (
478
+ F.col("group_reduction_frequency")
479
+ >= self.HIGH_GROUP_REDUCTION_THRESHOLD
480
+ )
481
+ )
482
+
483
+ medium_risk_condition = (
484
+ (
485
+ F.col("cardinality_frequency")
486
+ >= self.MEDIUM_CARDINALITY_THRESHOLD
487
+ )
488
+ |
489
+ (
490
+ F.col("group_reduction_frequency")
491
+ >= self.MEDIUM_GROUP_REDUCTION_THRESHOLD
492
+ )
493
+ )
494
+
495
+ return (
496
+ result
497
+ .withColumn(
498
+ "risk_level",
499
+ F.when(
500
+ high_risk_condition,
501
+ F.lit("High"),
502
+ )
503
+ .when(
504
+ medium_risk_condition,
505
+ F.lit("Medium"),
506
+ )
507
+ .otherwise(
508
+ F.lit("Low")
509
+ ),
510
+ )
511
+ .withColumn(
512
+ "suggested_action",
513
+ F.when(
514
+ F.col("cardinality_frequency")
515
+ >= self.HIGH_CARDINALITY_THRESHOLD,
516
+ F.lit(
517
+ "Consider suppression or strong generalization"
518
+ ),
519
+ )
520
+ .when(
521
+ F.col("group_reduction_frequency")
522
+ >= self.HIGH_GROUP_REDUCTION_THRESHOLD,
523
+ F.lit(
524
+ "Consider generalization"
525
+ ),
526
+ )
527
+ .when(
528
+ (
529
+ F.col("cardinality_frequency")
530
+ >= self.MEDIUM_CARDINALITY_THRESHOLD
531
+ )
532
+ |
533
+ (
534
+ F.col("group_reduction_frequency")
535
+ >= self.MEDIUM_GROUP_REDUCTION_THRESHOLD
536
+ ),
537
+ F.lit(
538
+ "Consider moderate generalization"
539
+ ),
540
+ )
541
+ .otherwise(
542
+ F.lit(
543
+ "Low priority for anonymization"
544
+ )
545
+ ),
546
+ )
547
+ .orderBy(
548
+ F.when(F.col("risk_level") == "High", 3)
549
+ .when(F.col("risk_level") == "Medium", 2)
550
+ .otherwise(1)
551
+ .desc(),
552
+ F.desc("cardinality_frequency"),
553
+ F.desc("group_reduction_frequency"),
554
+ F.desc("cardinality"),
555
+ )
556
+ )
557
+
558
+
559
+ class AnonymizationAdvisorResult:
560
+ """
561
+ Stores the results generated by AnonymizationAdvisor.
562
+
563
+ Attributes
564
+ ----------
565
+ summary_df : DataFrame
566
+ Global statistics describing the current equivalence groups
567
+ and the current k-anonymity level.
568
+
569
+ suggestions_df : DataFrame
570
+ Column-level anonymization recommendations based on attribute
571
+ cardinality and equivalence-group impact.
572
+ """
573
+
574
+ def __init__(
575
+ self,
576
+ summary_df: DataFrame,
577
+ suggestions_df: DataFrame,
578
+ ):
579
+ self.summary_df = summary_df
580
+ self.suggestions_df = suggestions_df
581
+
582
+ def get_summary_df(self) -> DataFrame:
583
+ """
584
+ Returns the summary DataFrame.
585
+
586
+ Returns
587
+ -------
588
+ DataFrame
589
+ Spark DataFrame containing the global k-anonymity summary.
590
+ """
591
+ return self.summary_df
592
+
593
+ def get_suggestions_df(self) -> DataFrame:
594
+ """
595
+ Returns the suggestions DataFrame.
596
+
597
+ Returns
598
+ -------
599
+ DataFrame
600
+ Spark DataFrame containing the column-level anonymization
601
+ recommendations.
602
+ """
603
+ return self.suggestions_df
604
+
605
+ def show_summary(
606
+ self,
607
+ truncate=False,
608
+ ):
609
+ """
610
+ Displays a global privacy assessment of the dataset.
611
+
612
+ The summary includes:
613
+
614
+ - target_k:
615
+ Desired k-anonymity level defined by the user.
616
+
617
+ - current_k:
618
+ Current k-anonymity level of the dataset, calculated as the
619
+ size of the smallest equivalence group.
620
+
621
+ - total_records:
622
+ Total number of records in the dataset.
623
+
624
+ - total_equivalence_groups:
625
+ Number of distinct equivalence groups generated from the
626
+ selected quasi-identifiers.
627
+
628
+ - risky_groups:
629
+ Number of equivalence groups whose size is smaller than
630
+ the target k value.
631
+
632
+ - risky_records:
633
+ Total number of records belonging to risky equivalence
634
+ groups.
635
+
636
+ - risky_records_frequency:
637
+ Proportion of records affected by risky groups with respect
638
+ to the total dataset size.
639
+
640
+ Interpretation
641
+ --------------
642
+ A dataset satisfies k-anonymity when:
643
+
644
+ current_k >= target_k
645
+
646
+ and therefore:
647
+
648
+ risky_groups = 0
649
+ risky_records = 0
650
+ """
651
+ self.summary_df.show(
652
+ truncate=truncate
653
+ )
654
+
655
+ def show_suggestions(
656
+ self,
657
+ truncate=False,
658
+ ):
659
+ """
660
+ Displays anonymization recommendations for each quasi-identifier.
661
+
662
+ The analysis combines the individual cardinality of each
663
+ quasi-identifier with the impact of removing that attribute from
664
+ the current equivalence-group definition.
665
+
666
+ The output includes:
667
+
668
+ - column:
669
+ Evaluated quasi-identifier.
670
+
671
+ - cardinality:
672
+ Number of distinct non-null values in the column.
673
+
674
+ - cardinality_frequency:
675
+ Ratio between the number of distinct values and the total
676
+ number of records.
677
+
678
+ Values close to 1 indicate that the attribute has a large
679
+ number of distinct values relative to the dataset size and
680
+ may therefore have strong identifying power.
681
+
682
+ - current_equivalence_groups:
683
+ Number of equivalence groups generated using all selected
684
+ quasi-identifiers.
685
+
686
+ - groups_without_column:
687
+ Number of equivalence groups that would remain if the column
688
+ were excluded from the quasi-identifier set.
689
+
690
+ - group_reduction:
691
+ Absolute reduction in equivalence groups obtained by removing
692
+ the column.
693
+
694
+ - group_reduction_frequency:
695
+ Relative reduction in equivalence groups with respect to the
696
+ original number of groups.
697
+
698
+ - risk_level:
699
+ Heuristic classification of the quasi-identifier as High,
700
+ Medium, or Low risk.
701
+
702
+ The classification considers both cardinality frequency and
703
+ equivalence-group reduction.
704
+
705
+ - suggested_action:
706
+ Suggested anonymization strategy based on the observed
707
+ characteristics of the quasi-identifier.
708
+
709
+ Interpretation
710
+ --------------
711
+ ``cardinality_frequency`` and ``group_reduction_frequency`` measure
712
+ different properties.
713
+
714
+ A high cardinality frequency indicates that the attribute has
715
+ substantial individual identifying power.
716
+
717
+ A high group reduction frequency indicates that removing the
718
+ attribute significantly reduces the fragmentation of the dataset.
719
+
720
+ These measures should be interpreted together. For example, two
721
+ unique identifiers may both have a cardinality frequency close to
722
+ 1 while individually producing almost no group reduction because
723
+ the other unique identifier continues to separate every record.
724
+
725
+ The risk levels and suggested actions are heuristic guidance.
726
+ They do not themselves constitute a formal privacy guarantee.
727
+ """
728
+ self.suggestions_df.show(
729
+ truncate=truncate
730
+ )