Beslægtede ord - simple word embeddings

Forfattet af

Klart som blæk

Senest opdateret

24.11.2023

Her følger en beskrivelse af vores eksperimenter med at skabe “word embeddings” ved hjælp af “singular value decomposition”. Tilgangen er inspireret af kapitel 5 i Supervised Machine Learning for Text Analysis af Emil Hvitfeldt og Julia Silge.

Gennem vores indeksering med både netværksanalyse og topic modellering etablerede vi kontekster, hvori ord indgik. Disse teknikker roterer groft sagt om ords direkte sammenfald - således at et ord forbindes med de ord, det typisk optræder sammen med. Men ord kan også være beslægtede ved, at de bruges ens. Eksempelvis kan ordet “sild” være beslægtet med ordet “ål”, ikke fordi de nødvendigvis optræder sammen, men fordi de begge ofte optræder efter ordet “røgede”. Dette sekundære slægtskab er sværere at identificere. Nogle gange afsløres det, når ting optræder sammen i klynger i netværksvisualiseringer, men ikke er umiddelbart forbundne. Det kan imidlertid være en vigtig del af et sprog.

Denne gennemgang beskriver vores forsøg på at indfange begge former for slægtskab samtidig. Resultatet kan benyttes ved at navigere Søg i avisen -> Undersøg sprogbrug. Det skal i den forbindelse tilføjes, at datasættet i mange ords tilfælde er for småt til at teknikken på pålidelig vis kan indfange dette slægtskab gennem sprogbrug. Derfor skal resultaterne også tages for hvad de er: et computergenereret forslag til slægtskab - ikke den absolutte sandhed.

Vi kan også tænke dette som skabelsen af en simplere, men syntentisk version af datasættet. Vektoriseringen skaber en form for latent betydningsrum, der kan navigeres på andre måder, end gennem en kvalitativ læsning eller mere ligefremme teknikker til fjernlæsning. Det syntetiske rum er stærkt præget af de tendenser, der ligger i materialet - og kan derfor netop bruges til at finde tendenser og associationer mellem ting. På den måde kan vi tænke teknikken som et element i en kildekritisk læsning, der udnytter denne syntetisering til at finde mulige associationer i det historiske kildemateriale.

Samtidig bør det fremhæves at teknikken i sin natur forvirres gevaldigt af homonymer, da vores sprog er fyldt med ord, der benyttes i omtale af flere forskellige ting. Disse betydningsdimensioner udviskes her. Alligevel synes vi, at resultatet er brugbart til at generere spørgsmål, der kan guide en videre undersøgelse

Pakker, data og setup

library(tidyverse)
library(readxl)
library(tidytext)
library(widyr)
library(irlba)

Vi starter rituelt med at loade pakker og data. Datasættet er den indekserede version efter vores topic modelleringen (se de forrige skridt). Vi loader også en stopordsliste. Denne er tilpasset formålet. Da hensigten er at fange ord, som optræder i den samme type formuleringer, er forholdsord og personlige stedord ikke inkluderet i denne version af stopordslisten. Disse ord vil ofte formidle relationer, og kan derfor indikere et slægtskab. Det kunne være i tilfælde, hvor et bestemt stedord optræder sammen med en afgrænset serie af udsagnsord, der i denne henseende må forstås som beslægtede ved at være knyttet til en bestemt type subjekt. En stopordsliste er imidlertid oftest baseret på skøn, som det er også tilfældet her.

df <- read_csv2("data/df_topics.csv") %>% 
  filter(type != "overskrift") %>% 
  mutate(tekst = str_replace_all(tekst, c("uu" = "u",
                                          "ii" = "i",
                                          "ee" = "e")))
stopord <- read_excel("data/stopord_til_svd.xlsx")

Andet led i vores setup optæller, hvor mange gange, hvert ord optræder i det samlede data. På baggrund af denne optælling skaber vi to ordlister - en med de 15000 hyppigste ord (men ingen tal) og en med alle ord, som optræder mere end 50 gange. Disse skal vi bruge til at filtrere senere i processen.

Token_count <- df %>% 
  unnest_tokens(word, tekst) %>% 
  count(word)

Token_top <- Token_count %>% 
  filter(!str_detect(word, "(\\d)+")) %>% 
  slice_max(n, n = 25000)

Token_above_50 <- Token_count %>% 
  filter(n > 50)

Et vektoriseret datasæt

For at kunne bruge “singular value decomposition” til vores formål er vi først nødt til at definere, hvad en kontekst er. Vi har i denne sammenhæng valgt en definition: En kontekst er en 4 ord lang sekvens af ord. Vi bryder derfor alle tekster op i 4 ords bidder - tænk det som vinduer, der bevæger sig igennem hver tekst, et ord ad gangen, og kontinuerligt finder hver sekvens af 4 ord. Disse bidder brydes herefter yderligere ned i ord, der dog forbindes ved nu at dele et ID, der i processen er tildelt hver bid. Herefter filtrerer vi det atomiserede datasæt med både vores stopordsliste og den ordliste, som indeholdt de 25000 hyppigste ord. Dermed står vi tilbage med en datastruktur, hvor vi ved hvilke af de hyppigste ord, der optræder sammen.

Denne operation er ret omfattende. Resultatet er en tabel med over 10 millioner rækker. Derfor kunne vi ikke køre de følgende operationer på vores almindelige pc’er. I stedet benyttede vi en virtuel maskine på ucloud, sat op med 64 gb ram. Alligevel tog det relativt lang tid at lave udregningerne. Så vær tålmodig med din computer, hvis du gør samme forsøg. Det kan i mange tilfælde være en ide at forsøge sig ad med et setup, der udnytter flere af computerens cpu-kerner på en gang.1

