Werkplaats / Notebook
In jouw eigen tempo
← Alle bouwprojecten
Neurale netwerken · PYTHON NOTEBOOK

Bouw en train een MLP op MNIST

Train een eigen NumPy-netwerk op echte cijferafbeeldingen, kies het model met validatie en meet daarna de apart gehouden testset.

75 min5 cellen
De opdracht, leerdoelen en voorkennis
  • Je verbindt preprocessing, stabiele softmax, eigen backprop en minibatch-SGD tot één werkend model.
  • Je kiest gewichten uitsluitend met validatie en rapporteert daarna echte testvoorspellingen.
  • Je inspecteert fouten en benoemt de beperkingen van een kleine oefenselectie.

Lees eerst: Stuur het foutsignaal terug door een heel netwerk · Leer met een handvol voorbeelden tegelijk · Stuur het leertempo en bewaar de beste stap.

Een handgeschreven 7 bestaat voor de computer uit pixelwaarden. Jij bouwt de hele route van die waarden naar tien kansen, en daarna de terugweg waarmee het netwerk leert. Je gebruikt eigen NumPy-code, zonder trainingsframework.

Het echte model heeft 784 invoerwaarden, 24 verborgen ReLU-neuronen en 10 logits. De vaste selectie bevat 2.000 trainingsbeelden, 400 validatiebeelden en 400 apart gehouden testbeelden; de download is ongeveer 462 KiB. Dit is een leerproject, geen volledige MNIST-benchmark.

1. Maak van een beeld een rij getallen

Open de notebookcellen van boven naar beneden. Variabelen blijven bestaan; na stoppen of herstarten voer je alles opnieuw uit. De datasetvermelding beschrijft selectie, bron en licentie.

Met vier pixels kun je de preprocessing zelf volgen. Dezelfde rijvolgorde die een 2×2-beeld afvlakt, maakt straks van 28×28 pixels een vector van 784 waarden. Delen door 255 brengt de oorspronkelijke waarden naar nul tot één.

import numpy as np

raw = np.array([[[255,0],[0,255]],
                [[0,255],[255,0]]])
flat = raw.reshape(2,4)
x = flat.astype(float)/255
print(x.tolist())
# [[1.,0.,0.,1.], [0.,1.,1.,0.]]

Maak load_data(path) met np.load(...,allow_pickle=False) en kopieer alle arrays; roep haar aan als data=load_data('/datasets/mnist-mini.npz'). Geef beelden als float32 met vorm (n,784), labels als int64 en oorspronkelijke indices ongewijzigd terug. Train en validatie delen geen oorspronkelijke rij; testindices horen bij een ander bronbestand en mogen dezelfde getallen bevatten.

Teken twaalf echte trainingsbeelden met hun labels. Bekijk nog geen testfouten om modelkeuzes te maken. De selectie valt onder de vermelde datasetlicentie; die staat los van de licentie van jouw netwerkcode.

01
CHECKPOINTLaad, splits en bekijk echte cijfers

2. Laat een verborgen laag verschillen herkennen

We gebruiken twee verborgen eenheden en twee klassen als klein rekenvoorbeeld. Een negatieve gewogen som wordt door ReLU nul. Daardoor bereikt niet iedere ingang iedere uitgang op dezelfde manier.

A=XW1+b1H=max(0,A)Z=HW2+b2

X bevat voorbeelden per rij; W zijn gewichtmatrices, b biases, A preactivaties, H verborgen activaties en Z logits. Voor ons eerste beeld geeft de eerste verborgen eenheid 1×1+1×0−0.25=0.75; de tweede wordt −0.4 en dus nul.

params = dict(
    w1=np.array([[1,-1],[.5,.5],
                 [-1,1],[0,.5]]),
    b1=np.array([-.25,.1]),
    w2=np.array([[1,-1],[.5,.25]]),
    b2=np.array([.1,-.1]))
a = x@params['w1']+params['b1']
h = np.maximum(a,0)
z = h@params['w2']+params['b2']
print(a.tolist())
# [[.75,-.4],[-.75,1.6]]
print(h.tolist())
# [[.75,0.],[0.,1.6]]

