Introduction

This code-through demonstrates how to prepare and visualize pre-post test survey data from School Participatory Budgeting (SPB), a civic education program designed to engage students in democratic decision-making. Through the SPB process, students “learn democracy by doing” through a process of participating in SPB steering committees on campus, developing proposals, campaigning and deliberating on projects, and voting to fund projects that students select through a democratic process.

Student growth is measured through a pre-post test survey examining four domains of civic learning: Knowledge, Attitudes, Skills, and Practices (KASP). Each domain includes multiple indicators measured on a 1–5 Likert scale. Together, these indicators provide a way to examine both overall changes within each KASP domain and changes across individual indicators following participation in SPB.

Content Overview

This code-through demonstrates two approaches for visualizing KASP survey results using ggplot2. First, a traditional bar graph is used to compare average pre-post test scores across the four KASP domains. The analysis then extends this approach by creating a circular bar plot that displays average pre-to-post change across individual KASP indicators while grouping them by domain.

Why You Should Care

Bar graphs are one of the most common tools for communicating quantitative data, but their design can be adapted to display different levels of information. A traditional bar graph provides a clear way to compare overall results across a small number of categories, while a circular bar plot can organize and display a larger number of indicators within meaningful groups. Using both approaches makes it possible to examine KASP outcomes at both the domain and individual indicator levels.

Learning Objectives

By the end of this code-through, you will be able to:

  • Prepare matched pre-post test survey data for visualization
  • Restructure data from wide to long format using pivot_longer()
  • Summarize survey responses by calculating mean scores and descriptive statistics
  • Create a traditional bar graph to compare pre-post test scores across KASP domains using ggplot2
  • Create a circular bar plot to visualize average pre-to-post change across individual KASP indicators grouped by domain

Visualizing KASP Survey Data

The analysis begins with a traditional bar graph that summarizes pre-post test scores across the four KASP domains. This provides a foundation for understanding the structure of the data and the overall patterns of change before moving to a more detailed visualization at the individual indicator level.

# Load packages that will be used in this code-through
library(readxl)
library(dplyr)
library(tidyr)
library(stringr)
library(tidyverse)
library(ggplot2)
library(gridExtra)
library(patchwork)

Basic Example: Pre-Post Test Bar Graph

The basic example demonstrates how to import the cleaned KASP analysis dataset, prepare matched pre-post test responses, summarize scores across the four KASP domains, and visualize the results using ggplot2. This provides a straightforward comparison of average Knowledge, Attitudes, Skills, and Practices scores before and after participation in SPB.

#Import the cleaned KASP analysis dataset
kasp_analysis <-readRDS("kasp_analysis.rds")

head(kasp_analysis)

The first visualization uses a bar graph created with the ggplot2 package. The approach is adapted from the R Graph Gallery’s grouped and stacked bar plot examples (Holtz, 2025b).

Before creating the bar graph, the KASP data need to be prepared so that the pre-post test scores can be compared across the four domains. For each domain, filter() removes records that are missing either a pre or post test score. This ensures that the analysis includes only respondents with matched pre-post test data for that domain.

Next, transmute() selects the respondent ID and corresponding domain scores and standardizes the variable names as Pre and Post. The domain variable is also created to identify whether each set of scores represents Knowledge, Attitudes, Skills, or Practices. Finally, bind_rows() combines the four domain-specific datasets into a single dataset.

paired_scores <- bind_rows(kasp_analysis %>%
    filter(!is.na(knowledge_pre), !is.na(knowledge_post)) %>%
    transmute(
      record_id,
      domain = "Knowledge",
      Pre = knowledge_pre,
      Post = knowledge_post),

  kasp_analysis %>%
    filter(!is.na(attitudes_pre), !is.na(attitudes_post)) %>%
    transmute(
      record_id,
      domain = "Attitudes",
      Pre = attitudes_pre,
      Post = attitudes_post),

  kasp_analysis %>%
    filter(!is.na(skills_pre), !is.na(skills_post)) %>%
    transmute(
      record_id,
      domain = "Skills",
      Pre = skills_pre,
      Post = skills_post),

  kasp_analysis %>%
    filter(!is.na(practices_pre), !is.na(practices_post)) %>%
    transmute(
      record_id,
      domain = "Practices",
      Pre = practices_pre,
      Post = practices_post)) %>%
  
  pivot_longer(
  cols = c(Pre, Post),
  names_to = "time",
  values_to = "score")

Before applying pivot_longer(), the data are organized in a wide format, with separate columns for the pre-post test scores:

knitr::include_graphics("Image 1.png")

The pivot_longer() function is used to change the data from wide to long format to prepare it for the bar graph. The original Pre and Post column names are moved into a new variable called time, which identifies when the survey was administered. The corresponding pre-post test values are placed in a new variable called score.

