10 Szövegösszehasonlítás

10.1 A szövegösszehasonlítás különböző megközelítései

A gépi szövegösszehasonlítás a mindennapi életünk számos területén megjelenő szövegbányászati technika, bár az emberek többsége nincs ennek tudatában. Ezen a módszeren alapulnak a böngészők kereső mechanizmusai, vagy a kérdés-felelet (Q&A) fórumok algoritmusai, melyek ellenőrzik, hogy szerepel-e már a feltenni kívánt kérdés a fórumon (Sieg 2018). Alkalmazzák továbbá a szövegösszehasonlítást a gépi szövegfordításban és az automatikus kérdésmegválaszolási feladatok esetén is (Wang and Dong 2020), de akár automatizált esszéértékelésre vagy plágiumellenőrzésre is hasznosítható az eljárás (Bar, Zesch, and Gurevych 2011).

A szövegösszehasonlítás hétköznapi életben előforduló rejtett alkalmazásain túl a társadalomtudományok művelői is számos esetben hasznosítják az eljárást. A politikatudomány területén többek között használhatjuk arra, hogy eldöntsük, mennyire különböznek egymástól a benyújtott törvényjavaslatok és az elfogadott törvények szövegei, ezzel fontos információhoz jutva arról, hogy milyen szerepe van a parlamenti vitának a végleges törvények kialakításában. Egy másik példa a szakpolitikai prioritásokban és alapelvekben végbemenő változások elemzése, melyet például szakpolitikai javaslatok vagy ilyen témájú viták leiratainak elemzésével is megtehetünk.

A könyv korábbi fejezeteiben bemutatott eljárások között több olyat találunk, melyek alkalmasak arra, hogy a szövegek hasonlóságából valamilyen információt nyerjünk. Ugyanakkor vannak módszerek, melyek segítségével számszerűsíthetjük a szövegek közötti különbségeket. Ez a fejezet ezekről nyújt rövid áttekintést. Mindenekelőtt azonban azt kell tisztáznunk, hogy miként értelmezzük a hasonlóságot. A hasonlóságelemzéseket jellemzően két nagy kategóriába szoktuk sorolni a mérni kívánt hasonlóság típusa szerint. Ez alapján beszélhetünk lexikális (formai) és szemantikai hasonlóságról.

10.2 Lexikális hasonlóság

A lexikális hasonlóság a gépi szövegfeldolgozás egy egyszerűbb megközelítése, amikor nem várjuk el az elemzésünktől, hogy „értse” a szöveget, csupán a formai hasonlóságot figyeljük. A megközelítés előnye, hogy számítási szempontból jelentősen egyszerűbb, mint a szemantikai hasonlóságra irányuló elemzések, hátránya azonban, hogy az egyszerűség könnyen tévútra vihet szofisztikáltabb elemzések esetén. Így például a lexikális hasonlóság szempontjából az alábbi két példamondat azonosnak tekinthető, hiszen formailag (kifejezések szintjén) megegyeznek.

1. „A boszorkány megsüti Jancsit és Juliskát.”

2. „Jancsi és Juliska megsüti a boszorkányt.”

Két dokumentum közötti lexikális hasonlóságot a szöveg számos szintjén mérhetjük: karakterláncok (stringek), szóalakok (tokenek), n-gramok (n egységből álló karakterláncok), szózsákok (bag of words) között, de akár a dokumentum nagyobb egységei, így szövegrészletek és dokumentumok között is. Bevett megközelítés továbbá a szókészlet összehasonlítása, melyet lexikális és szemantikai hasonlóság feltárására egyaránt használhatunk.

A hasonlóság számítására számos metrika létezik. Ezek jelentős része valamilyen távolságszámításon alapul, mint például a koszinus-távolság. Ez a metrika két szövegvektor (a két dokumentum-kifejezés mátrix) által bezárt szög alapján határozza meg a hasonlóságot (Wang and Dong 2020). Mindezt az alábbi képlet szerint:

\[ cos(X,Y)=\frac{X \cdot Y}{\|X\| \|Y\|} \]

vagyis kiszámoljuk a két vektor skaláris szorzatát, amelyet elosztunk a vektorok Euklidészi normáinak (gyakran hívják L2 normának is, és ennek segítségével kapjuk meg a vektorok hosszát) szorzatával. Vegyük az alábbi két példamondatot a koszinusz távolság számításának szemléltetésére:

1. Jancsi és Juliska megsüti a boszorkányt.

2. A pék megsüti a kenyeret.

A két példamondat (vagyis a dokumentumaink) dokumentum-kifejezés mátrixsza az alábbi táblázat szerint fog kinézni. Az X vektor reprezentálja az 1. példamondatot, az Y vektor pedig a második példamondatot.

Táblázat 10.1: Dokumentum-kifejezés mátrix két példamondattal
Vektor_név jancsi és juliska megsüti a boszokrkányt pék kenyeret
X 1 1 1 1 1 1 0 0
Y 0 0 0 1 2 0 1 1

A két mondat közötti távolság értékét a képlet szerint a következő módon számítjuk ki:

\[ \frac{x_{1}*y_{1}+x_{2}*y_{2}+x_{3}*y_{3}+x_{4}*y_{4}+x_{5}*y_{5}+x_{6}*y_{6}+x_{7}*y_{7}+x_{8}*y_{8}} {\sqrt{x_{1}^2+x_{2}^2+x_{3}^2+x_{4}^2+x_{5}^2+x_{6}^2+x_{7}^2+x_{8}^2}* \sqrt{y_{1}^2+y_{2}^2+y_{3}^2+y_{4}^2+y_{5}^2+y_{6}^2+y_{7}^2+y_{8}^2}} \]

A két példamondat koszinusz-távolságának értéke ennek megfelelően 0,463.

\[ \frac{1*0+1*0+1*0+1*1+1*2+1*0+0*1+0*1} {\sqrt{1^2+1^2+1^2+1^2+1^2+1^2+0^2+0^2}* \sqrt{0^2+0^2+0^2+1^2+2^2+0^2+1^2+1^2}} = \frac{3}{\sqrt{6}*\sqrt{7}}\approx 0,463 \]

