Werkplaats / De werkplaats
In jouw eigen tempo
← Klassieke machine learning
MODULE 02 · LES 05

Van een score naar een kans

Bouw een numeriek stabiele binaire classifier, train hem en gebruik ook scikit-learn.

65 min4 checkpoints
DIT GA JE ONTDEKKEN
  • Je zet een logit om in een kans en berekent stabiele log-loss.
  • Je traint logistische regressie zelf en gebruikt een echte bibliotheekpipeline.

Een spamfilter moet uiteindelijk kiezen: blokkeren of doorlaten. Maar vóór die keuze kan het model schatten hoe waarschijnlijk spam is. Logistische regressie (logistic regression) leert zo'n kans voor een binaire klasse: hier betekent label 1 spam en label 0 gewone mail.

Je begint met de bekende rechte score w maal x plus b. Die score mag negatief of groter dan één zijn; een kans niet. Een S-vormige omzetting drukt de score daarom naar een waarde tussen nul en één, waarna een aparte beslisdrempel de actie bepaalt.

1. Buig een score om naar een kans

Noem de lineaire score z, de logit. De sigmoid geeft kans p op klasse 1. Bij z = 0 is de noemer 2, dus p = 0.5; een positieve z geeft meer dan een halve kans.

z=wx+bp=11+e−z

e is hier het getal ongeveer 2.718, niet een foutvariabele. math.exp(a) berekent e tot de macht a. Bij z = ln(3) is exp(−z) gelijk aan 1/3, zodat p = 3/4 = 0.75; ln is de natuurlijke logaritme, de inverse van exp.

Bij sterk negatieve z zou exp(−z) te groot worden. Vermenigvuldig teller en noemer dan met exp(z): je krijgt exp(z)/(1+exp(z)). Beide rekenpaden gebruiken zo uitsluitend niet-positieve exponenten.

import math
def sigmoid(z):
    if z >= 0:
        return 1 / (1 + math.exp(-z))
    a = math.exp(z)
    return a / (1 + a)

print(sigmoid(0))            # 0.5
print(sigmoid(math.log(3)))  # 0.75
print(sigmoid(-1000))        # 0.0

Maak sigmoid(z) voor eindige getallen, waaronder ±1000. Wiskundig bereikt de sigmoid nul en één niet bij eindige z; computerafronding kan die uitkomsten wel geven. Zet in het diagram w = 0 en b = 0: alle kansen zijn 0.5; met w = 1 en b = 0 is de kans bij x = 1 ongeveer 0.731.

SCORE → KANS → BESLISSINGDe sigmoid maakt een kanscurve

p=1/(1+exp(−(w*x+b))). De stippellijn is de veelgebruikte drempel 0.5; het punt toont x=1.

00.250.50.751-505x
Logit bij x=11.0Kans klasse 10.731Bij drempel 0.5klasse 1
01
CHECKPOINTHoud ook extreme scores stabiel

2. Bestraf zekerheid die verkeerd uitpakt

Je kent cross-entropy uit informatietheorie. Voor één binair doel y neemt log-loss bij y = 1 de waarde −ln(p), en bij y = 0 de waarde −ln(1−p). We gebruiken hier natuurlijke logaritmen, dus geen bits.

Bij p = 0.75 kost een werkelijk positief voorbeeld ongeveer 0.288; een negatief voorbeeld ongeveer 1.386. Dezelfde zekerheid krijgt dus een andere straf afhankelijk van de waarheid. Gemiddeld leveren twee kansen 0.5 altijd ln(2), ongeveer 0.693.

Rechtstreeks logaritmen van afgeronde kansen nemen kan log(0) veroorzaken. Bereken daarom de equivalente straf rechtstreeks uit z:

ℓ=max(z,0)−zy+ln(1+e−|z|)

ℓ benoemt de straf voor één voorbeeld; max kiest de grootste van z en nul. math.log1p(a) berekent ln(1+a), ook nauwkeurig wanneer a heel klein is. De absolute waarde zorgt opnieuw voor een niet-positieve exponent.

def straf(z, y):
    basis = max(z, 0) - z * y
    extra = math.log1p(math.exp(-abs(z)))
    return basis + extra

z = math.log(3)
print(straf(z, 1))  # ongeveer 0.288
print(straf(z, 0))  # ongeveer 1.386
print(straf(1000, 0))  # 1000.0

Maak logit_loss(zs,ys) als gemiddelde van deze straffen. Lege of ongelijk lange lijsten en labels buiten 0/1 geven ValueError. Veelgemaakte fout: de kans eerst hard op 0 of 1 afronden; daarmee verlies je de informatie waarmee het model leert.

02
CHECKPOINTReken log-loss rechtstreeks uit logits

3. Leer uit kans min label

Bij sigmoid plus log-loss vereenvoudigt de kettingregel de afgeleide naar z tot p−y. Daardoor krijg je voor w het gemiddelde van x maal (p−y), en voor b het gemiddelde van (p−y). Hier staat geen factor twee: die hoorde bij MSE.

Neem x = [−1,1], y = [0,1], w = b = 0. De kansen zijn [0.5,0.5], dus de verschillen [0.5,−0.5]; dw = −0.5 en db = 0. Met eta = 1 wordt de nieuwe w = 0.5 en blijft b = 0. Beide voorbeelden krijgen dezelfde nieuwe straf, ongeveer 0.474.

xs = [-1, 1]
ys = [0, 1]
w = 0.0
b = 0.0
sw = 0
sb = 0
for i in range(len(xs)):
    fout = sigmoid(w * xs[i] + b) - ys[i]
    sw = sw + xs[i] * fout
    sb = sb + fout
w, b = w - sw / len(xs), b - sb / len(xs)
print(w, b)  # 0.5 0.0
print(straf(w * xs[0] + b, ys[0]))
# ongeveer 0.474, lager dan 0.693

Maak train_logistiek(xs,ys,eta,stappen) met (w,b,history), beginnend bij nul. Hergebruik de gezamenlijke update en verliesgeschiedenis uit regressie; nul stappen geeft één beginverlies. Weiger ongeldige paren/labels en een negatief geheel aantal stappen.

Bij perfect scheidbare data kunnen gewichten blijven groeien terwijl de loss daalt. Een lagere loss betekent dan niet dat de parameters naar een eindig optimum gaan.

03
CHECKPOINTLeer met het verschil tussen kans en label

4. Vraag de bibliotheek om kansen

Gebruik de pipeline en NumPyvormen uit het regressielab, nu met LogisticRegression(C=1.0). Deze variant gebruikt regularisatie: grote gewichten krijgen een extra straf. C regelt de sterkte: een kleinere C geeft meer straf. Daardoor hoeven haar gewichten niet exact bij onze ongestrafte trainingslus te passen.

import numpy as np
from sklearn.pipeline import (
    make_pipeline)
from sklearn.preprocessing import (
    StandardScaler)
from sklearn.linear_model import (
    LogisticRegression)
model = make_pipeline(
    StandardScaler(),
    LogisticRegression(C=1.0))
train = np.array([-2, -1, 1, 2])
train = train.reshape(-1, 1)
model.fit(train, [0, 0, 1, 1])
kolom = list(model.classes_).index(1)
kansen = model.predict_proba([[0.]])
print(kansen[:, kolom].tolist())  # [0.5]

classes_ geeft de kolomlabels; .index(1) zoekt de positie van klasse 1. predict_proba geeft één rij per query met een kans per klasse; [:,kolom] selecteert de juiste kans en .tolist() maakt een gewone lijst. Maak logistiek_sklearn(xs,ys,query) voor niet-lege training met beide klassen en een niet-lege query.

04
CHECKPOINTGebruik dezelfde fit/predict-scheiding in scikit-learn

