|
| 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