import matplotlib
if not hasattr(matplotlib.RcParams, "_get"):
    matplotlib.RcParams._get = dict.get

Modul 4: Neurale netværk og digitale billeder#

0: Opsætning#

import numpy as np
np.random.seed(0)
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D

from sklearn.model_selection import train_test_split
from skimage import data, img_as_float, restoration, color, util
from sklearn.datasets import load_digits
from sklearn.preprocessing import OneHotEncoder
from scipy import fftpack, ndimage, linalg
from sympy import symbols
from sympy.plotting import plot3d

plt.rcParams["figure.figsize"] = (6,4)
np.set_printoptions(precision=4, suppress=True)

1: Digitalt billede som matrix#

Vi importerer et billede:

img = img_as_float(data.camera())
plt.imshow(img, cmap="gray", vmin=0, vmax=1); plt.axis("off"); plt.title("Gråtone‑billede")

Hvordan ser man dette billede som en matrix i Python? Hvad er størrelsen af matricen, hvilke værdier indgår i matricen og hvad angiver værdierne i matricen?

# Convert to numpy array and inspect size
# Add CODE HERE

Zoom ind på de \(8 \times 8\) pixel i øverste, venstre hjørne og udskriv pixel-værdierne som en \(8 \times 8\) matrix. Hvad bemærker man ved de 64 gråtone-værdier?

print("Top-left 8×8 block:")
# ADD CODE HERE

Zoom nu ind på de \(8 \times 8\) pixel i nederste, højre hjørne og vis dit zoom som et (pixeleret) billede

# ADD CODE HERE

2: Digitalt billede som vektor#

Vi skal arbejde med automatisk genkendelse af håndskrevne tal fra sklearn.datasets.load_digits:

digits_object = load_digits() 
X = digits_object.data  # fetch data
y = digits_object.target # fetch labels
X = X / 16.0  # normalize to [0, 1]

# Show some examples
fig, axes = plt.subplots(2, 5, figsize=(8, 3))
for ax, img, label in zip(axes.ravel(), X, y):
    ax.imshow(img.reshape(8, 8), cmap="gray", vmin=0, vmax=1)
    ax.set_title(f"Label: {label}")
    ax.axis("off")

plt.show()

Kan du se hvilke håndskrevne tal der er tale om? Tjek med de rigtige labels.

Vi vil i dag gerne bygge et neuralt netværk \(\Phi : \mathbb{R}^n \to \mathbb{R}^k\) der kan genkende disse håndskrevne tal. Denne “AI-funktion” tager altså en vektor i \(\mathbb{R}^n\) som input og giver en vektor i \(\mathbb{R}^k\) som output.

Vores input er umiddelbart en \(8 \times 8\) matrix:

example_img = X[0].reshape(8, 8)  # image as 8x8 matrix
print(example_img)

Hvordan kan vi få det lavet om til en vektor?

# ADD CODE HERE

Input til vores AI-funktion \(\Phi\) er håndskrevne tal (repræsenteret som en vektor i \(\mathbb{R}^n\)). Vi ønsker at output af \(\Phi\) skal være en sandsynlighedsvektor af længde 10 med sandsynligheden for at det håndskrevne tal er hhv. 0, 1, 2, …, 9.

Note

En sandsynlighedsvektor er en vektor af tal tilhørende [0,1], hvis elementer summer til 1 (altså en diskret sandsynlighedsfordeling over klasserne).

Hvad er \(n\) og \(k\) i vores eksempel. Hvad er det ideelle output af funktionen \(\Phi\), hvis inputtet er et håndskrevet 8-tal?

Den (indtilvidere udkendte) AI-funktionen \(\Phi\) som opbygges som et neuralt netværk. Det næste opgaver introducerer byggestenene i sådanne funktioner.

3: ReLU-funktionen#

Definer ReLU funktionen \(\mathrm{ReLU}: \mathbb{R} \to \mathbb{R}\) i Python. Plot grafen af funktionen.

def relu(z):
    return 0 * z # FIX CODE HERE

x = np.linspace(-5, 5, 200)
plt.plot(x, relu(x), label="ReLU")

Forklar hvad der sker i følgende kode.

vec = np.array([-3, -1, 0, 1, 3])
print("ReLU on vector:", relu(vec)) 