A koszinusz-hasonlóság 0 és 1 közötti értékeket vehet fel. 0-ás értéket akkor kapunk, ha a dokumentumok egyáltalán nem hasonlítanak egymásra. Geometriai értelmeben ebben az esetben a két szövegvektor 90 fokos szöget zár be, hiszen cos(90) = 0 (Ladd 2020).

Egy másik széles körben alkalmazott dokumentumhasonlósági metrika a Jaccard-hasonlóság, melynek számítása egy egyszerű eljáráson alapul: a két dokumentumban egyező szavak számát elosztja a két dokumentumban szereplő szavak számának uniójával (vagyis a két dokumentumban szereplő szavak számának összegével, melyből kivonja az egyező szavak számának összegét). A Jaccard-hasonlóság tehát azt képes megmutatni, hogy a két dokumentum teljes szószámához képest mekkora az azonos kifejezések aránya (Niwattanakul et al. 2013, 2.o). Ahogy a koszinusz-hasonlóságnál is, itt is 0 és 1 közötti értéket kapunk, ahol a magasabb érték nagyobb hasonlóságra utal.

\[ Jaccard(doc_{1}, doc_{2}) = \frac{|doc_{1}\,\cap \, doc_{2}|}{|doc1 \, \cup \, doc2|} = \frac{|doc_{1} \, \cap \, doc_{2}|}{|doc_{1}| + |doc_{2}| - |doc_{1} \, \cap \, doc_{2} |} \]

10.3 Szemantikai hasonlóság

A szemantikai hasonlóság a lexikai hasonlósággal szemben egy komplexebb számítás, melynek során az algoritmus a szavak tartalmát is képes elemezni. Így például formai szempontból hiába nem azonos az alábbi két példamondat, a szemantikai hasonlóságvizsgálatnak észlelnie kell a tartalmi azonosságot.

1. „A diákok jegyzetelnek, amíg a professzor előadást tart.”

2. „A nebulók írnak, amikor az oktató beszél.”

A jelentésbeli hasonlóság kimutatására számos megközelítés létezik. Többek között alkalmazható a témamodellezés (topikmodellezés), melyet a Felügyelet nélküli tanulás fejezetben tárgyaltunk bővebben, ezen belül pedig az LDA Látens Dirichlet-Allokáció (Latent Dirichlet Allocation), valamint az LSA látens érzelemelemzés (Latent Sentiment Analysis) is nagyszerű lehetőséget kínál arra, hogy az egyes dokumentumainkat tartalmi hasonlóságok alapján csoportosítsuk.

Az LSA-nél és az LDA-nél azonban egy fokkal komplexebb megközelítés a szóbeágyazás, melyet a Szóbeágyazások című fejezetben mutattunk be. Ez a módszertan a témamodellezéshez képest a szöveg mélyebb szemantikai tartalmait is képes feltárni, hiszen a beágyazásnak köszönhetően képes formailag különböző, de jelentésükben azonos kifejezések azonosságát megmutatni. A jelentésbeli hasonlóság megállapítható a beágyazás során létrehozott vektorreprezentációkból (emlékezzünk: a hasonló vektorreprezentáció hasonló szemantikai tartalomra utal). Kimutathatjuk a szemantikai közelséget például a király – férfi – lovag kifejezések között, de olyan mesterségesen létrehozott jelentésbeli azonosságokat is feltárhatunk, mint az irányítószámok és az általuk jelölt városnevek kapcsolata. Abban az esetben, ha a szóbeágyazást kimondottan a szöveghasonlóság megállapítására szeretnénk használni, a WMD (Word Mover’s Distance) metrikát érdemes használni, mely a vektortérben elhelyezkedő szóvektorok közötti távolság által számszerűsíti a szövegek hasonlóságát (Kusner et al. 2015).

10.4 Hasonlóságszámítás

10.4.1 Adatbázis-importálás és előkészítés

A fejezet második felében a lexikai hasonlóság vizsgálatára, ezen belül a Jaccard-hasonlóság és a koszinusz-hasonlóság számítására mutatunk be egy-egy példát a törvényjavaslatok és az elfogadott törvények szövegeinek összehasonlításával. Az alábbiakban bemutatott elemzés a (Sebők, Berki, and Bolonyai 2021) megjelenés előtt álló kéziratból meríti elemzési fókuszát. Az eredeti kézirat által megvalósított elemzést a magyar korpusz egy részhalmazán replikáljuk az alábbiakban. A kutatási kérdés arra irányul, hogy mennyiben változik meg a törvényjavaslatok szövege a parlamenti vita folyamán, amíg a javaslat elfogadásra kerül. Az elemzés során a különböző kormányzati ciklusok közötti eltérésekre világítunk rá. Az elemzés megkezdése előtt a már ismert módon betöltjük a szükséges csomagokat.

library(stringr)
library(dplyr)
library(tidyr)
library(quanteda)
library(quanteda.textstats)
library(readtext)
library(ggplot2)
library(plotly)
library(HunMineR)

Ezt követően betöltjük azokat az adatbázisokat, amelyeken a szövegösszehasonlítást fogjuk végezni: az elfogadott törvények szövegét tartalmazó korpuszt, a törvényjavaslatok szövegét tartalmazó korpuszt, valamint az ezek összekapcsolását segítő adatbázist, melyben az összetartozó törvényjavaslatok és törvények azonosítóját (id-ját) tároltuk el. Ahogy behívjuk a három adattáblát, érdemes rögtön lekérni az oszlopneveket colnames() és a táblázat dimenzióit dim(), hogy lássuk, milyen adatok állnak a rendelkezésünkre, és mekkora táblákkal fogunk dolgozni. A dim() függvény első értéke a sorok száma, a második pedig az oszlopok száma lesz az adott táblázatban.

torvenyek <- HunMineR::data_lawtext_sample

colnames(torvenyek)
#> [1] "tv_id"          "torveny_szoveg" "korm_ciklus"    "ev"             "korm_ell"

dim(torvenyek)
#> [1] 600   5
tv_javaslatok <- HunMineR::data_lawprop_sample

colnames(tv_javaslatok)
#> [1] "tvjav_id"     "tvjav_szoveg"

