Fra avissider til segmenterede artikler

Forfattet af

Klart som blæk

Senest opdateret

30.10.2023

Fra Transkribus kan man eksportere hver side i et eller flere dokumenter som rå tekst i form af txt-filer. Dette gør det nemt at arbejde med teksten i R (eller tilsvarende værktøjer).

Imidlertid vil vi gerne præsentere avisens indhold i en form, hvor den naturlige enhed er en artikel. En side kan indeholde mange artikler, og artikler kan brede sig over flere sider. Siden er på denne måde ikke avisens primære struktur.

Det gav ikke mening at foretage denne segmentering manuelt. Datasættet er på omkring 6400 tætpakkede sider. Derfor har vi valgt en fremgangsmåde, der handler om at træne en algoritme til formålet - en såkaldt naive Bayes model. Denne udregner på baggrund af manuelt kategoriseret træningsmateriale en sandsynlighed for, at et stykke tekst tilhører en bestemt kategori. I denne kontekst rettes fremgangsmåden imod de enkelte linjer, som vi ønsker, at modellen skal kunne kategorisere som enten en overskrift, første linje i en artikel/annonce eller en almindelig linje. Følgende tekst beskriver skabelsen af denne algoritme.

Fra txt-filer til dataframe

Eksport fra Transkribus resulterer i en zip-fil, der indeholder en række undermapper. Dybt inde i denne rede ligger de enkelte tekstfiler, der er brudt op efter side. En fil pr. side i Transkribus. På denne måde får man en mappestruktur, hvor hvert tekstelementer ligger som individuelle txt-filer i separate mapper. Til at samle disse filer til en enkelt tabel, brugte vi følgende kode.

library(tidyverse)

Først loades tidyverse-pakken. Derefter skabes en liste over alle filer i vores data-mappe, hvor indholdet af den udpakkede zip-fil fra Transkribus befinder sig. Bemærk argumentet “recursive”, der definerer, at listen også skal indeholde navnene på filer i undermapper.

fil_liste <- list.files(path = "data/", 
                        pattern = "*.txt", 
                        full.names = TRUE,
                        recursive = TRUE)

Listen med navnene på filerne bruges derefter til at importere indholdet af hver tekstfil, der samles i en dataframe (dvs. en tabel). Imidlertid ender navnet på filen med at optræde som rækkenavn, så vi tilføjer en ID-kolonne, der indeholder en kopi af rækkenavnet.

Tekstfiler <- data.frame(
  Text = sapply(fil_liste, 
                FUN = function(x)readChar(x, file.info(x)$size)), 
  stringsAsFactors = FALSE)

Tekstfiler$ID <- rownames(Tekstfiler)

Hermed ender vi med en datastruktur, hvor hver række er en side i avisen, og hvor fuldteksten fra siden optræder i kolonnen Text.

Med henblik på at træne en model til at segmentere teksterne i overskrifter og artikler, bryder vi øjeblikkeligt denne struktur ned i enkelte linjer. Dette er simpelt, for txt-filerne indeholder linjeskift, der i det importerede data optræder med små koder (“\n” og “\r” i kombination). Disse kan derfor bruges til at splitte teksten i enkelte linjer, som de fandtes i selve avisen. Disse koder fjernes i samme ombæring fra vores opsplittede linjer.

lines <- Tekstfiler %>% 
  mutate(line = str_split(Text, "\n")) %>% 
  unnest(line) %>% 
  mutate(line = str_remove(line, "\r"),
         line = str_remove(line, "\n")) %>% 
  rename(fil_ID = ID)
  
lines <- lines %>%
  mutate(ID = rownames(lines))

Nu er hver linje sin egen række i en tabel.

En model til genkendelse af struktur