Trek per logitrij het maximum af: s=z−max(z). Softmax geeft dan kansen zonder grote exponenten. Bij ons eerste beeld zijn logits [0.85,−0.85] en kansen ongeveer [0.845535,0.154465]. Het juiste label krijgt cross-entropy −log(kans).

ℓi=log(∑jesij)−siyiL=1N∑iℓi

Hier is ℓ het verlies per voorbeeld, L het gemiddelde, N de batchgrootte, i een voorbeeld, j een klasse en y het juiste label. Deze log-som-exp-vorm voorkomt log(0) bij zeer kleine kansen.

s = z-z.max(axis=1,keepdims=True)
prob = np.exp(s)
den = prob.sum(axis=1,
               keepdims=True)
prob /= den
y = np.array([0,1])
exp_total = np.exp(s).sum(axis=1)
loss = (np.log(exp_total)
        -s[np.arange(2),y]).mean()
print(np.round(prob,6).tolist())
# [[.845535,.154465],
#  [.645656,.354344]]
print(round(float(loss),6))
# .602637

Maak init_model(seed=7) met een lokale default_rng: trek w1 (784,24) met standaardafwijking √(2/784), daarna w2 (24,10) met √(1/24); biases zijn nul en alle arrays float32. Forward bewaart x,a,h,s,p in een cache; metrics geeft een dictionary met loss en accuracy als Python-floats. De cache is de routekaart voor backward.

02
CHECKPOINTInitialiseer en bereken stabiele kansen

3. Geef alleen actieve ReLU-routes een foutsignaal

Voor softmax met cross-entropy is dz de kans minus het juiste één-hotlabel, gedeeld door N. Eén-hot betekent één op de juiste klasse en nul elders. Het ReLU-masker blokkeert de negatieve preactivaties; alleen een numerieke eindaccuracy controleren zou zo’n ontbrekend masker kunnen missen.

dz = prob.copy()
dz[np.arange(len(y)),y] -= 1
dz /= len(y)
dh = (dz@params['w2'].T)*(a>0)
grads = dict(w1=x.T@dh,
             b1=dh.sum(axis=0),
             w2=h.T@dz,
             b2=dz.sum(axis=0))
print(np.round(dh,6).tolist())
# [[-.154465,0.],[0.,.080707]]

Bij de eerste rij is dh voor de actieve eenheid −0.077233×1+0.077233×(−1)≈−0.154465; de tweede wordt nul. Maak backward(c,y,p) als dictionary met w1,b1,w2,b2 voor willekeurige passende netwerkvormen en laat cache en parameters intact. Bewerk dz als kopie: alle vier gradiënten horen bij dezelfde oude gewichten.

03
CHECKPOINTBereken alle vier parametergradiënten

4. Train op batches, kies op validatie

Eén SGD-update trekt eta maal de gradiënt van ieder parameter af. Met eta=0.1 verandert de eerste invoergewichtwaarde van 1 naar 1−0.1×(−0.154465)≈1.015447.

updated = {}
for k,v in params.items():
    updated[k] = v-.1*grads[k]
print(round(updated['w1'][0,0],6))
# 1.015447

Maak train(data,epochs=8,eta=.15): init_model(7), batches van 64 inclusief het restant en vóór de epochlus één rng=np.random.default_rng(8); shuffle iedere epoch met rng.permutation(...). Meet na iedere epoch beide metrics voor train en val. Bewaar history met epoch vanaf één, train en val; kopieer alle parameters bij strikt lager validatieverlies en retourneer beste model plus history.

Bij validatieverliezen [0.8,0.6,0.65] kies je dus epoch 2, niet de laatste. Gebruik nergens testdata tijdens training en weiger epochs onder één. Teken beide echte verliescurven met asnamen en legenda; een dalende trainingslijn alleen bewijst geen generalisatie.

04
CHECKPOINTTrain en bewaar het beste validatiemodel

5. Tel goede én verkeerde voorspellingen

Meet nu pas de testset met het bewaarde model. In de verwarringsmatrix zijn rijen echte labels en kolommen voorspellingen. Dit kleine drieklassenvoorbeeld plaatst een echte 1 die als 0 wordt voorspeld op positie [1,0].

