Tekstgenerering med neurale netværk
Vi har set, hvordan man kan få repræsenteret alle ord i ordforrådet som vektorer med Word2Vec. Næste skridt er at bruge disse vektorer til at generere tekst. I denne note ser vi på, hvordan det for eksempel kan gøres ved hjælp af neurale netværk. I praksis foretages tekstgenerering som regel ved hjælp af en særlig smart algoritme kaldet transformeren, der i en vis forstand laver Word2Vec og neurale netværk på én gang. Når du har læst denne note, kan du læse videre om tranformeren her.
Vi vil gerne kunne generere en tekst ét ord ad gangen. Lad os som eksempel sige, at vi har fået lavet sætningen
"En hund og en kat —"
og skal generere næste ord. Lige som med \(N\)-grams gør vi det lidt simplere ved kun at kigge på de sidste \(N-1\) ord i sætningen. Vi vil sætte \(N=4\) i denne note, så vi gætter næste ord på baggrund af de 3 foregående. I praksis ville man bruge et større \(N\). I eksemplet skal vi så gætte næste ord efter "og en kat". Til det bruger vi et neuralt netværk, som er en slags funktion, der tager de tre seneste ord som input. Som output giver netværket for hvert ord i ordforrådet en sandsynlighed for, at det er det næste ord.
I eksemplet er vores input til det neurale netværk altså "og en kat". For hvert af de tre ord, har vi lavet en vektorrepræsentation med Word2Vec. Hvis vektorerne fra Word2Vec har \(m\) koordinater, samler vi de tre vektorer i én vektor \(\vec{x}\) med \(3m\) koordinater, hvor de første \(m\) koordinater er vektoren for "og", de næste \(m\) koordinater er vektoren for "en", og de sidste \(m\) koordinater er vektoren for "kat". Hvis for eksempel vores vektorer for "og", "en" og "kat" er \[ \vec{v}_{\text{og}}=\begin{pmatrix} 2\\1 \end{pmatrix}, \quad \vec{v}_{\text{en}}=\begin{pmatrix} -1\\3 \end{pmatrix}, \quad \vec{v}_{\text{kat}}=\begin{pmatrix} 7\\-4 \end{pmatrix} \] så er vores input til det neurale netværk vektoren \[ \vec{x} =\begin{pmatrix} 2\\1 \\-1\\ 3\\ 7\\ -4 \end{pmatrix} \] Denne vektor indeholder både informationen fra Word2Vec og information om rækkefølgen af de tre ord, som svarer til rækkefølgen, de tre vektorer er sat ind i \(\vec{x}\).
Output fra det neurale netværk skal være en vektor \[ \vec{z}=\begin{pmatrix} z_1\\ \vdots \\ z_V\end{pmatrix} \] med \(V\) koordinater, hvor \(V\) er antallet af ord i vores ordforråd. Vi forestiller os, at vi har nummereret alle ord i ordforrådet. Den \(i\)’te koordinat i \(\vec{z}\) hører sammen med det \(i\)’te ord, som vi vil kalde "ord\(_i\)". Den \(i\)’te koordinat i \(\vec{z}\) skal give sandsynligheden for, at "ord\(_i\)" er det næste ord efter "og en kat". Med andre ord, \[ z_i=P(\text{ord}_i \text{ } |\text{ "og en kat" }) \]
En god outputvektor \(\vec{z}\) skal opfylde følgende:
Alle koordinater skal være sandsynligheder, så \[0\leq z_i\leq 1 \tag{1}\] for alle \(i\).
Summen af alle sandsynlighederne skal give 1, altså \[z_1+\dotsm + z_V=1 \tag{2}\]
da det er den samlede sandsynlighed for at få et af de mulige ord.Hvis "ord\(_i\)" er et godt bud på et ord, der følger efter "og en kat", skal \(z_i\) være tæt på \(1\) (der er i praksis mange ord, som er gode bud og summen af sandsynlighederne skal give \(1\), så \(z_i\) bliver ikke \(1\)).
Hvis ord\(_i\) er et dårligt bud på næste ord, skal \(z_i\) være tæt på \(0\).
Vores neurale netværk skal altså være en funktion, der tager vektoren \(\vec{x}\) som input og giver vektoren \(\vec{z}\) som output. Funktionen dannes ved at sammensætte en masse simplere funktioner. For at holde overblik kan man skitsere det neurale netværk som i figur 1.

