--- title: "Matrix operations" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{Matrix operations} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r setup, include = FALSE} knitr::opts_chunk$set( collapse = TRUE, comment = "#>" ) ``` dplyr verbs change *which* rows and columns a tidymatrix has. Matrix operations change the *values*. As elsewhere in the package, the active component decides the direction: with rows active an operation works row by row, with columns active column by column, and with the matrix active on all values at once. ```{r load-packages} library(tidymatrix) library(dplyr, warn.conflicts = FALSE) tm <- tidymatrix(big5_responses, big5_respondents, big5_items) ``` We use the `big5` personality survey: 400 respondents answering 30 items on a 1–5 scale (see `?big5`). ## Summary statistics into metadata `add_stats()` computes statistics along the active dimension and stores them as metadata columns. Per-respondent statistics are a standard data quality check in surveys: someone who gives the same answer to every question has a standard deviation close to zero. ```{r add-stats-rows} tm <- tm |> activate(rows) |> add_stats(mean, sd, .names = c("resp_mean", "resp_sd")) tm |> activate(rows) |> arrange(resp_sd) |> select(respondent_id, resp_mean, resp_sd, completion_min) ``` The respondents with the least variable answers also finished the survey in a couple of minutes. Sorting by completion time shows the other kind of careless respondent — those who answered at random, and so have a normal-looking standard deviation: ```{r add-stats-speed} tm |> activate(rows) |> arrange(completion_min) |> select(respondent_id, resp_sd, completion_min) ``` We remove both groups and move on with the clean data: ```{r clean} tm <- tm |> activate(rows) |> filter(completion_min > 3.5) ``` Per-item statistics work the same way with columns active. Custom functions can be passed as a named list with `.fns`: ```{r add-stats-cols} tm |> activate(columns) |> add_stats(.fns = list( item_mean = mean, item_sd = sd, pct_agree = \(x) mean(x >= 4) )) |> select(item_id, item_text, item_mean, item_sd, pct_agree) ``` ## Custom transformations with `transform_matrix()` `transform_matrix()` applies any function to the matrix. What the function receives depends on the active component: | Active | `fn` receives | Example | |---|---|---| | `matrix` | the whole matrix | `log`, `\(m) m / 4` | | `rows` | one row at a time | per-respondent ranks | | `columns` | one column at a time | per-item standardisation | The function must return values of the same size, and the row and column names are preserved. Extra arguments are passed on to the function; with rows active they are evaluated in the column metadata, with columns active in the row metadata (use `.env$x` for a variable `x` that clashes with a metadata column). ### Reverse-scoring items A questionnaire usually measures each trait with several statements, and the answers are later combined, for example averaged, into one score per trait. Some of the statements are deliberately worded in the opposite direction. These are called *reverse-keyed* items. They keep people who tend to agree with everything (or tick the same box throughout) from automatically ending up with high scores. In `big5`, two of the six items per trait are reverse-keyed. For example: | Trait | Normally keyed | Reverse-keyed | |---|---|---| | Extraversion | *"I start conversations."* | *"I keep in the background."* | | Conscientiousness | *"I finish what I start."* | *"I put off important tasks."* | | Neuroticism | *"I worry about things."* | *"I stay calm under pressure."* | Answering 5 ("strongly agree") to *"I keep in the background"* indicates *low* extraversion. Averaged as they are, such answers would cancel out the normally keyed ones. So before the items are combined, the answers to reverse-keyed items are flipped: 5 becomes 1, 4 becomes 2, 3 stays 3. On a 1–5 scale that is simply `6 - x`. Afterwards a high value always means a high trait level. This step is known as *reverse-scoring*. Which columns to flip is stored in the column metadata. With rows active the function gets one respondent's answers, one value per item. Extra arguments to `transform_matrix()` are passed on to the function, and they are evaluated with the column metadata as a data mask — just like the arguments of `mutate()`. So `flip = reversed` hands the function the `reversed` column, which lines up element by element with the answers: ```{r reverse} tm_scored <- tm |> activate(rows) |> transform_matrix(\(x, flip) ifelse(flip, 6L - x, x), flip = reversed) # E4 is positively keyed, E5 is reverse-keyed tm$matrix[1:5, c("E4", "E5")] tm_scored$matrix[1:5, c("E4", "E5")] ``` After scoring, items of the same trait correlate positively: ```{r reverse-check} round(cor(tm$matrix[, paste0("E", 1:6)])[5:6, 1:4], 2) round(cor(tm_scored$matrix[, paste0("E", 1:6)])[5:6, 1:4], 2) ``` ### Whole-matrix transformations With the matrix active, the function gets the full matrix. Rescaling the 1–5 answers to a 0–100 scale: ```{r transform-matrix} tm_scored |> activate(matrix) |> transform_matrix(\(m) (m - 1) / 4 * 100) |> pull_active() |> head(3) ``` ### Column- and row-wise transformations With columns active the function is applied to each item separately. For example, percentile ranks within each item: ```{r transform-cols} tm_scored |> activate(columns) |> transform_matrix(\(x) rank(x) / length(x)) |> activate(matrix) |> pull_active() |> head(3) |> round(2) ``` Extra arguments are passed on to the function: ```{r transform-args} tm_scored |> activate(columns) |> transform_matrix(\(x) x / 4) |> activate(matrix) |> transform_matrix(round, digits = 1) |> pull_active() |> head(3) ``` ## Centering and scaling `scale()` z-scores the active dimension (mean 0, sd 1), and `center()` only subtracts the mean. ### Standardising items Scaling columns puts every item on the same footing, whatever its mean and spread: ```{r scale-cols} tm_z <- tm_scored |> activate(columns) |> scale() round(colMeans(tm_z$matrix)[1:6], 3) round(apply(tm_z$matrix, 2, sd)[1:6], 3) ``` `scale()` takes the usual `center` and `scale` arguments, so `scale(center = TRUE, scale = FALSE)` is equivalent to `center()`. ### Removing response style Some people agree with nearly everything, others are more reserved. In psychology this is called *acquiescence*, and a simple way to reduce it is to center each respondent's answers on their own mean ("ipsatising"). This is done on the raw answers: because positively and reverse-keyed items partly cancel out, a respondent's raw mean mostly reflects their general tendency to agree. ```{r center-rows} tm_ipsative <- tm |> activate(rows) |> center() round(rowMeans(tm_ipsative$matrix)[1:5], 3) ``` ## Clipping `clip_values()` caps values at a minimum and/or maximum. A typical use is to limit extreme z-scores before drawing a heatmap, so that a few outliers do not use up the whole colour scale: ```{r clip} range(tm_z$matrix) tm_clipped <- tm_z |> activate(matrix) |> clip_values(min = -2, max = 2) range(tm_clipped$matrix) ``` ## Log transformation `log_transform()` is a convenience for skewed, count-like data such as gene expression or word counts, where a `log2(x + 1)` transformation is standard. It is not useful for Likert answers, so here is a small made-up count matrix: ```{r log} counts <- tidymatrix( matrix(c(0, 3, 12, 150, 7, 0, 1024, 45, 2), nrow = 3), data.frame(gene = c("g1", "g2", "g3")), data.frame(sample = c("s1", "s2", "s3")) ) counts |> activate(matrix) |> log_transform(base = 2, offset = 1) |> pull_active() |> round(2) ``` ## Transposing `t()` swaps rows and columns, including the metadata. If rows were active, columns are active afterwards, so the active *metadata* stays the same. ```{r transpose} tm_t <- t(tm_scored |> activate(rows)) dim(tm_scored$matrix) dim(tm_t$matrix) active(tm_t) names(tm_t$row_data) ``` Transposing is useful when a function only works along one direction, or when a package expects samples in rows rather than columns. ## Getting the matrix out `pull_active()` returns the active component: the matrix when the matrix is active, otherwise the active metadata. ```{r extract} m <- tm_scored |> activate(matrix) |> pull_active() class(m) dim(m) ``` ## Matrix operations and stored analyses Every operation that changes the values makes stored analysis results (principal components, clusterings, ...) out of date, so they are removed with a warning. The metadata columns they produced are kept. Transform first, analyse afterwards: ```{r invalidation} tm_pca <- tm_scored |> activate(columns) |> compute_prcomp(n_components = 2) list_analyses(tm_pca) tm_pca <- tm_pca |> activate(columns) |> scale() list_analyses(tm_pca) ``` ## See also * [PCA and clustering](statistical-analysis.html) builds on the scored matrix. * [Plotting and exporting](visualization.html) shows how to use scaled and clipped data in a heatmap.