Er ReLU funktionen lineær? Er den kontinuert? Udregn den afledte. Er den differentiabel?

4: Gradienten#

Vi ønsker at finde minimum af en funktion

\[\begin{equation*} f:\mathbb{R}^2 \to \mathbb{R}, \qquad f(x,y) = 3(x-1)^2 + y^2 + 4. \end{equation*}\]

Vi tegner først grafen for funktionen. I SymPy gøres det ved:

# Define the symbols x and y
x, y = symbols('x, y')

# Define the function f(x,y)
f = 3*(x-1)**2 + y**2 + 4

# Plot the function as a 3D surface
plot3d(f, (x, -1, 3), (y, -2, 2), title="Graph of f")

mens det i NumPy kræver lidt mere arbejde:

# Define the function f(x,y)
def f(x, y):
    return 3*(x-1)**2 + y**2 + 4

# Create a grid of (x, y) values
x_vals = np.linspace(-1, 3, 100)
y_vals = np.linspace(-2, 2, 100)
X, Y = np.meshgrid(x_vals, y_vals)

# Calculate the Z values for each point in the grid
Z = f(X, Y)

# Create the 3D plot
fig = plt.figure()
ax = fig.add_subplot(111, projection='3d')

# Plot the surface
ax.plot_surface(X, Y, Z, cmap='viridis', edgecolor='none')

# Add titles and labels
ax.set_title("Graph of f")
ax.set_xlabel('x')
ax.set_ylabel('y')
ax.set_zlabel('f(x, y)')

plt.show()

a: Gradient-vektoren#

Vi betragter funktionen \(f : \mathbb{R}^2 \to \mathbb{R}\) givet ved:

\[\begin{equation*} f(x,y)=3(x-1)^2 + y^2 + 4 \end{equation*}\]

Gradient-vektoren består af de partielle afledte:

\[\begin{equation*} \nabla f(x,y) = \begin{bmatrix} \frac{\partial f}{\partial x} \\ \frac{\partial f}{\partial y} \end{bmatrix}. \end{equation*}\]

hvilket også skrives \(\nabla f(x,y) = [\frac{\partial f}{\partial x}, \frac{\partial f}{\partial y}]^T\) - her betyder T’et blot at vi tager den transponerede af rækkevektoren, hvilket omdanner den til en søjlevektor.

Find gradient-vektoren for \(f\).

b: Gradient-vektoren og niveau-kurver#

Plot gradient-vektoren i et valgfrit punkt \((x_0,y_0)\). Plot niveau-kurven gennem samme punkt.

# ADD CODE HERE

Note

Niveaukurver for en funktion \(f\) er mængden af punkter \((x,y)\) hvor \(f(x,y)=c\) for et givet reelt tal \(c\), og i Python kan man lave et grid over (x,y)-plan med:

x = np.linspace( -1.0, 4.0, 200)
y = np.linspace( -2.0, 2.5, 200)
Xg, Yg = np.meshgrid(x, y)
Z = f(Xg, Yg)

og bruge matplotlib.pyplot.contour eller contourf til at tegne niveaukurver: plt.contour(Xg, Yg, Z, levels=15, cmap="gray") Man plotter oftest flere niveau-kurver i samme plot.

Det kan tage tid at skrive koden til at plotte disse ting fra bunden. Når du har gjort et forsøg, eller hvis du sidder fast, er du velkommen til at bruge koden i svaret som inspiration eller til at komme videre.

c: Minimum#

I hvilket punkt \((x,y)\) antager funktionen sin minimumsværdi?

d: Fortolkning#

Hvordan skal man forstå (det korrekte) udsagn: “Gradienten \(\nabla f(x,y)\) peger i den retning (fra punktet \((x,y)\)), hvor funktionen vokser hurtigst”? Er det korrekt at \(-\nabla f(x,y)\) peger i den retning, hvor funktionen aftager hurtigst?

Peger den negative gradient altid præcist mod funktionens minimum?

6: Netværkslandskabet: Grafen af et neuralt netværk#

I denne opgave skal vi visualisere og fortolke stykkevist-lineære funktioner skabt af ReLU-net, hvor input dimensionen er \(2\) og output dimensionen er \(1\). Det drejer sig altså om funktioner af formen \(\Phi: \mathbb{R}^2 \to \mathbb{R}\). Grafen for et neuralt netværk (netværks-funktionen \(\Phi\)) kaldes netværkslandskabet.

