tabcustody 0.1.0__tar.gz

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.
Files changed (52) hide show
  1. tabcustody-0.1.0/.github/workflows/ci.yml +52 -0
  2. tabcustody-0.1.0/.github/workflows/release.yml +40 -0
  3. tabcustody-0.1.0/.gitignore +23 -0
  4. tabcustody-0.1.0/.python-version +1 -0
  5. tabcustody-0.1.0/LICENSE +21 -0
  6. tabcustody-0.1.0/PKG-INFO +174 -0
  7. tabcustody-0.1.0/README.md +150 -0
  8. tabcustody-0.1.0/ci/run_readme.py +12 -0
  9. tabcustody-0.1.0/comparisons/builtin_saves.py +45 -0
  10. tabcustody-0.1.0/comparisons/leak.py +108 -0
  11. tabcustody-0.1.0/comparisons/pyproject.toml +20 -0
  12. tabcustody-0.1.0/comparisons/sacroml_check.py +34 -0
  13. tabcustody-0.1.0/comparisons/uv.lock +2404 -0
  14. tabcustody-0.1.0/pyproject.toml +65 -0
  15. tabcustody-0.1.0/src/tabcustody/__init__.py +6 -0
  16. tabcustody-0.1.0/src/tabcustody/__main__.py +5 -0
  17. tabcustody-0.1.0/src/tabcustody/_cli.py +43 -0
  18. tabcustody-0.1.0/src/tabcustody/_detect.py +230 -0
  19. tabcustody-0.1.0/src/tabcustody/_reader.py +280 -0
  20. tabcustody-0.1.0/src/tabcustody/_report.py +125 -0
  21. tabcustody-0.1.0/src/tabcustody/_reverse.py +72 -0
  22. tabcustody-0.1.0/src/tabcustody/py.typed +0 -0
  23. tabcustody-0.1.0/tests/fixtures/MANIFEST.json +67 -0
  24. tabcustody-0.1.0/tests/fixtures/build.py +157 -0
  25. tabcustody-0.1.0/tests/fixtures/build_frames.py +25 -0
  26. tabcustody-0.1.0/tests/fixtures/customers.py +14 -0
  27. tabcustody-0.1.0/tests/fixtures/frame-pandas2.pkl +0 -0
  28. tabcustody-0.1.0/tests/fixtures/frame-pandas3.pkl +0 -0
  29. tabcustody-0.1.0/tests/fixtures/gradient-boosting.pkl +0 -0
  30. tabcustody-0.1.0/tests/fixtures/kneighbors.joblib +0 -0
  31. tabcustody-0.1.0/tests/fixtures/kneighbors.pkl +0 -0
  32. tabcustody-0.1.0/tests/fixtures/kneighbors.zlib.joblib +0 -0
  33. tabcustody-0.1.0/tests/fixtures/lightgbm.pkl +0 -0
  34. tabcustody-0.1.0/tests/fixtures/logistic-regression.pkl +0 -0
  35. tabcustody-0.1.0/tests/fixtures/minmax-kneighbors.pkl +0 -0
  36. tabcustody-0.1.0/tests/fixtures/mlp.pkl +0 -0
  37. tabcustody-0.1.0/tests/fixtures/quantile-pipeline.pkl +0 -0
  38. tabcustody-0.1.0/tests/fixtures/random-forest.pkl +0 -0
  39. tabcustody-0.1.0/tests/fixtures/robust-kneighbors.pkl +0 -0
  40. tabcustody-0.1.0/tests/fixtures/standard-kneighbors.pkl +0 -0
  41. tabcustody-0.1.0/tests/fixtures/svc.pkl +0 -0
  42. tabcustody-0.1.0/tests/fixtures/tabicl.pkl +0 -0
  43. tabcustody-0.1.0/tests/fixtures/tabpfn-v2.tabpfn_fit +0 -0
  44. tabcustody-0.1.0/tests/fixtures/xgboost.pkl +0 -0
  45. tabcustody-0.1.0/tests/test_cli.py +43 -0
  46. tabcustody-0.1.0/tests/test_detection.py +118 -0
  47. tabcustody-0.1.0/tests/test_formats.py +85 -0
  48. tabcustody-0.1.0/tests/test_reader.py +73 -0
  49. tabcustody-0.1.0/tests/test_report.py +65 -0
  50. tabcustody-0.1.0/tests/test_reversal.py +75 -0
  51. tabcustody-0.1.0/tests/test_safety.py +56 -0
  52. tabcustody-0.1.0/uv.lock +569 -0
@@ -0,0 +1,52 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ quality:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v7
13
+ - uses: astral-sh/setup-uv@v10.2.0
14
+ - run: uv sync --locked
15
+ - run: uv run ruff check .
16
+ - run: uv run ruff format --check .
17
+ - run: uv run mypy
18
+
19
+ tests:
20
+ runs-on: ubuntu-latest
21
+ strategy:
22
+ fail-fast: false
23
+ matrix:
24
+ python: ["3.11", "3.12", "3.13"]
25
+ steps:
26
+ - uses: actions/checkout@v7
27
+ - uses: astral-sh/setup-uv@v10.2.0
28
+ with:
29
+ python-version: ${{ matrix.python }}
30
+ - run: uv sync --locked
31
+ - run: uv run pytest
32
+
33
+ installed-package:
34
+ runs-on: ubuntu-latest
35
+ steps:
36
+ - uses: actions/checkout@v7
37
+ - uses: astral-sh/setup-uv@v10.2.0
38
+ with:
39
+ python-version: "3.13"
40
+ - run: uv build
41
+ - name: Install the wheel in a fresh environment
42
+ run: |
43
+ uv venv "$RUNNER_TEMP/env"
44
+ uv pip install --python "$RUNNER_TEMP/env" "tabcustody @ file://$(ls "$PWD"/dist/*.whl)" scikit-learn
45
+ - name: Run the README quick start as written
46
+ working-directory: ${{ runner.temp }}
47
+ run: "$RUNNER_TEMP/env/bin/python $GITHUB_WORKSPACE/ci/run_readme.py $GITHUB_WORKSPACE/README.md"
48
+ - name: The command exits 1 on a file with training data and 0 on a clean one
49
+ run: |
50
+ set +e
51
+ "$RUNNER_TEMP/env/bin/tabcustody" scan tests/fixtures/tabicl.pkl; test $? -eq 1 || exit 1
52
+ "$RUNNER_TEMP/env/bin/tabcustody" scan tests/fixtures/xgboost.pkl; test $? -eq 0 || exit 1
@@ -0,0 +1,40 @@
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ tags: ["v*"]
6
+
7
+ jobs:
8
+ pypi:
9
+ runs-on: ubuntu-latest
10
+ environment:
11
+ name: pypi
12
+ url: https://pypi.org/project/tabcustody/
13
+ permissions:
14
+ id-token: write
15
+ steps:
16
+ - uses: actions/checkout@v7
17
+ - uses: astral-sh/setup-uv@v10.2.0
18
+ - run: uv build
19
+ - run: uv publish --trusted-publishing always
20
+
21
+ github-release:
22
+ needs: pypi
23
+ runs-on: ubuntu-latest
24
+ permissions:
25
+ contents: write
26
+ steps:
27
+ - uses: actions/checkout@v7
28
+ with:
29
+ fetch-depth: 0
30
+ - name: Write the notes from the commits since the previous tag
31
+ run: |
32
+ previous=$(git describe --tags --abbrev=0 "$GITHUB_REF_NAME^" 2>/dev/null || git rev-list --max-parents=0 HEAD)
33
+ {
34
+ git log --no-merges --format='- %s' "$previous..$GITHUB_REF_NAME"
35
+ echo
36
+ echo "**Full Changelog**: https://github.com/$GITHUB_REPOSITORY/compare/$previous...$GITHUB_REF_NAME"
37
+ } > notes.md
38
+ - run: gh release create "$GITHUB_REF_NAME" --title "${GITHUB_REF_NAME#v}" --notes-file notes.md --verify-tag
39
+ env:
40
+ GH_TOKEN: ${{ github.token }}
@@ -0,0 +1,23 @@
1
+ # planning artifacts (local only)
2
+ ai/
3
+
4
+ # python
5
+ __pycache__/
6
+ *.py[cod]
7
+ *.egg-info/
8
+ .venv/
9
+ dist/
10
+ .pytest_cache/
11
+ .ruff_cache/
12
+
13
+ # agent instructions (local only)
14
+ CLAUDE.md
15
+
16
+ # ephemeral viz pages
17
+ .tmp/
18
+
19
+ # reference files too large to version (integration suite)
20
+ tests/fixtures/large/
21
+
22
+ # macOS
23
+ .DS_Store
@@ -0,0 +1 @@
1
+ 3.12
@@ -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.
@@ -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,150 @@
1
+ # tabcustody
2
+
3
+ > 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.
4
+
5
+ **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.
6
+
7
+ ## Quick start
8
+
9
+ ```bash
10
+ pip install tabcustody
11
+ tabcustody scan model.pkl other.joblib fitted.tabpfn_fit
12
+ ```
13
+
14
+ 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.
15
+
16
+ From Python, with scikit-learn installed:
17
+
18
+ ```python
19
+ import pickle
20
+
21
+ from sklearn.datasets import make_classification
22
+ from sklearn.neighbors import KNeighborsClassifier
23
+
24
+ from tabcustody import scan_file
25
+
26
+ X, y = make_classification(n_samples=200, n_features=4, random_state=0)
27
+ with open("model.pkl", "wb") as file:
28
+ pickle.dump(KNeighborsClassifier().fit(X, y), file)
29
+
30
+ report = scan_file("model.pkl")
31
+ print(report.to_text())
32
+ assert report.carries_training_data
33
+ ```
34
+
35
+ ```
36
+ model.pkl (pickle)
37
+ training table _fit_X 200 rows x 4 columns
38
+ why: the 1-D array _y has one entry per row
39
+ why: _fit_X is a name libraries use for training rows
40
+ fix: An instance-based model keeps training rows by design: keep this file as confidential as the data it was fitted on.
41
+ ```
42
+
43
+ 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:
44
+
45
+ ```
46
+ fitted.tabpfn_fit (zip)
47
+ training table executor_state.joblib:ensemble_members[*].X_train 300 rows x 10 or 4 columns (8 alike)
48
+ why: the 1-D array executor_state.joblib:ensemble_members[*].y_train has one entry per row
49
+ why: X_train is a name libraries use for training rows
50
+ 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.
51
+ ```
52
+
53
+ 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.
54
+
55
+ ## The problem, measured
56
+
57
+ 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.
58
+
59
+ The same 300 synthetic customers, each model fitted then saved with `pickle`, and only the saved file inspected afterwards:
60
+
61
+ | Model | Saved file | Incomes found verbatim | Incomes rebuilt from the file alone |
62
+ |---|---|---|---|
63
+ | XGBoost 3.4.1 (control) | 0.1 MB | 0 / 300 | 0 / 300 |
64
+ | TabICL 2.2.0 | < 0.1 MB | 300 / 300 | 300 / 300 |
65
+ | TabPFN v2 (tabpfn 9.0.0) | 29.2 MB | 300 / 300 | 300 / 300 |
66
+ | TabPFN v3.5, the default weights (tabpfn 9.0.0) | 876.3 MB | 300 / 300 | 300 / 300 |
67
+ | TabDPT 1.3.1 | 252.5 MB | 11 / 300 | 300 / 300 |
68
+
69
+ 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).
70
+
71
+ 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.
72
+
73
+ 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.
74
+
75
+ ## What the libraries' own saves keep
76
+
77
+ 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:
78
+
79
+ | Save | Incomes found verbatim | Largest prediction gap after reload |
80
+ |---|---|---|
81
+ | TabPFN v2 `save_fit_state` (tabpfn 9.0.0) | 300 / 300 | n/a |
82
+ | TabICL 2.2.0 `save(save_training_data=True)`, `kv_cache=False` | 300 / 300 | 0 |
83
+ | TabICL 2.2.0 `save(save_training_data=False)`, `kv_cache=True` | 4 / 300 | 0 |
84
+
85
+ Measured by [`comparisons/builtin_saves.py`](comparisons/builtin_saves.py).
86
+
87
+ 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.
88
+
89
+ ## What existing tools see
90
+
91
+ 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:
92
+
93
+ | Model | SACRO-ML 2.0.1 verdict | Rows it matched |
94
+ |---|---|---|
95
+ | KNeighborsClassifier (scikit-learn 1.9.1) | leakage confirmed | 300 / 300 |
96
+ | TabICL 2.2.0 | not instance-based, no data leakage risk | 0 / 300 |
97
+
98
+ 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.
99
+
100
+ Other tools look at model files for a different reason, or skip them:
101
+
102
+ - **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.
103
+ - **Model auditors** — [ModelAudit](https://github.com/promptfoo/modelaudit) also searches weights for embedded credentials such as API keys, not for rows of a dataset.
104
+ - **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.
105
+
106
+ 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.
107
+
108
+ ## Guarantees and the tests that prove them
109
+
110
+ | Guarantee | Test |
111
+ |---|---|
112
+ | 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` |
113
+ | Scanning imports no model library | `test_safety.py::test_scanning_imports_neither_torch_nor_xgboost` |
114
+ | 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` |
115
+ | Instance-based models (k-nearest-neighbours, SVM) are reported | `test_detection.py::test_instance_based_models_are_reported_like_sacroml_does` |
116
+ | 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` |
117
+ | 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` |
118
+ | 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` |
119
+ | 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` |
120
+ | The report never prints a value of the table | `test_report.py::test_the_report_never_prints_a_value_of_the_table` |
121
+ | 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` |
122
+ | 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` |
123
+
124
+ ## Limits
125
+
126
+ - **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.
127
+ - **A one-column table stored in sorted order** is taken for a lookup grid, such as QuantileTransformer's quantiles, and not reported.
128
+ - **Only numeric columns are read.** Text and category columns of a DataFrame stay in the file but are not part of the reported shape.
129
+ - **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.
130
+ - **Formats.** Not read: lz4-compressed joblib, safetensors, ONNX, `torch.save` archives. PyTorch tensors inside a pickle stay opaque placeholders.
131
+ - **Memory.** The whole object tree is loaded: scanning the 836 MB TabPFN v3.5 pickle peaks at about the size of the file.
132
+
133
+ ## What comes next
134
+
135
+ 1. **Detect** — shipped in 0.1.
136
+ 2. **Separate.** Save the model without its table, and supply the table at prediction time from a place you control.
137
+ 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.
138
+ 4. **Trace and verify.** Know which exact table was in place for any past prediction, and check that production predicts like development.
139
+
140
+ Each step ships on its own, with its guarantees backed by named tests.
141
+
142
+ ## What tabcustody does not claim
143
+
144
+ - **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.
145
+ - **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.
146
+ - **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.
147
+
148
+ ## License
149
+
150
+ MIT
@@ -0,0 +1,12 @@
1
+ """Runs the first Python block of the README exactly as written, so the quick start cannot drift from the package."""
2
+
3
+ import re
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ readme = Path(sys.argv[1]).read_text()
8
+ block = re.search(r"```python\n(.*?)```", readme, re.DOTALL)
9
+ if block is None:
10
+ sys.exit("no Python block in the README")
11
+ exec(compile(block.group(1), "README.md", "exec"), {"__name__": "__main__"})
12
+ print("the README quick start ran as written")
@@ -0,0 +1,45 @@
1
+ """Measures what each library's own save function keeps: TabPFN's `.tabpfn_fit` archive and TabICL's `save()` with and without its option.
2
+
3
+ Run with `uv run python builtin_saves.py`; the output is the README's built-in saves table."""
4
+
5
+ import pickle
6
+ import tempfile
7
+ import warnings
8
+ import zipfile
9
+ from importlib.metadata import version
10
+ from pathlib import Path
11
+
12
+ import numpy as np
13
+
14
+ from leak import ROWS, customers, tabpfn_v2_model, target, verbatim
15
+
16
+ warnings.filterwarnings("ignore")
17
+
18
+ workdir = Path(tempfile.mkdtemp())
19
+
20
+
21
+ def tabpfn_fit_archive() -> tuple[str, str, str]:
22
+ path = workdir / "model.tabpfn_fit"
23
+ tabpfn_v2_model().save_fit_state(path)
24
+ with zipfile.ZipFile(path) as archive:
25
+ blob = b"".join(archive.read(name) for name in archive.namelist())
26
+ return f"TabPFN v2 `save_fit_state` (tabpfn {version('tabpfn')})", f"{verbatim(blob)} / {ROWS}", "n/a"
27
+
28
+
29
+ def tabicl_save(kv_cache: bool, keep_data: bool) -> tuple[str, str, str]:
30
+ from tabicl import TabICLClassifier
31
+
32
+ model = TabICLClassifier(device="cpu", kv_cache=kv_cache).fit(customers, target)
33
+ reference = model.predict_proba(customers)
34
+ path = workdir / f"tabicl-{kv_cache}-{keep_data}.pkl"
35
+ model.save(path, save_training_data=keep_data)
36
+ reloaded = pickle.loads(path.read_bytes())
37
+ gap = np.max(np.abs(reloaded.predict_proba(customers) - reference))
38
+ label = f"TabICL {version('tabicl')} `save(save_training_data={keep_data})`, `kv_cache={kv_cache}`"
39
+ return label, f"{verbatim(path.read_bytes())} / {ROWS}", f"{gap:.0e}"
40
+
41
+
42
+ print("| Save | Incomes found verbatim | Largest prediction gap after reload |")
43
+ print("|---|---|---|")
44
+ for row in (tabpfn_fit_archive(), tabicl_save(kv_cache=False, keep_data=True), tabicl_save(kv_cache=True, keep_data=False)):
45
+ print("| " + " | ".join(row) + " |")
@@ -0,0 +1,108 @@
1
+ """Measures, from a pickled model alone, how many of its 300 training incomes are readable or rebuildable.
2
+
3
+ Run with `uv run python leak.py [xgboost tabicl tabpfn-v2 tabpfn tabdpt]`; the output is the README table."""
4
+
5
+ import pickle
6
+ import struct
7
+ import subprocess
8
+ import sys
9
+ import warnings
10
+ from importlib.metadata import version
11
+
12
+ import numpy as np
13
+ import pandas as pd
14
+
15
+ warnings.filterwarnings("ignore")
16
+
17
+ ROWS = 300
18
+ rng = np.random.default_rng(0)
19
+ customers = pd.DataFrame(
20
+ {
21
+ "age": rng.integers(20, 70, ROWS),
22
+ "income": rng.normal(40000, 12000, ROWS).round(2),
23
+ "debt": rng.normal(8000, 3000, ROWS).round(2),
24
+ }
25
+ )
26
+ target = (customers["debt"] / customers["income"] > 0.2).astype(int)
27
+ incomes = customers["income"].to_numpy()
28
+
29
+
30
+ def verbatim(blob: bytes) -> int:
31
+ return sum(blob.count(struct.pack("<d", v)) + blob.count(struct.pack("<f", v)) > 0 for v in incomes)
32
+
33
+
34
+ def rebuilt(restored) -> int | None:
35
+ """Rebuilds incomes from the unpickled object only, never from the original frame."""
36
+ for attr in ("X_train", "X_", "_X_train"):
37
+ stored = getattr(restored, attr, None)
38
+ if stored is None:
39
+ continue
40
+ stored = np.asarray(stored, dtype=float)
41
+ scaler = getattr(restored, "scaler", None)
42
+ if scaler is not None and hasattr(scaler, "inverse_transform"):
43
+ stored = scaler.inverse_transform(stored)
44
+ return int(np.isclose(stored[:, 1], incomes, atol=0.005).sum())
45
+ return None
46
+
47
+
48
+ def report(name: str, model) -> None:
49
+ blob = pickle.dumps(model)
50
+ restored = pickle.loads(blob)
51
+ found = verbatim(blob)
52
+ recovered = rebuilt(restored)
53
+ recovered = found if recovered is None else max(found, recovered)
54
+ print(f"| {name} | {len(blob) / 1e6:.1f} MB | {found} / {ROWS} | {recovered} / {ROWS} |")
55
+
56
+
57
+ def xgboost_model():
58
+ import xgboost
59
+
60
+ return xgboost.XGBClassifier(n_estimators=50, max_depth=3).fit(customers, target)
61
+
62
+
63
+ def tabicl_model():
64
+ from tabicl import TabICLClassifier
65
+
66
+ return TabICLClassifier(device="cpu").fit(customers, target)
67
+
68
+
69
+ def tabpfn_v2_model():
70
+ from tabpfn import TabPFNClassifier
71
+ from tabpfn.constants import ModelVersion
72
+
73
+ return TabPFNClassifier.create_default_for_version(ModelVersion.V2, device="cpu").fit(customers, target)
74
+
75
+
76
+ def tabdpt_model():
77
+ from tabdpt import TabDPTClassifier
78
+
79
+ model = TabDPTClassifier(device="cpu")
80
+ model.fit(customers.to_numpy(float), target.to_numpy())
81
+ return model
82
+
83
+
84
+ def tabpfn_latest_model():
85
+ from tabpfn import TabPFNClassifier
86
+
87
+ return TabPFNClassifier(device="cpu").fit(customers, target)
88
+
89
+
90
+ MODELS = {
91
+ "xgboost": (f"XGBoost {version('xgboost')} (control)", xgboost_model),
92
+ "tabicl": (f"TabICL {version('tabicl')}", tabicl_model),
93
+ "tabpfn-v2": (f"TabPFN v2 (tabpfn {version('tabpfn')})", tabpfn_v2_model),
94
+ "tabpfn": (f"TabPFN v3.5, the default weights (tabpfn {version('tabpfn')})", tabpfn_latest_model),
95
+ "tabdpt": (f"TabDPT {version('tabdpt')}", tabdpt_model),
96
+ }
97
+
98
+ if __name__ == "__main__":
99
+ if len(sys.argv) == 2:
100
+ label, build = MODELS[sys.argv[1]]
101
+ report(label, build())
102
+ sys.exit()
103
+ print("versions:", ", ".join(f"{p} {version(p)}" for p in ("xgboost", "tabicl", "tabpfn", "tabdpt", "torch")))
104
+ print("| Model | Saved file | Incomes found verbatim | Incomes rebuilt from the file alone |")
105
+ print("|---|---|---|---|")
106
+ # One process per model: on macOS, fitting XGBoost and a PyTorch model in the same process segfaults.
107
+ for key in sys.argv[1:] or list(MODELS):
108
+ subprocess.run([sys.executable, __file__, key], check=True)