truth = np.array([0,1,2])
pred = np.array([0,0,2])
mini = np.zeros((3,3),dtype=int)
np.add.at(mini,(truth,pred),1)
print(mini.tolist())
# [[1,0,0],[1,0,0],[0,0,1]]
correct = np.trace(mini)
accuracy = correct/mini.sum()
print(round(accuracy,4))
# .6667

Maak confusion voor tien klassen en bereken test_metrics, test_predictions en test_matrix uit het echte model. Teken de matrix met benoemde assen en print de berekende accuracy.

De voorbeeldoplossing haalt hier ruim 80%; dit garandeert niets voor jouw eigen handschrift. Nieuwe modelkeuzes na testinspectie vereisen nieuwe evaluatiedata.

05
CHECKPOINTMeet de apart gehouden testset en inspecteer fouten

Wat neem je mee?

  • Pixelvoorbewerking, forward en backward vormen samen één controleerbare berekening.
  • Het ReLU-masker en gezamenlijke updates horen bij dezelfde oude parameters.
  • Validatie kiest het model; de eindtest meet het pas daarna.

Hierna benut je ruimtelijke structuur met convoluties in plaats van ieder pixel alleen als losse invoer te behandelen.

Bronnen: Keras — MNIST, herkomst, vorm en licentie ↗ NumPy — veilig laden zonder pickle ↗ · Gecontroleerd 2026-10-28.

MNIST oefenselectie · 473 kB. Wordt bij je eerste uitvoering geladen en op integriteit gecontroleerd. MNIST: Yann LeCun, Corinna Cortes en Christopher J. C. Burges; selectie en verpakking: AI-lab. Bron en selectie · CC BY-SA 3.0.

Python start zodra jij code uitvoert.
[1]

Laad, splits en bekijk echte cijfers

Een cijfer is een raster van pixelwaarden. Met vier pixels zie je de omzetting: [[255,0],[0,255]] wordt [1,0,0,1] na afvlakken en delen door 255; in MNIST worden 28×28 pixels 784 invoerwaarden. Teken twaalf verschillende trainingsbeelden met hun echte labels, zodat je ziet waarop je model gaat leren.

Schrijf load_data(path) met np.load(path,allow_pickle=False), kopieer alle arrays en roep haar aan als data=load_data('/datasets/mnist-mini.npz'). Beelden krijgen per split float32 en vorm (n,784), labels int64; behoud de oorspronkelijke indices. Er zijn 2000 train-, 400 validatie- en 400 testbeelden, waarbij train/val uit hetzelfde bronbestand komen en test uit een apart bestand.

Train en validatie mogen geen oorspronkelijke rij delen; een gelijk indexgetal in het aparte testbronbestand betekent een ander beeld. De datasetvermelding geeft selectie, herkomst en licentie. Bekijk voor modelkeuzes alleen trainingsbeelden; voer deze cel opnieuw uit na een runtimeherstart.

Bekijk voorbeeld
import numpy as np
import matplotlib.pyplot as plt

def load_data(path):
    with np.load(path,allow_pickle=False) as f:
        data={name:f[name].copy() for name in f.files}
    for split in ("train","val","test"):
        data["x_"+split]=data["x_"+split].reshape(-1,784).astype(np.float32)/255
        data["y_"+split]=data["y_"+split].astype(np.int64)
    return data

data=load_data('/datasets/mnist-mini.npz')
fig,axes=plt.subplots(2,6,figsize=(7,3))
for i,ax in enumerate(axes.flat):
    ax.imshow(data['x_train'][i].reshape(28,28),cmap='gray',vmin=0,vmax=1)
    ax.set_title(str(data['y_train'][i]))
    ax.axis('off')
fig.suptitle('Echte MNIST-trainingsbeelden')
fig.tight_layout()
print('Train / validatie / test:',*[len(data['y_'+s]) for s in ('train','val','test')])
[2]

Initialiseer en bereken stabiele kansen

Open bovenaan “De opdracht, leerdoelen en voorkennis” voor de voorbeeldmatrices en zes codeblokken. Verborgen neuronen maken gewogen combinaties van pixels; ReLU laat alleen positieve combinaties door, zodat het kleine voorbeeld van preactivaties [0.75,-0.4] naar verborgen uitvoer [0.75,0] gaat. Met logits [0.85,-0.85] krijg je kansen [0.845535,0.154465], zodat je de hele route met de hand kunt volgen.

