Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 93 additions & 21 deletions src/anonymeter/evaluators/inference_evaluator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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])

Expand All @@ -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(
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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
9 changes: 7 additions & 2 deletions tests/fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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.
Expand All @@ -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]
69 changes: 67 additions & 2 deletions tests/test_inference_evaluator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
[
Expand Down Expand Up @@ -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)

Expand All @@ -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)
4 changes: 2 additions & 2 deletions tests/test_singling_out_evaluator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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")

Expand Down
2 changes: 1 addition & 1 deletion tests/test_sklearn_inference_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down