Skip to content

Commit fe17163

Browse files
feat(RHOAIENG-21045): Add fairness metrics and tests
1 parent aa250e3 commit fe17163

File tree

7 files changed

+388
-0
lines changed

7 files changed

+388
-0
lines changed
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import numpy as np
2+
3+
def filter_rows_by_inputs(data, filter_func):
4+
return data[np.apply_along_axis(filter_func, 1, data)]
5+
6+
def calculate_confusion_matrix(test: np.array, truth: np.array, positive_class: int) -> dict:
7+
tp = np.sum((test == positive_class) & (truth == positive_class))
8+
tn = np.sum((test != positive_class) & (truth != positive_class))
9+
fp = np.sum((test == positive_class) & (truth != positive_class))
10+
fn = np.sum((test != positive_class) & (truth == positive_class))
11+
return {"tp": tp, "tn": tn, "fp": fp, "fn": fn}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# pylint: disable=line-too-long
2+
from typing import List, Any
3+
4+
import numpy as np
5+
6+
class DisparateImpactRatio:
7+
"""
8+
Calculate disparate impact ratio (DIR).
9+
"""
10+
@staticmethod
11+
def calculate_model(
12+
samples: np.ndarray,
13+
model: Any,
14+
privilege_columns: List[int],
15+
privilege_values: List[int],
16+
favorable_output: np.ndarray
17+
) -> float:
18+
"""
19+
Calculate disparate impact ratio (DIR) for model outputs.
20+
:param samples a NumPy array of inputs to be used for testing fairness
21+
:param model the model to be tested for fairness
22+
:param privilege_columns a list of integers specifying the indices of the privileged columns
23+
:param privilege_values a list integers specifying the privileged values
24+
:param favorable_output the outputs that are considered favorable / desirable
25+
return DIR score
26+
"""
27+
outputs = model.predict(samples)
28+
data = np.append(samples, outputs, axis=1)
29+
privileged = np.sum(data[:, privilege_columns] == privilege_values)
30+
unprivileged = np.sum(data[:, privilege_columns] != privilege_values)
31+
32+
return DisparateImpactRatio.calculate(privileged, unprivileged, favorable_output)
33+
34+
@staticmethod
35+
def calculate(
36+
privileged: np.ndarray,
37+
unprivileged: np.ndarray,
38+
favorable_output: int
39+
) -> float:
40+
"""
41+
Calculate disparate impact ratio (DIR) when the labels are pre-calculated.
42+
:param privileged a NumPy array with the privileged groups
43+
:param unprivileged a NumPy array with the unprivileged groups
44+
:param favorableOutput an output that is considered favorable / desirable
45+
return DIR, between 0 and 1
46+
"""
47+
probability_privileged = np.sum(privileged[:, -1] == favorable_output) / len(privileged)
48+
probability_unprivileged = np.sum(unprivileged[:, -1] == favorable_output) / len(unprivileged)
49+
return probability_unprivileged / probability_privileged
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# pylint: disable=line-too-long, too-many-arguments
2+
from typing import List, Any
3+
4+
import numpy as np
5+
6+
from src.core.metrics.fairness.fairness_metrics_utils import filter_rows_by_inputs, calculate_confusion_matrix
7+
8+
class GroupAverageOddsDifference:
9+
"""
10+
Calculate group average odds difference.
11+
"""
12+
@ staticmethod
13+
def calculate_model(
14+
samples: np.ndarray,
15+
model: Any,
16+
privilege_columns: List[int],
17+
privilege_values: List[int],
18+
postive_class: List[int],
19+
output_column: int
20+
):
21+
"""
22+
Calculate group average odds difference for model outputs.
23+
:param samples a NumPy arrary of inputs to be used for testing fairness
24+
:param model the model to be tested for fairness
25+
:param privilege_columns a list of integers specifying the indices of the privileged columns
26+
:param privilege_values a list of intergers specifying the privileged values
27+
:param postive_class the favorable / desirable outputs
28+
:param output_column the column index where the output is located
29+
return group average odds difference score
30+
"""
31+
outputs = model.predict(samples)
32+
truth = np.append(samples, outputs, axis=1)
33+
34+
return GroupAverageOddsDifference.calculate(samples, truth, privilege_columns, privilege_values, postive_class, output_column)
35+
36+
@staticmethod
37+
def calculate(test, truth, privilege_columns, privilege_values, positive_class, output_column):
38+
"""
39+
Calculate group average odds difference when the labels are pre-calculated.
40+
:param test a NumPy array representing the test data
41+
:param truth a NumPy array representing the truth data
42+
:param privilege_columns a list of integers specifying the indices of the privileged columns
43+
:param privilege_values a list of intergers specifying the privileged values
44+
:param positive_class the favorable / desirable outputs
45+
:param output_column the column where the output is located
46+
return group average odds difference, between -1 and 1
47+
"""
48+
def privilege_filter(row):
49+
return np.array_equal(row[privilege_columns], privilege_values)
50+
51+
test_privileged = filter_rows_by_inputs(test, privilege_filter)
52+
test_unprivileged = filter_rows_by_inputs(test, lambda row: not privilege_filter(row))
53+
54+
truth_privileged = filter_rows_by_inputs(truth, privilege_filter)
55+
truth_unprivileged = filter_rows_by_inputs(truth, lambda row: not privilege_filter(row))
56+
57+
ucm = calculate_confusion_matrix(test_unprivileged[:, output_column], truth_unprivileged[:, output_column], positive_class)
58+
pcm = calculate_confusion_matrix(test_privileged[:, output_column], truth_privileged[:, output_column], positive_class)
59+
60+
utp, utn, ufp, ufn = ucm["tp"], ucm["tn"], ucm["fp"], ucm["fn"]
61+
ptp, ptn, pfp, pfn = pcm["tp"], pcm["tn"], pcm["fp"], pcm["fn"]
62+
63+
return (utp / (utp + ufn + 1e-10) - ptp / (ptp + pfn + 1e-10)) / 2 + \
64+
(ufp / (ufp + utn + 1e-10) - pfp / (pfp + ptn + 1e-10)) / 2
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# pylint: disable=line-too-long, too-many-arguments
2+
from typing import List, Any
3+
4+
import numpy as np
5+
6+
from src.core.metrics.fairness.fairness_metrics_utils import filter_rows_by_inputs, calculate_confusion_matrix
7+
8+
class GroupAveragePredictiveValueDifference:
9+
"""
10+
Calculate group average predictive value difference.
11+
"""
12+
@staticmethod
13+
def calculate_model(
14+
samples: np.ndarray,
15+
model: Any,
16+
privilege_columns: List[int],
17+
privilege_values: List[int],
18+
positive_class: int,
19+
output_column: int
20+
) -> float:
21+
"""
22+
Calculate group average predictive value difference for model outputs.
23+
:param samples a Numpy array of inputs to be used for testing fairness
24+
:param model the model to be tested for fairness
25+
:param privilege_columns a list of integers specifying the indices of the privileged columns
26+
:param privilege_values a list of integers specifying the privileged values
27+
:param positive_class the favorable / desirable outputs
28+
:param output_column the column index where the output is located
29+
"""
30+
outputs = model.predict(samples)
31+
truth = np.append(samples, outputs, axis=1)
32+
return GroupAveragePredictiveValueDifference.calculate(samples, truth, privilege_columns, privilege_values, positive_class, output_column)
33+
34+
@staticmethod
35+
def calculate(test, truth, privilege_columns, privilege_values, positive_class, output_column):
36+
"""
37+
Calculate group average predictive value difference when the labels are pre-calculated.
38+
:param test a NumPy array representing the test data
39+
:param truth a NumPy array representing the truth data
40+
:param privilege_columns a list of integers specifying the indices of the privileged columns
41+
:param privilege_values a list of intergers specifying the privileged values
42+
:param positive_class the favorable / desirable outputs
43+
:param output_column the column where the output is located
44+
return group average predictive value difference, between -1 and 1
45+
"""
46+
def privilege_filter(row):
47+
return np.array_equal(row[privilege_columns], privilege_values)
48+
49+
test_privileged = filter_rows_by_inputs(test, privilege_filter)
50+
test_unprivileged = filter_rows_by_inputs(test, lambda row: not privilege_filter(row))
51+
52+
truth_privileged = filter_rows_by_inputs(truth, privilege_filter)
53+
truth_unprivileged = filter_rows_by_inputs(truth, lambda row: not privilege_filter(row))
54+
55+
ucm = calculate_confusion_matrix(test_unprivileged[:, output_column], truth_unprivileged[:, output_column], positive_class)
56+
pcm = calculate_confusion_matrix(test_privileged[:, output_column], truth_privileged[:, output_column], positive_class)
57+
58+
utp, utn, ufp, ufn = ucm["tp"], ucm["tn"], ucm["fp"], ucm["fn"]
59+
ptp, ptn, pfp, pfn = pcm["tp"], pcm["tn"], pcm["fp"], pcm["fn"]
60+
61+
return (utp / (utp + ufp) - ptp / (ptp + pfp + 1e-10)) / 2 + \
62+
(ufn / (ufn + utn) - pfn / (pfn + ptn + 1e-10)) / 2
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# pylint: disable=line-too-long
2+
from typing import List
3+
4+
import numpy as np
5+
6+
class GroupStatisticalParityDifference:
7+
"""
8+
Calculate group statistical parity difference (SPD).
9+
"""
10+
@staticmethod
11+
def calculate_model(
12+
samples: np.ndarray,
13+
model,
14+
privilege_columns: List[int],
15+
privilege_values: List[int],
16+
favorable_output,
17+
) -> float:
18+
"""
19+
Calculate group statistical parity difference (SPD) for model outputs.
20+
:param samples a NumPy array of inputs to be used for testing fairness
21+
:param model the model to be tested for fairness
22+
:param privilege_columns a list of integers specifying the indices of the privileged columns
23+
:param privilege_values a list integers specifying the privileged values
24+
:param favorable_output the outputs that are considered favorable / desirable
25+
return SPD score
26+
"""
27+
outputs = model.predict(samples)
28+
data = np.append(samples, outputs, axis=1)
29+
privileged = data[np.where(data[:, privilege_columns] == privilege_values)]
30+
unprivileged = data[np.where(data[:, privilege_columns] != privilege_values)]
31+
32+
return GroupStatisticalParityDifference.calculate(privileged, unprivileged, favorable_output)
33+
34+
@staticmethod
35+
def calculate(
36+
privileged,
37+
unprivileged,
38+
favorable_output,
39+
) -> float:
40+
"""
41+
Calculate statistical/demographic parity difference (SPD) when the labels are pre-calculated.
42+
:param priviledged numPy array with the privileged groups
43+
:param unpriviledged numPy array with the unpriviledged groups
44+
:param favorableOutput an output that is considered favorable / desirable
45+
return SPD, between 0 and 1
46+
"""
47+
probability_privileged = np.sum(privileged[:, -1] == favorable_output) / len(privileged)
48+
probability_unprivileged = np.sum(unprivileged[:, -1] == favorable_output) / len(unprivileged)
49+
return probability_unprivileged - probability_privileged
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# pylint: disable=too-few-public-methods, line-too-long
2+
from typing import Any
3+
4+
import numpy as np
5+
6+
class IndividualConsistency:
7+
"""
8+
Calculate individual fairness in terms of consistency of predictions across similar inputs.
9+
:param proximity_function: a function that finds the top k similar inputs, given a reference input and a list of inputs
10+
:param samples a list of inputs to be tested for consistency
11+
:param predictionProvider the model under inspection
12+
return the consistency measure
13+
"""
14+
@staticmethod
15+
def calculate(
16+
proximity_function: Any,
17+
samples: np.ndarray,
18+
prediction_provider: Any
19+
) -> float:
20+
"""
21+
Calculate individual fairness.
22+
:param proximity_function: a function that finds the top k similar inputs, given a reference input and a list of inputs
23+
:param samples a list of inputs to be tested for consistency
24+
:param prediction_provider the model under inspection
25+
return the consistency measure
26+
"""
27+
consistency = 1
28+
for sample in samples:
29+
prediction_outputs = prediction_provider.predict(sample)
30+
prediction_output = prediction_outputs[0]
31+
neighbors = proximity_function(sample, samples)
32+
neighbors_outputs = prediction_provider.predict(neighbors)
33+
for output in prediction_outputs:
34+
for neighbor_output in neighbors_outputs:
35+
if neighbor_output != output:
36+
consistency -= 1 / (len(neighbors) * len(prediction_output) * len(samples))
37+
return consistency