Maak init_model(seed=7) met een lokale default_rng: eerst w1 (784,24) met standaardafwijking √(2/784), daarna w2 (24,10) met √(1/24), plus nulbiases; alle arrays zijn float32. Schrijf forward(x,p) als cache met x,a,h,s,p: preactivatie a, ReLU-uitvoer h, verschoven logits s en kansen p. Trek per rij het maximum af vóór exp en deel door de rijtotaalsom.

Metrics geeft een dictionary met loss en accuracy als Python-floats. Bereken cross-entropy als gemiddelde van log(sum(exp(s)))−s[juist label], zodat ook een nauwelijks waarschijnlijke juiste klasse een eindig verlies krijgt. Voor de twee voorbeeldbeelden en labels [0,1] is het gemiddelde verlies 0.602637; de cache blijft beschikbaar voor de terugweg.

Bekijk voorbeeld
def init_model(seed=7):
    rng=np.random.default_rng(seed)
    return {"w1":rng.normal(0,np.sqrt(2/784),(784,24)).astype(np.float32),"b1":np.zeros(24,dtype=np.float32),"w2":rng.normal(0,np.sqrt(1/24),(24,10)).astype(np.float32),"b2":np.zeros(10,dtype=np.float32)}

def forward(x,p):
    a=x@p["w1"]+p["b1"]
    h=np.maximum(0,a)
    z=h@p["w2"]+p["b2"]
    s=z-z.max(axis=1,keepdims=True)
    exp=np.exp(s)
    return {"x":x,"a":a,"h":h,"s":s,"p":exp/exp.sum(axis=1,keepdims=True)}

def metrics(x,y,p):
    c=forward(x,p)
    loss=float(np.mean(np.log(np.exp(c["s"]).sum(axis=1))-c["s"][np.arange(len(y)),y]))
    return {"loss":loss,"accuracy":float(np.mean(c["p"].argmax(axis=1)==y))}
[3]

Bereken alle vier parametergradiënten

De fout begint als kans minus één-hotlabel, gedeeld door de batchgrootte; één-hot is één op de juiste klasse en nul elders. Voor de eerste voorbeeldrij is dz≈[-0.077233,0.077233]. Na vermenigvuldiging met de uitgangsgewichten en het ReLU-masker geeft dit dh≈[-0.154465,0]: de negatieve preactivatie krijgt geen signaal.

Maak backward(c,y,p) voor passende aantallen inputs, verborgen eenheden en klassen. Kopieer de kansen in dz, trek één af op de juiste labels en deel door het aantal voorbeelden; bereken w2/b2 vanuit h en dz. Propagëer met w2.T terug, vermenigvuldig met a>0 en bereken w1/b1 vanuit x.

Retourneer een dictionary met gradiëntarrays w1,b1,w2,b2 en verander cache of parameters niet. Iedere gradiënt hoort bij dezelfde voorwaartse pass; pas gewichten pas daarna samen aan. De nakijker vergelijkt met een onafhankelijk verlies en positieve én negatieve ReLU-gevallen.

Bekijk voorbeeld
def backward(c,y,p):
    dz=c["p"].copy()
    dz[np.arange(len(y)),y]-=1
    dz/=len(y)
    dh=(dz@p["w2"].T)*(c["a"]>0)
    return {"w1":c["x"].T@dh,"b1":dh.sum(axis=0),"w2":c["h"].T@dz,"b2":dz.sum(axis=0)}
[4]

Train en bewaar het beste validatiemodel

SGD trekt eta maal de gradiënt van elk gewicht af. In ons kleine voorbeeld wordt 1−0.1×(−0.154465)≈1.015447. Een lagere trainingsfout bewijst nog geen generalisatie: bij validatieverliezen [0.8,0.6,0.65] bewaar je epoch 2, ook al train je verder.

Schrijf train(data,epochs=8,eta=.15): start met init_model(7), maak vóór de epochlus één rng=np.random.default_rng(8) en gebruik iedere epoch rng.permutation(...). Verwerk batches van 64 inclusief het laatste restant en update alle parameters tegelijk; meet na iedere epoch verlies en accuracy op train en val en bewaar history met epoch (vanaf 1), train en val. Bij strikt lager validatieverlies kopieer je alle parameters; retourneer het beste model plus history en weiger epochs onder één.

