2  Introduction to R and Quarto

Author

Longhai Li

Published

September 21, 2026

2.1 A toy example

Throughout this unit we will use one running example to introduce the basic building blocks of R: vectors, strings, special values, matrices, data frames, lists, functions, file I/O, and graphics.

The mission: a class of 100 students has taken graded work in three categories: five quizzes, two tests, and one final exam, each scored out of 100 points. A student’s course score is a weighted average of the category means – quizzes count 30%, tests count 30%, and the final counts 40%. To make the simulated data realistic, a student’s test and final scores are generated from their quiz performance by a simple linear regression, with a different intercept for Program A and Program B students, and a few quizzes are missing entirely (a student who did not sit that quiz). We want to

  1. work out the calculation step by step for one student,
  2. package that calculation into a reusable function,
  3. apply the function to every student to get a full set of results, and
  4. present those results as a table and as plots.

This mirrors how real data-analysis code is usually built: explore on one case, generalize into a function, then apply and report.

2.2 Generating an artificial score dataset

2.2.1 Vectors: recording quiz scores

Each quiz, across all students, is naturally stored as a vector. We simulate a class of 100 students, five quizzes scored out of 100, using rnorm and clipping the result to a valid 0-100 range with pmin/pmax:

set.seed(123)
n <- 100

gen.quiz <- function(mean, sd)
{
    x <- round(rnorm(n, mean, sd))
    pmin(100, pmax(0, x))
}

Quiz1 <- gen.quiz(75, 20)
Quiz2 <- gen.quiz(78, 20)
Quiz3 <- gen.quiz(72, 20)
Quiz4 <- gen.quiz(80, 20)
Quiz5 <- gen.quiz(74, 20)

Quiz1
  [1]  64  70 100  76  78 100  84  50  61  66  99  82  83  77  64 100  85  36
 [19]  89  66  54  71  54  60  62  41  92  78  52 100  84  69  93  93  91  89
 [37]  86  74  69  67  61  71  50 100  99  53  67  66  91  73  80  74  74 100
 [55]  70 100  44  87  77  79  83  65  68  55  54  81  84  76  93 100  65  29
 [73]  95  61  61  96  69  51  79  72  75  83  68  88  71  82  97  84  68  98
 [91]  95  86  80  62 100  63 100 100  70  54

2.2.2 Strings: naming the categories and students

We could type out "Quiz1", "Quiz2", … by hand, but paste0 builds such labels programmatically – a habit that pays off the moment there are 40 quizzes instead of 5:

quiz.names <- paste0("Quiz", 1:5)
test.names <- paste0("Test", 1:2)

quiz.names
[1] "Quiz1" "Quiz2" "Quiz3" "Quiz4" "Quiz5"
test.names
[1] "Test1" "Test2"

With 100 students, typing out individual names is not realistic either. sprintf generates zero-padded IDs like "S001", "S002", … in one line – exactly the kind of job it is built for:

students <- sprintf("S%03d", 1:n)

head(students)
[1] "S001" "S002" "S003" "S004" "S005" "S006"

2.2.3 Simulating the tests and the final using vectorized math

In a real class, a student’s test and final scores are not independent of how they did on the quizzes. We simulate that by treating each student’s mean quiz score as a predictor in a simple linear regression.

This is a great place to demonstrate vectorized calculations in R. Instead of writing a for loop to calculate each student’s score one by one, we can simply add and multiply whole vectors of length 100 together. R aligns the elements automatically:

program <- factor(sample(c("Program A", "Program B"), n, replace = TRUE))

quiz.mat <- cbind(Quiz1, Quiz2, Quiz3, Quiz4, Quiz5)

head(quiz.mat)
     Quiz1 Quiz2 Quiz3 Quiz4 Quiz5
[1,]    64    64   100    66    73
[2,]    70    83    98    65    51
[3,]   100    73    67    61    61
[4,]    76    71    83    59    73
[5,]    78    59    64    71    87
[6,]   100    77    62    87    41
quiz.mean.true <- rowMeans(quiz.mat)
data.frame(program, quiz.mean.true)

The intercept depends on program, so we compute it with ifelse; the slope is the same for every student:

b0.test <- ifelse(program == "Program A", 12, 6)
b1.test <- 0.80

data.frame(program, b0.test, b1.test)

The final exam uses its own intercept and slope, computed the same way:

b0.final <- ifelse(program == "Program A", 15, 8)
b1.final <- 0.85

data.frame(program, b0.final, b1.final)

clip.round rounds a score and clips it to the valid 0-100 range – we’ll reuse it for every test and the final:

clip.round <- function(x) round(pmin(100, pmax(0, x)))

Now we add random error to the linear predictor to get each student’s raw Test1 score, then clip and round it:

error <- rnorm(n, 0, 8)
Test1.raw <- b0.test + b1.test*quiz.mean.true + error

data.frame(program, b0.test, b1.test, quiz.mean.true, error, Test1.raw)
Test1 <- clip.round(Test1.raw)

data.frame(program, Test1.raw, Test1)

Using the above method, we generate two tests and a final:

# Vectorized math: b0.test + b1.test * quiz.mean.true + rnorm(...)
Test1 <- clip.round(b0.test  + b1.test*quiz.mean.true  + rnorm(n, 0, 8))
Test2 <- clip.round(b0.test  + b1.test*quiz.mean.true  + rnorm(n, 0, 8))
Final <- clip.round(b0.final + b1.final*quiz.mean.true + rnorm(n, 0, 6))

2.2.4 Matrices: combining all the scores (Complete Data)

Before we simulate absences (missing values), let’s bind the quiz, test, and final columns together into one complete matrix. It will have one row per student and one column per graded item.

scores <- cbind(quiz.mat, Test1, Test2, Final)
colnames(scores) <- c(quiz.names, test.names, "Final")
rownames(scores) <- students

# Here is our complete dataset before any missing values
head(scores)
     Quiz1 Quiz2 Quiz3 Quiz4 Quiz5 Test1 Test2 Final
S001    64    64   100    66    73    55    83    81
S002    70    83    98    65    51    66    64    70
S003   100    73    67    61    61    69    68    76
S004    76    71    83    59    73    58    72    70
S005    78    59    64    71    87    67    62    83
S006   100    77    62    87    41    64    70    74

2.2.5 Adding and handling missing values

In reality, students occasionally miss a quiz. Let’s randomly drop about 5% of the quiz scores by assigning them the special value NA (Not Available).

miss.rate <- 0.05
sample(n, round(n*miss.rate))
[1] 31 35 73 49 38
# We only want to introduce NAs into the first 5 columns (the quizzes)
for(j in 1:ncol(scores)) {
    scores[sample(n, round(n*miss.rate)), j] <- NA
}

head(scores, 10)
     Quiz1 Quiz2 Quiz3 Quiz4 Quiz5 Test1 Test2 Final
S001    64    64   100    66    73    55    83    81
S002    70    83    98    65    51    66    64    70
S003   100    73    67    61    61    69    68    76
S004    76    71    83    59    73    58    NA    70
S005    78    59    64    NA    87    67    62    83
S006   100    77    62    87    41    64    70    74
S007    84    62    56    40    67    70    70    59
S008    50    45    NA    84    89    63    57    62
S009    61    70   100    NA    63    75    53    76
S010    66    96    71   100    79    79    76    95

is.na locates missing values anywhere in an object. Only the quiz columns have any, since we generated Test1, Test2, and Final without missingness:

colSums(is.na(scores))
Quiz1 Quiz2 Quiz3 Quiz4 Quiz5 Test1 Test2 Final 
    5     5     5     5     5     5     5     5 

We will rely on na.rm = TRUE later so that a missing quiz simply drops out of a student’s quiz average, rather than returning NA for their whole course score.

2.2.6 Data frames: adding names and program

A data frame lets us combine columns of different types – here, the students’ names and program alongside their (numeric) scores – into a single table:

roster <- data.frame(name = students, program = program, scores, check.names = FALSE)

roster

2.3 Calculating a Single Student’s Score

Before writing a function, it often helps to work through the logic step by step on a single example. Let’s find a student who missed a quiz so we can see how missing values are handled, and extract their record:

# Find the index of the first student who has at least one NA in columns 1 to 5
example.ix <- which(rowSums(is.na(scores[, 1:5])) > 0)[1]
student_record <- roster[example.ix, ]

student_record

To avoid hardcoding column positions, we can extract the column names from this record and use grep to dynamically search for columns starting with “Quiz”, “Test”, or “Final”:

# Extract all the column names from the student record
col_names <- names(student_record)
col_names
 [1] "name"    "program" "Quiz1"   "Quiz2"   "Quiz3"   "Quiz4"   "Quiz5"  
 [8] "Test1"   "Test2"   "Final"  
# Use grep to find the numeric indices of specific categories
grep("^Quiz", col_names, value = TRUE)
[1] "Quiz1" "Quiz2" "Quiz3" "Quiz4" "Quiz5"
quiz.cols <- grep("^Quiz", col_names)
quiz.cols
[1] 3 4 5 6 7
test.cols <- grep("^Test", col_names)
final.col <- grep("^Final", col_names)
# Check which columns grep found for the quizzes
quiz.cols
[1] 3 4 5 6 7

Next, we extract the scores using those indices, convert them to numeric values, and calculate the mean for each category. Using na.rm = TRUE ensures that the missing quiz is simply ignored rather than turning the whole average into NA:

# Calculate the mean for each category, ignoring NAs
quiz.mean <- mean(as.numeric(student_record[quiz.cols]), na.rm = TRUE)
test.mean <- mean(as.numeric(student_record[test.cols]), na.rm = TRUE)
final.score <- as.numeric(student_record[final.col])

c(Quiz = quiz.mean, Test = test.mean, Final = final.score)
 Quiz  Test Final 
 72.0  64.5  83.0 

Finally, we apply the syllabus weights to calculate the overall course score:

# Calculate the weighted course score
course.score <- (0.3 * quiz.mean) + (0.3 * test.mean) + (0.4 * final.score)

course.score
[1] 74.15

2.3.1 Aggregating into a Reusable Function

Now that we know the logic works perfectly for one student, we can package those exact steps into a reusable function. This function will take any student_record as its input and return a named vector containing the category means and the final score.

To make the grading policy more forgiving, we are adding a conditional rule: if a student’s final exam score is strictly higher than their test average, the final exam score will replace the test average. Because compute.course.score processes exactly one student at a time, we evaluate a single logical condition using a standard if statement rather than a vectorized alternative like ifelse. We also use !is.na() checks to ensure the if statement doesn’t crash by attempting to evaluate a missing value (which could happen if a student had no test or final scores).

compute.course.score <- function(student_record, w_quiz = 0.3, w_test = 0.3, w_final = 0.4) {
    
    col_names <- names(student_record)
    quiz.cols <- grep("^Quiz", col_names)
    test.cols <- grep("^Test", col_names)
    final.col <- grep("^Final", col_names)
    
    quiz.mean <- mean(as.numeric(student_record[quiz.cols]), na.rm = TRUE)
    test.mean <- mean(as.numeric(student_record[test.cols]), na.rm = TRUE)
    final.score <- as.numeric(student_record[final.col])
    
    # Explicit conditional check: replace test mean with final score if it is higher
    if (!is.na(final.score) && !is.na(test.mean) && final.score > test.mean) {
        test.mean <- final.score
    }
    
    # The weights are now dynamic variables rather than hardcoded numbers
    course.score <- (w_quiz * quiz.mean) + (w_test * test.mean) + (w_final * final.score)
    
    c(Quiz = quiz.mean, Test = test.mean, Final = final.score, course.score = course.score)
}

Let’s test our updated function on two specific scenarios to verify the conditional logic. We will format these inputs as named vectors rather than data frames. This mimics exactly what the apply() function will do later when it passes rows of our dataset to this function.

Student A struggled on the tests (average: 70) but aced the final (95). The function should detect that 95 > 70 and replace their test mean with 95 before calculating the final course score. Student B did great on the tests (average: 90) but dropped on the final (75). The function should keep their original test mean.