Vores input er vektoren \(\vec{x}\), der har \(3m\) koordinater. Disse koordinater svarer til de grønne cirkler i venstre side af figuren kaldet inputlaget. De lyserøde cirkler i midten angiver det skjulte lag1. Her omregnes \(\vec{x}\) til en ny vektor \(\vec{h}\) med \(d\) koordinater, hvor \(d\) er et tal, vi har valgt. Hver koordinat i \(\vec{h}\) svarer til en af de lyserøde cirkler i midten, og pilene i venstre side angiver, at hver koordinat i \(\vec{h}\) er en funktion af koordinaterne i \(\vec{x}\). Mere præcist beregnes koordinaterne i \(\vec{h}\) ved: \[ \begin{aligned} h_1&=f(w_{1,0}+w_{1,1}x_1+w_{1,2}x_2+\ldots +w_{1,3m}x_{3m})\\ &\vdots\\ h_j&=f(w_{j,0}+w_{j,1}x_1+w_{j,2}x_2+\ldots +w_{j,3m}x_{3m})\\ &\vdots\\ h_{d}&=f(w_{d,0}+w_{d,1}x_1+w_{d,2}x_2+\ldots +w_{d,3m}x_{3m})\\\end{aligned} \]
1 Man kan gøre sit neurale netværk mere fleksibelt ved at have flere skjulte lag. For at holde forklaringen simpel vil vi dog kun gennemgå tilfældet med ét skjult lag her.
Funktionen \(f\), der indgår, er en aktiveringsfunktion. Man kan for eksempel bruge sigmoid-funktionen \[ f(x)=\frac{1}{1+\mathrm{e}^{-x}} \] Du kan læse mere om aktiveringsfunktioner her. Desuden indgår der \(d \cdot (3m+1)\) vægte på formen \(w_{j,k}\). Vægtene er reelle konstanter. Når vi om lidt træner det neurale netværk, forsøger vi at bestemme værdien af disse vægte.
De mørkeblå cirkler til højre i figur 1 udgør outputlaget. I outputlaget laves vektoren \(\vec{h}\) om til outputvektoren \(\vec{z}\) med \(V\) koordinater svarende til cirklerne til højre. Igen viser pilene, at hver koordinat i \(\vec{z}\) er en funktion af koordinaterne i \(\vec{h}\). Funktionen udregnes i to trin:
Først udregner vi en vektor \(\vec{y}\) med \(V\) koordinater, hvor \(i\)’te koordinat er \[ y_i=u_{i,0}+u_{i,1}h_1+u_{i,2}h_2+\ldots + u_{i,d}h_{d} \] Igen indgår der nogle vægte \(u_{i,j}\). Dem er der i alt \((d+1)\cdot V\) af.
Derefter laver vi \(\vec{y}\) om til sandsynligheder. Det gør vi ved at bruge softmax-funktionen, som blev introduceret i noten om Word2Vec. Softmax-funktionen tager en \(V\)-dimensional vektor \(\vec{y}\) som input og giver en ny \(V\)-dimensional vektor \(\vec{z}=\text{Softmax}(\vec{y})\) som output. Den \(i\)’te koordinat i \(\vec{z}\) udregnes som \[ z_i=\frac{\mathrm{e}^{y_i}}{\mathrm{e}^{y_1} + \dotsm + \mathrm{e}^{y_V}} \] Vi så i Word2Vec-noten, at \(\vec{z}\) opfylder (1) og (2), således at det giver mening at tænke på \(\vec{z}\) som en vektor af sandsynligheder. Dette \(\vec{z}\) er vores outputvektor.
Samlet set er der ret mange vægte i modellen. Der er \(d\cdot (3m+1)\) vægte i det skjulte lag og \((d+1)\cdot V\) vægte i ouputlaget. I alt bliver det \(d\cdot(1+3m +V) +V\) vægte.
Hvis vi tager et meget simpelt eksempel, hvor alle ord repræsenteres ved en 3-dimensional vektor (\(m=3\)), vi har et ordforråd på \(V=2000\) ord og vi vælger, at der skal være \(d=50\) neuroner i det skjulte lag, så får vi i alt
\[ d\cdot(1+3m +V) +V = 50 \cdot (1 + 3 \cdot 3+2000) + 2000 =102500 \]
vægte! Det samlede antal vægte bliver altså hurtigt meget, meget stort.
Læg mærke til, at valget af \(d\) er det, der bestemmer antallet af vægte: \(m\) var fastlagt da vi lavede Word2Vec, \(V\) er antallet af ord i vores ordforråd, og 3-tallet er antal ord, vi prædikterer udfra. Valget af \(d\) er i praksis et kompromis. Jo større \(d\) er, des mere præcis en model kan vi lave. Omvendt bliver der også flere vægte, der skal bestemmes. Det kræver stor regnekraft. Desuden kræver det meget træningsdata, hvis man vil undgå overfitting - et problem, som du kan læse mere om i noten om krydsvalidering.
Træning af netværket
Som sagt indgår der ret mange vægte i modellen. Indtil nu har vi ikke sagt, hvilken værdi disse vægte skal have. Husk på, at vores neurale netværk skal give os sandsynligheden for, at et ord er næste ord i en sætning, når vi kender de 3 foregående ord. For at lære, hvilket ord der typisk kommer efter tre givne ord i virkelige tekster, får vi endnu engang brug for noget træningsdata i form af vores store tekstkorpus. Ud fra dette tekstkorpus laver vi et datasæt bestående af alle 4-gram, det vil sige alle sekvenser på 4 ord, der forekommer i teksten. De tre første ord kalder vi input, og det sidste kalder vi target.
Hvis for eksempel vores træningsdata består af sætningen
"Solen skinner, og en kat løber på græsplænen."
så laver vi en datatabel2 som i tabel 1.
2 Vi ignorerer tegnsætning.
Input 1 | Input 2 | Input 3 | Target |
---|---|---|---|
\(\vdots\) | \(\vdots\) | \(\vdots\) | \(\vdots\) |
Solen | skinner | og | |
skinner | og | en | kat |
og | en | kat | løber |
en | kat | løber | på |
\(\vdots\) | \(\vdots\) | \(\vdots\) | \(\vdots\) |
Hvis vores neurale netværk er valgt godt, skal det gerne give en høj sandsynlighed for targetordet, når vi giver de tre inputord som input. Vi forsøger derfor at vælge vægtene i det neurale netværk, så netværket giver en høj sandsynlighed for targetordet. Når vi bestemmer vægtene, så de passer til træningsdata, siger vi, at vi træner det neurale netværk.
Lad os se på en enkelt række i datasættet, for eksempel den der svarer til sekvensen "og en kat løber". Vores inputord er "og", "en" og "kat". Dem oversætter vi til vektoren \(\vec{x}\) ved at bruge ordenes Word2Vec-vektorer som beskrevet ovenfor. Output fra det neurale netværk er en vektor \(\vec{z}\), hvis \(i\)’te koordinat giver sandsynligheden for, at det \(i\)’te ord i ordforrådet er det næste ord. Hvis vi udelukkende ser på sekvensen "og en kat løber", og ignorerer alle de andre sekvenser i træningsdata, så skal "løber" have sandsynligheden 1, og alle andre ord skal have sandsynligheden 0. Vektoren med \(1\) i den koordinat, der svarer til det korrekte ord, og \(0\) i alle andre koordinater kaldes targetvektoren \(\vec{t}\).
I praksis rammer vores sandsynlighedsvektor \(\vec{z}\) aldrig target \(\vec{t}\) præcist, fordi der også skal tages højde for de andre sekvenser i datasættet. Det kunne for eksempel være, at sekvensen "og en kat spiser" også forekommer et sted i træningsdata. I så fald skal "spiser" også have høj sandsynlighed.
I det mindste vil vi gerne have, at \(\vec{z}\) kommer tæt på targetvektoren \(\vec{t}\). Vi måler, hvor langt vi er fra target med en tabsfunktion. Den tabsfunktion, vi vil bruge her, kaldes cross-entropy. Med output \(\vec{z}\) og targetvektor \(\vec{t}\), er cross-entropy givet ved
\[ CE(\vec{z},\vec{t})=-t_1\ln(z_1)-t_2\ln(z_2)- \cdots -t_V\ln(z_V) \]
Targetvektoren er \(0\) på alle koordinater undtagen den, der svarer til det korrekte ord, så alle andre led i summen er 0. Lad os sige, det korrekte ord har nummeret \(c\) i vores ordforråd, så \(t_c=1\) og \(t_j=0\) for alle \(j\neq c\). Så er \(CE(\vec{z},\vec{t})=-\ln(z_c)\). Da \(z_c\) er sandsynligheden for, at vores targetord er det næste, vil vi gerne have, at \(z_c\) er så stor som muligt. Da den naturlige logaritme er en voksende funktion, svarer det til, at \(CE(\vec{z},\vec{t})=-\ln(z_c)\) skal være så lille som muligt. Dette er illustreret i figur 2, hvor vi tegnet grafen for den naturlige logaritme-funktion samt grafen for minus den naturlige logartime-funktion (husk på at \(0<z_c<1\)):

