---
title: "Joins"
output: rmarkdown::html_vignette
vignette: >
%\VignetteIndexEntry{Joins}
%\VignetteEngine{knitr::rmarkdown}
%\VignetteEncoding{UTF-8}
---
```{r setup, include = FALSE}
knitr::opts_chunk$set(
collapse = TRUE,
comment = "#>"
)
```
Metadata often lives in more than one table. tidymatrix supports all six dplyr
joins on the active metadata. Joins that only *add columns* leave the matrix
alone; joins that *add or remove rows* of the metadata add or remove the
corresponding rows (or columns) of the matrix.
```{r load-packages}
library(tidymatrix)
library(dplyr, warn.conflicts = FALSE)
tm <- tidymatrix(big5_responses, big5_respondents, big5_items) |>
activate(rows) |>
filter(completion_min > 3.5)
```
We use the `big5` personality survey (see `?big5`), without the careless
respondents. Respondents come from six countries:
```{r countries-in-data}
tm |> activate(rows) |> pull(country) |> table()
```
The `big5_countries` table has extra information about the countries. It is
deliberately imperfect: Germany (`DE`) is missing and Norway (`NO`) has no
respondents.
```{r countries}
big5_countries
```
## Mutating joins on rows
### Left join: add columns, keep every respondent
`left_join()` is the usual way to add annotations. Every respondent is kept;
the ones without a match (Germany) get `NA`.
```{r left-join}
tm_left <- tm |>
activate(rows) |>
left_join(big5_countries, by = "country")
tm_left
tm_left |>
activate(rows) |>
filter(is.na(country_name)) |>
select(respondent_id, country, country_name)
identical(dim(tm_left$matrix), dim(tm$matrix))
```
### Inner join: keep only respondents with a match
`inner_join()` drops the German respondents from the metadata *and* from the
matrix.
```{r inner-join}
tm_inner <- tm |>
activate(rows) |>
inner_join(big5_countries, by = "country")
nrow(tm$matrix)
nrow(tm_inner$matrix)
```
Now we can, for example, compare Baltic and Nordic respondents:
```{r inner-join-use}
tm_inner |>
activate(rows) |>
count(region)
```
### Right and full joins: new rows get `NA` in the matrix
`right_join()` keeps every row of the *external* table, and `full_join()` keeps
every row of both. A row that exists only in the external table — Norway
here — has no answers, so its row of the matrix is filled with `NA`.
```{r full-join}
tm_full <- tm |>
activate(rows) |>
full_join(big5_countries, by = "country")
nrow(tm_full$matrix)
tm_full |>
activate(rows) |>
filter(is.na(respondent_id)) |>
select(respondent_id, country, country_name)
tail(tm_full$matrix[, 1:6], 3)
```
These joins are rarely what you want for annotations, but they can be useful
to line up a matrix against a fixed list of expected rows or columns.
## Filtering joins
`semi_join()` and `anti_join()` filter the metadata by whether a match exists,
without adding any columns.
```{r semi-anti}
# respondents from countries in the table
tm_semi <- tm |>
activate(rows) |>
semi_join(big5_countries, by = "country")
nrow(tm_semi$matrix)
ncol(tm_semi$row_data) # no new columns
# respondents from countries missing from the table
tm |>
activate(rows) |>
anti_join(big5_countries, by = "country") |>
select(respondent_id, country, age, gender)
```
`anti_join()` is a handy check before a `left_join()`: it shows exactly which
rows will end up with missing annotations.
## Joins on columns
Everything above works for the column metadata too. Here is a table describing
the five traits:
```{r trait-table}
trait_info <- data.frame(
trait = c("Extraversion", "Agreeableness", "Conscientiousness",
"Neuroticism", "Openness"),
abbreviation = c("E", "A", "C", "N", "O"),
high_pole = c("outgoing, energetic", "friendly, compassionate",
"organised, dependable", "anxious, moody",
"curious, imaginative"),
low_pole = c("reserved, quiet", "critical, detached",
"careless, spontaneous", "calm, stable",
"conventional, practical")
)
tm_items <- tm |>
activate(columns) |>
left_join(trait_info, by = "trait")
tm_items |>
activate(columns) |>
select(item_id, trait, abbreviation, high_pole)
```
A filtering join is a convenient way to select a predefined subset of columns.
Suppose a short form of the questionnaire uses only two items per trait:
```{r short-form}
short_form <- data.frame(
item_id = c("E1", "E5", "A1", "A5", "C1", "C5", "N1", "N5", "O1", "O5")
)
tm_short <- tm |>
activate(columns) |>
semi_join(short_form, by = "item_id")
colnames(tm_short$matrix)
```
Note that the matrix columns keep their original order, not the order of
`short_form`. Use `arrange()` afterwards if the order matters.
## Joining on differently named keys
`by` accepts everything `dplyr` does, including `join_by()` and named vectors
for keys with different names:
```{r different-keys}
country_codes <- data.frame(
iso2 = c("EE", "FI", "LV", "LT", "SE", "DE"),
eu_member_since = c(2004, 1995, 2004, 2004, 1995, 1958)
)
tm |>
activate(rows) |>
left_join(country_codes, by = join_by(country == iso2)) |>
select(respondent_id, country, eu_member_since)
```
## Duplicate keys
If the external table has several rows for the same key, a mutating join
duplicates the matching metadata rows — and the matrix rows with them. This is
the same behaviour as in dplyr, and it is almost always a mistake when
annotating a matrix. Check that the key is unique first:
```{r unique-keys}
anyDuplicated(big5_countries$country) == 0
```
## Joins and stored analyses
Stored analysis objects (PCA, clustering, ...) are removed after a join,
because a join may change which rows or columns are present. The metadata
columns an analysis created are kept. If you need both, join first and analyse
afterwards:
```{r join-then-analyse}
tm_analysed <- tm |>
activate(rows) |>
left_join(big5_countries, by = "country") |>
activate(columns) |>
compute_hclust(k = 5, method = "ward.D2")
list_analyses(tm_analysed)
```
## See also
* [Working with rows and columns](dplyr-verbs.html) for the other dplyr verbs.
* [Getting started](basic-usage.html) for an overview of the package.