Adgangen til mange digitale datasæt, der knytter sig til kulturarv nu til dags, er ofte kraftigt begrænset. Dette skyldes, at data kan indeholde personfølsomme oplysninger eller være omfattet af ophavsretloven. Dette er enormt ærgerligt i en tid, hvor flere og flere får øjnene op for, hvad man kan undersøge, når man bruger digitale metoder på store mængder data.
Ikke desto mindre findes der datasæt som ligger åbne. F. eks. de datasæt fra den danske avissamling, der er over 140 år gamle. Problemet med disse datasæt er dog, at kvaliteten er meget ringe. Helt konkret betyder det, at teksten fra aviserne fremgår med mange fejl. For at forstå hvorfor disse fejl er opstået er det nødvendigt at vende blikket mod digitaliseringen. I denne proces affotograferer man aviserne (enten fra mikrofilm eller fra original), Herefter lader man en computeralgoritme løbe igennem avissiderne. Denne algoritme gør to ting: 1. Segmenterer artiklerne - med andre ord så gætter den hvilke rubrikker hører til hvilke overskrifter 2. Udfører tekstgenkendelse således, at teksten bliver digital og man kan søge i den. Dette kaldes også OCR (Optical Character Recognition)
Denne algoritme er udviklet til moderne aviser, og derfor er resultatet oftest ret godt, når man har med nyere aviser at gøre (1910 til nu). Går man længere tilbage i tiden, begynder kvaliteten på digitaliseringen at falde. Dette skyldes blandt andet, at opsætningen af aviser er en anelse forskellige fra moderne opsætning. Èn af de helt store problemer er, at tekstgenkendelsen er dårlig. Dette skyldes, at man i gamle aviser brugte frakturtyper til at trykke sine aviser. Denne tekst vil nogle kende som gotiske bogstaver eller krøllede bogstaver.
Men hvad kan man så egentlig med denne ringe tekstkvalitet? Kan det overhovedet bruges til noget? Det vil jeg i denne rapport undersøge.
Enhver god opskrift på en kage lægger ud med billedet af kagen før, den gennemgår ingredienser og fremgangsmåde. Dette vil jeg også forsøge at efterleve i denne rapport. Derfor vil jeg på få linjer forsøge at beskrive det, rapporten arbejder hen imod og vise den visualisering, som er rapportens endelige produkt.
Den analyse, som jeg her vil foretage, er en såkaldt Term Frequency - Inversed Document Frequency-analyse(tf-idf). Den deltajerede forklaring vil komme senere, mens jeg her blot vil opridse, hvad det overordnet betyder i denne rapport. Datasættet som analysen udføres på er avisdata fra året 1849. Dette datasæt er åbent og med en sløj OCR-kvalitet. Mere om det senere.
I denne rapport vil jeg forsøge, via tf-idf, at finde ud af hvilke ord, der kendetegner de forskellige måneder i 1849. Tf-idf bruges til at straffe ord, der optræder i alle månederne. På denne måde får vi “frasorteret” ord som “den”, “og”, “der” og så videre. Disse ord kaldes også stopord. Tf-idf opjusterer samtidig ord, der bruges meget men kun i enkelte eller nogle af månederne.
Ved at se på de ord som tf-idf-metoden fremhæver per måned, kan man altså se hvilke ord, der er særlige for den enkelte måned. Dette kan både bekræfte viden vi allerede havde - f. eks. kunne man forestille sig at ordet “høst” ville springe frem i juli og august. Intet nyt her - vi ved godt at der høstes om sommeren. På den anden side kan ords fremtræden også få os til at undre os. Hvorfor træder et givent ord frem i netop denne månede? For at få svar på dette er vi nødt til at se ordet i sammenhæng og begive os til ordets kontekst i avisen. Denne bevægelse er altså fra en distant reading til en close reading. Med andre ord går man fra at betragte et datasæt fra toppen til at beskæftige sig med et enkelt ord i en bestemt måned. Særlig aspektet hvor vi undres kan være det gavnlige ved denne analyse - vi kan få øje på ting og mønstre, som ganske enkelt ville være en umulig opgave, hvis man skulle læse alle aviserne fra 1849 igennem selv.
Denne korte gennemgang af og visningen af det endelig mål skal tjene som bagtæppe for forståelse af de næste skridt i denne rapport.
Databehandlingen foretages i R og RStudio og overordnet set falder den i følgende dele:
I R bruger man pakker eller såkaldte biblioteker til at udvide grundfunktionaliteten. Pakken skal forståes som det konkrete ekstra lag, som andre kloge hoveder har udviklet og gjort muligt at bruge i R. Når man vil benytte sig af disse pakker, skal de først installeres på computeren. På den måde har man mulighed for at håndplukke de funktioner, som man ønsker at bruge. Når pakken ligger på computeren, er den inaktiv, når man starter et nyt script. Derfor bruger man funktionen library
for at fremkalde pakkens funktioner. Man kan sige, at funktionerne bliver pakket ud.
I denne sammehæng drejer det sig om:
library(tidyverse)
library(tidytext)
library(lubridate)
library(ggwordcloud)
library(wordcloud)
Dokumentation for de enkelte pakker:
På Det Kgl. Biblioteks datarepositorie for åbne data, LOAR, ligger blandt andet avisdata, der er ældre end 140 år gammel. 140 år er den grænse, man har sat af hensyn til at honorere, at skaberen af et værk (fx journalisten til en artikel) har ophavsretten 70 år efter sin død. I denne rapport bruger vi som sagt aviserne fra 1849 Alle de åbne avisdata kan hentes herfra: https://loar.kb.dk/handle/1902/157
Alternativt kan man indlæse dem direkte fra et link der peger hen på den såkaldte CSV-filen. Denne løsning vælger vi her:
artikler_1849 <- read_delim("https://loar.kb.dk/rest/bitstreams/17068cb1-fa5a-4efc-bbb8-bfbe3ed9b1aa/retrieve",
delim = ",",
escape_backslash = TRUE,
escape_double = FALSE)
For en liste over CSV-filer, der indeholder avisdata for perioden 1800-1877 se:
https://github.com/martinhauge/datasprint2020/blob/master/data/newspaper_retrieve_links_1800c.csv
CSV står for Comma Separated Values og kan forstås som et dumt excel-ark, hvor rækkerne er linjeskift og kommaerne er kolonnerne. Værdierne adskilles af kommaer, deraf navnet.
Lad os se hvordan avisdataen er struktureret:
artikler_1849
http://hdl.handle.net/109.3.1/uuid:000ecf81-babb-46e4-bf27-f9ad86b77c94-segment_1
Vi kan se, at der er 125563 rækker i avisdata fra 1849, hvilket er angivet i vores “Environment”. Disse rækker indholder den mindste enhed for aviser i avissamlingen, som udtrækket bygger på. Disse enheder er tekststumper, der ideelt set svarer til avisartikler. Dem er der ofte en del af på hver avisside, og de er udtrukket vha. automatisk segmentering af værktøjet ABBYY FineReader. recordID henviser til én af disse tekststumper, og til hver stump hører der en række metadata. I alt er der 5 forskellige variable som fordeler sig således:
recordID
: Den unikke ID som den pågældende avisside har fået i databasen - her er det også specificeret hvilken artikel på siden, som teksten kommer fra. Ser man på endelsen af recordID, er det enten nogroup eller segment_X. Alle recordIDs med segment-efternavnet er enkeltstående artikler, mens recordIDs med nogroup-endelsen er det skrald, der blev tilovers efter segmentering. Fjerner man endelsen fra recordID og udskifter man starten med doms_aviser_page får man den Mediestream-record der omhandler selve siden. Fx: doms_newspaperCollection:uuid:000ecf81-babb-46e4-bf27-f9ad86b77c94-segment_1 → doms_aviser_page:uuid:000ecf81-babb-46e4-bf27-f9ad86b77c94. Se søgning på en specifik side i Mediestream:
http://www2.statsbiblioteket.dk/mediestream/search/pageUUID%3A%22doms_aviser_page%3Auuid%3A000ecf81-babb-46e4-bf27-f9ad86b77c94%22). Læg her mærke til at linket består af en række elementer, men at det sidste led er identisk med recordID. Dette benytter vi os af senere for at forbinde vores avisdata med dens oprindelige kontekts som den findes i affotograferingerne i Mediestream.
sort_year_asc
: dato for udgivelsen af den avis, som segmentet stammer fra
editionId
: familyId for avisen samt dato for udgivelsen. familyId vil afspejle avisens titel, men i tilfælde hvor en avis skifter navn flere gange i gennem en periode, kan man opleve afvigelser mellem editionId og selve titlen på avisen.
fulltext_org
: den OCR-genkendte tekst fra segmentet i avisen. En hurtigt kig herpå bevidner den tvivlsomme kvalitet. Det er dog muligt at læse noget mening frem.
Da nogle af datoerne i kolonnen sort_year_asc
mangler både dag og måned, så oprenser vi her, således at de få tilfælde får datoen 1849-01-01. Årsagen hertil er, at vi vil se på månederne i forhold til hinanden. Hvis disse kolonner ikke bliver udfyldt, kan funktioner ofte ikke virke, da der mangler datapunkter. Da der er tale om så få tilfælde, er det en god ide at give dem samme dato i stedet for at slette disse datapunkter helt. For at sætte månederne op mod hinanden skal vi have en kolonne kun med måneden i og ikke hele datoen. Til dette bruges funktionen month
fra pakken lubridate
artikler_1849 %>%
#Some dates do not have month and day - in order to be able to extract months without parsing-errors these dates are set to the 1th of January
mutate(sort_year_asc = str_replace_all(sort_year_asc, "1849$", "1849-01-01")) %>%
mutate(date = ymd(sort_year_asc)) %>%
mutate(month = month(date)) -> artikler_1849
doms_newspaperCollection:uuid:000ecf81-babb-46e4-bf27-f9ad86b77c94-segment_1 doms_aviser_page:uuid:000ecf81-babb-46e4-bf27-f9ad86b77c94 http://www2.statsbiblioteket.dk/mediestream/search/pageUUID%3A%22doms_aviser_page%3Auuid%3A000ecf81-babb-46e4-bf27-f9ad86b77c94%22
Vi vil senere være interesserede i at kunne se et givent ord i sin oprindelige kontekst i avisen. Derfor skal vi bruge recordID
i en kolonne for sig selv, hvor den er indsat i en URL, der viser os den konkrete side i avisen, hvorfra et ord stammer fra.
artikler_1849 %>%
mutate(link = recordID) %>%
mutate(link = str_replace(link, pattern = "doms_newspaperCollection:uuid:", "http://www2.statsbiblioteket.dk/mediestream/search/pageUUID%3A%22doms_aviser_page%3Auuid%3A")) %>%
mutate(link = str_replace_all(link, "-segment_\\d+|-_nogroup_", "%22")) %>%
select(link, everything()) -> artikler_1849
For at begrænse antallet af variabler, som vi tager med videre herfra udvælger vi sort_year_asc
, month
, fulltext_org
, link
og editionId
. Her bruger vi funktionen select
til at udvælge dem:
artikler_1849 %>%
select(sort_year_asc, month, fulltext_org, link, editionId) -> artikler_1849_tekst_month
Data behandlingen vil tage udgangspunkt i Tidy Data-princippet som den er implementeret i tidytext-pakken. Tankegangen er her at tage en tekst og splitte den op i enkelte ord. På denne måde optræder der kun ét ord per række i datasættet.
Det næste der sker er, at vi omdanner data til det førnævnte tidytext format, hvor hvert ord kommer til at stå på en række for sig selv, hvilket gøres med unnest_tokens
. Resultatet af denne transformation gemmer vi til den efterfølgende analyse i referencen artikler_1849_tidy
.
artikler_1849_tekst_month %>%
unnest_tokens(word, fulltext_org) -> artikler_1849_tidy
Lad os lige udprinte denne nye dataframe for at se tidytext-formatet i “praksis”. Dette gøres ved at skrive data-elementets navn:
artikler_1849_tidy
For at danne os et overblik over vores datasæt vil vi nu lave en wordcloud, der indeholder de mest brugte ord i aviserne i 1849
artikler_1849_tidy %>%
count(word) %>%
with(wordcloud(word, n, max.words = 30, colors = c("darkgreen", "orange", "red")))
Ikke overraskende er det småord der optræder hyppigst i datasættet. En måde at omgå disse er at indlæse en stopordliste, som man kan bruge til at fjerne stopordene:
stopord <- read_csv("https://gist.githubusercontent.com/maxodsbjerg/f2271ec1a1d76af4b91eaa78cf6f2016/raw/059220dc20c68a2bdd00b0699cf97c23ddbc7f04/stopord.txt")
Vi prøver nu igen med funktionen anti_join(), der fjerne stopordene fra data:
artikler_1849_tidy %>%
anti_join(stopord) %>%
count(word, sort = TRUE) %>%
with(wordcloud(word, n, max.words = 30, colors = c("darkgreen", "orange", "red")))
## Joining, by = "word"
Efter at have sorteret stopordene fra begynder vi nu at kunne se den støj, som er kendetegnende for tekstkvaliteten i de gamle aviser. Tilsyneladende drejer det sig især om fristående bogstaver, men også “gamle stopord” som “saa” og “vilde” træder frem, da de ikke er på den brugte stopordsliste.
Ikke desto mindre har vi fået introduceret tidytext-princippet og hvordan dets kongstanke med ét ord pr. række kan bruges til at sige noget om store mængder tekstmateriale. Dog er vi også stødt på vores først problem i forbindelse med den ringe OCR-kvalitet. Lige under stopordene ligger der en del støj og forhindrer os i at se, hvad der egentlig er interessant i aviserne fra 1849. Vi har altså brug for en mere sofistikeret måde at beskæftige os med data fra aviserne 1849 end blot at optælle hyppigst forekommende ord. Her kommer den føromtalte term frequency - inversed document frequency ind i billedet.
Ved tf-idf er vi som nævnt i indledningen interesserede i at se på de enkelte måneder i stedet for hele året. I det følgende kommer vi altså til at opfatte avisteksterne, som en samlet tekst pr. måned. I den forbindelse vil vi samtidig straffe ord, som optræder i alle månederne. På denne måde kan vi luge alle stopordene ud, da ord som “og” “det”, “den” osv. vil være tilstede i alle måneder. Denne tilgang vil forventeligt også fjerne de hyppigst forekommende OCR-fejl, da de ligeledes vil være tilstede i alle månederne.
Første skridt er at finde de ord, der hyppigst forekommer pr. måned.
artikler_1849_tidy %>%
count(month, word, sort = TRUE)
Ikke overraskende er det småord, som optræder flest gange pr. måned. Dette er ikke videre interessant i denne undersøgelse, så vi er nu interesseret i at finde et mål, der gør at vi kan sammenligne ords hyppighed på tværs af månederne. Dette kan vi gøre ved at udregne ordets, termets, frekvens tf:
\[\textrm{tf}(y,\textrm{ord}) = \frac{n_{y,\textrm{ord}}}{N_y}\] hvor \(y\) er måneden, \(n_{y,\textrm{ord}}\) er antal gange ord optræder i år \(y\) og \(N_y\) er det totale antal ord og termer i måneden \(y\). Før vi kan tage dette skridt skal vi dog have R til at tælle, hvor mange ord, altså \(N_y\), der er i de enkelte måneder. Dette gøres med funktionen group_by
efterfulgt af summarise
:
artikler_1849_tidy %>%
count(month, word, sort = TRUE) %>%
group_by(month) %>%
summarise(total = sum(n)) -> total_words
## `summarise()` ungrouping output (override with `.groups` argument)
total_words
Herefter skal vi have tilføjet det totale antal ord pr månede til vores dataframe, hvilket gøres med left_join
:
artikler_1849_tidy %>%
count(month, word, sort = TRUE) %>%
left_join(total_words, by = "month") -> artikler_1849_word_count
artikler_1849_word_count
Nu har vi de tal vi skal bruge for at udregne ordenes frekvenser. Her udregner vi for “og” i oktober.
\[\textrm{tf}(1935, \textrm{"og"})=\frac{30279}{1195745 }=0.02532229\]
Ved at udregne frekvensen for termer kan vi sammenligne dem på tværs af måned. Det er dog ikke videre interessant at sammenligne brugen af ordet “og” månederne i mellem. Vi mangler derfor en måde at “straffe” ord som optræder hyppigt i alle måneder. Til dette kan vi bruge den førnævnte inversed document frequency (idf):
\[\textrm{idf}(\textrm{term})=\ln(\frac{n}{N})\]
Hvor n er det totale antal dokumenter (i vores tilfælde måneder) og N er antallet af måneder, hvor ordet fremgår. For “og” giver det en idf værdi på 0.
\[\textrm{idf}("\textrm{at}")=\ln(\frac{12}{12})=0\]
Herved får vi altså straffet ord som optræder med stor hyppighed i alle månederne eller mange af måneder. Ord der forekommer i alle månederne, kan altså altså ikke fortælle os noget særlig om en given månede. Disse ord vil have en idf på 0. Deres tf_idf, defineret som
\[\textrm{tf}\_\textrm{idf} = \textrm{tf} \times \textrm{idf}\]
bliver også 0.
R kan udregne tf og tf_idf for alle ordene med bind_tf_idf
funktionen:
artikler_1849_word_count %>%
bind_tf_idf(word, month, n) -> artikler_1849_tfidf
artikler_1849_tfidf
Ikke desto mindre ser vi ikke nogen interessante ord. Dette skyldes, at R oplister ordene i et stigende hierarki – altså lavest til højest. Vi beder det om at gøre det faldende i stedet – højest tf_idf
artikler_1849_tfidf %>%
arrange(desc(tf_idf))
Herefter kan vi gå over til en grafisk visualisering.
artikler_1849_tfidf %>%
arrange(desc(tf_idf)) %>%
mutate(word = factor(word, levels = rev(unique(word)))) %>%
group_by(month) %>%
top_n(10) %>%
ungroup %>%
ggplot(aes(label = word, size = tf_idf, color = tf_idf)) +
geom_text_wordcloud_area() +
scale_size_area(max_size = 8.5) +
theme_minimal() +
facet_wrap(~month, ncol = 3, scales = "free") +
scale_color_gradient(low = "darkred", high = "red") +
labs(
title = "#Danish Newspapers 1849: most important words pr. month",
subtitle = "Importance determined by term frequency (tf) - inversed document frequency(idf)")
## Selecting by tf_idf
## Warning in wordcloud_boxes(data_points = points_valid_first, boxes = boxes, :
## One word could not fit on page. It has been placed at its original position.
Det interessante ved denne visualisering er, at vi begynder at se ord, der kan undre os. F.eks. “luzian” i maj og “edith” i slutningen af året. Ved at søge på disse ord i Mediestream for året 1849 kan man konstatere, at der er tale om føljetonromaer, hvor Edith og Luzian er hovedpersonerne. Derfor er de særlige for de måneder. Men hvilke muligheder har vi for hurtigt at se et givent ord i dets kontekst? F. eks. er “schweinehund” særlig for april - hvad handler det om? Det bliver omdrejningspunktet i næste del. Først gemmer vi dog vores visualisering som en fil.
ggsave("../visualisations/artikler_wordclouds.png", width = 20, height = 25, units = "cm")
Lige som vi før fik R til at sætte alle ordene på en række for sig selv, så gør vi nu i princippet det samme. I stedet for at have ét ord pr. række vil vi dog nu have sekvenser af otte ord. Dette gør, at vi senere kan filtrere på ordene, der indgår i disse ordsekvenser. Vi kan f.eks. specificere, at ord nr. fire i sekvensen skal være schweinehund, hvorved vi altså får ordets kontekst. I første omgang skal vi dog have splittet teksten op i sekvenser af otte ord. Disse sekvenser kalder vi octograms:
artikler_1849 %>%
unnest_tokens(octogram, fulltext_org, token = "ngrams", n = 8) -> octogram
For at kunne filtrere i ordene splitter vi ordene op således, at de står i hver deres kolonne:
octogram %>%
separate(octogram, c("word1", "word2", "word3", "word4", "word5", "word6", "word7", "word8"), sep = " ") -> octogram_sep
Nu kan vi filtrere på resultatet, og da “Schweinehund” optrådte i april, sørger vi først for kun at få resultater fra april måned. Dernæst filtrerer vi således, at vi kun får de octograms, hvor “schweinehund” står som nr. 2. Til sidst sætter vi ordene sammen igen, hvilket går outputtet mere læseligt for os:
octogram_sep %>%
filter(month == 4) %>%
filter(word2 == "schweinehund") %>%
unite(octogram, word1, word2, word3, word4, word5, word6, word7, word8, sep = " ") %>%
select(octogram, everything())-> schweinehund
schweinehund
Dette eksempel viser, hvordan man hurtigt kan skabe sig et overblik, når man har store datasæt. Det giver mulighed for at dykke ned i specifikke måneder og undersøge særlige ord, der fortæller noget omkring samtidens tendenser og temaer. Dette eksempel kan overføres til mange andre opgaver med text mining, da de anvendte funktioner kan bruges i forskellige sammenhænge. Derfor opfordres der også herfra til at genbruge disse koder på andet materiale, da man ofte kommer langt med sine søgninger, hvis man bruger kode, der allerede er afprøvet.