Dette gentager vi nu for hver eneste række i vores træningsdata. Vi beregner en cross-entropy for hver. Til sidst lægger vi alle disse cross-entropy sammen til en samlet tabsfunktion \(L\), som helst skal være så lille som muligt. Vi har udregnet \(L\) udfra vores træningsdata og vægtene. Træningsdata er det, vi går ud fra, vi ved, så det kan vi ikke lave om på for at minimere \(L\). Derfor betragter vi nu \(L\) som en funktion af vægtene \(w_{j,k}\) og \(u_{i,j}\). Vi ønsker at bestemme vægtene således, at \(L\) bliver mindst mulig svarende til, at vores sandsynligheder kommer så tæt på target som muligt. Vi skal altså finde minimum for en funktion af mange variable. Det kan man for eksempel gøre ved hjælp af gradientnedstigning. For at lave gradientnedstigning er det vigtigt at kunne finde de partielt afledte af \(L\). En smart måde at lave gradientnedstigning på kaldes backpropagation. Et simpelt eksempel på backpropagation findes i noten om simple neurale net.
Tekstgenerering
Las os sige, at vi har fået trænet vores neurale netværk. Det vil sige, at vi har bestemt de vægte, der skal indgå. Så er vores neurale netværk en fastlagt funktion. Når vi giver netværket en inputvektor \(\vec{x}\), beregner det en outputvektor \(\vec{z}\) af sandsynligheder ved brug af de valgte vægte.
Vi kan nu gå i gang med at generere tekst. Lad os sige, at vi har dannet de første ord i en sætning. Det kunne være
"En hund og en kat —"
Vi tager de \(3\) sidste ord "og", "en" og "kat" og oversætter dem til en vektor \(\vec{x}\). Denne vektor giver vi som input til det neurale netværk. For hvert ord i ordforrådet beregner det neurale netværk sandsynligheden for, at det er det næste ord. Det kan være at "løber" får sandsynligheden 1/2, "spiser" får sandsynligheden 1/3, mens alle andre ord får meget små sandsynligheder. En mulighed er så at vælge det mest sandsynlige ord som det næste. Det ville være "løber" i vores eksempel. Det viser sig dog, at det giver for lidt variation i de sætninger, der dannes. I stedet kan man vælge et tilfældigt næste ord ud fra deres sandsynligheder. I vores eksempel ville vi vælge "løber" med sandsynlighed 1/2, "spiser" med sandsynlighed 1/3, og så videre.