How to plot Likert scales with a weighted survey in a dplyr friendly way | by Francisco Suárez Salas | Towards Data Science

If you have worked with Likert scale visualizations in R, probably you know the Likert package by Jason Bryer. Although this is a wonderful package, its capabilities when working with weighted surveys are limited. When looking for a workaround, I found this great post that explained the process but using a lot of base R functions that weren’t quite familiar to me. Finally, I managed to replicate the process in a dplyr friendly way that fitted into my workflow and thought that it could be useful to other R users facing the same issue. Here I will guide you through the whole process.
To make this a very general case, let’s generate some random data that you could find in a weighted survey. Our data frame will have an id for each respondent, their answer for 5 different questions on a Likert scale, a letter indicating the group they represent, and finally, the weight assigned to each respondent.
library(tidyverse)n_sample <- 100sample_data <- tibble(  id = seq(1, n_sample, 1),  q1 = sample.int(5, n_sample, replace = TRUE),  q2 = sample.int(5, n_sample, replace = TRUE),  q3 = sample.int(5, n_sample, replace = TRUE),  q4 = sample.int(5, n_sample, replace = TRUE),  q5 = sample.int(5, n_sample, replace = TRUE),  group = sample(c("A","B"), n_sample, replace = TRUE),  weight = runif(n_sample, 1, 3))
Our main goal here is to get our results weighted (or else we would be using the Likert package directly). To do this, you can use the super useful survey package that provides you with a lot of tools for working with survey data. This package is not that compatible with the tidyverse tools that I normally use, but luckily we can use the srvyr package, that allows us to work with the survey package functions using dplyr syntax.
First, we are going to transform our data into a survey object. Then we will define a function that extracts the weighted results for a single question, and finally, we will be applying this function to get the results for each of our questions.
library(srvyr)# Tranform sample data into survey datasurvey_data <- sample_data %>%  as_survey_design(id = 1,                   weights = weight)# Function that gets the weighted frequency of answers for a single questionget_weighted_results <- function(q, survey_data){  survey_data %>%    group_by(group) %>%    summarize(a = survey_total(get(q) == 1),              b = survey_total(get(q) == 2),              c = survey_total(get(q) == 3),              d = survey_total(get(q) == 4),              e = survey_total(get(q) == 5)) %>%    pivot_longer(-group,                 names_to = "ans",                 values_to = "freq") %>%    filter(!str_detect(ans, "se")) }# Apply function to each question and calculate proportionlikert_results <- tibble(q = c("q1", "q2", "q3", "q4", "q5")) %>%  mutate(results = map(q, get_weighted_results, survey_data)) %>%  unnest(results) %>%  group_by(q, group) %>%  mutate(prop = freq / sum(freq)) %>%  ungroup()
Let’s take a moment to study what our new function does:
Takes your survey data and groups it by a predefined variable. I’m assuming here that you have a grouping variable: if you don’t, just ignore this step.Summarizes to get the weighted frequencies for each possible answer on a Likert scale. Note that I’m saving each answer in a different letter. We will translate these letters into text later on.Pivots our data frame to a tidy format. This will be helpful when we start plotting with ggplot2.Filters rows containing “se”. By default, the survey_total function will also return the standard error of the total. We won’t use it here, but you can leave it if needed.
Takes your survey data and groups it by a predefined variable. I’m assuming here that you have a grouping variable: if you don’t, just ignore this step.
Summarizes to get the weighted frequencies for each possible answer on a Likert scale. Note that I’m saving each answer in a different letter. We will translate these letters into text later on.
Pivots our data frame to a tidy format. This will be helpful when we start plotting with ggplot2.
Filters rows containing “se”. By default, the survey_total function will also return the standard error of the total. We won’t use it here, but you can leave it if needed.
Then, we create a new data frame from scratch, with a column containing the variable names of our Likert questions. We apply our function to this column, unnest the results, and calculate the proportion that we will be plotting. You should get a data frame that looks like this:
In my case, I had a lot of Likert scale questions to show in different groupings, so I figured that the most convenient solution was to make a function that plotted whichever questions I needed. The function turned out quite long, but don’t be afraid of it, I will guide you through it step by step. Let’s take a look at it.
plot_weighted_likert <- function(x, my_labels, likert_results) {  # Filter data for questions needed  likert_results2 <- likert_results %>%    filter(q %in% x)  # Create data frame with labels  prop_labels <- likert_results2 %>%    mutate(      position = case_when(        ans == "a" | ans == "b" ~ "left",        ans == "c" ~ "center",        ans == "d" | ans == "e" ~ "right"      )    ) %>%    group_by(q, group, position) %>%    summarize(label = sum(prop * 100)) %>%    pivot_wider(names_from = position,                values_from = label)  # Data frame with right side values  high_columns <- likert_results2 %>%    filter(ans == "c" | ans == "d" | ans == "e") %>%    mutate(prop = case_when(ans == "c" ~ prop / 2 * 100,                            ans == "d" ~ prop * 100,                            ans == "e" ~ prop * 100,)) %>%    arrange(ans)  # Data frame with left side values  low_columns <- likert_results2 %>%    filter(ans == "a" | ans == "b" | ans == "c") %>%    mutate(prop = case_when(ans == "a" ~ prop * 100,                            ans == "b" ~ prop * 100,                            ans == "c" ~ prop / 2 * 100)) %>%    arrange(ans)  # Define empty ggplot object  p <- ggplot() +    # Add central black line    geom_hline(yintercept = 0) +    # Add right side columns    geom_bar(      data = high_columns,      mapping = aes(x = group,                    y = prop,                    fill = ans),      position = position_stack(reverse = TRUE),      stat = "identity"    ) +    # Add left side columns    geom_bar(      data = low_columns,      mapping = aes(x = group,                    y = -prop,                    fill = ans),      position = "stack",      stat = "identity"    ) +    # Left side labels    geom_text(      data = prop_labels,      mapping = aes(        x = group,        y = -100,        label = paste(round(left) , "%", sep = "")),        hjust = 1,        color = "black",        size = 3    ) +    # Central labels    geom_text(      data = prop_labels,      mapping = aes(        x = group,        y = 0,        label = paste(round(center) , "%", sep = "")),        hjust = 0.5,        color = "black",        size = 3    ) +    # Right side labels    geom_text(      data = prop_labels,      mapping = aes(        x = group,        y = 100,        label = paste(round(right) , "%", sep = "")),        hjust = -0.2,        color = "black",        size = 3    )  +    # Scale formatting    scale_y_continuous(      breaks = seq(-100, 100, 50),      limits = c(-105, 105),      labels = abs    )  +    # More formatting    theme(legend.title = element_blank(),          legend.position = "bottom",          axis.ticks = element_blank(),          strip.background = element_rect(fill = "#F0F0F0",                                          color = "#F0F0F0"),          panel.background = element_blank(),          panel.border = element_rect(            colour = "#F0F0F0",            fill = NA,            size = 1.5)          ) +    facet_wrap(~ q, ncol = 1) +    coord_flip() +    ylab("Proportion") +    xlab("") +    # Change Likert labels    scale_fill_discrete(labels = my_labels)  # Print the plot  p}
Ok, so, what are we doing here?
Our function will take a character vector with the name of the questions that we want to plot, another character vector containing the labels of our Likert scale, and the data frame we had created.The first step is to filter the data frame because we are just interested in a given set of questions.We then calculate the labels that will go on the left, center, and right side of our plot. As in the original Likert package, on each side, we want to show the percentage of extreme answers. For this reason, we create a new column named position (left, right, or center), calculate the sum of the proportions for each position, and finally, pivot the data frame into a wider format so it’s easier to use.Now we will prepare two new data frames to be plotted, which will contain the lower and higher values separately The central value will be divided by two and each half will go in one of these data frames. We have to arrange each data frame by the `ans` column, so values are stacked properly in our plot.We are ready to start plotting. To start, we create an empty ggplot object and add our central line.Then we add two geom_bar , for our left and right columns. Note two things here: for the left-side values, we are plotting -value as our y aesthetic, and we are specifying position_stack(reverse = TRUE) in the right-side bars so they stack in our desired order.We then add three geom_text to plot our percentage labels. As our labels are currently decimal numbers, we round them and paste them with a percentage sign. For the hjust parameters, I just went along with the default values in the original Likert package.Next, we give formatting to our y scale. Here I pass the abs function in thelabels parameters so our labels go from 100 to 0 to 100.Then I do some more formatting with theme. In this case, I went for a panel border a little different than the original, because I liked it better. After that, I add the faceting, flip the scales, add labels,Finally, I add a scale_fill_discrete to change my “a, b, c, d, e” labels to the desired ones.
Our function will take a character vector with the name of the questions that we want to plot, another character vector containing the labels of our Likert scale, and the data frame we had created.
The first step is to filter the data frame because we are just interested in a given set of questions.
We then calculate the labels that will go on the left, center, and right side of our plot. As in the original Likert package, on each side, we want to show the percentage of extreme answers. For this reason, we create a new column named position (left, right, or center), calculate the sum of the proportions for each position, and finally, pivot the data frame into a wider format so it’s easier to use.
Now we will prepare two new data frames to be plotted, which will contain the lower and higher values separately The central value will be divided by two and each half will go in one of these data frames. We have to arrange each data frame by the `ans` column, so values are stacked properly in our plot.
We are ready to start plotting. To start, we create an empty ggplot object and add our central line.
Then we add two geom_bar , for our left and right columns. Note two things here: for the left-side values, we are plotting -value as our y aesthetic, and we are specifying position_stack(reverse = TRUE) in the right-side bars so they stack in our desired order.
We then add three geom_text to plot our percentage labels. As our labels are currently decimal numbers, we round them and paste them with a percentage sign. For the hjust parameters, I just went along with the default values in the original Likert package.
Next, we give formatting to our y scale. Here I pass the abs function in thelabels parameters so our labels go from 100 to 0 to 100.
Then I do some more formatting with theme. In this case, I went for a panel border a little different than the original, because I liked it better. After that, I add the faceting, flip the scales, add labels,
Finally, I add a scale_fill_discrete to change my “a, b, c, d, e” labels to the desired ones.
Let’s see some examples of the resulting Likert plots. (Don’t forget to define a labels object, as there is no default one in the function).
my_labels <- c("Very\nunsatisfied",               "Unsatisfied",               "Neutral",               "Satisfied",               "Very\nsatistifed")plot_weighted_likert(c("q1"), my_labels, likert_results)plot_weighted_likert(c("q2", "q3", "q4"), my_labels, likert_results)plot_weighted_likert(c("q1", "q2", "q3", "q4", "q5"), my_labels, likert_results)
And that’s it! I hope this has been helpful and let me know if you have any doubts.
There could be some refinements that you may need to do to your plots that I did not cover here. I’ll address these issues briefly:
Personalized colors. To do this, replace the scale_fill_discrete for a scale_fill_manual , keep the labels parameter and add a 5 color vector to the values parameter.
Groups order. In our resulting plots, we got group B plotted above group A. To define the order in which groups will be plotted, you can mutate your group column using the factor function and specifying the order of the groups.
Questions labels. The simplest way here is to make your q column contain the question text. In this way, the function will keep working as intended. You can also add a new text column that contains the question text. If you do this, you will have to modify the function so it uses text as a variable instead of q when needed.
Questions order. When plotting questions text, your questions may be shown in a different order than expected. To account for this issue, you could create a text_order vector that contains the questions in the order that you need them to be shown, and mutate the text column in the likert_results2 data frame (inside the function, after you filter it) with the factor function and passing this vector.