This long format creates one row for each respondent, KASP domain, and survey time point, making it easier to group, summarize, and visualize the scores using ggplot2.

The restructured data now look like this:

knitr::include_graphics("Image 2.png")

Next, the respondent scores for each KASP domain are grouped into one average score for each domain and survey time (Pre-Post). The group_by() function groups observations by domain and time. Summarize() calculates the mean score, standard deviation, count of observations, standard error, and confidence intervals. Finally, mutate() modifies the columns domain and time into the order K-A-S-P and pre-post appear consecutively for how the data will be displayed.

bar_data <- paired_scores %>%
  group_by(domain, time) %>%
  summarize(mean_score = mean(score),
    sd_score = sd(score), n = n(),
    standard_error = sd_score / sqrt(n),
    confidence_interval = qt(0.975, df = n - 1) * standard_error,
    .groups = "drop") %>%
  mutate(domain = factor(domain,levels = c("Knowledge", "Attitudes", "Skills", "Practices")),
    time = factor(time, levels = c("Pre", "Post")))

This is now how the prepared data looks:

knitr::include_graphics("Image 3.png")

Ggplot can then be used to create the bar graph.

# Bar graph for each domain
ggplot(bar_data,aes(
    x = time,
    y = mean_score,
    fill = time)) +
  
  geom_col(
    width = 0.65) +

  facet_wrap(~ domain,
    nrow = 1,
    strip.position = "bottom") +

  scale_fill_manual(
    values = c(
      "Pre" = "#222e69",
      "Post" = "#fcb447")) +

  scale_y_continuous(
    limits = c(0, 5.4),
    breaks = 0:5) +

# These create labels for the bar graph.
  labs(
    title = "KASP Scores Before and After SPB",
    subtitle = "Mean scores among matched respondents",
    x = NULL,
    y = "Mean score (1–5)",
    fill = NULL)


Next, the bar chart can be refined to make the results easier to interpret. Mean scores can be added above each pre-post test bar, allowing viewers to quickly identify the average score for each domain. Applying a minimal theme removes the background grid lines and creates a cleaner visualization. The labels can also be adjusted so that each KASP domain appears below its pre-post test bars. Finally, error bars can be added to display the 95% confidence intervals around the mean scores for each domain.

ggplot(bar_data,aes(
    x = time,
    y = mean_score,
    fill = time)) +
  
  geom_col(
    width = 0.65) +
  
#Add the mean scores above the bars
  geom_text(
    aes(
      y=mean_score + 0.15,
      label = sprintf("%.2f", mean_score)),
    vjust = -0.5,
    size = 4) +

  facet_wrap(~ domain,
    nrow = 1,
    strip.position = "bottom") +

  scale_fill_manual(
    values = c(
      "Pre" = "#222e69",
      "Post" = "#fcb447")) +

  scale_y_continuous(
    limits = c(0, 5.4),
    breaks = 0:5) +

# These create labels for the bar graph.
  labs(
    title = "KASP Scores Before and After SPB",
    subtitle = "Mean scores among matched respondents",
    x = NULL,
    y = "Mean score (1–5)",
    fill = NULL) +
  
  theme_minimal() +
  
theme(
    legend.position = "bottom",
    panel.grid.major.x = element_blank(),
    strip.placement = "outside",
    strip.background = element_blank(),
    strip.text = element_text(
      face = "bold",
      size = 11),
    plot.title = element_text(
      face = "bold")) +
 
   geom_errorbar(
    aes(
      ymin = mean_score - confidence_interval,
      ymax = mean_score + confidence_interval),
    width = 0.15) +
   
  labs(
    caption = "Error bars represent 95% confidence intervals.")


Advanced Example: Circular Bar Plot

Building on the basic bar chart, we can create a circular bar plot to visualize change across individual KASP indicators while grouping the indicators by domain. This approach is particularly useful when displaying a large number of indicators because it allows Knowledge, Attitudes, Skills, and Practices to be presented together in a single visualization. The circular bar plot used in this example is adapted from the R Graph Gallery (Holtz, 2025a).

Before building the circular bar plot, we first need to prepare and restructure the KASP data. The following steps transform the pre-post test responses into a format that allows us to calculate and visualize the average change for each individual KASP indicator.

indicator_scores <- kasp_analysis %>%
  select(record_id, matches("^[kasp][0-9]+_(pre|post)$")) %>%

  pivot_longer(
    cols = -record_id,
    names_to = c("domain_code", "item", "time"),
    names_pattern = "([kasp])([0-9]+)_(pre|post)",
    values_to = "score") %>%

  pivot_wider(
    names_from = time,
    values_from = score) %>%
  
# Each row now represents one student and one KASP indicator

# Keep matched pre post responses only
  
  filter(
    !is.na(pre),
    !is.na(post))%>%
  