Herefter er det nødvendigt med manuelt arbejde. For at skabe træningsdata til algoritmen, kategoriserede vi c. 14.000 linjer i hånden. Det lyder af meget, men er reelt kun en lille flig af det meget større datasæt på næsten 700.000 linjer. Vi startede denne kategorisering i fællesskab for at blive enige om eventuelle tvivlsspørgsmål. Disse var der nogle af, især i forbindelse med artikler, der indeholder flere elementer. De mest vanskelige var nyhedsartikler, der fortæller flere nyheder fra samme sted. Her brugte vi avisens layout som guide, da nyhedsstoffet i disse tilfælde ofte splittes i et nyt afsnit for hver nyhed. Imidlertid var der også enkelte eksempler på, at dette adskilte indhold, der i virkeligheden hang sammen eller refereredes på tværs. I annoncerne var der tvivlsspørgsmål vedr. annoncer med flere separate elementer. Dette kunne f.eks. være de tilbagevendende bekendtgørelser om bankkurser, der fortæller om kurser forskellige steder i verden, eller annoncer om auktioner over forskellige ting (særligt ejendomme) til salg. I disse tilfælde etablerede vi en regel om, at elementer blev set som separate annoncer, når de kom som del af en fast rubrik - som i tilfældet med kurserne - og sammenhængende, når de var indrykket af samme annoncør - som i tilfældet med auktionerne. I praksis vil disse gråzoner imidlertid reflekteres i den endelige struktur, for hvor vi selv er i tvivl, er der også en chance for, at computeren bliver det.

Vores kategoriserede linjer importeres i R for at kunne bruge dette til at træne modeller. Der loades desuden en række pakker og laves nogle simple formateringer af kolonnerne.

library(tidymodels)
library(textrecipes)
library(discrim)
library(readxl)

træningsdata <- read_excel("data/lines_clean_test.xlsx")

træningsdata <- træningsdata %>% 
  mutate(line = str_remove(line, "\r"),
         ID = rownames(træningsdata),
         header = str_replace(as.character(header), "1", "overskrift"),
         first = str_replace(as.character(first), "1", "første_linje"),
         last = str_replace(as.character(last), "1", "sidste_linje"),
         linjetype = paste0(header, first),
         linjetype = str_replace(linjetype, "NANA", "alm_linje"),
         linjetype = str_remove_all(linjetype, "NA"),
         overskrift = str_detect(linjetype, "overskrift"),
         første_linje = str_detect(linjetype, "første_linje"),
         alm_linje = str_detect(linjetype, "alm_linje"))

Skabelsen af struktur-/regelbaserede indikatorer

I mange tilfælde kan algoritmer til tekstklassifikation trænes på de egentlige tekstdata. Dette er imidlertid vanskeligt her, fordi linjerne hver især indeholder ganske få ord. At forsøge at vektorisere den rå tekst og dermed gøre den spiselig som tal til computer rammer derfor den mur, at dataene vil have en ekstrem grad af “sparsity” - fordi linjerne hver især kun vil indeholde nogle få ud af mange tusinde mulige ord. Derfor valgte vi, at vores model primært skulle prøve at klassificere linjerne med udgangspunkt i strukturelle elementer i teksten. F.eks. kan det tænkes, at det vil hjælpe algoritmen til at finde overskrifter, hvis den ved, at nogle linjer starter med store bogstaver, imens andre ikke gør. På samme måde kan det tænkes, at det hjælper algoritmen til at finde første linje i en artikel, hvis den ved, at linjen forinden ikke afsluttes af “¬” (vores tegn for en orddeling i Transkribus). Til disse strukturelle elementer føjer vi yderligere en serie af indikatorer på hhv. overskrifter, annoncestart og signaturer, hvor vi etablerer, hvorvidt en given linjes første ord var blandt en serie af hyppige formuleringer, vi kender fra vores transskribering.

Den endelige serie af indikatorer er som følger:

træningsdata <- træningsdata %>% 
  mutate(contains_not = str_detect(line, "¬")) %>% 
  mutate(above_contains_not = lag(contains_not, default = FALSE)) %>%
  mutate(number_of_char = str_count(line, ".")) %>% 
  filter(number_of_char > 0) %>% 
  mutate(antal_punktum_ingen_forkort = str_count(line, " \\w{3,}\\.")) %>% 
  mutate(first_char = str_extract(line, "^.{1}")) %>% 
  mutate(starts_with_upper = str_detect(first_char, "^[[:upper:]]+$")) %>%
  mutate(last_char = str_extract(line, ".{1}$")) %>%
  mutate(last_char_is_period = str_detect(last_char, "\\.")) %>% 
  mutate(full_stop_above = lag(last_char_is_period, default = TRUE)) %>%
  mutate(number_char_above = lag(number_of_char, default = 1)) %>%
  mutate(short_line_above = number_char_above < 45) %>% 
  mutate(short_line_two_above = lag(short_line_above, default = TRUE)) %>% 
  mutate(is_short = number_of_char < 45) %>% 
  mutate(short_line_below = lead(number_of_char, default = 1)) %>% 
  mutate(short_line_below = short_line_below < 45) %>% 
  mutaterstal = str_detect(line, "[0-9]{4}")) %>% 
  mutate(line = str_replace_all(line, "Aalborg Kirkenyt", "Aalborg_Kirkenyt")) %>% 
  mutate(første_ord = str_extract(line, "(\\w+)")) %>%
  mutate(ad_indikator = str_detect(første_ord, "(Ved|Efter|Paa|Af| |Jfølge|Den|Da|Fredagen|Løverdagen|Søndagen|Mandagen|Designation|Tirsdagen|Onsdagen|Torsdagen|Hos|Død(s|)fald|Undertegnede|Døbte|Begravede|Nye$|Ægte|Døde|Odense|Marib(oe|o)|Aarh(uu|u)s)|Viborg|Hals|Nekrolog|Kiøbenhavns|Under|Efter|Frisk|Fin|Et|En|Jndkomne")) %>% 
  mutate(header_indikator = str_detect(første_ord, "(Blandede|Blandinger|Kiel|Hamborg|Tyrkiet|Indenlandske|Udenlandske|Au(c|k)tioner|Fædrelandet|Bek(i|j)endtg(i|j)ørelser|K(j|i)øbenhavn|Proclama|Spanien|England|Sverrig|Nordamerika|Jtalien|Frankrig|Grækenland|Vien|London|Sydamerika|Afrika|Aalborg_Kirkenyt|Befordringer|Ledige|Udlandet|Helsingør)")) %>% 
  mutate(sig_indikator = str_detect(første_ord, "(Aalborg|Nibe|Hjø(r|rr)ing|Sæby|constitueret|const|Auctions-Directeur|Directionen)")) %>% 
  mutate(line = str_replace_all(line, "Aalborg_Kirkenyt", "Aalborg Kirkenyt")) %>% 
  mutate(personer = str_detect(line, "Kløcker|Spies|Bassesen|Boghandler|Sadolin|Rottbøll|Winkel|Bech|Moltke|Møller|Horn(s|f)yld|Boeck|Jørgensen|Keller|Wølfferdt|Grønnerup|Schmidt|Schierup")) %>% 
  group_by(line) %>% 
  mutate(count = n()) %>% 
  mutate(unik = count < 4)

træningsdata$ad_indikator <- replace_na(træningsdata$ad_indikator, FALSE)
træningsdata$header_indikator <- replace_na(træningsdata$header_indikator, FALSE)
træningsdata$sig_indikator <- replace_na(træningsdata$sig_indikator, FALSE)

Herefter formaterer vi alle kolonner, så både kolonner med tekst og kolonner med logiske udsagn gemmes som en såkaldt “factor”. Dette gøres fordi vores model forventer, at dens “predictors”, dvs. kolonner hvis indhold kan bruges til at forudsige en linjes kategori, er af denne type.

træningsdata <- træningsdata %>% 
  mutate_if(is.character, as.factor) %>% 
  mutate_if(is.logical, as.factor)

For at kunne få en indikator på, hvordan vores modeller klarer sig, splittes vores træningsdata i trænings- og testdata. Vi vælger, at 1/5 af vores data holdes tilbage. Dette materiale ser algoritmen ikke, før den er færdigbagt. Dens evne til at gætte rigtigt på denne rest fortæller os dermed om dens præcision.

set.seed(1234)

avis_split <- initial_split(træningsdata, strata = linjetype, prop = 4/5)
avis_train <- training(avis_split) 
avis_test <- testing(avis_split)