# Create the mock record for Student A as a named vector
student_A <- c(
    name = "Student_A", program = "Program A",
    Quiz1 = "80", Quiz2 = "80", Quiz3 = "80", Quiz4 = "80", Quiz5 = "80", # Quiz mean = 80
    Test1 = "65", Test2 = "75",                                           # Test mean = 70
    Final = "95"
)

# Create the mock record for Student B as a named vector
student_B <- c(
    name = "Student_B", program = "Program B",
    Quiz1 = "80", Quiz2 = "80", Quiz3 = "80", Quiz4 = "80", Quiz5 = "80", # Quiz mean = 80
    Test1 = "90", Test2 = "90",                                           # Test mean = 90
    Final = "75"
)

# Test Student A: The Test mean should be updated to 95 in the output
cat("Student A Results:\n")
Student A Results:
compute.course.score(student_A)
        Quiz         Test        Final course.score 
        80.0         95.0         95.0         90.5 
# Math check: (0.3 * 80) + (0.3 * 95) + (0.4 * 95) = 90.5

# Test Student B: The Test mean should remain 90 in the output
cat("\nStudent B Results:\n")

Student B Results:
compute.course.score(student_B)
        Quiz         Test        Final course.score 
          80           90           75           81 
# Math check: (0.3 * 80) + (0.3 * 90) + (0.4 * 75) = 81

2.4 Applying the Function to Every Student

2.4.1 Using apply function

Now that we have a function working for one student, we can apply it across the entire class. The apply function is perfect for this: it takes our roster data frame, loops over either rows (1) or columns (2), and executes our custom function.

# apply over margin 1 (rows)
all.results <- t(apply(roster, 1, compute.course.score))

# Combine the original demographic info with the calculated scores
results <- cbind(roster[, c("name", "program")], all.results)

results

2.4.2 Looping the rows with for

While apply() is idiomatic R and very concise, we can also use a traditional for loop to process each student. To do this efficiently, we first pre-allocate an empty matrix of the correct size to store our output. Then, we iterate through the row indices one by one.

Inside the loop, we use unlist() to convert the single row of our data frame into a named vector. This guarantees we are feeding our compute.course.score function the exact same data structure that apply() uses under the hood.

# 1. Pre-allocate an empty matrix to hold the results
num_students <- nrow(roster)
loop_results <- matrix(NA, nrow = num_students, ncol = 4)

# Set the column and row names to match our expected output
colnames(loop_results) <- c("Quiz", "Test", "Final", "course.score")
rownames(loop_results) <- roster$name

# 2. Iterate over each row index
for (i in 1:num_students) {
    # Extract the single row as a named character vector
    student_record <- unlist(roster[i, ]) 
    
    # Compute the score and store it in the corresponding row of our matrix
    loop_results[i, ] <- compute.course.score(student_record)
}

# Peek at the first few rows of the loop results
head(loop_results)
     Quiz Test Final course.score
S001 73.4   81    81        78.72
S002 73.4   70    70        71.02
S003 72.4   76    76        74.92
S004 72.4   70    70        70.72
S005 72.0   83    83        79.70
S006 73.4   74    74        73.82

Now, let’s run the exact same calculation using apply() and use the all.equal() function to verify that both methods yield the exact same mathematical results.

# 3. Check if they are mathematically identical
# (We set check.attributes = FALSE to ignore minor metadata differences, 
# like how apply() inherits row names vs. how we manually assigned them)
is_same <- all.equal(loop_results, results, check.attributes = FALSE)

print(paste("Do the for loop and apply() give the exact same results?", is_same))
[1] "Do the for loop and apply() give the exact same results? target is matrix, current is data.frame"

2.4.3 Saving and reloading the results

The results table is worth keeping. A data frame can be written as a plain csv file for spreadsheets, or saved as an .RDS file to preserve it exactly (including the program factor) for future R sessions:

write.csv(results, file = "results.csv", row.names = FALSE)
read.csv ("results.csv")
saveRDS(results, file = "results.RDS")
results.reloaded <- readRDS("results.RDS")
identical(results, results.reloaded)
[1] TRUE

2.5 Writing a Function for the Whole Process with List Output

