{
"cells": [
{
"attachments": {},
"cell_type": "markdown",
"metadata": {
"collapsed": false,
"pycharm": {
"name": "#%% md\n"
}
},
"source": [
"# Accuracy: Pitfalls and Edge Cases\n",
"\n",
"\n",
"This notebook describes OpenDP's accuracy calculations, and ways in which an analyst might be tripped up by them.\n",
"\n",
"### Overview\n",
"\n",
"#### Accuracy vs. Confidence Intervals\n",
"\n",
"Each privatizing mechanism (e.g. Laplace, Gaussian) in OpenDP has an associated accuracy that is a function of alpha and\n",
"the noise scale. Imagine you have data $D$, and you want, for some function $\\phi$ to return $\\phi(D)$ in a\n",
"differentially private way -- we will call this value $\\phi_{dp}(D)$. An $\\alpha$-level accuracy guarantee $a$ promises\n",
"that, over infinite runs of the privatizing mechanism on the data in question,\n",
"$$ \\phi(D) \\in [\\phi_{dp}(D) - a, \\phi_{dp}(D) + a] $$\n",
"with probability at least $1 - \\alpha$.\n",
"\n",
"This looks very much like the traditional confidence interval, but it is important to note a major difference. In a\n",
"canonical confidence interval, the uncertainty being represented is due to sampling error -- that is, how often will it\n",
"be the case that $\\phi(P)$ (the value of $\\phi$ on the underlying population) is within some range of the realized\n",
"$\\phi(D)$.\n",
"\n",
"In OpenDP (and differentially private data analysis generally), \n",
"there is an extra layer of uncertainty due to the noise added to $\\phi(D)$ to produce $\\phi_{dp}(D)$. \n",
"OpenDP's accuracy utilities described below deal only with the uncertainty of $\\phi_{dp}(D)$ relative to $\\phi(D)$ \n",
"and not the uncertainty of $\\phi(D)$ relative to $\\phi(P)$, but there is ongoing work to provide methods that incorporate both."
]
},
{
"attachments": {},
"cell_type": "markdown",
"metadata": {},
"source": [
"#### What is $D$?\n",
"\n",
"OpenDP allows for analysis of data with an unknown number of rows by resizing the data to ensure consistency with an\n",
"estimated size\n",
"(see the [unknown dataset size notebook](https://github.com/opendp/opendp/blob/main/python/example/unknown_dataset_size.ipynb)\n",
"for more details). Accuracy guarantees are always relative to the preprocessed data $\\tilde{D}$ and operations such as\n",
"imputation and clipping are not factored into the accuracy.\n",
"\n",
"#### Synopsis\n",
"\n",
"Say an analyst releases $\\phi_{dp}(D)$ and gets an accuracy guarantee of $a$ at accuracy-level $\\alpha$ using the accuracy utilities described below. \n",
"$D$ is a dataset of unknown size drawn from population $P$ and will be resized to $\\tilde{D}$. \n",
"This suggests that over infinite runs of this procedure,\n",
"\n",
"- $\\phi_{dp}(D) \\in [\\phi(\\tilde{D}) - a, \\phi(\\tilde{D}) + a]$ with probability $1 - \\alpha$\n",
"- It is likely that $\\phi_{dp}(D) \\in [\\phi(D) - a, \\phi(D) + a]$ with probability $\\approx 1 - \\alpha$, though we\n",
"cannot make any guarantee. For many cases (e.g. resizing the data based on $n$ obtained from a differentially private\n",
"count and reasonable bounds on the data elements), this is likely to be approximately true. In the next section, we will\n",
"explore some examples of cases where this statement holds to varying extents.\n",
"\n",
"- We cannot directly make statements about the relationship uncertainty of $\\phi_{dp}(D)$ relative to $\\phi(P)$.\n",
"\n",
"### Accuracy Guarantees In Practice\n",
"\n",
"We now move to some empirical evaluations of how well our accuracy guarantees translate from $\\phi(\\tilde{D})$ to\n",
"$\\phi(D)$. We first consider the case where we actually know the size of the underlying data and are able to set\n",
"plausible lower/upper bounds on `age`.\n"
]
},
{
"cell_type": "code",
"execution_count": 2,
"metadata": {
"collapsed": false,
"pycharm": {
"name": "#%%\n"
}
},
"outputs": [],
"source": [
"# load libraries\n",
"import os\n",
"\n",
"import numpy as np\n",
"import pandas as pd\n",
"\n",
"from opendp.accuracy import laplacian_scale_to_accuracy\n",
"from opendp.measurements import make_base_laplace\n",
"from opendp.mod import enable_features, binary_search_param\n",
"from opendp.transformations import make_split_dataframe, make_clamp, make_resize, \\\n",
" make_sized_bounded_mean, make_select_column, make_impute_constant, make_cast\n",
"from opendp.domains import option_domain, atom_domain\n",
"\n",
"enable_features(\"contrib\")\n",
"enable_features(\"floating-point\")\n",
"\n",
"data_path = os.path.join('..', '..', '..', 'data', 'PUMS_california_demographics_1000', 'data.csv')\n",
"var_names = [\"age\", \"sex\", \"educ\", \"race\", \"income\", \"married\", \"pid\"]\n",
"D = pd.read_csv(data_path, names=var_names)\n",
"age = D.age\n",
"D_mean_age = np.mean(age)\n",
"\n",
"# This will provide the data that will be passed to the aggregator\n",
"with open(data_path, 'r') as infile:\n",
" data = infile.read()\n",
"\n",
"# establish extra information for this simulation\n",
"age_bounds = (0., 100.)\n",
"n_sims = 100\n",
"epsilon = 1.\n",
"alpha = 0.05\n",
"\n",
"D_tilde_mean_age = np.mean(np.clip(D.age, age_bounds[0], age_bounds[1]))\n",
"impute_constant = 50.\n",
"\n",
"def make_mean_aggregator(data_size):\n",
" return (\n",
" # Convert data into a dataframe of string columns\n",
" make_split_dataframe(separator=\",\", col_names=var_names) >>\n",
" # Selects a column of df, Vec\n",
" make_select_column(key=\"age\", TOA=str) >>\n",
" # Cast the column as Vec\n",
" make_cast(TIA=str, TOA=float) >>\n",
" # Impute null values\n",
" make_impute_constant(option_domain(atom_domain(T=float)), impute_constant) >>\n",
" # Clamp age values\n",
" make_clamp(bounds=age_bounds) >>\n",
" # Resize the dataset to length `data_size`.\n",
" # If there are fewer than `data_size` rows in the data, fill with a constant.\n",
" # If there are more than `data_size` rows in the data, only keep `data_size` rows\n",
" make_resize(size=data_size, atom_domain=atom_domain(age_bounds), constant=impute_constant) >>\n",
" make_sized_bounded_mean(size=data_size, bounds=age_bounds)\n",
" )\n"
]
},
{
"cell_type": "code",
"execution_count": 8,
"metadata": {
"collapsed": false,
"pycharm": {
"name": "#%%\n"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Accuracy interval (with accuracy value 0.2996) contains the true mean on D_tilde with probability 0.93\n",
"Accuracy interval (with accuracy value 0.2996) contains the true mean on D with probability 0.93\n"
]
}
],
"source": [
"data_size = 1_000\n",
"\n",
"mean_aggregator = make_mean_aggregator(data_size)\n",
"\n",
"scale = binary_search_param(lambda s: mean_aggregator >> make_base_laplace(s), 1, epsilon)\n",
"\n",
"measurement = mean_aggregator >> make_base_laplace(scale)\n",
"\n",
"releases = [measurement(data) for _ in range(n_sims)]\n",
"\n",
"accuracy = laplacian_scale_to_accuracy(scale, alpha)\n",
"\n",
"print('Accuracy interval (with accuracy value {0}) contains the true mean on D_tilde with probability {1}'.format(\n",
" round(accuracy, 4),\n",
" np.mean([(D_tilde_mean_age >= val - accuracy) & (D_tilde_mean_age <= val + accuracy) for val in releases])))\n",
"\n",
"print('Accuracy interval (with accuracy value {0}) contains the true mean on D with probability {1}'.format(\n",
" round(accuracy, 4),\n",
" np.mean([(D_mean_age >= val - accuracy) & (D_mean_age <= val + accuracy) for val in releases])))"
]
},
{
"cell_type": "markdown",
"metadata": {
"collapsed": false,
"pycharm": {
"name": "#%% md\n"
}
},
"source": [
"This performance is as expected. $D$ and $\\tilde{D}$ are actually the exact same data (the maximum age in the raw data\n",
" is 93, so our clamp to $[0, 100]$ does not change any values, and we know the correct $n$), so our theoretical\n",
" guarantees on $\\tilde{D}$ map exactly to guarantees on $D$.\n",
"\n",
"We now move to a scenario that is still realistic, but where the performance does not translate quite as well. In this\n",
" case, we imagine that the analyst believes the data to be of size 1050 and uses the default imputation within resize\n",
" so that the extra 50 elements are replaced with a constant.\n",
"\n",
"Note that our diagnostic testing of $\\tilde{D}$ in the code above is not trivial in this case. In the first example, we\n",
"knew that clamp/resize did not change the underlying data, so we could predict exactly the data on which the DP mean\n",
" would actually be calculated. This will not be true for the following examples, so we will simulate finding the true\n",
" underlying mean by releasing an extra DP mean with very high epsilon."
]
},
{
"cell_type": "code",
"execution_count": 9,
"metadata": {
"collapsed": false,
"pycharm": {
"name": "#%%\n"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Accuracy interval (with accuracy value 0.2853) contains the true mean on D_tilde with probability 0.97\n",
"Accuracy interval (with accuracy value 0.2853) contains the true mean on D with probability 0.65\n"
]
}
],
"source": [
"# This estimate is larger than the true size of 1000, so we will impute 50 values using the impute constant\n",
"data_size = 1_050\n",
"\n",
"mean_aggregator = make_mean_aggregator(data_size)\n",
"\n",
"# This value contains the true mean of the data after resizing and imputation\n",
"D_tilde_mean = mean_aggregator(data)\n",
"\n",
"scale = binary_search_param(lambda s: mean_aggregator >> make_base_laplace(s), 1, epsilon)\n",
"\n",
"measurement = mean_aggregator >> make_base_laplace(scale)\n",
"\n",
"releases = [measurement(data) for _ in range(n_sims)]\n",
"\n",
"accuracy = laplacian_scale_to_accuracy(scale, alpha)\n",
"\n",
"print('Accuracy interval (with accuracy value {0}) contains the true mean on D_tilde with probability {1}'.format(\n",
" round(accuracy, 4),\n",
" np.mean([(D_tilde_mean >= dp_mean - accuracy) & (D_tilde_mean <= dp_mean + accuracy)\n",
" for dp_mean in releases])))\n",
"\n",
"print('Accuracy interval (with accuracy value {0}) contains the true mean on D with probability {1}'.format(\n",
" round(accuracy, 4),\n",
" np.mean([(D_mean_age >= dp_mean - accuracy) & (D_mean_age <= dp_mean + accuracy) for dp_mean in releases])))"
]
},
{
"cell_type": "markdown",
"metadata": {
"collapsed": false,
"pycharm": {
"name": "#%% md\n"
}
},
"source": [
"The accuracy guarantee still holds on $\\tilde{D}$ (as it should), but we now see much worse performance relative to the\n",
"true underlying data $D$."
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3.8.13 ('psi')",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.8.13"
},
"vscode": {
"interpreter": {
"hash": "3220da548452ac41acb293d0d6efded0f046fab635503eb911c05f743e930f34"
}
}
},
"nbformat": 4,
"nbformat_minor": 0
}