Som sidste skridt i vores setup til modelbyggeriet specificerer vi, at vi vil bruge en naive Bayes model med det formål at klassificere.

nb_spec <- naive_Bayes() %>% 
  set_mode("classification") %>% set_engine("naivebayes")

Modeller til forudsigelse

Naive Bayes klassifikation kan godt operere med flere forskellige udfald. Dvs. at reelt kunne vi nøjes med at træne en enkelt model, der fortalte om en linje var det ene, det andet eller det tredje. Imidlertid forudså vi en risiko for, at de forskellige elementer vi havde skabt til at gætte linjetype ikke alle var lige nyttige til de tre forskellige kategorier. Vi eksperimenterede derfor i fællesskab med tre forskellige modeller - en til hver type linje vi gerne ville identificere. Dette tillod os at lege med, hvilke indikatorer, der kunne bruges til hvad.

Hver model skabes imidlertid på samme måde. Først etableres en form for opskrift, der definerer, hvad der skal forudsiges, og hvad der skal bruges som baggrund for forudsigelsen. I denne proces formateres data yderligere, så de er forståelige for algoritmen. Nedenfor ses koden for hver af de tre modeller. Den er med undtagelse af udeladelsen af bestemte kolonner som “predictors” identisk for hver model. Desuden spytter hvert kodestykke nogle tal ud. Disse fortæller om modellens præcision målt på vores testdata.

Model 1: forudsigelse af første linje i annonce/artikel

første_linje_rec <- recipe(første_linje ~ 
                     starts_with_upper + 
                     full_stop_above + 
                     last_char_is_period + 
                     is_short + 
                     contains_not + 
#                     above_contains_not +
                     ad_indikator +
                     header_indikator +
                     unik +
                     personer +
#                     sig_indikator +
                     årstal +
                     antal_punktum_ingen_forkort +
#                     short_line_two_above +
                     short_line_below +
                     short_line_above,
                   data = avis_train) %>% 
  step_dummy(all_factor_predictors()) %>% 
  step_normalize(all_predictors())

første_wf <- workflow() %>% 
  add_recipe(første_linje_rec) %>% 
  add_model(nb_spec)

første_fit <- første_wf %>% fit(data = avis_train)

første_nb_final_fit <- last_fit(første_wf, split = avis_split)
første_predictions <- collect_predictions(første_nb_final_fit)
collect_metrics(første_nb_final_fit)
fejlidentifikationer_1 <- første_predictions %>%
  mutate(ID = as.character(.row)) %>% 
  left_join(træningsdata) %>% 
  mutate(match = .pred_class == første_linje) %>% 
  filter(match == FALSE)

Model 2: almindelige linjer

linje_rec <- recipe(alm_linje ~ 
                     starts_with_upper + 
                     full_stop_above + 
#                     last_char_is_period + 
#                     is_short + 
                     contains_not + 
                     above_contains_not +
                     ad_indikator +
                     header_indikator +
#                     unik +
                     personer +
#                     sig_indikator +
                     årstal +
#                     antal_punktum_ingen_forkort +
#                     short_line_two_above +
                     short_line_below +
                     short_line_above,
                   data = avis_train) %>% 
  step_dummy(all_factor_predictors()) %>% 
  step_normalize(all_predictors())

alm_wf <- workflow() %>% 
  add_recipe(linje_rec) %>% 
  add_model(nb_spec)

alm_fit <- alm_wf %>% fit(data = avis_train)

alm_nb_final_fit <- last_fit(alm_wf, split = avis_split)
alm_predictions <- collect_predictions(alm_nb_final_fit)
collect_metrics(alm_nb_final_fit)
fejlidentifikationer_2 <- alm_predictions %>%
  mutate(ID = as.character(.row)) %>%
  left_join(træningsdata) %>% 
  mutate(match = .pred_class == alm_linje) %>% 
  filter(match == FALSE)

Model 3: overskrifter