dim(tv_javaslatok)
#> [1] 600   2
parok <- HunMineR::data_lawsample_match

colnames(parok)
#> [1] "tv_id"    "tvjav_id"

dim(parok)
#> [1] 600   2

Az importált adatbázisok megfigyeléseinek száma egységesen 600. Ez a több mint háromezer megfigyelést tartalmazó eredeti korpusz egy részhalmaza, mely gyorsabb és egyszerűbb elemzést tesz lehetővé. Az oszlopnevek lekérésével láthatjuk, hogy a törvénykorpuszban van néhány metaadat, amelyet az elemzés során felhasználhatunk: ezek a kormányzati ciklusra, a törvény elfogadásának évére, valamint a benyújtó kormánypárti vagy ellenzéki pártállására vonatkoznak. Ezenkívül rendelkezésre állnak a törvényeket és a törvényjavaslatokat azonosító kódok (tv_id és tvjav_id), melyek segítségével majd tudjuk párosítani az összetartozó törvényjavaslatok és törvények szövegeit. Ezt a left_join() függvénnyel tesszük meg. Elsőként a törvényeket tartalmazó adatbázishoz kapcsoljuk hozzá a törvény–törvényjavaslat párokat tartalmazó adatbázist a törvények azonosítója (tv_id) alapján. A colnames() függvény használatával ellenőrizhetjük, hogy sikeres volt-e a művelet, és az új táblában szerepelnek-e a kívánt oszlopok.

tv_tvjavid_osszekapcs <- left_join(torvenyek, parok)

colnames(tv_tvjavid_osszekapcs)
#> [1] "tv_id"          "torveny_szoveg" "korm_ciklus"    "ev"             "korm_ell"       "tvjav_id"

dim(tv_tvjavid_osszekapcs)
#> [1] 600   6

Második lépésben a törvényjavaslatokat tartalmazó adatbázist rendeljük hozzá az előzőekben már összekapcsolt két adatbázishoz.

tv_tvjav_minta <- left_join(tv_tvjavid_osszekapcs, tv_javaslatok)

colnames(tv_tvjav_minta)
#> [1] "tv_id"          "torveny_szoveg" "korm_ciklus"    "ev"             "korm_ell"       "tvjav_id"       "tvjav_szoveg"

dim(tv_tvjav_minta)
#> [1] 600   7

Ha jól végeztük a dolgunkat az adatbázisok összekapcsolása során, az eljárás végére 7 oszlopunk és 600 sorunk van, vagyis az újonnan létrehozott adatbázisba bekerült az összes változó (oszlop). A korpuszaink egy adattáblában való kezelése azért hasznos, mert így nem kell párhuzamosan elvégezni az azonos műveleteket a két korpusz, a törvények és a törvényjavaslatok tisztításához, hanem párhuzamosan tudunk dolgozni a kettővel. Kicsit közelebbről megvizsgálva az adatbázist, megtekinthetjük a metaadatainkat, valamit azt is láthatjuk a count funkció segítségével, hogy minden adatbázisunkban szereplő kormányzati ciklusra 100 megfigyelés áll rendelkezésünkre: tv_tvjav_minta %>% count(korm_ciklus).

summary(tv_tvjav_minta)
#>     tv_id           torveny_szoveg     korm_ciklus              ev          korm_ell     tvjav_id         tvjav_szoveg      
#>  Length:600         Length:600         Length:600         Min.   :1994   Min.   :  0   Length:600         Length:600        
#>  Class :character   Class :character   Class :character   1st Qu.:2000   1st Qu.:900   Class :character   Class :character  
#>  Mode  :character   Mode  :character   Mode  :character   Median :2006   Median :900   Mode  :character   Mode  :character  
#>                                                           Mean   :2006   Mean   :741                                        
#>                                                           3rd Qu.:2012   3rd Qu.:900                                        
#>                                                           Max.   :2018   Max.   :901

tv_tvjav_minta %>% 
  count(korm_ciklus)
#> # A tibble: 6 × 2
#>   korm_ciklus     n
#>   <chr>       <int>
#> 1 1994-1998     100
#> 2 1998-2002     100
#> 3 2002-2006     100
#> 4 2006-2010     100
#> 5 2010-2014     100
#> 6 2014-2018     100

Hasonlóan ellenőrizhetjük az egyes évekre eső megfigyelések számát is.

tv_tvjav_minta %>%
  count(ev)
#> # A tibble: 24 × 2
#>      ev     n
#>   <dbl> <int>
#> 1  1994    11
#> 2  1995    31
#> 3  1996    23
#> 4  1997    31
#> 5  1998    17
#> 6  1999    20
#> # ℹ 18 more rows

10.5 Szövegtisztítás

Mivel az elemzés során két különböző korpusszal dolgozunk – két oszlopnyi szöveggel –, egyszerűbb, ha a szövegtisztítás lépéseiből létrehozunk egy külön függvényt, amely magában foglalja a művelet egyes lépéseit, és lehetővé teszi, hogy ne kelljen minden szövegtisztítási lépést külön definiálni az egyes korpuszok esetén. A függvény neve jelen esetben szovegtisztitas lesz, és a már ismert lépéseket foglalja magában: kontrollkarakterek szóközzé alakítása, központozás és a számok eltávolítása. Kisbetűsítés, ismétlődő stringek és a stringek előtt található szóközök eltávolítása. Továbbá a str_remove_all() függvénnyel eltávolítjuk azokat az írásjeleket, amelyek előfordulnak a szövegben, de számunkra nem hasznosak.

A függvény definiálását az alábbi szintaxissal tehetjük meg.

fuggveny <- function(bemenet) {
  elvegzendo_lepesek
  return(kimenet)
}

A bemenet helyen azt jelöljük, hogy milyen objektumon fogjuk végrehajtani a műveleteket, a kimenetet pedig a return() függvénnyel definiáljuk, ez lesz a függvényünk úgynevezett visszatérési értéke, vagyis az elvégzendő lépések szerint átalakított objektum. A szövegtisztító függvény bemeneti és kimeneti értéke is text lesz, mivel ebbe a változóba mentettük az elvégzendő változtatásokat.