Vi betragter her kun tynde (shallow) netværk, hvor der kun er ét skjult lag. Arkitekturen for vores netværk kan beskrives med notationen \(2 \to n \to 1\) (eller som \(\mathbb{R}^2 \to \mathbb{R}^n \to \mathbb{R}^1\)). Denne notation angiver antallet af neuroner i hvert lag:

  • 2 neuroner i input-laget, svarende til de to input-variable \((x_1, x_2)\) som er en (søjle)vektor i \(\mathbb{R}^2\).

  • \(n\) neuroner i det skjulte lag, hvis output kan tænkes på som en søjlevektor i \(\mathbb{R}^n\). Dette tal, \(n\), kan vi variere for at se, hvordan det påvirker netværkets kompleksitet.

  • 1 neuron i output-laget, som producerer den endelige output-værdi \(z=\Phi(x_1, x_2)\) som her blot er et tal i \(\mathbb{R}^1\). I output-laget bruges der typisk ingen aktiveringsfunktion som fx ReLU, da ReLU ville umuliggøre negative output-værdier.

Vi skal forstå sammenhængen mellem antallet af neuroner i det skjulte lag, \(n\), og “kompleksiteten” af netværkslandskabet.

Det neurale netværk bygges af følgende funktioner:

def relu(z):
    return np.maximum(0.0, z)

def sample_weights(n):
    W1 = np.random.randn(n, 2)
    b1 = 0.2 * np.random.randn(n, 1)
    W2 = np.random.randn(1, n)
    b2 = 0.2 * np.random.randn(1, 1)
    return W1, b1, W2, b2

def neural_network(W1, b1, W2, b2, X):  
    Z1 = W1 @ X + b1  # Pre-activation (logits) for hidden layer
    H = relu(Z1)      # Post-activation for hidden layer
    Z2 = W2 @ H + b2  # Final output (logits)
    return Z2

Endelig introducerer vi nogle hjælpefunktioner til at plotte grafen:

# 2D grid evaluation 
def eval_on_grid(W1, b1, W2, b2, xlim=(-2,2), ylim=(-2,2), res=120):
    x1_vals = np.linspace(xlim[0], xlim[1], res)
    x2_vals = np.linspace(ylim[0], ylim[1], res)
    X1g, X2g = np.meshgrid(x1_vals, x2_vals, indexing='xy')
    XY_grid = np.stack([X1g.ravel(), X2g.ravel()], axis=0)      # (2, res*res)
    Z_out = neural_network(W1, b1, W2, b2, XY_grid).reshape(X1g.shape) # (res, res)
    return X1g, X2g, Z_out

# Plots 
def plot_surface(W1, b1, W2, b2, title="", xlim=(-2,2), ylim=(-2,2), res=120):
    X1g, X2g, Z_out = eval_on_grid(W1, b1, W2, b2, xlim, ylim, res)
    fig = plt.figure()
    ax = fig.add_subplot(111, projection='3d')
    ax.plot_surface(X1g, X2g, Z_out, linewidth=0, antialiased=True)
    ax.set_title(title)
    ax.set_xlabel("x1")
    ax.set_ylabel("x2")
    ax.set_zlabel("Φ(x1,x2)")
    plt.show()

def plot_contours(W1, b1, W2, b2, levels=10, title="", xlim=(-2,2), ylim=(-2,2), res=200):
    X1g, X2g, Z_out = eval_on_grid(W1, b1, W2, b2, xlim, ylim, res)
    plt.figure()
    cs = plt.contour(X1g, X2g, Z_out, levels=levels)
    plt.clabel(cs, inline=True, fontsize=8)
    plt.title(title if title else "Contour of Φ")
    plt.xlabel("x1"); plt.ylabel("x2")
    plt.axis("equal")
    plt.show()

a: Netværket når n=3#

Opbyg et netværk af formen (\(2\to 3\to 1\)) i Python, hvor du vælger følgende vægte:

\[\begin{equation*} W_1 = \begin{bmatrix} 1 & -1\\[4pt] 2 & 0\\[4pt] -1 & 2 \end{bmatrix},\qquad b_1 = \begin{bmatrix} 0\\[4pt] 1\\[4pt] -1 \end{bmatrix},\qquad W_2 = \begin{bmatrix} 1 & -2 & 1 \end{bmatrix},\qquad b_2 = \begin{bmatrix} 0 \end{bmatrix}. \end{equation*}\]

Note

Ordet “vægte” bruges her blot om parametrene (tallene) i matricerne \(W_\ell\) og i vektorerne \(b_\ell\). Normalt kaldes \(W_\ell\) for “the weight matrix” mens \(b_\ell\) kaldes for “the bias”. Det er disse parametre man ændrer på når man træner et netværk ved hjælp af gradient-metoden.

Tjek at dit netværk giver værdien \(-4\) når du kalder neural_network(W1, b1, W2, b2, np.array([[1.],[-1.]])). Plot derefter grafen som 3D-overflade \(z=\Phi(x_1,x_2)\) for netværket med præcis disse \(W_\ell\) matricer og \(b_\ell\) vektorer. Nedenfor plottes grafen for et “tilfældigt” netværk:

# Sample random weights for a 2 -> 3 -> 1 network
W1, b1, W2, b2 = sample_weights(3)

# Single test input (column vector)
x_test = np.array([[1.],[-1.]])   # shape (2,1)
z2_test = neural_network(W1, b1, W2, b2, x_test)
print("neural_network output for x = [[1.],[-1.]] ->", z2_test.ravel())

# A few more test points (shape (2, N))
X_check = np.array([[0., 1., 0., 1.],
                    [0., 0., 1., 1.]])
z2_check = neural_network(W1, b1, W2, b2, X_check)
print("inputs:\n", X_check.T)
print("outputs:\n", z2_check.ravel())

# Visualize using the helper functions already defined above
plot_surface(W1, b1, W2, b2, title="2→3→1 net")
plot_contours(W1, b1, W2, b2, levels=20, title="Konturplot: 2→3→1 net")

Hvor mange “knæk” kan du se i niveaukurverne? Hvorfor er overfladen stykkevist plan? Hvor mange lineære stykker er overfladen sammensat af?

b: Netværket når n er lavere og højere#

Kør samme visualisering for \(n=1\) og \(n=8\) og sammenlign den visuelle kompleksitet af grafen for netværket \(\Phi(x_1,x_2)\). Restriktionen af funktionen \(\Phi(x_1,x_2)\) langs en linje i planet \(x_2 = a x_1 + b\) vil være stykkevis lineær funktion af een variabel, nemlig \(x_1\). Men hvordan afhænger antallet af linjestykker sig når \(n\) ændres?

8: Træning af et Neuralt Netværk#

Vi har nu set på alle de nødvendige byggeblokke: digitale billeder som vektorer, ReLU-funktionen, gradient-metoden og strukturen af et neuralt netværk. I denne afsluttende opgave samler vi det hele for at træne et neuralt netværk fra bunden til at genkende de håndskrevne tal fra digits-datasættet.

I modsætning til næste opgave 9: Design af en Ring-Detektor, “Ring-Detektor”-opgaven, hvor vi designer vægtene manuelt, vil vi her initialisere vægtene tilfældigt og derefter lade gradient-metoden iterativt forbedre dem.

a: Problemstilling#

Målet er at finde de optimale vægte \((W_1, b_1, W_2, b_2)\) for et shallow neuralt netværk, \(\Phi: \mathbb{R}^{64} \to \mathbb{R}^{10}\), der minimerer en tabsfunktion. Tabsfunktionen måler, hvor “forkerte” netværkets forudsigelser er i forhold til de sande labels.

b: Netværkets Funktion#

Vores netværk har ét skjult lag og er givet ved funktionen:

\[\begin{equation*} \Phi(\mathbf{x}) = \text{softmax}\left( W_2 \cdot \text{ReLU}(W_1 \mathbf{x} + \mathbf{b}_1) + \mathbf{b}_2 \right) \end{equation*}\]

hvor:

  • \(\mathbf{x} \in \mathbb{R}^{64}\) er det “flade” input-billede.

  • \(W_1, \mathbf{b}_1, W_2, \mathbf{b}_2\) er vægtmatricer og bias-vektorer, som vi skal optimere.

  • ReLU er aktiveringsfunktionen for det skjulte lag.

  • softmax omdanner outputtet til en sandsynlighedsfordeling over de 10 cifre.