header_rec <- recipe(overskrift ~ 
                     starts_with_upper + 
#                     full_stop_above + 
#                     last_char_is_period + 
                     is_short + 
                     contains_not + 
                     above_contains_not +
#                     ad_indikator +
                     header_indikator +
                     unik +
                     personer +
                     sig_indikator +
                     årstal +
#                     antal_punktum_ingen_forkort +
#                     short_line_two_above +
                     short_line_below +
                     short_line_above,
                   data = avis_train) %>% 
  step_dummy(all_factor_predictors()) %>% 
  step_normalize(all_predictors())

header_wf <- workflow() %>% 
  add_recipe(header_rec) %>% 
  add_model(nb_spec)

header_fit <- header_wf %>% fit(data = avis_train)

header_nb_final_fit <- last_fit(header_wf, split = avis_split)
header_predictions <- collect_predictions(header_nb_final_fit)
collect_metrics(header_nb_final_fit)
fejlidentifikationer_3 <- header_predictions %>%
  mutate(ID = as.character(.row)) %>% 
  left_join(træningsdata) %>% 
  mutate(match = .pred_class == overskrift) %>% 
  filter(match == FALSE)

Linjeidentifikation på resten af datasættet

Målt på valideringsdata har vores model for overskrifter en præcision på 99.48 %. For almindelige linjer er den 95.53 %. Modellen, der skal identificere første linje klarer det med 96.54 %. Dette mønster er forventeligt, da overskrifterne har de klareste kendetegn (starter altid med stort, kommer altid efter punktum, matcher ofte en specifik term mm.). På flere måder er dette også det vigtigste element, da det adskiller nyhedsstof fra annoncer. Så det er en fordel, at det er der, vi har højst sikkerhed.

Næste skridt er at bruge modellerne. For at gøre dette skal vi imidlertid skabe de samme indikatorer i det fulde datasæt, som dem vi skabte i vores træningsdata. Dette gøres nedenfor.

df_lines <- lines %>%
  mutate(contains_not = str_detect(line, "¬")) %>% 
  mutate(above_contains_not = lag(contains_not, default = FALSE)) %>%
  mutate(number_of_char = str_count(line, ".")) %>% 
  filter(number_of_char > 0) %>% 
  mutate(antal_punktum_ingen_forkort = str_count(line, " \\w{3,}\\.")) %>% 
  mutate(first_char = str_extract(line, "^.{1}")) %>% 
  mutate(starts_with_upper = str_detect(first_char, "^[[:upper:]]+$")) %>%
  mutate(last_char = str_extract(line, ".{1}$")) %>%
  mutate(last_char_is_period = str_detect(last_char, "\\.")) %>% 
  mutate(full_stop_above = lag(last_char_is_period, default = TRUE)) %>%
  mutate(number_char_above = lag(number_of_char, default = 1)) %>%
  mutate(short_line_above = number_char_above < 45) %>% 
  mutate(short_line_two_above = lag(short_line_above, default = TRUE)) %>% 
  mutate(is_short = number_of_char < 45) %>% 
  mutate(short_line_below = lead(number_of_char, default = 1)) %>% 
  mutate(short_line_below = short_line_below < 45) %>% 
  mutaterstal = str_detect(line, "[0-9]{4}")) %>% 
  mutate(line = str_replace_all(line, "Aalborg Kirkenyt", "Aalborg_Kirkenyt")) %>% 
  mutate(første_ord = str_extract(line, "(\\w+)")) %>%
  mutate(ad_indikator = str_detect(første_ord, "(Ved|Efter|Paa|Af| |Jfølge|Den|Da|Fredagen|Løverdagen|Søndagen|Mandagen|Designation|Tirsdagen|Onsdagen|Torsdagen|Hos|Død(s|)fald|Undertegnede|Døbte|Begravede|Nye$|Ægte|Døde|Odense|Marib(oe|o)|Aarh(uu|u)s)|Viborg|Hals|Nekrolog|Kiøbenhavns|Under|Efter|Frisk|Fin|Et|En|Jndkomne")) %>% 
  mutate(header_indikator = str_detect(første_ord, "(Blandede|Blandinger|Kiel|Hamborg|Tyrkiet|Indenlandske|Udenlandske|Au(c|k)tioner|Fædrelandet|Bek(i|j)endtg(i|j)ørelser|K(j|i)øbenhavn|Proclama|Spanien|England|Sverrig|Nordamerika|Jtalien|Frankrig|Grækenland|Vien|London|Sydamerika|Afrika|Aalborg_Kirkenyt|Befordringer|Ledige|Udlandet|Helsingør)")) %>% 
  mutate(sig_indikator = str_detect(første_ord, "(Aalborg|Nibe|Hjø(r|rr)ing|Sæby|constitueret|const|Auctions-Directeur|Directionen)")) %>% 
  mutate(line = str_replace_all(line, "Aalborg_Kirkenyt", "Aalborg Kirkenyt")) %>% 
  mutate(personer = str_detect(line, "Kløcker|Spies|Bassesen|Boghandler|Sadolin|Rottbøll|Winkel|Bech|Moltke|Møller|Horn(s|f)yld|Boeck|Jørgensen|Keller|Wølfferdt|Grønnerup|Schmidt|Schierup")) %>% 
  group_by(line) %>% 
  mutate(count = n()) %>% 
  mutate(unik = count < 4)

