diff --git a/src/anonymeter/evaluators/inference_evaluator.py b/src/anonymeter/evaluators/inference_evaluator.py index a9ad545..51682ea 100644 --- a/src/anonymeter/evaluators/inference_evaluator.py +++ b/src/anonymeter/evaluators/inference_evaluator.py @@ -14,16 +14,16 @@ def _run_attack( - target: pd.DataFrame, - syn: pd.DataFrame, - n_attacks: int, - aux_cols: list[str], - secret: str, - n_jobs: int, - naive: bool, - regression: Optional[bool], - inference_model: Optional[InferencePredictor], -) -> int: + target: pd.DataFrame, + syn: pd.DataFrame, + n_attacks: int, + aux_cols: list[str], + secret: str, + n_jobs: int, + naive: bool, + regression: Optional[bool], + inference_model: Optional[InferencePredictor], +) -> tuple[int, pd.Series]: if regression is None: regression = pd.api.types.is_numeric_dtype(target[secret]) @@ -35,9 +35,11 @@ def _run_attack( # Instantiate the default KNN model if no other model is passed through `inference_model`. if inference_model is None: inference_model = KNNInferencePredictor(data=syn, columns=aux_cols, target_col=secret, n_jobs=n_jobs) + guesses = inference_model.predict(targets) + guesses = guesses.reindex_like(targets) - return evaluate_inference_guesses(guesses=guesses, secrets=targets[secret], regression=regression).sum() + return evaluate_inference_guesses(guesses=guesses, secrets=targets[secret], regression=regression).sum(), guesses def evaluate_inference_guesses( @@ -72,6 +74,9 @@ def evaluate_inference_guesses( Array of boolean values indicating the correcteness of each guess. """ + if not guesses.index.equals(secrets.index): + raise RuntimeError("The predictions indices do not match the target indices. Check your inference model.") + guesses_np = guesses.to_numpy() secrets_np = secrets.to_numpy() @@ -152,7 +157,7 @@ def __init__( syn: pd.DataFrame, aux_cols: list[str], secret: str, - regression: Optional[bool] = None, + regression: bool = False, n_attacks: int = 500, control: Optional[pd.DataFrame] = None, inference_model: Optional[InferencePredictor] = None @@ -180,7 +185,7 @@ def __init__( self._aux_cols = aux_cols self._evaluated = False - def _attack(self, target: pd.DataFrame, naive: bool, n_jobs: int, n_attacks: int) -> int: + def _attack(self, target: pd.DataFrame, naive: bool, n_jobs: int, n_attacks: int) -> tuple[int, pd.Series]: return _run_attack( target=target, syn=self._syn, @@ -207,14 +212,17 @@ def evaluate(self, n_jobs: int = -2) -> "InferenceEvaluator": The evaluated ``InferenceEvaluator`` object. """ - self._n_baseline = self._attack(target=self._ori, naive=True, n_jobs=n_jobs, - n_attacks=self._n_attacks_baseline) - self._n_success = self._attack(target=self._ori, naive=False, n_jobs=n_jobs, - n_attacks=self._n_attacks_ori) - self._n_control = ( - None if self._control is None else self._attack(target=self._control, naive=False, n_jobs=n_jobs, - n_attacks=self._n_attacks_control) - ) + self._n_baseline, self._guesses_baseline = self._attack( + target=self._ori, naive=True, n_jobs=n_jobs, n_attacks=self._n_attacks_baseline + ) + self._n_success, self._guesses_success = self._attack( + target=self._ori, naive=False, n_jobs=n_jobs, n_attacks=self._n_attacks_ori + ) + self._n_control, self._guesses_control = ( + (None, None) + if self._control is None + else self._attack(target=self._control, naive=False, n_jobs=n_jobs, n_attacks=self._n_attacks_control) + ) self._evaluated = True return self @@ -269,3 +277,67 @@ def risk(self, confidence_level: float = 0.95, baseline: bool = False) -> Privac """ results = self.results(confidence_level=confidence_level) return results.risk(baseline=baseline) + + def risk_for_groups(self, confidence_level: float = 0.95) -> dict[str, EvaluationResults]: + """Compute the inference risk for each group of targets with the same value of the secret attribute. + + Parameters + ---------- + confidence_level : float, default is 0.95 + Confidence level for the error bound calculation. + + Returns + ------- + dict[str, tuple[EvaluationResults | PrivacyRisk] + The group as a key, and then for every group the results (EvaluationResults), + and the risks (PrivacyRisk) as a tuple. + + """ + if not self._evaluated: + raise RuntimeError("The inference evaluator wasn't evaluated yet. Please, run `evaluate()` first.") + + all_results = {} + + # For every unique group in `self._secret` + for group, data_ori in self._ori.groupby(self._secret): + # Get the targets for the current group + common_indices = data_ori.index.intersection(self._guesses_success.index) + # Get the guesses for the current group + target_group = data_ori.loc[common_indices] + n_attacks_ori = len(target_group) + + # Count the number of success attacks + n_success = evaluate_inference_guesses( + guesses=self._guesses_success.loc[common_indices], + secrets=target_group[self._secret], + regression=self._regression, + ).sum() + + if self._control is not None: + # Get the targets for the current control group + data_control = self._control[self._control[self._secret] == group] + n_attacks_control = len(data_control) + + # Get the guesses for the current control group + common_indices = data_control.index.intersection(self._guesses_control.index) + + # Count the number of success control attacks + n_control = evaluate_inference_guesses( + guesses=self._guesses_control.loc[common_indices], + secrets=data_control[self._secret], + regression=self._regression, + ).sum() + else: + n_control = None + n_attacks_control = -1 + + # Recreate the EvaluationResults for the current group + all_results[group] = EvaluationResults( + n_attacks=(n_attacks_ori, self._n_attacks_baseline, n_attacks_control), + n_success=n_success, + n_baseline=self._n_baseline, # The baseline risk should be the same independent of the group + n_control=n_control, + confidence_level=confidence_level, + ) + + return all_results diff --git a/tests/fixtures.py b/tests/fixtures.py index d29fc90..e7edc1c 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -11,7 +11,7 @@ TEST_DIR_PATH = os.path.dirname(os.path.realpath(__file__)) -def get_adult(which: str, n_samples: Optional[int] = None) -> pd.DataFrame: +def get_adult(which: str, deduplicate_on: Optional[list[str]] = None, n_samples: Optional[int] = None) -> pd.DataFrame: """Fixture for the adult dataset. For details see: @@ -21,6 +21,8 @@ def get_adult(which: str, n_samples: Optional[int] = None) -> pd.DataFrame: ---------- which : str, in ['ori', 'syn'] Whether to return the "original" or "synthetic" samples. + deduplicate_on: list of str + A list of columns based on which we'd drop duplicates in the samples. n_samples : int Number of sample records to return. If `None` - return all samples. @@ -37,4 +39,7 @@ def get_adult(which: str, n_samples: Optional[int] = None) -> pd.DataFrame: else: raise ValueError(f"Invalid value {which} for parameter `which`. Available are: 'ori' or 'syn'.") - return pd.read_csv(os.path.join(TEST_DIR_PATH, "datasets", fname), nrows=n_samples) + samples = pd.read_csv(os.path.join(TEST_DIR_PATH, "datasets", fname)) + if deduplicate_on: + samples = samples.drop_duplicates(subset=deduplicate_on) + return samples.iloc[:n_samples] diff --git a/tests/test_inference_evaluator.py b/tests/test_inference_evaluator.py index eeaa934..c572ac4 100644 --- a/tests/test_inference_evaluator.py +++ b/tests/test_inference_evaluator.py @@ -29,6 +29,20 @@ def test_evaluate_inference_guesses_classification(guesses, secrets, expected): np.testing.assert_equal(out, expected) +@pytest.mark.parametrize( + "guesses, secrets, expected", + [ + (("a", "b"), ("a", "b"), (True, True)), + ((np.nan, "b"), (np.nan, "b"), (True, True)) + ], +) +def test_evaluate_inference_guesses_secrets_indices(guesses, secrets, expected): + secrets = pd.Series(secrets).sort_index(ascending=False) + with pytest.raises(RuntimeError) as runtime_error: + evaluate_inference_guesses(guesses=pd.Series(guesses), secrets=secrets, regression=False) + assert "The predictions indices do not match the target indices" in str(runtime_error.value) + + @pytest.mark.parametrize( "guesses, secrets, expected", [ @@ -103,8 +117,10 @@ def test_inference_evaluator_rates( ) @pytest.mark.parametrize("secret", ["education", "marital", "capital_gain"]) def test_inference_evaluator_leaks(aux_cols, secret): - ori = get_adult("ori", n_samples=10) - evaluator = InferenceEvaluator(ori=ori, syn=ori, control=ori, aux_cols=aux_cols, secret=secret, n_attacks=10) + ori = get_adult("ori", deduplicate_on=aux_cols, n_samples=10) + evaluator = InferenceEvaluator( + ori=ori, syn=ori, control=ori, aux_cols=aux_cols, secret=secret, n_attacks=ori.shape[0] + ) evaluator.evaluate(n_jobs=1) results = evaluator.results(confidence_level=0) @@ -123,3 +139,52 @@ def test_evaluator_not_evaluated(): ) with pytest.raises(RuntimeError): evaluator.risk() + + +@pytest.mark.parametrize( + "aux_cols", + [ + ["type_employer", "capital_loss", "hr_per_week", "age"], + ["education_num", "marital", "capital_loss"], + ], +) +@pytest.mark.parametrize("secret", ["education", "marital"]) +def test_inference_evaluator_group_wise_rates(aux_cols, secret): + ori = get_adult("ori", deduplicate_on=aux_cols, n_samples=10) + evaluator = InferenceEvaluator( + ori=ori, syn=ori, control=ori, aux_cols=aux_cols, secret=secret, n_attacks=ori.shape[0] + ) + evaluator.evaluate(n_jobs=1) + + group_wise = evaluator.risk_for_groups(confidence_level=0) + + for _, results in group_wise.items(): + np.testing.assert_equal(results.attack_rate, (1, 0)) + np.testing.assert_equal(results.control_rate, (1, 0)) + +@pytest.mark.parametrize( + "aux_cols", + [ + ["type_employer", "capital_loss", "hr_per_week", "age"], + ["education_num", "marital", "capital_loss"], + ], +) +@pytest.mark.parametrize("secret", ["education", "marital"]) +def test_inference_evaluator_group_wise_risks(aux_cols, secret): + ori = get_adult("ori", deduplicate_on=aux_cols, n_samples=10) + evaluator = InferenceEvaluator( + ori=ori, syn=ori, control=ori, aux_cols=aux_cols, secret=secret, n_attacks=ori.shape[0] + ) + evaluator.evaluate(n_jobs=1) + main_risk = evaluator.risk(confidence_level=0.95) + + group_wise = evaluator.risk_for_groups(confidence_level=0.95) + + sum_risks = 0.0 + for _, results in group_wise.items(): + risk = results.risk().value + np.testing.assert_equal(risk, 0) + + sum_risks += risk + + np.testing.assert_allclose(sum_risks, main_risk.value) diff --git a/tests/test_singling_out_evaluator.py b/tests/test_singling_out_evaluator.py index 05a79cb..066abc0 100644 --- a/tests/test_singling_out_evaluator.py +++ b/tests/test_singling_out_evaluator.py @@ -150,7 +150,7 @@ def test_singling_out_query_generator() -> None: @pytest.mark.parametrize("confidence_level", [0.5, 0.68, 0.95, 0.99]) @pytest.mark.parametrize("mode", ["univariate", "multivariate"]) def test_singling_out_risk_estimate(confidence_level: float, mode: str) -> None: - ori = get_adult("ori", 10) + ori = get_adult("ori", n_samples=10) soe = SinglingOutEvaluator(ori=ori, syn=ori, n_attacks=5) soe.evaluate(mode=mode) _, ci = soe.risk(confidence_level=confidence_level) @@ -176,7 +176,7 @@ def _so_probability(n: int, w: float): @pytest.mark.parametrize("max_attempts", [1, 2, 3]) def test_so_evaluator_max_attempts(max_attempts: int) -> None: - ori = get_adult("ori", 10) + ori = get_adult("ori", n_samples=10) soe = SinglingOutEvaluator(ori=ori, syn=ori, n_attacks=10, max_attempts=max_attempts) soe.evaluate(mode="multivariate") diff --git a/tests/test_sklearn_inference_model.py b/tests/test_sklearn_inference_model.py index ac45d2a..d352310 100644 --- a/tests/test_sklearn_inference_model.py +++ b/tests/test_sklearn_inference_model.py @@ -25,7 +25,7 @@ ) @pytest.mark.parametrize("secret", ["capital_gain", "capital_loss"]) def test_inference_evaluator_custom_model_regressor(aux_cols, secret): - ori = get_adult("ori", n_samples=10) + ori = get_adult("ori", deduplicate_on=aux_cols, n_samples=10) # Inference model prep categorical_cols = ori[aux_cols].select_dtypes(include=["object"]).columns