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.
A bill depth of zero or less is impossible. Remove rows where bill_depth_mm is zero or 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 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 positive depths.
penguin_clean <- penguin_data |>
filter(is.na(bill_depth_mm) | bill_depth_mm > 0)50 rows — no zero or 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 both fences of the 1.5 × IQR rule: Q1 − 1.5 × IQR and Q3 + 1.5 × IQR. Save them as lower_fence and upper_fence. Count how many non-missing bill depths fall outside either fence and save the count as n_raw_outliers. Print all three values. Do not remove any rows.
The 1.5 × IQR rule is a screening tool. Points outside the fences deserve a look; they are not automatically errors. Always compute both fences, even when you expect only one side to catch anything.
From ?quantile: quantile(x, probs, na.rm = FALSE). From ?IQR: IQR(x, na.rm = FALSE).
iqr <- IQR(mtcars$mpg, na.rm = TRUE)
lower <- quantile(mtcars$mpg, 0.25, na.rm = TRUE) - 1.5 * iqr
upper <- quantile(mtcars$mpg, 0.75, na.rm = TRUE) + 1.5 * iqr
n_out <- sum(mtcars$mpg < lower | mtcars$mpg > upper, na.rm = TRUE)Use the 25th and 75th percentiles and the IQR of non-missing bill depths, then count values below the lower fence or above the upper fence.
iqr_raw <- IQR(penguin_clean$bill_depth_mm, na.rm = TRUE)
lower_fence <- quantile(penguin_clean$bill_depth_mm, 0.25, na.rm = TRUE) - 1.5 * iqr_raw
upper_fence <- quantile(penguin_clean$bill_depth_mm, 0.75, na.rm = TRUE) + 1.5 * iqr_raw
n_raw_outliers <- sum(penguin_clean$bill_depth_mm < lower_fence |
penguin_clean$bill_depth_mm > upper_fence, na.rm = TRUE)
lower_fence
upper_fence
n_raw_outliersThe fences are about 15.8 mm and 21.4 mm. Nothing falls below the lower fence; one penguin has a bill depth above the upper fence.
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.
Honest caveat: with n = 49 and skew this mild, a t-test on the raw scale would also be defensible. We transform here to practice the full pipeline. The recap shows how little the transform actually changed.
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; impossible 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 both fences of the 1.5 × IQR rule on the log scale. Save them as lower_fence_log and upper_fence_log. Count how many non-missing log_depth values fall outside either fence and save the count as n_log_outliers. Print all three values. Do not remove any rows yet.
Apply the same two-fence formula to log_depth instead of bill_depth_mm.
Example on mtcars:
iqr <- IQR(mtcars$mpg, na.rm = TRUE)
lower_fence_log <- quantile(mtcars$mpg, 0.25, na.rm = TRUE) - 1.5 * iqr
upper_fence_log <- quantile(mtcars$mpg, 0.75, na.rm = TRUE) + 1.5 * iqr
n_log_outliers <- sum(mtcars$mpg < lower_fence_log | mtcars$mpg > upper_fence_log, na.rm = TRUE)iqr_log <- IQR(penguin_transformed$log_depth, na.rm = TRUE)
lower_fence_log <- quantile(penguin_transformed$log_depth, 0.25, na.rm = TRUE) - 1.5 * iqr_log
upper_fence_log <- quantile(penguin_transformed$log_depth, 0.75, na.rm = TRUE) + 1.5 * iqr_log
n_log_outliers <- sum(penguin_transformed$log_depth < lower_fence_log |
penguin_transformed$log_depth > upper_fence_log, na.rm = TRUE)
lower_fence_log
upper_fence_log
n_log_outliersThe log-scale fences are about 2.77 and 3.07. Zero penguins sit outside them — the one raw outlier was pulled inside the upper 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 changes the hypothesis. The mean of log_depth back-transforms to the geometric mean of bill depth, so testing against log(18.5) asks whether the geometric mean bill depth is 18.5 mm, not the arithmetic mean. Here the two means are nearly identical (18.73 vs. 18.77 mm), so nothing turns on it, but your write-up should say which one you tested.
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). Equivalently, the geometric mean bill depth (18.73 mm) is not significantly different from 18.5 mm.
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.
- The transform was marginal. Skewness dropped only from 0.59 to 0.46, and Shapiro-Wilk moved from p = 0.029 to p = 0.081. A t-test on the raw scale gives p = 0.13; the log-scale test gives p = 0.19. Same conclusion. With this sample size and this mild a skew, the raw-scale test would have been defensible too; we transformed to practice the pipeline. A p-value below 0.05 is not an order to transform.
- The hypothesis changed. Testing
log_depthagainstlog(18.5)asks whether the geometric mean bill depth is 18.5 mm, not the arithmetic mean. Here they are nearly identical (18.73 vs. 18.77 mm), so nothing turns on it, but say which one you tested.
Keep playing
Back-transform the confidence interval. t_test_result$conf.int is on the log scale. Wrap it in exp() to get a confidence interval for the geometric mean bill depth in millimeters, which is the last step of the log-transform pipeline.
Geometric mean ≈ 18.73 mm, 95% CI ≈ 18.38 to 19.08 mm. The interval contains 18.5, which matches p ≈ 0.19.
Compare with the raw scale. Run the same test on bill_depth_mm with mu = 18.5 and see whether the conclusion changes.
t ≈ 1.52, df = 48, p ≈ 0.13. Same conclusion as the log-scale test, and this interval is for the arithmetic mean in millimeters with no back-transform needed.
Check one group at a time. 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. Splitting the sexes is a better fix for this skew than any transformation.