df_lines$ad_indikator <- replace_na(df_lines$ad_indikator, FALSE)
df_lines$header_indikator <- replace_na(df_lines$header_indikator, FALSE)
df_lines$sig_indikator <- replace_na(df_lines$sig_indikator, FALSE)

Vi skal igen også gøre vores kolonner med hhv. tekst og logiske kolonner til faktorer. Da vi nu har meget mere tekst end før, sletter vi imidlertid først nogle af de kolonner, der indeholder meget tekst, som vi ikke bruger til noget. Så slipper vi for at vente på, at computeren tænker på dem.

df_lines_alt <- df_lines %>% 
  ungroup() %>%
  select(!c(fil_ID, Text, line))

df_lines_alt <- df_lines_alt %>%
  mutate_if(is.character, as.factor)

df_lines_alt <- df_lines_alt %>%
  mutate_if(is.logical, as.factor)

Herefter bruger vi de tre modeller på hver linje i vores komplette datasæt. Det er med andre ord her, vi benytter modellen til at forudsige, om hver af vores næsten 700.000 linjer er enten overskrift, første linje eller en almindelig linje. Til sidst knyttes nogle af produkterne af disse forudsigelser sammen i en tabel og smedes på vores oprindelige tabel med linjerne.

header_predictions <- augment(header_fit, df_lines_alt) %>% 
  rename(header_forudsigelse = 24) %>% 
  rename(header_false = 25) %>% 
  select(ID, header_forudsigelse, header_false) %>% 
  mutate(header_lavere_tærskel = header_false < 0.85)

first_predictions <- augment(første_fit, df_lines_alt) %>% 
  rename(første_forudsigelse = 24) %>% 
  rename(første_false = 25) %>% 
  select(ID, første_forudsigelse, første_false)

alm_predictions <- augment(alm_fit, df_lines_alt) %>% 
  rename(alm_forudsigelse = 24) %>% 
  rename(alm_false = 25) %>% 
  select(ID, alm_forudsigelse, alm_false)

all_predictions <- header_predictions %>% 
  left_join(first_predictions) %>% 
  left_join(alm_predictions) %>% 
  left_join(lines)

Bemærk, at vi i forbindelse med overskrifter ikke er tilfredse med algoritmens tærskel for, hvornår den føler sig sikker på, at noget ikke er en overskrift. Ved at kigge på resultaterne blev det nemlig tydeligt, at så snart modellen havde bare en lille usikkerhed om sine resultater, så var det typisk i tilfælde, hvor der faktisk var tale om en overskrift. Vi laver derfor variablen “header_lavere_tærskel”, der tester om værdien i header_false (sandsynligheden for, at der ikke er tale om en overskrift) er under 0.85. Dette bliver den værdi, vi senere bruger til at splitte sidernes tekster op med.

Fra linjer til artikler

På denne baggrund kunne vi samle linjerne til artikler. Selvom præcision ikke var 100 %, vil resultatet i udgangspunktet alligevel være mere nyttigt i langt de fleste scenarier. Vi starter med at skabe en ny tekstkolonne, hvor vi paster resultaterne af vores forudsigelser sammen.

