"""Ready-to-use fairness constraints.
Each function returns a constraint in the reverse-Polish (postfix) notation that
:class:`~fair_seldonian.config.SeldonianConfig` expects, so you can plug a named
fairness definition straight into the algorithm without hand-writing the string::
from fair_seldonian import SeldonianConfig, demographic_parity
config = SeldonianConfig(constraint=demographic_parity(epsilon=0.1))
The constraints are written over the base variables this library exposes as
primitives, of which there are three kinds:
* **cells** ``TP(g)``, ``FP(g)``, ``FN(g)``, ``TN(g)`` - each a *fraction of
group* ``g``, the four summing to 1 within a group;
* **label-conditioned rates** ``TPR(g)``, ``FPR(g)``, ``TNR(g)``, ``FNR(g)`` -
``TPR(g)`` is ``P(Y-hat = 1 | Y = 1, A = g)``, a mean over only that group's
positive rows;
* **predicted rates** ``PR(g)``, ``NR(g)`` - ``PR(g)`` is
``P(Y-hat = 1 | A = g)``, equal to ``TP(g) + FP(g)``.
The builders use whichever primitive expresses the definition in the fewest
leaves and without division. Demographic parity is written over ``PR(g)`` rather
than ``TP(g) + FP(g)`` (two leaves instead of four), and equal opportunity over
``TPR(g)`` rather than ``TP(g) / (TP(g) + FN(g))``. The identities hold either
way, but every leaf spends its own slice of ``delta``, and division would put the
constraint outside the affine fragment.
Every constraint encodes ``g(theta) <= 0`` and is bounded by a tolerance
``epsilon`` (smaller is stricter). Most bound a between-group *gap* and take a
``groups`` pair; :func:`error_rate` instead bounds a *single* group's error rate
and takes one ``group``. Group labels are matched against ``str(T)``, so the
defaults ``("1", "0")`` line up with a 0/1 sensitive column.
.. note::
``equal_opportunity`` and ``equalized_odds`` still need more data to certify
than the others, but not because of division - none of the builders divide.
Their leaves are *label-conditioned*, so each averages over a subset of its
group (only the positives, or only the negatives) and carries a smaller sample
size, which widens the interval. ``equalized_odds`` additionally spends four
slices of ``delta`` rather than two.
"""
from __future__ import annotations
__all__ = [
"FAIRNESS_CONSTRAINTS",
"demographic_parity",
"equal_opportunity",
"equalized_odds",
"error_rate",
"error_rate_parity",
]
def _check_epsilon(epsilon: float) -> None:
"""Reject a negative tolerance."""
if epsilon < 0:
raise ValueError(f"epsilon must be non-negative, got {epsilon}")
def _check_label(group: object) -> str:
"""Validate a single group label and return it as a postfix-safe string."""
g = str(group)
if not g:
raise ValueError("group labels must be non-empty")
if any(c.isspace() for c in g) or "(" in g or ")" in g:
raise ValueError(
f"group label {g!r} must not contain whitespace or parentheses"
)
return g
def _validate(epsilon: float, groups: tuple[object, object]) -> tuple[str, str]:
"""Validate the tolerance and a pair of group labels; return them as strings."""
_check_epsilon(epsilon)
if len(groups) != 2:
raise ValueError(f"groups must contain exactly two labels, got {groups!r}")
g1, g0 = (_check_label(g) for g in groups)
if g1 == g0:
raise ValueError(f"the two groups must be distinct, got {groups!r}")
return g1, g0
def _num(epsilon: float) -> str:
"""Render the tolerance as a single postfix token (no spaces)."""
return repr(float(epsilon))
def _positive_rate(g: str) -> str:
"""Predicted-positive rate for group ``g``: ``PR(g)``.
Written as a primitive rather than as ``TP(g) + FP(g)``. The two denote the
same quantity - the fraction of group ``g`` predicted positive, whatever the
label - but ``PR(g)`` is a single base variable, so demographic parity costs
two leaves rather than four. Every leaf spends its own slice of ``delta``, so
halving their number tightens the bound.
"""
return f"PR({g})"
def _true_positive_rate(g: str) -> str:
"""True-positive rate (recall) for group ``g``: ``TPR(g)``.
Written as a primitive rather than as ``TP(g) / (TP(g) + FN(g))``. The two
denote the same quantity, but ``TPR(g)`` is a single base variable - a mean
over the rows of group ``g`` with ``Y = 1``, carrying its own sample size -
whereas the ratio form needs a division. Division puts the constraint outside
the affine fragment and forces interval arithmetic, which on equalized odds
roughly doubles the slack.
"""
return f"TPR({g})"
def _false_positive_rate(g: str) -> str:
"""False-positive rate for group ``g``: ``FPR(g)``.
A primitive for the same reason as :func:`_true_positive_rate`.
"""
return f"FPR({g})"
def _error_rate(g: str) -> str:
"""Misclassification rate for group ``g``: ``FP(g) + FN(g)``."""
return f"FP({g}) FN({g}) +"
[docs]
def demographic_parity(
epsilon: float = 0.1, groups: tuple[object, object] = ("1", "0")
) -> str:
"""Demographic parity: equal predicted-positive rate across groups.
Bounds ``|P(Y-hat = 1 | A = g1) - P(Y-hat = 1 | A = g0)| <= epsilon``.
Also called *statistical parity* or the *independence* criterion: it requires
the prediction to be statistically independent of the sensitive attribute, so
each group is predicted positive at the same rate **regardless of the true
label** ``Y``. Because it ignores ``Y``, it is a natural target for
*allocative* decisions - where a positive prediction grants access to a
benefit and the goal is equal access across groups - but it can be satisfied
by a model that is deliberately less accurate for one group. It is
division-free and therefore the easiest of these criteria to certify.
References:
- Dwork, C., Hardt, M., Pitassi, T., Reingold, O., & Zemel, R. (2012).
Fairness through awareness. *ITCS '12*. https://arxiv.org/abs/1104.3913
- Barocas, S., Hardt, M., & Narayanan, A. (2023). *Fairness and Machine
Learning: Limitations and Opportunities* (independence criterion).
MIT Press. https://fairmlbook.org
:param epsilon: maximum allowed gap between the groups' positive rates.
:param groups: the two sensitive-attribute values ``(g1, g0)`` to compare.
:return: the constraint in postfix notation.
"""
g1, g0 = _validate(epsilon, groups)
return f"{_positive_rate(g1)} {_positive_rate(g0)} - abs {_num(epsilon)} -"
[docs]
def equal_opportunity(
epsilon: float = 0.1, groups: tuple[object, object] = ("1", "0")
) -> str:
"""Equal opportunity: equal true-positive rate (recall) across groups.
Bounds ``|TPR(g1) - TPR(g0)| <= epsilon``, where
``TPR(g) = P(Y-hat = 1 | Y = 1, A = g)``.
Introduced by Hardt, Price & Srebro (2016) as the single-error-rate relaxation
of equalized odds. Unlike demographic parity it is *conditioned on the true
label*, so it only asks that qualified members (those with ``Y = 1``) have an
equal chance of a positive prediction across groups, and does not penalise a
model for differing base rates between groups. It is the right target when the
cost of a missed positive (a false negative) is what must be shared fairly -
e.g. equal recall among applicants who truly qualify.
References:
- Hardt, M., Price, E., & Srebro, N. (2016). Equality of opportunity in
supervised learning. *NeurIPS 2016*. https://arxiv.org/abs/1610.02413
:param epsilon: maximum allowed gap between the groups' true-positive rates.
:param groups: the two sensitive-attribute values ``(g1, g0)`` to compare.
:return: the constraint in postfix notation.
"""
g1, g0 = _validate(epsilon, groups)
return (
f"{_true_positive_rate(g1)} {_true_positive_rate(g0)} - abs {_num(epsilon)} -"
)
[docs]
def equalized_odds(
epsilon: float = 0.1, groups: tuple[object, object] = ("1", "0")
) -> str:
"""Equalized odds: equal true- and false-positive rates across groups.
Bounds the *sum* of the two gaps by ``epsilon``::
|TPR(g1) - TPR(g0)| + |FPR(g1) - FPR(g0)| <= epsilon
where ``FPR(g) = P(Y-hat = 1 | Y = 0, A = g)``.
Introduced by Hardt, Price & Srebro (2016) as the *separation* criterion: the
prediction must be independent of the sensitive attribute *conditional on the
true label*, i.e. groups must be matched on both true-positive **and**
false-positive rate. It therefore controls unfairness for both qualified and
unqualified members, and is stricter than :func:`equal_opportunity`, which
bounds the true-positive gap alone. Here both gaps are required to fit within a
single tolerance ``epsilon``.
References:
- Hardt, M., Price, E., & Srebro, N. (2016). Equality of opportunity in
supervised learning. *NeurIPS 2016*. https://arxiv.org/abs/1610.02413
:param epsilon: maximum allowed sum of the true- and false-positive gaps.
:param groups: the two sensitive-attribute values ``(g1, g0)`` to compare.
:return: the constraint in postfix notation.
"""
g1, g0 = _validate(epsilon, groups)
tpr_gap = f"{_true_positive_rate(g1)} {_true_positive_rate(g0)} - abs"
fpr_gap = f"{_false_positive_rate(g1)} {_false_positive_rate(g0)} - abs"
return f"{tpr_gap} {fpr_gap} + {_num(epsilon)} -"
[docs]
def error_rate(epsilon: float = 0.1, group: object = "1") -> str:
"""Error rate: bound a single group's misclassification rate.
Bounds ``P(Y-hat != Y | A = group) = FP(group) + FN(group) <= epsilon`` - one
minus that group's accuracy. Unlike the other builders this is a *performance*
(behavioral) bound on one group rather than a between-group comparison: use it
to cap how often the model errs on a chosen subpopulation (or on the whole
sample, when the data is treated as a single group). Pair it with a parity
constraint when you want error to be both *low* and *equal*. It is
division-free.
References:
- Thomas, P. S., da Silva, B. C., Barto, A. G., Giguère, S., Brun, Y., &
Brunskill, E. (2019). Preventing undesirable behavior of intelligent
machines. *Science*, 366(6468), 999-1004 (behavioral constraints such
as bounded error). https://doi.org/10.1126/science.aag3311
:param epsilon: maximum allowed misclassification rate for the group.
:param group: the sensitive-attribute value whose error rate is bounded.
:return: the constraint in postfix notation.
"""
_check_epsilon(epsilon)
g = _check_label(group)
return f"{_error_rate(g)} {_num(epsilon)} -"
[docs]
def error_rate_parity(
epsilon: float = 0.1, groups: tuple[object, object] = ("1", "0")
) -> str:
"""Error-rate parity: equal misclassification rate across groups.
Bounds ``|err(g1) - err(g0)| <= epsilon``, where
``err(g) = P(Y-hat != Y | A = g) = FP(g) + FN(g)``.
Also called *overall accuracy equality*: it asks that the model be right
equally often for each group, without constraining *which* kind of error
(false positive or false negative) may differ. It is division-free like
demographic parity. Note that equal *total* error can still hide a group whose
mistakes are mostly false negatives while another's are mostly false positives;
use :func:`equalized_odds` when the error *types* must match too.
References:
- Berk, R., Heidari, H., Jabbari, S., Kearns, M., & Roth, A. (2021).
Fairness in criminal justice risk assessments: The state of the art.
*Sociological Methods & Research*, 50(1), 3-44 (overall accuracy
equality). https://arxiv.org/abs/1703.09207
- Barocas, S., Hardt, M., & Narayanan, A. (2023). *Fairness and Machine
Learning: Limitations and Opportunities*. MIT Press. https://fairmlbook.org
:param epsilon: maximum allowed gap between the groups' error rates.
:param groups: the two sensitive-attribute values ``(g1, g0)`` to compare.
:return: the constraint in postfix notation.
"""
g1, g0 = _validate(epsilon, groups)
return f"{_error_rate(g1)} {_error_rate(g0)} - abs {_num(epsilon)} -"
#: Group-parity builders, keyed by name. Each takes ``(epsilon, groups)`` and
#: bounds a between-group gap. :func:`error_rate` is a single-group performance
#: bound with a different signature and is intentionally not included here.
FAIRNESS_CONSTRAINTS = {
"demographic_parity": demographic_parity,
"equal_opportunity": equal_opportunity,
"equalized_odds": equalized_odds,
"error_rate_parity": error_rate_parity,
}