dataleaks 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.
- dataleaks/__init__.py +3 -0
- dataleaks/api.py +67 -0
- dataleaks/cli.py +276 -0
- dataleaks/detectors/__init__.py +0 -0
- dataleaks/detectors/cross_dataset/__init__.py +0 -0
- dataleaks/detectors/cross_dataset/overlap.py +137 -0
- dataleaks/detectors/feature/__init__.py +0 -0
- dataleaks/detectors/feature/identifier.py +238 -0
- dataleaks/detectors/feature/suspicious.py +278 -0
- dataleaks/detectors/feature/target_encoding.py +88 -0
- dataleaks/detectors/preprocessing/__init__.py +0 -0
- dataleaks/detectors/preprocessing/contamination.py +94 -0
- dataleaks/detectors/preprocessing/fit_before_split.py +83 -0
- dataleaks/detectors/split/__init__.py +0 -0
- dataleaks/detectors/split/duplicates.py +82 -0
- dataleaks/detectors/split/near_duplicates.py +146 -0
- dataleaks/detectors/split/overlap.py +254 -0
- dataleaks/detectors/target/__init__.py +0 -0
- dataleaks/detectors/target/derived.py +200 -0
- dataleaks/detectors/target/direct.py +60 -0
- dataleaks/detectors/target/statistical.py +104 -0
- dataleaks/detectors/temporal/__init__.py +0 -0
- dataleaks/detectors/temporal/future_features.py +784 -0
- dataleaks/detectors/temporal/parsing.py +265 -0
- dataleaks/detectors/temporal/time_order.py +95 -0
- dataleaks/engine/__init__.py +7 -0
- dataleaks/engine/aggregator.py +288 -0
- dataleaks/engine/defaults.py +341 -0
- dataleaks/engine/detector.py +16 -0
- dataleaks/engine/registry.py +97 -0
- dataleaks/engine/runner.py +75 -0
- dataleaks/recommendations/__init__.py +0 -0
- dataleaks/recommendations/fixes.py +42 -0
- dataleaks/recommendations/recommendations.py +27 -0
- dataleaks/reporting/__init__.py +5 -0
- dataleaks/reporting/console.py +93 -0
- dataleaks/reporting/json.py +114 -0
- dataleaks/reporting/report.py +224 -0
- dataleaks/reporting/summary.py +37 -0
- dataleaks/schemas/__init__.py +7 -0
- dataleaks/schemas/config.py +31 -0
- dataleaks/schemas/dataset.py +93 -0
- dataleaks/schemas/execution.py +86 -0
- dataleaks/schemas/finding.py +29 -0
- dataleaks/scoring/__init__.py +0 -0
- dataleaks/scoring/confidence.py +49 -0
- dataleaks/scoring/risk.py +76 -0
- dataleaks/scoring/severity.py +64 -0
- dataleaks-0.1.0.dist-info/METADATA +700 -0
- dataleaks-0.1.0.dist-info/RECORD +54 -0
- dataleaks-0.1.0.dist-info/WHEEL +5 -0
- dataleaks-0.1.0.dist-info/entry_points.txt +2 -0
- dataleaks-0.1.0.dist-info/licenses/LICENSE +21 -0
- dataleaks-0.1.0.dist-info/top_level.txt +1 -0
dataleaks/__init__.py
ADDED
dataleaks/api.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from dataleaks.engine.aggregator import FindingAggregator
|
|
6
|
+
from dataleaks.engine.defaults import build_default_registry
|
|
7
|
+
from dataleaks.engine.registry import DetectorRegistry
|
|
8
|
+
from dataleaks.engine.runner import DetectorRunner
|
|
9
|
+
from dataleaks.reporting.report import LeakageReport
|
|
10
|
+
from dataleaks.schemas.config import DataLeaksConfig
|
|
11
|
+
from dataleaks.schemas.dataset import DatasetContext
|
|
12
|
+
from dataleaks.schemas.finding import Finding
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DataLeaks:
|
|
16
|
+
"""Public API for running DataLeaks analysis."""
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
data,
|
|
21
|
+
*,
|
|
22
|
+
target: str | None = None,
|
|
23
|
+
train=None,
|
|
24
|
+
validation=None,
|
|
25
|
+
test=None,
|
|
26
|
+
config: DataLeaksConfig | None = None,
|
|
27
|
+
metadata: dict[str, Any] | None = None,
|
|
28
|
+
registry: DetectorRegistry | None = None,
|
|
29
|
+
) -> None:
|
|
30
|
+
self.context = DatasetContext(
|
|
31
|
+
data=data,
|
|
32
|
+
target=target,
|
|
33
|
+
train=train if train is not None else data,
|
|
34
|
+
validation=validation,
|
|
35
|
+
test=test,
|
|
36
|
+
metadata=metadata or {},
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
self.config = config or DataLeaksConfig()
|
|
40
|
+
|
|
41
|
+
if registry is None:
|
|
42
|
+
self.registry = build_default_registry(
|
|
43
|
+
self.context,
|
|
44
|
+
self.config,
|
|
45
|
+
)
|
|
46
|
+
else:
|
|
47
|
+
self.registry = registry
|
|
48
|
+
|
|
49
|
+
def run(self) -> LeakageReport:
|
|
50
|
+
"""Run detectors, aggregate findings, and build a report."""
|
|
51
|
+
|
|
52
|
+
runner = DetectorRunner(self.registry)
|
|
53
|
+
|
|
54
|
+
findings: list[Finding] = runner.run(
|
|
55
|
+
self.context
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
findings = FindingAggregator().aggregate(
|
|
59
|
+
findings
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
return LeakageReport.from_findings(
|
|
63
|
+
findings,
|
|
64
|
+
metadata=self.context.metadata,
|
|
65
|
+
execution=runner.execution,
|
|
66
|
+
schema_validation=self.context.schema_validation,
|
|
67
|
+
)
|
dataleaks/cli.py
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import ast
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
import pandas as pd
|
|
8
|
+
|
|
9
|
+
from dataleaks.api import DataLeaks
|
|
10
|
+
from dataleaks.reporting.console import ConsoleReporter
|
|
11
|
+
from dataleaks.reporting.json import JSONReporter
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
15
|
+
"""Build the DataLeaks command-line argument parser."""
|
|
16
|
+
|
|
17
|
+
parser = argparse.ArgumentParser(
|
|
18
|
+
prog="dataleaks",
|
|
19
|
+
description="Detect data leakage in machine learning datasets.",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
parser.add_argument(
|
|
23
|
+
"file",
|
|
24
|
+
help="Path to the training CSV dataset.",
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
parser.add_argument(
|
|
28
|
+
"--target",
|
|
29
|
+
help="Target column used for leakage analysis.",
|
|
30
|
+
default=None,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
parser.add_argument(
|
|
34
|
+
"--test",
|
|
35
|
+
help="Path to the test CSV dataset.",
|
|
36
|
+
default=None,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
parser.add_argument(
|
|
40
|
+
"--validation",
|
|
41
|
+
help="Path to the validation CSV dataset.",
|
|
42
|
+
default=None,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
parser.add_argument(
|
|
46
|
+
"--time-column",
|
|
47
|
+
help=(
|
|
48
|
+
"Time column used for chronological train/test "
|
|
49
|
+
"split validation."
|
|
50
|
+
),
|
|
51
|
+
default=None,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
parser.add_argument(
|
|
55
|
+
"--prediction-time-column",
|
|
56
|
+
help=(
|
|
57
|
+
"Prediction timestamp column used to detect "
|
|
58
|
+
"future feature timestamps."
|
|
59
|
+
),
|
|
60
|
+
default=None,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
parser.add_argument(
|
|
64
|
+
"--feature-time-columns",
|
|
65
|
+
nargs="+",
|
|
66
|
+
help=(
|
|
67
|
+
"One or more feature timestamp columns to compare "
|
|
68
|
+
"against the prediction timestamp."
|
|
69
|
+
),
|
|
70
|
+
default=None,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
parser.add_argument(
|
|
74
|
+
"--conditional-time-column",
|
|
75
|
+
help=(
|
|
76
|
+
"Temporal column whose expected presence depends "
|
|
77
|
+
"on a target condition."
|
|
78
|
+
),
|
|
79
|
+
default=None,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
parser.add_argument(
|
|
83
|
+
"--conditional-target-column",
|
|
84
|
+
help=(
|
|
85
|
+
"Target column controlling conditional temporal "
|
|
86
|
+
"missingness."
|
|
87
|
+
),
|
|
88
|
+
default=None,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
parser.add_argument(
|
|
92
|
+
"--conditional-present-when",
|
|
93
|
+
help=(
|
|
94
|
+
"Target value for which the conditional temporal "
|
|
95
|
+
"column is expected to be present."
|
|
96
|
+
),
|
|
97
|
+
default=None,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
parser.add_argument(
|
|
101
|
+
"--output",
|
|
102
|
+
choices=["console", "json"],
|
|
103
|
+
default="console",
|
|
104
|
+
help="Output format. Defaults to console.",
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
return parser
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _parse_cli_value(value: str) -> object:
|
|
111
|
+
"""Parse a CLI scalar into a Python value when possible."""
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
return ast.literal_eval(value)
|
|
115
|
+
except (ValueError, SyntaxError):
|
|
116
|
+
return value
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _build_temporal_metadata(
|
|
120
|
+
*,
|
|
121
|
+
time_column: str | None,
|
|
122
|
+
prediction_time_column: str | None,
|
|
123
|
+
feature_time_columns: list[str] | None,
|
|
124
|
+
conditional_time_column: str | None = None,
|
|
125
|
+
conditional_target_column: str | None = None,
|
|
126
|
+
conditional_present_when: str | None = None,
|
|
127
|
+
) -> dict:
|
|
128
|
+
"""Build temporal metadata from CLI arguments."""
|
|
129
|
+
|
|
130
|
+
temporal: dict[str, object] = {}
|
|
131
|
+
|
|
132
|
+
if time_column is not None:
|
|
133
|
+
temporal["time_column"] = time_column
|
|
134
|
+
|
|
135
|
+
if prediction_time_column is not None:
|
|
136
|
+
temporal["prediction_time_column"] = (
|
|
137
|
+
prediction_time_column
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
if feature_time_columns is not None:
|
|
141
|
+
temporal["feature_time_columns"] = (
|
|
142
|
+
feature_time_columns
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
conditional_args = (
|
|
146
|
+
conditional_time_column,
|
|
147
|
+
conditional_target_column,
|
|
148
|
+
conditional_present_when,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
conditional_count = sum(
|
|
152
|
+
value is not None
|
|
153
|
+
for value in conditional_args
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
if conditional_count not in (0, 3):
|
|
157
|
+
raise ValueError(
|
|
158
|
+
"--conditional-time-column, "
|
|
159
|
+
"--conditional-target-column, and "
|
|
160
|
+
"--conditional-present-when must be provided together"
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
if conditional_count == 3:
|
|
164
|
+
temporal["conditional_columns"] = {
|
|
165
|
+
conditional_time_column: {
|
|
166
|
+
"target_column": conditional_target_column,
|
|
167
|
+
"present_when": _parse_cli_value(
|
|
168
|
+
conditional_present_when
|
|
169
|
+
),
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return temporal
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def main() -> int:
|
|
177
|
+
"""Run the DataLeaks CLI."""
|
|
178
|
+
|
|
179
|
+
parser = build_parser()
|
|
180
|
+
args = parser.parse_args()
|
|
181
|
+
|
|
182
|
+
try:
|
|
183
|
+
data = pd.read_csv(args.file)
|
|
184
|
+
|
|
185
|
+
test = (
|
|
186
|
+
pd.read_csv(args.test)
|
|
187
|
+
if args.test is not None
|
|
188
|
+
else None
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
validation = (
|
|
192
|
+
pd.read_csv(args.validation)
|
|
193
|
+
if args.validation is not None
|
|
194
|
+
else None
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
temporal_metadata = _build_temporal_metadata(
|
|
198
|
+
time_column=args.time_column,
|
|
199
|
+
prediction_time_column=(
|
|
200
|
+
args.prediction_time_column
|
|
201
|
+
),
|
|
202
|
+
feature_time_columns=args.feature_time_columns,
|
|
203
|
+
conditional_time_column=(
|
|
204
|
+
args.conditional_time_column
|
|
205
|
+
),
|
|
206
|
+
conditional_target_column=(
|
|
207
|
+
args.conditional_target_column
|
|
208
|
+
),
|
|
209
|
+
conditional_present_when=(
|
|
210
|
+
args.conditional_present_when
|
|
211
|
+
),
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
metadata = {}
|
|
215
|
+
|
|
216
|
+
if temporal_metadata:
|
|
217
|
+
metadata["temporal"] = temporal_metadata
|
|
218
|
+
|
|
219
|
+
report = DataLeaks(
|
|
220
|
+
data,
|
|
221
|
+
target=args.target,
|
|
222
|
+
test=test,
|
|
223
|
+
validation=validation,
|
|
224
|
+
metadata=metadata,
|
|
225
|
+
).run()
|
|
226
|
+
|
|
227
|
+
if args.output == "json":
|
|
228
|
+
output = JSONReporter().render(report)
|
|
229
|
+
else:
|
|
230
|
+
output = ConsoleReporter().render(report)
|
|
231
|
+
|
|
232
|
+
print(output)
|
|
233
|
+
return 0
|
|
234
|
+
|
|
235
|
+
except FileNotFoundError as exc:
|
|
236
|
+
missing_file = args.file
|
|
237
|
+
|
|
238
|
+
if args.test is not None and args.test in str(exc):
|
|
239
|
+
missing_file = args.test
|
|
240
|
+
|
|
241
|
+
elif (
|
|
242
|
+
args.validation is not None
|
|
243
|
+
and args.validation in str(exc)
|
|
244
|
+
):
|
|
245
|
+
missing_file = args.validation
|
|
246
|
+
|
|
247
|
+
print(
|
|
248
|
+
f"Error: dataset file not found: {missing_file}",
|
|
249
|
+
file=sys.stderr,
|
|
250
|
+
)
|
|
251
|
+
return 1
|
|
252
|
+
|
|
253
|
+
except pd.errors.EmptyDataError as exc:
|
|
254
|
+
print(
|
|
255
|
+
f"Error: dataset file is empty: {exc}",
|
|
256
|
+
file=sys.stderr,
|
|
257
|
+
)
|
|
258
|
+
return 1
|
|
259
|
+
|
|
260
|
+
except pd.errors.ParserError as exc:
|
|
261
|
+
print(
|
|
262
|
+
f"Error: could not parse CSV file: {exc}",
|
|
263
|
+
file=sys.stderr,
|
|
264
|
+
)
|
|
265
|
+
return 1
|
|
266
|
+
|
|
267
|
+
except (TypeError, ValueError) as exc:
|
|
268
|
+
print(
|
|
269
|
+
f"Error: {exc}",
|
|
270
|
+
file=sys.stderr,
|
|
271
|
+
)
|
|
272
|
+
return 1
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
if __name__ == "__main__":
|
|
276
|
+
raise SystemExit(main())
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import pandas as pd
|
|
4
|
+
|
|
5
|
+
from dataleaks.engine.detector import BaseDetector
|
|
6
|
+
from dataleaks.schemas.dataset import DatasetContext
|
|
7
|
+
from dataleaks.schemas.finding import Finding
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CrossDatasetOverlapDetector(BaseDetector):
|
|
11
|
+
"""Detect exact overlap between the current dataset and an external dataset."""
|
|
12
|
+
|
|
13
|
+
name = "cross_dataset_overlap"
|
|
14
|
+
category = "cross_dataset_leakage"
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
reference_data: pd.DataFrame,
|
|
19
|
+
columns: list[str] | None = None,
|
|
20
|
+
) -> None:
|
|
21
|
+
if not isinstance(reference_data, pd.DataFrame):
|
|
22
|
+
raise TypeError(
|
|
23
|
+
"reference_data must be a pandas DataFrame"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
if columns is not None and not columns:
|
|
27
|
+
raise ValueError(
|
|
28
|
+
"columns must contain at least one column"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
self.reference_data = reference_data
|
|
32
|
+
self.columns = columns
|
|
33
|
+
|
|
34
|
+
def detect(self, context: DatasetContext) -> list[Finding]:
|
|
35
|
+
data = context.data
|
|
36
|
+
reference = self.reference_data
|
|
37
|
+
|
|
38
|
+
if data.empty or reference.empty:
|
|
39
|
+
return []
|
|
40
|
+
|
|
41
|
+
columns = self._resolve_columns(
|
|
42
|
+
data,
|
|
43
|
+
reference,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
if not columns:
|
|
47
|
+
return []
|
|
48
|
+
|
|
49
|
+
current_rows = (
|
|
50
|
+
data[columns]
|
|
51
|
+
.drop_duplicates()
|
|
52
|
+
.reset_index(drop=True)
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
reference_rows = (
|
|
56
|
+
reference[columns]
|
|
57
|
+
.drop_duplicates()
|
|
58
|
+
.reset_index(drop=True)
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
current_index = pd.MultiIndex.from_frame(current_rows)
|
|
62
|
+
reference_index = pd.MultiIndex.from_frame(reference_rows)
|
|
63
|
+
|
|
64
|
+
overlap = current_index.intersection(reference_index)
|
|
65
|
+
overlap_count = len(overlap)
|
|
66
|
+
|
|
67
|
+
if overlap_count == 0:
|
|
68
|
+
return []
|
|
69
|
+
|
|
70
|
+
current_unique_count = len(current_rows)
|
|
71
|
+
|
|
72
|
+
overlap_ratio = (
|
|
73
|
+
overlap_count / current_unique_count
|
|
74
|
+
if current_unique_count
|
|
75
|
+
else 0.0
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
if overlap_ratio >= 0.5:
|
|
79
|
+
severity = "critical"
|
|
80
|
+
elif overlap_ratio >= 0.1:
|
|
81
|
+
severity = "high"
|
|
82
|
+
else:
|
|
83
|
+
severity = "medium"
|
|
84
|
+
|
|
85
|
+
return [
|
|
86
|
+
Finding(
|
|
87
|
+
detector=self.name,
|
|
88
|
+
category=self.category,
|
|
89
|
+
severity=severity,
|
|
90
|
+
confidence=overlap_ratio,
|
|
91
|
+
explanation=(
|
|
92
|
+
f"{overlap_count} unique row(s) from the current "
|
|
93
|
+
"dataset also occur in the reference dataset."
|
|
94
|
+
),
|
|
95
|
+
recommendation=(
|
|
96
|
+
"Verify whether the reference dataset was used during "
|
|
97
|
+
"training, preprocessing, augmentation, or evaluation. "
|
|
98
|
+
"Ensure that shared observations cannot transfer "
|
|
99
|
+
"target or evaluation information into the model."
|
|
100
|
+
),
|
|
101
|
+
affected_columns=columns,
|
|
102
|
+
evidence={
|
|
103
|
+
"type": "cross_dataset_exact_overlap",
|
|
104
|
+
"overlap_count": overlap_count,
|
|
105
|
+
"current_unique_rows": current_unique_count,
|
|
106
|
+
"reference_unique_rows": len(reference_rows),
|
|
107
|
+
"overlap_ratio": overlap_ratio,
|
|
108
|
+
"columns": columns,
|
|
109
|
+
},
|
|
110
|
+
)
|
|
111
|
+
]
|
|
112
|
+
|
|
113
|
+
def _resolve_columns(
|
|
114
|
+
self,
|
|
115
|
+
data: pd.DataFrame,
|
|
116
|
+
reference: pd.DataFrame,
|
|
117
|
+
) -> list[str]:
|
|
118
|
+
if self.columns is not None:
|
|
119
|
+
missing = [
|
|
120
|
+
column
|
|
121
|
+
for column in self.columns
|
|
122
|
+
if column not in data.columns
|
|
123
|
+
or column not in reference.columns
|
|
124
|
+
]
|
|
125
|
+
|
|
126
|
+
if missing:
|
|
127
|
+
raise ValueError(
|
|
128
|
+
f"Configured overlap columns are missing: {missing}"
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
return self.columns
|
|
132
|
+
|
|
133
|
+
return [
|
|
134
|
+
column
|
|
135
|
+
for column in data.columns
|
|
136
|
+
if column in reference.columns
|
|
137
|
+
]
|
|
File without changes
|