Grafos e relações semânticas na linguagem natural

Análise de um dataset de terminologia de dados explorando três bibliotecas de visualização

Por

Juno Takano

Última atualização

14 de dezembro de 2023

Visão geral das relações entre os termos do Multilingual Data Stewardship Terminology

Introdução

O universo do desenvolvimento de software é saturado de termos carregados de importância mas com vidas muito curtas, ou onde é difícil enxergar a diferença entre relevância e ruído. Isso impacta decisões — pessoais ou institucionais — sobre onde investir recursos ao aprender, pesquisar e ensinar tecnologias.

Como conseguir analisar e representar visualmente a relevância de elementos de uma amostra que muda tão rapidamente? E será que ela muda tão rapidamente? Onde é a superfície e onde está o espaço mais profundo desses campos semânticos?

Sem intenção de dar uma resposta definitiva mas com interesse em explorar ferramentas, optei por uma perspectiva iterativa e que pudesse ser facilmente documentada e reproduzida.

Para isso, escolhi como ferramenta principal a linguagem R e o sistema de publicação Quarto. Dessa maneira, foi possível usar um só ambiente para fazer a importação, limpeza, análise, visualização e ainda a exportação desse processo para uma página web apresentável.

O dataset utilizado foi o SSHOC Multilingual Data Stewardship Terminology, um conjunto de definições ligadas à ideia de Data Stewardship, que pode ser traduzida como gestão ou administração de dados.

O conjunto é resultado do trabalho de Francesca Frontini, Federica Gamba, Monica Monachini e Daan Broeder do Instituto de Linguística Computacional A. Zampolli, pelo Conselho Nacional de Pesquisa da Itália e traz 211 termos nos idiomas inglês, holandês, francês, alemão, italiano, grego e esloveno.

Entre os termos mais relevantes, segundo as métricas desenvolvidas, destacaram-se (em tradução livre) metadado, conjunto de dados, dados de pesquisa, sujeito de dados, dados pessoais e dados brutos. Continue lendo se deseja saber mais sobre os resultados do estudo.

Atalho

Abaixo você encontra um relatório técnico detalhado, com todo o código executado para processar e analisar o conjunto de dados.

Se prefere ler somente as conclusões resumidas e acionáveis, salte para o final. Se quiser ver apenas as visualizações mais relevantes, pode começar pela categorização ou ir diretamente para a seção 5.

1. Perguntar

Objetivo Representar visualmente as relações entre diferentes termos técnicos da área de dados
Problema Determinar quais termos são mais relevantes
Métricas Menções de cada termo por um termo diferente do dataset
Público Profissionais de PLN, pessoas treinadoras, produtoras de materiais e estudantes da área de dados
Dados SSHOC Multilingual Data Stewardship Terminology

Conjunto de dados utilizado:

Frontini, Francesca; Gamba, Federica; Monachini, Monica and Broeder, Daan, 2021, SSHOC Multilingual Data Stewardship Terminology, ILC-CNR for CLARIN-IT repository hosted at Institute for Computational Linguistics “A. Zampolli”, National Research Council, in Pisa, http://hdl.handle.net/20.500.11752/ILC-567.

Para quantificar a relevância de um termo em relação aos demais, foi usada uma métrica aqui chamada de popularidade.

O grafo é uma rede de elementos (chamados também nós ou nodes) que estão conectados por ligações (chamadas também de links ou edges).

Um grafo com oito nós, cada um representado por círculos preenchidos ligados por linhas. Dois nós centrais, com as legendas 'data object' e 'data structure', são representados por um círculo maior. Os demais nós são representados por dois tamanhos de círculos menores de acordo com a quantidade de ligações que recebem: uma ou nenhuma. 'Data description' e 'unstructured data' fazem ligações com 'data structure'. 'non-personal data' e 'data discoverability' fazem ligação com 'data object', que por sua ve faz ligação com 'data structure'. O nó 'data findability' faz ligação com 'data discoverability' e recebe uma ligação de 'findable data'.

Exemplo de um grafo simples.

No exemplo de grafo acima, o nó data structure recebe três ligações e tem portanto um valor de popularidade 3. O nó data object, por sua vez, recebe apenas duas ligações e tem popularidade 2.

Trata-se da quantidade de vezes que um determinado nó (cada elemento da rede de conexões) recebe uma conexão. Para esse estudo, uma conexão representa uma menção daquele termo na definição de outro.

Por exemplo, vemos abaixo a definição de data security:

“Result of the data protection measures taken to guarantee data integrity.”

Ao mencionar tanto data protection quanto data integrity, as relações são estabelecidas através do mapeamento feito usando as ferramentas da linguagem R.

Para esse estudo, não foram considerados termos no plural nem suas diferentes formas decompostas, apenas as junções exatas do termo. Contudo, foram levadas em conta todos os termos alternativos do conjunto de dados, na forma das AltLabels.

Isso significa que um termo pode ter mais de um nome, como por exemplo ocorre com o termo data publication. Ele tem as AltLabels data publishing e publication of data. Qualquer uma delas, se mencionada na definição de outro termo, estabelece uma relação.

Para grifar essa diferença, ao longo do estudo foram utilizadas diferentes cores ou formatos de linha para representar as diferentes relações entre cada termo.

2. Preparar

Nesta etapa, os dados são carregados e preparados para o processamento.

O arquivo de dados original é disponibilizado no formato csv e tem uma estrutura de dados longa. Isso significa que um mesmo ID se repete em diferentes linhas. Será preciso converter essa estrutura para uma forma ampla para que os dados possam ser manipulados e plotados.

A preparação envolve ainda observar a confiabilidade dos dados. No conjunto original é possível ver que há múltiplas fontes possíveis para os dados. Na demo oficial do projeto também é possível ver o cruzamento das informações em um formato mais legível.

Os dados não possuem dados pessoais para serem anonimizados e usa a licença pública Creative Commons - Attribution 4.0 International (CC BY 4.0).

Carregamento

Para começar a trabalhar de fato com os dados, começamos carregando as bibliotecas que serão usadas na etapa de limpeza:

library(tidyverse)
── Attaching packages ─────────────────────────────────────── tidyverse 1.3.2 ──
✔ ggplot2 3.4.0      ✔ purrr   0.3.5 
✔ tibble  3.1.8      ✔ dplyr   1.0.10
✔ tidyr   1.2.1      ✔ stringr 1.5.0 
✔ readr   2.1.3      ✔ forcats 0.5.2 
── Conflicts ────────────────────────────────────────── tidyverse_conflicts() ──
✖ dplyr::filter() masks stats::filter()
✖ dplyr::lag()    masks stats::lag()
library(janitor)

Attaching package: 'janitor'

The following objects are masked from 'package:stats':

    chisq.test, fisher.test

Usaremos a sigla MDST para nos referimos ao dataset. Ela representa o seu nome completo, Multilingual Data Stewardship Terminology.

mdst <- read_delim("data/SSHOC/terminology.csv", delim=";")
New names:
Rows: 490 Columns: 17
── Column specification
──────────────────────────────────────────────────────── Delimiter: ";" chr
(17): ...1, ...2, ...3, ...4, Translations, ...6, ...7, ...8, ...9, ...1...
ℹ Use `spec()` to retrieve the full column specification for this data. ℹ
Specify the column types or set `show_col_types = FALSE` to quiet this message.
• `` -> `...1`
• `` -> `...2`
• `` -> `...3`
• `` -> `...4`
• `` -> `...6`
• `` -> `...7`
• `` -> `...8`
• `` -> `...9`
• `` -> `...10`
• `` -> `...12`
• `` -> `...13`
• `` -> `...14`
• `` -> `...15`
• `` -> `...16`
• `` -> `...17`

Pela saída da importação podemos ver que os nomes originais das colunas, por estarem distribuídos em duas linhas, não podem ser lidos automaticamente.

Antes de prosseguir podemos verificar por problemas na importação:

problems(mdst)

E verificar os tipos de dados:

spec(mdst)
cols(
  ...1 = col_character(),
  ...2 = col_character(),
  ...3 = col_character(),
  ...4 = col_character(),
  Translations = col_character(),
  ...6 = col_character(),
  ...7 = col_character(),
  ...8 = col_character(),
  ...9 = col_character(),
  ...10 = col_character(),
  Linking = col_character(),
  ...12 = col_character(),
  ...13 = col_character(),
  ...14 = col_character(),
  ...15 = col_character(),
  ...16 = col_character(),
  ...17 = col_character()
)

Todas as variáveis são do tipo character.

Vamos converter para um dataframe:

mdst = data.frame(mdst)

E agora temos o conjunto carregado:

mdst %>% 
  filter(
    grepl("data stewardship", ...3, ignore.case = TRUE)
  )

Acima, vemos uma busca pela definição do conceito de “data stewardship”.

Data stewardship can be defined as the tasks and responsibilities that relate to the management, sharing, and preservation of research data throughout the research lifecycle and beyond.

Em tradução livre:

Gestão de dados pode ser definida como o conjunto de tarefas e responsabilidades que se relacionam à gestão, compartilhamento, e preservação de dados de pesquisa ao longo do ciclo de pesquisa e além dele.

3. Processar

Com os dados carregados, podemos dar início à limpeza.

Tentando usar o conhecimento obtido ao longo da certificação, optei por utilizar apenas a linguagem R para fazer todo o processo.

Além da limpeza, serão realizados aqui alguns processos de validação para verificar se os dados realmente estão limpos e se não há informações duplicadas ou IDs faltando, já que na etapa de análise será necessário criar métricas que dependem de uma sequência precisa e ininterrupta de IDs.

Limpeza

Cabeçalhos

Os cabeçalhos das colunas vieram em duas colunas separadas, sendo portanto importados incorretamente por padrão.

colnames(mdst)
 [1] "...1"         "...2"         "...3"         "...4"         "Translations"
 [6] "...6"         "...7"         "...8"         "...9"         "...10"       
[11] "Linking"      "...12"        "...13"        "...14"        "...15"       
[16] "...16"        "...17"       