Token_df <- df %>%
  unnest_tokens(tekst, tekst, "ngrams", n = 4) %>%
  rownames_to_column(var = "Token_ID") %>% 
  unnest_tokens(word, tekst) %>% 
  anti_join(stopord) %>% 
  inner_join(Token_top) %>% 
  mutate(værdi = 1)

Det er på baggrund af denne datastruktur, at vi udfører vores SVD. Denne teknik er en form for dimensionalitetsreduktion. Tænk det sådan, at vores tekst består af millioner af rækker, der hver spreder sig over 15000 kolonner, en for hvert af vores ord. I langt størstedelen af denne tabel står der nul, fordi et givet vindue kun kan indeholde op til 4 af de 25000 ord, som kolonnerne indfanger. Denne struktur er tynd (på engelsk taler man om “sparsity”) og besværlig for nærmest alle former for modellering eller statistisk bearbejdning. Der er simpelthen for mange dimensioner, som ikke fortæller os andet end fravær. I sådanne situationer kan det være nyttigt at reducere antallet af dimensioner. Tænk det som en fond: den starter med en masse vand og nogle ret tvivlsomme ingredienser (hønsefødder, gulerødder, halsen af en svane etc.). Det er mest bare vand. Efter mange timer står du tilbage med en reduktion. Den smager ikke præcis af selve ingredienserne, men indeholder det meste af deres substans. Meget er gået tabt i processen, men det som er tilbage, er det mest smagfulde. Sådan er det også med dimensionalitetsreduktion. Det tager en uoverskuelig mængde af dimensioner og reducerer dem ned til en overskuelig mængde, der gør andre former for computationel bearbejdning mulig. Der findes et væld af teknikker, alle meget velbeskrevede, der laver sådanne reduktioner. Den mest benyttede er “principal components analysis”, der også blev benyttet i vores egne eksperimenter med superviseret klassifikation. Her benytter vi imidlertid en anden, “singular value decomposition”. Denne teknik reducerer vores datasæt til en række nye syntetiske dimensioner, der udtrykker sammenfald i dataene. Hvert ord har en score i hver dimension. Hvis et ord optræder med nogenlunde tilsvarende værdier i disse dimensioner, betyder det, at ordene optræder i de samme typer af kontekster. Som udgangspunkt definerer vi, at vi vil stå tilbage med 200 dimensioner - dette valg blev truffet efter at have eksperimenteret med forskellige værdier og have kigget på resultatet.

Token_vectors <- Token_df %>% 
  widely_svd(word, Token_ID, værdi, nv = 200)

Fordi vores umådeligt mangedimensionelle datasæt nu er væsentligt mindre komplekst (fra 25000 til 200 dimensioner) kan vi benytte konventionelle statistiske værktøjer til at udregne slægtskabet. I denne sammenhæng bruger vi cosinus (“cosine similarity”). Dermed kan vi finde lighed imellem alle ord, målt på baggrund af de 200 syntetiske dimensioner. Fordi vi her benytter funktioner fra widyr pakken, omformes vores datasæt samtidig til en “tidy” format (hvilket i praksis vil sige, at det går fra at være bredt til at være langt).

Token_similarity <- Token_vectors %>% 
  pairwise_similarity(word, dimension, value)

Da vores datasæt pt. indeholder målinger på ligheden imellem alle kombinationer af de 25000 ord, er det ret stort. Over 600 millioner stort. Da langt de fleste af disse målinger viser en svag lighed - og vi samtidig ofte har at gøre med ord, der er så tilpas sjældne, er disse målinger ret upræcise. Vi filtrerer derfor resultatet. I den kolonne vi definerer som “nøgleord” - den hvis slægtskab til andre vi gerne vil kunne undersøge - beholder vi kun ord der optræder mindst 50 gange. Samtidig filtrerer vi, så ord skal have en score på mindst 0.5. Dermed ender vi med et datasæt, der indeholder scorer for omkring 700000 kombinationer af ord.

Top_similarities <- Token_similarity %>% 
  slice_max(similarity, n = 5000000) %>% 
  inner_join(Token_above_50, by = c("item1" = "word")) %>% 
  select(item1, item2, similarity) %>% 
  rename(nøgleord = item1, 
         relateret_ord = item2, 
         lighed = similarity) %>% 
  slice_max(lighed > 0.5)

Herefter gemmer vi datasættet. Det herefter inkorporeres som en del af vores app.

write_csv2(Top_similarities, "Top_similarities_nv200.csv")

Metoden er nyttig til at finde ord, hvis brug minder om hinanden. Prøv eksempelvis at søge på gadenavne, navne eller afgrøder. Metoden bruges til at undersøge mindre indlysende slægtskaber. Søger man eksempelvis på “(k|q)(u|v)ind” og dermed finder sammenhænge som forskellige stavemåder af ordet “kvinde” kan indgå i, ser vi konturerne af en kønnet diskurs om kvinders sociale roller og slægtskab til bestemte typer af subjekter.

Knæk og bræk i jagten på ords slægtninge!

Fodnoter

  1. Hvitfeld og Silge foretager i denne forberedelse endnu en manøvre, nemlig at udregne pmi (pointwise mutual information) for hvert ordpar. Det gør den efterfølgende vektorisering mindre krævende rent computationelt. Imidlertid gav dette i vores øjne et ringere resultat.↩︎