- Je berekent een verborgen toestand met dezelfde parameters op iedere tijdstap.
- Je verzamelt gradiënten van directe outputs en toekomstige toestanden.
“Hond bijt man” en “Man bijt hond” bevatten dezelfde woorden, maar geven andere rollen aan hond en man. Een model dat losse woordvectoren alleen optelt, verliest hun volgorde. Een recurrent neuraal netwerk (recurrent neural network, RNN) neemt bij iedere nieuwe invoer een verborgen toestand uit het verleden mee.
Denk aan een notitie die je na ieder gelezen woord bijwerkt. De notitie verandert; de schrijfregel blijft dezelfde. Deze les bouwt één tanh-RNN zonder batch of extra uitvoerlaag, zodat je de toestanden en hun terugweg precies kunt volgen.
1. Combineer nieuwe informatie met de vorige toestand
Op tijdstap t heeft x_t D invoerwaarden en h_t H geheugenwaarden. De gedeelde matrices Wx en Wh hebben vormen (D,H) en (H,H); bias b heeft H waarden. Onze rijvectorconventie gebruikt één gecombineerde bias; de PyTorch-bron heeft een andere opslagconventie met twee biases.
Neem één inputwaarde 1, vorige toestand 0.5, Wx=1, Wh=−1 en b=0. De preactivatie is 1−0.5=0.5 en de nieuwe toestand tanh(0.5)≈0.462117. Het recurrente gewicht bepaalt hier hoe het verleden de huidige input bijstuurt.
import numpy as np
x = np.array([1.])
h = np.array([.5])
wx = np.array([[1.]])
wh = np.array([[-1.]])
b = np.array([0.])
next_h = np.tanh(x@wx+h@wh+b)
print(np.round(next_h,6).tolist())
# [.462117]
Maak rnn_step(x,h,params) met sleutels wx,wh,b. Geef een nieuwe verborgen vector en behoud de inputs. Reken vóór matrixproducten in floating point om integeroverloop te voorkomen; dezelfde numerieke regel geldt voor de hele reeks.
012. Rol dezelfde regel door de tijd uit
Gebruik nu invoer [[0.5],[0]] en h0=[0.5], met dezelfde gewichten. De eerste preactivatie is 0.5−0.5=0 en daarna 0−0=0, dus de opgeslagen toestanden zijn [0.5,0,0]. De twee nullen komen door deze gekozen waarden, niet doordat het netwerk altijd vergeet.
inputs = np.array([[.5],[0.]])
h = np.array([.5])
states = [h.copy()]
for item in inputs:
h = np.tanh(item@wx+h@wh+b)
states.append(h.copy())
states = np.stack(states)
print(states.ravel().tolist())
# [.5,0.,0.]
Maak rnn_forward(x,params,h0) voor x van vorm (T,D). Bewaar states van vorm (T+1,H), met de oorspronkelijke h0 op positie 0, plus een kopie van x. Een lege reeks bevat alleen h0; kopieer de invoer zodat latere wijzigingen je opgeslagen berekening niet veranderen.
Verander in de visualisatie de leesrichting en daarna het recurrente gewicht. Dezelfde waarden in een andere volgorde geven doorgaans een andere eindtoestand. Begin voor losse, niet-gerelateerde reeksen met een passende eigen begintoestand, anders kan informatie uit het vorige voorbeeld doorlekken.
hₜ=tanh(0.8xₜ+whₜ₋₁), met h₀=0. Verander de volgorde of het gedeelde recurrente gewicht w. Alle toestanden worden opnieuw berekend.
| Stap t | Invoer xₜ | Vorige h | Nieuwe h |
|---|---|---|---|
| 1 | 1.0 | 0.000 | 0.664 |
| 2 | 0.0 | 0.664 | 0.434 |
| 3 | -1.0 | 0.434 | -0.459 |
| 4 | 1.0 | -0.459 | 0.445 |
| 5 | 0.0 | 0.445 | 0.302 |
∂h₅/∂x₁ = 0.050129
Dit is de gevoeligheid voor de eerste gelezen input, via alle vijf tanh-stappen. Bij w=0 heeft een eerdere input geen invloed op een latere toestand.
3. Stuur ook latere fouten terug
Bij terugrekenen door de tijd (backpropagation through time, BPTT) beïnvloedt een toestand zowel zijn directe verlies als latere toestanden. Dy[t] hoort bij states[t+1]; carry is het foutsignaal dat uit de toekomst terugkomt. Tel ze vóór de tanh-afgeleide op en loop achteruit.
Noem die som q, de preactivatiegradiënt d en de volgende toestand h. Het teken ⊙ betekent elementgewijs vermenigvuldigen. T betekent transponeren; de recurrente matrix voert de bijdrage naar de vorige toestand terug.
Kies voor onze twee stappen directe signalen dy=[1,2]. Achteraan wordt d=2 en carry=−2; bij de eerste stap wordt d=1−2=−1. Zonder toekomstbijdrage had je daar ten onrechte 1 gekregen.
dy = np.array([1.,2.])
carry = 0.; gwx = 0.; gwh = 0.
gb = 0.; dx = np.zeros(2)
for t in [1,0]:
q = dy[t]+carry
d = q*(1-states[t+1,0]**2)
gwx += inputs[t,0]*d
gwh += states[t,0]*d
gb += d
dx[t] = d
carry = -d
print(gwx,gwh,gb) # -.5 -.5 1.
print(dx.tolist(),carry)
# [-1.,2.] 1.0
In vectorvorm tel je outer(x[t],d) bij dWx, outer(states[t],d) bij dWh en d bij db. Dezelfde parameters waren overal gebruikt, dus krijgen ze bijdragen uit alle stappen. Voor dx[t] gebruik je d@Wx.T; de laatste carry is dh0.
Maak rnn_backward(cache,params,dy) met wx,wh,b,x,h0, zonder cache of parameters te veranderen. Middel niet extra: dy bevat een eventuele verliesmiddeling al. Bij T=0 zijn alle gradiënten nul.
Controleer bijvoorbeeld dWx via de scalaire verliesfunctie L=h1×1+h2×2. Een kleine verandering van het gedeelde Wx verandert beide stappen; de centrale differentie moet ongeveer −0.5 opleveren.
def objective(weight):
h = .5; loss = 0.
for x,g in zip([.5,0.],[1.,2.]):
h = np.tanh(x*weight-h)
loss += h*g
return loss
eps = 1e-5
delta = objective(1+eps)
delta -= objective(1-eps)
print(round(delta/(2*eps),6))
# -.5
03Wat neem je mee?
- Toestanden veranderen; gewichten worden door de tijd gedeeld.
- BPTT telt directe en toekomstige foutsignalen vóór de lokale afgeleide op.
- Veel vermenigvuldigingen kunnen signalen uitdoven of versterken; afgekapt terugrekenen is een andere benadering.
Hierna voeg je poorten toe die bepalen wat een geheugen bewaart en toont.