c: Input vs. Parametre: En Vigtig Forskel#

Det er afgørende at skelne mellem to forskellige roller, som “variable” spiller i denne proces:

  1. Modellens Input (x): Når vi tænker på den færdigtrænede netværks-funktion, \(\Phi(\mathbf{x})\), er \(\mathbf{x}\) (billed-vektoren) den variabel, der kommer ind i funktionen. Parametrene \(W_1, \mathbf{b}_1, W_2, \mathbf{b}_2\) er på dette tidspunkt faste konstanter, der definerer selve funktionen. Vi har én specifik funktion, \(\Phi\), som vi kan kalde med forskellige input-billeder.

  2. Modellens Parametre (W, b): Når vi træner modellen, bytter rollerne om. Her er vores mål at finde de bedste værdier for parametrene. Derfor er det nu \(W_1, \mathbf{b}_1, W_2, \mathbf{b}_2\), der er de variable, som vi justerer på. Tabsfunktionen \(L\), der indføres nedenfor, er en funktion af disse parametre, \(L(W_1, \mathbf{b}_1, \dots)\), hvor træningsdataene (\(\mathbf{x}\) og \(\mathbf{y}\)) holdes konstante. Her er \(\mathbf{x}\) input-billedet, og \(\mathbf{y}\) er den tilhørende korrekte label (f.eks. tallet 7), som modellen skal lære at forudsige.

Gradient-metoden opererer på tabsfunktionen. Den behandler altså modellens parametre som variable og justerer dem for at minimere tabet. Når træningen er færdig, “låser” vi disse optimale parametre, og resultatet er vores færdige netværks-funktion, \(\Phi(\mathbf{x})\), der er klar til at modtage nye billeder som input.

d: One-hot encoding#

Vores neurale netværk producerer et output i form af en sandsynlighedsvektor i \(\mathbb{R}^{10}\). For at kunne beregne tabet (fejlen) skal vi sammenligne denne output-vektor med den sande label. Den sande label, f.eks. tallet 3, skal derfor også repræsenteres som en vektor i \(\mathbb{R}^{10}\).

Dette gøres ved hjælp af one-hot encoding: En label \(y\) omdannes til en vektor af længde \(K\) (antallet af klasser), som består af lutter nuller, undtagen på den position, der svarer til klassens indeks, hvor der står et 1-tal.

Eksempel: For vores problem med \(K=10\) klasser (cifrene 0-9), vil en label \(y=3\) blive til one-hot vektoren:

\[\begin{equation*} \mathbf{y} = [0, 0, 0, 1, 0, 0, 0, 0, 0, 0] \end{equation*}\]

hvor 1-tallet står på indeks 3 (husk 0-baseret indeksering).

Hvordan ser one-hot vektoren ud for en label \(y=7\)?

For at lave one-hot encoding af labels i Python kan man bruge OneHotEncoder fra sklearn.
Et eksempel:

enc = OneHotEncoder(sparse_output=False)
Y = enc.fit_transform(y.reshape(-1, 1))

Her omdannes vektoren y med klasselabels til en one-hot matrix Y, hvor hver række svarer til én one-hot encoding for et billede.

e: Tabsfunktionen (Cross-Entropy Loss)#

For at måle hvor “forkert” netværkets forudsigelse er, bruger vi en tabsfunktion. For klassifikationsproblemer er Cross-Entropy Loss det mest almindelige valg.

For et enkelt træningseksempel \((\mathbf{x}, \mathbf{y})\), hvor \(\mathbf{y}\) er den sande one-hot label og \(\mathbf{p} = \Phi(\mathbf{x})\) er netværkets forudsagte sandsynligheder, er tabet givet ved:

\[\begin{equation*} L(\mathbf{p}, \mathbf{y}) = - \sum_{i=0}^{9} y_i \log(p_i) \end{equation*}\]

Vi beregner gennemsnittet af dette tab over alle billeder i vores træningssæt.

Forståelse af Cross-Entropy

