Werkplaats / De werkplaats
In jouw eigen tempo
← Neurale netwerken
MODULE 03 · LES 08

Laat iedere berekening haar herkomst onthouden

Bouw een kleine rekengraaf met lokale afgeleiden en een topologische volgorde die gedeelde knopen herkent.

75 min3 checkpoints
DIT GA JE ONTDEKKEN
  • Je slaat getallen en lokale afgeleiden op als knopen en verbindingen.
  • Je onderscheidt een gedeelde knoop van meerdere verbindingen naar diezelfde knoop.

Je hebt voor ieder gewicht handmatig een terugweg afgeleid. Bij een nieuw netwerk zou je dat rekenwerk steeds opnieuw moeten organiseren. We laten daarom iedere optelling en vermenigvuldiging haar eigen kleine stukje van de kettingregel bewaren.

Denk aan een getal met een kassabon: op de bon staat waar het vandaan kwam. Zo'n getal heet een knoop; de verwijzingen naar eerdere getallen vormen een rekengraaf. Onze engine werkt met losse getallen, scalars, en gebruikt geen differentiatiebibliotheek.

1. Een getal bewaart zijn ouders

Een knoop is een Python-dictionary met drie velden. value bewaart de huidige waarde als float, grad begint op nul en ontvangt later de totale afgeleide, parents bewaart de ingangen met hun lokale factoren. Een blad, bijvoorbeeld een modelgewicht, heeft geen ouders.

def knoop(value, parents=()):
    return dict(value=float(value), grad=0.,
                parents=tuple(parents))

x = knoop(2)
print(x['value'], x['grad'], x['parents'])
# 2.0 0.0 ()

Een tuple is een geordende rij waarvan je de posities niet achteraf verandert. Iedere ouderverbinding wordt een paar (ouderknoop,lokale_afgeleide); parents=() betekent dat er standaard geen verbindingen zijn. Bewaar het ouderobject zelf, zodat de terugweg later bij het oorspronkelijke gewicht uitkomt.

Voor een optelling c = a+b is de lokale afgeleide naar beide ingangen één. Bij a = 2 en b = 3 geeft de nieuwe knoop waarde 5 en twee verbindingen met factor 1. De graaf bewaart dus meer dan alleen die uitkomst.

c=a+b∂c∂a=1,∂c∂b=1
def optel(a, b):
    waarde = a['value'] + b['value']
    return knoop(waarde, ((a,1.), (b,1.)))

a, b = knoop(2), knoop(3)
c = optel(a, b)
print(c['value'], len(c['parents']))
# 5.0 2
dubbel = optel(a, a)
print(dubbel['parents'][0][0] is a)  # True
print(dubbel['parents'][1][0] is a)  # True

is vraagt of twee verwijzingen hetzelfde object aanwijzen. Bij a+a zijn er twee ingangen naar hetzelfde blad: beide leveren straks een bijdrage. Maak knoop en optel volgens dit contract, met een nieuwe dictionary voor elke nieuwe uitkomst.

01
CHECKPOINTBewaar de twee ingangen van een optelling

2. Bewaar kleine, lokale afgeleiden

In de visualisatie gaat x eerst samen met y door een product en wordt x later opnieuw opgeteld. Kies een knoop: de oranje verbindingen tonen alleen haar lokale factoren. Het totale effect op de einduitkomst berekenen we pas in de volgende les.

ELKE OPERATIE ONTHOUDT ZIJN OUDERSEen getal met een rekenverleden

m=xy, s=m+x, t=tanh(s). Kies een uitkomst; oranje verbindingen tonen haar lokale afgeleiden.

y=2x=1110.010x1.00y2.00m2.00s3.00t1.00
Gekozen knoopmLokale afgeleiden2 · 1

Getallen op de verbindingen zijn lokale factoren, nog geen totale gradiënten. x komt langs twee verschillende routes bij de eindknoop.

Bij een product c = ab bepaalt de andere ingang de gevoeligheid. Als a = 2 en b = −3, is de waarde −6, de factor naar a gelijk aan −3 en de factor naar b gelijk aan 2. Bij tanh gebruik je de al berekende activatie c voor de factor 1−c².

c=ab:∂c∂a=b,∂c∂b=ac=tanh(a):∂c∂a=1−c2
import math
def keer(a, b):
    waarde = a['value'] * b['value']
    ouders = ((a,b['value']),
              (b,a['value']))
    return knoop(waarde, ouders)

def tanh(a):
    c = math.tanh(a['value'])
    return knoop(c, ((a,1-c*c),))

m = keer(knoop(2), knoop(-3))
print(m['value'])  # -6.0
print([local for _,local in m['parents']])
# [-3.0, 2.0]
t = tanh(knoop(1))
print(round(t['value'], 6))  # 0.761594
print(round(t['parents'][0][1], 6))
# 0.419974

De lus haalt uit ieder paar de lokale factor; _ betekent dat we de ouder hier niet gebruiken. Maak keer en tanh, ook voor negatieve en grote invoer. math.tanh berekent grote waarden stabiel, zonder zelf gevaarlijk grote exponenten te vormen.