Sometimes a function needs to return multiple distinct pieces of information that don’t fit neatly into a single table. A list in R is a flexible container that can bundle objects of completely different types and sizes together.

Let’s write a wrapper function that takes our raw roster data and our weighting scheme. It will calculate the scores, but instead of just returning the table, it will return a list containing four very different items:

  1. The full processed data frame.
  2. A numeric vector of just the final course scores.
  3. A frequency table of scores binned into 10-point intervals.
  4. A character vector of the names of students who failed (scored below 60).
analyze_class_performance <- function(roster_data, w_quiz = 0.3, w_test = 0.3, w_final = 0.4) {
    
    # 1. Pass the weights down into compute.course.score
    all.results <- t(apply(roster_data, 1, compute.course.score, 
                           w_quiz = w_quiz, w_test = w_test, w_final = w_final))
                           
    processed_table <- cbind(roster_data[, c("name", "program")], all.results)
    
    # 2. Extract final course scores 
    final_scores <- processed_table$course.score
    
    # 3. Create bins (handling NAs)
    bins <- seq(0, 100, by = 10)
    score_bins <- table(cut(final_scores, breaks = bins, include.lowest = TRUE), useNA = "ifany")
    
    # 4. Extract names of failing students (safely ignoring NAs using which)
    failing_students <- processed_table$name[which(final_scores < 60)]
    
    # 5. Extract names of students with missing overall scores
    incomplete_students <- processed_table$name[is.na(final_scores)]
    
    # Bundle everything into a named list
    list(
        ProcessedTable = processed_table,
        CourseScores = final_scores,
        ScoreDistribution = score_bins,
        Failing_Students = failing_students,
        incomplete_students = incomplete_students
    )
}

When we run this function, it returns a single list object. We can use the str() function to quickly inspect the structure of our new list and see all the different data types nested inside it:

# Run the analysis
class_report <- analyze_class_performance(roster)

# Inspect the structure of the returned list
str(class_report, max.level = 1)
List of 5
 $ ProcessedTable     :'data.frame':    100 obs. of  6 variables:
 $ CourseScores       : num [1:100] 78.7 71 74.9 70.7 79.7 ...
 $ ScoreDistribution  : 'table' int [1:11(1d)] 0 0 0 0 0 1 21 42 28 3 ...
  ..- attr(*, "dimnames")=List of 1
 $ Failing_Students   : chr "S094"
 $ incomplete_students: chr [1:5] "S026" "S027" "S039" "S070" ...

To access individual elements inside a list, you use the $ operator (just like with data frames) or double brackets [[ ]]. Let’s look at the score distribution and the failing students:

# Access the 10-point bin distribution
class_report$ScoreDistribution

  [0,10]  (10,20]  (20,30]  (30,40]  (40,50]  (50,60]  (60,70]  (70,80] 
       0        0        0        0        0        1       21       42 
 (80,90] (90,100]     <NA> 
      28        3        5 
# Access the names of the failing students
class_report$Failing_Students
[1] "S094"
class_report$incomplete_students 
[1] "S026" "S027" "S039" "S070" "S096"

2.6 The use of pipe |>

The pipe operator x |> f(...) is just shorthand for f(x, ...) – it takes whatever is on its left and inserts it as the first argument of the function call on its right. This lets you write a sequence of transformations in the order you think of them, instead of nesting calls inside out:

# Without the pipe: read from the inside out
round(sqrt(mean(c(4, 9, 16, 25))), 2)
[1] 3.67
# With the pipe: read top to bottom, left to right
c(4, 9, 16, 25) |>
    mean() |>
    sqrt() |>
    round(2)
[1] 3.67

Each step’s output becomes the next step’s first input, so mean() receives the vector, sqrt() receives the mean, and round(2) receives the square root (with 2 filling the second argument, digits). Extra arguments after the first are passed straight through exactly as they would be in a normal call, which matters once a pipeline mixes several multi-argument functions – much like the quiz-generating recipe used throughout this unit:

set.seed(123)

# Same idea as gen.quiz(), written as one pipeline: each step's output
# fills the *first* argument of the next call, while the remaining,
# named arguments are supplied just as in an ordinary function call
rnorm(20, mean = 75, sd = 10) |>
    round(digits = 0) |>       # round(x, digits = 0)
    pmin(100) |>                # pmin(x, 100)
    pmax(0) |>                  # pmax(x, 0)
    sort(decreasing = TRUE) |>  # sort(x, decreasing = TRUE)
    head(n = 5)                 # head(x, n = 5)
[1] 93 92 91 87 82

Sometimes the piped value needs to land in an argument other than the first. The placeholder _ marks exactly where it goes, as long as that argument is named:

# seq(1, to = 10, by = 2) -- the piped 10 fills the "to" argument
10 |> seq(1, to = _, by = 2)
[1] 1 3 5 7 9

This same idea – chaining verbs with |> – is what makes the dplyr pipelines below read like a recipe.

2.7 Data Manipulation with dplyr

Base R’s indexing, apply(), and for loops can do everything we’ve done so far, but dplyr offers a grammar of a handful of verbs – mutate(), filter(), select(), arrange(), group_by(), summarize() – that are chained together with the pipe |>, so a whole data-wrangling recipe reads top to bottom like a sentence instead of nesting function calls inside one another.

2.7.1 Generating a roster with mutate()

mutate() isn’t limited to computing new columns from data that already exists in a data frame – it can build up a data frame from nothing at all, one column at a time, chaining together exactly the same generative steps we used in Generating an artificial score dataset. Within a single mutate() call, each new column is available to the expressions that follow it, so we can go straight from raw quiz scores to quiz.mean.true to Test1/Test2/Final, all in one pipeline, reusing the gen.quiz() and clip.round() helpers we already defined:

library(dplyr)

set.seed(2026)   # a fresh cohort, just for this demonstration

n2 <- 100
students2 <- sprintf("S%03d", 1:n2)
program2  <- factor(sample(c("Program A", "Program B"), n2, replace = TRUE))

data.frame(name = students2, program = program2) |>
    mutate(
        Quiz1 = gen.quiz(75, 20),
        Quiz2 = gen.quiz(78, 20),
        Quiz3 = gen.quiz(72, 20),
        Quiz4 = gen.quiz(80, 20),
        Quiz5 = gen.quiz(74, 20),
        quiz.mean.true = rowMeans(cbind(Quiz1, Quiz2, Quiz3, Quiz4, Quiz5)),
        b0.test  = if_else(program == "Program A", 12, 6),
        b0.final = if_else(program == "Program A", 15, 8),
        Test1 = clip.round(b0.test  + 0.80 * quiz.mean.true + rnorm(n(), 0, 8)),
        Test2 = clip.round(b0.test  + 0.80 * quiz.mean.true + rnorm(n(), 0, 8)),
        Final = clip.round(b0.final + 0.85 * quiz.mean.true + rnorm(n(), 0, 6)),
        across(c(starts_with("Quiz"), starts_with("Test"), Final),
               ~ replace(.x, sample(n(), round(n() * miss.rate)), NA))
    ) -> roster_worktable

roster_worktable |> slice_head (n = 6)
roster_mutate <- roster_worktable |> select(-quiz.mean.true, -b0.test, -b0.final) 

roster_mutate |> slice_head (n= 6)

A few things are worth noticing here. b0.test and b0.final are computed with if_else() – the vectorized, type-checked dplyr analogue of ifelse() – directly from the program column, and then used (and finally dropped again with select(-...)) just like the helper vectors from the original script. n(), dplyr’s built-in count of the current number of rows, replaces the standalone n <- 100 we relied on earlier, so this pipeline needs nothing from the outside except students2/program2 to start from. The tidyselect helper starts_with() – the dplyr analogue of the grep("^Quiz", ...) trick from earlier – picks out groups of columns by name, and wrapping it in across() applies the same NA-injection rule to all eight score columns at once, in place of the explicit for loop over 1:ncol(scores).

Because this pipeline draws its own random numbers with a fresh seed, roster_mutate is a different simulated class from roster – not a reproduction of it – but it is built by exactly the same recipe, entirely inside one mutate() chain. The rest of this section goes back to analyzing the original roster.

2.7.2 Recomputing course scores with mutate() and rowwise()