Lad os sige, at den sande label er \(y=3\), så den tilhørende one-hot vektor er \(\mathbf{y} = [0, 0, 0, 1, 0, \dots]\). I dette tilfælde reduceres tabsfunktionen til \(L = -1 \cdot \log(p_3)\).

Hvorfor er dette en fornuftig måde at måle fejl på? Tænk på, hvad der sker med værdien af \(L\), når netværkets forudsagte sandsynlighed for klasse 3, \(p_3\), er:

  1. Meget tæt på 1 (en korrekt og sikker forudsigelse).

  2. Meget tæt på 0 (en forkert og sikker forudsigelse).

Alternativ Tabsfunktion

Kunne vi ikke have brugt en mere simpel tabsfunktion, som f.eks. den kvadratiske fejl (Mean Squared Error), vi kender fra lineær regression?

\[ L_{MSE}(\mathbf{p}, \mathbf{y}) = \sum_{i=0}^{9} (p_i - y_i)^2 \]

Når vi senere skal implementere tabet i Python, skal vi implementere følgende to trin:

  1. Softmax: Vi omdanner outputtet fra netværkets sidste lag (såkaldte logits) til en sandsynlighedsfordeling.

  2. Cross-Entropy Loss: Vi måler fejlen ved at tage den negative logaritme af sandsynligheden for den korrekte klasse og derefter gennemsnittet over alle træningsbilleder.

Et eksempel:

# Softmax: omdanner logits til sandsynligheder for hver klasse
exp_Z = np.exp(Z)
# probs er softmax-outputtet
probs = exp_Z / np.sum(exp_Z, axis=0, keepdims=True)
# Cross-entropy loss:
# Y_train er one-hot encoding matrix
loss = np.mean(-np.log(probs[Y_train.argmax(axis=0), np.arange(m_train)]))
# For at beregne tabet, skal vi bruge sandsynligheden for den korrekte klasse for hvert eksempel.
# probs[Y_train.argmax(axis=0), np.arange(m_train)] er en effektiv måde at udvælge disse sandsynligheder på.
# Y_train.argmax(axis=0) giver række-indekserne (de sande klasser).
# np.arange(m_train) giver søjle-indekserne (eksempel-nummeret).

f: Træningsprocessen#

Koden nedenfor implementerer gradient-metoden (også kaldet backpropagation og gradient descent) ved at gentage følgende tre skridt i en løkke:

  1. Forward Pass: Send alle træningsbilleder gennem netværket for at beregne forudsigelserne (probs) og det samlede tab (loss).

  2. Backward Pass (Backpropagation): Beregn gradienten af tabsfunktionen \(\nabla L\) med hensyn til hver eneste parameter i netværket. Dette gøres effektivt ved hjælp af kædereglen, hvor fejlen “propagateres” baglæns gennem netværket.

  3. Parameter Update: Juster hver parameter ved at tage et lille skridt i den negative gradient-retning.

Efter at have gentaget disse skridt mange gange, testes det trænede netværks nøjagtighed på et separat testsæt, som det aldrig har set før.

g: Kort om Holdout-metoden#

Når man træner et neuralt netværk, er det afgørende at teste modellen på billeder den ikke har set under træningen. Holdout-metoden opdeler derfor datasættet i to dele.

Hvis vi tester på de samme data, som vi træner på, risikerer vi at modellen bare husker træningsbillederne i stedet for at lære generelle mønstre. Så siger man, at modellen ‘overfitter’ til træningsdataen. Et separat testsæt afslører, hvor godt modellen kan generalisere.

Træningssæt:

  • Modellen lærer fra dette datasæt

  • Her foretages forward pass, backpropagation og vægtopdateringer

Testsæt:

  • Modellen ser dette datasæt først til allersidst

  • Giver et retvisende billede af generaliseringsevnen

Kort sagt: Træn på ét datasæt - evaluer på et andet.

I Python kan man splitte et datasæt til et træningssæt og testsæt med funktionen train_test_split(X, Y, test_size=0.2, random_state=42), hvor random_state fastlåser, hvordan vi splitter dataen, så man kan genskabe sine resultater senere.

h: Implementeringen af modellen#

Vi er nu klar til at teste og træne netværket. Vi bruger ligesom i opgaven 2: Digitalt billede som vektor sklearn’s digits-datasæt, som består af 8x8 pixels billeder af håndskrevne tal:

# Load small digits dataset (8x8, built into sklearn)
X, y = load_digits(return_X_y=True)
X = X / 16.0
enc = OneHotEncoder(sparse_output=False)
Y = enc.fit_transform(y.reshape(-1, 1))
print(f"Original data shapes: X={X.shape}, Y={Y.shape}")

Herefter bruger vi Holdout-metoden til at reservere 20 procent af billederne til test af modellen ved at benytte funktionen train_test_split(). Derudover initialiserer vi vægtmatricerne og bias-vektorerne med tilfældige tal trukket fra en normalfordelingen.

# Split data, and TRANSPOSE X to fit the W @ X convention
# X shape becomes (features, samples) instead of (samples, features)
X_train_orig, X_test_orig, Y_train, Y_test = train_test_split(X, Y, test_size=0.2, random_state=42)
X_train, X_test = X_train_orig.T, X_test_orig.T
Y_train, Y_test = Y_train.T, Y_test.T

# Get number of samples
m_train = X_train.shape[1]
m_test = X_test.shape[1]

# Tiny 1-hidden-layer NN with (neurons, features) shape for weights
# W1: 32 neurons, 64 input features -> (32, 64)
# b1: bias for each of the 32 neurons -> (32, 1)
# W2: 10 neurons, 32 hidden features -> (10, 32)
# b2: bias for each of the 10 neurons -> (10, 1)
W1 = np.random.randn(32, 64) * 0.1
b1 = np.zeros((32, 1))
W2 = np.random.randn(10, 32) * 0.1
b2 = np.zeros((10, 1))

Vi kører 100 iterationer af gradient-metoden og tester herefter modellen på det tilbageholdte testsæt:

lr = 0.1
for epoch in range(100):
    # forward pass with W @ X
    Z1 = W1 @ X_train + b1
    H = np.maximum(0, Z1)  # Hidden layer activations, shape (32, m_train)
    Z2 = W2 @ H + b2       # Output logits, shape (10, m_train)
    
    # Softmax applied column-wise (axis=0) for each sample
    exp_Z2 = np.exp(Z2)
    probs = exp_Z2 / np.sum(exp_Z2, axis=0, keepdims=True)
    
    # Cross-entropy loss
    loss = np.mean(-np.log(probs[Y_train.argmax(axis=0), np.arange(m_train)]))
    
    # backward pass (backpropagation)
    dZ2 = probs - Y_train
    dW2 = (1/m_train) * dZ2 @ H.T
    db2 = (1/m_train) * np.sum(dZ2, axis=1, keepdims=True)
    
    dH = W2.T @ dZ2
    dZ1 = dH * (Z1 > 0) # ReLU gradient
    dW1 = (1/m_train) * dZ1 @ X_train.T
    db1 = (1/m_train) * np.sum(dZ1, axis=1, keepdims=True)
    
    # update parameters
    W1 -= lr * dW1
    b1 -= lr * db1
    W2 -= lr * dW2
    b2 -= lr * db2

# test accuracy
Z1_test = W1 @ X_test + b1
H_test = np.maximum(0, Z1_test)
Z2_test = W2 @ H_test + b2
pred = np.argmax(Z2_test, axis=0) # Get predicted class for each column (sample)
acc = np.mean(pred == Y_test.argmax(axis=0))
print(f"Accuracy: {acc:.3f}")

Hvad sker der med test-præcisionen, hvis du ændrer initialiseringen af vægtmatricerne og bias-vektorerne? Hvor stor en ændring skal der til, før at præcisionen bliver betydelig dårligere? Hvad tror du der sker hvis for epoch in range(100): ovenfor ændres til for epoch in range(10): eller for epoch in range(1000):? (Prøv)

Vi har nu set, hvordan man træner et neuralt netværk og måler dets præstation på hele test-sættet. Men for at forstå hvad modellen laver, er det nyttigt at kigge på klassificeringen af et enkelt billede. Koden nedenfor viser sandsynlighedsoutputtet, når det første test-billede køres igennem modellen:

# Take a single image from the test set
sample_idx = 0  # First test image
single_image = X_test[:, sample_idx:sample_idx+1]  # Keep shape (64, 1)

# Run the image through the network
Z1_single = W1 @ single_image + b1
H_single = np.maximum(0, Z1_single)
Z2_single = W2 @ H_single + b2

# Find the predicted class
predicted_class = np.argmax(Z2_single, axis=0)[0]
actual_class = np.argmax(Y_test[:, sample_idx])

# Display the image
plt.figure(figsize=(6, 3))

# Left: show the actual image
plt.subplot(1, 2, 1)
# Reshape back to 8x8 and display
image_2d = X_test_orig[sample_idx].reshape(8, 8)
plt.imshow(image_2d, cmap='gray')
plt.title(f'Image {sample_idx}\nActual: {actual_class}')
plt.axis('off')

# Right: show probabilities
plt.subplot(1, 2, 2)
probabilities = np.exp(Z2_single) / np.sum(np.exp(Z2_single))
classes = range(10)
colors = ['red' if i == actual_class else ('green' if i == predicted_class else 'blue') for i in classes]

plt.bar(classes, probabilities.flatten(), color=colors)
plt.xlabel('Class')
plt.ylabel('Probability')
plt.title(f'Predicted: {predicted_class}\nCorrect: {predicted_class == actual_class}')
plt.xticks(classes)

plt.tight_layout()
plt.show()

Kan du finde et billede, der misklassificeres af modellen?

Nedenstående kode identificerer de første 5 fejlklassificerede billeder sammen med sandsynlighedsoutputtet for modellen:

# Get all predictions on the test set
Z1_test = W1 @ X_test + b1  # First layer pre-activation
H_test = np.maximum(0, Z1_test)  # ReLU activation
Z2_test = W2 @ H_test + b2  # Output layer logits
test_predictions = np.argmax(Z2_test, axis=0)  # Predicted classes
true_labels = np.argmax(Y_test, axis=0)  # True classes

# Find where the model makes mistakes
incorrect_indices = np.where(test_predictions != true_labels)[0]

# Display the first 5 misclassified images
plt.figure(figsize=(12, 8))

for i, idx in enumerate(incorrect_indices[:5]):
    # Show the image
    plt.subplot(2, 5, i+1)
    image_2d = X_test_orig[idx].reshape(8, 8)  # Reshape to 8x8
    plt.imshow(image_2d, cmap='gray')
    plt.title(f'Image {idx}\nTrue: {true_labels[idx]}\nPred: {test_predictions[idx]}')
    plt.axis('off')
    
    # Show probability distribution
    plt.subplot(2, 5, i+6)
    single_image = X_test[:, idx:idx+1]  # Get single image keeping shape
    Z1_single = W1 @ single_image + b1
    H_single = np.maximum(0, Z1_single)
    Z2_single = W2 @ H_single + b2
    probabilities = np.exp(Z2_single) / np.sum(np.exp(Z2_single))  # Softmax
    
    classes = range(10)
    # Color coding: red=true, green=predicted, blue=other
    colors = ['red' if c == true_labels[idx] else 
              ('green' if c == test_predictions[idx] else 'blue') 
              for c in classes]
    
    plt.bar(classes, probabilities.flatten(), color=colors)
    plt.xticks(classes)
    plt.xlabel('Class')
    plt.ylabel('Probability')

plt.tight_layout()
plt.show()

Når du kigger på de fejlklassificerede eksempler og deres sandsynlighedsfordelinger, hvilke mønstre lægger du mærke til? Prøv eventuelt at ændre hvilke fejlklassificerede billeder, der vises, og undersøg om disse mønstre er gennemgående.

Ekstra-spørgsmål (frivillig)#

  1. Er man garanteret til at finde det ægte minimumspunkt for tabsfunktionen \(L\) ved gradient-metoden brugt oven for?

  2. Hvorfor kan man ikke bare finde minimumspunktet for tabsfunktionen \(L\) direkte? Kan man plotte funktionen \(L\) og visuelt aflæse minimum?

  3. Netværkets funktion \(\Phi\) er bestemt af dets vægtmatricer (\(W_1, W_2\)) og bias-vektorer (\(b_1, b_2\)). Beregn det samlede antal af disse justerbare parametre i \(\Phi\)?

  4. Kan du matematisk verificere udregningen af nogle af de centrale gradienter i backward pass-delen af koden (f.eks. dZ2 og dW2) i for-løkken for epoch in range(100): i Python-koden ovenfor?