Bewaar factoren van deze voorwaartse pass. Verander je bladwaarden voordat je terugrekent, dan passen opgeslagen factoren niet meer bij de nieuwe waarden. Bouw na een trainingsupdate daarom een verse graaf.

02
CHECKPOINTSla lokale afgeleiden van deze voorwaartse pass op

3. Bezoek knopen één keer, behoud iedere verbinding

Voor de heenweg komen ouders vóór hun kinderen. Zo'n planning heet een topologische volgorde. Start bij de uitkomst, bezoek eerst haar ouders en zet daarna de uitkomst op de lijst; dit herhaalde bezoeken heet recursie.

Gebruik een set voor al bezochte objectidentiteiten, gemeten met id(node). Twee aparte bladeren met dezelfde waarde blijven twee knopen. Een gedeeld blad bezoek je één keer, terwijl zijn meerdere verbindingen wel blijven bestaan.

Volg nu de hele graaf uit de visualisatie bij x = 2 en y = −0.5. Eerst wordt m = −1, daarna s = m+x = 1 en tenslotte t = tanh(1), ongeveer 0.761594. De volgorde bevat vijf knopen; x komt via twee routes terug, maar staat eenmaal op de lijst.

def volgorde(root):
    ordered, seen = [], set()
    def visit(node):
        if id(node) in seen:
            return
        seen.add(id(node))
        for parent, _ in node['parents']:
            visit(parent)
        ordered.append(node)
    visit(root)
    return ordered

x, y = knoop(2), knoop(-.5)
m = keer(x, y)
s = optel(m, x)
t = tanh(s)
print([round(n['value'], 6)
       for n in volgorde(t)])
# [2.0, -0.5, -1.0, 1.0, 0.761594]

De operator == vergelijkt inhoud, terwijl is hetzelfde object herkent. Twee apart gemaakte bladeren kunnen dezelfde inhoud hebben, maar vertegenwoordigen dan nog steeds twee aparte gewichten. Daarom gebruikt de bezochte set identiteiten, geen dictionarygelijkheid.

x = knoop(3)
s = keer(x, x)
f = optel(s, x)
print([n['value'] for n in volgorde(f)])
# [3.0, 9.0, 12.0]
print(len(s['parents']))  # 2

Er zijn drie knopen, maar alleen het product heeft al twee verbindingen naar x. Verwijder je die dubbele verbinding als vermeende duplicaat, dan verdwijnt later een afgeleidebijdrage. Maak volgorde(root) als lijst met elke bereikbare knoop één keer, ouders vóór kinderen.

03
CHECKPOINTPlan iedere knoop na zijn ouders

Wat neem je mee?

  • Een knoop bewaart waarde, totale gradiënt en lokale ouderverbindingen.
  • Dezelfde ouder mag meerdere ingangen van één operatie vullen.
  • Objectidentiteit bepaalt welke knoop gedeeld is; een topologische planning behoudt alle verbindingen.

Hierna loop je die planning achteruit en tel je de teruggaande bijdragen bij elkaar op.

DE VOLGENDE STAPTel de bijdragen van alle rekenpaden op
JOUW EXPERIMENT

Probeer het zelf

Python
main.py

Python start zodra jij code uitvoert.

UITVOER

Je uitvoer verschijnt hier.
Alles rekent in je eigen browser.

Jouw checkpoints0 / 3
01
Bewaar de twee ingangen van een optelling

Schrijf knoop(value,parents=()) en optel(a,b) met sleutels value,grad,parents. Nieuwe gradiënten starten op 0.0.

Een hint

Gebruik tuple(parents). Optel bewaart ((a,1.0),(b,1.0)), ook als a en b hetzelfde object zijn.

02
Sla lokale afgeleiden van deze voorwaartse pass op

Schrijf keer(a,b) en tanh(a), met nieuwe knopen en de juiste ouderverbindingen.

Een hint

Bij keer(a,a) bewaar je twee verbindingen met beide lokale afgeleide a.value. math.tanh voorkomt overflow.

03
Plan iedere knoop na zijn ouders

Schrijf volgorde(root) als lijst met iedere bereikbare knoop precies één keer, ouders vóór kinderen.

Een hint

Gebruik id(node) in seen; dictionaries zijn zelf niet hashbaar. Voeg node pas na zijn ouders toe.

Vastgelopen? Bekijk de voorbeeldoplossing
import math

def knoop(value,parents=()):
    return {"value":float(value),"grad":0.0,"parents":tuple(parents)}

def optel(a,b):
    return knoop(a["value"]+b["value"],((a,1.0),(b,1.0)))

def keer(a,b):
    return knoop(a["value"]*b["value"],((a,b["value"]),(b,a["value"])))

def tanh(a):
    value=math.tanh(a["value"])
    return knoop(value,((a,1-value*value),))

def volgorde(root):
    ordered=[]
    seen=set()
    def visit(node):
        if id(node) in seen:
            return
        seen.add(id(node))
        for parent,_ in node["parents"]:
            visit(parent)
        ordered.append(node)
    visit(root)
    return ordered

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