#Calculate the change from the student's pre score to their post score. 
#This is important because the graph should represent the amount of change per indicator. 
  mutate(change = post - pre,
    item_number = as.integer(item),
    item_label = paste0(toupper(domain_code),item_number)) %>%

  group_by(
    domain_code,
    item_number,
    item_label) %>%

  summarize(mean_change = mean(change), n = n(),.groups = "drop")

Now that the data are organized into a table with the appropriate headers, we can use the prepared data to build the circular bar graph.

gap_size <- 2

# Create KASP ordering
indicator_scores <- indicator_scores %>%
  mutate(domain_number = case_when(
      domain_code == "k" ~ 1,
      domain_code == "a" ~ 2,
      domain_code == "s" ~ 3,
      domain_code == "p" ~ 4)) %>%
  arrange(domain_number, item_number)


# Create bar positions with gaps between domains
indicator_scores <- indicator_scores %>%
  group_by(domain_code) %>%
  mutate(position_within_domain = row_number()) %>%
  ungroup() %>%
  mutate(id = row_number() + (domain_number - 1) * gap_size)

# Total positions around circle
number_of_positions <- max(indicator_scores$id) + gap_size

# Calculate label angles
label_data <- indicator_scores %>%
  mutate(angle = 90 - 360 * (id - 0.5) / number_of_positions,
         hjust = if_else( angle < -90, 1, 0),
    angle = if_else(angle < -90, angle + 180, angle))

# Calculate plotting range
change_limit <- max(abs(indicator_scores$mean_change), na.rm = TRUE)
change_limit <- ceiling(change_limit * 10) / 10

# Give center of circle enough empty space for labels
inner_limit <- -change_limit * 0.75

# Give labels enough room outside bars
outer_limit <- change_limit * 1.35

# Find center of each KASP section
domain_labels <- indicator_scores %>%
  group_by(domain_code) %>%
  summarize(id = mean(range(id)), .groups = "drop") %>%
  mutate(domain_letter = toupper(domain_code))

# Plot on the circular bar graph
ggplot(indicator_scores,
    aes(
    x = id,
    y = mean_change,
    fill = domain_code)) +

# Place reference line where value is zero
  geom_hline(
    yintercept = 0,
    color = "#222e69",
    linewidth = 0.8) +

  geom_col(
    width = 0.72,
    color = "white",
    linewidth = 0.2) +

# Item labels around the outside
  geom_text(data = label_data,
  aes(
    x = id,
    y = change_limit * 1.15,
    label = paste0(item_label," (",sprintf("%+.2f", mean_change),")"),
    angle = angle,
    hjust = hjust),
  inherit.aes = FALSE,
  size = 3,
  fontface = "bold",
  lineheight = 0.9) +

# K, A, S, and P inside the circle
geom_text(data = domain_labels,
  aes(
    x = id,
    y = inner_limit * 0.2,
    label = domain_letter),
  inherit.aes = FALSE,
  color = "#222e69",
  size = 7,
  fontface = "bold") +

  scale_fill_manual(
    values = c(
      "k" = "#407ec9",
      "a" = "#62a97a",
      "s" = "#fcb447",
      "p" = "#510c51"),
    breaks = c("k", "a", "s", "p"),
    labels = c("K", "A", "S", "P")) +

  scale_x_continuous(limits = c(0.5, number_of_positions + 0.5), breaks = NULL, expand = c(0,0)) +

  scale_y_continuous(limits = c(inner_limit, outer_limit)) +

  coord_polar(start = 0, clip = "off") +

# Add labels
  labs(title = "Change in KASP Item Scores After SPB",
    subtitle = "Average matched change for each survey question",
    fill = NULL,
    caption = "Change = post-score − pre-score") +

# Clean design
  theme_void() +
  theme(legend.position = "bottom", plot.title = element_text(
      face = "bold",
      hjust = 0.5,
      size = 16),
      plot.subtitle = element_text(hjust = 0.5),
      plot.caption = element_text(hjust = 0.5), 
      plot.margin = margin(20, 40, 20, 40))


The circular bar chart shows the average pre-to-post change for each of the 40 KASP indicators. Positive values indicate that students reported higher scores after participating in SPB. Across the indicators, mean change ranged from approximately +0.14 to +1.19 points on the five-point scale. The visualization also makes it possible to compare patterns in growth across the Knowledge, Attitudes, Skills, and Practices domains.

Most notably, it’s valuable for providing a visual on a large number of indicators that can be organized into categories. While the basic bar chart is useful for summarizing the overall pre-post change across the four KASP domains, the circular bar chart provides a more in depth view of change across individual survey indicators. Together, the two visualizations provide both a high-level summary of student growth and a more granular understanding of where changes occurred across the KASP indicators.



Further Resources

Learn more about the packages and School Participatory Budgeting with the following:




Works Cited

This code through references and cites the following sources: