Check Assumptions Before a t-test
Is our data normally distributed? If not, what should we do about it?
So you want to test whether the mean of one sample is no different than a hypothesized value. There are many statistical paths to test this null hypothesis and a one-sample t-test is one of the most common. Why? It’s powerful (i.e., can detect differences when they exist) and they are simple to calculate. In part, that power comes from an assumption that the data is normally distributed. If we assume normality then all we need is a mean and a standard deviation to give us a complete theoretical picture of how the underlying population is structured. But, we have to make sure our data is normal enough before we can make the assumption and run the t-test.
How can we do this?
- Clean up our data so missing values don’t get in the way.
- Use
na.rm = TRUEin summaries (mean, quantile, IQR, Shapiro)
- Use
- Identify and trim/filter out impossible values (e.g., negative counts or bill depths)
- Flag distributional outliers (1.5 × IQR)
- Look at your data using
ggplotandgeom_histogram() - Test for normality using
shapiro.test() - Decide if your data is non-normal enough to warrant a transformation
- If your data is normal, decide what to do, if anything, about your outliers
- If the data is not normally distributed, transform it. We will use a log transformation in this activity
- Look at your transformed data with a histogram and test for normality again using
shapiro.test() - Decide which test you will run
- If the data is still not normally distributed, even after appropriate transformation, conduct a non-parametric test
- If the data is normal enough then run a one-sample t-test
1. Load packages
Coding Assignment 3 starts with the tidyverse; this activity also needs the Palmer Penguins dataset. Run library(tidyverse) and library(palmerpenguins). A conflicts message after tidyverse is expected, not an error.
From ?library: library(package, ...) attaches a package so its functions become available.
library(package name here)
library(another package name here)Load both packages — tidyverse for dplyr and ggplot2, palmerpenguins for the penguins dataset.
library(tidyverse)
library(palmerpenguins)On DataHub you will load tidyverse for the graded assignment; here you also need palmerpenguins for practice data.
2. Build the study group
A one-sample t-test asks whether the mean of one group differs from a hypothesized value. Filter to Adelie penguins measured in 2007 and save the result as penguin_data. You will check assumptions on bill_depth_mm.
From ?filter: keep rows where conditions are TRUE.
penguins |>
filter(species == "Gentoo")Filter to Adelie and year 2007. No new columns needed — bill_depth_mm is already there.
penguin_data <- penguins |>
filter(species == "Adelie", year == 2007)You should have 50 rows — one Adelie cohort from a single field season.
3. Glimpse the data
Run glimpse() on penguin_data to see row count, column names, and types.
From ?glimpse: glimpse(x) prints rows, columns, and types.
glimpse(mtcars)Apply it to the dataset you just built.
glimpse(penguin_data)You should see 50 rows. bill_depth_mm is <dbl> — and one penguin has a missing bill measurement.
4. Step 1 — missing and impossible values
Two different data problems show up in real datasets: missing values (NA) and impossible values (e.g. negative bill depths). They need different handling.
- Missing means no measurement was recorded — keep those rows for now.
- Impossible means a sign error or bad entry — remove those rows.
Use filter(is.na(bill_depth_mm) | bill_depth_mm >= 0) to drop impossible depths while keeping missing ones. After this step, every numeric summary uses na.rm = TRUE so R skips the remaining NAs.
Bill depth cannot be negative. Remove rows where bill_depth_mm is negative. Keep rows where bill_depth_mm is missing. Save the result as penguin_clean.
| means “or”
Inside filter(), the vertical bar | means “or”. The condition is.na(bill_depth_mm) | bill_depth_mm >= 0 keeps a row if either the depth is missing or it is zero or positive.
Writing filter(bill_depth_mm >= 0) alone silently drops the missing rows — because NA >= 0 evaluates to NA, not TRUE.
From ?filter: keep rows where conditions are TRUE.
mtcars |>
filter(is.na(mpg) | mpg >= 0)Keep missing bill depths or non-negative depths.
penguin_clean <- penguin_data |>
filter(is.na(bill_depth_mm) | bill_depth_mm >= 0)50 rows — no negative values in this dataset, so nothing was removed. The one missing measurement is still there. Confirming there are no impossible values is itself a reportable result.
Count how many bill depths are still missing, then preview the mean using na.rm = TRUE — the same pattern you will use in every summary from here on.
From ?sum: sum(is.na(x)) counts missing values. From ?mean: set na.rm = TRUE to skip them.
Example on mtcars:
n_missing <- sum(is.na(mtcars$mpg))
mean_preview <- mean(mtcars$mpg, na.rm = TRUE)Apply the same pattern to penguin_clean$bill_depth_mm.
n_missing <- sum(is.na(penguin_clean$bill_depth_mm))
mean_preview <- mean(penguin_clean$bill_depth_mm, na.rm = TRUE)
n_missing
mean_previewOne missing depth; mean ≈ 18.8 mm computed from the 49 complete cases only.
5. Flag distributional outliers
Using only the non-missing bill depths in penguin_clean, calculate the upper fence of the 1.5 × IQR rule: Q3 + 1.5 × IQR. Save it as upper_fence and print it. Also count how many non-missing bill depths exceed that fence and save the count as n_raw_outliers. Print both values. Do not remove any rows.
The 1.5 × IQR rule is a screening tool. Points above the fence deserve a look; they are not automatically errors.
From ?quantile: quantile(x, probs, na.rm = FALSE). From ?IQR: IQR(x, na.rm = FALSE).
q3 <- quantile(mtcars$mpg, 0.75, na.rm = TRUE)
fence <- q3 + 1.5 * IQR(mtcars$mpg, na.rm = TRUE)
n_out <- sum(mtcars$mpg > fence, na.rm = TRUE)Use the 75th percentile and IQR of non-missing bill depths, then count values above the fence.
upper_fence <- quantile(penguin_clean$bill_depth_mm, 0.75, na.rm = TRUE) +
1.5 * IQR(penguin_clean$bill_depth_mm, na.rm = TRUE)
n_raw_outliers <- sum(penguin_clean$bill_depth_mm > upper_fence, na.rm = TRUE)
upper_fence
n_raw_outliersThe upper fence is about 21.4 mm — one penguin has a bill depth above it.
6. Histogram on the raw scale
Make a histogram of the non-missing bill depths in penguin_clean using ggplot() and geom_histogram(). Save the plot as hist_raw and display it.
From ?ggplot: add a layer with +.
hist_raw <- ggplot(mtcars, aes(x = mpg)) +
geom_histogram()
hist_rawMap bill_depth_mm to the x-axis and assign the whole plot to hist_raw.
hist_raw <- ggplot(penguin_clean, aes(x = bill_depth_mm)) +
geom_histogram()
hist_rawThe distribution is right-skewed — a long tail toward deep bills.
7. Shapiro-Wilk on the raw scale
Run shapiro.test() on the non-missing bill depths in penguin_clean. Save the result as shapiro_raw and print it.
The null hypothesis is normality. A small p-value (conventionally below 0.05) means the data look inconsistent with a normal distribution. A large p-value means you do not have evidence against normality — not proof that the data are perfectly normal.
Do not treat p > 0.05 as a green light or p < 0.05 as an automatic veto — combine this test with your histogram and sample size.
Pass the bill-depth vector directly.
shapiro.test(mtcars$mpg)Use non-missing values from penguin_clean$bill_depth_mm.
shapiro_raw <- shapiro.test(penguin_clean$bill_depth_mm)
shapiro_rawp ≈ 0.029 — evidence against normality on the raw scale.
8. Make the call on normality
Based on your histogram and Shapiro-Wilk result, are the raw bill depths normally distributed enough for a one-sample t-test? Assign data_is_normal to TRUE or FALSE (no quotes, all caps).
Look at shapiro_raw$p.value. Is it above or below 0.05?
data_is_normal <- FALSEp ≈ 0.029 — below 0.05, and the histogram is right-skewed. Follow checklist steps 7–9 and try a log transform.
9. Log transform
The raw depths are not normally distributed. Apply a log transformation: use mutate() to add a column called log_depth equal to the natural log of bill_depth_mm. Save the result as penguin_transformed and inspect it with glimpse().
From ?mutate: mutate(.data, ...) adds or changes columns. From ?log: log(x) returns the natural logarithm.
mtcars |>
mutate(log_mpg = log(mpg))Add log_depth = log(bill_depth_mm) to penguin_clean.
penguin_transformed <- penguin_clean |>
mutate(log_depth = log(bill_depth_mm))
glimpse(penguin_transformed)Missing bill depths stay missing; negative rows were never present.
10. Histogram on the log scale
Make a histogram of log_depth from penguin_transformed, using only non-missing values. Save the plot as hist_log and display it.
Same pattern as the raw histogram — map the transformed column to x.
hist_log <- ggplot(mtcars, aes(x = log(mpg))) +
geom_histogram()
hist_loghist_log <- ggplot(penguin_transformed, aes(x = log_depth)) +
geom_histogram()
hist_logThe distribution looks much more symmetric after the log transform.
11. Shapiro-Wilk on the log scale
Run shapiro.test() on the non-missing log_depth values in penguin_transformed. Save the result as shapiro_log and print it.
Same function as before — pass a numeric vector to shapiro.test().
Example:
shapiro.test(mtcars$mpg)Use non-missing values from penguin_transformed$log_depth.
shapiro_log <- shapiro.test(penguin_transformed$log_depth)
shapiro_logp ≈ 0.081 — no evidence against normality after the log transform.
12. Make the call on log
Based on your histogram and Shapiro-Wilk result for the log-transformed data, are the log bill depths normally distributed enough for a one-sample t-test? Assign log_is_normal to TRUE or FALSE (no quotes, all caps).
Look at shapiro_log$p.value. Is it above or below 0.05?
log_is_normal <- TRUEp ≈ 0.081 — no evidence against normality on the log scale.
13. Re-flag outliers after transformation
Using only the non-missing log_depth values in penguin_transformed, calculate the upper fence of the 1.5 × IQR rule on the log scale. Save it as upper_fence_log and print it. Count how many non-missing log_depth values exceed that fence and save the count as n_log_outliers. Print both values. Do not remove any rows yet.
Apply the same Q3 + 1.5 × IQR formula to log_depth instead of bill_depth_mm.
Example on mtcars:
q3 <- quantile(mtcars$mpg, 0.75, na.rm = TRUE)
upper_fence_log <- q3 + 1.5 * IQR(mtcars$mpg, na.rm = TRUE)
n_log_outliers <- sum(mtcars$mpg > upper_fence_log, na.rm = TRUE)upper_fence_log <- quantile(penguin_transformed$log_depth, 0.75, na.rm = TRUE) +
1.5 * IQR(penguin_transformed$log_depth, na.rm = TRUE)
n_log_outliers <- sum(penguin_transformed$log_depth > upper_fence_log, na.rm = TRUE)
upper_fence_log
n_log_outliersZero penguins sit above the log-scale fence — the one raw outlier was pulled inside the fence by the transformation.
14. Decide whether to trim/Winsorize outliers
Based on your log-scale outlier count, decide whether to remove any rows. Assign remove_outliers to TRUE if you would filter out IQR outliers, or FALSE if you would keep all rows.
The raw outlier was a Torgersen male with a 21.5 mm bill depth — large but biologically plausible. After the log transform, n_log_outliers is 0, so there is nothing left to filter out. Assign TRUE or FALSE accordingly.
remove_outliers <- FALSEKeep all rows. The one bird flagged on the raw scale is no longer an outlier after transformation, and it was never an impossible value like a negative count.
15. Run a one-sample t-test
Assumptions look good on the log scale: no impossible values, no log-scale IQR outliers, and log_depth is normal enough. Test whether mean log bill depth differs from log(18.5).
Use t.test() on penguin_transformed$log_depth with mu = log(18.5). Save the result as t_test_result and print it.
The null hypothesis is that the true mean equals mu. A small p-value (conventionally below 0.05) means the sample mean looks inconsistent with the hypothesized value.
Logging then testing means your inference applies to log(depth), not the original millimeter scale. That is acceptable if you report it clearly in your write-up.
From ?t.test: for one sample, pass a numeric vector and a hypothesized mean.
t.test(mtcars$mpg, mu = 20)Use penguin_transformed$log_depth and mu = log(18.5).
t_test_result <- t.test(penguin_transformed$log_depth, mu = log(18.5))
t_test_resultt ≈ 1.33, df = 48, p ≈ 0.19. Mean log bill depth in these 2007 Adelie penguins is not significantly different from log(18.5).
16. Recap — functions you practiced
| Function | Role |
|---|---|
library(tidyverse) |
Loads dplyr, ggplot2, and related packages |
library(palmerpenguins) |
Loads the penguins dataset |
filter() |
Keeps rows matching a condition (species == "Adelie", is.na(...) \| ... >= 0) |
mutate() |
Adds transformed columns (log_depth) |
glimpse() |
Quick look at rows, columns, and types |
mean(..., na.rm = TRUE) |
Average of a numeric column, skipping missing values |
quantile(..., 0.75, na.rm = TRUE) |
75th percentile for the IQR fence |
IQR(..., na.rm = TRUE) |
Interquartile range for the 1.5×IQR rule |
sum(x > fence, na.rm = TRUE) |
Counts values above an outlier fence |
ggplot() + geom_histogram() |
Visual check of distribution shape |
shapiro.test() |
Formal test of normality (H₀: data are normal) |
log() |
Natural-log transformation for right-skewed data |
t.test(x, mu = ...) |
One-sample t-test against a hypothesized mean |
If you can explain each row in your own words, you have covered the core of Coding Assignment 3.
Keep playing
What happens if you filter to male Adelie penguins only? Run Shapiro-Wilk on that smaller group and compare it to the full 2007 cohort.
p ≈ 0.12 — males alone pass the normality check on the raw scale. The full 2007 cohort failed because males and females have different bill depths stacked together. That is why you check assumptions on one group, not a pile of groups.