9 - Introducing inference

Author

Jacob Lahne

Published

June 24, 2026

Introduction

Now that we’re done with the first Data Analysis Report, we’re going to be starting on the second half of the class, in which we move from the basics of how to code for research–learning how R works, learning basic concepts of control flow and data wrangling–to the why of our coding work: analyzing data. This will be the focus of the rest of the class!

In the last few weeks you actually started to learn a lot about how to do exploratory and descriptive data analysis: how to make basic, useful exploratory plots, how to generate summaries of data that tell you something about what you’ve got. You also have spent some time putting together your first data-analysis report, which made use of those skills.

So this week we will start with a summary of what inferential data analysis is, and why we might want to employ it in our work. We’re going to start off very simple with some very basic sensory data–right where I feel at home. I’m going to spend a little time giving you a biography of the data so that you will be able to relate it to your own work.

Then, we’ll step back and talk about how small experiments like this one can be used for inferring knowledge about more general situations. We’ll talk about heuristic (traditional) statistical approaches and resampling approaches to inference. Finally, we’ll talk about the counterexample of how we usually learn and think about statistical inference in science, and how it contrasts with what we’re doing here.

Motivation: inference from experiments

Today we’re going to work with a set of data my undergraduate students in FST 3024 (Principles of Sensory Evaluation) collected for me in 2023. Each year, the class does a service-learning project for a small food producer. In 2023, the students worked with Joell Eifert, our undergraduate-research coordinator, to help Homestead Creamery test a new production method for their Hokie Tracks Ice Cream:

Hokie Tracks Ice Cream

According to Homestead

From farm to market, the collaboration between the nationally ranked Virginia Tech College of Agriculture and Life Sciences’ Department of Food Science and Technology and the award-winning Homestead Creamery, brings innovated food science research to the market in the form of delicious Hokie-inspired ice cream. Hokie Tracks is the perfect sweet & savory combination of vanilla ice cream with salted caramel swirls and chocolate covered pretzel pieces!

The basic issue at hand was that Homestead wanted to scale up production of Hokie Tracks, replacing labor-intensive hand-mixing of the caramel swirls and chocolate pieces with a piece of equipment known as a “variegator”, which would allow continuous instead of batch production. Homestead asked us to test whether people could tell the difference between the same recipe made with the two production methods, and whether subjects preferred one or the other.

We conducted what is known as a “preference test”: consumers were given two samples without labeling information, asked to taste both, and forced to indicate whether they preferred one sample or the other. If, as a group, consumers had no true preference, about 50% should select one sample and the other 50% would select the other. We also collected “free comment” data that allowed consumers to explain their choices.

library(readxl)
preference_data <- 
  read_excel("data/Week 9/Hokie Tracks Raw Data.xlsx", range = "A1:C80")
product_codes <- 
  read_excel("data/Week 9/Hokie Tracks Raw Data.xlsx", range = "D2:F4")

glimpse(preference_data)
Rows: 79
Columns: 3
$ Panelist           <dbl> 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, …
$ `Preferred Sample` <dbl> 2, 1, 2, 1, 1, 2, 1, 1, 1, 2, 2, 1, 1, 2, 1, 2, 1, …
$ `Open Comments`    <chr> "I accidentally clicked 890, I liked 357 more becau…
glimpse(product_codes)
Rows: 2
Columns: 3
$ `Sample Number` <dbl> 1, 2
$ BC              <dbl> 357, 890
$ `Sample Name`   <chr> "Hand-mixed", "Machine-fed"

I’ve imported both the preference data itself and a little reference box in the workbook, entered by the TA at the time, that gives labels for the codes given to Preferred Sample in the preference_data data frame. Otherwise, we’d have to guess what sample 1 and sample 2 were. How can we join these two tables together so we can have informative labels on our data?

preference_data <-
  preference_data |>
  left_join(product_codes, by = c("Preferred Sample" = "Sample Number"))

We also might want to tell R that our “numeric” data is in fact just categorical, by changing appropriate columns into factor columns.

preference_data <-
  preference_data |>
  mutate(preferred_sample = as.factor(`Sample Name`)) |>
  # And we can reorganize and drop unnecessary columns
  select(Panelist, preferred_sample, `Open Comments`)

Now we can use skim() to get us a nice summary (although truly this is overkill with such simple data).

skim(preference_data)
Data summary
Name preference_data
Number of rows 79
Number of columns 3
_______________________
Column type frequency:
character 1
factor 1
numeric 1
________________________
Group variables None

