Leer cijferpatronen met je eigen CNN
Train vier convolutiefilters en een classifier, met eigen backprop door ReLU en maxpooling, op echte MNIST-beelden.
De opdracht, leerdoelen en voorkennis
- Je implementeert een trainbare convolutie als gedeelde matrixvermenigvuldiging.
- Je routeert gradiënten door maxpooling en controleert alle soorten parameters.
- Je kiest een model met validatie en inspecteert echte geleerde filters en testvoorspellingen.
Lees eerst: Bouw en train een MLP op MNIST · Vat buurten samen en stuur de gradiënt terug · Leer het gedeelde filter met backprop.
Je vorige netwerk zag ieder cijfer als een lange rij pixels. Nu train je filters die hetzelfde lokale patroon overal zoeken. Ook de filtergewichten veranderen tijdens leren: dit project gebruikt een eigen terugweg door convolutie, ReLU en maxpooling.
Om het werk klein te houden middelen we MNIST naar 14×14 pixels. Het echte model gebruikt vier 3×3-filters en tien klassen. Hieronder volgen we eerst één klein filter met twee klassen, zodat je dezelfde route met de hand kunt narekenen.
1. Maak pixels en lokale patches klaar
Een gemiddelde van een 2×2-blok vat vier oorspronkelijke pixels samen. Het blok [[0,255],[255,0]] wordt na schalen en middelen 0.5. Dat is een kleinere representatie; het model krijgt daardoor andere informatie dan het vorige MLP.
import numpy as np
tile = np.array([[0,255],[255,0]])
scaled = tile.astype(float)/255
value = scaled.mean()
print(value) # 0.5
Maak prepare(path) met veilig laden zonder pickle. Deel beelden als float32 door 255, reshape naar (n,14,2,14,2) en middel assen 2 en 4: de twee lokale blokassen. Stapel de negen verschoven 12×12-uitsneden langs de laatste as, eerst filterrij en dan filterkolom; bewaar alle labels als int64.
Roep data=prepare('/datasets/mnist-mini.npz') aan. De patchtensor heeft vorm (n,12,12,9): op iedere beeldpositie staan negen lokale pixels klaar voor een inproduct. De vaste selectie bevat 2000 train-, 400 validatie- en 400 testbeelden.
Neem voor ons kleine voorbeeld een beeld van 3×3 na middelen en een filter van 2×2. Vier vensters geven vier rijen met vier pixelwaarden; dezelfde gewichtvector wordt op iedere rij toegepast.
image = np.array([[0.,.5,1.],
[.5,1.,0.],
[1.,0.,.5]])
rows = []
for i,j in np.ndindex(2,2):
rows.append(
image[i:i+2,j:j+2].ravel())
patches = np.stack(rows)
kernel = np.array([1.,-1.,0.,1.])
pre = patches@kernel+.25
a = pre.reshape(2,2)
print(a.tolist())
# [[.75,-.25],[-.25,1.75]]
Maak init(seed=7) met een lokale default_rng: eerst kernel (9,4) met standaardafwijking √(2/9), daarna head (144,10) met √(1/144); conv_bias (4,) en head_bias (10,) zijn nul. Alle parameters zijn float32. Eén kernelkolom is één filter; de hele laag deelt 36 filtergewichten en vier biases.
012. Volg de winnende activatie naar een kans
P is de patchtensor, K de gedeelde kernelmatrix, b de convolutiebias en A de preactivatie. H is de ReLU-uitvoer. Op elke positie geldt dezelfde berekening.
Ons voorbeeld wordt [[0.75,0],[0,1.75]]. Het maximum staat op rij 1, kolom 1: afgevlakte index 3. Met uitgangsgewichten [1,−1] krijg je logits [1.75,−1.75]; voor label 1 is dit een zelfverzekerde fout.
h = np.maximum(a,0)
winner = int(h.argmax())
pooled = h.ravel()[winner]
head = np.array([1.,-1.])
z = pooled*head
s = z-z.max()
weights = np.exp(s)
prob = weights/weights.sum()
loss = np.log(weights.sum())-s[1]
print(winner,pooled) # 3 1.75
print(np.round(prob,6).tolist())
# [.970688,.029312]
print(round(float(loss),6))
# 3.52975
Maak forward(x,p) met cache x,a,winners,flat,z,prob. Pooling_windows groepeert ieder 2×2-venster; de laatste as bevat de vier lokale posities in rijvolgorde. Kies argmax, haal de waarden op met take_along_axis, flatten tot 144 features en bereken tien logits met head plus head_bias.
Trek per rij het maximum af en bewaar die verschoven logits als z. Score geeft een dictionary met loss en accuracy als Python-floats, met stabiele log-som-exp-cross-entropy. De twee gegeven poolinghelpers herschikken alleen assen; zij voeren geen leerberekening uit.
023. Stuur het foutsignaal langs dezelfde route terug
Voor ons ene voorbeeld is dz=[0.970688,−0.970688]. Via head=[1,−1] krijgt de gepoolde waarde ongeveer 1.941376 terug. Alleen de gekozen positie ontvangt dat signaal; het ReLU-masker houdt negatieve preactivaties gesloten.
dz = prob.copy()
dz[1] -= 1
dpool = dz@head
route = np.eye(4)[winner]
da = dpool*route
da *= a.ravel()>0
dw = patches.T@da
db = da.sum()
print(route.tolist())
# [0.,0.,0.,1.]
print(np.round(dw,6).tolist())
# [1.941376,0.,0.,.970688]
print(round(float(db),6))
# 1.941376
new_kernel = kernel-.1*dw
print(round(new_kernel[0],6))
# .805862
De winnende patch is [1,0,0,0.5], dus zijn de twee niet-nulle gewichtsbijdragen 1×1.941376 en 0.5×1.941376. Met eta=0.1 wordt het eerste gewicht 1−0.1×1.941376≈0.805862. In het echte model telt dezelfde kernelgradiënt alle batch- en beeldposities op; een bias krijgt de som van zijn kanaal.
Vlak betekent dat batch- en beeldposities samen de rijas vormen; T geeft transponeren aan.
Maak backward(c,y,p) als dictionary kernel,conv_bias,head,head_bias en behoud cache en parameters. Middel alleen dz over de batch; bereken head-gradiënten vanuit flat en dz. Reshape dflat naar (n,6,6,4), routeer met np.eye(4)[winners] en dpool[...,None], keer de venstergroepering om en pas a>0 toe.
[...,None] voegt één laatste as toe om iedere gradiënt langs zijn gekozen route te sturen. Voor de kernel gebruik je daarna de matrixvermenigvuldiging van x.reshape(−1,9).T met da.reshape(−1,4). Update nog geen gewichten: alle gradiënten horen bij dezelfde oude parameters.
4. Leer op train, bewaar de beste validatieversie
Door deze vormen bevat het echte model 1490 trainbare getallen. Pooling vermindert het aantal activaties, maar heeft zelf geen parameters.
| Stap | Vorm per beeld | Parameters |
|---|---|---|
| Patches | 12×12×9 | 0 |
| Conv + ReLU | 12×12×4 | 40 |
| Maxpool | 6×6×4 | 0 |
| Flatten | 144 | 0 |
| Classifier | 10 | 1450 |
conv_count = 9*4+4
head_count = 144*10+10
print(conv_count+head_count)
# 1490
Maak train(data,epochs=8,eta=.08): init(7), één default_rng(8) vóór de epochlus en iedere epoch een nieuwe permutation. Verwerk batches van 64 inclusief het restant; meet beide metrics op train en val en bewaar history met epoch vanaf één, train, val. Kopieer alle parameters bij strikt lager validatieverlies en retourneer beste model plus history; weiger epochs onder één en behoud de datadictionary met arrays.
Gebruik nergens testkeys tijdens training. Teken de twee echte verliescurven met asnamen en legenda. Bij validatieverliezen [0.8,0.6,0.65] hoort de kopie van epoch 2, ook wanneer je daarna verder traint.
045. Bekijk echte filters, zonder er te veel uit af te leiden
Bereken test_metrics en predictions met het gekozen model. Learned_filters is model['kernel'].T.reshape(4,3,3): eerst kolommen naar filters draaien, dan iedere reeks van negen gewichten naar een matrix. Het nummervoorbeeld toont waarom alleen reshape een andere indeling zou geven.
numbered=np.arange(36).reshape(9,4)
filters = numbered.T.reshape(4,3,3)
print(filters[0].tolist())
# [[0,4,8],[12,16,20],[24,28,32]]
Feature_maps komt uit ReLU vóór pooling voor het eerste testbeeld, met vorm (4,12,12). Teken geleerde filters en deze featurekaarten als twee figuren met gelijke kleurschaal binnen iedere figuur, en print de gemeten testaccuracy. De nummervoorbeelden hierboven zijn geen geleerde gewichten.
Een filterplaatje is geen volledige verklaring van een voorspelling. Dit CNN gebruikt kleinere beelden dan het vorige MLP; hun scores vormen geen gecontroleerde architectuurvergelijking. Nieuwe modelkeuzes na testinspectie vereisen nieuwe evaluatiedata.
05Wat neem je mee?
- Eén gedeelde kernelmatrix berekent lokale inproducten op alle posities.
- Backprop gebruikt dezelfde poolingwinnaars en ReLU-poorten als forward.
- Validatie kiest de gewichten; test en echte figuren onderzoeken het gekozen model.
Hierna maak je diepere netwerken beter trainbaar met residualverbindingen en hergebruik je geleerde representaties.
Bronnen: Keras — MNIST, herkomst en licentie ↗ PyTorch — convolutie als cross-correlatie ↗ PyTorch — maxpooling ↗ · 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.
Maak lokale patches en initialiseer de gewichten
Je traint nu ook de lokale filters, niet alleen de classifier. Open bovenaan “De opdracht, leerdoelen en voorkennis” voor zes doorlopende codevoorbeelden: van een gemiddeld pixelblok naar een echte gewichtsupdate. Een blok [[0,255],[255,0]] wordt na delen door 255 en middelen één waarde 0.5.
Maak prepare(path) met allow_pickle=False. Schaal de drie datasplits als float32, reshape naar (n,14,2,14,2) en middel assen 2 en 4; stapel de negen verschoven 12×12-uitsneden langs de laatste as, eerst filterrij en dan kolom. Bewaar de patchtensor als (n,12,12,9), labels als int64 en roep data=prepare('/datasets/mnist-mini.npz') aan.
Maak init(seed=7) met een lokale default_rng: trek eerst kernel (9,4) met standaardafwijking √(2/9), daarna head (144,10) met √(1/144). Conv_bias (4,) en head_bias (10,) zijn nul; alle arrays zijn float32. Samen zijn dat 36+4+1440+10=1490 trainbare getallen.
Bekijk voorbeeld
import numpy as np
import matplotlib.pyplot as plt
def prepare(path):
data={}
with np.load(path,allow_pickle=False) as raw:
for split in ('train','val','test'):
images=raw['x_'+split].astype(np.float32)/255
images=images.reshape(-1,14,2,14,2).mean(axis=(2,4))
patches=np.stack([images[:,i:i+12,j:j+12] for i in range(3) for j in range(3)],axis=-1)
data['x_'+split]=patches
data['y_'+split]=raw['y_'+split].astype(np.int64)
return data
def init(seed=7):
rng=np.random.default_rng(seed)
return {'kernel':rng.normal(0,np.sqrt(2/9),(9,4)).astype(np.float32),'conv_bias':np.zeros(4,dtype=np.float32),'head':rng.normal(0,np.sqrt(1/144),(144,10)).astype(np.float32),'head_bias':np.zeros(10,dtype=np.float32)}
data=prepare('/datasets/mnist-mini.npz')
print('Patchtensor:',data['x_train'].shape)
print('Trainbare getallen:',sum(v.size for v in init().values()))Van gedeelde filters via pooling naar kansen
Dezelfde kernelkolom berekent het lokale inproduct op iedere beeldpositie. In het kleine lesvoorbeeld ontstaan preactivaties [[0.75,-0.25],[-0.25,1.75]]; ReLU maakt de negatieve waarden nul. Maxpool kiest vervolgens 1.75 op de laatste lokale positie, index 3 in rijvolgorde.
Maak forward(x,p) met cache x,a,winners,flat,z,prob: bereken a=x@kernel+conv_bias en h=max(a,0). Pooling_windows groepeert alleen assen tot (n,6,6,4,4), waarbij de laatste as de vier lokale posities bevat; kies argmax en haal de maxima op met take_along_axis. Flatten naar 144 features, bereken logits met head en head_bias en trek per rij het maximum af.
Score geeft een dictionary met loss en accuracy als Python-floats; bereken cross-entropy via log(sum(exp(z)))−z[juist label]. In het kleine voorbeeld geven logits [1.75,-1.75] kansen [0.970688,0.029312] en verlies 3.52975 voor label 1. De vormhelpers doen geen convolutie of leerberekening: die bouw jij.
Bekijk voorbeeld
def pooling_windows(h):
# (batch,12,12,4) -> (batch,6,6,4,vier lokale posities)
return h.reshape(-1,6,2,6,2,4).transpose(0,1,3,5,2,4).reshape(-1,6,6,4,4)
def undo_pool_windows(windows):
# Keer uitsluitend de vormtransformatie om, zonder waarden te veranderen.
return windows.reshape(-1,6,6,4,2,2).transpose(0,1,4,2,5,3).reshape(-1,12,12,4)
def forward(x,p):
a=x@p['kernel']+p['conv_bias']
h=np.maximum(a,0)
windows=pooling_windows(h)
winners=windows.argmax(axis=-1)
pooled=np.take_along_axis(windows,winners[...,None],axis=-1)[...,0]
flat=pooled.reshape(-1,144)
z=flat@p['head']+p['head_bias']
z=z-z.max(axis=1,keepdims=True)
exp=np.exp(z)
return {'x':x,'a':a,'winners':winners,'flat':flat,'z':z,'prob':exp/exp.sum(axis=1,keepdims=True)}
def score(x,y,p):
c=forward(x,p)
return {'loss':float(np.mean(np.log(np.exp(c['z']).sum(axis=1))-c['z'][np.arange(len(y)),y])),'accuracy':float(np.mean(c['prob'].argmax(axis=1)==y))}Train ook de filters, via de gekozen poolingroutes
Het foutsignaal gaat langs dezelfde winnaar en ReLU-poort als in forward. Bij het lesvoorbeeld is dz≈[0.970688,-0.970688] en krijgt de gepoolde waarde via head=[1,-1] ongeveer 1.941376 terug. Alleen index 3 ontvangt dat signaal; de kernelgradiënt wordt [1.941376,0,0,0.970688].
Maak backward(c,y,p) als dictionary kernel,conv_bias,head,head_bias, zonder cache of parameters te veranderen. Bereken dz=(prob−onehot)/batchgrootte, head/biasgradiënten vanuit flat en dz, en dpool vanuit dz@head.T met vorm (n,6,6,4). Gebruik np.eye(4)[winners] voor één-hotroutes en dpool[...,None] voor één extra laatste as, keer de venstergroepering om en vermenigvuldig met a>0.
Voor de kernel tel je alle batch- en beeldposities samen: x.reshape(−1,9).T @ da.reshape(−1,4); conv_bias krijgt de som over batch, rij en kolom. Deel niet nogmaals door het aantal posities, want dz bevat de batchmiddeling al. Update pas wanneer alle vier gradiënten berekend zijn; met eta 0.1 wordt het eerste voorbeeldgewicht 0.805862.
Bekijk voorbeeld
def backward(c,y,p):
dz=c['prob'].copy()
dz[np.arange(len(y)),y]-=1
dz/=len(y)
dflat=dz@p['head'].T
dpool=dflat.reshape(-1,6,6,4)
routes=np.eye(4,dtype=np.float32)[c['winners']]
dwindows=dpool[...,None]*routes
dh=undo_pool_windows(dwindows)
da=dh*(c['a']>0)
return {'kernel':c['x'].reshape(-1,9).T@da.reshape(-1,4),'conv_bias':da.sum(axis=(0,1,2)),'head':c['flat'].T@dz,'head_bias':dz.sum(axis=0)}Leer op train en kies op validatie
Ook filtergewichten leren van de classificatiefout; de vormhelpers blijven ongewijzigd. Een dalende trainingslijn bewijst nog geen generalisatie. Bij validatieverliezen [0.8,0.6,0.65] kies je de parameters van epoch 2, ook als je langer doortraint.
Maak train(data,epochs=8,eta=.08) met init(7), één default_rng(8) vóór de epochlus en iedere epoch een nieuwe permutation. Verwerk batches van 64 inclusief het restant, bereken alle gradiënten uit dezelfde parameters en update daarna de hele dictionary; meet train en val met score. Bewaar history met epoch vanaf één, train, val en kopieer alle arrays bij strikt lager validatieverlies; behoud de datadictionary met arrays.
Retourneer het beste model plus history en weiger epochs onder één; lees nergens testkeys tijdens training. Teken beide werkelijk gemeten verliescurven met asnamen en legenda. Vergelijk kernel met de beginwaarden om te zien dat ook de gedeelde filters veranderden.
Bekijk voorbeeld
def train(data,epochs=8,eta=.08):
if epochs<1:
raise ValueError("Gebruik minstens één epoch")
p=init(7)
rng=np.random.default_rng(8)
history=[]
best=None
best_loss=float('inf')
for epoch in range(epochs):
order=rng.permutation(len(data['y_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()}
row={'epoch':epoch+1,'train':score(data['x_train'],data['y_train'],p),'val':score(data['x_val'],data['y_val'],p)}
history.append(row)
if row['val']['loss']<best_loss:
best_loss=row['val']['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('Eigen convolutiefilters leren op MNIST')
plt.legend();plt.tight_layout()
print('Gekozen epoch:',min(history,key=lambda r:r['val']['loss'])['epoch'])
print('Kernelverandering:',round(float(np.linalg.norm(model['kernel']-init()['kernel'])),4))Evalueer en bekijk wat de filters doen
Het model ligt nu vast: bereken test_metrics en predictions op de apart gehouden testselectie. Kernelkolommen zijn filters, dus learned_filters=model['kernel'].T.reshape(4,3,3). Alleen reshape zonder transponeren zou de gewichten van verschillende filters door elkaar halen.
Feature_maps is de ReLU-uitvoer vóór pooling voor het eerste testbeeld, met vorm (4,12,12). Teken de vier geleerde filters en vier echte activatiekaarten als twee figuren, elk met een gedeelde kleurschaal; print de berekende testaccuracy. De nummervoorbeelden in de les tonen alleen de volgorde, niet de werkelijk geleerde gewichten.
Een filterplaatje is op zichzelf geen volledige verklaring voor een voorspelling. Dit CNN gebruikt kleinere beelden dan het MLP; scores vergelijken bewijst daardoor geen architectuurvoordeel. Nieuwe keuzes na testinspectie vereisen nieuwe evaluatiedata.
Bekijk voorbeeld
test_metrics=score(data['x_test'],data['y_test'],model)
predictions=forward(data['x_test'],model)['prob'].argmax(axis=1)
learned_filters=model['kernel'].T.reshape(4,3,3)
feature_maps=np.maximum(forward(data['x_test'][:1],model)['a'][0],0).transpose(2,0,1)
fig,axes=plt.subplots(2,2,figsize=(5,4),layout='constrained')
limit=float(np.abs(learned_filters).max())
for i,ax in enumerate(axes.flat):
im=ax.imshow(learned_filters[i],cmap='RdBu_r',vmin=-limit,vmax=limit)
ax.set_title('Geleerd filter '+str(i+1));ax.set_xticks([]);ax.set_yticks([])
fig.colorbar(im,ax=axes.ravel().tolist(),shrink=.8,label='Gewicht')
fig,axes=plt.subplots(2,2,figsize=(5,4),layout='constrained')
for i,ax in enumerate(axes.flat):
im=ax.imshow(feature_maps[i],cmap='viridis',vmin=0,vmax=float(feature_maps.max()))
ax.set_title('Featurekaart '+str(i+1));ax.set_xticks([]);ax.set_yticks([])
fig.colorbar(im,ax=axes.ravel().tolist(),shrink=.8,label='ReLU-activatie')
print('Test-accuracy:',round(test_metrics['accuracy'],4))
print('Correct / totaal:',int(np.sum(predictions==data['y_test'])),len(predictions))
print('Eerste testbeeld: echt',int(data['y_test'][0]),'voorspeld',int(predictions[0]))