tabcustody 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.
- tabcustody/__init__.py +6 -0
- tabcustody/__main__.py +5 -0
- tabcustody/_cli.py +43 -0
- tabcustody/_detect.py +230 -0
- tabcustody/_reader.py +280 -0
- tabcustody/_report.py +125 -0
- tabcustody/_reverse.py +72 -0
- tabcustody/py.typed +0 -0
- tabcustody-0.1.0.dist-info/METADATA +174 -0
- tabcustody-0.1.0.dist-info/RECORD +13 -0
- tabcustody-0.1.0.dist-info/WHEEL +4 -0
- tabcustody-0.1.0.dist-info/entry_points.txt +2 -0
- tabcustody-0.1.0.dist-info/licenses/LICENSE +21 -0
tabcustody/__init__.py
ADDED
tabcustody/__main__.py
ADDED
tabcustody/_cli.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""The `tabcustody` command: exits 1 when a file carries training data, 2 when a file cannot be read, 0 otherwise."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
|
|
10
|
+
from tabcustody._report import scan_file
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _parser() -> argparse.ArgumentParser:
|
|
14
|
+
parser = argparse.ArgumentParser(prog="tabcustody")
|
|
15
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
16
|
+
scan = commands.add_parser("scan", help="tell whether model files carry their training data")
|
|
17
|
+
scan.add_argument("files", nargs="+", help="pickle, joblib or .tabpfn_fit files")
|
|
18
|
+
scan.add_argument("--json", action="store_true", help="print one JSON report per file")
|
|
19
|
+
scan.add_argument(
|
|
20
|
+
"--min-rows", type=int, default=20, help="smallest table reported (default: 20)"
|
|
21
|
+
)
|
|
22
|
+
return parser
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
26
|
+
arguments = _parser().parse_args(argv)
|
|
27
|
+
reports = [scan_file(path, min_rows=arguments.min_rows) for path in arguments.files]
|
|
28
|
+
if arguments.json:
|
|
29
|
+
print(json.dumps([report.to_dict() for report in reports], indent=2))
|
|
30
|
+
else:
|
|
31
|
+
print("\n\n".join(report.to_text() for report in reports))
|
|
32
|
+
carrying = sum(report.carries_training_data for report in reports)
|
|
33
|
+
unreadable = sum(report.error is not None for report in reports)
|
|
34
|
+
print(
|
|
35
|
+
f"\n{len(reports)} file(s) scanned: {carrying} carry training data, {unreadable} unreadable."
|
|
36
|
+
)
|
|
37
|
+
if any(report.error for report in reports):
|
|
38
|
+
return 2
|
|
39
|
+
return 1 if any(report.carries_training_data for report in reports) else 0
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
if __name__ == "__main__":
|
|
43
|
+
sys.exit(main())
|
tabcustody/_detect.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""Finds training tables in an object tree built by the reader, without being given the training data."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Iterator
|
|
7
|
+
from dataclasses import dataclass, replace
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
from tabcustody._reader import Shell
|
|
13
|
+
from tabcustody._reverse import produced, scaler_kind
|
|
14
|
+
|
|
15
|
+
TRAINING_NAMES = frozenset(
|
|
16
|
+
{"X_train", "X_", "_fit_X", "X_fit_", "support_vectors_", "X_transformed_"}
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class Finding:
|
|
22
|
+
"""A 2-D numeric array judged to hold training rows; `companion` is the 1-D array with one entry per row, if any."""
|
|
23
|
+
|
|
24
|
+
path: str
|
|
25
|
+
shape: tuple[int, ...]
|
|
26
|
+
companion: str | None
|
|
27
|
+
evidence: tuple[str, ...]
|
|
28
|
+
reversible_by: str | None = None
|
|
29
|
+
owner: str | None = None
|
|
30
|
+
hint: str | None = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _is_numeric(value: Any, ndim: int) -> bool:
|
|
34
|
+
return isinstance(value, np.ndarray) and value.ndim == ndim and value.dtype.kind in "iuf"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
_FRAMES = frozenset({"pandas.DataFrame", "pandas.core.frame.DataFrame"})
|
|
38
|
+
_SERIES = frozenset({"pandas.Series", "pandas.core.series.Series"})
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _manager(node: Shell) -> Any:
|
|
42
|
+
return node.attributes.get("_mgr", node.attributes.get("_data"))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _placement(locations: Any, count: int) -> list[int] | None:
|
|
46
|
+
if isinstance(locations, slice):
|
|
47
|
+
columns = list(range(locations.start or 0, locations.stop, locations.step or 1))
|
|
48
|
+
elif _is_numeric(locations, 1):
|
|
49
|
+
columns = [int(column) for column in locations]
|
|
50
|
+
else:
|
|
51
|
+
return None
|
|
52
|
+
return columns if len(columns) == count else None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _blocks(manager: Any) -> list[tuple[np.ndarray, Any]]:
|
|
56
|
+
"""Pairs each block of a pickled pandas BlockManager with the columns it holds, for pandas 1 to 3 layouts."""
|
|
57
|
+
blocks: dict[int, tuple[np.ndarray, Any]] = {}
|
|
58
|
+
for _, node in _walk(manager):
|
|
59
|
+
if isinstance(node, Shell) and node.name.endswith("_unpickle_block") and node.args:
|
|
60
|
+
values, locations = node.args[0], node.args[1] if len(node.args) > 1 else None
|
|
61
|
+
elif isinstance(node, dict) and "values" in node and "mgr_locs" in node:
|
|
62
|
+
values, locations = node["values"], node["mgr_locs"]
|
|
63
|
+
else:
|
|
64
|
+
continue
|
|
65
|
+
if isinstance(values, np.ndarray):
|
|
66
|
+
blocks[id(values)] = (values, locations)
|
|
67
|
+
return list(blocks.values())
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def as_table(node: Any) -> np.ndarray | None:
|
|
71
|
+
"""Returns `node` as a 2-D numeric array: a NumPy array as is, a pickled pandas DataFrame rebuilt from its numeric blocks."""
|
|
72
|
+
if _is_numeric(node, 2):
|
|
73
|
+
return node # type: ignore[no-any-return]
|
|
74
|
+
if not (isinstance(node, Shell) and node.name in _FRAMES):
|
|
75
|
+
return None
|
|
76
|
+
columns: dict[int, np.ndarray] = {}
|
|
77
|
+
for values, locations in _blocks(_manager(node)):
|
|
78
|
+
if not _is_numeric(values, 2):
|
|
79
|
+
continue
|
|
80
|
+
placement = _placement(locations, values.shape[0])
|
|
81
|
+
if placement is None:
|
|
82
|
+
return None
|
|
83
|
+
columns.update(zip(placement, values, strict=True))
|
|
84
|
+
if not columns or len({column.shape[0] for column in columns.values()}) != 1:
|
|
85
|
+
return None
|
|
86
|
+
return np.column_stack([columns[index] for index in sorted(columns)])
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def as_vector(node: Any) -> np.ndarray | None:
|
|
90
|
+
"""Returns `node` as a 1-D numeric array: a NumPy array as is, a pickled pandas Series by its values."""
|
|
91
|
+
if _is_numeric(node, 1):
|
|
92
|
+
return node # type: ignore[no-any-return]
|
|
93
|
+
if not (isinstance(node, Shell) and node.name in _SERIES):
|
|
94
|
+
return None
|
|
95
|
+
return next((values for _, values in _walk(_manager(node)) if _is_numeric(values, 1)), None)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _is_lookup_grid(array: np.ndarray) -> bool:
|
|
99
|
+
"""Tells a lookup table, such as QuantileTransformer's quantiles_, where every column is sorted, from rows of data."""
|
|
100
|
+
return array.shape[0] > 1 and bool(np.all(np.diff(array, axis=0) >= 0))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _named_children(node: Any) -> list[tuple[str, Any]]:
|
|
104
|
+
if isinstance(node, Shell):
|
|
105
|
+
return [(f".{key}", value) for key, value in node.attributes.items()] + [
|
|
106
|
+
(f"[{key!r}]", value) for key, value in node.entries.items()
|
|
107
|
+
]
|
|
108
|
+
if isinstance(node, dict):
|
|
109
|
+
return [(f"[{key!r}]", value) for key, value in node.items()]
|
|
110
|
+
return []
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _positional_children(node: Any) -> list[tuple[str, Any]]:
|
|
114
|
+
if isinstance(node, Shell):
|
|
115
|
+
children = [(f"[{index}]", value) for index, value in enumerate(node.items)]
|
|
116
|
+
children += [(f"<arg{index}>", value) for index, value in enumerate(node.args)]
|
|
117
|
+
if node.state is not None:
|
|
118
|
+
children.append(("<state>", node.state))
|
|
119
|
+
return children
|
|
120
|
+
if isinstance(node, list | tuple | set | frozenset):
|
|
121
|
+
return [(f"[{index}]", value) for index, value in enumerate(node)]
|
|
122
|
+
if isinstance(node, np.ndarray) and node.dtype.kind == "O":
|
|
123
|
+
return [(f"[{index}]", value) for index, value in enumerate(node.ravel())]
|
|
124
|
+
return []
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _walk(root: Any) -> Iterator[tuple[str, Any]]:
|
|
128
|
+
seen: set[int] = set()
|
|
129
|
+
stack: list[tuple[str, Any]] = [("", root)]
|
|
130
|
+
while stack:
|
|
131
|
+
path, node = stack.pop()
|
|
132
|
+
if id(node) in seen:
|
|
133
|
+
continue
|
|
134
|
+
seen.add(id(node))
|
|
135
|
+
yield path, node
|
|
136
|
+
children = _named_children(node) + _positional_children(node)
|
|
137
|
+
stack.extend((path + suffix, child) for suffix, child in reversed(children))
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
_PIPELINE_STEP = re.compile(r"^(?P<pipeline>.*)\[(?P<index>\d+)\]\[1\]$")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _parent(path: str) -> str:
|
|
144
|
+
return path[: max(path.rfind("."), path.rfind("["), 0)]
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _feeds(scaler_path: str, table_path: str) -> bool:
|
|
148
|
+
"""Tells whether the scaler sits beside the table's owner, or is the pipeline step right before it."""
|
|
149
|
+
owner = _parent(table_path)
|
|
150
|
+
if _parent(scaler_path) == owner:
|
|
151
|
+
return True
|
|
152
|
+
step = _PIPELINE_STEP.match(owner)
|
|
153
|
+
if step is None:
|
|
154
|
+
return False
|
|
155
|
+
return scaler_path == f"{step['pipeline']}[{int(step['index']) - 1}][1]"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _attach_scalers(
|
|
159
|
+
tables: list[Finding], arrays: dict[str, np.ndarray], scalers: list[tuple[str, Shell]]
|
|
160
|
+
) -> list[Finding]:
|
|
161
|
+
attached = []
|
|
162
|
+
for finding in tables:
|
|
163
|
+
feeding = [
|
|
164
|
+
(path, scaler)
|
|
165
|
+
for path, scaler in scalers
|
|
166
|
+
if _feeds(path, finding.path) and produced(arrays[finding.path], scaler)
|
|
167
|
+
]
|
|
168
|
+
if len(feeding) != 1:
|
|
169
|
+
attached.append(finding)
|
|
170
|
+
continue
|
|
171
|
+
path, scaler = feeding[0]
|
|
172
|
+
note = f"its columns carry the fingerprint of the {scaler_kind(scaler)} at {path}, which reverses it"
|
|
173
|
+
attached.append(replace(finding, reversible_by=path, evidence=(*finding.evidence, note)))
|
|
174
|
+
return attached
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def find_tables(tree: Any, min_rows: int = 20, prefix: str = "") -> list[Finding]:
|
|
178
|
+
"""Returns the training tables in `tree`: 2-D numeric arrays beside a 1-D array of the same length, then their copies."""
|
|
179
|
+
|
|
180
|
+
def _clean(path: str) -> str:
|
|
181
|
+
return prefix + path.removeprefix(".")
|
|
182
|
+
|
|
183
|
+
tables: list[Finding] = []
|
|
184
|
+
arrays: dict[str, np.ndarray] = {}
|
|
185
|
+
scalers: list[tuple[str, Shell]] = []
|
|
186
|
+
for path, node in _walk(tree):
|
|
187
|
+
table = as_table(node)
|
|
188
|
+
if table is not None:
|
|
189
|
+
arrays[_clean(path)] = table
|
|
190
|
+
if scaler_kind(node) is not None:
|
|
191
|
+
scalers.append((_clean(path), node))
|
|
192
|
+
siblings = _named_children(node)
|
|
193
|
+
for name, child in siblings:
|
|
194
|
+
value = as_table(child)
|
|
195
|
+
if value is None or value.shape[0] < min_rows or _is_lookup_grid(value):
|
|
196
|
+
continue
|
|
197
|
+
companion = next(
|
|
198
|
+
(
|
|
199
|
+
other
|
|
200
|
+
for other, candidate in siblings
|
|
201
|
+
if (vector := as_vector(candidate)) is not None
|
|
202
|
+
and len(vector) == value.shape[0]
|
|
203
|
+
),
|
|
204
|
+
None,
|
|
205
|
+
)
|
|
206
|
+
if companion is None:
|
|
207
|
+
continue
|
|
208
|
+
evidence = [f"the 1-D array {_clean(path + companion)} has one entry per row"]
|
|
209
|
+
if name.lstrip(".") in TRAINING_NAMES:
|
|
210
|
+
evidence.append(f"{name.lstrip('.')} is a name libraries use for training rows")
|
|
211
|
+
owner = node.name if isinstance(node, Shell) else None
|
|
212
|
+
tables.append(
|
|
213
|
+
Finding(
|
|
214
|
+
_clean(path + name),
|
|
215
|
+
value.shape,
|
|
216
|
+
_clean(path + companion),
|
|
217
|
+
tuple(evidence),
|
|
218
|
+
owner=owner,
|
|
219
|
+
)
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
confirmed = {finding.path: finding.shape[0] for finding in tables}
|
|
223
|
+
for path, array in arrays.items():
|
|
224
|
+
if path in confirmed:
|
|
225
|
+
continue
|
|
226
|
+
source = next((other for other, rows in confirmed.items() if rows == array.shape[0]), None)
|
|
227
|
+
if source is not None:
|
|
228
|
+
copy_evidence = (f"it has as many rows as the training table {source}",)
|
|
229
|
+
tables.append(Finding(path, array.shape, None, copy_evidence))
|
|
230
|
+
return _attach_scalers(tables, arrays, scalers)
|
tabcustody/_reader.py
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""Reads a pickle without running it: NumPy arrays are rebuilt, every other class or function becomes a Shell."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import bz2
|
|
6
|
+
import collections
|
|
7
|
+
import gzip
|
|
8
|
+
import importlib
|
|
9
|
+
import io
|
|
10
|
+
import lzma
|
|
11
|
+
import pickle
|
|
12
|
+
import zipfile
|
|
13
|
+
import zlib
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, BinaryIO, ClassVar, Self, cast
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Shell:
|
|
21
|
+
"""An object the file asked for, kept as its dotted name, arguments and state, with none of its code run."""
|
|
22
|
+
|
|
23
|
+
dotted_name: ClassVar[str] = ""
|
|
24
|
+
|
|
25
|
+
args: tuple[Any, ...]
|
|
26
|
+
attributes: dict[str, Any]
|
|
27
|
+
state: Any
|
|
28
|
+
items: list[Any]
|
|
29
|
+
entries: dict[Any, Any]
|
|
30
|
+
|
|
31
|
+
def __new__(cls, *args: Any, **kwargs: Any) -> Self:
|
|
32
|
+
shell = object.__new__(cls)
|
|
33
|
+
shell.args = args + tuple(kwargs.items())
|
|
34
|
+
shell.attributes = {}
|
|
35
|
+
shell.state = None
|
|
36
|
+
shell.items = []
|
|
37
|
+
shell.entries = {}
|
|
38
|
+
return shell
|
|
39
|
+
|
|
40
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def name(self) -> str:
|
|
45
|
+
return type(self).dotted_name
|
|
46
|
+
|
|
47
|
+
def __setstate__(self, state: Any) -> None:
|
|
48
|
+
if (
|
|
49
|
+
isinstance(state, tuple)
|
|
50
|
+
and len(state) == 2
|
|
51
|
+
and all(p is None or isinstance(p, dict) for p in state)
|
|
52
|
+
):
|
|
53
|
+
for part in state:
|
|
54
|
+
self.attributes.update(part or {})
|
|
55
|
+
elif isinstance(state, dict) and all(isinstance(key, str) for key in state):
|
|
56
|
+
self.attributes.update(state)
|
|
57
|
+
else:
|
|
58
|
+
self.state = state
|
|
59
|
+
|
|
60
|
+
def append(self, item: Any) -> None:
|
|
61
|
+
self.items.append(item)
|
|
62
|
+
|
|
63
|
+
def extend(self, items: Any) -> None:
|
|
64
|
+
self.items.extend(items)
|
|
65
|
+
|
|
66
|
+
def __setitem__(self, key: Any, value: Any) -> None:
|
|
67
|
+
self.entries[key] = value
|
|
68
|
+
|
|
69
|
+
def __repr__(self) -> str:
|
|
70
|
+
return f"Shell({self.name})"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
_shell_classes: dict[str, type[Shell]] = {}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _shell_class(dotted_name: str) -> type[Shell]:
|
|
77
|
+
if dotted_name not in _shell_classes:
|
|
78
|
+
_shell_classes[dotted_name] = type(dotted_name, (Shell,), {"dotted_name": dotted_name})
|
|
79
|
+
return _shell_classes[dotted_name]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _numpy_attribute(submodule: str, name: str) -> Any:
|
|
83
|
+
try:
|
|
84
|
+
module = importlib.import_module(f"numpy._core.{submodule}")
|
|
85
|
+
except ImportError:
|
|
86
|
+
module = importlib.import_module(f"numpy.core.{submodule}")
|
|
87
|
+
return getattr(module, name)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _new_object(cls: Any, *args: Any) -> Any:
|
|
91
|
+
if isinstance(cls, type) and issubclass(cls, Shell):
|
|
92
|
+
return cls.__new__(cls, *args)
|
|
93
|
+
return _shell_class("copyreg.__newobj__")(cls, *args)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _reconstructor(cls: Any, base: Any, state: Any) -> Any:
|
|
97
|
+
shell = _new_object(cls)
|
|
98
|
+
if state is not None and isinstance(shell, Shell):
|
|
99
|
+
shell.state = state
|
|
100
|
+
return shell
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _encode(text: Any, encoding: str = "utf-8", *rest: Any) -> Any:
|
|
104
|
+
"""Stands in for `_codecs.encode`, which pickle protocol 2 uses to carry bytes; only turns text into bytes."""
|
|
105
|
+
if (
|
|
106
|
+
isinstance(text, str)
|
|
107
|
+
and isinstance(encoding, str)
|
|
108
|
+
and encoding.lower() in ("latin1", "latin-1", "utf-8")
|
|
109
|
+
):
|
|
110
|
+
return text.encode(encoding)
|
|
111
|
+
return _shell_class("_codecs.encode")(text, encoding, *rest)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
_NUMPY_BUILDERS = {
|
|
115
|
+
("multiarray", "_reconstruct"),
|
|
116
|
+
("multiarray", "scalar"),
|
|
117
|
+
("numeric", "_frombuffer"),
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
_SAFE_GLOBALS: dict[tuple[str, str], Any] = {
|
|
121
|
+
("builtins", "set"): set,
|
|
122
|
+
("builtins", "frozenset"): frozenset,
|
|
123
|
+
("builtins", "bytearray"): bytearray,
|
|
124
|
+
("builtins", "complex"): complex,
|
|
125
|
+
("builtins", "slice"): slice,
|
|
126
|
+
("builtins", "object"): object,
|
|
127
|
+
("builtins", "list"): list,
|
|
128
|
+
("builtins", "dict"): dict,
|
|
129
|
+
("builtins", "tuple"): tuple,
|
|
130
|
+
("collections", "OrderedDict"): collections.OrderedDict,
|
|
131
|
+
("copyreg", "_reconstructor"): _reconstructor,
|
|
132
|
+
("copyreg", "__newobj__"): _new_object,
|
|
133
|
+
("_codecs", "encode"): _encode,
|
|
134
|
+
("numpy", "ndarray"): np.ndarray,
|
|
135
|
+
("numpy", "dtype"): np.dtype,
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class UnreadableFileError(Exception):
|
|
140
|
+
"""Raised when a file is neither a pickle, a joblib file nor a zip archive of them, or is malformed."""
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
_JOBLIB_WRAPPER = "joblib.numpy_pickle.NumpyArrayWrapper"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _read_exactly(stream: BinaryIO, size: int) -> bytes:
|
|
147
|
+
chunks, remaining = [], size
|
|
148
|
+
while remaining:
|
|
149
|
+
chunk = stream.read(remaining)
|
|
150
|
+
if not chunk:
|
|
151
|
+
raise UnreadableFileError("the file ends inside an array")
|
|
152
|
+
chunks.append(chunk)
|
|
153
|
+
remaining -= len(chunk)
|
|
154
|
+
return b"".join(chunks)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class _Unpickler(pickle._Unpickler):
|
|
158
|
+
# The C unpickler reads ahead, which would swallow the raw array bytes joblib writes between opcodes.
|
|
159
|
+
|
|
160
|
+
dispatch: ClassVar[dict[int, Any]] = dict(pickle._Unpickler.dispatch)
|
|
161
|
+
stack: list[Any]
|
|
162
|
+
|
|
163
|
+
def __init__(self, stream: BinaryIO) -> None:
|
|
164
|
+
super().__init__(stream)
|
|
165
|
+
self._stream = stream
|
|
166
|
+
|
|
167
|
+
def find_class(self, module: str, name: str) -> Any:
|
|
168
|
+
if (module, name) in _SAFE_GLOBALS:
|
|
169
|
+
return _SAFE_GLOBALS[(module, name)]
|
|
170
|
+
prefix, _, submodule = module.rpartition(".")
|
|
171
|
+
if prefix in ("numpy.core", "numpy._core") and (submodule, name) in _NUMPY_BUILDERS:
|
|
172
|
+
return _numpy_attribute(submodule, name)
|
|
173
|
+
if module.startswith("numpy.dtypes") and name.endswith("DType"):
|
|
174
|
+
return getattr(importlib.import_module("numpy.dtypes"), name)
|
|
175
|
+
return _shell_class(f"{module}.{name}")
|
|
176
|
+
|
|
177
|
+
def persistent_load(self, pid: Any) -> Shell:
|
|
178
|
+
return _shell_class("pickle.persistent_id")(pid)
|
|
179
|
+
|
|
180
|
+
def _load_build(self) -> None:
|
|
181
|
+
pickle._Unpickler.load_build(self) # type: ignore[attr-defined]
|
|
182
|
+
top = self.stack[-1]
|
|
183
|
+
if isinstance(top, Shell) and top.name == _JOBLIB_WRAPPER:
|
|
184
|
+
self.stack[-1] = self._read_joblib_array(top)
|
|
185
|
+
|
|
186
|
+
def _read_joblib_array(self, wrapper: Shell) -> np.ndarray:
|
|
187
|
+
"""Reads the array joblib writes right after its NumpyArrayWrapper, as joblib 1.6 `read_array` does."""
|
|
188
|
+
try:
|
|
189
|
+
dtype = np.dtype(wrapper.attributes["dtype"])
|
|
190
|
+
shape = tuple(int(size) for size in wrapper.attributes["shape"])
|
|
191
|
+
except (KeyError, TypeError, ValueError) as error:
|
|
192
|
+
raise UnreadableFileError(f"malformed joblib array header: {error}") from error
|
|
193
|
+
if dtype.hasobject:
|
|
194
|
+
loaded = _Unpickler(self._stream).load()
|
|
195
|
+
return np.asarray(loaded, dtype=object)
|
|
196
|
+
if wrapper.attributes.get("numpy_array_alignment_bytes") is not None:
|
|
197
|
+
padding = int.from_bytes(_read_exactly(self._stream, 1), "little")
|
|
198
|
+
_read_exactly(self._stream, padding)
|
|
199
|
+
count = int(np.prod(shape, dtype=np.int64)) if shape else 1
|
|
200
|
+
array = np.frombuffer(
|
|
201
|
+
_read_exactly(self._stream, count * dtype.itemsize), dtype=dtype, count=count
|
|
202
|
+
)
|
|
203
|
+
if wrapper.attributes.get("order") == "F":
|
|
204
|
+
return np.asarray(array.reshape(shape[::-1]).transpose())
|
|
205
|
+
return np.asarray(array.reshape(shape))
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
_Unpickler.dispatch[pickle.BUILD[0]] = _Unpickler._load_build
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class _ZlibReader(io.RawIOBase):
|
|
212
|
+
"""The zlib stream joblib writes with `compress=` an integer, decompressed as it is read."""
|
|
213
|
+
|
|
214
|
+
def __init__(self, stream: BinaryIO) -> None:
|
|
215
|
+
self._stream = stream
|
|
216
|
+
self._decompressor = zlib.decompressobj()
|
|
217
|
+
self._pending = b""
|
|
218
|
+
|
|
219
|
+
def readable(self) -> bool:
|
|
220
|
+
return True
|
|
221
|
+
|
|
222
|
+
def readinto(self, buffer: Any) -> int:
|
|
223
|
+
while not self._pending and not self._decompressor.eof:
|
|
224
|
+
compressed = self._stream.read(1 << 16)
|
|
225
|
+
if not compressed:
|
|
226
|
+
break
|
|
227
|
+
self._pending = self._decompressor.decompress(compressed)
|
|
228
|
+
size = min(len(buffer), len(self._pending))
|
|
229
|
+
buffer[:size] = self._pending[:size]
|
|
230
|
+
self._pending = self._pending[size:]
|
|
231
|
+
return size
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _decompressed(stream: BinaryIO) -> BinaryIO:
|
|
235
|
+
head = stream.peek(6)[:6] if hasattr(stream, "peek") else b""
|
|
236
|
+
if head.startswith(b"\x1f\x8b"):
|
|
237
|
+
return cast(BinaryIO, gzip.GzipFile(fileobj=stream))
|
|
238
|
+
if head.startswith(b"BZh"):
|
|
239
|
+
return cast(BinaryIO, bz2.BZ2File(stream))
|
|
240
|
+
if head.startswith((b"\xfd7zXZ\x00", b"]\x00\x00")):
|
|
241
|
+
return cast(BinaryIO, lzma.LZMAFile(stream))
|
|
242
|
+
if head[:1] == b"\x78":
|
|
243
|
+
return cast(BinaryIO, io.BufferedReader(_ZlibReader(stream)))
|
|
244
|
+
return stream
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _load(stream: BinaryIO) -> Any:
|
|
248
|
+
try:
|
|
249
|
+
return _Unpickler(_decompressed(stream)).load()
|
|
250
|
+
except UnreadableFileError:
|
|
251
|
+
raise
|
|
252
|
+
except Exception as error:
|
|
253
|
+
raise UnreadableFileError(
|
|
254
|
+
f"not a readable pickle or joblib file: {type(error).__name__}: {error}"
|
|
255
|
+
) from error
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def read_bytes(data: bytes) -> Any:
|
|
259
|
+
"""Returns the object tree of a pickle, rebuilding NumPy arrays and turning every other class into a Shell."""
|
|
260
|
+
return _load(io.BufferedReader(io.BytesIO(data)))
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def read(path: str | Path) -> Any:
|
|
264
|
+
"""Returns the object tree of the pickle or joblib file at `path`; nothing it names is imported or called, NumPy array builders aside."""
|
|
265
|
+
with Path(path).open("rb") as stream:
|
|
266
|
+
return _load(stream)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def read_members(path: str | Path) -> tuple[str, list[tuple[str | None, Any]]]:
|
|
270
|
+
"""Returns the file's format and the object tree of each pickle or joblib member; a lone file has one member named None."""
|
|
271
|
+
if zipfile.is_zipfile(path):
|
|
272
|
+
trees: list[tuple[str | None, Any]] = []
|
|
273
|
+
with zipfile.ZipFile(path) as archive:
|
|
274
|
+
for member in archive.infolist():
|
|
275
|
+
if member.is_dir() or member.filename.endswith(".json"):
|
|
276
|
+
continue
|
|
277
|
+
with archive.open(member) as stream:
|
|
278
|
+
trees.append((member.filename, _load(stream))) # type: ignore[arg-type]
|
|
279
|
+
return "zip", trees
|
|
280
|
+
return "pickle", [(None, read(path))]
|
tabcustody/_report.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Turns the findings of a file into a report that names paths, shapes and fixes, and never a value of the table."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass, replace
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from tabcustody._detect import Finding, find_tables
|
|
11
|
+
from tabcustody._reader import UnreadableFileError, read_members
|
|
12
|
+
|
|
13
|
+
_KEEP_CONFIDENTIAL = "keep this file as confidential as the data it was fitted on."
|
|
14
|
+
_INSTANCE_BASED = f"An instance-based model keeps training rows by design: {_KEEP_CONFIDENTIAL}"
|
|
15
|
+
|
|
16
|
+
HINTS = (
|
|
17
|
+
(
|
|
18
|
+
"tabicl.",
|
|
19
|
+
(
|
|
20
|
+
"TabICL can save without the rows: fit with kv_cache=True, then call "
|
|
21
|
+
"save(path, save_training_data=False); the file then keeps the model's key-value cache instead."
|
|
22
|
+
),
|
|
23
|
+
),
|
|
24
|
+
(
|
|
25
|
+
"tabpfn.",
|
|
26
|
+
(
|
|
27
|
+
"TabPFN keeps the rows in its default fit mode. Releases that include PriorLabs/TabPFN "
|
|
28
|
+
"pull request 1323 drop them once the caches are built when the model is fitted with "
|
|
29
|
+
f"fit_mode='fit_with_cache'; otherwise {_KEEP_CONFIDENTIAL}"
|
|
30
|
+
),
|
|
31
|
+
),
|
|
32
|
+
("tabdpt.", f"TabDPT has no save function and keeps its training rows: {_KEEP_CONFIDENTIAL}"),
|
|
33
|
+
("sklearn.neighbors.", _INSTANCE_BASED),
|
|
34
|
+
("sklearn.svm.", _INSTANCE_BASED),
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _hint(owner: str | None) -> str | None:
|
|
39
|
+
if owner is None:
|
|
40
|
+
return None
|
|
41
|
+
return next((hint for prefix, hint in HINTS if owner.startswith(prefix)), None)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class Report:
|
|
46
|
+
"""What one file carries: its findings, or the reason it could not be read."""
|
|
47
|
+
|
|
48
|
+
path: str
|
|
49
|
+
format: str
|
|
50
|
+
findings: tuple[Finding, ...]
|
|
51
|
+
error: str | None = None
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def carries_training_data(self) -> bool:
|
|
55
|
+
return bool(self.findings)
|
|
56
|
+
|
|
57
|
+
def to_dict(self) -> dict[str, Any]:
|
|
58
|
+
return {
|
|
59
|
+
"path": self.path,
|
|
60
|
+
"format": self.format,
|
|
61
|
+
"carries_training_data": self.carries_training_data,
|
|
62
|
+
"error": self.error,
|
|
63
|
+
"findings": [
|
|
64
|
+
{
|
|
65
|
+
"path": finding.path,
|
|
66
|
+
"rows": finding.shape[0],
|
|
67
|
+
"columns": finding.shape[1],
|
|
68
|
+
"kind": "training table" if finding.companion else "copy",
|
|
69
|
+
"companion": finding.companion,
|
|
70
|
+
"reversible_by": finding.reversible_by,
|
|
71
|
+
"owner": finding.owner,
|
|
72
|
+
"evidence": list(finding.evidence),
|
|
73
|
+
"hint": finding.hint,
|
|
74
|
+
}
|
|
75
|
+
for finding in self.findings
|
|
76
|
+
],
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
def to_text(self) -> str:
|
|
80
|
+
lines = [f"{self.path} ({self.format})"]
|
|
81
|
+
if self.error:
|
|
82
|
+
lines.append(f" unreadable: {self.error}")
|
|
83
|
+
elif not self.findings:
|
|
84
|
+
lines.append(" no training table found")
|
|
85
|
+
for group in _alike(self.findings):
|
|
86
|
+
first, kind = group[0], "training table" if group[0].companion else "copy"
|
|
87
|
+
rows = " or ".join(sorted({str(f.shape[0]) for f in group}, key=int, reverse=True))
|
|
88
|
+
columns = " or ".join(sorted({str(f.shape[1]) for f in group}, key=int, reverse=True))
|
|
89
|
+
count = f" ({len(group)} alike)" if len(group) > 1 else ""
|
|
90
|
+
path = _pattern(first.path) if len(group) > 1 else first.path
|
|
91
|
+
lines.append(f" {kind} {path} {rows} rows x {columns} columns{count}")
|
|
92
|
+
evidence = [_pattern(reason) if len(group) > 1 else reason for reason in first.evidence]
|
|
93
|
+
lines.extend(f" why: {reason}" for reason in evidence)
|
|
94
|
+
if first.hint:
|
|
95
|
+
lines.append(f" fix: {first.hint}")
|
|
96
|
+
return "\n".join(lines)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
_INDEX = re.compile(r"\[\d+\]")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _pattern(text: str) -> str:
|
|
103
|
+
return _INDEX.sub("[*]", text)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _alike(findings: tuple[Finding, ...]) -> list[list[Finding]]:
|
|
107
|
+
groups: dict[tuple[str, bool, str | None], list[Finding]] = {}
|
|
108
|
+
for finding in findings:
|
|
109
|
+
key = (_pattern(finding.path), finding.companion is None, finding.hint)
|
|
110
|
+
groups.setdefault(key, []).append(finding)
|
|
111
|
+
return list(groups.values())
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def scan_file(path: str | Path, min_rows: int = 20) -> Report:
|
|
115
|
+
"""Scans one model file without running it; a file it cannot read gives a Report whose `error` says why."""
|
|
116
|
+
try:
|
|
117
|
+
file_format, members = read_members(path)
|
|
118
|
+
except (UnreadableFileError, OSError) as error:
|
|
119
|
+
return Report(str(path), "unknown", (), str(error))
|
|
120
|
+
findings = tuple(
|
|
121
|
+
replace(finding, hint=_hint(finding.owner))
|
|
122
|
+
for member, tree in members
|
|
123
|
+
for finding in find_tables(tree, min_rows=min_rows, prefix=f"{member}:" if member else "")
|
|
124
|
+
)
|
|
125
|
+
return Report(str(path), file_format, findings)
|
tabcustody/_reverse.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Reverses scikit-learn scalers from their stored arrays alone, without importing scikit-learn."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
from tabcustody._reader import Shell
|
|
10
|
+
|
|
11
|
+
SCALERS = ("StandardScaler", "MinMaxScaler", "RobustScaler")
|
|
12
|
+
TOLERANCE = 1e-6
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def scaler_kind(node: Any) -> str | None:
|
|
16
|
+
"""Returns the scaler class name when `node` is the Shell of a scikit-learn scaler this module can reverse."""
|
|
17
|
+
if not isinstance(node, Shell) or not node.name.startswith("sklearn.preprocessing"):
|
|
18
|
+
return None
|
|
19
|
+
kind = node.name.rsplit(".", 1)[-1]
|
|
20
|
+
return kind if kind in SCALERS else None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _vector(scaler: Shell, name: str, width: int, default: float) -> np.ndarray:
|
|
24
|
+
value = scaler.attributes.get(name)
|
|
25
|
+
return np.full(width, default) if value is None else np.asarray(value, dtype=float)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _width(scaler: Shell) -> int | None:
|
|
29
|
+
scale = scaler.attributes.get("scale_")
|
|
30
|
+
return None if scale is None else int(np.asarray(scale).shape[0])
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def reverse(table: np.ndarray, scaler: Shell) -> np.ndarray:
|
|
34
|
+
"""Returns the rows `scaler` was given before it produced `table`; raises ValueError for a scaler it cannot reverse."""
|
|
35
|
+
kind, width = scaler_kind(scaler), table.shape[1]
|
|
36
|
+
scale = _vector(scaler, "scale_", width, 1.0)
|
|
37
|
+
if kind == "StandardScaler":
|
|
38
|
+
return np.asarray(table * scale + _vector(scaler, "mean_", width, 0.0), dtype=float)
|
|
39
|
+
if kind == "MinMaxScaler":
|
|
40
|
+
return np.asarray((table - _vector(scaler, "min_", width, 0.0)) / scale, dtype=float)
|
|
41
|
+
if kind == "RobustScaler":
|
|
42
|
+
return np.asarray(table * scale + _vector(scaler, "center_", width, 0.0), dtype=float)
|
|
43
|
+
raise ValueError(f"no reversal for {scaler.name}")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def produced(table: np.ndarray, scaler: Shell) -> bool:
|
|
47
|
+
"""Tells whether `table` carries the fingerprint `scaler` leaves on the very rows it was fitted on."""
|
|
48
|
+
kind = scaler_kind(scaler)
|
|
49
|
+
if kind is None or _width(scaler) != table.shape[1] or table.shape[0] < 2:
|
|
50
|
+
return False
|
|
51
|
+
varying = np.ptp(table, axis=0) > TOLERANCE
|
|
52
|
+
if not varying.any():
|
|
53
|
+
return False
|
|
54
|
+
columns = table[:, varying]
|
|
55
|
+
if kind == "StandardScaler":
|
|
56
|
+
centred = scaler.attributes.get("mean_") is None or np.allclose(
|
|
57
|
+
columns.mean(axis=0), 0, atol=TOLERANCE
|
|
58
|
+
)
|
|
59
|
+
return centred and np.allclose(columns.std(axis=0), 1, atol=TOLERANCE)
|
|
60
|
+
if kind == "MinMaxScaler":
|
|
61
|
+
low, high = scaler.attributes.get("feature_range", (0, 1))
|
|
62
|
+
return np.allclose(columns.min(axis=0), low, atol=TOLERANCE) and np.allclose(
|
|
63
|
+
columns.max(axis=0), high, atol=TOLERANCE
|
|
64
|
+
)
|
|
65
|
+
centred = scaler.attributes.get("center_") is None or np.allclose(
|
|
66
|
+
np.median(columns, axis=0), 0, atol=TOLERANCE
|
|
67
|
+
)
|
|
68
|
+
if scaler.attributes.get("scale_") is None:
|
|
69
|
+
return centred
|
|
70
|
+
low, high = scaler.attributes.get("quantile_range", (25.0, 75.0))
|
|
71
|
+
spread = np.percentile(columns, high, axis=0) - np.percentile(columns, low, axis=0)
|
|
72
|
+
return centred and np.allclose(spread, 1, atol=TOLERANCE)
|
tabcustody/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: tabcustody
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: With in-context tabular models (TabPFN, TabICL, TabDPT), the saved model file is your training data. tabcustody detects it.
|
|
5
|
+
Project-URL: Homepage, https://github.com/jrachid/tabcustody
|
|
6
|
+
Project-URL: Repository, https://github.com/jrachid/tabcustody
|
|
7
|
+
Project-URL: Issues, https://github.com/jrachid/tabcustody/issues
|
|
8
|
+
Author: Rachid Jeffali
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: data-leakage,gdpr,in-context-learning,mlops,pickle,privacy,tabdpt,tabicl,tabpfn,tabular-foundation-models
|
|
12
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: Science/Research
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
19
|
+
Classifier: Topic :: Security
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.11
|
|
22
|
+
Requires-Dist: numpy>=1.24
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# tabcustody
|
|
26
|
+
|
|
27
|
+
> With in-context tabular models, the model file **is** your training data. tabcustody proves it on your files, then lets you deploy, erase and trace without exposing that data.
|
|
28
|
+
|
|
29
|
+
**Status: pre-alpha.** Version 0.1 ships the scanner: it tells whether a model file carries its training data, without running the file. Separating, erasing and tracing come next.
|
|
30
|
+
|
|
31
|
+
## Quick start
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install tabcustody
|
|
35
|
+
tabcustody scan model.pkl other.joblib fitted.tabpfn_fit
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
The command reads pickle files, joblib files (plain or compressed with zlib, gzip, bz2 or xz) and zip archives of them such as TabPFN's `.tabpfn_fit`. It exits with 1 when a file carries training data, 2 when a file cannot be read, 0 otherwise, so it can gate a CI job; `--json` prints one report per file.
|
|
39
|
+
|
|
40
|
+
From Python, with scikit-learn installed:
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
import pickle
|
|
44
|
+
|
|
45
|
+
from sklearn.datasets import make_classification
|
|
46
|
+
from sklearn.neighbors import KNeighborsClassifier
|
|
47
|
+
|
|
48
|
+
from tabcustody import scan_file
|
|
49
|
+
|
|
50
|
+
X, y = make_classification(n_samples=200, n_features=4, random_state=0)
|
|
51
|
+
with open("model.pkl", "wb") as file:
|
|
52
|
+
pickle.dump(KNeighborsClassifier().fit(X, y), file)
|
|
53
|
+
|
|
54
|
+
report = scan_file("model.pkl")
|
|
55
|
+
print(report.to_text())
|
|
56
|
+
assert report.carries_training_data
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
model.pkl (pickle)
|
|
61
|
+
training table _fit_X 200 rows x 4 columns
|
|
62
|
+
why: the 1-D array _y has one entry per row
|
|
63
|
+
why: _fit_X is a name libraries use for training rows
|
|
64
|
+
fix: An instance-based model keeps training rows by design: keep this file as confidential as the data it was fitted on.
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
On a TabPFN archive the report names the rows of every ensemble member, and on a TabICL file it names the save option that leaves them out:
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
fitted.tabpfn_fit (zip)
|
|
71
|
+
training table executor_state.joblib:ensemble_members[*].X_train 300 rows x 10 or 4 columns (8 alike)
|
|
72
|
+
why: the 1-D array executor_state.joblib:ensemble_members[*].y_train has one entry per row
|
|
73
|
+
why: X_train is a name libraries use for training rows
|
|
74
|
+
fix: TabPFN keeps the rows in its default fit mode. Releases that include PriorLabs/TabPFN pull request 1323 drop them once the caches are built when the model is fitted with fit_mode='fit_with_cache'; otherwise keep this file as confidential as the data it was fitted on.
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
The scanner never unpickles the file the usual way: only NumPy arrays and plain containers are rebuilt, every other class becomes an inert placeholder, and no library the file names is imported. The report gives paths, shapes and evidence, never a value of the table.
|
|
78
|
+
|
|
79
|
+
## The problem, measured
|
|
80
|
+
|
|
81
|
+
A classic model such as XGBoost learns rules from its training data, then forgets the data: the saved file holds split thresholds, nothing else. The new tabular foundation models — TabPFN, TabICL, TabDPT — work differently. They predict by reading the training table again at every prediction, the way a language model reads its prompt. So a fitted model keeps the table, and saving the model saves the table.
|
|
82
|
+
|
|
83
|
+
The same 300 synthetic customers, each model fitted then saved with `pickle`, and only the saved file inspected afterwards:
|
|
84
|
+
|
|
85
|
+
| Model | Saved file | Incomes found verbatim | Incomes rebuilt from the file alone |
|
|
86
|
+
|---|---|---|---|
|
|
87
|
+
| XGBoost 3.4.1 (control) | 0.1 MB | 0 / 300 | 0 / 300 |
|
|
88
|
+
| TabICL 2.2.0 | < 0.1 MB | 300 / 300 | 300 / 300 |
|
|
89
|
+
| TabPFN v2 (tabpfn 9.0.0) | 29.2 MB | 300 / 300 | 300 / 300 |
|
|
90
|
+
| TabPFN v3.5, the default weights (tabpfn 9.0.0) | 876.3 MB | 300 / 300 | 300 / 300 |
|
|
91
|
+
| TabDPT 1.3.1 | 252.5 MB | 11 / 300 | 300 / 300 |
|
|
92
|
+
|
|
93
|
+
Measured on 28 September 2026 by [`comparisons/leak.py`](comparisons/leak.py), which anyone can rerun with the pinned versions of [`comparisons/pyproject.toml`](comparisons/pyproject.toml).
|
|
94
|
+
|
|
95
|
+
TabDPT is the instructive row. It stores the table standardised — an income of 38,693.02 becomes -0.058 — so searching the file for known values finds almost nothing, and a manual check concludes the file is clean. But the scaler that standardised the table is saved in the same file, and calling it in reverse rebuilds every income to the cent.
|
|
96
|
+
|
|
97
|
+
Nothing here is a bug in these libraries: keeping the table is how in-context learning works. The risk is in the habits around model files. Teams have treated them as harmless build artefacts for a decade — committed to Git, copied to buckets, sent to vendors — and with these models each of those copies is a copy of the customer table.
|
|
98
|
+
|
|
99
|
+
## What the libraries' own saves keep
|
|
100
|
+
|
|
101
|
+
The table above uses `pickle`. Each library's own save function tells the same story by default, and one of them offers a way out:
|
|
102
|
+
|
|
103
|
+
| Save | Incomes found verbatim | Largest prediction gap after reload |
|
|
104
|
+
|---|---|---|
|
|
105
|
+
| TabPFN v2 `save_fit_state` (tabpfn 9.0.0) | 300 / 300 | n/a |
|
|
106
|
+
| TabICL 2.2.0 `save(save_training_data=True)`, `kv_cache=False` | 300 / 300 | 0 |
|
|
107
|
+
| TabICL 2.2.0 `save(save_training_data=False)`, `kv_cache=True` | 4 / 300 | 0 |
|
|
108
|
+
|
|
109
|
+
Measured by [`comparisons/builtin_saves.py`](comparisons/builtin_saves.py).
|
|
110
|
+
|
|
111
|
+
TabICL's documentation presents `save_training_data=False` as giving "better data privacy": the file keeps the model's cached key-value projections of the table instead of the table, and predictions after reload are unchanged. It works only if the model was fitted with `kv_cache=True`, it is off by default, 4 incomes still appear verbatim, and whether the table can be rebuilt from the cache has not been measured here. TabPFN's `.tabpfn_fit` archive leaves out the foundation model's weights but keeps the table, and TabDPT offers no option to drop it. TabPFN is about to follow TabICL: [pull request 1323](https://github.com/PriorLabs/TabPFN/pull/1323), merged on 28 September 2026 for memory reasons and not yet released, drops each ensemble member's table once its cache is built in `fit_mode="fit_with_cache"`. Measured on TabPFN's main branch at commit `dbba40314b`, with TabPFN v2 weights: the default mode keeps 300 of 300 incomes in the `.tabpfn_fit` archive, `fit_with_cache` keeps 4, and predictions after reload are unchanged in both.
|
|
112
|
+
|
|
113
|
+
## What existing tools see
|
|
114
|
+
|
|
115
|
+
The closest prior work is [SACRO-ML](https://github.com/AI-SDC/SACRO-ML), which checks models leaving trusted research environments. Its instance-based attack detects models that store their training rows — SVMs and k-nearest-neighbours, the classic cases — by comparing the stored instances with the training data you give it. On its own ground it works, and on anything else it reports no risk:
|
|
116
|
+
|
|
117
|
+
| Model | SACRO-ML 2.0.1 verdict | Rows it matched |
|
|
118
|
+
|---|---|---|
|
|
119
|
+
| KNeighborsClassifier (scikit-learn 1.9.1) | leakage confirmed | 300 / 300 |
|
|
120
|
+
| TabICL 2.2.0 | not instance-based, no data leakage risk | 0 / 300 |
|
|
121
|
+
|
|
122
|
+
Same 300 customers as above, measured by [`comparisons/sacroml_check.py`](comparisons/sacroml_check.py). The TabICL file it clears is one from which every income is rebuilt.
|
|
123
|
+
|
|
124
|
+
Other tools look at model files for a different reason, or skip them:
|
|
125
|
+
|
|
126
|
+
- **Pickle security scanners** — [fickling](https://github.com/trailofbits/fickling), [picklescan](https://github.com/mmaitre314/picklescan), [modelscan](https://github.com/protectai/modelscan), the [Hugging Face pickle scan](https://huggingface.co/docs/hub/security-pickle) — look for code that runs when the file loads. Arrays are data, so they are not their concern.
|
|
127
|
+
- **Model auditors** — [ModelAudit](https://github.com/promptfoo/modelaudit) also searches weights for embedded credentials such as API keys, not for rows of a dataset.
|
|
128
|
+
- **Cloud data-loss prevention** — [Amazon Macie](https://docs.aws.amazon.com/macie/latest/user/discovery-supported-storage.html) classifies pickle and NumPy files as objects it cannot analyse, so personal data inside them goes unreported.
|
|
129
|
+
|
|
130
|
+
tabcustody discovers the table without being handed the training data, covers the in-context tabular models, and tells when values stored behind a scaler can be rebuilt, as with TabDPT.
|
|
131
|
+
|
|
132
|
+
## Guarantees and the tests that prove them
|
|
133
|
+
|
|
134
|
+
| Guarantee | Test |
|
|
135
|
+
|---|---|
|
|
136
|
+
| Reading a file runs none of its code, joblib object arrays included | `test_safety.py::test_a_pickle_that_runs_a_command_on_load_runs_nothing`, `test_formats.py::test_an_object_array_inside_a_joblib_file_is_read_without_running_it` |
|
|
137
|
+
| Scanning imports no model library | `test_safety.py::test_scanning_imports_neither_torch_nor_xgboost` |
|
|
138
|
+
| The training table of TabICL, TabPFN v2, TabPFN v3.5 and TabDPT is found | `test_detection.py::test_the_training_table_of_each_in_context_model_is_found` (TabPFN and TabDPT files are too large to version: marked `integration`), `test_formats.py::test_a_tabpfn_fit_archive_is_scanned_member_by_member` |
|
|
139
|
+
| Instance-based models (k-nearest-neighbours, SVM) are reported | `test_detection.py::test_instance_based_models_are_reported_like_sacroml_does` |
|
|
140
|
+
| No finding on XGBoost, LightGBM, logistic regression, random forest, gradient boosting, a 100×100 MLP or a QuantileTransformer pipeline | `test_detection.py::test_classic_models_report_no_table`, `::test_a_square_weight_matrix_next_to_its_bias_is_not_a_table`, `::test_a_quantile_grid_beside_its_levels_is_not_a_table` |
|
|
141
|
+
| A table kept as a pandas DataFrame, pickled by pandas 2 or 3, is found and rebuilt in its column order | `test_detection.py::test_a_table_kept_as_a_pandas_dataframe_is_found`, `::test_a_dataframe_is_rebuilt_column_by_column_in_its_own_order` |
|
|
142
|
+
| A table stored standardised is declared reversible, and reversing gives the exact rows | `test_reversal.py::test_a_standardised_table_is_rebuilt_from_the_scaler_saved_beside_it`, `::test_a_scaled_pipeline_table_is_rebuilt_from_the_scaler_step` |
|
|
143
|
+
| A scaler that does not feed the table is never credited | `test_reversal.py::test_a_scaler_nested_inside_another_transformer_is_not_credited`, `::test_a_table_stored_as_is_is_not_said_to_be_reversible` |
|
|
144
|
+
| The report never prints a value of the table | `test_report.py::test_the_report_never_prints_a_value_of_the_table` |
|
|
145
|
+
| joblib files, compressed or not, read like pickles | `test_formats.py::test_a_joblib_file_is_scanned_like_a_pickle`, `::test_compressed_joblib_files_are_read` |
|
|
146
|
+
| The command exits 1 on a file that carries training data | `test_cli.py::test_the_command_exits_non_zero_when_a_table_is_found` |
|
|
147
|
+
|
|
148
|
+
## Limits
|
|
149
|
+
|
|
150
|
+
- **A table is recognised by its target.** The scanner looks for a 2-D numeric array beside a 1-D array with one entry per row. Rows kept without a target, or tables under 20 rows (`--min-rows`), are not reported.
|
|
151
|
+
- **A one-column table stored in sorted order** is taken for a lookup grid, such as QuantileTransformer's quantiles, and not reported.
|
|
152
|
+
- **Only numeric columns are read.** Text and category columns of a DataFrame stay in the file but are not part of the reported shape.
|
|
153
|
+
- **Reversal covers scikit-learn's StandardScaler, MinMaxScaler and RobustScaler**, when the scaler sits beside the table or is the pipeline step right before it. Tables behind other transforms are still reported; they are not called reversible.
|
|
154
|
+
- **Formats.** Not read: lz4-compressed joblib, safetensors, ONNX, `torch.save` archives. PyTorch tensors inside a pickle stay opaque placeholders.
|
|
155
|
+
- **Memory.** The whole object tree is loaded: scanning the 836 MB TabPFN v3.5 pickle peaks at about the size of the file.
|
|
156
|
+
|
|
157
|
+
## What comes next
|
|
158
|
+
|
|
159
|
+
1. **Detect** — shipped in 0.1.
|
|
160
|
+
2. **Separate.** Save the model without its table, and supply the table at prediction time from a place you control.
|
|
161
|
+
3. **Erase.** Find a person's rows, remove them, refit, and keep a record that proves it — an erasure request under GDPR article 17 becomes a routine operation with these models, where classic models would need retraining.
|
|
162
|
+
4. **Trace and verify.** Know which exact table was in place for any past prediction, and check that production predicts like development.
|
|
163
|
+
|
|
164
|
+
Each step ships on its own, with its guarantees backed by named tests.
|
|
165
|
+
|
|
166
|
+
## What tabcustody does not claim
|
|
167
|
+
|
|
168
|
+
- **Leakage through predictions.** Whether an attacker can infer training rows from a model's answers is a research question, studied in [arXiv 2606.26021](https://arxiv.org/abs/2606.26021) and [arXiv 2606.31474](https://arxiv.org/abs/2606.31474). tabcustody is about the far plainer leak above: the rows are in the file.
|
|
169
|
+
- **That users are already reporting this.** As of 28 September 2026 no public issue describes it. These models are young; tabcustody is built ahead of the moment teams put them in production at scale.
|
|
170
|
+
- **Models not in the table.** TabPFN 2.5, 2.6 and 3, and AutoGluon's Mitra, are not measured yet. They join the table when `comparisons/leak.py` has run on them.
|
|
171
|
+
|
|
172
|
+
## License
|
|
173
|
+
|
|
174
|
+
MIT
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
tabcustody/__init__.py,sha256=m7ZB6xX0mCoRmM2fopiE5oqDo3506yUozcNd0_JrcLo,246
|
|
2
|
+
tabcustody/__main__.py,sha256=LnPOD1Is8i6ksVrXIgPiV-KY7d1CHqKL4pg8Kcqx_PA,63
|
|
3
|
+
tabcustody/_cli.py,sha256=_sSL7-epC8p-EVNs0v0Mbku_cukh6RWLC-R5Xcob3kc,1690
|
|
4
|
+
tabcustody/_detect.py,sha256=fX59ha0HsiRMy9ORdhfnmizeu_NNQjXvB9rucVPi1w8,8936
|
|
5
|
+
tabcustody/_reader.py,sha256=56GOZcP5zZrs6mmCFoUxk5mq-scsZEl4QSlFjJ6bPSs,9838
|
|
6
|
+
tabcustody/_report.py,sha256=3wK5cnUhsVQg8vUtJ0MalnBnA7ZvUcZhFfQY8pl5Mpc,4765
|
|
7
|
+
tabcustody/_reverse.py,sha256=8O1G8kyV47kLYbQkAaC3OR_BJCVxFSRNxMh9diAaU4k,3081
|
|
8
|
+
tabcustody/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
tabcustody-0.1.0.dist-info/METADATA,sha256=37z6BVOB34QdhZbSUl49CiWSiddhThyNICEfHGEkI3A,13639
|
|
10
|
+
tabcustody-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
11
|
+
tabcustody-0.1.0.dist-info/entry_points.txt,sha256=sTjk7jJLXuuSm3lnnsasT0CW0ZpWPJiC7URgUcPBucY,52
|
|
12
|
+
tabcustody-0.1.0.dist-info/licenses/LICENSE,sha256=eCHmfhsEWXn7Hlw1UZuEJpp7JTf6UTYRnl1aqZVW_g0,1071
|
|
13
|
+
tabcustody-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Rachid Jeffali
|
|
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.
|