- 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.
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.
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.
m=xy, s=m+x, t=tanh(s). Kies een uitkomst; oranje verbindingen tonen haar lokale afgeleiden.
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².
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.
023. 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.
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.