Skip to content

Commit d264602

Browse files
Ryan McKennacopybara-github
authored andcommitted
Add documentation guide on specifying attribute domains.
Add a dedicated guide (`specifying_domains.md`) covering why attribute domains must be specified before inspecting private data, the supported attribute types and out-of-domain handling in `dpsynth.domain`, specifying domains in Python and YAML, and deriving domains automatically from Pydantic models, Protocol Buffers, and Pandas dtypes via `dpsynth.adapters`. PiperOrigin-RevId: 997186307
1 parent 8fe165f commit d264602

3 files changed

Lines changed: 289 additions & 0 deletions

File tree

‎docs/index.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,9 @@ contributor expanding the library, explore the documentation below:
104104

105105
* **[Documentation Sitemap](sitemap.md)**: Complete table of contents and
106106
layout of the DPSynth documentation.
107+
* **[Specifying Attribute Domains](specifying_domains.md)**: Upfront domain
108+
requirements, supported attribute types, the DPSynth domain language, and
109+
inferring domains from Pydantic, Protobuf, and Pandas schemas.
107110
* **[Data Model & Terminology](data_and_terminology.md)**: Attributes
108111
(categorical vs. numerical), schema deduction, and `domain.yaml`
109112
specifications.
@@ -151,6 +154,7 @@ APIs:
151154
:caption: Getting Started
152155
:hidden:
153156
157+
specifying_domains
154158
data_and_terminology
155159
in_memory_api
156160
scalable_pipeline_api

‎docs/sitemap.md‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,23 @@
2121

2222
--------------------------------------------------------------------------------
2323

24+
<details>
25+
<summary>📁 <a href="specifying_domains.md">Specifying Attribute Domains</a></summary>
26+
27+
* [Why Domains Must Be Specified Up Front](specifying_domains.md#why-domains-must-be-specified-up-front)
28+
* [Supported Attribute Types](specifying_domains.md#supported-attribute-types)
29+
* [Out-of-Domain Handling](specifying_domains.md#out-of-domain-handling)
30+
* [Specifying Domains in the DPSynth Language](specifying_domains.md#specifying-domains-in-the-dpsynth-language)
31+
* [Deriving Domains from Structured Containers](specifying_domains.md#deriving-domains-from-structured-containers)
32+
* [1. From Pydantic Models (`dpsynth.adapters.pydantic`)](specifying_domains.md#1-from-pydantic-models-dpsynthadapterspydantic)
33+
* [2. From Protocol Buffers (`dpsynth.adapters.protobuf`)](specifying_domains.md#2-from-protocol-buffers-dpsynthadaptersprotobuf)
34+
* [3. From Pandas Dtypes (`dpsynth.adapters.pandas`)](specifying_domains.md#3-from-pandas-dtypes-dpsynthadapterspandas)
35+
* [Customizing Inferred Domains](specifying_domains.md#customizing-inferred-domains)
36+
37+
</details>
38+
39+
--------------------------------------------------------------------------------
40+
2441
<details>
2542
<summary>📁 <a href="data_and_terminology.md">Data Model and Terminology</a></summary>
2643

‎docs/specifying_domains.md‎

Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
<!-- Copyright 2026 Google LLC
2+
3+
Licensed under the Apache License, Version 2.0 (the "License");
4+
you may not use this file except in compliance with the License.
5+
You may obtain a copy of the License at
6+
7+
http://www.apache.org/licenses/LICENSE-2.0
8+
9+
Unless required by applicable law or agreed to in writing, software
10+
distributed under the License is distributed on an "AS IS" BASIS,
11+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
See the License for the specific language governing permissions and
13+
limitations under the License. -->
14+
15+
# Specifying Attribute Domains
16+
17+
<!-- disableFinding(LINK_RELATIVE_G3DOC) -->
18+
19+
Every synthesis mechanism in DPSynth requires an explicit **domain
20+
specification** describing the attributes (columns) of your dataset and the set
21+
of valid values each attribute can take. This guide explains why domains must be
22+
defined before touching private data, how to express domains directly in the
23+
DPSynth domain language, and how to derive them automatically from structured
24+
schema definitions like Pydantic models, Protocol Buffers, and Pandas dtypes.
25+
26+
## Why Domains Must Be Specified Up Front
27+
28+
In differential privacy, the domain of an attribute defines the sample space of
29+
noisy measurements and bounds the sensitivity of each record's contribution.
30+
Crucially, **the domain must be specified before inspecting the sensitive
31+
dataset**:
32+
33+
* **Data-dependent bounds leak privacy**: Computing `df["income"].min()` and
34+
`df["income"].max()` or extracting `df["category"].unique()` directly from
35+
private data is not differentially private. Adding or removing a single
36+
outlier record can change the observed range or reveal the presence of a
37+
rare category.
38+
* **Calibration precedes execution**: In DPSynth's [three-step mechanism
39+
lifecycle](mechanism_api.md#three-step-pipeline), you pass the domain schema
40+
to `config.calibrate(schema, epsilon=..., delta=...)` to allocate privacy
41+
budgets and construct per-column initializers *before* the calibrated
42+
mechanism ever sees private data.
43+
* **Handling unknown domains privately**: When the valid categories of a
44+
string column are not known from public metadata, use
45+
{class}`~dpsynth.domain.OpenSetCategoricalAttribute` rather than scanning
46+
the raw data. DPSynth will automatically spend a fraction of the privacy
47+
budget on differentially private partition selection to discover common
48+
categories.
49+
50+
## Supported Attribute Types
51+
52+
DPSynth provides four attribute classes in {mod}`dpsynth.domain` to model
53+
tabular columns:
54+
55+
| Attribute Type | When to Use | Required Public Metadata | DP Initialization |
56+
| :--- | :--- | :--- | :--- |
57+
| {class}`~dpsynth.domain.CategoricalAttribute` | Finite set of known values (`bool`, `int`, `float`, or `str`), such as booleans, enum values, US states, or status codes. | `possible_values`: sequence of valid categories. | None (domain is already discrete and fixed). |
58+
| {class}`~dpsynth.domain.NumericalAttribute` | Ordered integer or continuous floating-point numbers, such as age, balance, or duration. | `min_value` and `max_value` (inclusive bounds), plus `dtype` (`"int"` or `"float"`). | Discretizes into bins via DP quantiles (or uses fixed `bin_edges` if provided). |
59+
| {class}`~dpsynth.domain.OpenSetCategoricalAttribute` | Categorical strings whose valid values are unknown in advance, such as free-entered city names or job titles. | None (`default_value` defaults to `"<OOD>"`; optional `public_possible_values`). | Discovers frequent categories via DP Gaussian thresholding (requires $\delta > 0$). |
60+
| {class}`~dpsynth.domain.FreeFormTextAttribute` | Unstructured text fields synthesized by a language model. | `max_tokens` (defaults to `256`). | Handled by text-generation mechanisms. |
61+
62+
### Out-of-Domain Handling
63+
64+
Real-world datasets frequently contain missing values (`None`, `NaN`) or records
65+
that fall outside expected bounds. Because raising a runtime error on unexpected
66+
records would leak information about the private data, DPSynth handles
67+
out-of-domain values deterministically during encoding:
68+
69+
* **Categorical attributes**: Any value not present in `possible_values` is
70+
mapped to `possible_values[out_of_domain_index]` (default `0`). If missing
71+
or unexpected values may occur, place a typed sentinel (such as `"Unknown"`
72+
for strings or `-1` for integers) at index `0` of `possible_values`. If all
73+
records are guaranteed to be in-domain, omit the sentinel so the synthesizer
74+
never generates it.
75+
* **Numerical attributes**: Controlled by `clip_to_range`:
76+
* `clip_to_range=True` (default): Values outside `[min_value, max_value]`
77+
are clipped to the nearest bound, and `NaN` or non-numeric entries map
78+
to `min_value`.
79+
* `clip_to_range=False`: Values outside `[min_value, max_value]` and `NaN`
80+
entries are routed to a dedicated out-of-domain bin during
81+
discretization and decoded back to `sentinel` (`np.nan` by default, or a
82+
custom numeric/string sentinel).
83+
84+
## Specifying Domains in the DPSynth Language
85+
86+
You can define a domain directly in Python as a dictionary mapping column names
87+
to attribute instances, or wrap it in a {class}`~dpsynth.domain.Schema` when
88+
pairing the domain with cross-attribute constraints:
89+
90+
```python
91+
import dpsynth
92+
from dpsynth import domain
93+
94+
domains = {
95+
"age": domain.NumericalAttribute(min_value=18, max_value=100, dtype="int"),
96+
"balance": domain.NumericalAttribute(
97+
min_value=0.0,
98+
max_value=50_000.0,
99+
dtype="float",
100+
clip_to_range=False,
101+
),
102+
"tier": domain.CategoricalAttribute(
103+
possible_values=["UNKNOWN", "FREE", "PRO", "ENTERPRISE"],
104+
out_of_domain_index=0,
105+
),
106+
"is_active": domain.CategoricalAttribute(possible_values=[False, True]),
107+
"city": domain.OpenSetCategoricalAttribute(),
108+
}
109+
110+
# Optional: bundle with cross-attribute constraints in a Schema
111+
schema = domain.Schema(domains)
112+
```
113+
114+
Domain specifications and `Schema` objects can also be saved to and loaded from
115+
YAML via `dpsynth.to_yaml` and `dpsynth.from_yaml`:
116+
117+
```python
118+
yaml_str = dpsynth.to_yaml(schema)
119+
restored_schema = dpsynth.from_yaml(yaml_str)
120+
```
121+
122+
## Deriving Domains from Structured Containers
123+
124+
In many applications, your dataset schema is already captured in a structured
125+
container such as a **Pydantic model**, a **Protocol Buffer descriptor**, or
126+
**Pandas column dtypes** (for example, from a Parquet schema). Manually
127+
rewriting tens or hundreds of fields in the DPSynth domain language creates
128+
unnecessary boilerplate and risks schema drift.
129+
130+
DPSynth provides `infer_domain` helpers under `dpsynth.adapters` that
131+
automatically translate these schema definitions into `dict[str,
132+
domain.AttributeType]`. Because the output is a standard Python dictionary, you
133+
can inspect or override individual attributes before calibrating your mechanism.
134+
135+
### 1. From Pydantic Models (`dpsynth.adapters.pydantic`)
136+
137+
Pydantic models can encode both categorical types (`bool`, `enum.Enum`,
138+
`typing.Literal`) and numerical bounds (`pydantic.Field(ge=..., le=...)`)
139+
directly on the class definition. Optional fields (`T | None`) automatically
140+
include `"None"` in `CategoricalAttribute` or set `clip_to_range=False` on
141+
`NumericalAttribute`:
142+
143+
```python
144+
import enum
145+
from typing import Literal
146+
from dpsynth.adapters import pydantic as pydantic_adapter
147+
import pydantic
148+
149+
150+
class Tier(enum.Enum):
151+
FREE = "FREE"
152+
PRO = "PRO"
153+
154+
155+
class UserRecord(pydantic.BaseModel):
156+
age: int = pydantic.Field(ge=18, le=100)
157+
balance: float | None = pydantic.Field(ge=0.0, le=50_000.0)
158+
tier: Tier
159+
status: Literal["active", "suspended"]
160+
city: str
161+
162+
163+
domains = pydantic_adapter.infer_domain(UserRecord)
164+
```
165+
166+
If numeric fields on the Pydantic model do not carry `Field(ge=..., le=...)`
167+
annotations, you can supply their bounds via the `numerical_bounds` argument:
168+
169+
```python
170+
domains = pydantic_adapter.infer_domain(
171+
UserRecord,
172+
numerical_bounds={"age": (18, 100), "balance": (0.0, 50_000.0)},
173+
)
174+
```
175+
176+
Non-optional nested `BaseModel` fields are recursively flattened into
177+
dot-separated attribute names (such as `"metrics.balance"`).
178+
179+
### 2. From Protocol Buffers (`dpsynth.adapters.protobuf`)
180+
181+
Protocol Buffer descriptors define field types (`int32`, `float`, `bool`,
182+
`string`), enum values, and nested message structures, but do not store numeric
183+
min/max ranges. Pass your Protobuf message class or `Descriptor` along with
184+
`numerical_bounds` to `protobuf.infer_domain`:
185+
186+
```python
187+
from dpsynth.adapters import protobuf
188+
189+
domains = protobuf.infer_domain(
190+
UserProfileProto,
191+
numerical_bounds={
192+
"age": (18.0, 100.0),
193+
"account.balance": (0.0, 50_000.0),
194+
},
195+
ignore_unsupported_fields=True,
196+
)
197+
```
198+
199+
Key behaviors of `protobuf.infer_domain`:
200+
201+
* **Enums and booleans**: Mapped to `CategoricalAttribute` using enum names by
202+
default (`enum_format="name"`) or wire numbers (`enum_format="number"`).
203+
* **Integers and floats**: Mapped to `NumericalAttribute` with `dtype="int"`
204+
or `dtype="float"` using the bounds provided in `numerical_bounds`.
205+
* **Strings**: Mapped to `OpenSetCategoricalAttribute`.
206+
* **Nested messages**: Non-repeated submessages are recursively flattened into
207+
dot-separated keys (such as `"account.balance"`).
208+
* **Unsupported or unbounded fields**: Setting
209+
`ignore_unsupported_fields=True` skips repeated fields, unsupported wire
210+
types (such as `bytes`), and numeric fields omitted from `numerical_bounds`.
211+
212+
### 3. From Pandas Dtypes (`dpsynth.adapters.pandas`)
213+
214+
When working with typed tabular formats such as Parquet or Arrow, or when a
215+
public schema is already represented as Pandas dtypes (`pd.CategoricalDtype`,
216+
`bool`, `int64`, `float64`, `string`), use `pandas.infer_domain` on a
217+
`df.dtypes` `pd.Series` or a `{column: dtype}` mapping:
218+
219+
```python
220+
from dpsynth.adapters import pandas as pandas_adapter
221+
import pandas as pd
222+
223+
schema_dtypes = {
224+
"age": "int64",
225+
"balance": "float64",
226+
"tier": pd.CategoricalDtype(["UNKNOWN", "FREE", "PRO", "ENTERPRISE"]),
227+
"is_active": "bool",
228+
"city": "string",
229+
}
230+
231+
domains = pandas_adapter.infer_domain(
232+
schema_dtypes,
233+
numerical_bounds={"age": (18.0, 100.0), "balance": (0.0, 50_000.0)},
234+
ignore_unsupported_fields=True,
235+
)
236+
```
237+
238+
```{warning}
239+
When inferring a `CategoricalAttribute` from a `pd.CategoricalDtype`, the list
240+
of categories in `dtype.categories` **must** come from a public schema. Never
241+
call `df[col].astype("category")` on sensitive data and pass the resulting
242+
dtypes to `infer_domain`, as doing so extracts the exact active support of the
243+
private dataset without differential privacy. For string columns with unknown
244+
categories, leave the dtype as `"string"` (or `object`) so it maps to
245+
`OpenSetCategoricalAttribute`.
246+
```
247+
248+
### Customizing Inferred Domains
249+
250+
Because `infer_domain` returns a plain `dict[str, domain.AttributeType]`, you
251+
can easily customize specific columns after inference when a schema container
252+
does not capture a nuance of your domain (for example, replacing an inferred
253+
`OpenSetCategoricalAttribute` on a string column with a known closed-set
254+
`CategoricalAttribute`, or specifying custom `bin_edges` on a numerical column):
255+
256+
```python
257+
domains = protobuf.infer_domain(
258+
UserProfileProto,
259+
numerical_bounds={"age": (18.0, 100.0)},
260+
ignore_unsupported_fields=True,
261+
)
262+
263+
# Override a string field whose valid country codes are publicly known:
264+
domains["country_code"] = domain.CategoricalAttribute(
265+
possible_values=["OTHER", "US", "CA", "GB", "DE", "FR", "JP", "IN", "BR"],
266+
out_of_domain_index=0,
267+
)
268+
```

0 commit comments

Comments
 (0)