triplexanalytics 0.2.2__cp39-abi3-win_amd64.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.
triplex/__init__.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Triplex Analytics in plain Python: schema contracts, anonymization and synthetic data.
|
|
2
|
+
|
|
3
|
+
The engine is the same native code the `triplex` command line runs. Everything happens in this
|
|
4
|
+
process, on this machine; nothing is sent anywhere.
|
|
5
|
+
|
|
6
|
+
A table goes in as a file path, a pandas or Polars DataFrame, or anything else Arrow can read.
|
|
7
|
+
A result table comes back as a `Table`, which converts to whichever of those you use.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import io
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
|
|
15
|
+
from . import _native
|
|
16
|
+
from ._native import TriplexError
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"Result",
|
|
20
|
+
"Table",
|
|
21
|
+
"TriplexError",
|
|
22
|
+
"anonymize",
|
|
23
|
+
"convert",
|
|
24
|
+
"draft_contract",
|
|
25
|
+
"infer_schema",
|
|
26
|
+
"synthesize",
|
|
27
|
+
"validate",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
# Rows read from a file to infer its schema, as in the command line. A table passed in memory
|
|
31
|
+
# is always read in full.
|
|
32
|
+
SAMPLE_ROWS = 1000
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Table:
|
|
36
|
+
"""A result table, held as Arrow IPC stream bytes until you ask for it in a library's form."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, ipc):
|
|
39
|
+
self.ipc = ipc
|
|
40
|
+
|
|
41
|
+
def to_arrow(self):
|
|
42
|
+
import pyarrow as pa
|
|
43
|
+
|
|
44
|
+
return pa.ipc.open_stream(self.ipc).read_all()
|
|
45
|
+
|
|
46
|
+
def to_pandas(self):
|
|
47
|
+
return self.to_arrow().to_pandas()
|
|
48
|
+
|
|
49
|
+
def to_polars(self):
|
|
50
|
+
import polars as pl
|
|
51
|
+
|
|
52
|
+
return pl.read_ipc_stream(io.BytesIO(self.ipc))
|
|
53
|
+
|
|
54
|
+
def __arrow_c_stream__(self, requested_schema=None):
|
|
55
|
+
return self.to_arrow().__arrow_c_stream__(requested_schema)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class Result:
|
|
60
|
+
"""What an anonymize or synthesize run produced: the table and the run's report."""
|
|
61
|
+
|
|
62
|
+
table: Table
|
|
63
|
+
report: dict
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _input(data):
|
|
67
|
+
"""`(path, ipc)` for the engine: a path stays a path, a table becomes Arrow IPC bytes."""
|
|
68
|
+
if isinstance(data, (str, os.PathLike)):
|
|
69
|
+
return os.fspath(data), None
|
|
70
|
+
if isinstance(data, Table):
|
|
71
|
+
return None, data.ipc
|
|
72
|
+
if hasattr(data, "write_ipc_stream"): # Polars, which needs no pyarrow for this
|
|
73
|
+
buffer = io.BytesIO()
|
|
74
|
+
data.write_ipc_stream(buffer)
|
|
75
|
+
return None, buffer.getvalue()
|
|
76
|
+
|
|
77
|
+
import pyarrow as pa
|
|
78
|
+
|
|
79
|
+
if type(data).__module__.partition(".")[0] == "pandas":
|
|
80
|
+
# The row index is not a column of the dataset.
|
|
81
|
+
table = pa.Table.from_pandas(data, preserve_index=False)
|
|
82
|
+
else:
|
|
83
|
+
table = pa.table(data)
|
|
84
|
+
sink = pa.BufferOutputStream()
|
|
85
|
+
with pa.ipc.new_stream(sink, table.schema) as writer:
|
|
86
|
+
writer.write_table(table)
|
|
87
|
+
return None, sink.getvalue().to_pybytes()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def infer_schema(data, *, sample_rows=SAMPLE_ROWS, delimiter=None):
|
|
91
|
+
"""Infer the schema of `data` and return it as a dict (what `triplex schema` prints).
|
|
92
|
+
|
|
93
|
+
`delimiter` is the field separator of a `.csv` path that does not use a comma.
|
|
94
|
+
"""
|
|
95
|
+
path, ipc = _input(data)
|
|
96
|
+
return json.loads(_native.infer_schema(path, ipc, sample_rows, delimiter))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def draft_contract(data, *, output=None, sample_rows=SAMPLE_ROWS, delimiter=None):
|
|
100
|
+
"""A starting contract for `data`: every column with the type found in it, all required.
|
|
101
|
+
|
|
102
|
+
Returns the contract as a dict, which `validate` accepts. `output` also writes it as a
|
|
103
|
+
`.triplex.yaml` file. A draft only describes the table it was made from: add the limits you
|
|
104
|
+
expect (null rate, value range, mean, pattern) before you rely on it.
|
|
105
|
+
"""
|
|
106
|
+
path, ipc = _input(data)
|
|
107
|
+
contract, yaml = _native.draft_contract(path, ipc, sample_rows, delimiter)
|
|
108
|
+
if output is not None:
|
|
109
|
+
with open(output, "w", encoding="utf-8") as file:
|
|
110
|
+
file.write(yaml)
|
|
111
|
+
return json.loads(contract)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def validate(data, contract, *, sample_rows=SAMPLE_ROWS, delimiter=None):
|
|
115
|
+
"""Check `data` against `contract`: a `.triplex.yaml` path, or a contract as a dict.
|
|
116
|
+
|
|
117
|
+
A dict is what `draft_contract` returns, or the same shape built or edited in Python.
|
|
118
|
+
Returns the validation report: `{"valid": bool, "errors": [...]}`.
|
|
119
|
+
"""
|
|
120
|
+
if isinstance(contract, dict):
|
|
121
|
+
if any("inferred_type" in column for column in contract.get("columns", ())):
|
|
122
|
+
# The two dicts look alike, but an inferred schema describes one table as it is and
|
|
123
|
+
# sets no expectation to check against.
|
|
124
|
+
raise TypeError(
|
|
125
|
+
"this is an inferred schema, not a contract; "
|
|
126
|
+
"pass triplex.draft_contract(data) to check against the same columns and types"
|
|
127
|
+
)
|
|
128
|
+
contract_path, contract_text = None, json.dumps(contract)
|
|
129
|
+
else:
|
|
130
|
+
contract_path, contract_text = os.fspath(contract), None
|
|
131
|
+
path, ipc = _input(data)
|
|
132
|
+
return json.loads(
|
|
133
|
+
_native.validate(contract_path, contract_text, path, ipc, sample_rows, delimiter)
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def convert(input, output, *, input_delimiter=None, output_delimiter=None):
|
|
138
|
+
"""Convert the file at `input` to the format of `output`'s extension. Returns the report."""
|
|
139
|
+
return json.loads(_native.convert(input, output, input_delimiter, output_delimiter))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def anonymize(
|
|
143
|
+
data, recipe, *, mapping_out, output=None, mapping_passphrase_env=None, exclude_rows=()
|
|
144
|
+
):
|
|
145
|
+
"""Mask the columns the `.triplex.yaml` recipe at `recipe` declares.
|
|
146
|
+
|
|
147
|
+
Each salt is read from the environment variable its recipe column names. The mapping file,
|
|
148
|
+
which can reverse the result, is written to `mapping_out` and is not returned; name an
|
|
149
|
+
environment variable in `mapping_passphrase_env` to have it encrypted. `output` also writes
|
|
150
|
+
the masked table as CSV. `exclude_rows` are 0-based rows to leave out.
|
|
151
|
+
|
|
152
|
+
The result is pseudonymized, not anonymous: see `report["warning"]`.
|
|
153
|
+
"""
|
|
154
|
+
path, ipc = _input(data)
|
|
155
|
+
table, report = _native.anonymize(
|
|
156
|
+
recipe, mapping_out, path, ipc, output, mapping_passphrase_env, set(exclude_rows)
|
|
157
|
+
)
|
|
158
|
+
return Result(Table(table), json.loads(report))
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def synthesize(data, *, rows=None, seed=42, min_category_count=5, method="copula", output=None):
|
|
162
|
+
"""Generate a synthetic table shaped like `data`.
|
|
163
|
+
|
|
164
|
+
`method` is `"copula"`, `"bayes-net"` or `"marginals"`. None of the three carries a formal
|
|
165
|
+
privacy guarantee: read `report["privacy_statement"]` and the leakage figures in the report.
|
|
166
|
+
`rows` defaults to as many as the input has. `output` also writes the table as CSV.
|
|
167
|
+
"""
|
|
168
|
+
path, ipc = _input(data)
|
|
169
|
+
table, report = _native.synthesize(path, ipc, output, rows, seed, min_category_count, method)
|
|
170
|
+
return Result(Table(table), json.loads(report))
|
triplex/_native.pyd
ADDED
|
Binary file
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: triplexanalytics
|
|
3
|
+
Version: 0.2.2
|
|
4
|
+
Classifier: Development Status :: 3 - Alpha
|
|
5
|
+
Classifier: Intended Audience :: Developers
|
|
6
|
+
Classifier: Intended Audience :: Science/Research
|
|
7
|
+
Classifier: Operating System :: OS Independent
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Rust
|
|
10
|
+
Classifier: Topic :: Scientific/Engineering :: Information Analysis
|
|
11
|
+
Classifier: Topic :: Security
|
|
12
|
+
Requires-Dist: pytest ; extra == 'dev'
|
|
13
|
+
Requires-Dist: pyarrow ; extra == 'dev'
|
|
14
|
+
Requires-Dist: pandas ; extra == 'dev'
|
|
15
|
+
Requires-Dist: polars ; extra == 'dev'
|
|
16
|
+
Provides-Extra: dev
|
|
17
|
+
Summary: Triplex Analytics in plain Python: schema contracts, anonymization and synthetic data, run locally.
|
|
18
|
+
Keywords: privacy,anonymization,pseudonymization,synthetic-data,schema
|
|
19
|
+
License-Expression: Apache-2.0
|
|
20
|
+
Requires-Python: >=3.9
|
|
21
|
+
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
|
|
22
|
+
Project-URL: Changelog, https://github.com/triplexanalytics/triplex/blob/main/CHANGELOG.md
|
|
23
|
+
Project-URL: Documentation, https://triplexanalytics.io/docs/python.html
|
|
24
|
+
Project-URL: Homepage, https://triplexanalytics.io
|
|
25
|
+
Project-URL: Source, https://github.com/triplexanalytics/triplex
|
|
26
|
+
|
|
27
|
+
# triplex-python
|
|
28
|
+
|
|
29
|
+
Triplex Analytics in plain Python: infer and check schema contracts, anonymize a table, generate
|
|
30
|
+
synthetic data. No notebook, no browser, no server. The engine is the same native code the
|
|
31
|
+
`triplex` command line runs.
|
|
32
|
+
|
|
33
|
+
Not published yet. Install from a source checkout (needs the Rust toolchain):
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
cd packages/triplex-python
|
|
37
|
+
pip install maturin
|
|
38
|
+
maturin develop --release
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Use
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
import pandas as pd
|
|
45
|
+
import triplex
|
|
46
|
+
|
|
47
|
+
# 1. Check the raw export against the contract you keep in version control.
|
|
48
|
+
raw = pd.read_csv("customers.csv")
|
|
49
|
+
report = triplex.validate(raw, "customers.triplex.yaml")
|
|
50
|
+
if not report["valid"]:
|
|
51
|
+
raise SystemExit(report["errors"])
|
|
52
|
+
|
|
53
|
+
# 2. Your own pandas work: clean, filter, derive, drop.
|
|
54
|
+
df = raw.dropna(subset=["email"])
|
|
55
|
+
df = df[df["plan"] != "free"]
|
|
56
|
+
df["age_band"] = (df["age"] // 10) * 10
|
|
57
|
+
df = df.drop(columns=["signup_date"])
|
|
58
|
+
|
|
59
|
+
# 3. See what the table holds now: types, null rates, likely identifiers.
|
|
60
|
+
schema = triplex.infer_schema(df)
|
|
61
|
+
|
|
62
|
+
# 4. Generate a synthetic copy for tests or a demo.
|
|
63
|
+
run = triplex.synthesize(df, rows=1000, seed=1)
|
|
64
|
+
synthetic = run.table.to_pandas()
|
|
65
|
+
run.report["leakage"]
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
| Function | What it does |
|
|
69
|
+
|---|---|
|
|
70
|
+
| `infer_schema(data)` | The inferred schema as a dict |
|
|
71
|
+
| `draft_contract(data, output=...)` | A starting contract: every column with its type, all required. Returns a dict; `output` also writes it as `.triplex.yaml` |
|
|
72
|
+
| `validate(data, contract)` | Checks `data` against a contract (a `.triplex.yaml` path or a dict); returns `{"valid": ..., "errors": [...]}` |
|
|
73
|
+
| `convert(input, output)` | Converts a file between `.csv`, `.tsv`, `.jsonl` and `.parquet`, by extension |
|
|
74
|
+
| `anonymize(data, recipe, mapping_out=...)` | Masks the columns a `.triplex.yaml` recipe declares |
|
|
75
|
+
| `synthesize(data, rows=..., seed=..., method=...)` | Generates a synthetic table; `method` is `"copula"` (default), `"bayes-net"` or `"marginals"` |
|
|
76
|
+
|
|
77
|
+
A contract is what you expect the data to be; an inferred schema is what one table holds right
|
|
78
|
+
now. `draft_contract` gives you a first contract to edit (add null-rate, range, mean or pattern
|
|
79
|
+
limits) and commit. Passing the dict from `infer_schema` to `validate` is an error.
|
|
80
|
+
|
|
81
|
+
`data` is a file path, a pandas or Polars DataFrame, a pyarrow Table, or anything else Arrow can
|
|
82
|
+
read. `anonymize` and `synthesize` read `.csv` and `.parquet` paths; `infer_schema` and
|
|
83
|
+
`validate` also read `.tsv` and `.jsonl`.
|
|
84
|
+
|
|
85
|
+
`anonymize` and `synthesize` return a result with two fields: `report` (a dict) and `table`.
|
|
86
|
+
Read the table with `table.to_pandas()`, `table.to_polars()` or `table.to_arrow()`, or pass
|
|
87
|
+
`output="result.csv"` to have it written as CSV as well.
|
|
88
|
+
|
|
89
|
+
An engine error is raised as `triplex.TriplexError`, with a stable `code` such as
|
|
90
|
+
`"COLUMN_NOT_FOUND"`.
|
|
91
|
+
|
|
92
|
+
pandas, Polars and pyarrow are optional: install the ones you use. pandas input and
|
|
93
|
+
`to_pandas()` need pyarrow; Polars works without it.
|
|
94
|
+
|
|
95
|
+
## Anonymizing
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
import os
|
|
99
|
+
os.environ["CUSTOMER_SALT"] = "..." # better: set it outside the script
|
|
100
|
+
|
|
101
|
+
run = triplex.anonymize(df, "recipe.triplex.yaml", mapping_out="mapping.csv")
|
|
102
|
+
masked = run.table.to_pandas()
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
- **The salt** is read from the environment variable each recipe column names (`salt_env`). It
|
|
106
|
+
is never a function argument.
|
|
107
|
+
- **The mapping file** reverses the pseudonyms. It is written to the path you give in
|
|
108
|
+
`mapping_out` and is not returned to Python. Pass `mapping_passphrase_env="SOME_VAR"` to have
|
|
109
|
+
it encrypted with the passphrase in that variable.
|
|
110
|
+
- The result is pseudonymized, not anonymous, as long as the mapping file exists. The report says
|
|
111
|
+
so in `report["warning"]`.
|
|
112
|
+
|
|
113
|
+
## Synthetic data
|
|
114
|
+
|
|
115
|
+
None of the three methods here carries a formal privacy guarantee. Every report includes a
|
|
116
|
+
`privacy_statement` and measured leakage figures; read them before sharing the output.
|
|
117
|
+
Identifier-like columns and dates are left out of the synthetic table and listed in
|
|
118
|
+
`report["columns_dropped"]`.
|
|
119
|
+
|
|
120
|
+
The differentially private methods and the sketch method of the command line are not available
|
|
121
|
+
from Python yet.
|
|
122
|
+
|
|
123
|
+
## Where your data goes
|
|
124
|
+
|
|
125
|
+
Nowhere. The engine runs inside your Python process and makes no network call. Files are read
|
|
126
|
+
from and written to the paths you pass.
|
|
127
|
+
|
|
128
|
+
## Limits
|
|
129
|
+
|
|
130
|
+
- Column types of a result table are inferred by the engine from the values (integer, float,
|
|
131
|
+
boolean or text). They are not copied from the input, so a date column comes back as text.
|
|
132
|
+
- A table is copied when it is handed to the engine and again when it comes back. Large tables
|
|
133
|
+
are untested.
|
|
134
|
+
- Tested on Linux with CPython 3.13, pandas, Polars and pyarrow.
|
|
135
|
+
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
triplex/__init__.py,sha256=eXEr6t1gvvPmMWUgznPLyO3HrbvVHuUXkDvjyusQ1S0,6528
|
|
2
|
+
triplex/_native.pyd,sha256=5dYccwA7zTgosrZ7foFPymUoC-eXOtnEHL-6ECImZHA,10924032
|
|
3
|
+
triplexanalytics-0.2.2.dist-info/METADATA,sha256=LtL2WwakxvsxFSii-PG9BFmhl7zUSZOhMpo6qNz4LWY,5881
|
|
4
|
+
triplexanalytics-0.2.2.dist-info/WHEEL,sha256=xe4_tbg8wYdeh4O7nLbnWckzIQtCR4nODDgwke_TKy8,95
|
|
5
|
+
triplexanalytics-0.2.2.dist-info/sboms/triplex-python.cyclonedx.json,sha256=gUPBjv7UHBtoi4OS83pwOtVW6bUqmdkwLZ1M1Ai6BgY,139470
|
|
6
|
+
triplexanalytics-0.2.2.dist-info/RECORD,,
|