Voer acht epochs uit en teken beide werkelijk gemeten verliescurven met asnamen en legenda. Gebruik nergens x_test of y_test tijdens training; de testset hoort ook niet bij de modelkeuze. Een eerder bewaarde beste kopie mag niet door latere updates veranderen.

Bekijk voorbeeld
def train(data,epochs=8,eta=.15):
    if epochs<1:
        raise ValueError("Gebruik minstens één epoch")
    p=init_model(7)
    rng=np.random.default_rng(8)
    history=[]
    best=None
    best_loss=float("inf")
    for epoch in range(epochs):
        order=rng.permutation(len(data["x_train"]))
        for start in range(0,len(order),64):
            i=order[start:start+64]
            g=backward(forward(data["x_train"][i],p),data["y_train"][i],p)
            p={k:v-eta*g[k] for k,v in p.items()}
        train_metrics=metrics(data["x_train"],data["y_train"],p)
        val_metrics=metrics(data["x_val"],data["y_val"],p)
        history.append({"epoch":epoch+1,"train":train_metrics,"val":val_metrics})
        if val_metrics["loss"]<best_loss:
            best_loss=val_metrics["loss"]
            best={k:v.copy() for k,v in p.items()}
    return best,history

model,history=train(data)
plt.figure(figsize=(6,3.5))
plt.plot([r['epoch'] for r in history],[r['train']['loss'] for r in history],label='Train')
plt.plot([r['epoch'] for r in history],[r['val']['loss'] for r in history],label='Validatie')
plt.xlabel('Epoch')
plt.ylabel('Cross-entropy')
plt.title('Werkelijk gemeten leercurven')
plt.legend()
plt.tight_layout()
best=min(history,key=lambda r:r['val']['loss'])
print('Gekozen epoch:',best['epoch'])
print('Validatie-accuracy:',round(best['val']['accuracy'],4))
[5]

Meet de apart gehouden testset en inspecteer fouten

De gewichten liggen nu vast: meet pas nu de apart gehouden testbeelden. In de verwarringsmatrix zijn rijen echte labels en kolommen voorspellingen. Een echte 1 voorspeld als 0 telt dus op positie [1,0], terwijl de diagonaal de juiste voorspellingen telt.

Maak confusion(y_true,y_pred) met vorm (10,10), ook wanneer een klasse in de invoer ontbreekt. Bereken test_metrics en test_predictions uit het bewaarde model en bouw daaruit test_matrix; teken die matrix met benoemde assen en print de berekende accuracy. De diagonaalsom gedeeld door het totaal moet dezelfde accuracy geven.

De voorbeeldoplossing haalt op deze vaste kleine selectie ruim 80 procent; dat garandeert niets voor jouw eigen handschrift. Als je na testinspectie een ander model kiest, is deze testset onderdeel van ontwikkeling geworden en heb je nieuwe evaluatiedata nodig. Neem je gecontroleerde code mee naar convolutienetwerken, die de ruimtelijke structuur van beelden benutten.

Bekijk voorbeeld
def confusion(y_true,y_pred):
    matrix=np.zeros((10,10),dtype=int)
    np.add.at(matrix,(y_true,y_pred),1)
    return matrix

test_metrics=metrics(data['x_test'],data['y_test'],model)
test_predictions=forward(data['x_test'],model)['p'].argmax(axis=1)
test_matrix=confusion(data['y_test'],test_predictions)
fig,ax=plt.subplots(figsize=(5,4))
im=ax.imshow(test_matrix,cmap='Blues')
ax.set(xticks=range(10),yticks=range(10),xlabel='Voorspeld cijfer',ylabel='Echt cijfer',title='Apart gehouden MNIST-testselectie')
fig.colorbar(im,ax=ax,label='Aantal beelden')
fig.tight_layout()
print('Test-accuracy:',round(test_metrics['accuracy'],4))
print('Correct / totaal:',int(np.trace(test_matrix)),int(test_matrix.sum()))
print('Fouten per echt cijfer:',(test_matrix.sum(axis=1)-np.diag(test_matrix)).tolist())