Werkplaats / De werkplaats
In jouw eigen tempo
← AI in de wereld
MODULE 03 · LES 06

Vergelijk groepsmaten met de juiste noemers

Tel groepsbeslissingen, onderscheid selectie/TPR/FPR en behoud onbekende maten zonder een algemeen fairnessoordeel.

75 min3 checkpoints
DIT GA JE ONTDEKKEN
  • Je telt TP/FP/TN/FN per expliciete groep en berekent drie verschillende conditionele maten.
  • Je onderscheidt demographic parity, equal opportunity en equalized odds en bewaart ontbrekende groepsinformatie.

Een helpdeskmodel kiest welke meldingen extra aandacht krijgen. Het team wil weten of groepen op dezelfde manier worden behandeld. Welke fouten moet je daarvoor vergelijken?

Denk aan twee bakken met kaartjes: urgente en gewone meldingen. Je kunt tellen hoeveel kaartjes worden geselecteerd, hoeveel urgente kaartjes worden gevonden, of hoeveel gewone kaartjes onterecht worden geselecteerd. Die vragen hebben verschillende noemers.

Je gebruikt uitsluitend verzonnen groepen a en b met volledige, gedeclareerde binaire labels. Een groepsmaat beschrijft dit voorbeeld; hij bepaalt niet zelfstandig wat maatschappelijk eerlijk is. Eerst maak je zichtbaar wat je eigenlijk vergelijkt.

1. Bewaar de vier soorten beslissingen

Een echte positieve beslissing (true positive, TP) vindt een urgent kaartje. Een foutpositieve beslissing (false positive, FP) selecteert een gewoon kaartje. TN en FN tellen respectievelijk terecht overgeslagen gewone kaartjes en gemiste urgente kaartjes.

Groep a heeft twee urgente en twee gewone meldingen. Het model vindt één urgente melding en selecteert daarnaast één gewone. Groep b heeft één urgente en drie gewone meldingen; daar worden alle vier correct behandeld.

| Groep | TP | FP | TN | FN | |---|---:|---:|---:|---:| | a | 1 | 1 | 1 | 1 | | b | 1 | 0 | 3 | 0 |

y = [1,1,0,0,1,0,0,0]
p = [1,0,1,0,1,0,0,0]
g = ['a']*4 + ['b']*4
counts = group_confusions(
    y,p,g,['a','b'])
for row in counts:
    print(row['tp'],row['fp'])
# 1 1
# 1 0

Een expliciet gevraagde groep blijft ook zonder waargenomen rijen aanwezig, met vier nullen. Zo verdwijnt ontbrekende informatie niet stilletjes uit het verslag. De groepsvolgorde blijft de volgorde die jij hebt opgegeven.

01
CHECKPOINTBewaar alle vier besliscellen per groep

2. Kies de juiste bak als noemer

De selectieratio deelt geselecteerde meldingen door alle meldingen. Het vindpercentage voor positieven (true positive rate, TPR) kijkt alleen naar urgente meldingen. Het foutpositieve percentage (false positive rate, FPR) kijkt juist alleen naar gewone meldingen.

VERKEN HET ZELFDezelfde tellingen beantwoorden drie verschillende vragen

Kies welke kaartjes in de noemer horen. Bekijk ook een groep zonder positieve labels en gelijke TPR/FPR bij verschillende base rates.

TPR: gevonden positieven deelt door werkelijke positieven. De balk toont alleen een percentage als die noemer positief is.

0%50%100%a50%a: 1 van 2, 50%b100%b: 1 van 1, 100%Percentage van gekozen noemer

Groep a4 rijen

TP
1
FP
1
TN
1
FN
1

50%1 / 2 werkelijke positieven

Accuracy ter context: 50%. Accuracy heeft alle rijen als noemer.

Groep b4 rijen

TP
1
FP
0
TN
3
FN
0

100%1 / 1 werkelijke positieven

