14  Juntando dados (Joins)

Acima: um exemplo animado de uma operação de left join (image source)

Essa página descreve maneiras de combinar diferentes data frames com operações do tipo “join”, “match”, “link” e “bind”.

Raramente nosso fluxo de análise epidemiológica não envolverá múltiplas fontes de dados e relações entre múltiplas bases. Talvez você precise conectar dados laboratoriais com desfechos clínicos dos pacientes, ou dados de mobilidade do Google às tendências de doenças infecciosas, ou ainda, uma base de dados em um estágio da análise com uma versão transormada dessa mesma base.

Nessa página vamos demonstrar como escrever código para:

14.1 Preparação

Carregue os pacotes R

O código abaixo realiza o carregamento dos pacotes necessários para a análise dos dados. Neste manual, enfatizamos o uso da função p_load(), do pacman, que instala os pacotes, caso não estejam instalados, e os carrega no R para utilização. Também é possível carregar pacotes instalados utilizando a função library(), do R base. Para mais informações sobre os pacotes do R, veja a página Introdução ao R.

pacman::p_load(
  rio, # importar and exportar
  here, # localizar arquivos
  tidyverse, # gerenciamento e visualização de dados
  RecordLinkage, # combinações probabilisticas
  fastLink # combinações probabilisticas
)

Importe dados

Para iniciar, importaremos a versão limpa da linelist de casos de uma epidemia simulada de Ebola. Se você quiser acompanhar, clique para baixar a linelist “limpa” (com um arquivo .rds). Importe os dados com a função import() do pacote rio (ela manipula vários tipos de arquivo, tais como .xlsx, .csv, .rds - veja na página Importando and exportando para detalhes).

# importa a linelist de casos
linelist <- import("linelist_cleaned.rds")

As primeiras 50 linhas da linelist são mostradas abaixo.

Base de dados de exemplo

Para demonstrar um join na seção abaixo, utilizaremos as seguintes bases de dados:

  1. Uma versão “miniatura” da linelist de casos, contendo apenas as colunas case_id, date_onset, e hospital, e apenas as primeiras 10 linhas
  2. Um dataframe separado chamado hosp_info, que contém mais detalhes sobre cada hospital

Na seção de combinação probabilística, utilizaremos outras duas bases diferentes. O código para criar essas bases serão mostradas naquela seção.

Linelist de casos “miniatura”

Abaixo está a miniatura da linelist de casos, que contém apenas 10 linhas e só as colunas case_id, date_onset, e hospital.

linelist_mini <- linelist %>% # começa com a linelist original
  select(case_id, date_onset, hospital) %>% # seleciona colunas
  head(10) # seleciona apenas 10 primeira linhas

Dataframe com informações dos hospitais

Abaixo está o código para criar um dataframe separado com informações adicionais sobre sete hospitais (abrangência populacional e o nível de atenção disponível). Note que o nome “Military Hospital” pertence a dois hospitais diferentes - um no primeiro nível servindo 10000 residentes e outro no nível secundário servindo 50280 residentes.

# Cria o dataframe de informação dos hospitais
hosp_info <- data.frame(
  hosp_name     = c("central hospital", "military", "military", "port", "St. Mark's", "ignace", "sisters"),
  catchment_pop = c(1950280, 40500, 10000, 50280, 12000, 5000, 4200),
  level         = c("Tertiary", "Secondary", "Primary", "Secondary", "Secondary", "Primary", "Primary")
)

Aqui está esse dataframe:

Pré-limpeza

Joins tradicionais (não-probabilísticos) são sensíveis ao caso (maiúsculos/minúsculos) e necessitam que os caracteres combinem de forma exata (exact match) entre os valores nos dois dataframes. Para demonstrar alguns dos passos de limpeza que você possa precisar executar antes de iniciar um join, vamos limpar e alinhar as bases linelist_mini e hosp_info agora.

Identifique as diferenças

Precisamos que os valores da coluna hosp_name no dataframe hosp_info combinem com os valores da coluna hospital no dataframe linelist_mini.

Aqui estão os valores do dataframe linelist_mini, impressos com a função unique() do R base:

unique(linelist_mini$hospital)
[1] "Other"                               
[2] "Ausente"                             
[3] "St. Mark's Maternity Hospital (SMMH)"
[4] "Port Hospital"                       
[5] "Military Hospital"                   

e aqui estão os valores do dataframe hosp_info:

unique(hosp_info$hosp_name)
[1] "central hospital" "military"         "port"             "St. Mark's"      
[5] "ignace"           "sisters"         

Você pode ver que apesar de alguns dos hospitais existirem em ambos dataframes, existem muitas diferenças na forma como os nomes estão escritos.

Alinhando valores

Vamos começar limpando os valores do dataframe hosp_info. Como explicado na página Limpando dados e funções principais, podemos recodificar valores com critérios lógicos utilizando a função case_when() do pacote dplyr. Para os quatro hospitais que existem em ambos os dataframes, vamos mudar os valores para alinhar com os valores da linelist_mini. Para os outros hospitais, deixaremos os valores como estão (TRUE ~ hosp_name).

CUIDADO: Tipicamente, quando estamos limpando dataframes devemos criar uma nova coluna (ex: hosp_name_clean), mas para facilitar a demonstração vamos mostrar diretamente a modificação da antiga coluna