Our course-score calculation is inherently a row-wise operation: for each student we need to look across several quiz columns and average just that student’s values. rowwise() tells dplyr to treat each row as its own little group, and c_across() lets mean() reach across a selection of columns (again using starts_with()) within that row.

Inside a single mutate() call, later expressions can refer to columns just created earlier in the same call – so we can reproduce the “replace the test mean with the final score if it’s higher” rule from compute.course.score() using if_else() in place of the base-R if statement:

dplyr_results <- roster |>
    rowwise() |>
    mutate(
        Quiz  = mean(c_across(starts_with("Quiz")), na.rm = TRUE),
        Test  = mean(c_across(starts_with("Test")), na.rm = TRUE),
        Test  = if_else(!is.na(Final) & !is.na(Test) & Final > Test, Final, Test),
        course.score = 0.3 * Quiz + 0.3 * Test + 0.4 * Final
    ) |>
    ungroup() |>
    select(name, program, Quiz, Test, Final, course.score)

dplyr_results

rowwise() groups by row, so we ungroup() again immediately afterward – otherwise every later verb would keep operating one row at a time. As a sanity check, this dplyr pipeline should produce exactly the same numbers as results, our earlier apply()-based table, just as the for-loop version did:

all.equal(as.data.frame(dplyr_results), results, check.attributes = FALSE)
[1] TRUE

2.7.3 Summarizing by group with group_by() and summarize()

This is where dplyr’s advantage over base R really shows. Producing a by-program summary table with tapply() or nested sapply() calls in base R takes some fiddling; with dplyr it’s a direct continuation of the same pipeline. group_by() splits the data by program, and every subsequent summarize() argument is computed separately within each group:

dplyr_results |>
    group_by(program) |>
    summarize(
        n                 = n(),
        mean_quiz         = mean(Quiz, na.rm = TRUE),
        mean_test         = mean(Test, na.rm = TRUE),
        mean_final        = mean(Final, na.rm = TRUE),
        mean_course_score = mean(course.score, na.rm = TRUE),
        .groups = "drop"
    )

Program A’s mean course score comes out noticeably higher – exactly the intercept difference (b0.test, b0.final) we built into the simulation, and the same pattern we’ll see again in the by-program boxplot below.

2.7.4 Filtering and sorting with filter() and arrange()

filter() keeps rows matching a logical condition (replacing the which()-based indexing we used for Failing_Students), and arrange() sorts rows by one or more columns – desc() reverses the order. Chaining them together gives the failing students, worst first, and separately the top five performers in the class:

# Failing students, worst first
dplyr_results |>
    filter(course.score < 60) |>
    arrange(course.score) |>
    select(name, program, course.score)
# Top 5 students in the class
dplyr_results |>
    arrange(desc(course.score)) |>
    select(name, program, course.score) |>
    slice_head(n = 5)

We’ll keep using class_report$ProcessedTable – our original apply()-based results – for the reports and plots below, but from here on in the course, dplyr’s pipe-and-verb style will be our default tool for wrangling real datasets.

2.8 Presenting the Results

2.8.1 Presenting the Processed Results with gt

Finally, gt turns the raw data frame of results into a polished, presentation-ready report. We can extract the ProcessedTable directly from our class_report list and enhance it.

Here we use tab_spanner() to group the category scores and the final score under distinct visual headers, and fmt_number() to ensure all grades display exactly two decimal places. The column headers below spell out the underlying formulas: the quiz average \(\sum_{i}^5{q_i}/5\), the test score \(T\), the final exam \(F_1\), and the overall course score \(F\).

library(gt)

# Extract the full processed table from our list output
# (We use head() to display just the first 10 rows in the report)
full_results <- head(class_report$ProcessedTable, 10)

