Indeksering via usuperviseret tekstklassifikation - topic modelling

Forfattet af

Klart som blæk

Senest opdateret

8.12.2023

I forrige trin viste vi, hvordan en håndholdt, men computerassisteret, indeksering, så ud. Dette var imidlertid ikke vores eneste forsøg på at bruge computeren som assistent til indeksering. Denne artikel beskriver vores eksperimenter med såkaldt topic modelling. Denne teknik rangerer blandt usuperviserede machine learning-teknikker. Den går, groft sagt, ud på at betragte en tekst som en samling af ord, der tilhører forskellige emner (topics). Emnerne er ikke defineret på forhånd - de identificeres af computeren ud fra sammensætningerne af ord. Metoden er veldokumenteret og brugt ofte i regi af digital humaniora. Dog er det sjældent, at vi som historikere arbejder med så tilpas store mængder tekst til, at metoden giver mening. Især ikke i lyset af, at teknikken fungerer bedst, hvis teksterne deler et ophav og en form (f.eks. som opslag på en SoMe-platform). Historikeres materiale er ofte mere heterogent og udstrakt i tid end som så. På den måde hviler teknikken på nogle antagelser om tekster, der delvist er løsrevet fra omskiftelige historiske kontekster. Som det ofte er tilfældet med teknikker hentet fra digital humaniora og social datavidenskab peger grebet på denne måde på nometetiske erkendelsesinteresser.

Teknikken er dog brugt og beskrevet af historikere, blandt andet Shawn Graham, Ian Milligan og Scott Weingarts Exploring Big Historical Data.1 Graham, Milligan og Weingart beskriver også en af de helt afgørende udfordringer ved at lave topic modelling: Nok finder computeren emnerne, men computeren skal forinden fortælles, hvor mange emner den skal finde. Resultatets kvalitet afhænger af, at man rammer nogenlunde indenfor skiven - altså at man har en fornemmelse for, hvor mange emner, der faktisk er i et tekstkorpus.

Vi havde fra start et håb om, at netværksvisualiseringen kunne bruges som en genvej til at etablere, hvor mange emner avisen berørte. Men det var ikke tilfældet. Den filtrering vi havde lavet i processen mod vores netværksvisualisering havde skilt så tilpas meget fra, at det egentlige antal af emner i realiteten var højere. Når vi bad computeren om at finde 30 emner, var det således tydeligt, at den meget ofte puljede tekster sammen, der handlede om væsensforskellige ting. Dermed var det resulterende indeks ikke nyttigt. Modsat fandt vi også, at et meget stort antal emner resulterede i en kategorisering, der adskilte tekster med tydeligt slægtskab på tværs af kategorier. I denne proces snævrede vi os iterativt ind mod et gab på mellem 90 og 180 emner. I vores indeksering endte vi derfor med at skabe to kategoriseringer - en med hvert af disse tal. I søgeinterfacet er hvert indeksord annoteret, så det er muligt at identificere, hvilken proces, der har genereret det.

Koden er beskrevet nedenfor. Den er skrevet med udgangspunkt i Julia Silge og David Robertsons fremgangsmåde.

Kode

Vi startede med at loade pakker og data. Ligesom i tidligere workflows, filtrerer vi overskrifter fra og tildeler hver tekst et ID. Vi loader også en stopordsliste. Her brugte vi samme liste som i netværksvisualiseringseksperimentet, men i tilbageblik kunne vi med fordel have haft en mere inkluderende liste. F.eks. var det fra vores resultater tydeligt, at nogle emner blev dannet omkring egentlig fyldord. Så vores erfaring viser, at der er et behov for en ganske omfattende stopordsliste til dette formål.

library(tidyverse)
library(readxl)
library(topicmodels)
library(tidytext)

avis <- read_csv2("df.csv") %>% 
  filter(type != "overskrift") %>% 
  rownames_to_column(var = "ID")
stopord <- read_excel("stopord.xlsx")

Første skridt i selve bearbejdningen af teksterne var igen en tokenisering og stopordsfiltrering. I denne proces fjernede vi også alle ord, der indeholder tal. Vi lavede herefter en optælling over ord pr. tekst og skabte en såkaldt document-term-matrix, der kan tænkes som en meget bred datastruktur, hvor hver række er en tekst og hver kolonne er et ord. En sådan datatruktur er nødvendigvis “tynd”, fordi langt de fleste celler indeholder et 0 - af den simple grund, at langt de fleste ord ikke optræder i mere end et fåtal af tekster. Denne tynde datastruktur er i mange sammenhænge ikke specielt hensigtsmæssig at arbejde med, men det er det algoritmen forventer.

#Tokeniser og fjern stopord
Tidy_avis <- avis %>%
  unnest_tokens(word, tekst) %>% 
  anti_join(stopord) %>% 
  select(c(ID, word)) %>% 
  filter(!str_detect(word, "\\d"))

Tidy_avis_count <- Tidy_avis %>%
  count(ID, word)

#Lav en document-term matrix
Tidy_avis_dtm <- Tidy_avis_count %>%
  cast_dtm(ID, word, n)