hosp_info <- hosp_info %>%
  mutate(
    hosp_name = case_when(
      # critério                         # novo valor
      hosp_name == "military" ~ "Military Hospital",
      hosp_name == "port" ~ "Port Hospital",
      hosp_name == "St. Mark's" ~ "St. Mark's Maternity Hospital (SMMH)",
      hosp_name == "central hospital" ~ "Central Hospital",
      TRUE ~ hosp_name
    )
  )

Os nomes dos hospitais que aparecem em ambos os dataframes estão alinhados. Existem dois hospitais em hosp_info que não estão presentes em linelist_mini - vamos lidar com estes depois, no join.

unique(hosp_info$hosp_name)
[1] "Central Hospital"                    
[2] "Military Hospital"                   
[3] "Port Hospital"                       
[4] "St. Mark's Maternity Hospital (SMMH)"
[5] "ignace"                              
[6] "sisters"                             

Antes de fazer um join, geralmente o mais fácil é converter uma coluna completamente para letras minúsculas (lowercase) ou letras maiúsculas (uppercase). Se você precisar converter todos os valores de uma coluna para MAIÚSCULO ou minúsculo, utilize a função mutate() e envolva a coluna com alguma dessas funções do pacote stringr, como mostrado na página Caracteres e strings.

str_to_upper()
str_to_upper()
str_to_title()

14.2 Joins com dplyr

O pacote dplyr oferece várias funções diferentes de join. dplyr está incluso no pacote tidyverse. Essas funções de join são descritas abaixo, com casos de uso simples.

Agradecimentos a https://github.com/gadenbuie pelos gifs informativos!

Sintaxe geral

Os comandos de join podem ser executados individualmente para juntar dois dataframes em um novo objeto, ou podem ser usados em uma cadeia de comandos com pipe (%>%) para mesclar um dataframe em outro à medida que ele vai sendo limpo ou modificado de alguma outra forma.

No exemplo abaixo, a função left_join() é utilizada como um comando individual para criar um novo dataframe chamado joined_data. As entradas (inputs) são os dataframes 1 e 2 (df1 e df2). O primeiro dataframe listado é o dataframe de base e o segundo é o dataframe que será mesclado a (joined to) ele.

O terceiro argumento by = é onde você especifica as colunas em cada dataframe que serão utilizadas para alinhar as linhas dos dois dataframes. Se os nomes dessas colunas forem diferentes, insira o nome delas com um vetor c() como mostrado abaixo, onde as linhas são combinadas com base nos valores comuns entre a coluna ID em df1 e a coluna identifier em df2.

# Faz o join baseado nos valores comuns entre a coluna "ID" (primeiro dataframe) e coluna "identifier" (segundo dataframe)
joined_data <- left_join(df1, df2, by = c("ID" = "identifier"))

Se as colunas utilizadas no parâmetro by tiverem exatamente o mesmo nome em ambos os dataframes, você pode inserir apenas esse nome, entre aspas.

# Join baseado nos valores comuns da coluna "ID" em ambos os dataframes
joined_data <- left_join(df1, df2, by = "ID")

Se você estiver juntando dataframes baseado em valores comuns ao longo de múltiplos campos, liste esses campos em um vetor c(). Esse exemplo junta linhas se os valores em três colunas em cada base de dado se alinham de forma exata.

# join baseado nos mesmos primeiro nome, último nome e idade
joined_data <- left_join(df1, df2, by = c("name" = "firstname", "surname" = "lastname", "Age" = "age"))

Os comandos de join também podem ser rodados em uma cadeia de comandos com pipe. Isso irá modificar o dataframe que está sendo passado pelo pipe.

No exemplo abaixo, df1 está sendo pessado pelos pipes, df2 será juntado a ele e df1 então será modificado e redefinido.

df1 <- df1 %>%
  filter(date_onset < as.Date("2020-03-05")) %>% # miscellaneous cleaning
  left_join(df2, by = c("ID" = "identifier")) # join df2 to df1

CUIDADO: Joins são caso-específicos (maiúsculas e minúsculas)! Então, é útil converter todos os valores para minúsculo ou maiúsculo antes de fazer o join. Veja a página sobre caracteres/strings.

Left joins e right joins

Um left join ou right join é normalmente utilizado para adicionar informação a um dataframe - novas informações são adicionadas apenas a linhas que já existem no data frame base. Esses são tipos de joins comuns em trabalhos epidemiológicos pois eles são utilizados para adicionar informações de uma base de dado em outra.

Para utilizar esses joins, a ordem em que os dataframes estão escritos nos comandos é importante*.

  • Em um left join, o primeiro dataframe escrito é o de base
  • Em um right join, o segundo dataframe escrito é o de base

Todas as linhas do dataframe base são mantidas. As informações no outro dataframe (secundário) será juntada ao dataframe de base apenas se houver a combinação via as colunas dos identificadores. E ainda:

  • Linhas no dataframe secundário que não combinam são removidas.
  • Se houver muitas linhas no dataframe base que combinam com uma linha no secundário (muitos-para-um), a informação do secundário é adicionada a cada linha do base que combina.
  • Se uma linha do base combina com múltiplas linhas no secundário (um-para-muitos), todas as combinações são dadas, o que significa que novas linhas podem ser adicionadas ao dataframe resultante!

Exemplos animados de left e right joins (fonte da imagem)

Exemplo

Abaixo está a saída de um left_join() do hosp_info (dataframe secundário, veja aqui) em linelist_mini (dataframe base, veja aqui). O linelist_mini tem nrow(linelist_mini) linhas. O linelist_mini modificado é mostrado. Note o seguinte:

  • Duas novas colunas, catchment_pop e level foram adicionadas ao lado esquerdo da linelist_mini
  • Todas as linhas originais do dataframe base linelist_mini são mantidas
  • Quaisquer linhas originais do linelist_mini para “Military Hospital” foram duplicadas porque combinaram com duas linhas no dataframe secundário, então ambas as combinações são retornadas
  • A coluna de identificador do join na base secundária (hosp_name) desapareceu pois está em redundância com a coluna de identificador da base primária (hospital)
  • Quando a linha da base não combina com nenhuma linha da base secundária (ex: quando hospital é “Other” ou “Missing”), as colunas do dataframe secundário serão preenchidas com NA (em branco)
  • Linhas no dataframe secundário sem nenhuma combinação no dataframe base (hospitais “sisters” e “ignace”) foram removidas
linelist_mini %>%
  left_join(hosp_info, by = c("hospital" = "hosp_name"))
Warning in left_join(., hosp_info, by = c(hospital = "hosp_name")): Detected an unexpected many-to-many relationship between `x` and `y`.
ℹ Row 5 of `x` matches multiple rows in `y`.
ℹ Row 4 of `y` matches multiple rows in `x`.
ℹ If a many-to-many relationship is expected, set `relationship =
  "many-to-many"` to silence this warning.

“Devo fazer um right join, ou um left join?”

Para responder à pergunta acima, pergunte-se “qual dataframe deve manter todas as suas linhas?” - utilize esse como o base. Um left join manterá todas as linhas do primeiro dataframe escrito no comando, enquanto um right join manterá todas as linhas do segundo dataframe.

Os dois comandos abaixo geram a mesma saída - um join de 10 linhas do hosp_info em linelist_mini (base), mas eles usam joins diferentes. O resultado é que a ordem das colunas vai diferir se hosp_info aparecer na direita (em um left join) ou na esquerda (em um right join). A ordem das linhas também pode mudar de posição da mesma forma. No entanto, ambas essas consequências podem ser resolvidas depois utilizando select() para reordenar as colunas, ou arrange() para ordenar (sort) as linhas.

# Os dois comandos abaixo geram os memso dados, mas com linhas e colunas em ordem diferentes
left_join(linelist_mini, hosp_info, by = c("hospital" = "hosp_name"))
right_join(hosp_info, linelist_mini, by = c("hosp_name" = "hospital"))

Aqui está o resultado do join de hosp_info em linelist_mini por meio de um left join (novas colunas vindas da direita)

Warning in left_join(linelist_mini, hosp_info, by = c(hospital = "hosp_name")): Detected an unexpected many-to-many relationship between `x` and `y`.
ℹ Row 5 of `x` matches multiple rows in `y`.
ℹ Row 4 of `y` matches multiple rows in `x`.
ℹ If a many-to-many relationship is expected, set `relationship =
  "many-to-many"` to silence this warning.

Aqui, o resultado do join de hosp_info em linelist_mini por meio de um right join (novas colunas vindas da esquerda)

Warning in right_join(hosp_info, linelist_mini, by = c(hosp_name = "hospital")): Detected an unexpected many-to-many relationship between `x` and `y`.
ℹ Row 4 of `x` matches multiple rows in `y`.
ℹ Row 5 of `y` matches multiple rows in `x`.
ℹ If a many-to-many relationship is expected, set `relationship =
  "many-to-many"` to silence this warning.

Considere também se seu caso de uso está em meio a uma cadeia de comandos com pipe (%>%). Se os dados passando através dos pipes for o dataframe base, você irá preferivelmente utilizar um left join para adicionar mais dados a ele.

Full join

Um full join é o mais inclusivo dos joins - ele retorna todas as linhas de ambos os dataframes.

Se houver alguma linha presente em um mas não em outro (onde não foi encontrada nenhuma combinação entre os dados), o dataframe vai incluir essa linha e tornar-se mais longo. Os valores NA para representar valores ausentes serão utilizados para preencher os espaços vazios criados. À medida que você for executando o join, monitore cuidadosamente o número de colunas e linhas para resolver problemas de sensitividade de caso (maiúsculo/minúsculo) ou combinação exata de caracteres.

O dataframe base será aquele escrito primeiro no comando. Mudar essa posição não vai impactar em quais registros serão retornados pelo join, mas pode impactar na ordem das colunas e linhas resultantes e também em quais colunas de identificadores serão mantidos.

Exemplo animado de um full join (fonte da imagem)

Exemplo

Abaixo está uma saída de um full_join() do hosp_info (originalmente nrow(hosp_info), veja aqui) em linelist_mini (originalmente nrow(linelist_mini), veja aqui). Note o seguinte:

  • Todas as linhas do dataframe base são mantidas (linelist_mini)
  • Linhas no dataframe secundário que não combinam com o dataframe base são mantidas (“ignace” e “sisters”), com valores nas colunas correspondentes do base (case_id e onset) preenchidas com os valores ausentes NA
  • Da mesma forma, linhas no dataframe base que não combinam com o secundário (“Other” e “Missing”) são mantidas, com as colunas catchment_pop e level do secundário preenchidas com NA
  • Nos casos das combinações um-para-muitos ou muitos-para-um (ex: linhas para “Military Hospital”), todas as combinações possíveis são retornadas (aumentando o tamanho do dataframe final)
  • Apenas a coluna do identificador do dataframe base foi mantida (hospital)