tests/metrics/test_fairness.py

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
# pylint: disable=line-too-long, missing-function-docstring
2+
from typing import List, Optional
3+
4+
from pytest import approx
5+
import numpy as np
6+
import pandas as pd
7+
8+
from sklearn.linear_model import LogisticRegression
9+
from sklearn.preprocessing import LabelEncoder
10+
11+
from aif360.sklearn.metrics import (
12+
disparate_impact_ratio,
13+
statistical_parity_difference,
14+
average_odds_difference,
15+
average_predictive_value_difference,
16+
)
17+
18+
from src.core.metrics.fairness.group.disparate_impact_ratio import DisparateImpactRatio
19+
from src.core.metrics.fairness.group.group_average_odds_difference import GroupAverageOddsDifference
20+
from src.core.metrics.fairness.group.group_average_predictive_value_difference import GroupAveragePredictiveValueDifference
21+
from src.core.metrics.fairness.group.group_statistical_parity_difference import GroupStatisticalParityDifference
22+
23+
df = pd.read_csv(
24+
"https://raw.githubusercontent.com/trustyai-explainability/model-collection/8aa8e2e762c6d2b41dbcbe8a0035d50aa5f58c93/bank-churn/data/train.csv",
25+
)
26+
X = df.drop(columns=["Exited"], axis=1)
27+
y = df["Exited"]
28+
29+
def train_model():
30+
categorical_features = ['Geography', 'Gender', 'Card Type', 'HasCrCard', 'IsActiveMember', 'Complain']
31+
label_encoders = {}
32+
for feature in categorical_features:
33+
label_encoders[feature] = LabelEncoder()
34+
X[feature] = label_encoders[feature].fit_transform(X[feature])
35+
lr = LogisticRegression().fit(X, y)
36+
37+
y_pred = pd.DataFrame(lr.predict(X))
38+
return y_pred
39+
40+
def truth_predict_output():
41+
y.index = X["Gender"]
42+
y_pred = pd.DataFrame(train_model())
43+
y_pred.index = X["Gender"]
44+
return y, y_pred
45+
46+
def get_privileged_unprivleged_split():
47+
data = df[[col for col in df.columns if col != "Exited"] + ["Exited"]]
48+
data = data.to_numpy()
49+
privileged = data[np.where(data[:, 2] == "Male")]
50+
unprivileged = data[np.where(data[:, 2] == "Female")]
51+
return privileged, unprivileged
52+
53+
def get_labeled_data():
54+
data = df[[col for col in df.columns if col != "Exited"] + ["Exited"]]
55+
data = data.to_numpy()
56+
y_pred = pd.DataFrame(train_model())
57+
data_pred = data.copy()
58+
data_pred[:, -1] = y_pred.to_numpy().flatten()
59+
return data, data_pred
60+
61+
y, y_pred = truth_predict_output()
62+
privileged, unprivileged = get_privileged_unprivleged_split()
63+
data, data_pred = get_labeled_data()
64+
65+
66+
def test_disparate_impact_ratio():
67+
dir = disparate_impact_ratio(y, prot_attr="Gender", priv_group="Male", pos_label=1)
68+
69+
score = DisparateImpactRatio.calculate(
70+
privileged=privileged,
71+
unprivileged=unprivileged,
72+
favorable_output=1
73+
)
74+
assert score == approx(dir, abs=1e-5)
75+
76+
77+
def test_statistical_parity_difference():
78+
spd = statistical_parity_difference(y, prot_attr="Gender", priv_group="Male", pos_label=1)
79+
80+
score = GroupStatisticalParityDifference.calculate(
81+
privileged=privileged,
82+
unprivileged=unprivileged,
83+
favorable_output=1
84+
)
85+
86+
assert score == approx(spd, abs=1e-5)
87+
88+
89+
def test_average_odds_difference():
90+
aod = average_odds_difference(y, y_pred, prot_attr="Gender", priv_group="Male", pos_label=1)
91+
92+
score = GroupAverageOddsDifference.calculate(
93+
test=data_pred,
94+
truth=data,
95+
privilege_columns=[2],
96+
privilege_values=["Male"],
97+
positive_class=1,
98+
output_column=-1
99+
)
100+
101+
assert score == approx(aod, abs=0.2)
102+
103+
104+
def test_average_predictive_value_difference():
105+
apvd = average_predictive_value_difference(y, y_pred, prot_attr="Gender", priv_group="Male", pos_label=1)
106+
107+
score = GroupAveragePredictiveValueDifference.calculate(
108+
test=data_pred,
109+
truth=data,
110+
privilege_columns=[2],
111+
privilege_values=["Male"],
112+
positive_class=1,
113+
output_column=-1
114+
)
115+
116+
assert score == approx(apvd, abs=0.2)

0 commit comments

Comments
 (0)