Skip to content

Commit 583bda5

Browse files
authored
FAI-903: Add group fairness Python bindings (#129)
Add group fairness Python bindings. * Add group statistical parity difference * Add Disparate Impact Ratio (DIR) * Add average odds difference * Add Average Predictive Value Difference * Add column selectors * Add SPD for when providing models * Add model-based DIR metric * Update dev requirements * Add model-based AOD * Add model-based APVD * Add biased and unbiased synthetic income dataset * Update tests * Change to binary serialised model * Downgrade XGBoost version for CI compatibility * Fix linting and formatting * Fix test files relative paths
1 parent 7957fa9 commit 583bda5

File tree

10 files changed

+437
-25
lines changed

10 files changed

+437
-25
lines changed

requirements-dev.txt

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
1-
JPype1==1.3.0
2-
black==21.6b0
1+
JPype1==1.4.1
2+
black==22.10.0
33
click==8.0.4
44
joblib==1.2.0
55
jupyter==1.0.0
66
scikit-learn==0.24.2
7-
pylint==2.15.6
8-
pytest==6.2.4
9-
setuptools
10-
wheel
11-
xgboost==1.4.2
12-
twine==3.4.2
7+
pylint==2.15.7
8+
pytest==7.2.0
9+
setuptools==65.6.3
10+
wheel==0.38.4
11+
xgboost==1.6.2
12+
twine==4.0.2
1313
pyarrow==7.0.0
1414
numpydoc
1515
matplotlib==3.5.1

scripts/local.sh

100644100755
File mode changed.

src/trustyai/metrics/fairness/__init__.py

Whitespace-only changes.
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
"""Group fairness metrics"""
2+
# pylint: disable = import-error
3+
from typing import List, Optional, Any, Union
4+
5+
import pandas as pd
6+
from jpype import JInt
7+
from org.kie.trustyai.explainability.metrics import FairnessMetrics
8+
9+
from trustyai.model import Output, Value, PredictionProvider, Model
10+
from trustyai.utils.data_conversions import pandas_to_trusty
11+
12+
ColumSelector = Union[List[int], List[str]]
13+
14+
15+
def _column_selector_to_index(columns: ColumSelector, dataframe: pd.DataFrame):
16+
if isinstance(columns[0], str): # passing column
17+
columns = dataframe.columns.get_indexer(columns)
18+
indices = [JInt(c) for c in columns] # Java casting
19+
return indices
20+
21+
22+
def statistical_parity_difference(
23+
privileged: pd.DataFrame,
24+
unprivileged: pd.DataFrame,
25+
favorable: List[Output],
26+
outputs: Optional[List[int]] = None,
27+
) -> float:
28+
"""Calculate Statistical Parity Difference between privileged and unprivileged dataframes"""
29+
return FairnessMetrics.groupStatisticalParityDifference(
30+
pandas_to_trusty(privileged, outputs),
31+
pandas_to_trusty(unprivileged, outputs),
32+
favorable,
33+
)
34+
35+
36+
# pylint: disable = line-too-long
37+
def statistical_parity_difference_model(
38+
samples: pd.DataFrame,
39+
model: Union[PredictionProvider, Model],
40+
privilege_columns: ColumSelector,
41+
privilege_values: List[Any],
42+
favorable: List[Output],
43+
) -> float:
44+
"""Calculate Statistical Parity Difference using a samples dataframe and a model"""
45+
_privilege_values = [Value(v) for v in privilege_values]
46+
_jsamples = pandas_to_trusty(samples, no_outputs=True)
47+
return FairnessMetrics.groupStatisticalParityDifference(
48+
_jsamples,
49+
model,
50+
_column_selector_to_index(privilege_columns, samples),
51+
_privilege_values,
52+
favorable,
53+
)
54+
55+
56+
def disparate_impact_ratio(
57+
privileged: pd.DataFrame,
58+
unprivileged: pd.DataFrame,
59+
favorable: List[Output],
60+
outputs: Optional[List[int]] = None,
61+
) -> float:
62+
"""Calculate Disparate Impact Ration between privileged and unprivileged dataframes"""
63+
return FairnessMetrics.groupDisparateImpactRatio(
64+
pandas_to_trusty(privileged, outputs),
65+
pandas_to_trusty(unprivileged, outputs),
66+
favorable,
67+
)
68+
69+
70+
# pylint: disable = line-too-long
71+
def disparate_impact_ratio_model(
72+
samples: pd.DataFrame,
73+
model: Union[PredictionProvider, Model],
74+
privilege_columns: ColumSelector,
75+
privilege_values: List[Any],
76+
favorable: List[Output],
77+
) -> float:
78+
"""Calculate Disparate Impact Ration using a samples dataframe and a model"""
79+
_privilege_values = [Value(v) for v in privilege_values]
80+
_jsamples = pandas_to_trusty(samples, no_outputs=True)
81+
return FairnessMetrics.groupDisparateImpactRatio(
82+
_jsamples,
83+
model,
84+
_column_selector_to_index(privilege_columns, samples),
85+
_privilege_values,
86+
favorable,
87+
)
88+
89+
90+
# pylint: disable = too-many-arguments
91+
def average_odds_difference(
92+
test: pd.DataFrame,
93+
truth: pd.DataFrame,
94+
privilege_columns: ColumSelector,
95+
privilege_values: List[Any],
96+
positive_class: List[Any],
97+
outputs: Optional[List[int]] = None,
98+
) -> float:
99+
"""Calculate Average Odds between two dataframes"""
100+
if test.shape != truth.shape:
101+
raise ValueError(
102+
f"Dataframes have different shapes ({test.shape} and {truth.shape})"
103+
)
104+
_privilege_values = [Value(v) for v in privilege_values]
105+
_positive_class = [Value(v) for v in positive_class]
106+
# determine privileged columns
107+
_privilege_columns = _column_selector_to_index(privilege_columns, test)
108+
return FairnessMetrics.groupAverageOddsDifference(
109+
pandas_to_trusty(test, outputs),
110+
pandas_to_trusty(truth, outputs),
111+
_privilege_columns,
112+
_privilege_values,
113+
_positive_class,
114+
)
115+
116+
117+
def average_odds_difference_model(
118+
samples: pd.DataFrame,
119+
model: Union[PredictionProvider, Model],
120+
privilege_columns: ColumSelector,
121+
privilege_values: List[Any],
122+
positive_class: List[Any],
123+
) -> float:
124+
"""Calculate Average Odds for a sample dataframe using the provided model"""
125+
_jsamples = pandas_to_trusty(samples, no_outputs=True)
126+
_privilege_values = [Value(v) for v in privilege_values]
127+
_positive_class = [Value(v) for v in positive_class]
128+
# determine privileged columns
129+
_privilege_columns = _column_selector_to_index(privilege_columns, samples)
130+
return FairnessMetrics.groupAverageOddsDifference(
131+
_jsamples, model, _privilege_columns, _privilege_values, _positive_class
132+
)
133+
134+
135+
def average_predictive_value_difference(
136+
test: pd.DataFrame,
137+
truth: pd.DataFrame,
138+
privilege_columns: ColumSelector,
139+
privilege_values: List[Any],
140+
positive_class: List[Any],
141+
outputs: Optional[List[int]] = None,
142+
) -> float:
143+
"""Calculate Average Predictive Value Difference between two dataframes"""
144+
if test.shape != truth.shape:
145+
raise ValueError(
146+
f"Dataframes have different shapes ({test.shape} and {truth.shape})"
147+
)
148+
_privilege_values = [Value(v) for v in privilege_values]
149+
_positive_class = [Value(v) for v in positive_class]
150+
_privilege_columns = _column_selector_to_index(privilege_columns, test)
151+
return FairnessMetrics.groupAveragePredictiveValueDifference(
152+
pandas_to_trusty(test, outputs),
153+
pandas_to_trusty(truth, outputs),
154+
_privilege_columns,
155+
_privilege_values,
156+
_positive_class,
157+
)
158+
159+
160+
# pylint: disable = line-too-long
161+
def average_predictive_value_difference_model(
162+
samples: pd.DataFrame,
163+
model: Union[PredictionProvider, Model],
164+
privilege_columns: ColumSelector,
165+
privilege_values: List[Any],
166+
positive_class: List[Any],
167+
) -> float:
168+
"""Calculate Average Predictive Value Difference for a sample dataframe using the provided model"""
169+
_jsamples = pandas_to_trusty(samples, no_outputs=True)
170+
_privilege_values = [Value(v) for v in privilege_values]
171+
_positive_class = [Value(v) for v in positive_class]
172+
# determine privileged columns
173+
_privilege_columns = _column_selector_to_index(privilege_columns, samples)
174+
return FairnessMetrics.groupAveragePredictiveValueDifference(
175+
_jsamples, model, _privilege_columns, _privilege_values, _positive_class
176+
)

src/trustyai/metrics/saliency.py

Lines changed: 36 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
from org.kie.trustyai.explainability.model import (
1010
PredictionInput,
11-
PredictionInputsDataDistribution
11+
PredictionInputsDataDistribution,
1212
)
1313
from org.kie.trustyai.explainability.local import LocalExplainer
1414

@@ -20,9 +20,13 @@
2020
from . import ExplainabilityMetrics
2121

2222

23-
def impact_score(model: PredictionProvider, pred_input: PredictionInput,
24-
explainer: Union[LimeExplainer, SHAPExplainer],
25-
k: int, is_model_callable: bool = False):
23+
def impact_score(
24+
model: PredictionProvider,
25+
pred_input: PredictionInput,
26+
explainer: Union[LimeExplainer, SHAPExplainer],
27+
k: int,
28+
is_model_callable: bool = False,
29+
):
2630
"""
2731
Parameters
2832
----------
@@ -53,8 +57,13 @@ def impact_score(model: PredictionProvider, pred_input: PredictionInput,
5357
return ExplainabilityMetrics.impactScore(model, pred, top_k_features)
5458

5559

56-
def mean_impact_score(explainer: Union[LimeExplainer, SHAPExplainer],
57-
model: PredictionProvider, data: list, is_model_callable=False, k=2):
60+
def mean_impact_score(
61+
explainer: Union[LimeExplainer, SHAPExplainer],
62+
model: PredictionProvider,
63+
data: list,
64+
is_model_callable=False,
65+
k=2,
66+
):
5867
"""
5968
Parameters
6069
----------
@@ -76,13 +85,18 @@ def mean_impact_score(explainer: Union[LimeExplainer, SHAPExplainer],
7685
"""
7786
m_is = 0
7887
for features in data:
79-
m_is += impact_score(model, features, explainer, k, is_model_callable=is_model_callable)
88+
m_is += impact_score(
89+
model, features, explainer, k, is_model_callable=is_model_callable
90+
)
8091
return m_is / len(data)
8192

8293

83-
def classification_fidelity(explainer: Union[LimeExplainer, SHAPExplainer],
84-
model: PredictionProvider, inputs: list,
85-
is_model_callable: bool = False):
94+
def classification_fidelity(
95+
explainer: Union[LimeExplainer, SHAPExplainer],
96+
model: PredictionProvider,
97+
inputs: list,
98+
is_model_callable: bool = False,
99+
):
86100
"""
87101
Parameters
88102
----------
@@ -111,11 +125,16 @@ def classification_fidelity(explainer: Union[LimeExplainer, SHAPExplainer],
111125
pairs.append(_Pair.of(saliency, simple_prediction(c_input, output)))
112126
return ExplainabilityMetrics.classificationFidelity(pairs)
113127

128+
114129
# pylint: disable = too-many-arguments
115-
def local_saliency_f1(output_name: str, model: PredictionProvider,
116-
explainer: Union[LimeExplainer, SHAPExplainer],
117-
distribution: PredictionInputsDataDistribution, k: int,
118-
chunk_size: int):
130+
def local_saliency_f1(
131+
output_name: str,
132+
model: PredictionProvider,
133+
explainer: Union[LimeExplainer, SHAPExplainer],
134+
distribution: PredictionInputsDataDistribution,
135+
k: int,
136+
chunk_size: int,
137+
):
119138
"""
120139
Parameters
121140
----------
@@ -143,5 +162,6 @@ def local_saliency_f1(output_name: str, model: PredictionProvider,
143162
local_explainer = JObject(explainer._explainer, LocalExplainer)
144163
else:
145164
local_explainer = explainer
146-
return ExplainabilityMetrics.getLocalSaliencyF1(output_name, model, local_explainer,
147-
distribution, k, chunk_size)
165+
return ExplainabilityMetrics.getLocalSaliencyF1(
166+
output_name, model, local_explainer, distribution, k, chunk_size
167+
)

src/trustyai/utils/data_conversions.py

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@
22
# pylint: disable = import-error, line-too-long, trailing-whitespace, unused-import, cyclic-import
33
# pylint: disable = consider-using-f-string, invalid-name, wrong-import-order
44
import warnings
5-
from typing import Union, List
5+
from typing import Union, List, Optional
6+
from itertools import filterfalse
67

78
import trustyai.model
89
from trustyai.model.domain import feature_domain
910
from org.kie.trustyai.explainability.model import (
11+
Dataframe,
1012
Feature,
1113
Output,
1214
PredictionInput,
@@ -417,3 +419,39 @@ def prediction_object_to_pandas(
417419
]
418420
)
419421
return df
422+
423+
424+
def pandas_to_trusty(
425+
df: pd.DataFrame, outputs: Optional[List[int]] = None, no_outputs=False
426+
) -> Dataframe:
427+
"""
428+
Converts a Pandas :class:`pandas.DataFrame` into a TrustyAI :class:`Dataframe`.
429+
Either outputs can be provided as a list of column indices or `no_outputs` can be specified, for an inputs-only
430+
:class:`Dataframe`.
431+
432+
Parameters
433+
----------
434+
outputs : List[int]
435+
Optional list of column indices to be marked as outputs
436+
437+
no_outputs : bool
438+
Specify if the :class:`Dataframe` is inputs-only
439+
"""
440+
df = df.reset_index(drop=True)
441+
n_columns = len(df.columns)
442+
indices = list(range(n_columns))
443+
if not no_outputs:
444+
if not outputs: # If no output column supplied, assume the right-most
445+
output_indices = [n_columns - 1]
446+
input_indices = list(filterfalse(output_indices.__contains__, indices))
447+
else:
448+
output_indices = outputs
449+
input_indices = list(filterfalse(outputs.__contains__, indices))
450+
451+
pi = many_inputs_convert(df.iloc[:, input_indices])
452+
po = many_outputs_convert(df.iloc[:, output_indices])
453+
454+
return Dataframe.createFrom(pi, po)
455+
456+
pi = many_inputs_convert(df)
457+
return Dataframe.createFromInputs(pi)

tests/general/data/income-biased.zip

22.1 KB
Binary file not shown.
22.2 KB
Binary file not shown.
402 KB
Binary file not shown.

0 commit comments

Comments
 (0)