Vamos renomear as colunas usando a função clean_names do pacote janitor:

mdst_cl <- mdst %>%
  row_to_names(row_number = 1) %>% 
  clean_names() %>% 
  rename(type = na, source = source_of_definition)

Duas colunas precisaram ainda ser nomeadas manualmente.

Agora temos essas colunas:

colnames(mdst_cl)
 [1] "concept_id"                     "type"                          
 [3] "term"                           "source"                        
 [5] "dutch"                          "french"                        
 [7] "german"                         "greek"                         
 [9] "italian"                        "slovenian"                     
[11] "loterre_open_science_thesaurus" "terms4fair_skills"             
[13] "ccr_metadata"                   "linked_open_vocabularies"      
[15] "lov_2"                          "iso"                           
[17] "broader_concept"               

Padronização

Existem valores “AltLabel” e “altLabel” que poderiam ser padronizados para AltLabel:

unique(mdst_cl$type)
[1] "PrefLabel"  "Definition" "AltLabel"   "altLabel"  

Para substituir como “AltLabel”:

mdst_cl$type <- str_replace(mdst_cl$type, "altLabel", "AltLabel")

E podemos verificar se a quantidade de “altLabel” agora é zero:

mdst_cl %>% 
  filter(type == "altLabel") %>% 
  nrow() == 0 # TRUE
[1] TRUE

Vamos testar se não houve erros na substituição somando os valores únicos:

local({
  lbls <- c("AltLabel" = 0, "altLabel " = 0,
            "Definition" = 0, "PrefLabel" = 0)
  
  for (i in 1:4) {
    lbls[i] <- lbls[i] + mdst_cl %>% 
      filter(type == names(lbls)[i]) %>% 
      nrow()
  }
  
  lbls; sum(lbls) == nrow(mdst_cl) # TRUE
})
[1] TRUE

O código acima soma a quantidade de ocorrências de cada uma das quatro palavras. Um resultado TRUE significa que o total é igual ao total de linhas no dataframe (atualmente, 489).

Podemos agora separar as colunas usando apenas ID, tipo e descrição para a análise.

mdst_fl <- mdst_cl %>% 
  select(concept_id, type, term)

Por fim, vamos converter todas as colunas para letras minúsculas, exceto a coluna type:

mdst_fl <- mdst_fl %>%
  mutate(concept_id = tolower(concept_id)) %>% 
  mutate(term = tolower(term))

Pivoting

A seguir é preciso converter do formato longo para amplo.

Os dados estão estruturados da seguinte forma:

concept_id type term
A PrefLabel PrefLabel_A
NA Definition Definition_A
B PrefLabel PrefLabel_B
NA Definition Definition_B

Ou seja, cada ID pode ter informações em mais de uma linha, que são definididas pela coluna “type”.

Há ainda casos com três tipos, que incluem uma ou várias AltLabels:

concept_id type term
B PrefLabel PrefLabel_B
NA Definition Definition_B
C PrefLabel PrefLabel_C
NA AltLabel AltLabel_C
NA Definition Definition_C

Para fazer a conversão em um formato amplo, primeiro vamos preencher os valores NA da coluna concept_id com o último valor acima de cada NA:

mdst_fl <- mdst_fl %>%
  fill(names(mdst_fl), .direction = "down")

Agora temos ainda o formato longo, mas os valores nulos (NA) foram preenchidos com os respectivos IDs:

head(mdst_fl, 10)

Não resta nenhum valor NA:

colSums(is.na(mdst_fl))
concept_id       type       term 
         0          0          0 

Agora temos uma estrutura assim:

concept_id type term
B PrefLabel PrefLabel_B
B Definition Definition_B
C PrefLabel PrefLabel_C
C AltLabel AltLabel_C
C Definition Definition_C

O próximo passo é separar os tipos PrefLabel, Definition e AltLabel em suas próprias colunas.

Podemos fazer isso com a função pivot_wider:

mdst_pv <- mdst_fl %>% 
  pivot_wider(names_from = type, values_from = term, values_fn = list) %>% 
  mutate(PrefLabel = as.character(PrefLabel)) %>% 
  unnest_wider(AltLabel, names_sep = "_")

Para que o pivoting com pivot_wider() una as linhas com conteúdos diferentes mas categorias iguais (na coluna type) usa-se a instrução unnest(), que separa essas linhas novamente. Do contrário, haveria a perda de dados.

Este post no rdrr.io foi essencial para compreender melhor a solução.

O seguinte erro era gerado sem ela:

Warning: Values from `term` are not uniquely identified; output will contain list-cols.
* Use `values_fn = list` to suppress this warning.
* Use `values_fn = {summary_fun}` to summarise duplicates.
* Use the following dplyr code to identify duplicates.
  {data} %>%
    dplyr::group_by(concept_id, type) %>%
    dplyr::summarise(n = dplyr::n(), .groups = "drop") %>%
    dplyr::filter(n > 1L)

O que isso indica é que valores de coluna duplicados são, por padrão, unidos em listas para que não sejam perdidos na hora de juntar as linhas por seus IDs.

Agora vamos converter de volta para um dataframe:

mdst_pv <- data.frame(mdst_pv)

E finalmente temos um único concept_id por observação:

length(unique(mdst_pv$concept_id))== mdst_fl %>% select(concept_id) %>% unique() %>% nrow()
[1] TRUE

Para ficar mais fácil de entender a tabela, vamos reordenar as colunas:

mdst_pv <- mdst_pv %>% 
  select(concept_id, PrefLabel, Definition, AltLabel_1, AltLabel_2, AltLabel_3)

Deduplicação

Agora que a maioria das partes duplicadas foi removida, é possível focar no restante. Vamos ver quantos itens únicos temos na coluna Definition:

length(unique(mdst_pv$Definition))
[1] 209

209 é muito mais próximo do número de definições mostrado nos metadados, 211.

A quantidade de IDs únicos é 209:

length(unique(mdst_pv$concept_id))
[1] 209

Esse valor é o mesmo antes e depois da mudança para uma estrutura ampla:

length(unique(mdst_pv$concept_id)) == length(unique(mdst_fl$concept_id))
[1] TRUE

Agora vamos contar os valores únicos de cada coluna:

mdst_pv %>%
  summarize_all(n_distinct) %>% 
  mutate(AltLabel_total = sum(AltLabel_1, AltLabel_2, AltLabel_3))

Nos metadados do conjunto há a informação de que são 210 conceitos. A página online informa 211 conceitos.

Abaixo foi feito um cruzamento dos dados em seu estado atual com os totais exibidos na página do SSHOC:

Coluna Original Atual Diferença
concept_id 211 209 2
PrefLabel 211 209 2
Definition 211 209 2
AltLabel 61 73 -12

No nosso data frame atual temos 209 IDs, 209 PrefLabel e 209 definições, todos valores únicos. Faltam dois itens para cada.

O maior problema ainda são as AltLabels. Temos 70 AltLabels, o que significa que temos 12 AltLabels a mais. O que encontramos até agora removeria apenas duas AltLabels, deixando ainda outras 7. É possível que haja mais duplicações entre as colunas que foram separadas.

Se olharmos o último ID, ele é “version_control_211”:

tail(mdst_fl, 1)

Observando os dados para tentar entender a diferença entre os 211 IDs e as 209 observações, percebi que o número das linhas não batia com o dos IDs.

Por exemplo, os IDs 170 e 171 são pulados:

mdst_pv[168:173, ]

Olhando no conjunto original, eles também não constam:

mdst %>% 
  mutate(row = row_number()) %>% 
  filter(...1 == "orphan_data_169") %>% 
  select(...1, row)
mdst[396:410, ]

Parece não existir uma PrefLabel nem um concept_id para a AltLabel “PID”.

Escrevi primeiro um teste que encontra as linhas que não batem com os seus IDs:

Para isso criei uma nova coluna com apenas o número do ID:

mdst_pv <- mdst_pv %>% 
  mutate(id_no = str_replace(concept_id, "^.*_", "")) %>% 
  mutate(id_no = as.integer(id_no))

E usei o somatório de 1 a 211 junto com um loop for para verificar se os números estão em sequência e não são duplicados. Isso também facilita encontrar exatamente onde está o erro:

for (i in 1:211) {
  if (i != mdst_pv$id_no[i]) {
    print(
      paste(i, "!=", mdst_pv$id_no[i])
      )
    break
  }
}
[1] "170 != 172"
rm(i); paste("Diferença:", sum(1:211) - sum(mdst_pv$id_no))
[1] "Diferença: 341"

Isso nos leva de novo à linha 170:

mdst_pv[168:172,]

Aqui parece ter faltado um concept_id para a entrada referente a “PID” e há ainda uma entrada faltando após essa, que seria do ID 171. Isso teve algumas consequências:

  • O campo Definition do ID orphan_data_169 ficou com sua própria definição e a de PID juntas
  • “PID” ficou como a AltLabel do ID orphan_data_169.

Inserção

Para alterar os dados da coluna Definition — atualmente do tipo lista devido a essa entrada duplicada — será preciso tipá-la como character:

mdst_pv <- mdst_pv %>% 
  mutate(Definition = as.character(Definition))

E agora vamos adicionar o ID personal_identifier_170 para essa observação e as informações do ID 171 encontradas na página web do projeto:

mdst_ad <- mdst_pv %>% 
  add_row(tibble_row(
    concept_id = "persistent_identification_170", PrefLabel = "persistent identification", Definition = "the act of identifying a resource via its persistent identifier.", id_no = as.integer(170)), .after = 169) %>% 
  add_row(tibble_row(
    concept_id = "persistent_identifier_171", PrefLabel = "persistent identifier", Definition = "a unique and stable denomination (reference) of a digital resource (e.g. research data) through allocation of a code that can be persistently and explicitly referenced on the internet.", AltLabel_1 = "pid", id_no = as.integer(171)), .after = 170) %>% 
  mutate(id_no = as.integer(id_no))

Conferindo se está tudo certo:

mdst_ad[170:171,]

Agora precisamos retirar a AltLabel e definição extras em orphan_data_169:

mdst_ad[169,] <- tibble_row(concept_id = "orphan_data_169", PrefLabel = "orphan data", Definition = "data that is not machine readable because the data exists with no identifiable computer application or system that can retrieve it, or the data is machine readable but does not have sufficient content, context or structure to render it understandable.", AltLabel_1 = NA, AltLabel_2 = NA, AltLabel_3 = NA, id_no = as.integer(169))

Vamos ver como ficou essa área novamente:

mdst_ad[168:172,]

Validação

Feitas essas inserções e transformações, podemos executar o teste criado anteriormente para validar que a sequência de IDs agora está correta.

Primeiro vamos reescrever a coluna id_no com os números da coluna concept_id, já que ela foi alterada:

mdst_ad <- mdst_ad  %>% 
  mutate(id_no = str_replace(concept_id, "^.*_", "")) %>% 
  mutate(id_no = as.integer(id_no))

E então executar o teste:

for (i in 1:211) {
  if (i != mdst_ad$id_no[i]) {
    print(
      paste("Erro no índice", i, "id_no:", mdst_ad$id_no[i], "ID:", mdst_ad$concept_id)
      )
    break
  }    
}

rm(i); paste("Diferença:", sum(1:211) - sum(mdst_ad$id_no)) # 0
[1] "Diferença: 0"

Com os números dos IDs agora iguais aos números de cada observação, podemos remover a coluna id_no:

mdst_ad <- select(mdst_ad, !id_no)

Mais deduplicação

Antes de fazer mais validações, vamos remover qualquer caractere de espaço que esteja sobrando ou duplicado:

mdst_adt <- mdst_ad %>% 
  mutate_if(is.character, trimws) %>% 
  mutate_if(is.character, gsub, pattern = "  ", replacement = " ")

Isso parece ter provocado nove alterações no dataframe:

table(mdst_adt == mdst_ad)

FALSE  TRUE 
    9   694 

Podemos encontrar em qual coluna elas estão com sapply():

as_tibble(mdst_adt == mdst_ad) %>% 
  sapply(table)
$concept_id

TRUE 
 211 

$PrefLabel

TRUE 
 211 

$Definition

FALSE  TRUE 
    9   202 

$AltLabel_1

TRUE 
  57 

$AltLabel_2

TRUE 
  12 

$AltLabel_3

TRUE 
   1 

Sabendo que todos se encontram na coluna Definition, vamos encontrar quais foram elas usando a função setdiff():

setdiff(mdst_adt$Definition, mdst_ad$Definition)
[1] "formal document that outlines how to handle research data both during your research and after the research project is completed."                                                                        
[2] "data modeling is the process of creating a visual representation of either a whole information system or parts of it to communicate connections between data points and structures."                     
[3] "the practice of making data available for reuse. this may be done, for example, by depositing the data in a repository, through data publication."                                                       
[4] "data stewardship can be defined as the tasks and responsibilities that relate to the management, sharing, and preservation of research data throughout the research lifecycle and beyond."               
[5] "data storage is the recording (storing) of information (data) in a storage medium."                                                                                                                      
[6] "hierarchical data is defined as a set of data items that are related to each other by hierarchical relationships. hierarchical relationships exist where one item of data is the parent of another item."
[7] "online, free of cost, accessible data that can be used, reused and distributed provided that the data source is attributed."                                                                             
[8] "the practice of making research data available for reuse. this may be done, for example, by depositing the research data in a repository, through research data publication."                            

A função retornou apenas oito resultados. Vamos inspecionar mais de perto o resultado anterior achando qual é o concept_id de cada uma das nove linhas encontradas:

mdst_adt_diff <- mdst_ad

mdst_adt_diff$Definition <- (mdst_adt$Definition == mdst_ad$Definition)

mdst_adt_diff %>% 
  filter(Definition == FALSE) %>% 
  select(concept_id, Definition)

Vemos aqui que o ID metadata_158 é o único a não ser encontrado na comparação com setdiff(mdst_adt$Definition, mdst_ad$Definition).

Podemos confirmar isso retirando o texto sem espaços extras do texto original e vendo se resta apenas um espaço.

O código a seguir usa a função mutate_all para criar uma tabela que mostra todas as colunas comparadas.

mdst_adc <- mdst_ad %>% 
  mutate_all(list(~ str_trim(., side = "both"),
                  ~ str_replace_all(., "  ", " ")))

A função mutate_all acima recebe uma lista de funções anônimas como argumento. O símbolo de fórmulas ~ faz com que o código fique mais limpo, evitando ter de declarar as funções como function .... Os pontos servem para capturar os argumentos passados pela mutate_all para cada observação do conjunto.

O resultado será um novo dataframe com novas colunas com o nome da função aplicada no final. Teremos portanto uma coluna “concept_id” com os dados originais, outra com “concept_id_str_trim”, e outra com “concept_id_replace_all” e assim por diante para todas as colunas.

Esse é nosso conjunto comparando todas as diferenças:

mdst_adc

Com base nesse conjunto, podemos tentar chegar às diferenças exatas entre as strings originais e suas versões limpas:

diff_final <- mdst_adc %>% 
  mutate(trim_diff = str_replace(Definition, fixed(Definition_str_trim), "")) %>% 
  mutate(replace_diff = str_replace(Definition, fixed(Definition_str_replace_all), "")) %>% 
  mutate(bools_diff = mdst_adt_diff$Definition) %>% 
  filter(bools_diff == FALSE) %>% 
  select(concept_id, trim_diff, replace_diff)

Através desse resultado podemos saber que cada linha diferente possuía um espaço em branco:

diff_final %>% 
  filter(trim_diff == " ")

E que não havia espaços duplicados:

diff_final %>% 
  filter(replace_diff == "")

Vamos verificar se há mais itens duplicados agora que retiramos espaços em excesso e desagregamos as listas:

mdst_adt %>%
  summarize_all(n_distinct) %>% 
  mutate(AltLabel_total = sum(AltLabel_1, AltLabel_2, AltLabel_3))

Os valores das AltLabels não se alteraram desde a primeira verificação. Os IDs ainda são únicos. Mas parece que ainda há uma definição duplicada.

Vamos encontrá-la criando um teste para achar valores duplicados:

dup_check <- function(df, col) {
  df %>% 
  select(all_of(col)) %>% 
  drop_na() %>% 
  duplicated() %>% 
  as_tibble() %>% 
  mutate(row_no = row_number()) %>% 
  filter(value == TRUE)
}

Essa função recebe um dataframe e uma coluna e cria um dataframe booleano com TRUE para duplicatas. Ela adiciona uma coluna com o número da linha correspondente e um filtro para retornar apenas os valores duplicados.

Vamos executar o teste na coluna Definition:

dup_check(mdst_adt, "Definition")

Ela indica que a linha 171 tem um erro de duplicação. Já trabalhamos nessa linha ao resolver a falta de informações. Essa descrição foi inserida manualmente nessa etapa, e está correta de acordo com o ID persistent_identifier_171.

Vamos então procurar qual é a sua duplicata:

mdst_adt %>% 
  filter(Definition == mdst_adt[171,]$Definition)

O ID metadata_158 está com a descrição incorreta. Ela é uma duplicação da definição do ID persistent_identifier_171.

Vamos verificar onde esse ID está no conjunto original para encontrar a origem do erro:

mdst %>% 
  mutate(row_no = row_number()) %>% 
  filter(...1 == "metadata_158") %>% 
  select(...1, row_no)

Acessando um recorte dessa linha, vemos que o erro já estava presente desde o dataset original.

mdst[368:380,1:3]

Ao contrário dos erros anteriores, na página oficial do projeto a informação também está duplicada. Usaremos como dados de proxy os de uma referência usada no próprio conjunto, a FRBR-aligned Bibliographic Ontology.

mdst_adt[158,]$Definition <- "a separate work that provides information describing one or more characteristics of a resource or entity."

Revalidação

Agora sim temos 211 entradas únicas para todas as variáveis:

mdst_adt %>%
  summarize_all(n_distinct) %>% 
  mutate(AltLabel_total = sum(AltLabel_1, AltLabel_2, AltLabel_3))

Quanto a duplicações entre as diferentes colunas de AltLabels, testes sucessivos como o demonstrado abaixo foram usados para encontrá-las também:

mdst_adt %>% 
  filter(AltLabel_1 == AltLabel_2) %>% 
  select(AltLabel_1, AltLabel_2) 
mdst_adt %>% 
  filter(AltLabel_2 == AltLabel_3) %>% 
  select(AltLabel_2, AltLabel_3) 
mdst_adt %>% 
  filter(AltLabel_1 == AltLabel_3) %>% 
  select(AltLabel_3, AltLabel_3)

Foi encontrada apenas uma duplicação das AltLabel_1 e AltLabel_2 “raw data”. Vamos encontrar a qual ID se referem:

mdst_adt %>% 
  filter(AltLabel_1 == "raw data")

E portanto remover a AltLabel_2 da linha 181:

mdst_adt[181,]$AltLabel_2 <- NA

Totais de duplicações por coluna:

lbls = c("concept_id" = 0, "AltLabel_1" = 0, "AltLabel_2" = 0, "AltLabel_3" = 0)

for (i in 1:4) {
  lbls[i] <- dup_check(mdst_adt, names(labels)[i]) %>% 
    nrow()
}

lbls; rm(lbls, i)
concept_id AltLabel_1 AltLabel_2 AltLabel_3 
         0          0          0          0 

Modelagem

Antes de mapear as relações, precisei entender qual era a estrutura que os dados deveriam usar.

Para isso estudei os exemplos e a documentação da primeira biblioteca, a ggraph, que utiliza alguns princípios da ggplot — abordados na certificação — para fazer visualizações de grafos:

library(ggplot2)
library(ggraph)
library(tidygraph)

Attaching package: 'tidygraph'
The following object is masked from 'package:stats':

    filter

Esse é o exemplo mais simples encontrado de um conjunto de dados compatível com a ggraph:

highschool