Accuracy ter context: 100%. Accuracy heeft alle rijen als noemer.

Grootste verschil voor deze maat: 0,5. Dit verschil kiest zelf geen acceptabele grens.

Alle groepen en rijen zijn verzonnen. Dit zijn empirische maten op gedeclareerde labels, geen causaal oordeel of algemene fairnessgarantie. Een ontbrekende maat is onbekend, geen gemeten nul.

Noem het aantal meldingen in groep g n_g. TP_g, FP_g, TN_g en FN_g zijn de vier tellingen uit die groep. In de volgende breuken bepaalt de noemer welke kaartjes mogen meetellen.

sg=TPg+FPgngTPRg=TPgTPg+FNgFPRg=FPgFPg+TNg

Bij a worden twee van vier kaartjes geselecteerd, één van twee urgente gevonden en één van twee gewone verkeerd geselecteerd. Alle drie fracties zijn een half. Bij b zijn die fracties een kwart, één en nul.

rates = conditional_rates(counts)
for row in rates:
    print(row['selection_rate'],
          row['tpr'],row['fpr'])
# 0.5 0.5 0.5
# 0.25 1.0 0.0

Bewaar de noemers naast de fracties. Een TPR van nul betekent dat aanwezige urgente meldingen allemaal zijn gemist. Zonder urgente meldingen kun je die TPR helemaal niet berekenen: de code geeft dan None.

02
CHECKPOINTBereken drie maten met verschillende noemers

3. Verschillende maten stellen verschillende eisen

Gelijke selectieratio’s heten demographic parity. Equal opportunity vergelijkt TPR; equalized odds vergelijkt zowel TPR als FPR. Dat zijn afzonderlijke empirische criteria, geen uitwisselbare definities van algemene eerlijkheid.

Voor een bekende groepsmaat r_g gebruik je het grootste verschil tussen groepen. Noem dat verschil Δr; max en min nemen de hoogste en laagste waarde. Voor equalized odds neem je het grootste van het TPR-verschil en het FPR-verschil.

Δr=maxgrg−mingrg

In ons handvoorbeeld is het selectieverschil een kwart. Beide conditionele verschillen zijn een half, dus de equalized-odds-gap is ook een half. De functie rapporteert die verschillen, zonder zelf een moreel acceptabele grens te kiezen.

gaps = fairness_gaps(rates)
print(gaps['selection_gap'])
print(gaps['equalized_odds_gap'])
# 0.25
# 0.5

Een andere verhouding urgente en gewone meldingen heet een andere base rate. Daardoor kunnen gelijke TPR en FPR toch samengaan met verschillende totale accuracy en selectieratio’s. Kijk dus niet alleen naar een mooie gelijkheid, maar ook naar de populatie en de gekozen noemers.

03
CHECKPOINTBehoud onbekende groepsverschillen

Wat als een groep niet te beoordelen is?

Ontbreekt voor één gevraagde groep een benodigde maat, dan blijft de bijbehorende gap onbekend. De ontbrekende groep laten vallen zou de vergelijking gunstiger kunnen laten lijken. Het verslag bewaart daarom welke groepen informatie missen.

sparse = group_confusions(
    [1,0],[1,0],['a','b'],['a','b','c'])
missing = conditional_rates(sparse)
print(missing[1]['tpr'])
print(fairness_gaps(missing)[
    'equalized_odds_gap'])
# None
# None

De gapfunctie controleert aangeleverde samenvattingen, niet hun herkomst of de kwaliteit van labels. Verkeerde labels, kleine steekproeven en niet-gemeten groepen verdwijnen niet door een kleine gap. Een causaal oordeel of juridische beoordeling vraagt meer dan deze tabel.

Wat je nu kunt

  • Je telt beslissingen per expliciete groep en bewaart ontbrekende groepen.
  • Je kiest de juiste noemer voor selectie, TPR en FPR.
  • Je onderscheidt criteria en behandelt ontbrekende informatie als onbekend.

Hierna verander je beslisdrempels: welke fouten verschuiven, welk criterium kies je en wat blijft overeind op andere gegevens?

DE VOLGENDE STAPKies drempels op validatie en houd ze daarna vast
JOUW EXPERIMENT

Probeer het zelf

Python
main.py

Python start zodra jij code uitvoert.

UITVOER

Je uitvoer verschijnt hier.
Alles rekent in je eigen browser.

Jouw checkpoints0 / 3
01
Bewaar alle vier besliscellen per groep

Schrijf group_confusions(labels,predictions,groups,group_ids). Gebruik de gegeven _fair_data. Labels/predictions zijn na np.asarray 1D-vectoren met dtype i/u, 1–256 waarden 0 of 1. Pure bool/float/object ongeldig; gemengde bool/int kan integer worden. Groups is exact list/tuple met evenveel groepsnamen; group_ids exact list/tuple met 2–8 unieke namen, iedere rijgroep moet daarin voorkomen. Groepsnamen zijn exacte Python-str met 1–24 ASCII-tekens: eerste kleine letter, daarna kleine letters/cijfers/underscore/streepje. Geef een lijst in group_ids-volgorde, elke rij een dict met id, count, tp, fp, tn, fn als gehele tellingen. Positief label/positieve voorspelling is 1. Een gevraagde groep zonder rijen blijft met vier nullen aanwezig. Behoud alle invoer, container-/groepsvolgorde, readonlyflags en de globale Python-/NumPy-RNG. Geef zelfstandige uitvoercontainers. Ongeldige invoer geeft ValueError. Alleen volledige gedeclareerde oefenlabels; geen algemeen fairnessoordeel.

Een hint

Tel TP/FP/TN/FN apart en sla een lege gevraagde groep niet over.

02
Bereken drie maten met verschillende noemers

Schrijf conditional_rates(count_rows). Gebruik de gegeven _fair_counts. Count_rows is exact list/tuple met 2–8 exacte dicts, precies id/count/tp/fp/tn/fn. Ids uniek; count is tp+fp+tn+fn; samen 1–256 rijen. Lege gevraagde groepen mogen. Tellingen zijn Python-/NumPy-integers binnen 0–256 zonder bool, veilig naar Python-int geconverteerd. Groepsnamen zijn exacte Python-str met 1–24 ASCII-tekens: eerste kleine letter, daarna kleine letters/cijfers/underscore/streepje. Geef een lijst met dezelfde groepvolgorde. Elke dict bevat id, denominators (dict selection_rate=count, tpr=tp+fn, fpr=fp+tn), en selection_rate=(tp+fp)/count, tpr=tp/(tp+fn), fpr=fp/(fp+tn). Een rate is float als zijn noemer positief is, anders exact None. Denominators blijven integerwaarden. Behoud alle invoer, container-/groepsvolgorde, readonlyflags en de globale Python-/NumPy-RNG. Geef zelfstandige uitvoercontainers. Ongeldige invoer geeft ValueError. Een ontbrekende noemer geeft onbekend; een gemeten nul met positieve noemer blijft 0.0.

Een hint

Bepaal eerst welke kaartjes bij iedere noemer horen; deel alleen bij een positieve noemer.

03
Behoud onbekende groepsverschillen

Schrijf fairness_gaps(rate_rows). Gebruik de gegeven _fair_rates. Rate_rows is exact list/tuple met 2–8 dicts met precies id, denominators, selection_rate, tpr, fpr. Denominators heeft precies de drie maatnamen met integerwaarden 0–256 zonder bool; selection_rate-noemer is tpr-noemer plus fpr-noemer; samen 1–256 rijen. Ids uniek. Groepsnamen zijn exacte Python-str met 1–24 ASCII-tekens: eerste kleine letter, daarna kleine letters/cijfers/underscore/streepje. Scalarvalidators weigeren bool, accepteren Python-/NumPy-int/float, converteren veilig naar Python-float en controleren daarna eindigheid/bounds van die geconverteerde waarde. Iedere rate is None precies bij noemer nul, anders een eindige geconverteerde fractie binnen 0–1. Geef een dict met selection_gap, tpr_gap, fpr_gap (max-min over alle groepen, of None zodra een gevraagde groepsmaat ontbreekt), equalized_odds_gap (max van beide conditionele gaps, of None als een ontbreekt), en undefined_groups (dict selection_rate/tpr/fpr naar lijsten van ontbrekende ids in inputvolgorde). Behoud alle invoer, container-/groepsvolgorde, readonlyflags en de globale Python-/NumPy-RNG. Geef zelfstandige uitvoercontainers. Ongeldige invoer geeft ValueError. De functie beoordeelt aangeleverde ratesummaries; zij bewijst geen reconstrueerbare integernumeratoren, gewogen coherentie, dataprovenance, causale of algemene fairness.

Een hint

Neem een ontbrekende groepsmaat niet weg uit de vergelijking; rapporteer haar id.

Vastgelopen? Bekijk de voorbeeldoplossing
import math

import re

import numpy as np

# Jouw functies

def group_confusions(labels, predictions, groups, group_ids):
    labels, predictions, groups, ids = _fair_data(labels, predictions, groups, group_ids)
    rows = {name: {'id': name, 'count': 0, 'tp': 0, 'fp': 0, 'tn': 0, 'fn': 0} for name in ids}
    for truth, prediction, name in zip(labels, predictions, groups):
        row = rows[name]
        row['count'] += 1
        key = ('tp' if prediction else 'fn') if truth else 'fp' if prediction else 'tn'
        row[key] += 1
    return list(rows.values())

def conditional_rates(count_rows):
    rows = _fair_counts(count_rows)
    result = []
    for row in rows:
        denoms = {'selection_rate': row['count'], 'tpr': row['tp'] + row['fn'], 'fpr': row['fp'] + row['tn']}
        numerators = {'selection_rate': row['tp'] + row['fp'], 'tpr': row['tp'], 'fpr': row['fp']}
        item = {'id': row['id'], 'denominators': denoms}
        for key in numerators:
            item[key] = numerators[key] / denoms[key] if denoms[key] else None
        result.append(item)
    return result

def fairness_gaps(rate_rows):
    rows = _fair_rates(rate_rows)
    result = {}
    unknown = {}
    for metric, key in [('selection_rate', 'selection_gap'), ('tpr', 'tpr_gap'), ('fpr', 'fpr_gap')]:
        unknown[metric] = [row['id'] for row in rows if row[metric] is None]
        values = [row[metric] for row in rows if row[metric] is not None]
        result[key] = None if unknown[metric] else max(values) - min(values)
    result['equalized_odds_gap'] = None if result['tpr_gap'] is None or result['fpr_gap'] is None else max(result['tpr_gap'], result['fpr_gap'])
    result['undefined_groups'] = unknown
    return result

# Gegeven helpers

def _fair_id(value):
    if type(value) is not str or re.fullmatch('[a-z][a-z0-9_-]{0,23}', value) is None:
        raise ValueError('Korte synthetische groepsnaam vereist')
    return value

def _fair_int(value):
    if isinstance(value, (bool, np.bool_)) or not isinstance(value, (int, np.integer)) or (not 0 <= int(value) <= 256):
        raise ValueError('Gehele telling binnen 0–256 vereist')
    return int(value)

def _fair_number(value):
    if isinstance(value, (bool, np.bool_)) or not isinstance(value, (int, float, np.integer, np.floating)):
        raise ValueError('Eindige fractie vereist')
    try:
        value = float(value)
    except (OverflowError, ValueError, TypeError):
        raise ValueError('Eindige fractie vereist') from None
    if not math.isfinite(value) or not 0 <= value <= 1:
        raise ValueError('Fractie binnen 0–1 vereist')
    return value

