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.
- anonymization_lib/__init__.py +7 -0
- anonymization_lib/io/__init__.py +4 -0
- anonymization_lib/io/data_import.py +90 -0
- anonymization_lib/io/export.py +87 -0
- anonymization_lib/metrics/__init__.py +5 -0
- anonymization_lib/metrics/k_anonymity.py +391 -0
- anonymization_lib/metrics/l_diversity.py +393 -0
- anonymization_lib/metrics/t_closeness.py +618 -0
- anonymization_lib/techniques/__init__.py +6 -0
- anonymization_lib/techniques/generalization.py +907 -0
- anonymization_lib/techniques/pipeline.py +134 -0
- anonymization_lib/techniques/substitution.py +343 -0
- anonymization_lib/techniques/suppression.py +255 -0
- anonymization_lib/utils.py +153 -0
- anonymization_lib/visualization/Suggestion.py +730 -0
- anonymization_lib/visualization/__init__.py +4 -0
- anonymization_lib/visualization/visualization.py +845 -0
- anonymization_lib-1.4.0.dist-info/METADATA +302 -0
- anonymization_lib-1.4.0.dist-info/RECORD +22 -0
- anonymization_lib-1.4.0.dist-info/WHEEL +5 -0
- anonymization_lib-1.4.0.dist-info/licenses/LICENSE +21 -0
- anonymization_lib-1.4.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,907 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import warnings
|
|
4
|
+
|
|
5
|
+
from pyspark.sql import DataFrame
|
|
6
|
+
from pyspark.sql import functions as F
|
|
7
|
+
|
|
8
|
+
from ..utils import infer_semantic_type
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Generalization:
|
|
12
|
+
"""
|
|
13
|
+
Applies generalization to a column in a Spark DataFrame.
|
|
14
|
+
|
|
15
|
+
The transformation supports:
|
|
16
|
+
|
|
17
|
+
- Categorical generalization using mapping rules.
|
|
18
|
+
- Numerical generalization using interval rules.
|
|
19
|
+
- Temporal generalization using year, month, quarter or semester.
|
|
20
|
+
|
|
21
|
+
Generalization rules can be loaded from JSON files stored in locations
|
|
22
|
+
accessible either by the local Python process or by Spark. This allows
|
|
23
|
+
the transformation to work with local files as well as distributed or
|
|
24
|
+
remote storage systems supported by the active Spark environment.
|
|
25
|
+
|
|
26
|
+
Examples of supported locations may include:
|
|
27
|
+
|
|
28
|
+
- Local filesystem paths.
|
|
29
|
+
- Databricks Workspace files.
|
|
30
|
+
- Databricks Volumes.
|
|
31
|
+
- DBFS paths.
|
|
32
|
+
- Cloud object storage such as S3 or ABFS, when configured in Spark.
|
|
33
|
+
|
|
34
|
+
Parameters
|
|
35
|
+
----------
|
|
36
|
+
column : str
|
|
37
|
+
Name of the column to be generalized.
|
|
38
|
+
|
|
39
|
+
rules_path : str, optional
|
|
40
|
+
Path to a JSON file containing the generalization configuration
|
|
41
|
+
and rules.
|
|
42
|
+
|
|
43
|
+
The path may refer to a local file or to a location readable by
|
|
44
|
+
Spark.
|
|
45
|
+
|
|
46
|
+
Expected JSON structure for categorical generalization::
|
|
47
|
+
|
|
48
|
+
{
|
|
49
|
+
"type": "categorical",
|
|
50
|
+
"rules": [
|
|
51
|
+
{"from": ["A", "B"], "to": "Group 1"},
|
|
52
|
+
{"from": "C", "to": "Group 2"}
|
|
53
|
+
]
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
Expected JSON structure for numerical generalization::
|
|
57
|
+
|
|
58
|
+
{
|
|
59
|
+
"type": "numeric",
|
|
60
|
+
"rules": [
|
|
61
|
+
{"min": 0, "max": 18, "value": "0-18"},
|
|
62
|
+
{"min": 19, "max": 35, "value": "19-35"}
|
|
63
|
+
]
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
For temporal generalization, the JSON configuration may contain::
|
|
67
|
+
|
|
68
|
+
{
|
|
69
|
+
"type": "date"
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
A rules file is required for categorical and numerical
|
|
73
|
+
generalization. Temporal generalization can also be inferred
|
|
74
|
+
directly from the column semantic type when no rules file is
|
|
75
|
+
provided.
|
|
76
|
+
|
|
77
|
+
mode : str, optional
|
|
78
|
+
Temporal generalization mode.
|
|
79
|
+
|
|
80
|
+
Supported values are:
|
|
81
|
+
|
|
82
|
+
- ``"year"``
|
|
83
|
+
- ``"month"``
|
|
84
|
+
- ``"quarter"``
|
|
85
|
+
- ``"semester"``
|
|
86
|
+
|
|
87
|
+
This parameter is required when temporal generalization is applied.
|
|
88
|
+
|
|
89
|
+
default_value : str, optional
|
|
90
|
+
Value assigned when no categorical rule matches or when a numeric
|
|
91
|
+
value falls outside all configured intervals.
|
|
92
|
+
|
|
93
|
+
If not provided, the original value is preserved.
|
|
94
|
+
|
|
95
|
+
include_year : bool, default=True
|
|
96
|
+
Controls whether temporal generalization preserves the year.
|
|
97
|
+
|
|
98
|
+
Examples with ``include_year=True``:
|
|
99
|
+
|
|
100
|
+
- month: ``2024-05``
|
|
101
|
+
- quarter: ``2024-Q2``
|
|
102
|
+
- semester: ``2024-S1``
|
|
103
|
+
|
|
104
|
+
Examples with ``include_year=False``:
|
|
105
|
+
|
|
106
|
+
- month: ``05``
|
|
107
|
+
- quarter: ``Q2``
|
|
108
|
+
- semester: ``S1``
|
|
109
|
+
|
|
110
|
+
The option has no effect when ``mode="year"``.
|
|
111
|
+
|
|
112
|
+
output_column : str, optional
|
|
113
|
+
Name assigned to the generalized column.
|
|
114
|
+
|
|
115
|
+
If not provided, the original column is overwritten.
|
|
116
|
+
|
|
117
|
+
If provided and different from ``column``, the original column is
|
|
118
|
+
generalized and then renamed to ``output_column``. Therefore, the
|
|
119
|
+
original column is not preserved in the resulting DataFrame.
|
|
120
|
+
|
|
121
|
+
Notes
|
|
122
|
+
-----
|
|
123
|
+
The transformation itself is performed using Spark expressions and is
|
|
124
|
+
therefore distributed.
|
|
125
|
+
|
|
126
|
+
Generalization rules are small configuration objects and are loaded on
|
|
127
|
+
the driver. Dataset records are not collected to the driver.
|
|
128
|
+
|
|
129
|
+
Spark-compatible paths are read through Spark, while ordinary local
|
|
130
|
+
paths can be read directly through Python.
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
VALID_TEMPORAL_MODES = {
|
|
134
|
+
"year",
|
|
135
|
+
"month",
|
|
136
|
+
"quarter",
|
|
137
|
+
"semester"
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
VALID_RULE_TYPES = {
|
|
141
|
+
"categorical",
|
|
142
|
+
"numeric",
|
|
143
|
+
"date"
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
def __init__(
|
|
147
|
+
self,
|
|
148
|
+
column: str,
|
|
149
|
+
rules_path: str = None,
|
|
150
|
+
mode: str = None,
|
|
151
|
+
default_value: str = None,
|
|
152
|
+
include_year: bool = True,
|
|
153
|
+
output_column: str = None
|
|
154
|
+
):
|
|
155
|
+
"""
|
|
156
|
+
Initializes the generalization transformation.
|
|
157
|
+
|
|
158
|
+
Parameters
|
|
159
|
+
----------
|
|
160
|
+
column : str
|
|
161
|
+
Name of the column to be generalized.
|
|
162
|
+
|
|
163
|
+
rules_path : str, optional
|
|
164
|
+
Path to a JSON configuration containing the generalization
|
|
165
|
+
rules. The path may refer to a local file or to a location
|
|
166
|
+
readable by Spark.
|
|
167
|
+
|
|
168
|
+
mode : str, optional
|
|
169
|
+
Temporal generalization mode. Supported values are ``year``,
|
|
170
|
+
``month``, ``quarter`` and ``semester``.
|
|
171
|
+
|
|
172
|
+
default_value : str, optional
|
|
173
|
+
Value assigned when no categorical or numerical rule applies.
|
|
174
|
+
If omitted, the original value is preserved.
|
|
175
|
+
|
|
176
|
+
include_year : bool, default=True
|
|
177
|
+
Whether temporal generalization should preserve the year for
|
|
178
|
+
month, quarter and semester modes.
|
|
179
|
+
|
|
180
|
+
output_column : str, optional
|
|
181
|
+
Name of the generalized output column. If omitted, the
|
|
182
|
+
original column is overwritten.
|
|
183
|
+
|
|
184
|
+
Raises
|
|
185
|
+
------
|
|
186
|
+
ValueError
|
|
187
|
+
If any configuration parameter has an invalid value or type.
|
|
188
|
+
"""
|
|
189
|
+
if not isinstance(column, str) or not column.strip():
|
|
190
|
+
raise ValueError(
|
|
191
|
+
"'column' must be a non-empty string."
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
if rules_path is not None:
|
|
195
|
+
if not isinstance(rules_path, str) or not rules_path.strip():
|
|
196
|
+
raise ValueError(
|
|
197
|
+
"'rules_path' must be a non-empty string."
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
if mode is not None:
|
|
201
|
+
if not isinstance(mode, str):
|
|
202
|
+
raise ValueError(
|
|
203
|
+
"'mode' must be a string."
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
if mode not in self.VALID_TEMPORAL_MODES:
|
|
207
|
+
raise ValueError(
|
|
208
|
+
f"Unsupported mode '{mode}'. "
|
|
209
|
+
f"Use {sorted(self.VALID_TEMPORAL_MODES)}."
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
if not isinstance(include_year, bool):
|
|
213
|
+
raise ValueError(
|
|
214
|
+
"'include_year' must be a boolean."
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
if output_column is not None:
|
|
218
|
+
if (
|
|
219
|
+
not isinstance(output_column, str)
|
|
220
|
+
or not output_column.strip()
|
|
221
|
+
):
|
|
222
|
+
raise ValueError(
|
|
223
|
+
"'output_column' must be a non-empty string."
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
self.column = column
|
|
227
|
+
self.rules_path = rules_path
|
|
228
|
+
self.mode = mode
|
|
229
|
+
self.default_value = default_value
|
|
230
|
+
self.include_year = include_year
|
|
231
|
+
self.output_column = output_column or column
|
|
232
|
+
|
|
233
|
+
def _rename_output_column(
|
|
234
|
+
self,
|
|
235
|
+
df: DataFrame
|
|
236
|
+
) -> DataFrame:
|
|
237
|
+
"""
|
|
238
|
+
Renames the generalized column when an output column is configured.
|
|
239
|
+
|
|
240
|
+
Parameters
|
|
241
|
+
----------
|
|
242
|
+
df : pyspark.sql.DataFrame
|
|
243
|
+
DataFrame containing the generalized column.
|
|
244
|
+
|
|
245
|
+
Returns
|
|
246
|
+
-------
|
|
247
|
+
pyspark.sql.DataFrame
|
|
248
|
+
DataFrame with the generalized column renamed when
|
|
249
|
+
``output_column`` differs from the original column name.
|
|
250
|
+
"""
|
|
251
|
+
if self.output_column != self.column:
|
|
252
|
+
return df.withColumnRenamed(
|
|
253
|
+
self.column,
|
|
254
|
+
self.output_column
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
return df
|
|
258
|
+
|
|
259
|
+
def _load_rules_config(
|
|
260
|
+
self,
|
|
261
|
+
df: DataFrame
|
|
262
|
+
) -> dict:
|
|
263
|
+
"""
|
|
264
|
+
Loads the generalization configuration from a JSON file.
|
|
265
|
+
|
|
266
|
+
The method supports both local files and paths accessible through
|
|
267
|
+
Spark.
|
|
268
|
+
|
|
269
|
+
Local files are read directly using Python. Other paths are read
|
|
270
|
+
through Spark's text reader, allowing the active Spark environment
|
|
271
|
+
to resolve distributed filesystems and cloud storage locations.
|
|
272
|
+
|
|
273
|
+
Parameters
|
|
274
|
+
----------
|
|
275
|
+
df : pyspark.sql.DataFrame
|
|
276
|
+
Spark DataFrame used to access the active Spark session.
|
|
277
|
+
|
|
278
|
+
Returns
|
|
279
|
+
-------
|
|
280
|
+
dict
|
|
281
|
+
Parsed JSON configuration.
|
|
282
|
+
|
|
283
|
+
Raises
|
|
284
|
+
------
|
|
285
|
+
ValueError
|
|
286
|
+
If no rules path is configured, the file cannot be read, the
|
|
287
|
+
file is empty, the JSON is invalid, or the root JSON value is
|
|
288
|
+
not an object.
|
|
289
|
+
|
|
290
|
+
Notes
|
|
291
|
+
-----
|
|
292
|
+
Only the rules configuration is transferred to the driver. These
|
|
293
|
+
files are expected to be small configuration files rather than
|
|
294
|
+
large datasets.
|
|
295
|
+
"""
|
|
296
|
+
if self.rules_path is None:
|
|
297
|
+
raise ValueError(
|
|
298
|
+
"'rules_path' must be provided."
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
content = None
|
|
302
|
+
|
|
303
|
+
# ------------------------------------------------------
|
|
304
|
+
# LOCAL FILE
|
|
305
|
+
# ------------------------------------------------------
|
|
306
|
+
|
|
307
|
+
if os.path.isfile(self.rules_path):
|
|
308
|
+
try:
|
|
309
|
+
with open(
|
|
310
|
+
self.rules_path,
|
|
311
|
+
"r",
|
|
312
|
+
encoding="utf-8"
|
|
313
|
+
) as file:
|
|
314
|
+
content = file.read()
|
|
315
|
+
|
|
316
|
+
except OSError as exc:
|
|
317
|
+
raise ValueError(
|
|
318
|
+
f"Unable to read rules file "
|
|
319
|
+
f"'{self.rules_path}'."
|
|
320
|
+
) from exc
|
|
321
|
+
|
|
322
|
+
# ------------------------------------------------------
|
|
323
|
+
# SPARK-COMPATIBLE PATH
|
|
324
|
+
# ------------------------------------------------------
|
|
325
|
+
|
|
326
|
+
else:
|
|
327
|
+
try:
|
|
328
|
+
rows = (
|
|
329
|
+
df.sparkSession
|
|
330
|
+
.read
|
|
331
|
+
.text(self.rules_path)
|
|
332
|
+
.select("value")
|
|
333
|
+
.collect()
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
if rows:
|
|
337
|
+
content = "\n".join(
|
|
338
|
+
row["value"]
|
|
339
|
+
for row in rows
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
except Exception as exc:
|
|
343
|
+
raise ValueError(
|
|
344
|
+
f"Unable to read rules file "
|
|
345
|
+
f"'{self.rules_path}' using either the local "
|
|
346
|
+
f"filesystem or Spark."
|
|
347
|
+
) from exc
|
|
348
|
+
|
|
349
|
+
if not content or not content.strip():
|
|
350
|
+
raise ValueError(
|
|
351
|
+
f"Rules file '{self.rules_path}' is empty."
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
try:
|
|
355
|
+
config = json.loads(content)
|
|
356
|
+
|
|
357
|
+
except json.JSONDecodeError as exc:
|
|
358
|
+
raise ValueError(
|
|
359
|
+
f"Invalid JSON in rules file "
|
|
360
|
+
f"'{self.rules_path}'."
|
|
361
|
+
) from exc
|
|
362
|
+
|
|
363
|
+
if not isinstance(config, dict):
|
|
364
|
+
raise ValueError(
|
|
365
|
+
"The rules JSON root must be an object."
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
return config
|
|
369
|
+
|
|
370
|
+
def _get_rules_type(
|
|
371
|
+
self,
|
|
372
|
+
config: dict
|
|
373
|
+
) -> str:
|
|
374
|
+
"""
|
|
375
|
+
Returns and validates the generalization type defined in a rules
|
|
376
|
+
configuration.
|
|
377
|
+
|
|
378
|
+
Parameters
|
|
379
|
+
----------
|
|
380
|
+
config : dict
|
|
381
|
+
Parsed generalization configuration.
|
|
382
|
+
|
|
383
|
+
Returns
|
|
384
|
+
-------
|
|
385
|
+
str
|
|
386
|
+
Generalization type defined by the ``type`` field.
|
|
387
|
+
|
|
388
|
+
Raises
|
|
389
|
+
------
|
|
390
|
+
ValueError
|
|
391
|
+
If the configuration does not contain a supported
|
|
392
|
+
generalization type.
|
|
393
|
+
"""
|
|
394
|
+
rules_type = config.get("type")
|
|
395
|
+
|
|
396
|
+
if rules_type not in self.VALID_RULE_TYPES:
|
|
397
|
+
raise ValueError(
|
|
398
|
+
f"Unsupported generalization type in JSON: "
|
|
399
|
+
f"{rules_type}. "
|
|
400
|
+
f"Use {sorted(self.VALID_RULE_TYPES)}."
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
return rules_type
|
|
404
|
+
|
|
405
|
+
def _apply_temporal_with_mode(
|
|
406
|
+
self,
|
|
407
|
+
df: DataFrame
|
|
408
|
+
) -> DataFrame:
|
|
409
|
+
"""
|
|
410
|
+
Applies temporal generalization after validating that a temporal
|
|
411
|
+
mode has been configured.
|
|
412
|
+
|
|
413
|
+
Parameters
|
|
414
|
+
----------
|
|
415
|
+
df : pyspark.sql.DataFrame
|
|
416
|
+
Input dataset.
|
|
417
|
+
|
|
418
|
+
Returns
|
|
419
|
+
-------
|
|
420
|
+
pyspark.sql.DataFrame
|
|
421
|
+
DataFrame with the temporal column generalized.
|
|
422
|
+
|
|
423
|
+
Raises
|
|
424
|
+
------
|
|
425
|
+
ValueError
|
|
426
|
+
If no temporal generalization mode has been provided.
|
|
427
|
+
"""
|
|
428
|
+
if self.mode is None:
|
|
429
|
+
raise ValueError(
|
|
430
|
+
"'mode' must be provided for temporal generalization."
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
return self._apply_temporal_generalization(df)
|
|
434
|
+
|
|
435
|
+
def _apply_categorical(
|
|
436
|
+
self,
|
|
437
|
+
df: DataFrame,
|
|
438
|
+
config: dict
|
|
439
|
+
) -> DataFrame:
|
|
440
|
+
"""
|
|
441
|
+
Applies categorical generalization using mapping rules.
|
|
442
|
+
|
|
443
|
+
Each categorical rule maps one or more original values to a
|
|
444
|
+
generalized value.
|
|
445
|
+
|
|
446
|
+
A rule can contain either a single value or a list of values in
|
|
447
|
+
the ``from`` field.
|
|
448
|
+
|
|
449
|
+
Example
|
|
450
|
+
-------
|
|
451
|
+
A rule such as::
|
|
452
|
+
|
|
453
|
+
{"from": ["Single", "Divorced"], "to": "Not married"}
|
|
454
|
+
|
|
455
|
+
maps both original values to ``"Not married"``.
|
|
456
|
+
|
|
457
|
+
Parameters
|
|
458
|
+
----------
|
|
459
|
+
df : pyspark.sql.DataFrame
|
|
460
|
+
Input dataset.
|
|
461
|
+
|
|
462
|
+
config : dict
|
|
463
|
+
Parsed JSON configuration containing categorical rules.
|
|
464
|
+
|
|
465
|
+
Returns
|
|
466
|
+
-------
|
|
467
|
+
pyspark.sql.DataFrame
|
|
468
|
+
DataFrame with the selected column generalized.
|
|
469
|
+
|
|
470
|
+
Raises
|
|
471
|
+
------
|
|
472
|
+
ValueError
|
|
473
|
+
If no rules or no valid categorical rules are found.
|
|
474
|
+
|
|
475
|
+
Notes
|
|
476
|
+
-----
|
|
477
|
+
The mapping is converted into a Spark map expression. The
|
|
478
|
+
transformation is therefore evaluated by Spark workers without
|
|
479
|
+
requiring dataset records to be collected to the driver.
|
|
480
|
+
"""
|
|
481
|
+
mapping = {}
|
|
482
|
+
|
|
483
|
+
rules = config.get("rules", [])
|
|
484
|
+
|
|
485
|
+
if not rules:
|
|
486
|
+
raise ValueError(
|
|
487
|
+
"No rules found in JSON configuration."
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
for rule in rules:
|
|
491
|
+
if not isinstance(rule, dict):
|
|
492
|
+
warnings.warn(
|
|
493
|
+
f"Invalid categorical rule ignored: {rule}"
|
|
494
|
+
)
|
|
495
|
+
continue
|
|
496
|
+
|
|
497
|
+
try:
|
|
498
|
+
original = rule["from"]
|
|
499
|
+
general = str(rule["to"])
|
|
500
|
+
|
|
501
|
+
except KeyError as exc:
|
|
502
|
+
warnings.warn(
|
|
503
|
+
f"Invalid rule ignored. "
|
|
504
|
+
f"Missing key {exc}: {rule}"
|
|
505
|
+
)
|
|
506
|
+
continue
|
|
507
|
+
|
|
508
|
+
except (TypeError, ValueError):
|
|
509
|
+
warnings.warn(
|
|
510
|
+
f"Invalid categorical rule ignored: {rule}"
|
|
511
|
+
)
|
|
512
|
+
continue
|
|
513
|
+
|
|
514
|
+
# Allow a single value or a list of values in "from"
|
|
515
|
+
originals = original if isinstance(original, list) else [original]
|
|
516
|
+
|
|
517
|
+
for value in originals:
|
|
518
|
+
mapping[str(value)] = general
|
|
519
|
+
|
|
520
|
+
if not mapping:
|
|
521
|
+
raise ValueError(
|
|
522
|
+
"No valid categorical rules found."
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
mapping_expr = F.create_map(
|
|
526
|
+
*[
|
|
527
|
+
item
|
|
528
|
+
for key, value in mapping.items()
|
|
529
|
+
for item in (
|
|
530
|
+
F.lit(key),
|
|
531
|
+
F.lit(value)
|
|
532
|
+
)
|
|
533
|
+
]
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
source_col = (
|
|
537
|
+
F.col(self.column)
|
|
538
|
+
.cast("string")
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
new_col = mapping_expr[source_col]
|
|
542
|
+
|
|
543
|
+
if self.default_value is not None:
|
|
544
|
+
new_col = F.coalesce(
|
|
545
|
+
new_col,
|
|
546
|
+
F.lit(str(self.default_value))
|
|
547
|
+
)
|
|
548
|
+
else:
|
|
549
|
+
new_col = F.coalesce(
|
|
550
|
+
new_col,
|
|
551
|
+
source_col
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
result = df.withColumn(
|
|
555
|
+
self.column,
|
|
556
|
+
new_col.cast("string")
|
|
557
|
+
)
|
|
558
|
+
|
|
559
|
+
return self._rename_output_column(result)
|
|
560
|
+
|
|
561
|
+
def _apply_numeric(
|
|
562
|
+
self,
|
|
563
|
+
df: DataFrame,
|
|
564
|
+
config: dict
|
|
565
|
+
) -> DataFrame:
|
|
566
|
+
"""
|
|
567
|
+
Applies numerical generalization using interval rules.
|
|
568
|
+
|
|
569
|
+
Each rule defines a closed numerical interval using ``min`` and
|
|
570
|
+
``max`` together with the generalized label assigned to values
|
|
571
|
+
inside that interval.
|
|
572
|
+
|
|
573
|
+
Example
|
|
574
|
+
-------
|
|
575
|
+
A rule such as::
|
|
576
|
+
|
|
577
|
+
{"min": 18, "max": 30, "value": "18-30"}
|
|
578
|
+
|
|
579
|
+
assigns the value ``"18-30"`` to numeric values satisfying:
|
|
580
|
+
|
|
581
|
+
18 <= value <= 30
|
|
582
|
+
|
|
583
|
+
Parameters
|
|
584
|
+
----------
|
|
585
|
+
df : pyspark.sql.DataFrame
|
|
586
|
+
Input dataset.
|
|
587
|
+
|
|
588
|
+
config : dict
|
|
589
|
+
Parsed JSON configuration containing numerical interval rules.
|
|
590
|
+
|
|
591
|
+
Returns
|
|
592
|
+
-------
|
|
593
|
+
pyspark.sql.DataFrame
|
|
594
|
+
DataFrame with the selected column generalized.
|
|
595
|
+
|
|
596
|
+
Raises
|
|
597
|
+
------
|
|
598
|
+
ValueError
|
|
599
|
+
If no rules or no valid numerical rules are found.
|
|
600
|
+
|
|
601
|
+
Notes
|
|
602
|
+
-----
|
|
603
|
+
Interval rules are converted into Spark ``when`` expressions and
|
|
604
|
+
are evaluated in a distributed manner.
|
|
605
|
+
|
|
606
|
+
Intervals are evaluated in the order in which they appear in the
|
|
607
|
+
configuration. If intervals overlap, the first matching rule takes
|
|
608
|
+
precedence.
|
|
609
|
+
"""
|
|
610
|
+
rules = config.get("rules", [])
|
|
611
|
+
|
|
612
|
+
if not rules:
|
|
613
|
+
raise ValueError(
|
|
614
|
+
"No rules found in JSON configuration."
|
|
615
|
+
)
|
|
616
|
+
|
|
617
|
+
expr = None
|
|
618
|
+
|
|
619
|
+
numeric_col = (
|
|
620
|
+
F.col(self.column)
|
|
621
|
+
.cast("double")
|
|
622
|
+
)
|
|
623
|
+
|
|
624
|
+
for rule in rules:
|
|
625
|
+
if not isinstance(rule, dict):
|
|
626
|
+
warnings.warn(
|
|
627
|
+
f"Invalid numeric rule ignored: {rule}"
|
|
628
|
+
)
|
|
629
|
+
continue
|
|
630
|
+
|
|
631
|
+
try:
|
|
632
|
+
start = float(rule["min"])
|
|
633
|
+
end = float(rule["max"])
|
|
634
|
+
label = str(rule["value"])
|
|
635
|
+
|
|
636
|
+
except KeyError as exc:
|
|
637
|
+
warnings.warn(
|
|
638
|
+
f"Invalid rule ignored. "
|
|
639
|
+
f"Missing key {exc}: {rule}"
|
|
640
|
+
)
|
|
641
|
+
continue
|
|
642
|
+
|
|
643
|
+
except (TypeError, ValueError):
|
|
644
|
+
warnings.warn(
|
|
645
|
+
f"Invalid numeric values in rule: {rule}"
|
|
646
|
+
)
|
|
647
|
+
continue
|
|
648
|
+
|
|
649
|
+
if start > end:
|
|
650
|
+
warnings.warn(
|
|
651
|
+
f"Invalid interval ignored: {rule}"
|
|
652
|
+
)
|
|
653
|
+
continue
|
|
654
|
+
|
|
655
|
+
condition = (
|
|
656
|
+
(numeric_col >= F.lit(start))
|
|
657
|
+
& (numeric_col <= F.lit(end))
|
|
658
|
+
)
|
|
659
|
+
|
|
660
|
+
if expr is None:
|
|
661
|
+
expr = F.when(
|
|
662
|
+
condition,
|
|
663
|
+
F.lit(label)
|
|
664
|
+
)
|
|
665
|
+
else:
|
|
666
|
+
expr = expr.when(
|
|
667
|
+
condition,
|
|
668
|
+
F.lit(label)
|
|
669
|
+
)
|
|
670
|
+
|
|
671
|
+
if expr is None:
|
|
672
|
+
raise ValueError(
|
|
673
|
+
"No valid numerical rules found."
|
|
674
|
+
)
|
|
675
|
+
|
|
676
|
+
if self.default_value is not None:
|
|
677
|
+
expr = expr.otherwise(
|
|
678
|
+
F.lit(str(self.default_value))
|
|
679
|
+
)
|
|
680
|
+
else:
|
|
681
|
+
expr = expr.otherwise(
|
|
682
|
+
F.col(self.column).cast("string")
|
|
683
|
+
)
|
|
684
|
+
|
|
685
|
+
result = df.withColumn(
|
|
686
|
+
self.column,
|
|
687
|
+
expr.cast("string")
|
|
688
|
+
)
|
|
689
|
+
|
|
690
|
+
return self._rename_output_column(result)
|
|
691
|
+
|
|
692
|
+
def _apply_temporal_generalization(
|
|
693
|
+
self,
|
|
694
|
+
df: DataFrame
|
|
695
|
+
) -> DataFrame:
|
|
696
|
+
"""
|
|
697
|
+
Applies temporal generalization based on the selected aggregation
|
|
698
|
+
level.
|
|
699
|
+
|
|
700
|
+
Supported modes are year, month, quarter and semester.
|
|
701
|
+
|
|
702
|
+
By default, ``include_year`` is True, meaning that generalized
|
|
703
|
+
values preserve the year whenever applicable.
|
|
704
|
+
|
|
705
|
+
Examples
|
|
706
|
+
--------
|
|
707
|
+
With ``include_year=True``:
|
|
708
|
+
|
|
709
|
+
- year: ``2024``
|
|
710
|
+
- month: ``2024-05``
|
|
711
|
+
- quarter: ``2024-Q2``
|
|
712
|
+
- semester: ``2024-S1``
|
|
713
|
+
|
|
714
|
+
With ``include_year=False``:
|
|
715
|
+
|
|
716
|
+
- year: ``2024``
|
|
717
|
+
- month: ``05``
|
|
718
|
+
- quarter: ``Q2``
|
|
719
|
+
- semester: ``S1``
|
|
720
|
+
|
|
721
|
+
Parameters
|
|
722
|
+
----------
|
|
723
|
+
df : pyspark.sql.DataFrame
|
|
724
|
+
Input dataset.
|
|
725
|
+
|
|
726
|
+
Returns
|
|
727
|
+
-------
|
|
728
|
+
pyspark.sql.DataFrame
|
|
729
|
+
DataFrame with the temporal column generalized.
|
|
730
|
+
|
|
731
|
+
Raises
|
|
732
|
+
------
|
|
733
|
+
ValueError
|
|
734
|
+
If the configured temporal mode is unsupported.
|
|
735
|
+
"""
|
|
736
|
+
col = F.col(self.column)
|
|
737
|
+
|
|
738
|
+
if self.mode == "year":
|
|
739
|
+
new_col = (
|
|
740
|
+
F.year(col)
|
|
741
|
+
.cast("string")
|
|
742
|
+
)
|
|
743
|
+
|
|
744
|
+
elif self.mode == "month":
|
|
745
|
+
if self.include_year:
|
|
746
|
+
new_col = F.date_format(
|
|
747
|
+
col,
|
|
748
|
+
"yyyy-MM"
|
|
749
|
+
)
|
|
750
|
+
else:
|
|
751
|
+
new_col = F.date_format(
|
|
752
|
+
col,
|
|
753
|
+
"MM"
|
|
754
|
+
)
|
|
755
|
+
|
|
756
|
+
elif self.mode == "quarter":
|
|
757
|
+
quarter = F.concat(
|
|
758
|
+
F.lit("Q"),
|
|
759
|
+
F.quarter(col)
|
|
760
|
+
)
|
|
761
|
+
|
|
762
|
+
if self.include_year:
|
|
763
|
+
new_col = F.concat(
|
|
764
|
+
F.year(col),
|
|
765
|
+
F.lit("-"),
|
|
766
|
+
quarter
|
|
767
|
+
)
|
|
768
|
+
else:
|
|
769
|
+
new_col = quarter
|
|
770
|
+
|
|
771
|
+
elif self.mode == "semester":
|
|
772
|
+
semester = F.concat(
|
|
773
|
+
F.lit("S"),
|
|
774
|
+
F.when(
|
|
775
|
+
F.month(col) <= 6,
|
|
776
|
+
1
|
|
777
|
+
).otherwise(2)
|
|
778
|
+
)
|
|
779
|
+
|
|
780
|
+
if self.include_year:
|
|
781
|
+
new_col = F.concat(
|
|
782
|
+
F.year(col),
|
|
783
|
+
F.lit("-"),
|
|
784
|
+
semester
|
|
785
|
+
)
|
|
786
|
+
else:
|
|
787
|
+
new_col = semester
|
|
788
|
+
|
|
789
|
+
else:
|
|
790
|
+
raise ValueError(
|
|
791
|
+
f"Unsupported mode '{self.mode}'."
|
|
792
|
+
)
|
|
793
|
+
|
|
794
|
+
result = df.withColumn(
|
|
795
|
+
self.column,
|
|
796
|
+
new_col.cast("string")
|
|
797
|
+
)
|
|
798
|
+
|
|
799
|
+
return self._rename_output_column(result)
|
|
800
|
+
|
|
801
|
+
def transform(
|
|
802
|
+
self,
|
|
803
|
+
df: DataFrame
|
|
804
|
+
) -> DataFrame:
|
|
805
|
+
"""
|
|
806
|
+
Applies generalization to the configured column.
|
|
807
|
+
|
|
808
|
+
The transformation strategy is selected from either the external
|
|
809
|
+
rules configuration or the semantic type of the target column.
|
|
810
|
+
|
|
811
|
+
When ``rules_path`` is provided, the JSON configuration is loaded
|
|
812
|
+
once and its ``type`` field determines the transformation:
|
|
813
|
+
|
|
814
|
+
- ``categorical``:
|
|
815
|
+
Categorical mapping rules are applied.
|
|
816
|
+
|
|
817
|
+
- ``numeric``:
|
|
818
|
+
Numerical interval rules are applied.
|
|
819
|
+
|
|
820
|
+
- ``date``:
|
|
821
|
+
Temporal generalization is applied using the configured mode.
|
|
822
|
+
|
|
823
|
+
When no rules file is provided, temporal columns can be generalized
|
|
824
|
+
directly using their inferred semantic type. Numerical and
|
|
825
|
+
categorical generalization require external rules.
|
|
826
|
+
|
|
827
|
+
Parameters
|
|
828
|
+
----------
|
|
829
|
+
df : pyspark.sql.DataFrame
|
|
830
|
+
Input dataset.
|
|
831
|
+
|
|
832
|
+
Returns
|
|
833
|
+
-------
|
|
834
|
+
pyspark.sql.DataFrame
|
|
835
|
+
DataFrame with the selected column generalized.
|
|
836
|
+
|
|
837
|
+
Raises
|
|
838
|
+
------
|
|
839
|
+
ValueError
|
|
840
|
+
If the input DataFrame is None, is not Spark-compatible, does
|
|
841
|
+
not contain the configured column, contains an unsupported
|
|
842
|
+
rules type, or requires rules that were not provided.
|
|
843
|
+
|
|
844
|
+
Notes
|
|
845
|
+
-----
|
|
846
|
+
The method only constructs Spark transformations. Dataset records
|
|
847
|
+
are not collected to the driver.
|
|
848
|
+
|
|
849
|
+
When external rules are used, only the small JSON configuration
|
|
850
|
+
itself is loaded on the driver.
|
|
851
|
+
"""
|
|
852
|
+
if df is None:
|
|
853
|
+
raise ValueError(
|
|
854
|
+
"Input DataFrame cannot be None."
|
|
855
|
+
)
|
|
856
|
+
|
|
857
|
+
# Compatible with standard Spark and Spark Connect.
|
|
858
|
+
if not hasattr(df, "withColumn"):
|
|
859
|
+
raise ValueError(
|
|
860
|
+
"Input must be a Spark-compatible DataFrame."
|
|
861
|
+
)
|
|
862
|
+
|
|
863
|
+
if self.column not in df.columns:
|
|
864
|
+
raise ValueError(
|
|
865
|
+
f"Column '{self.column}' does not exist "
|
|
866
|
+
f"in the DataFrame."
|
|
867
|
+
)
|
|
868
|
+
|
|
869
|
+
# ------------------------------------------------------
|
|
870
|
+
# EXTERNAL RULES
|
|
871
|
+
# ------------------------------------------------------
|
|
872
|
+
|
|
873
|
+
if self.rules_path is not None:
|
|
874
|
+
config = self._load_rules_config(df)
|
|
875
|
+
rules_type = self._get_rules_type(config)
|
|
876
|
+
|
|
877
|
+
if rules_type == "numeric":
|
|
878
|
+
return self._apply_numeric(
|
|
879
|
+
df,
|
|
880
|
+
config
|
|
881
|
+
)
|
|
882
|
+
|
|
883
|
+
if rules_type == "categorical":
|
|
884
|
+
return self._apply_categorical(
|
|
885
|
+
df,
|
|
886
|
+
config
|
|
887
|
+
)
|
|
888
|
+
|
|
889
|
+
if rules_type == "date":
|
|
890
|
+
return self._apply_temporal_with_mode(df)
|
|
891
|
+
|
|
892
|
+
# ------------------------------------------------------
|
|
893
|
+
# AUTOMATIC TEMPORAL GENERALIZATION
|
|
894
|
+
# ------------------------------------------------------
|
|
895
|
+
|
|
896
|
+
semantic_type = infer_semantic_type(
|
|
897
|
+
df,
|
|
898
|
+
self.column
|
|
899
|
+
)
|
|
900
|
+
|
|
901
|
+
if semantic_type == "date":
|
|
902
|
+
return self._apply_temporal_with_mode(df)
|
|
903
|
+
|
|
904
|
+
raise ValueError(
|
|
905
|
+
"For numeric or categorical generalization, "
|
|
906
|
+
"'rules_path' must be provided."
|
|
907
|
+
)
|