linelist_mini %>%
  full_join(hosp_info, by = c("hospital" = "hosp_name"))
Warning in full_join(., hosp_info, by = c(hospital = "hosp_name")): Detected an unexpected many-to-many relationship between `x` and `y`.
ℹ Row 5 of `x` matches multiple rows in `y`.
ℹ Row 4 of `y` matches multiple rows in `x`.
ℹ If a many-to-many relationship is expected, set `relationship =
  "many-to-many"` to silence this warning.

Inner join

Um inner join é o mais restritivo dos joins - ele retona apenas as linhas que combinam em ambos os dataframes. Isso significa que o número linhas no dataframe base pode, de fato, reduzir. Ajustes em relação a qual dataframe será o base (escrito primeiro na função) não irá impactar quais linhas serão retornadas, mas vai impactar na ordem das colunas e linhas, e quais colunas de identificadores serão mantidas.

Exemplo animado de um inner join (fonte da imagem)

Exemplo

Abaixo está a saída de um inner_join() de linelist_mini (base) com hosp_info (secundário). Note o seguinte:

  • As linhas do dataframe base que não combinam com as do secundário são removidas (linhas onde hospital é “Missing” ou “Other”)
  • De forma análoga, linhas do secundário que não combinam com o base são removidas (linhas onde hosp_name é “sisters” ou “ignace”)
  • Apenas a coluna de identificador do base foi mantida (hospital)
linelist_mini %>%
  inner_join(hosp_info, by = c("hospital" = "hosp_name"))
Warning in inner_join(., hosp_info, by = c(hospital = "hosp_name")): Detected an unexpected many-to-many relationship between `x` and `y`.
ℹ Row 5 of `x` matches multiple rows in `y`.
ℹ Row 4 of `y` matches multiple rows in `x`.
ℹ If a many-to-many relationship is expected, set `relationship =
  "many-to-many"` to silence this warning.

Semi join

Um semi join é “join filtro” que utiliza outra base de dados não para adicionar linhas ou colunas, mas para fazer uma filtragem.

Um semi-join mantém todas as observações do dataframe base que combinam com algum valor do dataframe secundário (mas não adiciona novas colunas nem duplica nenhuma linha para o caso de múltiplas combinações). Leia mais sobre esses joins “filtros” aqui.

Exemplo de um semi join animado (fonte da imagem)

Como um exemplo, o código abaixo retorna as linhas do dataframe hosp_info que tem combinações em linelist_mini baseados no nome do hospital.

hosp_info %>%
  semi_join(linelist_mini, by = c("hosp_name" = "hospital"))
                             hosp_name catchment_pop     level
1                    Military Hospital         40500 Secondary
2                    Military Hospital         10000   Primary
3                        Port Hospital         50280 Secondary
4 St. Mark's Maternity Hospital (SMMH)         12000 Secondary

Anti join

O anti join é um outro tipo de “join filtro” que retorna linhas no dataframe base que não possuem uma combinação no dataframe secundário.

Leia mais sobre esses joins “filtros” aqui.

Cenários comuns para anti-join incluem identificar registros que não estão presentes em outro data frame, checar erros de grafia em um join (revisão de registros que deveriam ter combinado) e examinar registros que foram excluídos depois de outro join.

Assim como com right_join() e left_join(), o dataframe base (listado primeiro) é importante. As linhas retornadas serão apenas do dataframe base. Veja no gif abaixo qu e a linha no dataframe secundário (linha roxa 4) não é retornada mesmo que ela não tenha combinado com o base.

Exemplo animado de um anti join (fonte da imagem)

Um exemplo simples de anti_join()

Para um exemplo simples, vamos achar os hospitais de hosp_info que não possuem nenhum caso presente em linelist_mini. Nós listamos hosp_info primeiro, como o dataframe base. Os hospitais que não estão presentes em linelist_mini são retornados.

hosp_info %>%
  anti_join(linelist_mini, by = c("hosp_name" = "hospital"))

Exemplo complexo de anti_join()

Para outro exemplo, vamos supor que rodamos um inner_join() entre linelist_mini e hosp_info. Será retornado apenas um subconjunto dos registros originais de linelist_mini, visto que alguns não estão presentes em hosp_info.

linelist_mini %>%
  inner_join(hosp_info, by = c("hospital" = "hosp_name"))
Warning in inner_join(., hosp_info, by = c(hospital = "hosp_name")): Detected an unexpected many-to-many relationship between `x` and `y`.
ℹ Row 5 of `x` matches multiple rows in `y`.
ℹ Row 4 of `y` matches multiple rows in `x`.
ℹ If a many-to-many relationship is expected, set `relationship =
  "many-to-many"` to silence this warning.

Para revisar os registros de linelist_mini que foram excluídos no inner join, podemos rodar um anti-join com as mesmas configurações (linelist_mini como o dataframe base).

linelist_mini %>%
  anti_join(hosp_info, by = c("hospital" = "hosp_name"))

Para ver os registros de hosp_info que foram excluídos no inner join, também podemos rodar um anti-join com hosp_info como o dataframe base.

14.3 Combinação probabilística