szovegtisztitas <- function(text) {
  text = str_replace(text, "[:cntrl:]", " ")
  text = str_remove_all(string = text, pattern = "[:punct:]")
  text = str_remove_all(string = text, pattern = "[:digit:]")
  text = str_to_lower(text)
  text = str_trim(text)
  text = str_squish(text)
  return(text)
}

Miután létrehoztuk a szövegtisztításra alkalmas függvényünket, az adatbázis két oszlopára fogjuk alkalmazni: a törvények szövegét és a törvényjavaslatok szövegét tartalmazó oszlopra, amiben a mapply() függvény lesz a segítségünkre. A mapply() függvényen belül megadjuk megadjuk az adattáblát és hivatkozunk annak releváns oszlopaira tv_tvjav_minta[ ,c("torveny_szoveg","tvjav_szoveg")]. Az alkalmazni kívánt függvényt a FUN argumentumaként adhatjuk meg – értelemszerűen ez esetünkben az előzőekben létrehozott szovegtisztitas függvény lesz. Végezetül pedig a függvényünk által megtisztított új oszlopokkal felülírjuk az előző adatbázisunk vonatkozó oszlopait, vagyis a torveny_szoveg és a tvjav_szoveg oszlopokat: tv_tvjav_minta[, c("torveny_szoveg","tvjav_szoveg")]. Amennyiben számítunk rá, hogy még változhatnak a szövegeket tartalmazó oszlopok, akkor érdemes előre definiálni a szöveges oszlopok neveit, hogy később csak egy helyen kelljen változtatni a kódon.

szovegek <- c("torveny_szoveg", "tvjav_szoveg")

tv_tvjav_minta[, szovegek] <- mapply(tv_tvjav_minta[, szovegek], FUN = szovegtisztitas)

A szövegtisztítás következő lépése a tiltólistás szavak meghatározása és kiszűrése a szövegből. Itt a quanteda csomagban elérhető magyar nyelvű stopszavakat, valamint a 7. fejezetben meghatározott speciális jogi stopszavak listáját használjuk.

legal_stopwords <- HunMineR::data_legal_stopwords

A stopszavak beimportálását követően korpusszá alakítjuk a szövegeinket és tokenizáljuk azokat. Ezt már külön-külön végezzük el a törvények és a törvényjavaslatok szövegeire, azonos lépésekben haladva. A létrehozott objektumokat itt is ellenőrizhetjük, például a summary(torvenyek_coprus) paranccsal, vagy a torvenyek_tokens[1:3] paranccsal, mely az első 3 dokumentum tokenjeit fogja megmutatni.

torvenyek_corpus <- corpus(tv_tvjav_minta$torveny_szoveg)

tv_javaslatok_corpus <- corpus(tv_tvjav_minta$tvjav_szoveg)
torvenyek_tokens <- tokens(torvenyek_corpus) %>%
  tokens_remove(stopwords("hungarian")) %>% 
  tokens_remove(legal_stopwords) %>%
  tokens_wordstem(language = "hun")

tv_javaslatok_tokens <- tokens(tv_javaslatok_corpus) %>%
  tokens_remove(stopwords("hungarian")) %>%
  tokens_remove(legal_stopwords) %>%
  tokens_wordstem(language = "hun")

A szövegek tokenizálásával és a tiltólistás szavak eltávolításával a szövegtisztítás végére értünk, így megkezdhetjük az elemzést.

10.6 A Jaccard-hasonlóság számítása

A Jaccard-hasonlóság kiszámításához a quanteda.textstats textstat_simil() függvényét fogjuk alkalmazni. Mivel a textstat_simil() függvény dokumentum-kifejezés mátrixot vár bemenetként, elsőként alakítsuk át ennek megfelelően a korpuszainkat. Az előző fejezetekhez hasonlóan itt is a TF-IDF súlyozást választottuk a mátrix létrehozásakor.

torvenyek_dfm <- dfm(torvenyek_tokens) %>% 
  dfm_tfidf()

tv_javaslatok_dfm <- dfm(tv_javaslatok_tokens) %>% 
  dfm_tfidf()

Miután létrehoztuk a dokumentum-kifejezés mátrixokat, érdemes a leggyakoribb tokeneket ellenőrizni a textstat_frequency() függvénnyel, hogy biztosak lehessünk abban, hogy a megfelelő eredményt értük el a szövegtisztítás során. (Amennyiben nem vagyunk elégedettek, érdemes visszatérni a stopszavakhoz és újabb kifejezéseket hozzárendelni a stopszólistához.)

tv_toptokens <- textstat_frequency(torvenyek_dfm, n = 10, force = TRUE)

tv_toptokens
#>         feature frequency rank docfreq group
#> 1            an      6992    1     131   all
#> 2      szerződő      4795    2     150   all
#> 3   felhalmozás      3618    3      28   all
#> 4        articl      3426    4      74   all
#> 5        kiadás      3328    5     224   all
#> 6           for      3305    6     100   all
#> 7   contracting      3295    7      39   all
#> 8  költségvetés      3130    8     206   all
#> 9    befektetés      3130    9      73   all
#> 10     szanálás      2629   10      13   all
tvjav_toptokens <- textstat_frequency(tv_javaslatok_dfm, n = 10, force = TRUE)

tvjav_toptokens
#>          feature frequency rank docfreq group
#> 1             an      6051    1     160   all
#> 2       szerződő      4533    2     148   all
#> 3         articl      3490    3      75   all
#> 4     befektetés      3447    4      90   all
#> 5  bűncselekmény      3274    5      96   all
#> 6    contracting      3266    6      40   all
#> 7       szanálás      3235    7      12   all
#> 8        bíróság      3226    8     301   all
#> 9      egyezmény      3045    9     231   all
#> 10           for      3045   10     109   all

A létrehozott dokumentum-kifejezés mátrixokon elvégezhetjük a dokumentumhasonlóság-vizsgálatot. A Jaccard-hasonlóság-metrika, illetve a quanteda textstat_simil() függvénye alkalmazható egy korpuszra is. Egy korpuszra végezve az elemzést, a függvény a korpusz dokumentumai közötti hasonlóságot számítja ki, míg két korpuszra mindkét korpusz összes dokumentuma közötti hasonlóságot. Érdemes továbbá azt is megjegyezni, hogy a textstat_simil() method argumentumaként megadható számos más hasonlósági metrika is, melyekkel további érdekes számítások végezhetők. Bővebben a textstat_simil() függény használatáról és argumentumairól a quanteda hivatalos honlapján olvashatunk.50 A textstat_simil() függvény kapcsán azt is érdemes figyelembe venni, hogy mivel nemcsak a dokumentum párokra, hanem az összes bemenetként megadott dokumentumra külön kiszámítja a Jaccard-indexet, a korpusz(ok) méretének növelésével a számítás kapacitás- és időigényessége exponenciálisan növekszik. Két 600 dokumentumból álló korpusz esetén kb. 4–5 perc a számítási idő, míg 360 dokumentum esetén csupán 1–2 perc.

jaccard_hasonlosag <- textstat_simil(torvenyek_dfm, tv_javaslatok_dfm, method = "jaccard")

Mivel az eredménymátrixunk meglehetősen terjedelmes, nem érdemes az egészet egyben megtekinteni, egyszerűbb az első néhány dokumentum közötti hasonlóságra szűrni, melyet a szögletes zárójelben való indexeléssel tudunk megtenni. Az [1:5, 1:5] kifejezéssel specifikálhatjuk a sorokat és az oszlopkat az elsőtől az ötödikig.

jaccard_hasonlosag[1:5, 1:5]
#> 5 x 5 Matrix of class "dgeMatrix"
#>        text1 text2  text3  text4  text5
#> text1 0.5541 0.126 0.1144 0.1250 0.1526
#> text2 0.0983 0.572 0.1278 0.1649 0.0943
#> text3 0.0880 0.124 0.7288 0.1022 0.0764
#> text4 0.1089 0.189 0.1182 0.5720 0.1134
#> text5 0.1504 0.094 0.0881 0.0984 0.6273

A mátrix főátlójában jelennek meg az összetartozó törvényekre és törvényszövegekre vonatkozó értékek, minden más érték nem összetartozó törvény- és törvényjavaslat-szövegek hasonlóságára vonatkozik, vagyis a vizsgálatunk szempontjából irreleváns. A mátrix főátlóját a diag() függvény segítségével nyerhetjük ki. Ha jól dolgoztunk, a létrehozott jaccard_diag első öt eleme (jaccard_diag[1:5]) megegyezik a fent megjelenített 5 × 5-ös mátrix főátlójában elhelyezkedő értékekkel, hossza pedig (length()) a mátrix bármelyik dimenziójával.

jaccard_diag <- diag(as.matrix(jaccard_hasonlosag))

jaccard_diag[1:5]
#> text1 text2 text3 text4 text5 
#> 0.554 0.572 0.729 0.572 0.627

Miután sikerült kinyerni az egyes törvény–törvényjavaslat párokra vonatkozó Jaccard-értéket, érdemes a számításainkat hozzárendelni az eredeti adattáblánkhoz, hogy a meglévő metaadatok fényében tudjuk kiértékelni az egyes dokumentumok közötti hasonlóságot. A hozzárendeléshez egyszerűen definiálunk egy új oszlopot a meglévő adatbázisban tv_tvjav_minta$jaccard_index, melyhez hozzárendeljük a kinyert értékeket.

tv_tvjav_minta$jaccard_index <- jaccard_diag

Érdemes megnézni a végeredményt, ellenőrizni a Jaccard-hasonlóság legmagasabb vagy legalacsonyabb értékeit. A top_n() függvény használatával ki tudjuk válogatni a legmagasabb és a legalacsonyabb értékeket. A top_n() függvény első argumentuma a változó lesz, ami alapján a legalacsonyabb és a legmagasabb értékeket keressük, a második argumentum pedig azt specifikálja, hogy a legmagasabb és a legalacsonyabb értékek közül hányat szeretnénk látni. Az n=5 értékkel a legmagasabb, az n=-5 értékkel a legalacsonyabb 5 Jaccard-indexszel rendelkező sort tudjuk kiszűrni. Emellett érdemes arra is odafigyelni, hogy a szövegeket tartalmazó oszlopainkat ne próbáljuk meg kiíratni, hiszen ez jelentősen lelassítja az RStudio működését és csökkenti a kiírt eredmények áttekinthetőségét.

tvjav_oszlopok <- c(
  "tv_id",
  "korm_ciklus",
  "tvjav_id",
  "jaccard_index"
)

tv_tvjav_minta[, tvjav_oszlopok] %>% 
  top_n(jaccard_index, n = 5)
#> # A tibble: 5 × 4
#>   tv_id     korm_ciklus tvjav_id         jaccard_index
#>   <chr>     <chr>       <chr>                    <dbl>
#> 1 1994XCV   1994-1998   1994-1998_T0276          0.991
#> 2 1995LXXXI 1994-1998   1994-1998_T1296          0.987
#> 3 1999XXXV  1998-2002   1998-2002_T0807          0.985
#> 4 2013XLII  2010-2014   2010-2014_T10219         0.981
#> 5 2014VIII  2010-2014   2010-2014_T13631         0.980
tv_tvjav_minta[, tvjav_oszlopok] %>% 
  top_n(jaccard_index, n = -5)
#> # A tibble: 5 × 4
#>   tv_id       korm_ciklus tvjav_id         jaccard_index
#>   <chr>       <chr>       <chr>                    <dbl>
#> 1 1998I       1994-1998   1994-1998_T4328        0.0513 
#> 2 2005LII     2002-2006   2002-2006_T16291       0.0532 
#> 3 2007CLXVIII 2006-2010   2006-2010_T04678       0.0270 
#> 4 2010CLV     2010-2014   2010-2014_T01809       0.0273 
#> 5 2012CXCV    2010-2014   2010-2014_T09103       0.00895

10.7 A koszinusz-hasonlóság számítása

A Jaccard-hasonlóság számítása után a koszinusz-távolság számítása már nem jelent nagy kihívást, hiszen a textat_simil() függvénnyel ezt is kiszámíthatjuk, csupán a metrika paramétereként (method =) megadhatjuk a koszinuszt is. Ahogy az előbbiekben, itt is a dokumentum-kifejezés mátrixokat adjuk meg bemeneti értékként.

koszinusz_hasonlosag <- textstat_simil(x = torvenyek_dfm, y = tv_javaslatok_dfm, method = "cosine")

Érdemes itt is megtekinteni a mátrix első néhány sorába és oszlopába eső értékeket.

koszinusz_hasonlosag[0:5, 0:5]
#> 5 x 5 Matrix of class "dgeMatrix"
#>        text1   text2   text3   text4   text5
#> text1 0.6238 0.01731 0.01716 0.01699 0.05780
#> text2 0.0118 0.92878 0.00877 0.04633 0.00753
#> text3 0.0155 0.01226 0.98497 0.00662 0.00203
#> text4 0.0202 0.05076 0.00612 0.96063 0.01878
#> text5 0.0763 0.00743 0.00318 0.01804 0.75334

Ebben az esetben is csak a mátrix átlójára van szükségünk, melyet a fent ismertetett módon nyerünk ki a mátrixból.

koszinusz_diag <- diag(as.matrix(koszinusz_hasonlosag))

koszinusz_diag[1:5]
#> text1 text2 text3 text4 text5 
#> 0.624 0.929 0.985 0.961 0.753

Végezetül pedig az átlóból kinyert koszinusz értékeket is hozzárendeljük az adatbázisunkhoz.

tv_tvjav_minta$koszinusz <- koszinusz_diag

A kibővített adatbázis oszlopneveit a colnames() függvénnyel ellenőrizhetjük, hogy lássuk, valóban sikerült-e hozzárendelnünk a koszinusz értékeket a táblához.

colnames(tv_tvjav_minta)
#> [1] "tv_id"          "torveny_szoveg" "korm_ciklus"    "ev"             "korm_ell"       "tvjav_id"       "tvjav_szoveg"  
#> [8] "jaccard_index"  "koszinusz"

10.8 Az eredmények vizualizációja

A hasonlósági metrikák vizulizációjára gyakran alkalmazott megoldás a hőtérkép (heatmap), mellyel korrelációs mátrixokat ábrázolhatunk. Ebben az esetben a mátrix értékeit egy színskálán vizualizáljuk, ahol a világosabb színek a magasabb, a sötétebb színek az alacsonyabb értékeket jelölik. A Jaccard-hasonlóság számításakor és a koszinusz-hasonlóság számításakor kapott mátrixok esetén is ábrázolhatjuk az értékeinket ilyen módon. Mivel azonban mindkét mátrix 600 × 600-as, nem érdemes a teljes mátrixot megjeleníteni, mert ilyen nagy mennyiségű adatnál már értelmezhetetlenné válik az ábra, így csak az utolsó 100 elemet, vagyis a 2014–2018-as időszakra vonatkozó értékeket jelenítjük meg. Ezt a koszinusz_hasonlóság nevű objektumunk feldarabolásával tesszük meg, szögletes zárójelben jelölve, hogy a mátrix mely sorait és mely oszlopait szeretnénk használni: koszinusz_hasonlosag[501:600, 501:600]. A koszinusz_hasonlosag objektumból egy data frame-et készítünk, ahol a dokumentumok közötti hasonlóság szerepel. A mátrix formátumból a tidyr csomag pivot_longer() függvényét használva tudjuk a kívánt formátumot elérni.

koszinusz_df <- as.matrix(koszinusz_hasonlosag[501:600, 501:600]) %>%
  as.data.frame() %>%
  rownames_to_column("docs1") %>%
  pivot_longer(
    "text501":"text600",
    names_to = "docs2",
    values_to = "similarity"
    )

glimpse(koszinusz_df)
#> Rows: 10,000
#> Columns: 3
#> $ docs1      <chr> "text501", "text501", "text501", "text501", "text501", "text501", "text501", "text501", "text501", "text501", "…
#> $ docs2      <chr> "text501", "text502", "text503", "text504", "text505", "text506", "text507", "text508", "text509", "text510", "…
#> $ similarity <dbl> 0.95618, 0.02592, 0.04451, 0.03346, 0.01169, 0.04536, 0.07091, 0.27322, 0.04593, 0.00791, 0.02199, 0.11017, 0.0…

Ezt követően pedig a ggplot függvényt használva a geom_tile segítségével tudjuk elkészíteni a hőtérképet, ami a hasonlósági mátrixot ábrázolja (ld. 10.1. ábra).

koszinusz_plot <- ggplot(koszinusz_df, aes(docs1, docs2, fill = similarity)) +
  geom_tile() +
  scale_fill_gradient(high = "#2c3e50", low = "#bdc3c7") +
  labs(
    x = NULL,
    y = NULL,
    fill = "Koszinusz-hasonlóság"
  ) + 
  theme(
    axis.text.x=element_blank(),
    axis.ticks.x=element_blank(),
    axis.text.y=element_blank(),
    axis.ticks.y=element_blank()
)

ggplotly(koszinusz_plot, tooltip = "similarity")

Ábra 10.1: Koszinusz-hasonlóság hőtérképen ábrázolva

A Jaccard-hasonlósági hőtérképet ugyanezzel a módszerrel tudjuk elkészíteni (ld. 10.2. ábra).

# adatok átalakítása
jaccard_df <- as.matrix(jaccard_hasonlosag[501:600, 501:600]) %>%
  as.data.frame() %>%
  rownames_to_column("docs1") %>%
  pivot_longer(
    "text501":"text600",
    names_to = "docs2",
    values_to = "similarity"
    )

# a ggplot ábra
jaccard_plot <-ggplot(jaccard_df, aes(docs1, docs2, fill = similarity)) +
  geom_tile() +
  scale_fill_gradient(high = "#2c3e50", low = "#bdc3c7") +
  labs(
    x = NULL,
    y = NULL,
    fill = "Jaccard hasonlóság"
  ) + 
  theme(
    axis.text.x=element_blank(),
    axis.ticks.x=element_blank(),
    axis.text.y=element_blank(),
    axis.ticks.y=element_blank()
)

ggplotly(jaccard_plot, tooltip = "similarity")

Ábra 10.2: Jaccard hasonlóság hőtérképen ábrázolva

A két ábra összehasonlításánál láthatjuk, hogy a koszinusz-hasonlóság általában magasabb hasonlósági értékeket mutat. A mátrix főátlójában kiugró világos csík azt mutatja meg, hogy a legnagyobb hasonlóság az összetartozó törvény- és törvényjavaslat-szövegek között mutatkozik meg, eddig tehát az adataink az elvárásaink szerinti képet mutatják. Amennyiben a világos csíkot nem látnánk, az egyértelmű visszajelzés volna arról, hogy elrontottunk valamit a szövegelőkészítés eddigi lépései során, vagy a várakozásásaink voltak teljesen rosszak.

A koszinusz- és a Jaccard-hasonlóság értékét ábrázolhatjuk közös pont-diagramon a geom_jitter() segítségével (ld. 10.3. ábra). Ehhez először egy kicsit átalakítjuk a data frame-et a tidyr csomag pivot_longer() függvényével, hogy a két hasonlósági érték egy oszlopban legyen. Ez azért szükséges, hogy a ggplot ábránkat könnyebben tudjuk létrehozni.

tv_tvjav_tidy <- tv_tvjav_minta %>%
  pivot_longer(
    "jaccard_index":"koszinusz",
    values_to = "hasonlosag",
    names_to = "hasonlosag_tipus" 
    )


glimpse(tv_tvjav_tidy)
#> Rows: 1,200
#> Columns: 9
#> $ tv_id            <chr> "1994LXXXII", "1994LXXXII", "1996CXXXI", "1996CXXXI", "1995CXIV", "1995CXIV", "1995XC", "1995XC", "1996LX…
#> $ torveny_szoveg   <chr> "évi lxxxii törvény a magánszemélyek jövedelemadójáról szóló évi xc törvény módosításáról lábjegyzet a ma…
#> $ korm_ciklus      <chr> "1994-1998", "1994-1998", "1994-1998", "1994-1998", "1994-1998", "1994-1998", "1994-1998", "1994-1998", "…
#> $ ev               <dbl> 1994, 1994, 1996, 1996, 1995, 1995, 1995, 1995, 1996, 1996, 1997, 1997, 1994, 1994, 1997, 1997, 1996, 199…
#> $ korm_ell         <dbl> 900, 900, 900, 900, 900, 900, 900, 900, 900, 900, 900, 900, 900, 900, 900, 900, 900, 900, 900, 900, 900, …
#> $ tvjav_id         <chr> "1994-1998_T0233", "1994-1998_T0233", "1994-1998_T3167", "1994-1998_T3167", "1994-1998_T1538", "1994-1998…
#> $ tvjav_szoveg     <chr> "magyar köztársaság kormánya t számú törvényjavaslat a magánszemélyek jövedelemadójáról szóló törvény mód…
#> $ hasonlosag_tipus <chr> "jaccard_index", "koszinusz", "jaccard_index", "koszinusz", "jaccard_index", "koszinusz", "jaccard_index"…
#> $ hasonlosag       <dbl> 0.554, 0.624, 0.572, 0.929, 0.729, 0.985, 0.572, 0.961, 0.627, 0.753, 0.698, 0.978, 0.520, 0.695, 0.711, …
jaccard_koszinusz_plot <- ggplot(tv_tvjav_tidy, aes(ev, hasonlosag)) +
  geom_jitter(aes(shape = hasonlosag_tipus,color = hasonlosag_tipus), width = 0.1, alpha = 0.45) +
  scale_x_continuous(breaks = seq(1994, 2018, by = 2)) + 
  labs(
    y = "Jaccard és koszinusz-hasonlóság", 
    shape = NULL, 
    color = NULL, 
    x = NULL
    ) +
  theme(legend.position = "bottom",
        panel.grid.major.x = element_blank(),
        panel.grid.minor.x = element_blank() )

ggplotly(jaccard_koszinusz_plot)

Ábra 10.3: Koszinusz- és Jaccard hasonlóság értékeinek alakulása

A pont-diagramm ebben a formájában keveset árul el a számunkra, láthatjuk, hogy a koszinusz értékek általában magasabbak, de azt nem tudjuk meg mondani az egyes pontok alapján, hogy évről évre hogyan alakultak a jaccard és koszinusz értékek. Ezért a hasonlósági értékek évenkénti alakulásának megértése érdekében érdemes átlagot számolni a mutatókra. Ezt a group_by() és a summarize() függvények együttes alkalmazásával tehetjük meg. Megadjuk, hogy évenkénti bontásban szeretnénk a számításainkat elvégezni group_by(ev), és azt, hogy átlagszámítást szeretnénk végezni mean().

evenkenti_atlag <- tv_tvjav_tidy %>% 
  group_by(ev, hasonlosag_tipus) %>% 
  summarize(atl_hasonlosag = mean(hasonlosag))

head(evenkenti_atlag)
#> # A tibble: 6 × 3
#> # Groups:   ev [3]
#>      ev hasonlosag_tipus atl_hasonlosag
#>   <dbl> <chr>                     <dbl>
#> 1  1994 jaccard_index             0.556
#> 2  1994 koszinusz                 0.758
#> 3  1995 jaccard_index             0.499
#> 4  1995 koszinusz                 0.777
#> 5  1996 jaccard_index             0.675
#> 6  1996 koszinusz                 0.887

Az évenkénti átlagot tartalmazó adattáblánkra ezt követően vonal diagramot illesztünk. A 10.4.-es ábrán az látható, hogy a Jaccard- és koszinusz-hasonlóság értéke nagyjából együtt mozog, leszámítva a 2002-es, 2003-as éveket, mivel azonban itt csak néhány adatpont áll rendelkezésre, ettől az inkonzisztenciától eltekinthetünk.

jac_cos_ave_df <- ggplot(evenkenti_atlag, aes(ev, atl_hasonlosag)) +
  geom_line(aes(linetype = hasonlosag_tipus)) +
  labs(y = "Átlagos Jaccard- és koszinusz-hasonlóság", 
       linetype = NULL, 
       x = NULL) +
  theme(
    legend.position = "bottom",
    panel.grid.major.x = element_blank(),
    panel.grid.minor.x = element_blank() 
    )

ggplotly(jac_cos_ave_df)

Ábra 10.4: Koszinusz- és Jaccard hasonlóság vonal diagramon ábrázolva

Ahhoz, hogy valamivel pontosabb képet kapjunk a Jaccard-index értékének alakulásáról, az eredményeinket érdemes vizualizálni a ggplot2 segítségével. Elsőként évenkénti bontásban, boxplotokkal ábrázoljuk a Jaccard-index értékének alakulását (ld. 10.5. ábra). Így már nem csak azt láthatjuk, hogy évente mennyi volt az átlagos jaccard érték, hanem azt is, hogy az átlagtól mekkora eltérések voltak jelen, ez utóbbit természetesen azt is befolyásolja, hogy hány törvényt fogadtak el az adott évben.

jac_cos_box_df <- ggplot(tv_tvjav_minta, aes(as.factor(ev), jaccard_index)) +
  geom_boxplot() +
  labs(
    x = NULL,
    y = "Jaccard-hasonlóság" ) +
  theme(
    axis.text.x = element_text(angle = 45),
    legend.position = "none"
    )

ggplotly(jac_cos_box_df)

Ábra 10.5: Jaccard hasonlóság boxplotokkal ábrázolva

A legalacsonyabb Jaccard-hasonlóság talán az 1994–1998-as időszakra jellemző, míg a 2014–2018-as időszakra szembetűnően magas Jaccard-értékeket látunk az első ábrázolt ciklushoz képest. Összességében nehéz trendet látni az ábrán, de érdemes azt is megjegyezni, hogy a negatív irányba kiugró adatpontok a 2014–2018-as ciklusban jelentősen nagyobb arányban tűnnek fel, mint a korábbi kormányzati ciklusok alatt.

Végezetül pedig ábrázolhatjuk a Jaccard-hasonlóságot a benyújtó személye alapján is a korm_ell változónk alapján. A változók értékei a következők a CAP kódkönyve alapján:

0 - Ellenzéki benyújtó

1 - Kormánypárti benyújtó

2 - Kormánypárti és ellenzéki benyújtó közösen

3 - Benyújtók legalább két ellenzéki pártból

4 - Benyújtók legalább két kormánypártból

900 - Nem releváns – a benyújtó a kabinet tagja

901 - Nem releváns – a benyújtó a bizottság tagja

902 - Nem releváns – a benyújtó sem a parlamentnek, sem a kabinetnek, sem a bizottságnak nem tagja

Mivel nincs túl sok adatpontunk, és ezek többsége a 900-as adatpont alá esik (lásd tv_tvjav_minta %>% count(korm_ell)), érdemes összevonni a 0-ás és a 3-as változót, valamint az 1-es és a 4-es változót egy-egy értékbe, hogy jobban elemezhetőek legyenek az eredményeink. Ehhez a korm_ell változó értékei alapján definiálunk egy új korm_ell2 változót. Az új változó definiálását és az értékadásokat a dplyr case_when() függvényével fogjuk megtenni. A függvényen belül a bal oldalra kerül, hogy milyen értékek alapján szeretnénk az új értéket meghatározni, a tilde (~) után pedig az, hogy mi legyen az újonnan létrehozott oszlop értéke. Tehát a case_when()-en belül lévő első sor azt fejezi ki, hogy amennyiben a korm_ell egyenlő 0-val, vagy (|) a korm_ell egyenlő 3-mal, legyen a korm_ell2 értéke 0.

tv_tvjav_minta <- tv_tvjav_minta %>%
  mutate(
    korm_ell2 = case_when(
      korm_ell == 0 | korm_ell == 3 ~ "Ellenzéki képviselő",
      korm_ell == 1 | korm_ell == 4 ~ "Kormánypárti képviselő",
      korm_ell == 2 ~ "Kormánypárti és ellenzéki képviselők közösen",
      korm_ell == 900 ~ "Kabinet tagja",
      korm_ell == 901 ~ "Parlamenti bizottság",
      korm_ell == 902 ~ "Egyik sem"
    ),
  korm_ell2 = as.factor(korm_ell2)
  )

Miután létrehoztuk az új oszlopot, létrehozhatjuk a vizualizációt is annak alapján. Itt egy speciális pontdiagramot fogunk használni: geom_jitter(). Ez annyiban különbözik a pontdiagramtól, hogy kicsit szórtabban ábrázolja a diszkrét értékekre (évekre) eső pontokat, hogy az egy helyen sűrűsödő értékek ne takarják ki egymást. A facet_wrap segítségével tudjuk kategóriánként ábrázolni az évenkénti hasonlóságot (ld. 10.6. ábra).

jaccard_be_plot <- ggplot(tv_tvjav_minta, aes(ev, jaccard_index)) +
  geom_jitter(width = 0.1, alpha = 0.5) +
  scale_x_continuous(breaks = seq(1994, 2018, by = 2)) +
  facet_wrap(~ korm_ell2, ncol = 1) +
  theme(panel.spacing = unit(2, "lines"))+
  labs(
    y = NULL,
    x = NULL)

ggplotly(jaccard_be_plot, height = 1000)

Ábra 10.6: Jaccard hasonlóság a benyújtó személye alapján

Mivel a törvényjavaslatok túlnyomó többségét a kabinet tagjai nyújtják be, nem igazán tudunk érdemi következtetéseket levonni arra vonatkozóan, hogy az ellenzéki vagy a kormánypárti képviselők által benyújtott javaslatok módosulnak-e többet a vita folyamán. Amennyiben ezzel a kérdéssel alaposabban is szeretnénk foglalkozni, érdemes csak azokat a sorokat kiválasztani a hasonlóságszámításhoz, amelyekben a számunkra releváns megfigyelések szerepelnek. Ha azonban ezt az eljárást választjuk, mindenképpen fontos odafigyelni arra is, hogy az elemzésben használandó megfigyelések kiválogatása nehogy szelektív legyen valamely nem megfigyelt változó szempontjából, ezzel befolyásolva a kutatás eredményeit.