def _fair_data(labels, predictions, groups, group_ids):
    vectors = []
    for value in [labels, predictions]:
        raw = np.asarray(value)
        if raw.ndim != 1 or not 1 <= len(raw) <= 256 or raw.dtype.kind not in 'iu' or np.any((raw != 0) & (raw != 1)):
            raise ValueError('Binaire integer-vector van 1–256 rijen vereist')
        vectors.append(raw.astype(np.int64, copy=True))
    if len(vectors[0]) != len(vectors[1]) or type(groups) not in (list, tuple) or len(groups) != len(vectors[0]):
        raise ValueError('Evenveel labels, voorspellingen en groepen vereist')
    if type(group_ids) not in (list, tuple) or not 2 <= len(group_ids) <= 8:
        raise ValueError('Twee tot acht expliciete groepen vereist')
    ids = [_fair_id(value) for value in group_ids]
    if len(set(ids)) != len(ids):
        raise ValueError('Unieke groepsnamen vereist')
    selected = [_fair_id(value) for value in groups]
    if any((value not in ids for value in selected)):
        raise ValueError('Iedere rij moet bij een gevraagde groep horen')
    return (vectors[0], vectors[1], selected, ids)

def _fair_counts(rows):
    if type(rows) not in (list, tuple) or not 2 <= len(rows) <= 8:
        raise ValueError('Twee tot acht groepstellingen vereist')
    cleaned = []
    for row in rows:
        if type(row) is not dict or set(row) != {'id', 'count', 'tp', 'fp', 'tn', 'fn'}:
            raise ValueError('Volledige groepstelling vereist')
        item = {'id': _fair_id(row['id'])}
        for key in ['count', 'tp', 'fp', 'tn', 'fn']:
            item[key] = _fair_int(row[key])
        if item['count'] != sum((item[key] for key in ['tp', 'fp', 'tn', 'fn'])):
            raise ValueError('Telling moet uit de vier cellen volgen')
        cleaned.append(item)
    if len({row['id'] for row in cleaned}) != len(cleaned) or not 1 <= sum((row['count'] for row in cleaned)) <= 256:
        raise ValueError('Unieke groepen met samen 1–256 rijen vereist')
    return cleaned

def _fair_rates(rows):
    metrics = ['selection_rate', 'tpr', 'fpr']
    if type(rows) not in (list, tuple) or not 2 <= len(rows) <= 8:
        raise ValueError('Twee tot acht groepsmaten vereist')
    cleaned = []
    for row in rows:
        if type(row) is not dict or set(row) != {'id', 'denominators', *metrics}:
            raise ValueError('Drie groepsmaten met hun noemers vereist')
        denoms = row['denominators']
        if type(denoms) is not dict or set(denoms) != set(metrics):
            raise ValueError('Geef alle drie noemers')
        copied = {key: _fair_int(denoms[key]) for key in metrics}
        if copied['selection_rate'] != copied['tpr'] + copied['fpr']:
            raise ValueError('Positieve en negatieve labels moeten alle rijen vormen')
        item = {'id': _fair_id(row['id']), 'denominators': copied}
        for key in metrics:
            if copied[key] == 0:
                if row[key] is not None:
                    raise ValueError('Een lege noemer geeft onbekend, geen nul')
                item[key] = None
            else:
                item[key] = _fair_number(row[key])
        cleaned.append(item)
    if len({row['id'] for row in cleaned}) != len(cleaned) or not 1 <= sum((row['denominators']['selection_rate'] for row in cleaned)) <= 256:
        raise ValueError('Unieke groepen met samen 1–256 rijen vereist')
    return cleaned

Voer de code uit en verklaar voor jezelf waarom elke stap werkt.