Se você não tiver um único identificador comum entre as bases de dados para fazer o join, considere utilizar um algoritmo de combinação probabilística. Ele acharia combinações entre os registros baseados em similaridade (ex: distância de strings de Jaro–Winkler, ou distância numérica). Abaixo temos um exemplo simples utilizando o pacote fastLink .

Carregue o pacote

pacman::p_load(
  tidyverse, # manipulação e visualização de dados
  fastLink # combinação dos registros
)

Aque estão duas pequenas bases de dados que nós iremos utilizar para demonstrar a combinação probabilística (cases e test_results):

Aqui está o código utilizado para construir as bases de dados:

# cria as bases de dados

cases <- tribble(
  ~gender, ~first, ~middle, ~last, ~yr, ~mon, ~day, ~district,
  "M", "Amir", NA, "Khan", 1989, 11, 22, "River",
  "M", "Anthony", "B.", "Smith", 1970, 09, 19, "River",
  "F", "Marialisa", "Contreras", "Rodrigues", 1972, 04, 15, "River",
  "F", "Elizabeth", "Casteel", "Chase", 1954, 03, 03, "City",
  "M", "Jose", "Sanchez", "Lopez", 1996, 01, 06, "City",
  "F", "Cassidy", "Jones", "Davis", 1980, 07, 20, "City",
  "M", "Michael", "Murphy", "O'Calaghan", 1969, 04, 12, "Rural",
  "M", "Oliver", "Laurent", "De Bordow", 1971, 02, 04, "River",
  "F", "Blessing", NA, "Adebayo", 1955, 02, 14, "Rural"
)

results <- tribble(
  ~gender, ~first, ~middle, ~last, ~yr, ~mon, ~day, ~district, ~result,
  "M", "Amir", NA, "Khan", 1989, 11, 22, "River", "positive",
  "M", "Tony", "B", "Smith", 1970, 09, 19, "River", "positive",
  "F", "Maria", "Contreras", "Rodriguez", 1972, 04, 15, "Cty", "negative",
  "F", "Betty", "Castel", "Chase", 1954, 03, 30, "City", "positive",
  "F", "Andrea", NA, "Kumaraswamy", 2001, 01, 05, "Rural", "positive",
  "F", "Caroline", NA, "Wang", 1988, 12, 11, "Rural", "negative",
  "F", "Trang", NA, "Nguyen", 1981, 06, 10, "Rural", "positive",
  "M", "Olivier", "Laurent", "De Bordeaux", NA, NA, NA, "River", "positive",
  "M", "Mike", "Murphy", "O'Callaghan", 1969, 04, 12, "Rural", "negative",
  "F", "Cassidy", "Jones", "Davis", 1980, 07, 02, "City", "positive",
  "M", "Mohammad", NA, "Ali", 1942, 01, 17, "City", "negative",
  NA, "Jose", "Sanchez", "Lopez", 1995, 01, 06, "City", "negative",
  "M", "Abubakar", NA, "Abullahi", 1960, 01, 01, "River", "positive",
  "F", "Maria", "Salinas", "Contreras", 1955, 03, 03, "River", "positive"
)

A base de dados cases possui 9 registros de pacientes que estão esperando para testar resultados.

A base de dados test_results possui 14 registros e contém a coluna result, que nós queremos adicionar aos registros cases baseados nas combinações probabilísticas dos registros.

Combinação Probabilística (probabilistic matching)

A função fastlink() do pacote fastlink pode ser utilizada para aplicar um algoritmo que busca as combinações entre os valores. Aqui estão as informações básicas. Você pode ler mais detalhes digitando ?fastlink no seu console.

  • Defina os dois dataframes que serão comparados nos argumentos dfA = e dfB =
  • Em varnames = defina todos os nomes de colunas que serão utilizadas para testar a combinação. Todas devem existir tanto em dfA quanto em dfB.
  • Em stringdist.match = defina o nome das colunas que estão em varnames que serão avaliadas pela “distancia” entre strings.
  • Em numeric.match = defina o nome das colunas que estão em varnames que serão avaliadas pela distância numérica.
  • Valores ausentes serão ignorados
  • Por padrão, cada linha em qualquer dos dataframes será combinada com no máximo uma linha do outro dataframe. Se você quiser ver todas as combinações que foram avaliadas defina o parâmetro dedupe.matches = FALSE. A deduplicação é feita utilizando a solução de Winkler para atribuição linear.

Dica: divida uma coluna única de data em 3 colunas numéricas separadas utilizadando day(), month(), e year() do pacote lubridate

O limiar padrão para as combinações é 0.94 (threshold.match =) mas você pode ajustar para maior ou menor. Se você for ajustar manualmente o limiar, considere que com limiares mais altos você pode gerar mais falsos negativos (linhas que não combinam quando na verdade deveriam combinar) e, da mesma forma, um limiar mais baixo pode gerar falsos positivos.

Abaixo, os dados estão sendo combinados pela distância de strings nas colunas de nome e distrito, e na distância numérica para ano, mês e dia do nascimento. Um limiar para combinação de 95% de probabilidade foi configurado.

fl_output <- fastLink::fastLink(
  dfA = cases,
  dfB = results,
  varnames = c("gender", "first", "middle", "last", "yr", "mon", "day", "district"),
  stringdist.match = c("first", "middle", "last", "district"),
  numeric.match = c("yr", "mon", "day"),
  threshold.match = 0.95
)

==================== 
fastLink(): Fast Probabilistic Record Linkage
==================== 

If you set return.all to FALSE, you will not be able to calculate a confusion table as a summary statistic.
Calculating matches for each variable.
Getting counts for parameter estimation.
    Parallelizing calculation using OpenMP. 1 threads out of 8 are used.
Running the EM algorithm.
Getting the indices of estimated matches.
    Parallelizing calculation using OpenMP. 1 threads out of 8 are used.
Deduping the estimated matches.
Getting the match patterns for each estimated match.

Revisando as combinações

Nós definimos o objeto retornado da função fastLink() como fl_output. Ele é da classe list, e de fato contém vários dataframes internamente que detalham os resultados da combinação. Um desses dataframes é matches, que vai conter as combinações mais prováveis entre cases e results. Você pode acessar esse dataframe de combinações com fl_output$matches. Abaixo, ele está sendo salvo como my_matches para facilitar o acesso depois.

Quando my_matches é impresso no console, você vê duas colunas de vetores: os pares de número de linhas/índices (também chamados “rownames”) em cases (“inds.a”) e em results (“inds.b”) representando as melhores combinações. Se o número da linha de um dataframe estiver ausente, então nenhuma combinação foi achada no outro dataframe no limiar de combinação especificado.

# print matches
my_matches <- fl_output$matches
my_matches
  inds.a inds.b
1      1      1
2      2      2
3      3      3
4      4      4
5      8      8
6      7      9
7      6     10
8      5     12

Algumas coisas para se ter em mente:

  • As combinações ocorrem apesar de algumas diferenças sutis na grafia dos nomes ou nas datas de nascimento:
    • “Tony B. Smith” combinou com “Anthony B Smith”
    • “Maria Rodriguez” combinou com “Marialisa Rodrigues”
    • “Betty Chase” combinou com “Elizabeth Chase”
    • “Olivier Laurent De Bordeaux” combinou com “Oliver Laurent De Bordow” (data de nascimneto ausente foi ignorada)
  • Uma linha de cases (para “Blessing Adebayo”, linha 9) não teve nenhuma boa combinação em results, por isso não está prente em my_matches.

Join baseado em combinações probabilísticas

Para utilizar essas combinações para fazer um join de results e cases, uma estratégia seria:

  1. Utilizar left_join() para fazer o join de my_matches em cases (combinando os nomes das colunas em cases com “inds.a” em my_matches)
  2. Depois, utilizar outro left_join() para fazer o join de results em cases (combinando o recém adquirido “inds.b” em cases com os nomes das linhas em results)

Antes dos joins, devemos limpar os três dataframes:

  • Ambos dfA e dfB devem ter seus números de linhas (“rowname”) convertidos para uma coluna propriamente dita.
  • Ambas as colunas de my_matches devem ser convertidas para a classe character, para que o join possa ser feito com os nomes de coluna que são caracteres
# Limpeza dos dados antes do join
#############################

# converte os nomes das linhas de cases em coluna
cases_clean <- cases %>% rownames_to_column()

# converte os nomes das linhas de test_results em coluna
results_clean <- results %>% rownames_to_column()

# converte todas as colunas no dataframe de combinações (my_matches) para character, para que o join possa ser feito com os nomes das linhas
matches_clean <- my_matches %>%
  mutate(across(everything(), as.character))



# Faça o join das combinações com dfA, e depois adicione dfB
###################################
# a coluna "inds.b" é adicionada ao dfA
complete <- left_join(cases_clean, matches_clean, by = c("rowname" = "inds.a"))

# colunas do dfB são adicionadas
complete <- left_join(complete, results_clean, by = c("inds.b" = "rowname"))

Como feito no código acima, o dataframe complete, resultado dos joins, vai conter todas as colunas de cases e results. Muitas delas estarão com sufixos “.x” e “.y”, por que, caso contrário, o nome das colunas seriam duplicados.

Como opção, para ter apenas os 9 registros “originais” em cases com as novaas colunas de results, utilize select() em results antes dos joins, de forma que ela contenha apenas os nomes de linhas e colunas que você queira adicionar a cases (ex: a coluna result).

cases_clean <- cases %>% rownames_to_column()

results_clean <- results %>%
  rownames_to_column() %>%
  select(rowname, result) # selecion apenas algumas colunas

matches_clean <- my_matches %>%
  mutate(across(everything(), as.character))

# joins
complete <- left_join(cases_clean, matches_clean, by = c("rowname" = "inds.a"))
complete <- left_join(complete, results_clean, by = c("inds.b" = "rowname"))

Se você quiser um subconjunto de qualquer um dos dataframes contendo apenas as linhas que combinaram, você pode utilizar o código abaixo:

cases_matched <- cases[my_matches$inds.a, ] # Linhas em cases que combinaram com uma linha em results
results_matched <- results[my_matches$inds.b, ] # Linhas em results que combinaram com uma linha em cases

Ou, para ver apenas as linhas que não combinaram:

cases_not_matched <- cases[!rownames(cases) %in% my_matches$inds.a, ] # Linhas em cases que NÃO combinaram com uma linha em results
results_not_matched <- results[!rownames(results) %in% my_matches$inds.b, ] # Linhas em results que NÃO combinaram com uma linha em cases

Remoção de duplicidades probabilística

A combinação probabilística também pode ser utilizada para remover duplicidades uma base de dados. Veja a página de remoção de duplicidades para outros métodos de deduplicação.

Aqui, vamos começar com a base cases, mas a partir daqui chamaremos ela de cases_dup, pois ela tem 2 linhas adicionais que podem ser duplicidades de linhas anteriores: Veja “Tony” com “Anthony”, e “Marialisa Rodrigues” com “Maria Rodriguez”.

Execute fastLink() como anteriormente, mas compare o dataframe cases_dup com ele mesmo. Quando os dois dataframes passados para a função são idênticos, ela assume que você quer fazer a remoção de duplicidades. Note que nós não especificamos stringdist.match = ou numeric.match = como havíamos feito anteriormente.

## Execute fastLink na mesma base de dados
dedupe_output <- fastLink(
  dfA = cases_dup,
  dfB = cases_dup,
  varnames = c("gender", "first", "middle", "last", "yr", "mon", "day", "district")
)

==================== 
fastLink(): Fast Probabilistic Record Linkage
==================== 

If you set return.all to FALSE, you will not be able to calculate a confusion table as a summary statistic.
dfA and dfB are identical, assuming deduplication of a single data set.
Setting return.all to FALSE.

Calculating matches for each variable.
Getting counts for parameter estimation.
    Parallelizing calculation using OpenMP. 1 threads out of 8 are used.
Running the EM algorithm.
Getting the indices of estimated matches.
    Parallelizing calculation using OpenMP. 1 threads out of 8 are used.
Calculating the posterior for each pair of matched observations.
Getting the match patterns for each estimated match.

Agora, você pode revisar as duplicidades em potencial com getMatches(). Passe o dataframe tanto como dfA = como dfB =, e passa a saída da função fastLink() como fl.out =. fl.out deve ser da classe fastLink.dedupe, ou em outras palavras, o resultado de fastLink().

## Execute getMatches()
cases_dedupe <- getMatches(
  dfA = cases_dup,
  dfB = cases_dup,
  fl.out = dedupe_output
)

Veja a coluna da extrema direita, que indica os IDs das duplicatas - as duas últimas linhas estão identificadas como sendo potenciais duplicatadas das linhas 2 e 3.

Para retornar o número de linhas que são potenciais duplicidades, você pode contar o número de linhas por valores únicos na coluna dedupe.ids, e aí filtrar para manter apenas aquelas com mais de uma linha. Nesse caso, isso deixaria as linhas 2 e 3.

cases_dedupe %>%
  count(dedupe.ids) %>%
  filter(n > 1)
  dedupe.ids n
1          2 2
2          3 2

Para investigar a totalidade das linhas que poderiam ser duplicidades, coloque o número da linha nesse comando:

# mostra a linha 2 e todas as possíveis duplicidades dela
cases_dedupe[cases_dedupe$dedupe.ids == 2, ]
   gender   first middle  last   yr mon day district dedupe.ids
2       M Anthony     B. Smith 1970   9  19    River          2
10      M    Tony     B. Smith 1970   9  19    River          2

14.4 Anexando (binding) e alinhando

Um outro método de combinar dois dataframes é anexando (“binding”) um ao outro. Você também pode pensar nisso como incluir (“appending”) ou adicionar (“adding”) linhas ou colunas.

Essa seção também vai discutir como “alinhar” a ordem das linhas de um dataframe à ordem de outro dataframe. Esse tópico é discutido abaixo na seção de Anexar colunas.

Anexar linhas (bind rows)

Para anexar linhas de um dataframe no final de outro dataframe, utilize a função bind_rows() do pacote dplyr. Essa é uma função bastante inclusiva, então, todas as colunas presentes em cada dataframe será incluída na saída. Algumas notas:

  • Diferente da versão row.bind() do R base, bind_rows() do dplyr não exige que a ordem das colunas seja a mesma em ambos os dataframes. Contanto que os nomes das colunas estejam grafados de forma idêntica, a função vai alinhá-las corretamente.
  • Você poder, opcionalmente, especificar o argumento .id =. Passe um nome de coluna para o argumento. Isso vai produzir uma nova coluna que serve para identificar de qual dataframe cada linha veio originalmente.
  • Você pode usar bind_rows() em uma lista de dataframes que tenham estrutura semelhante para combiná-los em um só. Veja um exemplo na página Iteração, loops e listas que envolve a importação de múltimplas linelists com purrr.

Um exemplo comum de anexação de linhas é anexar uma linha de “total” no final de uma tabela descritiva feita utilizando a função summarise() do dplyr. Abaixo nós criamos uma tabela de contagem de casos e medianas dos valores de CT por hospital com uma linha total.

A função summarise() é utilizada em dados agrupados por hospital para retornar um dataframe sumárizado por hospital. Porém, a função summarise() não gera colunas de “totais” automaticamente, então, nós criamos uma sumarizando os dados novamente, mas com os dados não agrupados por hospital. Isso produz um segundo dataframe de apenas uma linha. Nós podemos anexar (bind) esses dataframes um ao outro para chegar na tabela final.

Veja outros exemplos práticos como esse nas páginas Tabelas descritivas e Tabelas para apresentação.

# Cria a tabela principal
###################
hosp_summary <- linelist %>%
  group_by(hospital) %>% # Agrupa os dados por hospital
  summarise( # Cria colunas sumário dos indicadores de interesse
    cases = n(), # Número de linhas por grupo hospital-desfecho
    ct_value_med = median(ct_blood, na.rm = T)
  ) # valores medianos de CT por grupo

Aqui está o dataframe hosp_summary:

Cria um dataframe com as estatísticas de “total” (sem o agrupamento por hospital). Isso vai retornar apenas uma linha.

# cria totais
###############
totals <- linelist %>%
  summarise(
    cases = n(), # Número de linhas da base de dados inteira
    ct_value_med = median(ct_blood, na.rm = T)
  ) # CT Mediano para toda a base de dados

E abaixo está o dataframe totals. Note como tem apenas duas colunas. Essas colunas também está em hosp_summary, mas tem uma coluna em hosp_summary que não está em totals (hospital).

Agora podemos anexar as linhas com bind_rows().

# Bind data frames together
combined <- bind_rows(hosp_summary, totals)

Agora podemos visualizar o resultado. Veja como na última linha, um valor vazio, NA, preenche a coluna hospital que não estava em hosp_summary. Como explicado na página Tabelas para apresentação, você pode preencher essa célula com “Total” utilizando replace_na().

Anexar colunas

Existe uma função similar no dplyr, a bind_cols() que você pode utilizar para combinar dois dataframes “lateralmente”. Note que as linhas são combinadas umas com as outras por posição (não como no join acima) - por exemplo, a 12ª linha de cada dataframe estarão alinhadas.

Para demonstrar, vamos anexar várias tabelas de sumário juntas. Para fazer isso, também demonstraremos como rearranjar a ordem das linhas de um dataframe para combinar com a ordem do outro, com match().

Aqui nós definimos case_info como um dataframe de sumário dos casos da linelist, por hospital, com o número de casos e o número de mortes.

# Informação dos casos
case_info <- linelist %>%
  group_by(hospital) %>%
  summarise(
    cases = n(),
    deaths = sum(outcome == "Death", na.rm = T)
  )

Então, digamos que haja um dataframe diferente, contact_fu (fu -> follow up), contendo informação acerca da porcentagem de contatos expostos que foram investigados e “acompanhados” (follow-up), novamente por hospital.

contact_fu <- data.frame(
  hospital = c("St. Mark's Maternity Hospital (SMMH)", "Military Hospital", "Missing", "Central Hospital", "Port Hospital", "Other"),
  investigated = c("80%", "82%", NA, "78%", "64%", "55%"),
  per_fu = c("60%", "25%", NA, "20%", "75%", "80%") # fu -> follow up
)

Note que os hospitais são os mesmos, mas estão em ordens diferentes em cada dataframe. A solução mais fácil seria utilizar left_join() na coluna hospital, mas você poderia também utilizar bind_cols() com um passo extra.

Utilize match() para alinhar a ordem

Pelo fato da ordem das linhas ser diferente, um simples bind_cols() resultaria em um desalinhamento dos dados. Para consertar isso, podemos utilizar match() do base R para alinhar as linhas de um dataframe na mesma ordem de outro. Vamos assumir, para essa abordagem, que não há valores duplicados em nenhum dos dataframes.

Quando utilizamos match(), a sintaxe é match(ORDEM DO VETOR ALVO, COLUNA PARA MUDAR), onde o primeiro argumento é a ordem desejada (seja um vetor individual, ou nesse caso, uma coluna do dataframe), e o segundo argumento é uma coluna do dataframe que será reordenado. A saída do match() é um vetor de números representando a posição correta do ordenamento. Você pode ler mais com ?match.

match(case_info$hospital, contact_fu$hospital)
[1] NA  4  2  6  5  1

Você pode utilizar esse vetor numérico para reordenar o dataframe - coloque ele entre colchetes [ ] antes da vírgula. Leia mais sobre a sintaxe de subconjuntos com colchetes no base R na página Introdução ao R. O comando abaixo cria um novo dataframe, definido como o anterior em que as linhas estão ordenadas de acordo com o vetor numérico acima.

contact_fu_aligned <- contact_fu[match(case_info$hospital, contact_fu$hospital), ]

Agora podemos anexar as colunas dos dataframes um no outro, com a ordem de linhas correta. Note que algumas colunas estão duplicadas e vão precisar de uma limpeza utilizando rename(). Leia mais sobre bind_rows() aqui.

bind_cols(case_info, contact_fu)
New names:
• `hospital` -> `hospital...1`
• `hospital` -> `hospital...4`
# A tibble: 6 × 6
  hospital...1                     cases deaths hospital...4 investigated per_fu
  <chr>                            <int>  <int> <chr>        <chr>        <chr> 
1 Ausente                           1469    611 St. Mark's … 80%          60%   
2 Central Hospital                   454    193 Military Ho… 82%          25%   
3 Military Hospital                  896    399 Missing      <NA>         <NA>  
4 Other                              885    395 Central Hos… 78%          20%   
5 Port Hospital                     1762    785 Port Hospit… 64%          75%   
6 St. Mark's Maternity Hospital (…   422    199 Other        55%          80%   

Uma alternativo do R base a bind_cols é a função cbind(), que faz a mesma operação.

14.5 Recursos

A página do tidyverse sobre joins

A página do R for Data Science sobre dados relacionais

A página do tidyverse no dplyr sobre binding (ligação)

Uma vignette sobre fastLink na página do Github do pacote

Publicação descrevendo a metodologia do fastLink

Publicação descrevendo o pacote RecordLinkage