Skip to content

Commit 2fdf44b

Browse files
authored
Merge pull request #1980 from bluewave-labs/develop
Develop -> Master (26 Aug)
2 parents 5496e1e + d957ee0 commit 2fdf44b

File tree

112 files changed

+5550
-691
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

112 files changed

+5550
-691
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,4 @@ venv/
1313
express.log
1414
fastapi.log
1515
react.log
16+
.idea/

BiasAndFairnessModule/.gitignore

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,2 @@
1-
<<<<<<< HEAD
21
*.zip
3-
=======
42
/artifacts
5-
>>>>>>> upstream/develop
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import unittest
2+
3+
import numpy as np
4+
5+
6+
class BaseMetricsTestCase(unittest.TestCase):
7+
"""
8+
Shared base class for fairness metric tests.
9+
10+
Generates a small, reproducible synthetic dataset with bias across a
11+
protected attribute. Exposes:
12+
- self.n_samples
13+
- self.protected_attributes (group 0/1)
14+
- self.y_true (0/1)
15+
- self.y_pred (0/1)
16+
- self.y_scores ([0, 1])
17+
- self.legitimate_attributes (categorical strata for conditional metrics)
18+
"""
19+
20+
def setUp(self):
21+
# Global seed for reproducibility across all tests inheriting this base
22+
np.random.seed(42)
23+
24+
# Match the provided generation spec exactly for deterministic results
25+
self.n_samples = 500
26+
27+
# Protected attribute with class imbalance
28+
self.protected_attributes = np.random.choice(
29+
[0, 1], size=self.n_samples, p=[0.7, 0.3]
30+
).astype(int)
31+
32+
# Ground truth labels
33+
self.y_true = np.random.choice(
34+
[0, 1], size=self.n_samples, p=[0.6, 0.4]
35+
).astype(int)
36+
37+
# Biased predictions (group 1 has higher positive rate)
38+
self.y_pred = np.zeros(self.n_samples, dtype=int)
39+
for i in range(self.n_samples):
40+
if self.protected_attributes[i] == 0:
41+
self.y_pred[i] = np.random.choice([0, 1], p=[0.8, 0.2])
42+
else:
43+
self.y_pred[i] = np.random.choice([0, 1], p=[0.6, 0.4])
44+
45+
# Continuous scores (clipped to [0,1] while preserving bias pattern)
46+
self.y_scores = (
47+
np.random.random(self.n_samples) * 0.8
48+
) # Scale down initial values
49+
self.y_scores[
50+
self.protected_attributes == 1
51+
] += 0.2 # Add bias while staying in [0,1]
52+
53+
# Legitimate attribute for conditional metrics (e.g., conditional statistical parity)
54+
self.legitimate_attributes = np.random.choice(
55+
[0, 1, 2], size=self.n_samples, p=[0.5, 0.3, 0.2]
56+
).astype(int)
57+
58+
59+
# Intentionally no tests here; concrete test classes should inherit from BaseMetricsTestCase.
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import unittest
2+
3+
from src.metrics import balance_negative_class
4+
from tests.base_test_metrics import BaseMetricsTestCase
5+
6+
7+
class TestBalanceNegativeClass(BaseMetricsTestCase):
8+
def test_balance_negative_class_ratio_and_difference(self):
9+
metric_frame = balance_negative_class(
10+
y_true=self.y_true,
11+
y_pred_proba=self.y_scores,
12+
protected_attributes=self.protected_attributes,
13+
)
14+
15+
ratio = float(metric_frame.ratio(method="between_groups"))
16+
difference = float(metric_frame.difference(method="between_groups"))
17+
18+
self.assertAlmostEqual(ratio, 0.684072790, places=9)
19+
self.assertAlmostEqual(difference, 0.185452927, places=9)
20+
21+
22+
if __name__ == "__main__":
23+
unittest.main()
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import unittest
2+
3+
from src.metrics import balance_positive_class
4+
from tests.base_test_metrics import BaseMetricsTestCase
5+
6+
7+
class TestBalancePositiveClass(BaseMetricsTestCase):
8+
def test_balance_positive_class_ratio_and_difference(self):
9+
metric_frame = balance_positive_class(
10+
y_true=self.y_true,
11+
y_pred_proba=self.y_scores,
12+
protected_attributes=self.protected_attributes,
13+
)
14+
15+
ratio = float(metric_frame.ratio(method="between_groups"))
16+
difference = float(metric_frame.difference(method="between_groups"))
17+
18+
self.assertAlmostEqual(ratio, 0.648702328, places=9)
19+
self.assertAlmostEqual(difference, 0.213191682, places=9)
20+
21+
22+
if __name__ == "__main__":
23+
unittest.main()
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import unittest
2+
3+
from src.metrics import calibration
4+
from tests.base_test_metrics import BaseMetricsTestCase
5+
6+
7+
class TestCalibrationMetric(BaseMetricsTestCase):
8+
def test_calibration_ratio_and_difference(self):
9+
metric_frame = calibration(
10+
y_true=self.y_true,
11+
y_pred_proba=self.y_scores,
12+
protected_attributes=self.protected_attributes,
13+
)
14+
15+
ratio = float(metric_frame.ratio(method="between_groups"))
16+
difference = float(metric_frame.difference(method="between_groups"))
17+
18+
self.assertAlmostEqual(ratio, 0.8464818194, places=10)
19+
self.assertAlmostEqual(difference, 0.051970213833, places=12)
20+
21+
22+
if __name__ == "__main__":
23+
unittest.main()
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import unittest
2+
3+
from src.metrics import conditional_statistical_parity
4+
from tests.base_test_metrics import BaseMetricsTestCase
5+
6+
7+
class TestConditionalStatisticalParity(BaseMetricsTestCase):
8+
def test_conditional_statistical_parity_values(self):
9+
result = conditional_statistical_parity(
10+
y_pred=self.y_pred,
11+
protected_attributes=self.protected_attributes,
12+
legitimate_attributes=self.legitimate_attributes,
13+
)
14+
15+
# Expected values as specified
16+
expected = [
17+
{
18+
"stratum": "0",
19+
"group_selection_rates": {"0": 0.19886363636363635, "1": 0.48},
20+
"disparity": 0.28113636363636363,
21+
},
22+
{
23+
"stratum": "2",
24+
"group_selection_rates": {"0": 0.2753623188405797, "1": 0.5625},
25+
"disparity": 0.2871376811594203,
26+
},
27+
{
28+
"stratum": "1",
29+
"group_selection_rates": {
30+
"0": 0.2376237623762376,
31+
"1": 0.44680851063829785,
32+
},
33+
"disparity": 0.20918474826206024,
34+
},
35+
]
36+
37+
# Compare ignoring list order by indexing by stratum
38+
result_by_stratum = {entry["stratum"]: entry for entry in result}
39+
expected_by_stratum = {entry["stratum"]: entry for entry in expected}
40+
41+
self.assertEqual(set(result_by_stratum.keys()), set(expected_by_stratum.keys()))
42+
43+
# Tolerance for floating point comparisons
44+
for stratum, expected_entry in expected_by_stratum.items():
45+
self.assertIn(stratum, result_by_stratum)
46+
got_entry = result_by_stratum[stratum]
47+
48+
# Check group_selection_rates
49+
exp_rates = expected_entry["group_selection_rates"]
50+
got_rates = got_entry["group_selection_rates"]
51+
self.assertEqual(set(exp_rates.keys()), set(got_rates.keys()))
52+
for group_key, exp_val in exp_rates.items():
53+
self.assertAlmostEqual(got_rates[group_key], exp_val, places=12)
54+
55+
# Check disparity
56+
self.assertAlmostEqual(
57+
got_entry["disparity"], expected_entry["disparity"], places=12
58+
)
59+
60+
61+
if __name__ == "__main__":
62+
unittest.main()
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import unittest
2+
3+
from src.metrics import conditional_use_accuracy_equality
4+
from tests.base_test_metrics import BaseMetricsTestCase
5+
6+
7+
class TestConditionalUseAccuracyEquality(BaseMetricsTestCase):
8+
def test_npv_and_ppv_ratio_and_difference(self):
9+
result = conditional_use_accuracy_equality(
10+
y_true=self.y_true,
11+
y_pred=self.y_pred,
12+
protected_attributes=self.protected_attributes,
13+
)
14+
15+
# NPV checks
16+
npv_ratio = float(result.npv.ratio(method="between_groups"))
17+
npv_diff = float(result.npv.difference(method="between_groups"))
18+
self.assertAlmostEqual(npv_ratio, 0.943449929, places=9)
19+
self.assertAlmostEqual(npv_diff, 0.035660306, places=9)
20+
21+
# PPV checks
22+
ppv_ratio = float(result.ppv.ratio(method="between_groups"))
23+
ppv_diff = float(result.ppv.difference(method="between_groups"))
24+
self.assertAlmostEqual(ppv_ratio, 0.78, places=9)
25+
self.assertAlmostEqual(ppv_diff, 0.078974359, places=9)
26+
27+
28+
if __name__ == "__main__":
29+
unittest.main()
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import unittest
2+
3+
import numpy as np
4+
5+
from src.metrics import demographic_parity
6+
from tests.base_test_metrics import BaseMetricsTestCase
7+
8+
9+
class TestDemographicParity(BaseMetricsTestCase):
10+
def test_value_in_range(self):
11+
value = demographic_parity(self.y_true, self.y_pred, self.protected_attributes)
12+
self.assertTrue(0.0 <= value <= 1.0)
13+
14+
def test_value_close_to_expected(self):
15+
value = demographic_parity(self.y_true, self.y_pred, self.protected_attributes)
16+
self.assertTrue(
17+
np.isclose(value, 0.26157, atol=0.05),
18+
msg=f"demographic_parity={value}",
19+
)
20+
21+
22+
if __name__ == "__main__":
23+
unittest.main()
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import unittest
2+
3+
import numpy as np
4+
5+
from src.metrics import equal_selection_parity
6+
from tests.base_test_metrics import BaseMetricsTestCase
7+
8+
9+
class TestEqualSelectionParity(BaseMetricsTestCase):
10+
def test_output_and_expected_counts(self):
11+
result = equal_selection_parity(
12+
self.y_true, self.y_pred, self.protected_attributes
13+
)
14+
15+
# Output format: keys are np.int64 group labels, values are Python ints
16+
self.assertIn(np.int64(0), result)
17+
self.assertIn(np.int64(1), result)
18+
self.assertIsInstance(result[np.int64(0)], int)
19+
self.assertIsInstance(result[np.int64(1)], int)
20+
21+
# Exact expected counts
22+
self.assertEqual(result[np.int64(0)], 78)
23+
self.assertEqual(result[np.int64(1)], 75)
24+
25+
26+
if __name__ == "__main__":
27+
unittest.main()

0 commit comments

Comments
 (0)