final_lines <- all_predictions %>% 
  mutate(prediction_string = paste0(header_lavere_tærskel, 
                                    første_forudsigelse, 
                                    alm_forudsigelse, 
                                    sep = ""),
         text_alt = paste0(prediction_string, line, sep = ""))

Fordi denne kolonne består af tre forskellige logiske kolonner, kan den have en række formuleringer i sit indhold:

"TRUEFALSEFALSE"  "FALSETRUEFALSE"  "FALSEFALSETRUE"  "FALSETRUETRUE"   "TRUEFALSETRUE"   "FALSEFALSEFALSE" "TRUETRUEFALSE"

Denne streng paster vi ind i starten af hver linje. Derefter samler vi alle linjer, der hører til en given udgave. Dette gøres ved at identificere datoen i det oprindelige filnavn, der ligger indlejret som en streng af fire tal efterfulgt af en bindestreg og to tal mere og til sidst yderligere en bindestreg og to tal: f.eks. “1820-02-18”. Ved at gruppere på datoen kan vi derefter lime alle linjer, der hører til en given dato (og dermed udgave) sammen.

udgivelser_med_splits <- final_lines %>% 
  mutate(dato = str_extract(fil_ID, "\\d{4}-\\d{2}-\\d{2}")) %>% 
  group_by(dato) %>%
  reframe(side_text = paste0(text_alt, collapse = " "))

Ved at identificere kombinationer af TRUE og FALSE i de sammenlimede udgaver, kan vi nu starte med at splitte op i sektioner, forstået som noget, der indledes af en overskrift. De knæk vi skal bruge findes ved at kigge efter strenge, der starter med TRUE.

sektioner <- udgivelser_med_splits %>% 
  mutate(sektion = str_split(side_text, "TRUEFALSEFALSE")) %>%
  unnest(sektion) %>% 
  rownames_to_column(var = "Sektion_ID")

sektioner <- sektioner %>% 
  mutate(sektion = str_split(sektion, "TRUETRUEFALSE")) %>%
  unnest(sektion)

Sektionerne kan så brydes yderligere op i artikler ved at finde andre kombinationer.

artikler <- sektioner %>% 
  mutate(artikel = str_split(sektion, "FALSETRUEFALSE")) %>% 
  unnest(artikel) %>% 
  rownames_to_column(var = "Artikel_ID")

Resultatet er, at vores sider nu er brudt op i mindre stumper, der (når algoritmen gætter rigtigt) repræsenterer enten en overskrift eller en artikel/annonce.

Det segmenterede datasæt

Vores struktur mangler nu bare at blive renset for alt det rod, vi selv har introduceret for at kunne bearbejde den. Vi har stadig kombinationer af TRUE og FALSE flettet ind i selve teksten. Og vi har stadig vores tegn for orddeling fra Transkribus. Disse fjerner vi med str_remove_all. Samtidig skaber vi en kolonne, der fortæller, om teksten er en artikel eller en overskrift. Det gør vi på den simpleste måde, vi kan tænke os til: vi tæller antal tegn i hver tekst og definerer tekster under 30 tegn som overskrifter og alt andet som artikel/annonce. Til sidst fjerner vi også alle tekster, der har færre end et tegn - tomme artikler, der er resultatet af formateringsprocessen.

df_ny_struktur <- artikler %>% 
  select(dato, artikel) %>% 
  mutate(artikel = str_remove_all(artikel, "TRUE"),
         artikel = str_remove_all(artikel, "FALSE"),
         artikel = str_remove_all(artikel, "¬ "),
         overskrift = str_count(artikel) < 30,
         antal_tegn = str_count(artikel)) %>% 
  filter(antal_tegn > 1) %>% 
  mutate(type = str_replace(overskrift, "TRUE", "overskrift"),
         type = str_replace(type, "FALSE", "artikel/annonce")) %>% 
  select(dato, artikel, type) %>% 
  rename(tekst = artikel)

Resultatet er en datastruktur med 3 kolonner: dato, tekst og type.