Nedenfor træner vi så selve vores emnemodeller. Vi træner to med udgangspunkt i de valgte antal af emner. Fordi algoritmen er non-deterministisk, forsøger vi at øje reproducerbarheden med seed-argumentet. Dette gøres primært for vores egen skyld, så resultatet ikke varierer for meget imellem forskellige iterationer.

Det skal bemærkes, at operationen tager ret lang tid. Det skyldes, at der er mange emner og mange tekster. De resulterende dataobjekter er også temmeligt omfattende. I praksis gemte vi derfor disse med funktionerne saveRDS og readRDS, så vi havde arkiverede versioner af selve disse objekter, når noget gik galt.

Avis_LDA_180 <- LDA(Tidy_avis_dtm, k = 180, control = list(seed = 1234))
Avis_LDA_90 <- LDA(Tidy_avis_dtm, k = 90, control = list(seed = 1234))

Vores emnemodeller kommer i udgangspunktet i formater, der ikke passer til vores normale databearbejdsningsværktøjer. Imidlertid kan vi bruge funktionen “tidy” til at skabe versioner af dele af modeller. Det gøres ved at justere på argumentet “matrix”.

Avis_topics_180 <- tidy(Avis_LDA_180, matrix = "beta") 
Avis_topics_90 <- tidy(Avis_LDA_90, matrix = "beta") 

Computeren inddeler teksterne i emner, men fortæller os intet om, hvad et emne handler om. For at blive klogere på det, fandt vi de 50 ord, der klarest tilhørte hvert emne. Disse eksporterede vi til Google Docs og annoterede dem i fællesskab, således at hvert topic fik et emneord. Enkelte topics valgte vi ikke at navngive, fordi de tydeligt var defineret af fyldord eller ikke gav mening for os. Processen, hvor vi fandt ordene med klarest, tilhørsforhold blev udført med følgende kode:

Top_terms_180 <- Avis_topics_180 %>% 
  group_by(topic) %>% 
  top_n(50, beta) %>% 
  ungroup() %>% 
  arrange(topic, -beta) 

Top_terms_list_180 <- Top_terms_180 %>% 
  group_by(topic) %>% 
  summarize(ord = paste(term, collapse = ", "))

Top_terms_90 <- Avis_topics_90 %>% 
  group_by(topic) %>% 
  top_n(50, beta) %>% 
  ungroup() %>% 
  arrange(topic, -beta) 

Top_terms_list_90 <- Top_terms_90 %>% 
  group_by(topic) %>% 
  summarize(ord = paste(term, collapse = ", "))

Med disse oversigter over emner og vores annotationer i baghånden, tildelte vi hvert dokument til det emne, der præger dets ordsammensætning mest. Det gjorde vi igen med tidy-funktionen, men nu rettet imod en anden matrix.

# Document-Topic sandsynligheder 
Avis_documents_180 <- tidy(Avis_LDA_180, matrix = "gamma") 
Avis_documents_90 <- tidy(Avis_LDA_90, matrix = "gamma") 

# Lav en hård dokumentklassifikation 
Avis_documents_high_180 <- Avis_documents_180 %>% 
  group_by(document) %>% 
  slice_max(gamma) %>% 
  ungroup() 
Avis_documents_high_90 <- Avis_documents_90 %>% 
  group_by(document) %>% 
  slice_max(gamma) %>% 
  ungroup() 

# Join topic tilbage 
avis_180 <- avis %>% 
  left_join(Avis_documents_high_180, by = c("ID" = "document"))
avis_90 <- avis %>% 
  left_join(Avis_documents_high_90, by = c("ID" = "document"))

Sidste trin var nu blot at erstatte computerens sekventielle navngivning af hvert dokuments emne (1, 2, 3 … 90) med vores manuelt navngivne emnetitler. Det gjorde vi ved at importere vores lister skabt i Google Docs og flette dem ind.

topics_90_titler <- read_csv("90_topics_2.csv")

avis_3 <- avis_90 %>% 
  left_join(topics_90_titler) %>% 
  rename(topic_90 = topic)

topics_180_titler <- read_csv("180_topics_2.csv")

avis_4 <- avis_180 %>%
  left_join(topics_180_titler, by = "topic")

avis_med_topics <- avis_4 %>%
  left_join(select(avis_3, ID, kategori, emneord, topic_90), by = "ID")

Ikke alle topics er lige klare i deres betydning. Når man udforsker resultatet i vores søgeinterface, støder man derfor også på fejl, der kan irriterede og frustrere. Samtidig er nogle emner klart afgrænsede, f.eks. krystalliserer naturkatastrofer sig som et emne både i versionen med 90 og 180 emner. Modsat dukker enkelte emner først op i versionen med 180 emner, f.eks. i annoncer, hvor folk søger arbejde/arbejdere. I versionen med 90 emner, var disse puljet sammen med annoncer om udlejning af boliger (muligvis under indflydelse af ordet “Condition”, der indgår centralt i begge kontekster). Således fandt vi ikke det mest givende antal topics, men resultatet kan alligevel tjene til at give andre veje igennem arkivet end simpel fritekstsøgning.

Fodnoter

  1. Shawn Graham, Ian Milligan og Scott Weingart, Exploring Big Historical Data: The Historian’s Macroscope (London: Imperial College Press, 2016), s. 113–121.↩︎