Variable type: character

skim_variable n_missing complete_rate min max empty n_unique whitespace
Open Comments 0 1 13 367 0 79 0

Variable type: factor

skim_variable n_missing complete_rate ordered n_unique top_counts
preferred_sample 0 1 FALSE 2 Han: 50, Mac: 29

Variable type: numeric

skim_variable n_missing complete_rate mean sd p0 p25 p50 p75 p100 hist
Panelist 0 1 40 22.95 1 20.5 40 59.5 79 ▇▇▇▇▇

We are essentially interested in one thing in these data: do we think that there are enough people expressing a preference for a particular sample that we believe it’s a “true” preference? Do we think that overall people are not just selecting samples at random?

preference_data |>
  count(preferred_sample)
# A tibble: 2 × 2
  preferred_sample     n
  <fct>            <int>
1 Hand-mixed          50
2 Machine-fed         29

It certainly looks like more people prefer the Hand-mixed sample. But is this just a random sample? Let’s look into how we might answer this question.

We have two questions we might want to answer from this simple example:

  1. Is the difference between numbers of people who prefer Hand-mixed over Machine-fed large? Are we seeing a real effect?
  2. If we repeated this experiment (say, by putting these two products both on the market at the same time), what kind of range of behavior (proportion of people choosing Hand-fed over Machine-fed) would we observe? What kind of range of proportions is compatible with these data?

Let’s use what we’ve learned about simulation and random sampling to answer each of these questions using a coding approach, and then circle back to a statistical heuristic approach.

“A resampling approach” to statistics

Today we are going to learn some approaches to thinking about inference with data that don’t rely on assumptions about formula or rules. These are called “resampling” approaches to statistics, and they instead rely on us being a bit clever about using computational power to do simulations on our datasets. This sounds fancy and intimidating, and I am pleased to say that while it may be a little bit alien, it is not!

As a caveat, however, I will note that we are going to just learn about the basics of these approaches today (and in this class in general). There are whole books on this topic, and more sophisticated simulationist approaches like Bayesian statistics are going to be the most precise and powerful models (for example see McElreath 2020). My hope is that giving you a taste of a simulationist approach shows you how powerful you are once you know the basics of coding for research!

Confidence intervals around statistics (the “bootstrap”)

The first questions we have is about our sample means, which are “point estimates” for the actual, population mean. What am I saying here? Well, let’s take a minute to unpack the idea of a point estimate, and then apply it to our example.

First, let’s imagine we have the normal distribution:

p <- 
  ggplot(data = data.frame(x = c(-3, 3)), aes(x)) + 
  geom_function(fun = dnorm, n = 101, args = list(mean = 0, sd = 1)) +
  labs(y = NULL) +
  theme_classic()
p

This is our population distribution. We know that its true population mean is 0, and its true population standard deviation is 1, by definition. Now, let’s pull 3 samples of 100 observations from the normal distribution with mean = 0 and sd = 1, and take a look at where they go on the graph:

set.seed(9)
normal_samples <- tibble(x1 = rnorm(100),
                         x2 = rnorm(100),
                         x3 = rnorm(100))
skim(normal_samples)
Data summary
Name normal_samples
Number of rows 100
Number of columns 3
_______________________
Column type frequency:
numeric 3
________________________
Group variables None

Variable type: numeric

skim_variable n_missing complete_rate mean sd p0 p25 p50 p75 p100 hist
x1 0 1 -0.05 0.96 -2.62 -0.75 -0.17 0.42 2.68 ▁▆▇▃▁
x2 0 1 -0.13 0.99 -2.74 -0.85 0.01 0.61 1.81 ▂▃▆▇▃
x3 0 1 0.00 0.99 -2.47 -0.50 -0.05 0.71 2.01 ▂▃▇▆▃

We can see what is happening here by plotting these onto our normal distribution:

p + 
  geom_vline(xintercept = mean(normal_samples$x1), color = "red", size = 0.75) +
  geom_vline(xintercept = mean(normal_samples$x2), color = "red", size = 0.75, linetype = "dashed") +
  geom_vline(xintercept = mean(normal_samples$x3), color = "red", size = 0.75, linetype = "dotted") 
Warning: Using `size` aesthetic for lines was deprecated in ggplot2 3.4.0.
ℹ Please use `linewidth` instead.

Even though our samples are coming from the exact same population, they have different means (and standard deviations) because of sampling error. Although, as a side note, I will say that “error” makes it sound like something we can fix–in fact, this variation is fundamental and cannot be avoided.

To conclude: even when we are sampling from a perfectly defined and known population, our samples will only approximate that population. But in the long term, if we draw enough samples they will end up in the limit being unbiased approximations. Armed with that knowledge, let’s return to our example.

These rules apply to all kinds of random variables. In our ice-cream preference example, our data show an observed proportion of \(p_{obs}=50/79\). We could assume that, if there were truly no population preference, our data is a sample coming from a binomial distribution with \(n = 79\). Then we could look at a similar story.

We will use a slightly different plotting strategy because the binomial distribution is a discrete distribution: the number of successes is a positive integer (\(0, 1, 2, ...\)), not a range of real numbers.

p <- 
  tibble(x = 0:79,
         y = dbinom(x, size = 79, prob = 1/2)) |>
  ggplot(aes(x = x, y = y)) + 
  geom_col(fill = "grey") + 
  theme_classic() +
  labs(x = NULL, y = "density") + 
  scale_y_continuous(expand = expansion(mult = c(0, 0.05)))


p

We can see that this is centered right around \(79/2 = 39.5\), which gives equal probability to \(39/79\) or \(40/70\) people saying they prefer the hand-mixed ice cream.

If we take 3 random probability draws from the binomial distribution with these properties, we’ll get results that are close to but not identical to that most likely outcome:

set.seed(9)
random_preference <- 
  tibble(draw = 1:3,
         proportion = rbinom(n = draw, size = 79, prob = 1/2),
         linetype = as.factor(draw + 1))

p + 
  geom_vline(aes(xintercept = proportion, linetype = linetype), 
             data = random_preference, 
             show.legend = FALSE,
             color = "red")

So we have the same situation! Even when we have a “known” population, drawing finite, random samples from that distribution will give us results that only approximate it.

For kicks, we might remember that “large enough” binomial distributions are well-approximated by a normal distribution with \(\mu = p\) and \(\sigma = \sqrt{p(1-p)}\). How well? Let’s see:

p + 
  geom_function(fun = dnorm, 
                n = 101, 
                args = list(mean = 39.5, sd = sqrt(79/4)),
                color = "red") 

Pretty well, I’d say!

A bootstrapping example

In this example, for our sample we had a specific 79 people taste each ice cream and report which one they preferred. Thus, when we calculate our point-estimate summary statistics, we are basing that estimated group mean proportion (or SD or whatever) on those 79 observations. But how would it change if we had 79 other observations? What we know is our sampling-error biased mean, and what we want to get a better picture of is the population these means came from. In our explanation above, what this is equivalent to is trying to infer the shape of the normal distribution with only 1 of the 100-sample draws (that we showed were full of errors). How can we do this?

The answer here is that we can use an approach called “resampling” to build a simulated population from our observed sample, and then use that population to understand how representative our observed point-estimate statistics are. This sounds complicated, but in practice it is very intuitive. The description from Bruce and Bruce (2017) for the steps to do this is quite clear (paraphrased here in almost pseudocode):

  1. From your sample, draw a sample of the same size with replacement.
  2. Record the mean, SD, or other relevant statistic from your “resampled” sample and record the information.
  3. Repeate steps 1-2 a large number of times (a typical number is \(N=10,000\), but this can vary depending on computational intensivity)
  4. Use the new dataset of statistics to calculate a confidence interval or to draw boxplots or histograms to compare to your point estimate.

The reason this approach works well is the “with replacement” bit. This means that, rather than just shuffling our data, we will be sometimes drawing the same sample multiple times. Over the long run, this creates a population that would plausibly generate our sample, as shown in Bruce and Bruce (2017):

Schematic example of bootstrapping (Bruce and Bruce 2017, 58)

Again, this sounds like we’re doing something mystically complicated, but the steps above are each things we already know how to do. Let’s take a look in practice, with our dataset. We have 4 different means we want to resample, one for each of the different treatments. We’ll write a little for loop to do this for us (although you could check out the boot package for more functionality).

# Here we set a number of iterations for the bootstrap.  If your computer is
# slower, consider setting this to 100 just for the example.

boot_reps <- 1000

# As a bonus, here's how we make a progress bar to track the task

pb <- progress::progress_bar$new(total = boot_reps)

# for each step of the loop, we will generate a new sample for each of our
# treatments and take its mean, which is the proportion in a set of 0s and 1s.
# We will store the results of the proportion of people who preferred hand-mixed
# ice cream
booted_proportions <- 
  tibble(boot_id = numeric(),
         boot_proportion = numeric())

for(i in 1:boot_reps){
  preferred_hand_mixed <-
    preference_data |>
    # by setting prop(ortion) = 1 and asking for replacement, we get a new "resample"
    slice_sample(prop = 1, replace = TRUE) |>
    count(preferred_sample) |>
    # Here we cheat: we know that the sum of n is 79 each time.  We could do
    # this programatically with a few more steps.  How?
    mutate(proportion = n / 79) |>
    # Get the actual cell containing the proportion of hand-mixed ice cream
    # preference in the new sample
    _[[1, 3]]
  
  # Let's not forget to store our results!
  booted_proportions <- 
    bind_rows(
      booted_proportions,
      tibble(boot_id = i,
             boot_proportion = preferred_hand_mixed)
    )
  
  # Update our progress bar
  pb$tick()
}

Now we have a big (1,000 row) data frame, in which each row is a plausible simulation of means we could have gotten if our experimental sample had been different, but drawn from the same population! Let’s go ahead and do a little basic data exploration on this dataset.

p_boot <-
  booted_proportions |>
  ggplot(aes(x = boot_proportion)) + 
  geom_histogram(bins = 25, fill = "grey", color = "white") + 
  theme_classic() + 
  scale_y_continuous(expand = expansion(mult = c(0, 0.05)))

p_boot

We can do a couple things with this. We can compare our observed proportion, \(p_{obs}\), to the mean of the bootstrapped proportions to see how representative it is. We can also look at the range of these bootstrapped proportions: what, for example, is an interval that contains 95% of the observations?

p_boot + 
  geom_vline(xintercept = 50/79, color = "red") + 
  geom_vline(xintercept = mean(booted_proportions$boot_proportion), 
             color = "red", linetype = 2) +
  annotate(geom = "label", x = 51/79, y = 25, label = "italic(p)[obs]", parse = TRUE) + 
  annotate(geom = "label", x = mean(booted_proportions$boot_proportion + 1/79), y = 55, 
           label = "italic(bar(p))[boot]", parse = TRUE)

We can see that the lines are practically overlapping, the actual values are:

# p_obs
50/79
[1] 0.6329114
#p_boot
mean(booted_proportions$boot_proportion)
[1] 0.6342785

We can calculate a 95% “boot interval” by looking at the quantiles of our bootstrapped estimates:

boot_quantiles <- 
  quantile(booted_proportions$boot_proportion,
           probs = c(0.025, 0.975))

boot_quantiles
     2.5%     97.5% 
0.5316456 0.7341772 

Note that there’s nothing magical about the 95% interval–it is just “pretty big”. It tells us that 95% of the bootstrapped proportions fall between 53% and 73% preferring hand-mixed ice cream. For our basic question–do more people prefer hand-mixed ice cream–this is evidence for us to conclude that, if our sampling was an effective representation of the population, it is pretty unlikely that people prefer the machine-mixed recipe. Very few of our “resamples” give us a proportion of 50% or under.

We can actually plot these numbers to make presenting them to stakeholders easier:

p_boot + 
  geom_histogram(data = booted_proportions |> mutate(fill = if_else(boot_proportion <= 0.5, "a", "b")),
                 mapping = aes(fill = fill, x = boot_proportion),
                 bins = 25, color = "white", 
                 show.legend = FALSE) + 
  scale_fill_manual(values = c("black", "grey")) +
  annotate(geom = "rect", xmin = boot_quantiles[1], xmax = boot_quantiles[2],
           ymin = 0, ymax = Inf, fill = "pink", alpha = 1/3) + 
  annotate(geom = "text", label = "95% range of\nbootstrap values",
           x = 0.54, y = 95, hjust = 0)

This tells us a couple things:

  1. Any values of 0.5 (50%) or less are well outside the lower quantile of our 95% range, which means that–if we think this is a “wide enough” range–they are not very likely to be observed in another test.
  2. This data would be evidence for us that there is some kind of preference in the population for the hand-mixed ice cream.

The advantage of the bootstrap is that it is nonparametric: it makes no assumptions about how the data are generated and works equally well for any kind of statistic or any kind of data. It just requires writing some pseudocode to properly generate the loop, and then you have an empirical distribution for your data. Neat!

Is that difference important?

So far we have used resampling to mostly answer questions about our point-estimates: how reliable and stable are our sample statistics? These approaches work well (conceptually) when we are trying to estimate a single parameter, like a proportion. But sometimes we are interested in comparing two parameters: proportions, means, even variances. The key difference (in my mind) is that in these cases–comparing two statistics or parameters–we have some sort of group ID.

In the case of our ice cream data, we would not apply that: each observation we have is a single choice (hand-mixed or machine extruded). So we’ll look at another class project from the same year: a comparison of Black Walnut Syrup to Maple Syrup.

Some of you may know that maple syrup is made from tapping maple trees for sap and then boiling down the sap. It is not made from corn syrup (former Vermont resident here).

From Wikipedia

Black Walnut Syrup is a novel product made in the same way, but from Black Walnut trees, which are abundant in Appalachia.

From Cornell Extension

Students in FST 3024 in 2023 used an affective test to determine whether consumers in Blacksburg preferred one syrup over the other. Unlike the ice cream data, these students asked research subjects to taste and rate the two syrups on a scale of 1-9, where 1 = “Dislike Very Much”. Therefore, we have a bunch of (quasi-)interval data. Let’s take a quick look.

syrup_data <-
  read_excel("data/Week 9/Walnut Syrup Test Raw Data.xlsx",
             sheet = 2, range = "A1:C131")

syrup_labels <-
  read_excel("data/Week 9/Walnut Syrup Test Raw Data.xlsx",
             sheet = 2, range = "E2:F4")

syrup_data <- 
  left_join(
    syrup_data,
    syrup_labels,
    by = c("Blinding Code" = "BC")
  )

glimpse(syrup_data)
Rows: 130
Columns: 4
$ `Blinding Code` <dbl> 113, 113, 113, 113, 113, 113, 113, 113, 113, 113, 113,…
$ Panelist        <dbl> 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16,…
$ `Liking Rating` <dbl> 2, 8, 6, 7, 8, 6, 7, 8, 3, 8, 7, 7, 7, 7, 4, 5, 5, 8, …
$ `Sample Name`   <chr> "Black Walnut Syrup", "Black Walnut Syrup", "Black Wal…

We can now use a “split-apply-combine” approach to get a quick summary of our average liking for each product:

syrup_data |>
  group_by(`Sample Name`) |>
  summarize(mean_liking = mean(`Liking Rating`),
            sd = sd(`Liking Rating`))
# A tibble: 2 × 3
  `Sample Name`      mean_liking    sd
  <chr>                    <dbl> <dbl>
1 Black Walnut Syrup        6.18  1.84
2 Maple Syrup               6.43  1.72

And we could even look at the distributions of liking, perhaps as boxplots:

syrup_data |>
  ggplot(aes(x = `Sample Name`, y = `Liking Rating`)) +
  geom_boxplot() + 
  theme_classic() + 
  labs(x = NULL)

We’d like to know if this small difference in liking is meaningful–we do have 65 subjects in the study, so perhaps a 1/3 point difference is meaningful? We could bootstrap the data and produce a robust, non-parametric estimate for the range of each mean, etc. And this would actually work great, but I am setting us up for another approach!

Specifically, here we have an \(x\) variable: sample type. We want to know if knowing the sample type (Black Walnut or Maple) gets us any more information to predict liking.

These are the kinds of questions that lead to testable hypotheses. In future classes, we are going to touch on models of statistical analysis like the linear model which give us formulas to deal with these questions. In particular, we might use ANOVA (ANalysis Of VAriance) to answer this kind of question (where \(x\) is categorical), or linear regression (they’re really the same thing). But today we’re going to just use some raw computational power to do so.

Does knowing the type of syrup help us predict liking?

We know we have 130 observations in our data set: half (65) are ratings of Maple Syrup, and half are ratings of Black Walnut Syrup. If that doesn’t tell us anything, then switching labels of observations will not change our data. The term for this is permuting our data. We can do this easily using tools like sample().

set.seed(9)
permutation_example <- 
  syrup_data |>
  bind_cols(
    syrup_data |>
      select(`Liking Rating`) |>
      slice_sample(prop = 1) |>
      rename(permuted_liking = 1)
  )

permutation_example
# A tibble: 130 × 5
   `Blinding Code` Panelist `Liking Rating` `Sample Name`      permuted_liking
             <dbl>    <dbl>           <dbl> <chr>                        <dbl>
 1             113        1               2 Black Walnut Syrup               5
 2             113        2               8 Black Walnut Syrup               6
 3             113        3               6 Black Walnut Syrup               8
 4             113        4               7 Black Walnut Syrup               8
 5             113        5               8 Black Walnut Syrup               6
 6             113        6               6 Black Walnut Syrup               6
 7             113        7               7 Black Walnut Syrup               7
 8             113        8               8 Black Walnut Syrup               3
 9             113        9               3 Black Walnut Syrup               8
10             113       10               8 Black Walnut Syrup               6
# ℹ 120 more rows
permutation_example |>
  group_by(`Sample Name`) |>
  summarize(original_mean_liking = mean(`Liking Rating`),
            permuted_mean_liking = mean(permuted_liking))
# A tibble: 2 × 3
  `Sample Name`      original_mean_liking permuted_mean_liking
  <chr>                             <dbl>                <dbl>
1 Black Walnut Syrup                 6.18                 6.43
2 Maple Syrup                        6.43                 6.18

In this case, quite by coincidence, we get reversed mean values. But if we run the same code again (without set.seed(9)) we’ll get slightly different numbers. If we do this enough times, we’ll be able to see whether or not labels are meaningful. How? You guessed it: for() loops!

permutation_results <- tibble()

pb <- progress::progress_bar$new(total = 1e4)

for(i in 1:1e4){
  permuted_data <- 
    tibble(
      name = syrup_data$`Sample Name`,
      liking = sample(syrup_data$`Liking Rating`, size = 130, replace = FALSE)
    )
  
  permutation_results <- 
  bind_rows(  
    permuted_data |>
      group_by(name) |>
      summarize(mean_liking = mean(liking)) |>
      mutate(perm_id = i),
    permutation_results
  )
  
  pb$tick()
}

permutation_results
# A tibble: 20,000 × 3
   name               mean_liking perm_id
   <chr>                    <dbl>   <int>
 1 Black Walnut Syrup        6.2    10000
 2 Maple Syrup               6.42   10000
 3 Black Walnut Syrup        6.38    9999
 4 Maple Syrup               6.23    9999
 5 Black Walnut Syrup        6.25    9998
 6 Maple Syrup               6.37    9998
 7 Black Walnut Syrup        6.29    9997
 8 Maple Syrup               6.32    9997
 9 Black Walnut Syrup        6.12    9996
10 Maple Syrup               6.49    9996
# ℹ 19,990 more rows

We can now look at this in a number of ways. First, we’ll look at the distribution of mean liking ratings, and then we’ll look at our distribution of differences in mean liking compared to our actual results.

permutation_results |>
  ggplot(aes(x = mean_liking, fill = name)) + 
  geom_density(alpha = 1/2) + 
  scale_fill_manual(values = c("orange", "maroon")) + 
  theme_classic() + 
  labs(fill = NULL, x = "Liking Rating", y = NULL)

These are so close to identical that it is almost impossible to tell them apart. This implies that our observed difference is probably not very meaningful, but let’s look at it to be sure:

observed_diff <- 
  syrup_data |>
  select(-`Blinding Code`) |>
  pivot_wider(names_from = `Sample Name`, values_from = `Liking Rating`) |>
  mutate(diff = `Black Walnut Syrup` - `Maple Syrup`) |>
  reframe(observed_diff = mean(diff)) |>
  pull(observed_diff)

permuted_diffs <- 
  permutation_results |>
  pivot_wider(names_from = name, values_from = mean_liking) |>
  mutate(diff = `Black Walnut Syrup` - `Maple Syrup`)


p_perm <- 
  permuted_diffs |>
  ggplot(aes(x = diff)) + 
  geom_histogram(fill = "grey", color = "white", bins = 24) +
  geom_vline(xintercept = observed_diff, color = "red") + 
  theme_classic() +
  labs(x = "Mean Difference", y = NULL) + 
  annotate(geom = "label", x = -0.3, y = 1000, 
           label = "Observed Difference", hjust = 1)

p_perm

We can see that our observed mean difference, as we expected from our density plots, fall squarely into the middle of our distribution of permuted mean differences. If we wanted to be really sure, we could examine the quantiles, just as we did for the bootstrapped results.

permuted_diffs$diff |>
  quantile(probs = c(0.025, 0.975))
      2.5%      97.5% 
-0.5846154  0.6153846 
# And for a reminder, our observed difference
observed_diff
[1] -0.2461538

Our observation falls squarely within this range. If the labels were meaningful–Walnut vs Maple–we’d expect that scrambling them would give us less information than we had when the labels were correct. That doesn’t seem to be the case here!

As a final note, our permutation approach here does not require any of the assumptions that we typically need to make, about normality, independence of errors, etc. It does not matter that (as we could show) our data is very non-normal. Instead, we have just reasoned out a way to examine sources of variation and compare them without making any assumptions of how or why our data is shaped how it is. However, please do note that working out these permutations for complex designs is quite difficult, and often computationally expensive. When your designs become more complex, better-designed models for simulation like Bayesian approaches (McElreath 2020) will work much better!

What do we know about statistics?

Finally, as we move into inference, I want to quickly review some ideas about statistical inference for scientific research that we typically take for granted.

We usually learn about statistics in conjunction with the scientific method in a research-methods or stats class:

The scientific method, image by J.R. Bee

(OK, I just can’t stop myself here, but this is one of the problems with our framework, as we’ll see below. The “Conclusion” in this cartoon actually is never reached in our idea of the Null Hypothesis Test, because all we can do is reject the null. This is counterintuitive and also not a necessary part of the scientific method.)

Typically, the basic stats classes we all take in our undergrad and grad careers follow a trajectory that is something like this:

  1. Talk about probability, usually using coin flips and dice rolls.
  2. Talk about “the scientific method”, as above.
  3. Introduce the idea of a hypothesis as a falsifiable statement. Concentrate on the counterintuitive but key idea of the null hypothesis.
  4. Talk about how to calculate key statistics about samples: central tendencies (means, medians, etc), variations (variance, deviation).
  5. Introduce probability distributions based on situations, typically:
  6. The binomial distribution for coin flips.
  7. The normal distribution for something like heights of students in the class. Don’t forget about the t-distribution, too…
  8. Maybe some more esoteric distributions like the Poisson distribution or the \(\chi^2\)-distribution.
  9. Introduce a bunch of heuristic calculations for how to take a sample you think comes from some distribution to the theoretical null distribution. These are statistical tests.
  10. This is where you usually learn about a menu of tests that you select based on how you believe your sample is distributed, your specific null hypothesis, etc, sch as the following:

Flowchart for selecting a statistical test, taken from the Nishimura Lab.

I think we have all seen a chart like this one, and we’ve all experienced the problem of asking a question like:

OK, I collected the data from my experiment. Now which is the right statistical test to conduct?

Uh…

In order to use this kind of chart, we need to make a lot of assumptions. We have to have a well-defined, falsifiable hypothesis (which we should really have before we start collecting data, but…), we need to understand the various tests enough to understand how to use them, and we need to understand the various assumptions underlying each test (this is the point at which we tend to fail the most often).

I don’t like these charts and this melange of statistical methods for all of these reasons! In particular, I dislike them because the assumptions that underlie the tests are important, but because they are rarely achieved in practice our tests are often failing in unpredictable ways. But even more so, I dislike them because, for many simple problems, we simply do not need to be going to all this trouble.

These types of charts, and the statistical tests they lead to, are the result of the need for heuristic approaches to statistics: as we talked about in Week 3, statistics originated in the 17th centuries with studies of probability in the absence of simulation. It was simply not practical to simulate “null” distributions, so early theorists developed very clever, computationally tractable approaches that avoided the need for simulation. But now we have computers. And we have learned to write simple code to make simulations for ourselves, which can often (much more intelligibly) help us explore our data and test our hypotheses.

So, on that note, and with what we’ve learned today, I look forward to trying out applications of this code with you on Thursday!

BONUS: Bayesian preference test

library(tidyverse)
library(brms)
library(tidybayes)

# We know that 29/79 subjects preferred the machine-fed.
m1 <- 
  brm(data = list(mf = 29),
      family = binomial(link = "identity"),
      formula = mf | trials(79) ~ 0 + Intercept,
      iter = 5000, chains = 4, cores = 4, warmup = 1000, seed = 1)

as_draws_df(m1) |>
  ggplot(aes(x = b_Intercept)) + 
  stat_halfeye(point_interval = mode_hdi, 
               fill = "darkorange", color = "maroon") +
  geom_ribbon(data = tibble(x = c(0.475, 0.525), ymin = -Inf, ymax = Inf),
            aes(x = x, ymin = ymin, ymax = ymax), 
            inherit.aes = FALSE,
            fill = "pink", alpha = 1/2) + 
  annotate(geom = "text", x = 0.5, y = 0.5, label = "ROPE:\n0.5 ± .025") + 
  theme_bw()

Reading

This week, you should read:

  1. R for Data Science, Chapter 13
  2. Stat 545, Chapters 14-16
  3. Bayes Rules! If you want to learn more about Bayes, I recommend Chapters 1-3 of this free online resource. Tidy-friendly!

Session info

R version 4.5.3 (2026-03-11)
Platform: aarch64-apple-darwin20
Running under: macOS Tahoe 26.5.1

Matrix products: default
BLAS:   /Library/Frameworks/R.framework/Versions/4.5-arm64/Resources/lib/libRblas.0.dylib 
LAPACK: /Library/Frameworks/R.framework/Versions/4.5-arm64/Resources/lib/libRlapack.dylib;  LAPACK version 3.12.1

locale:
[1] en_US.UTF-8/en_US.UTF-8/en_US.UTF-8/C/en_US.UTF-8/en_US.UTF-8

time zone: America/New_York
tzcode source: internal

attached base packages:
[1] stats     graphics  grDevices utils     datasets  methods   base     

other attached packages:
 [1] tidybayes_3.0.7 brms_2.23.0     Rcpp_1.1.1      readxl_1.4.5   
 [5] skimr_2.2.2     lubridate_1.9.5 forcats_1.0.1   stringr_1.6.0  
 [9] dplyr_1.2.1     purrr_1.2.2     readr_2.2.0     tidyr_1.3.2    
[13] tibble_3.3.1    ggplot2_4.0.2   tidyverse_2.0.0

loaded via a namespace (and not attached):
 [1] svUnit_1.0.8          tidyselect_1.2.1      farver_2.1.2         
 [4] loo_2.9.0             S7_0.2.1              fastmap_1.2.0        
 [7] TH.data_1.1-5         tensorA_0.36.2.1      digest_0.6.39        
[10] timechange_0.4.0      estimability_1.5.1    lifecycle_1.0.5      
[13] StanHeaders_2.32.10   processx_3.8.7        survival_3.8-6       
[16] magrittr_2.0.5        posterior_1.7.0       compiler_4.5.3       
[19] rlang_1.2.0           progress_1.2.3        tools_4.5.3          
[22] utf8_1.2.6            yaml_2.3.12           knitr_1.51           
[25] prettyunits_1.2.0     labeling_0.4.3        bridgesampling_1.2-1 
[28] htmlwidgets_1.6.4     pkgbuild_1.4.8        repr_1.1.7           
[31] RColorBrewer_1.1-3    abind_1.4-8           multcomp_1.4-30      
[34] withr_3.0.2           stats4_4.5.3          grid_4.5.3           
[37] colorspace_2.1-2      inline_0.3.21         xtable_1.8-8         
[40] emmeans_2.0.3         scales_1.4.0          MASS_7.3-65          
[43] cli_3.6.6             mvtnorm_1.3-7         rmarkdown_2.31       
[46] crayon_1.5.3          generics_0.1.4        otel_0.2.0           
[49] RcppParallel_5.1.11-2 rstudioapi_0.18.0     tzdb_0.5.0           
[52] rstan_2.32.7          splines_4.5.3         bayesplot_1.15.0     
[55] parallel_4.5.3        cellranger_1.1.0      matrixStats_1.5.0    
[58] base64enc_0.1-6       vctrs_0.7.3           Matrix_1.7-4         
[61] sandwich_3.1-1        jsonlite_2.0.0        callr_3.7.6          
[64] arrayhelpers_1.1-0    hms_1.1.4             ggdist_3.3.3         
[67] rematch_2.0.0         glue_1.8.1            codetools_0.2-20     
[70] distributional_0.7.0  stringi_1.8.7         gtable_0.3.6         
[73] QuickJSR_1.9.2        pillar_1.11.1         htmltools_0.5.9      
[76] Brobdingnag_1.2-9     R6_2.6.1              evaluate_1.0.5       
[79] lattice_0.22-9        backports_1.5.1       rstantools_2.6.0     
[82] gridExtra_2.3         coda_0.19-4.1         nlme_3.1-168         
[85] checkmate_2.3.4       xfun_0.57             zoo_1.8-15           
[88] pkgconfig_2.0.3      

References

Bruce, Peter, and Andrew Bruce. 2017. Practical Statistics for Data Scientists. O’Reilly.
McElreath, Richard. 2020. Statistical Rethinking. Second. CRC Press.