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
Svar
Værdierne i matricen er decimal-tal normaliseret til intervallet [0, 1], hvor hver værdi angiver gråtone‑intensiteten i den tilsvarende pixel (0 = sort, 1 = hvid). Matricens dimensioner svarer til billedets højde og bredde (512×512).
Du kan copy-paste denne kode til et Python-vindue:
# Convert to numpy array and inspect size
arr = np.array(img)
print("shape:", arr.shape, "dtype:", arr.dtype)
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
Svar
Det bemærkes, at de 64 gråtoneværdier i det øverste venstre hjørne er næsten konstante, hvilket tyder på et homogent område uden mange detaljer (fx en “ensfarvet” flade). Det ses også i det zoomede billede, hvor nabopixel kun varierer lidt, hvilket indikerer lav lokal kontrast i den pågældende region. Det gælder generelt for de fleste naturlige billeder af høj opløsning, at der er en høj grad af spatial korrelation: Værdien af en pixel er ofte meget tæt på værdien af dens nabopixeler. Denne redundans (overflødige information) er en fundamental egenskab, der udnyttes i billedkomprimeringsformater som JPEG. I stedet for at gemme den præcise værdi for hver enkelt pixel, kan man mere effektivt gemme en basisværdi og de små forskelle til nabopixelerne.
print("Top-left 8×8 block:")
print(arr[:8, :8])
plt.imshow(arr[:8, :8], cmap="gray", vmin=0, vmax=1); plt.axis("off"); plt.title("Zoomet billede")
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
Svar
print("Bottom-right 8×8 block:")
print(arr[-8:, -8:])
plt.imshow(arr[-8:, -8:], cmap="gray", vmin=0, vmax=1); plt.axis("off"); plt.title("Zoomet billede")
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
Svar
# flatten matrix into vector
example_img = X[0].reshape(8, 8) # first image as 8x8 matrix
example_vec = example_img.flatten() # flatten to vector of length 64
print("Matrix shape:", example_img.shape)
print("Vector shape:", example_vec.shape)
example_img, example_vec
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?
Svar
n = 64 (8×8 pixels fladet ud) k = 10 (klasserne 0–9)
Ideelt output for et 8-tal: en “one‑hot” sandsynlighedsvektor af længde 10 med 1 ved indeks 8, f.eks. [0,0,0,0,0,0,0,0,1,0] (husk at vi bruger 0‑baseret indeksering — altså er 1-tallet placeret ved indeks 8 (den niende komponent)
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")
Svar
def relu(z):
return np.maximum(0.0, z)
Forklar hvad der sker i følgende kode.
vec = np.array([-3, -1, 0, 1, 3])
print("ReLU on vector:", relu(vec))
Svar
ReLU defineres som en skalar-funktion af en variabel, men vi kan nemt udvide den til en vektor-funktion ved at lade den virke på en vektor koordinatvis, hvilket Python gør helt automatisk.
Er ReLU funktionen lineær? Er den kontinuert? Udregn den afledte. Er den differentiabel?
Hint
En lineær funktion opfylder \(f(x+y)=f(x)+f(y)\) og \(f(a x)=a f(x)\) for alle \(x,y\) og alle skalarer \(a\).
Svar
ReLU er ikke lineær: den opfylder fx ikke \(f(-1)=-f(1)\).
ReLU er kontinuert (ingen spring), men kun stykkevis-lineær. Den afledte er
\(f'(x)=0\) for \(x<0\)
\(f'(x)=1\) for \(x>0\) og er ikke differentiabel i \(x=0\) (funktionens eneste ikke-differentiable punkt). (Det er muligt at tale om en subgradient i \(x=0\), der er hvilket som helst tal i [0,1].)
4: Gradienten#
Vi ønsker at finde minimum af en funktion
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:
Gradient-vektoren består af de partielle afledte:
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\).
Svar
Udregn:
Altså:
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.
Svar
def f(x, y):
return 3*(x-1)**2 + y**2 + 4
def grad(x, y):
return np.array([6*(x-1), 2*y])
# Choose a point (x0,y0)
x0, y0 = 2.0, 1.0
g = grad(x0, y0)
# Grid for contour plot
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)
# Contour level through the chosen point
c_level = f(x0, y0)
plt.figure(figsize=(6,6))
# Plot several level curves
cs = plt.contour(Xg, Yg, Z, levels=15, cmap="gray")
plt.clabel(cs, inline=True, fontsize=8)
# Highlight the specific level curve through (x0,y0)
plt.contour(Xg, Yg, Z, levels=[c_level], colors="red", linewidths=2, linestyles="--")
# Plot the point and gradient vectors (gradient and negative gradient)
plt.plot(x0, y0, "ko", label=f"point ({x0},{y0})")
# scale arrows for visibility
scale = 0.25
plt.arrow(x0, y0, scale*g[0], scale*g[1], color="blue",
head_width=0.08, head_length=0.1, length_includes_head=True, label="grad f")
plt.arrow(x0, y0, -scale*g[0], -scale*g[1], color="green",
head_width=0.08, head_length=0.1, length_includes_head=True, label="-grad f")
plt.text(x0 + 0.05, y0 + 0.05, f"∇f={g}", fontsize=9)
plt.axis("equal")
plt.xlabel("x"); plt.ylabel("y")
plt.title("Gradient at point and level curve through the point")
plt.legend(["point", "gradient (blue)", "negative gradient (green)"], loc="upper left")
plt.show()
c: Minimum#
I hvilket punkt \((x,y)\) antager funktionen sin minimumsværdi?
Svar
Fra grafen af funktionen kan vi se, at funktionen har et minimum, og fra plottet af niveaukurverne kan man aflæse hvor funktionen antager sin minimumsværdi. Man kan også “regne” sig frem til det: I et sådant mimimumspunkt er der ingen retning hvor funktionen vokser, og derfor må gradient-vektoren være nul-vektoren. Vi kan altså finde minimum ved at løse ligningen \(\nabla f(x,y) = \mathbf{0}\).
Dette giver os de to ligninger:
Den eneste løsning til disse ligninger er punktet \((x,y)=(1,0)\). Dette er derfor funktionens minimumspunkt.
Punkter hvor gradient-vektoren er nulvektoren kaldes stationære punkter, og sådan punkter kan være (lokale) minimumspunkter, maksimumspunkter eller såkaldte sadelpunkter.
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?
Svar
Ja, begge udsagn er korrekte. Gradienten \(\nabla f(x,y)\) er en vektor i \((x,y)\)-planen, der peger i den retning, man skal bevæge sig for at opnå den hurtigste stigning i funktionsværdien. Modsat peger den negative gradient, \(-\nabla f(x,y)\), i retningen for det hurtigste fald.
Det er vigtigt at huske, at gradienten kun giver lokal information. Den fortæller os om den stejleste retning lige præcis i det punkt, vi står i (ud fra tangentplanen i punktet).
Eksempel: For funktionen \(f(x,y) = 3(x-1)^2 + y^2 + 4\) i punktet \((x,y)=(2,1)\) er gradienten \(\nabla f(2,1) = [6, 2]^T\). Den negative gradient er derfor \(-\nabla f(2,1) = [-6, -2]^T\). Hvis vi står i punktet \((2,1)\) og vil finde funktionens minimum, er den lokalt bedste retning at bevæge sig i retningen \([-6, -2]^T\) (eller en hvilken som helst positiv skalering af den, f.eks. \([-3, -1]^T\)). Vi ved dog ikke, hvor langt vi skal bevæge os i denne retning.
Peger den negative gradient altid præcist mod funktionens minimum?
Svar
Nej. Negative gradient peger kun i den lokale retning for hurtigst fald og giver ingen global information.
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:
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")
Svar
# Deterministic integer weights for a 2 -> 3 -> 1 network
W1 = np.array([[ 1., -1.],
[ 2., 0.],
[-1., 2.]])
b1 = np.array([[0.],[1.],[-1.]])
W2 = np.array([[1., -2., 1.]])
b2 = np.array([[0.]])
Hvor mange “knæk” kan du se i niveaukurverne? Hvorfor er overfladen stykkevist plan? Hvor mange lineære stykker er overfladen sammensat af?
Svar
Du bør kunne se (op til) tre “knæk” i hver niveaukurve. Hvert knæk stammer fra præcis én neuron i det skjulte lag.
Hvorfor opstår et “knæk”?
Hvorfor er overfladen stykkevist plan?
Hvad er antallet af regioner?
Svar på 1. Hver neuron, \(k\), i det skjulte lag beregner først en lineær funktion af inputtet: \(\mathbf{w_k} \cdot \mathbf{x} + b_k\). Resultatet af denne beregning sendes ind i ReLU-funktionen. Da \(\text{ReLU}\) kun er aktiv for positive værdier, “tænder” eller “slukker” neuronen ved den grænse, hvor dens input er nul. Denne grænse er en linje i input-planen, som er defineret ved ligningen:
Når input-punktet \(\mathbf{x}\) krydser denne linje, ændres outputtet fra den pågældende neuron brat fra \(0\) til en lineær funktion (eller omvendt). Dette skaber et “knæk” i den samlede funktion. Med \(n=3\) neuroner er der altså tre sådanne linjer, der skaber knæk i overfladen.
Svar på 2. De \(n\) linjer opdeler input-planen i forskellige regioner. Inden for enhver af disse regioner er aktiveringsmønstret for alle neuroner fast (hver neuron er enten konstant “tændt” eller “slukket”). Når aktiveringsmønstret er fast, opfører hele netværket sig som en simpel affin funktion (en lineær transformation plus en konstant). Grafen for en affin funktion er et plan. Derfor består netværkets samlede graf af forskellige plane “stykker”, der er sat sammen ved knæk-linjerne.
Svar på 3. Antal regioner: Med \(n=3\) linjer kan planet maksimalt opdeles i 7 forskellige lineære regioner. Dette er et klassisk resultat fra kombinatorisk geometri. Prøv at tegne 3 tilfældige linjer på et stykke papir og tæl antallet af regioner mellem linjerne.
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?
Svar
Når \(n\) vokser øges antallet af “knæk” i niveaukurverne og overfladens visuelle kompleksitet (flere lineære patcher).
På et 1D-snit gennem planet kan hver skjult neuron bidrage med højst ét skæringspunkt, så antallet af skæringer ≤ \(n\), og dermed antallet af lineære stykker langs snittet ≤ \(n+1\).
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:
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.
ReLUer aktiveringsfunktionen for det skjulte lag.softmaxomdanner 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:
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.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:
hvor 1-tallet står på indeks 3 (husk 0-baseret indeksering).
Hvordan ser one-hot vektoren ud for en label \(y=7\)?
Svar
For \(y=7\) er one-hot vektoren:
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:
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:
Meget tæt på 1 (en korrekt og sikker forudsigelse).
Meget tæt på 0 (en forkert og sikker forudsigelse).
Svar
Funktionen \(-\log(p)\) har en ideel opførsel for en tabsfunktion:
Når \(p_3 \to 1\) (korrekt forudsigelse): Går \(-\log(p_3) \to 0\). Netværket straffes altså minimalt for en korrekt og sikker forudsigelse.
Når \(p_3 \to 0\) (forkert forudsigelse): Går \(-\log(p_3) \to \infty\). Netværket straffes ekstremt hårdt, hvis det er meget sikkert på en forkert klasse.
Denne egenskab tvinger effektivt netværket til at øge sandsynligheden for den korrekte klasse.
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 \]
Svar
Man kan godt bruge kvadratisk fejl, men det er generelt en dårlig idé til klassifikationsproblemer med en softmax-output. Hovedårsagen er relateret til gradienterne:
Problem med Kvadratisk Fejl: Hvis netværket er meget forkert på den (f.eks. forudsiger \(p_3 \approx 0\) når den sande label er \(y_3=1\)), kan gradienten af den kvadratiske fejl blive meget lille. Det betyder, at netværket “lærer” ekstremt langsomt, selvom fejlen er stor. Dette fænomen kaldes vanishing gradients.
Fordel ved Cross-Entropy i kombination med Softmax: Ja, denne simple gradient er et resultat af den matematiske “synergi” mellem Cross-Entropy og Softmax. Når man bruger kædereglen til at finde gradienten af tabet med hensyn til outputtet \(z_i\) før softmax-funktionen, simplificerer udtrykket sig til:
hvor \(p_i\) er outputtet efter softmax. Dette er altså blot forskellen mellem den forudsagte og den sande sandsynlighed. Hvis fejlen er stor, er gradienten også stor, og netværket lærer hurtigt.
Når vi senere skal implementere tabet i Python, skal vi implementere følgende to trin:
Softmax: Vi omdanner outputtet fra netværkets sidste lag (såkaldte logits) til en sandsynlighedsfordeling.
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:
Forward Pass: Send alle træningsbilleder gennem netværket for at beregne forudsigelserne (
probs) og det samlede tab (loss).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.
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 tilfor epoch in range(10):ellerfor 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?
Hint
Næste kodecelle finder automatisk eksempler på fejlklassificerede billeder, så prøv at kør den.
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)#
Er man garanteret til at finde det ægte minimumspunkt for tabsfunktionen \(L\) ved gradient-metoden brugt oven for?
Hvorfor kan man ikke bare finde minimumspunktet for tabsfunktionen \(L\) direkte? Kan man plotte funktionen \(L\) og visuelt aflæse minimum?
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\)?
Kan du matematisk verificere udregningen af nogle af de centrale gradienter i
backward pass-delen af koden (f.eks.dZ2ogdW2) ifor-løkkenfor epoch in range(100):i Python-koden ovenfor?