gt(full_results) |>
    tab_header(
        title = "Class Performance Report",
        subtitle = "Individual category means and final calculated course scores"
    ) |>
    # Student Information
    tab_spanner(
        label = "Student Information",
        columns = c(name, program)
    )|>
    # Group the individual graded categories
    tab_spanner(
        label = "Category Breakdown",
        columns = c(Quiz, Test, Final)
    ) |>
    # Group the final processed output
    tab_spanner(
        label = "Submitted Score",
        columns = c(course.score)
    ) |>
    # Enforce two decimal places for all numeric score columns
    fmt_number(
        columns = c(Quiz, Test, Final),
        decimals = 2
    ) |>
    fmt_number(
        columns = c(course.score),
        decimals = 0
    ) |>
    cols_label(
        name = "Name",
        program = "Program",
        Quiz = md("Quizzes ($\\sum_{i}^5{q_i}/5$)"),
        Test = md("Tests ($T$)"),
        Final = md("Final Exam ($F_1$)"),
        course.score = md("Course Score ($F$)")
    ) |>
    # Shrink the name/program columns so the score columns have more room
    cols_width(
        name ~ px(100),
        program ~ px(90)
    ) |>
    tab_stubhead(label = "Student ID")
Table 2.1: Shows the kind of tidy data frame used to practise data handling in R, with one row per student and one column per assessment category. Class performance report: first 10 students, with category scores and the final course score.
Class Performance Report
Individual category means and final calculated course scores
Student Information
Category Breakdown
Submitted Score
Name Program Quizzes (\(\sum_{i}^5{q_i}/5\)) Tests (\(T\)) Final Exam (\(F_1\)) Course Score (\(F\))
S001 Program A 73.40 81.00 81.00 79
S002 Program B 73.40 70.00 70.00 71
S003 Program B 72.40 76.00 76.00 75
S004 Program A 72.40 70.00 70.00 71
S005 Program B 72.00 83.00 83.00 80
S006 Program A 73.40 74.00 74.00 74
S007 Program A 61.80 70.00 59.00 63
S008 Program B 67.00 62.00 62.00 64
S009 Program B 73.50 76.00 76.00 75
S010 Program A 82.40 95.00 95.00 91

2.8.2 Base R graphics

A quick histogram shows the distribution of course scores, with a vertical line marking the class average:

hist(class_report$ProcessedTable$course.score, breaks = 15, col = "lightblue",
     main = "Distribution of Course Scores", xlab = "Course Score")
abline(v = mean(class_report$ProcessedTable$course.score, na.rm = TRUE), col = "red", lwd = 2)
Figure 2.1: Demonstrates how to summarize a single numeric variable in R with a histogram, which shows the shape, centre, and spread of the class scores. Distribution of course scores across the class, with the class average marked in red.

2.8.3 Plotting with ggplot2

ggplot2 builds a comparison across programs declaratively, by mapping columns of class_report$ProcessedTable to visual properties and layering geometries with +. Here the boxplot is enhanced with the individual course scores jittered on top:

library(ggplot2)

ggplot(class_report$ProcessedTable, aes(x = program, y = course.score, fill = program)) +
    geom_boxplot(show.legend = FALSE, alpha = 0.6) +
    geom_jitter(width = 0.05, size = 1.5, alpha = 0.6) +
    labs(
        title = "Course Score by Academic Program",
        x = "Program", y = "Course Score"
    ) +
    theme_minimal()
Figure 2.2: Demonstrates how to compare a numeric variable across groups in R, using boxplots with jittered points so that both the group summaries and the individual scores are visible. Course score by academic program, with individual scores jittered over the boxplots.

We built a program-specific intercept into the simulation when generating the final exam scores; a scatter plot of the final versus each student’s mean quiz score, with a fitted line per program, should reveal it:

ggplot(class_report$ProcessedTable, aes(x = Quiz, y = Final, color = program)) +
    geom_point(size = 2, alpha = 0.6) +
    geom_smooth(method = "lm", se = FALSE) +
    labs(
        title = "Final Exam vs. Mean Quiz Score, by Program",
        x = "Mean Quiz Score", y = "Final Exam Score", color = "Program"
    ) +
    theme_minimal()
Figure 2.3: Demonstrates how to visualize a relationship separately within groups in R, by fitting one regression line per program to a scatterplot. Final exam score versus mean quiz score, by program, with a fitted regression line per program revealing the built-in intercept offset.

The two fitted lines are roughly parallel but vertically offset – exactly the intercept difference we built into b0.final when simulating the data.