A documentação mostra como obter uma medida de popularidade:

graph <- as_tbl_graph(highschool) %>% 
  mutate(Popularity = centrality_degree(mode = "in"))

A estrutura fica dessa forma:

graph
# A tbl_graph: 70 nodes and 506 edges
#
# A directed multigraph with 1 component
#
# Node Data: 70 × 2 (active)
  name  Popularity
  <chr>      <dbl>
1 1              2
2 2              0
3 3              0
4 4              4
5 5              5
6 6              2
# … with 64 more rows
#
# Edge Data: 506 × 3
   from    to  year
  <int> <int> <dbl>
1     1    13  1957
2     1    14  1957
3     1    20  1957
# … with 503 more rows

Essa é a visualização do conjunto acima:

ggraph(graph, layout = "kk") +
  geom_edge_fan(aes(alpha = after_stat(index)), show.legend = FALSE)

Ela mostra apenas as ligações entre cada nó.

No exemplo abaixo, pontos são usados nos nós para representar a popularidade:

ggraph(graph, layout = "kk") +
  geom_edge_fan(aes(alpha = after_stat(index)), show.legend = FALSE) +
  geom_node_point(aes(size = Popularity))

E aqui o grafo é separado em duas facetas de acordo com a variável year:

ggraph(graph, layout = "kk") +
  geom_edge_fan(aes(alpha = after_stat(index)), show.legend = FALSE) +
  geom_node_point(aes(size = Popularity)) +
  facet_edges(~year) +
  theme_graph(foreground = "purple4", fg_text_colour = "white")

Como é difícil entender esse exemplo com tanta informação, decidi filtrar as 10 primeiras linhas do conjunto highschool.

rm(graph)

hs10 <- highschool %>% 
  head(10)

hs10_graph <- as_tbl_graph(hs10) %>% 
  mutate(Popularity = centrality_degree(mode = "in"))

Agora temos as seguintes amostras:

Dez primeiras linhas do conjunto highschool:

hs10

Estrutura:

str(hs10)
'data.frame':   10 obs. of  3 variables:
 $ from: num  1 1 1 1 1 2 2 3 3 4
 $ to  : num  14 15 21 54 55 21 22 9 15 5
 $ year: num  1957 1957 1957 1957 1957 ...

Todos os valores são numéricos.

Esse é o grafo dessas dez primeiras linhas, exibido como uma árvore:

hs10_graph
# A tbl_graph: 12 nodes and 10 edges
#
# A rooted forest with 2 trees
#
# Node Data: 12 × 2 (active)
  name  Popularity
  <chr>      <dbl>
1 1              0
2 2              0
3 3              0
4 4              0
5 14             1
6 15             2
# … with 6 more rows
#
# Edge Data: 10 × 3
   from    to  year
  <int> <int> <dbl>
1     1     5  1957
2     1     6  1957
3     1     7  1957
# … with 7 more rows

Ele tem essa estrutura:

str(hs10_graph)
Classes 'tbl_graph', 'igraph'  hidden list of 10
 $ : num 12
 $ : logi TRUE
 $ : num [1:10] 0 0 0 0 0 1 1 2 2 3
 $ : num [1:10] 4 5 6 7 8 6 9 10 5 11
 $ : num [1:10] 0 1 2 3 4 5 6 8 7 9
 $ : num [1:10] 0 1 8 2 5 3 4 6 7 9
 $ : num [1:13] 0 5 7 9 10 10 10 10 10 10 ...
 $ : num [1:13] 0 0 0 0 0 1 3 5 6 7 ...
 $ :List of 4
  ..$ : num [1:3] 1 0 1
  ..$ : Named list()
  ..$ :List of 2
  .. ..$ name      : chr [1:12] "1" "2" "3" "4" ...
  .. ..$ Popularity: Named num [1:12] 0 0 0 0 1 2 2 1 1 1 ...
  .. .. ..- attr(*, "names")= chr [1:12] "1" "2" "3" "4" ...
  ..$ :List of 1
  .. ..$ year: num [1:10] 1957 1957 1957 1957 1957 ...
 $ :<environment: 0x563dfaa17dc0> 
 - attr(*, "active")= chr "nodes"

Se plotarmos apenas essa amostra, temos o seguinte grafo:

ggraph(hs10_graph, layout = "kk") +
  geom_edge_fan(aes(alpha = after_stat(index)), show.legend = TRUE) +
  geom_node_point(aes(size = Popularity)) +
  facet_edges(~year) +
  theme_graph(foreground = "purple4", fg_text_colour = "white")

Analisando a coluna de popularidade, nesse ponto percebi que ela se refere à quantidade de vezes que um valor é mencionado na coluna “to”.

Se ordenarmos os nós pela popularidade, temos 15 e 21 no topo:

hs10_graph %>% 
  arrange(desc(Popularity))
# A tbl_graph: 12 nodes and 10 edges
#
# A rooted forest with 2 trees
#
# Node Data: 12 × 2 (active)
  name  Popularity
  <chr>      <dbl>
1 15             2
2 21             2
3 14             1
4 54             1
5 55             1
6 22             1
# … with 6 more rows
#
# Edge Data: 10 × 3
   from    to  year
  <int> <int> <dbl>
1     9     3  1957
2     9     1  1957
3     9     2  1957
# … with 7 more rows

Do lado direito, sozinhos, o ponto maior é 5, que recebe uma conexão de 4, e o menor é 4, que não recebe nenhuma conexão.

Isso também mostra que a parte transparente das linhas (edges) que conectam os nós é mais fraca na parte em que sai do nó. Isso é definido em geom_edge_fan(aes(alpha = after_stat(index))

Na página sobre Edges encontrei como tornar as linhas em setas e nomear os nós:

ggraph(hs10_graph, layout = "kk") + 
  geom_edge_link(aes(start_cap = label_rect(node1.name),
                     end_cap = label_rect(node2.name)), 
                 arrow = arrow(length = unit(4, "mm"))) + 
  geom_node_point(aes(size = Popularity), color = "gray") +
  geom_node_text(aes(label = name))

A visualização acima foi essencial para compreender a biblioteca através de um exemplo prático e reduzido, e confirmar as suspeitas descritas anteriormente.

A documentação sobre nodes da biblioteca ggpraph tem muitos exemplos interessantes. Para não perder tempo, porém, decidi primeiro transformar os dados do projeto para que pudesse fazer mais testes já usando eles.

Agora já temos uma ideia de que tipo de esquema precisamos para usar a estrutura de grafo. Ele parece ser centrado nas colunas “name” (um “ID”), “from” e “to” (de/para), que indicam as conexões de saída e entrada, e uma coluna de “popularidade” que mostra o total de menções daquele ID na coluna “para”.

Mapeamento

Podemos começar limpando o ambiente. Vamos trabalhar com o conjunto “termos” como o resultado da limpeza feita até aqui.

termos <- mdst_adt
rm(mdst_ad, mdst_adc, mdst_cl, mdst_fl, mdst_pv, dup_check, hs10_graph, hs10)

Para podermos plotar um grafo e observar relações, será necessário então ter uma estrutura similar a essa:

# A tibble: 6 × 2
   from    to
  <dbl> <dbl>
1     1    14
2     1    15
3     1    21
4     1    54
5     1    55
6     2    21

Mas para termos também a informação de que tipo de relação existe entre cada nó, seria preferível uma estrutura assim:

from to edge
1 14 PrefLabel
2 15 AltLabel1
3 21 AltLabel1
4 54 PrefLabel
5 55 AltLabel2
6 21 PrefLabel
7 34 PrefLabel
8 66 PrefLabel
9 11 AltLabel3
10 80 PrefLabel

A diferença entre PrefLabel e AltLabel é apenas uma possibilidade entre várias que podem ser usadas para determinar qual é a relação entre os dois termos. Neste caso, a diferença entre a AltLabel ser 1, 2 ou 3 é pouco relevante, então não será usada.

Outras relações poderiam ser relacionadas à semântica presente na frase, por exemplo, se o termo é mencionado na definição como “é” ou “não é”, como um “tipo de” ou “pertence a”, etc.

Aqui vamos utilizar um algoritmo simples que apenas conta a quantidade de menções de cada PrefLabel ou AltLabel e as adiciona em uma tabela de relações:

flowchart TB
    a( ) -->
    
    A{id = pair} -- T --> B[pair = pair + 1]
    B --> Ae( )
    A -- F --> Ae( )
    Ae( ) --> C{pair contém \nPrefLabel}
    C -- T --> D[adiciona a \nrelacoes]
    D --> Ce( )
    C -- F --> E{pair contém \nAltLabel}
    E -- T --> F[adiciona a \nrelacoes]
    E -- F --> Ee( )
    
    F --> Ee( )
    Ee --> Ce( )

    --> z( )

Por exemplo, o ID discovery_metadata_141 tem uma definição que começa com “Metadata that are used for the discovery of data”. Essa definição contém a PrefLabel “metadata” do ID metadata_158.

Isso criará uma relação com origem (from) em discovery_metadata_141 e chegada (to) em metadata_158 com a relação (edge) PrefLabel:

from to edge
141 158 PrefLabel

Vamos criar o dataframe relacoes com uma coluna para a relação de origem (from), de chegada (to) e uma coluna para saber qual é o tipo de relação (edge), optando apenas entre PrefLabel e AltLabel.

relacoes = data.frame(to = 0, from = 0, edge = "none")

str(relacoes)
'data.frame':   1 obs. of  3 variables:
 $ to  : num 0
 $ from: num 0
 $ edge: chr "none"

O código abaixo implementa o algoritmo mostrado acima.

Ele percorre nosso conjunto de dados e mapeia cada relação em uma nova linha, gravando o ID de origem e destino de cada menção de uma PrefLabel em uma Definition.

Código
matches <- 1
for (id in 1:nrow(termos)) {
  
  
  for (pair in 1:nrow(termos)) {
    
    if (id == pair) {
      pair <- pair + 1
    }
    else {
      if (str_detect(termos[pair,]$Definition, 
           paste0("\\b", termos[id,]$PrefLabel, "\\b")) == TRUE) {
        
        relacoes[matches,] <- c(id, pair, "PrefLabel")
        matches <- matches + 1
      }
      else if (is.na(termos[id,]$AltLabel_1) == FALSE &&
               str_detect(termos[pair,]$Definition, 
                paste0("\\b", termos[id,]$AltLabel_1, "\\b")) == TRUE) {
        
        relacoes[matches,] <- c(id, pair, "AltLabel")
        matches <- matches + 1
      }
      else if (is.na(termos[id,]$AltLabel_2) == FALSE &&
               str_detect(termos[pair,]$Definition,
                paste0("\\b", termos[id,]$AltLabel_2, "\\b")) == TRUE) {
        relacoes[matches,] <- c(id, pair, "AltLabel")
        matches <- matches + 1
      }
      else if (is.na(termos[id,]$AltLabel_3) == FALSE &&
               str_detect(termos[pair,]$Definition,
                paste0("\\b", termos[id,]$AltLabel_3, "\\b")) == TRUE) {
        relacoes[matches,] <- c(id, pair, "AltLabel")
        matches <- matches + 1
      }
    }
    
  }
}

paste("Matches:", matches)
[1] "Matches: 138"

Possivelmente existem soluções menos iterativas. Essa técnica não seria útil para um conjunto de dados muito grande.

Após o mapeamento, foram encontradas um total de 138 relações.

Terminada a manipulação como strings, vamos tipar os IDs como inteiros de novo:

relacoes <- relacoes %>% 
  mutate(across(c(1, 2), as.integer))

Entre as relações, a maioria acontece por PrefLabels:

Código
relacoes %>% 
  select(edge) %>% 
  ggplot(aes(x = edge, fill = edge)) +
  geom_bar() +
  geom_text(aes(label = after_stat(count)), stat = "count", vjust = 2, colour = "white") +
  theme(legend.position = "none")

Apenas 24 relações são AltLabels.

Uma vez com as relações mapeadas, podemos começar a estruturar os dados para plotagem:

library(ggplot2)
library(ggraph)
library(tidygraph)

Por fim é preciso criar um grafo com o tipo tbl_graph (do pacote tidygraph), que será passado às funções da biblioteca ggraph para gerar os grafos:

grafo <- as_tbl_graph(relacoes, nodes = termos, edges = relacoes) %>% 
  mutate(Popularity = centrality_degree(mode = "in")) %>% 
  mutate(Community = as.factor(group_infomap()))

Este grafo já incluirá informações de popularidade e comunidade.

Agora podemos começar a visualizar as relações entre os termos.

4. Analisar

Código
ggraph(grafo, layout = "kk") +
  geom_edge_fan(aes(alpha = after_stat(index)), show.legend = TRUE) +
  geom_node_point(aes(size = Popularity), color = "black") +
  theme_graph(foreground = "purple4", fg_text_colour = "white")

A primeira informação que logo vemos aqui é que há um aglomerado principal, do lado esquerdo, e apenas alguns aglomerados menores, unindo no máximo cinco termos.

Entre estes conjuntos menores temos um com 5 elementos, 4 com 3 elementos e outros 5 com 2 elementos cada.

Ao tentar adicionar os nomes de cada nó, percebi que a quantidade de texto é enorme e fica pior ainda se adicionamos os nomes das conexões:

Código
ggraph(grafo, layout = "kk") + 
  geom_edge_link(aes(),
                 arrow = arrow(length = unit(4, "mm")),
                 end_cap = circle(3, "mm")) +
  geom_edge_link(aes(start_cap = label_rect(node1.name),
                 end_cap = label_rect(node2.name)), 
                 arrow = arrow(length = unit(1, "mm"))) + 
  geom_node_point(aes(size = Popularity), color = "gray", show.legend = FALSE) +
  geom_node_text(aes(label = termos[name,]$PrefLabel))

Vamos usar cores para representar o tipo de conexão e ajustar o tamanho da fonte de acordo com a popularidade:

Código
ggraph(grafo, layout = "kk") + 
  geom_edge_density(aes(fill = edge), show.legend = FALSE) +
  geom_node_point(aes(colour = Community, size = Popularity), show.legend = FALSE) + 
  geom_edge_parallel(aes(color = edge),
                 arrow = arrow(length = unit(1, "mm")), 
                 start_cap = circle(3, "mm"),
                 end_cap = circle(3, "mm")) +
  geom_node_text(aes(label = termos[name,]$PrefLabel, size = 3 + Popularity),
                 alpha = 0.75) +
  theme(legend.title = element_blank(), legend.position = "bottom",
        legend.justification = "right", legend.direction = "horizontal") + 
  guides(size = "none")

A visualização acima usa um efeito de densidade para representar as áreas de acordo com relações por PrefLabel ou AltLabel. É possível ver aqui que as PrefLabels (azul) predominam por todo o grafo, à exceção da área ao redor do nó data set.

Vejamos quais são as AltLabels desse termo:

Código
termos %>% 
  filter(PrefLabel %in% c("data set")) %>% 
  select(AltLabel_1, AltLabel_2, AltLabel_3)

Na verdade, a única AltLabel para “data set” é “dataset”. Se observarmos mais de perto, podemos perceber que a maioria das relações para “data set” ocorrem por sua AltLabel e não pelo nome principal:

Código
grafo %>% 
  filter(Community %in% c(1, 2, 3)) %>% 

  ggraph(layout = "kk") + 
  
  geom_edge_fan(aes(color = edge, start_cap = label_rect(node1.name),
    end_cap = label_rect(node2.name)),
    edge_width = 0.25,
    arrow = arrow(length = unit(1, "mm")), 
    start_cap = circle(4, "mm"),
    end_cap = circle(4, "mm")) +
  
  geom_node_point(aes(colour = Community, size = Popularity), show.legend = FALSE) + 
  
  geom_node_text(aes(
    label = termos[name,]$PrefLabel,
    size = Popularity,
    alpha = Popularity),
    show.legend = FALSE,
    repel = TRUE) +
  theme(legend.title = element_blank(), legend.position = "bottom",
        legend.direction = "horizontal", legend.justification = "right") +
  facet_edges(~edge)

Vamos usar o geom geom_node_label no lugar de geom_node_text para ter um fundo branco em nossas labels e usar a propriedade repel = TRUE para evitar tantas sobreposições.

Código
ggraph(grafo, layout = "kk") + 
geom_edge_parallel(aes(color = edge, start_cap = label_rect(node1.name),
  end_cap = label_rect(node2.name)),
  arrow = arrow(length = unit(1, "mm")), 
  start_cap = circle(4, "mm"),
  end_cap = circle(4, "mm")) +
geom_node_point(aes(colour = Community, size = Popularity), show.legend = FALSE) + 
geom_node_label(aes(
  label = termos[name,]$PrefLabel,
  size = 3 + Popularity),
  show.legend = FALSE,
  repel = TRUE,
  alpha = 0.75) +
theme(legend.title = element_blank(), legend.position = "bottom", legend.justification = "right", legend.direction = "horizontal")

O resultado é bem mais legível, mas ainda não é apropriado para uma pequena área de visualização. Nesta página, estamos usando toda a área disponível e mesmo assim a visualização é difícil. O gráfico está poluído e não é possível discernir a que cada label se refere.

Vamos separar um pouco mais, retirando os conjuntos menores.

Clusterização

A visão geral pode ser interessante, mas é mais apropriada para visualizações interativas que possam ser arrastadas e usar de recursos como zoom e a exibição dinâmica dos nomes dos nós — ao tocar ou passar o mouse em cima, por exemplo. Esse processo será abordado na seção 5.

Para continuar a análise, vamos criar clusters, ou seja, conjuntos menores de nós onde poderemos focar.

As bibliotecas que lidam com estruturas de dados como grafos fornecem recursos úteis para manipular e segmentar esses dados, encontrando subgrupos dentro deles e separando suas relações. Um desses recursos é a possibilidade de automaticamente encontrar quais são as “comunidades” de nós, ou seja, quais nós possuem mais proximidade dentro da rede.

Vamos gerar um novo gráfico que exiba numericamente as comunidades nas labels:

Código
ggraph(grafo, layout = "kk") + 
  geom_edge_parallel(aes(color = edge, start_cap = label_rect(node1.name),
    end_cap = label_rect(node2.name)),
    arrow = arrow(length = unit(1, "mm")), 
    start_cap = circle(4, "mm"),
    end_cap = circle(4, "mm"),
    show.legend = FALSE) +
  geom_node_point(aes(colour = Community, size = Popularity), show.legend = FALSE) + 
  geom_node_label(aes(
    label = Community),
    show.legend = FALSE,
    repel = TRUE,
    alpha = 0.75)

Com base nessa vizualização podemos pensar em novos conjuntos clusterizados:

cluster_data_repository <- grafo %>% 
  filter(Community == 7)

Esse conjunto mostrará apenas o pequeno cluster de cinco nós, nomeado pelo seu nó mais popular, “data repository”:

Código
ggraph(cluster_data_repository, layout = "kk") + 
  
  geom_edge_parallel(aes(color = edge, start_cap = label_rect(node1.name),
    end_cap = label_rect(node2.name)),
    arrow = arrow(length = unit(1, "mm")), 
    start_cap = circle(4, "mm"),
    end_cap = circle(4, "mm")) +
  
  geom_node_point(aes(colour = Community, size = Popularity), show.legend = FALSE) + 
  
  geom_node_label(aes(
    label = termos[name,]$PrefLabel),
    show.legend = FALSE,
    repel = TRUE,
    alpha = 0.75) +
  theme(legend.title = element_blank(), legend.position = c(1,0.05),
        legend.direction = "horizontal", legend.justification = "right",
        legend.background = element_rect(fill = "transparent"),
        legend.box.background = element_rect(fill = "transparent", color = "transparent")) +
    facet_graph(~Community)

Agora vamos separar nosso cluster principal, onde está a maior parte da amostra:

cluster_principal <- grafo %>% 
  filter(Community %in% c(1:6, 8:10, 14, 15, 22))
Código
ggraph(cluster_principal, layout = "kk") + 
geom_edge_parallel(aes(color = edge, start_cap = label_rect(node1.name),
  end_cap = label_rect(node2.name)),
  arrow = arrow(length = unit(1, "mm")), 
  start_cap = circle(4, "mm"),
  end_cap = circle(4, "mm")) +
geom_node_point(aes(colour = Community, size = Popularity), show.legend = FALSE) + 
geom_node_label(aes(
  label = termos[name,]$PrefLabel,
  size = 5 + Popularity),
  show.legend = FALSE,
  repel = TRUE,
  alpha = 0.75) +
theme(legend.title = element_blank(), legend.position = "bottom",
      legend.justification = "right", legend.direction = "horizontal")

Para podermos segmentar mais ainda, vamos novamente visualizar as comunidades, dessa vez com o tamanho e a opacidade ajustadas por popularidade:

Código
ggraph(cluster_principal, layout = "kk") + 
  geom_edge_fan(aes(color = edge, start_cap = label_rect(node1.name),
    end_cap = label_rect(node2.name)),
    arrow = arrow(length = unit(1, "mm")), 
    start_cap = circle(4, "mm"),
    end_cap = circle(4, "mm")) +
  geom_node_point(aes(colour = Community, size = Popularity)) + 
  geom_node_text(aes(label = termos[name,]$PrefLabel, size = Popularity),
                 color = "darkgray", alpha = 1) +
  geom_node_label(aes(label = Community), repel = TRUE) +
  theme(legend.position = "none")

Análise da popularidade

Agora que temos as comunidades bem definidas visualmente, vamos incluir os valores de popularidade no conjunto principal para podermos extrair algumas informações sobre a métrica:

termos <- termos %>% 
  mutate(Popularity = 0) %>% 
  mutate(id = row_number())

for (obs in 1:nrow(termos)) {
  termos[obs,]$Popularity <- sum(relacoes$to == termos[obs,]$id)
}

rm(obs)

termos <- termos %>% 
  select(concept_id, Popularity, PrefLabel,
         Definition, AltLabel_1, AltLabel_2, AltLabel_3)

O conjunto termos agora inclui dados de popularidade. Podemos ver quais são os termos mais populares:

termos %>% 
  filter(Popularity > 0) %>% 
  arrange(desc(Popularity))

E podemos ver também quais são os níveis existentes de popularidade:

table(termos$Popularity)

  0   1   2   3   4   5   6   8  10  11  19 
159  29   8   5   3   1   2   1   1   1   1 

A grande maioria dos termos (159) tem popularidade zero. 29 termos recebem apenas uma ligação e 8 recebem duas. Os demais termos, que recebem 3 ou mais ligações, juntos são apenas 15.

Também podemos representar isso da seguinte forma:

Código
data.frame(popularidade = c("zero", "1", "2", "3 ou mais"),
           quantidade = c(159, 29, 8, 15)) %>%
  ggplot(aes(x = "", y = quantidade, fill = popularidade)) +
  geom_bar(stat = "identity", width = 1) +
  coord_polar("y", start = 0) +
  labs(title = "Popularidade dos termos", subtitle = "159 termos não recebem ligações.")

Podemos também saber qual é a popularidade média:

mean(termos$Popularity)
[1] 0.6492891

Ou a popularidade média entre termos com popularidade acima de zero:

termos %>% 
  filter(Popularity > 0) %>% 
  select(Popularity) %>% 
  summary()
   Popularity    
 Min.   : 1.000  
 1st Qu.: 1.000  
 Median : 1.000  
 Mean   : 2.635  
 3rd Qu.: 3.000  
 Max.   :19.000  

A popularidade média quando acima de zero é de 2.635.

Com base nesses dados, abaixo foi desenvolvida uma relação de quais seriam possíveis nomes para cada categoria a partir dos nós mais relevantes em cada comunidade.

A tabela está ordenada da maior à menor popularidade e limita-se à amostra do cluster principal.

Comunidade Popularidade
2 metadata 19
3 research data 11
1 data set 10
4 personal data 8
5 data management 6
2 data quality 6
6 raw data 5
2 data interoperability 4
8 data protection 4
3 persistent identifier 4
1 data accessibility 3
5 data collection 3
9 data structure 3
4 data subject 3
3 identifier 3
10 data findability 2

Categorização

Se você veio até aqui pelo atalho e gostaria de saber o que significam termos como e popularidade, pode encontrar uma explicação na seção 1.

Em nossa amostra principal estão contempladas todas as comunidades de 1 a 10, com exceção da comunidade 7, que não conecta-se ao cluster principal.

Além destas 9 comunidades, que são as maiores e mais populares, restam ainda três comunidades menores: 14, 15 e 22.

Vamos começar nossa viagem por elas.

Abaixo podemos ver as três separadamente:

Código
cluster_principal %>%
  filter(Community %in% c(14, 15, 22)) %>% 
  ggraph(layout = "kk") + 
  geom_edge_parallel(aes(color = edge, start_cap = label_rect(node1.name),
    end_cap = label_rect(node2.name)),
    arrow = arrow(length = unit(1, "mm")), 
    start_cap = circle(4, "mm"),
    end_cap = circle(4, "mm")) +
  geom_node_point(aes(colour = Community, size = Popularity), show.legend = FALSE) + 
  geom_node_text(aes(
    label = termos[name,]$PrefLabel, size = 4 + Popularity),
    show.legend = FALSE,
    repel = TRUE,
    alpha = 0.70,
    check_overlap = TRUE) +
  theme(legend.title = element_blank(), legend.position = "bottom",
        legend.direction = "horizontal", legend.justification = "right") +
    facet_graph(~Community)

As comunidades 14 e 22 estão bastante próximas no grafo, tendo como ponte o cluster research data:

Código
cluster_principal %>%
  filter(Community %in% c(14, 22, 3)) %>% 
  ggraph(layout = "kk") + 
  geom_edge_parallel(aes(color = edge, start_cap = label_rect(node1.name),
    end_cap = label_rect(node2.name)),
    arrow = arrow(length = unit(1, "mm")), 
    start_cap = circle(4, "mm"),
    end_cap = circle(4, "mm")) +
  geom_node_point(aes(colour = Community, size = Popularity), show.legend = TRUE) + 
  geom_node_text(aes(
    label = termos[name,]$PrefLabel, size = 4 + Popularity),
    show.legend = FALSE,
    repel = TRUE,
    alpha = 0.5) +
  theme(legend.title = element_blank(), legend.position = "bottom",
        legend.direction = "horizontal", legend.justification = "right")  +
  guides(size = "none")

Na visualização acima, podemos ver que a comunidade 14 tem como ponto mais externo “data governance”. Esse termo faz a ponte para os demais de sua comunidade.

O mesmo acontece com “data archiving”. Se este termo não fosse mencionado por “data lifecycle”, a comunidade seria outra das que ficaram excluídas do cluster principal.

Já a comunidade 15 está mais próxima da comunidade 2, metadata. Ela aparece abaixo em azul, na parte inferior do gráfico:

Código
cluster_principal %>%
  filter(Community %in% c(14, 22, 15, 2, 3)) %>% 
  ggraph(layout = "kk") + 
  geom_edge_parallel(aes(color = edge, start_cap = label_rect(node1.name),
    end_cap = label_rect(node2.name)),
    arrow = arrow(length = unit(1, "mm")), 
    start_cap = circle(4, "mm"),
    end_cap = circle(4, "mm")) +
  geom_node_point(aes(colour = Community, size = Popularity), show.legend = TRUE) + 
  geom_node_text(aes(
    label = termos[name,]$PrefLabel, size = 20 + Popularity),
    show.legend = FALSE,
    repel = TRUE,
    alpha = 0.5) +
  theme(legend.title = element_blank(), legend.position = "bottom",
        legend.direction = "horizontal", legend.justification = "right")  +
  guides(size = "none")

A visualização acima mostra as três comunidades menores, 14, 15 e 22, junto de suas vizinhas maiores, o cluster metadata (número 2, em laranja, abaixo) e o cluster research data (número 3, em verde, acima).

Outro fato visível nesta visualização é como a comunidade 5 representa um ponto nevrálgico entre quase todas as principais comunidades. Sem ela, a comunidade 3, research data, uma das três maiores, ficaria isolada das duas outras.

Podemos confirmar isso na visualização abaixo, inclui todas as comunidades do cluster principal exceto a comunidade 5:

Código
cluster_principal %>%
  filter(!Community %in% c(5)) %>% 
  ggraph(layout = "kk") + 
  geom_edge_parallel(aes(color = edge, start_cap = label_rect(node1.name),
    end_cap = label_rect(node2.name)),
    arrow = arrow(length = unit(1, "mm")), 
    start_cap = circle(4, "mm"),
    end_cap = circle(4, "mm")) +
  geom_node_point(aes(colour = Community, size = Popularity), show.legend = TRUE) + 
  geom_node_text(aes(
    label = termos[name,]$PrefLabel, size = 4 + Popularity),
    show.legend = FALSE,
    repel = TRUE,
    alpha = 0.5) +
  theme(legend.title = element_blank(), legend.position = "bottom",
        legend.direction = "horizontal", legend.justification = "right")  +
  guides(size = "none")

Aqui podemos ver um recorte desse coração do grafo, com as quatro comunidades juntas: 1, 2, 3 e 5.

Código
cluster_principal %>%
  filter(Community %in% c(1, 2, 3, 5)) %>% 
  ggraph(layout = "kk") + 
  geom_edge_parallel(aes(color = edge, start_cap = label_rect(node1.name),
    end_cap = label_rect(node2.name)),
    arrow = arrow(length = unit(1, "mm")), 
    start_cap = circle(4, "mm"),
    end_cap = circle(4, "mm")) +
  geom_node_point(aes(colour = Community, size = Popularity), show.legend = TRUE) + 
  geom_node_label(aes(
    label = termos[name,]$PrefLabel, size = 4 + Popularity),
    show.legend = FALSE,
    repel = TRUE,
    alpha = 0.7) +
  theme(legend.title = element_blank(), legend.position = "bottom",
        legend.direction = "horizontal", legend.justification = "right")  +
  guides(size = "none")

E nessa visualização vemos elas junto com as três menores onde começamos nossa viagem:

Código
cluster_principal %>%
  filter(Community %in% c(1, 2, 3, 5, 14, 15, 22)) %>% 
  ggraph(layout = "kk") + 
  geom_edge_parallel(aes(color = edge, start_cap = label_rect(node1.name),
    end_cap = label_rect(node2.name)),
    arrow = arrow(length = unit(1, "mm")), 
    start_cap = circle(4, "mm"),
    end_cap = circle(4, "mm")) +
  geom_node_point(aes(colour = Community, size = Popularity), show.legend = TRUE) + 
  geom_node_label(aes(
    label = termos[name,]$PrefLabel, size = 4 + Popularity),
    show.legend = FALSE,
    repel = TRUE,
    alpha = 0.7) +
  theme(legend.title = element_blank(), legend.position = "bottom",
        legend.direction = "horizontal", legend.justification = "right")  +
  guides(size = "none")

Duas comunidades relativamente pequenas, mas com um pouco mais de popularidade, também estão bastante próximas a esse núcleo: 6 e 10.

Código
cluster_principal %>%
  filter(Community %in% c(6, 10)) %>% 
  ggraph(layout = "kk") + 
  geom_edge_parallel(aes(color = edge, start_cap = label_rect(node1.name),
    end_cap = label_rect(node2.name)),
    arrow = arrow(length = unit(1, "mm")), 
    start_cap = circle(4, "mm"),
    end_cap = circle(4, "mm")) +
  geom_node_point(aes(colour = Community, size = Popularity), show.legend = TRUE) + 
  geom_node_label(aes(
    label = termos[name,]$PrefLabel, size = 4 + Popularity),
    show.legend = FALSE,
    repel = TRUE,
    alpha = 0.7) +
  theme(legend.title = element_blank(), legend.position = "bottom",
        legend.direction = "horizontal", legend.justification = "right")  +
  guides(size = "none") +
  facet_graph(~Community)

Elas fazem pontes com o cluster metadata:

Código
cluster_principal %>%
  filter(Community %in% c(2, 6, 10)) %>% 
  ggraph(layout = "kk") + 
  geom_edge_parallel(aes(color = edge, start_cap = label_rect(node1.name),
    end_cap = label_rect(node2.name)),
    arrow = arrow(length = unit(1, "mm")), 
    start_cap = circle(4, "mm"),
    end_cap = circle(4, "mm")) +
  geom_node_point(aes(colour = Community, size = Popularity), show.legend = TRUE) + 
  geom_node_text(aes(
    label = termos[name,]$PrefLabel, size = 4 + Popularity),
    show.legend = FALSE,
    repel = TRUE,
    alpha = 0.5) +
  theme(legend.title = element_blank(), legend.position = "bottom",
        legend.direction = "horizontal", legend.justification = "right")  +
  guides(size = "none")

Juntando portanto todas as comunidades centrais, com as quatro maiores e suas imediações formadas pelas pequenas comunidades (2, 6, 10, 14, 15, 22) temos um grafo já bastante recheado:

Código
cluster_principal %>%
  filter(Community %in% c(1, 2, 3, 5, 2, 6, 10, 14, 15, 22)) %>% 
  ggraph(layout = "kk") + 
  geom_edge_parallel(aes(color = edge, start_cap = label_rect(node1.name),
    end_cap = label_rect(node2.name)),
    arrow = arrow(length = unit(1, "mm")), 
    start_cap = circle(4, "mm"),
    end_cap = circle(4, "mm")) +
  geom_node_point(aes(colour = Community, size = Popularity), show.legend = TRUE) + 
  geom_node_label(aes(
    label = termos[name,]$PrefLabel, size = 4 + Popularity),
    show.legend = FALSE,
    repel = TRUE,
    alpha = 0.7) +
  theme(legend.title = element_blank(), legend.position = "bottom",
        legend.direction = "horizontal", legend.justification = "right")  +
  guides(size = "none")

Restam apenas as comunidades, 4, 8 e 9 que formam as bordas esquerda e direita do cluster principal.

As comunidades 4 e 8 são muito próximas, e falam sobre a proteção de dados e o sujeito de dados. Elas possuem relações entre si, independentemente de outros conjuntos.

Código
cluster_personal_data <- cluster_principal %>%
  filter(Community %in% c(4, 8)) %>% 
  ggraph(layout = "kk") + 
  geom_edge_parallel(aes(color = edge, start_cap = label_rect(node1.name),
    end_cap = label_rect(node2.name)),
    arrow = arrow(length = unit(1, "mm")), 
    start_cap = circle(4, "mm"),
    end_cap = circle(4, "mm")) +
  geom_node_point(aes(colour = Community, size = Popularity), show.legend = TRUE) + 
  geom_node_text(aes(
    label = termos[name,]$PrefLabel, size = 4 + Popularity),
    show.legend = FALSE,
    repel = TRUE,
    alpha = 0.7) +
  theme(legend.title = element_blank(), legend.position = "bottom",
        legend.direction = "horizontal", legend.justification = "right")  +
  guides(size = "none") +
  facet_graph(~Community, margins = TRUE)

cluster_personal_data

Sua ligação com o cluster principal não é através do cluster metadata, mas sim do nó “data security” (da comunidade 8) que se conecta ao nó “data integrity” do cluster data set:

Código
cluster_principal %>%
  filter(Community %in% c(1, 4, 8)) %>% 
  ggraph(layout = "kk") + 
  geom_edge_parallel(aes(color = edge, start_cap = label_rect(node1.name),
    end_cap = label_rect(node2.name)),
    arrow = arrow(length = unit(1, "mm")), 
    start_cap = circle(4, "mm"),
    end_cap = circle(4, "mm")) +
  geom_node_point(aes(colour = Community, size = Popularity), show.legend = TRUE) + 
  geom_node_text(aes(
    label = termos[name,]$PrefLabel, size = 4 + Popularity),
    show.legend = FALSE,
    repel = TRUE,
    alpha = 0.5) +
  theme(legend.title = element_blank(), legend.position = "bottom",
        legend.direction = "horizontal", legend.justification = "right")  +
  guides(size = "none")

Este é um de meus recortes preferidos. Ele mostra um caminho significativo que vai de “dados pessoais” para “proteção de dados” e que então faz uma ponte que passa por “segurança” e “integridade de dados” para só então desaguar no conjunto principal.

Por fim, no outro extremo do grafo, no lado esquerdo, temos a pequena comunidade 9, que orbita ao redor do nó “data structure”:

Código
cluster_principal %>%
  filter(Community %in% c(9)) %>% 
  ggraph(layout = "kk") + 
  geom_edge_parallel(aes(color = edge, start_cap = label_rect(node1.name),
    end_cap = label_rect(node2.name)),
    arrow = arrow(length = unit(1, "mm")), 
    start_cap = circle(4, "mm"),
    end_cap = circle(4, "mm")) +
  geom_node_point(aes(colour = Community, size = Popularity), show.legend = TRUE) + 
  geom_node_label(aes(
    label = termos[name,]$PrefLabel, size = 4 + Popularity),
    show.legend = FALSE,
    repel = TRUE,
    alpha = 0.7) +
  theme(legend.title = element_blank(), legend.position = "none",
        legend.direction = "horizontal", legend.justification = "right")  +
  guides(size = "none") +
  facet_graph(~Community)

Através de “PID record” ela conecta-se a “persistent identifier”, que por sua vez está ligado ao cluster research data:

Código
cluster_principal %>%
  filter(Community %in% c(3, 9)) %>% 
  ggraph(layout = "kk") + 
  geom_edge_parallel(aes(color = edge, start_cap = label_rect(node1.name),
    end_cap = label_rect(node2.name)),
    arrow = arrow(length = unit(1, "mm")), 
    start_cap = circle(4, "mm"),
    end_cap = circle(4, "mm")) +
  geom_node_point(aes(colour = Community, size = Popularity), show.legend = TRUE) + 
  geom_node_text(aes(
    label = termos[name,]$PrefLabel, size = 4 + Popularity),
    show.legend = FALSE,
    repel = TRUE,
    alpha = 0.5) +
  theme(legend.title = element_blank(), legend.position = "bottom",
        legend.direction = "horizontal", legend.justification = "right")  +
  guides(size = "none")

Aqui voltamos a nossa primeira visualização geral do cluster principal, com todas as suas comunidades:

Código
ggraph(cluster_principal, layout = "kk") + 
geom_edge_parallel(aes(color = edge, start_cap = label_rect(node1.name),
  end_cap = label_rect(node2.name)),
  arrow = arrow(length = unit(1, "mm")), 
  start_cap = circle(4, "mm"),
  end_cap = circle(4, "mm")) +
geom_node_point(aes(colour = Community, size = Popularity), show.legend = FALSE) + 
geom_node_label(aes(
  label = termos[name,]$PrefLabel,
  size = 5 + Popularity),
  show.legend = FALSE,
  repel = TRUE,
  alpha = 0.75) +
theme(legend.title = element_blank(), legend.position = "bottom",
      legend.justification = "right", legend.direction = "horizontal")

Outros protótipos

Para registro da exploração, seguem abaixo alguns outros protótipos que usam diferentes layouts:

Código
cluster_principal %>% 
  ggraph(layout = "partition", circular = TRUE) +
  geom_edge_diagonal() + 
  geom_node_point(aes(size = Popularity, color = Community), show.legend = FALSE) + 
  geom_node_text(aes(label = name), color = "black", check_overlap = TRUE, size = 4) +
  coord_fixed()

Código
grafo %>% 
  filter(Community %in% c(1:2)) %>% 
  ggraph(layout = "linear") + 
    geom_edge_arc(aes(), color = "gray") +
    geom_node_point(aes(size = Popularity, color = Popularity), show.legend = FALSE) +
    geom_node_text(aes(label = termos[name,]$PrefLabel), nudge_y = -3, size = 4) +
    facet_graph(~ Community) +
    coord_flip()

Código
grafo %>% 
  filter(Community %in% c(9:12)) %>% 
  ggraph(layout = "linear", circular = TRUE) + 
  geom_edge_arc(aes(color = edge)) + 
  geom_node_point(aes(size = Popularity, color = Community), show.legend = TRUE) +
  geom_node_text(aes(label = termos[name,]$PrefLabel), size = 5, nudge_y = -0.15, angle = -0) +
  theme(legend.position = "bottom", legend.direction = "horizontal") +
  guides(size = "none")

Código
grafo %>% 
  filter(Community %in% c(7,5,12)) %>% 
  ggraph("tree") + 
  geom_edge_diagonal(aes(color = edge)) +
  geom_node_label(aes(label = termos[name,]$PrefLabel, fill = Community),
                 color = "white",
                 fontface = "bold",
                 size = 2.5,
                 nudge_x = 0,
                 nudge_y = -0.130,
                 label.padding = unit(0.25, "lines")) +
  theme(legend.position = "bottom", legend.justification = "left", legend.direction = "horizontal", legend.background = element_rect(fill = "transparent"), legend.box.background = element_rect(fill = "transparent", color = "transparent")) +
  coord_flip()

Demais referências da pesquisa:

5. Compartilhar

Os dados aqui contam a história de uma viagem por diferentes campos semânticos. Temos termos que pertencem a um mesmo contexto, a gestão de dados, mas que têm diferentes níveis de proximidade.

No começo, parti com o objetivo de determinar quais termos tinham mais relevância. Ficou bastante evidente que termos como metadados e data set são bastante importantes para podermos compreender inúmeros outros, ou seja, eles são componentes importantes para explicar e precisar o vocabulário de dados.

Outro termo de grande destaque também foi personal data, que aparece com importância para explicar ideias relacionadas à proteção de dados. Esse termo gerou uma das relações entre comunidades mais semanticamente significativas.

Visualizações como essa podem exemplificar o poder de pensar os termos não só por suas repetições mas especialmente por suas relações e como essas relações os agrupam. É justamente o nó “segurança de dados” que faz a ponte externa desse conjunto para o cluster principal.

Apesar de não terem grande popularidade, esses termos que realizam pontes, mencionando ou sendo mencionados por outros termos fora de suas comunidades, também poderiam ser pensados como tendo importância específica na análise de relações em linguagem natural, já que sem eles teríamos uma rede semântica mais dispersa.

Ao pensar em análise de definições, a menção de um termo implica não somente conexão ou relevância mas uma possível necessidade daquela palavra para conseguir definir outro termo.

Numericamente, esses foram os resultados finais da métrica de popularidade:

Código
termos %>% 
  filter(Popularity > 0) %>% 
  arrange(desc(Popularity)) %>% 
  select(PrefLabel, Popularity) %>% 
  rename(Termo = PrefLabel) %>% 
  rename(Popularidade = Popularity)

Para pessoas que se interessam ou trabalham com o processamento de linguagem natural, ou que precisam determinar a importância de diferentes conceitos para estudar ou ensinar sobre eles, o mapeamento de relações semânticas pode ser uma ferramenta bastante didática.

Como última etapa, gostaria ainda de explorar a possibilidade de navegar interativamente por este grafo após ter mergulhado nele por algum tempo. Essa intenção surgiu após as primeiras plotagens, pois durante todo o processo muitos gráficos acabam poluídos demais pelo texto que identifica cada ponto.

Bibliotecas para visualização interativa de grafos permitem que o texto identificador apareça apenas quando interagimos com um determinado nó.

networkD3

A biblioteca networkD3 usa uma estrutura de dados parecida, mas com dataframes separados para edges e nodes.

library(networkD3)

Modelagem

Cria um dataframe para edges:

edges_d3 <- relacoes %>% 
  rename(Source = from) %>% 
  rename(Target = to) %>% 
  select(-edge)

Converte os valores para inteiros:

edges_d3 <- edges_d3 %>% 
  mutate(across(c(1, 2), as.integer))

Cria a estrutura para os nós:

nodes_d3 <- termos %>% 
  mutate(id = row_number()) %>% 
  mutate(popularity = 0) %>% 
  select(id, PrefLabel, popularity, Definition)

É necessário ainda diminuir todos os IDs em 1 pois a biblioteca é baseada em um framework JavaScript que usa indexação a partir de 0, ao contrário da R.

nodes_d3[, 1] <- nodes_d3[, 1] - 1
edges_d3[, 1:2] <- edges_d3[, 1:2] - 1

Aqui a popularidade é calculada e acrescentada ao dataframe de nós e multiplicada por 10 para que a diferença de tamanho seja mais visível:

for (row in 1:nrow(nodes_d3)) {
  nodes_d3[row,]$popularity <- 10 * sum(edges_d3$Target == nodes_d3[row,]$id)
}

rm(row)

Por fim temos uma primeira plotagem interativa:

Código
clickScript <- 'console.log("click: " + d.name + ", row " + (d.index + 1));'

forceNetwork(Links = edges_d3, Nodes = nodes_d3,
                 NodeID = "PrefLabel",
                 Group = "popularity",
                 zoom = TRUE,
                 Nodesize = "popularity",
                 opacity = 1,
                 clickAction = clickScript)

Na renderização acima é possível dar zoom e arrastar a visualização. Os nomes são exibidos ao tocar ou clicar sobre um nó e é possível ainda movimentá-los.

visNetwork

A última biblioteca, visNetwork, oferece a possibilidade de exibir as definições completas ao interagir com cada nó.

library(visNetwork)

Para trabalhar com essa biblioteca são necessaŕios também dois dataframes distintos, um para nodes e outro para edges. O dataframe com nodes deve ter uma coluna com IDs e o de edges deve ter colunas from e to que ligam os IDs.

Outro fator é que as colunas desse dataframe podem ser usadas para passar suas propriedades estéticas.

Vamos fazer essas adaptações:

nodes_vN <- termos %>% 
  mutate(id = row_number()) %>% 
  mutate(size = 0) %>%
  rename(label = PrefLabel) %>%
  rename(title = Definition) %>% 
  select(id, label, size, concept_id, title)

for (row in 1:nrow(nodes_vN)) {
  nodes_vN[row,]$size <- 2 * (5 + sum(relacoes$to == nodes_vN[row,]$id))
  if (nodes_vN[row,]$size == 0) {
    nodes_vN[row,]$size <- 3
  }
}

rm(row)

nodes_vN

Criação do dataframe de edges:

edges_vN <- relacoes %>% 
  mutate(dashes = FALSE) %>% 
  mutate(color = "red")

for (row in 1:nrow(edges_vN)) {
  if (edges_vN[row,]$edge == "PrefLabel") {
    edges_vN[row,]$color <- "#CD6839"
    edges_vN[row,]$dashes <- FALSE
  }
  else {
    edges_vN[row,]$color <- "#8B4726"
    edges_vN[row,]$dashes <- TRUE
  }
}

rm(row); edges_vN

Esta é a plotagem mais básica, sem argumentos extras:

Código
visNetwork(nodes_vN, edges_vN)

Desde o primeiro gráfico é possível ver que o resultado padrão é muito mais legível do que nas demais bibliotecas.

Usando mais argumentos podemos personalizar cores e incluir mais controles interativos:

Código
visNetwork(nodes_vN, edges_vN, width = "100%") %>% 
  visOptions(highlightNearest = TRUE, nodesIdSelection = TRUE, collapse = TRUE) %>%
  visNodes(color = list(background = "#EEE5DE", border = "#8B8682",
                        highlight = "#FFA54F"),
           shadow = list(enabled = TRUE, size = 10)) %>% 
  visEdges(shadow = TRUE, smooth = TRUE) %>% 
  visLayout(randomSeed = 1) %>% 
  visPhysics(stabilization = FALSE,
             solver = "repulsion") %>% 
  visEvents(selectNode = "function(properties) {
      console.log('seleção: ' + this.body.data.nodes.get(properties.nodes[0]).id);}") %>% 
  visClusteringOutliers()

Na visualização acima é possível dar zoom, arrastar a visão, movimentar e colapsar nós. Quando um nó é selecionada, suas relações são destacadas.

Abaixo, os mesmos dados com um layout circular.

Código
visNetwork(nodes_vN, edges_vN) %>% 
  visOptions(highlightNearest = TRUE, nodesIdSelection = TRUE, collapse = TRUE,
             manipulation = TRUE) %>%
  visNodes(color = list(background = "#EEE5DE", border = "#8B8682",
                        highlight = "#FFA54F"),
           shadow = list(enabled = TRUE, size = 10)) %>% 
  visEdges(shadow = TRUE, smooth = TRUE) %>% 
  visIgraphLayout(layout = "layout_in_circle") 

Este formato é de difíci visualização com tantos nós inclusos. Aqui também está demonstrada a possibilidade de editar os nós com os controles interativos providos pela biblioteca.

Outras bibliotecas de visualização de grafos

6. Agir

Para pessoas interessadas na gestão de dados, a análise dessa amostra sugere que termos como metadados, interoperabilidade de dados e proteção de dados são especialmente relevantes para definir outros termos.

Grupos como dados pessoais, dados de pesquisa, qualidade de dados e gestão de dados, além de estarem entre os mais populares, agem como agregadores e fazem importantes pontes entre as diferentes áreas do grafo.

Outros termos altamente populares não foram destacados neste último resumo acionável por terem um papel mais passivo como objetos da gestão de dados: conjunto de dados e dados brutos.

Apesar de estar disponível em diferentes idiomas, o português não era uma das contempladas pelo Multilingual Data Stewardship Terminology. Seria possível, porém, cruzar dados de fontes compatíveis que possibilitariam um novo estudo de relações observando variações entre os idiomas.

Saiba mais

Se desejar consultar o código fonte você pode encontrá-lo no GitHub.