Wat neem je mee?

  • Een lineaire logit wordt een kans, waarna je apart een beslisdrempel kiest.
  • Stabiele rekenvormen voorkomen overloop en logaritmen van afgeronde nul.
  • Training gebruikt kans min label; een geldige kanswaarde is nog geen bewezen betrouwbare kans.

Hierna bekijk je een classifier die voorbeelden bewaart in plaats van gewichten leert.

DE VOLGENDE STAPVraag het aan de dichtstbijzijnde voorbeelden
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 / 4
01
Houd ook extreme scores stabiel

Schrijf sigmoid(z), ook werkend voor z=−1000 en z=1000.

Een hint

Gebruik twee rekenpaden afhankelijk van het teken; in beide blijft de exponent niet-positief.

02
Reken log-loss rechtstreeks uit logits

Schrijf logit_loss(zs,ys), met ValueError bij lege/ongelijke lijsten of andere labels dan 0 en 1.

Een hint

Som de stabiele vorm en deel door het aantal labels; math.log1p(a) berekent ln(1+a).

03
Leer met het verschil tussen kans en label

Schrijf train_logistiek(xs,ys,eta,stappen) als (w,b,history). Start bij nul; weiger ongeldige paren/labels en negatieve stappen.

Een hint

Bereken alle kansen met de huidige parameters; update daarna w en b. Gebruik logit_loss voor history.

04
Gebruik dezelfde fit/predict-scheiding in scikit-learn

Schrijf logistiek_sklearn(xs,ys,query) en geef de klasse-1-kansen als gewone lijst terug.

Een hint

Gebruik de uitgelegde pipeline met StandardScaler en LogisticRegression(C=1.0). Fit op training en selecteer bij predict_proba de kolom van klasse 1.

Vastgelopen? Bekijk de voorbeeldoplossing
def sigmoid(z):
    import math
    if z >= 0:
        return 1 / (1 + math.exp(-z))
    a = math.exp(z)
    return a / (1 + a)


def logit_loss(zs, ys):
    import math
    if not zs or len(zs) != len(ys):
        raise ValueError("Binaire niet-lege paren vereist")
    totaal = 0
    for i in range(len(zs)):
        if ys[i] not in (0, 1):
            raise ValueError("Alleen binaire labels")
        z = zs[i]
        basis = max(z, 0) - z * ys[i]
        extra = math.log1p(math.exp(-abs(z)))
        totaal = totaal + basis + extra
    return totaal / len(zs)


def train_logistiek(xs, ys, eta, stappen):
    if stappen < 0:
        raise ValueError("Negatief aantal stappen")
    w = 0.0
    b = 0.0
    zs = []
    for i in range(len(xs)):
        zs.append(w * xs[i] + b)
    history = [logit_loss(zs, ys)]
    for stap in range(stappen):
        sw = 0
        sb = 0
        for i in range(len(xs)):
            fout = sigmoid(w * xs[i] + b) - ys[i]
            sw = sw + xs[i] * fout
            sb = sb + fout
        dw = sw / len(xs)
        db = sb / len(xs)
        w, b = w - eta * dw, b - eta * db
        zs = []
        for i in range(len(xs)):
            zs.append(w * xs[i] + b)
        history.append(logit_loss(zs, ys))
    return w, b, history


def logistiek_sklearn(xs, ys, query):
    import numpy as np
    from sklearn.pipeline import make_pipeline
    from sklearn.preprocessing import StandardScaler
    from sklearn.linear_model import LogisticRegression
    model = make_pipeline(StandardScaler(), LogisticRegression(C=1.0))
    train = np.array(xs).reshape(-1, 1)
    model.fit(train, ys)
    kolom = list(model.classes_).index(1)
    test = np.array(query).reshape(-1, 1)
    kansen = model.predict_proba(test)
    return kansen[:, kolom].tolist()

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