Non Parametric Tests and Multiple Comparisons

2026 fall

Overview of Today’s Lecture

Non-Parametric Testing Procedures

An Unknown Population Distribution.

We’ve seen in the previous lectures that under certain conditions, the t-test can be used to run hypothesis tests even when the data is not normally distributed. The central limit theorem tells us that when a population is not normally distributed, when you take a large enough sample, the mean of the sample tends to be normal. The larger the sample, the more normal the sample mean will appear.

One Sample Sign Test (Continuous data)

This is a non parametric test that lets us compare the sample median to a known population median. It is a simple alternative to the one sample t-test when you cannot assume normality of the data and is based on counting the number of observations that fall above/below the expected median. It’s easiest to understand with an example.

Q:
A manufacturer claims that the median lifespan of a certain brand of light bulbs is 1000 hours. A consumer protection group suspects that the actual median lifespan might be different. They collect a random sample of 10 bulbs from store shelves and record their lifespans (in hours):

# Sample data: lifespans of 10 light bulbs
lifespans <- c(980, 1020, 1010, 970, 1050, 1030, 1000, 950, 990, 1040)
lifespans
##  [1]  980 1020 1010  970 1050 1030 1000  950  990 1040

Determine whether there is evidence that the median lifespan differs from 1000 hours.

A:
Here we have a small sample of only 10 observations that clearly does not come from a normal distribution. Our goal is to see if the median lifespan is different from 1000.

plot(hist(lifespans))

samp.med = median(lifespans)
samp.med
## [1] 1005

With such a small sample, the histogram doesn’t provide much useful information to us.

The median of 1005 is close to 1000, but also doesn’t give us much info either.

The trick to solving this problem is to think about the null hypothesis scenario, i.e. how the data would behave if the population had a median of 1000.

If the population median is \(\theta_0\) we expect half of the sample observations to fall below and half above. This is our basis of the statistical hypothesis test.

We can use the same framework we used for our categorical comparisons.

binom.test(k, n, p=0.5)
# Hypothesized median under H0
median_h0 <- 1000

# Calculate the difference from hypothesized median
differences <- lifespans - median_h0

# Determine the signs of differences
# whether the observation falls above/below
signs <- sign(differences)

# Remove zeros (cases exactly equal to the hypothesized median)
signs_no_ties <- signs[signs != 0]

# Count positive signs (lifespans > 1000)
n_positive <- sum(signs_no_ties > 0)

# Total number of non-tied observations
n_total <- length(signs_no_ties)

# Perform a two-sided binomial test
test_result <- binom.test(n_positive, n_total, p = 0.5, alternative = "two.sided")

# Print results
print(test_result)
## 
##  Exact binomial test
## 
## data:  n_positive and n_total
## number of successes = 5, number of trials = 9, p-value = 1
## alternative hypothesis: true probability of success is not equal to 0.5
## 95 percent confidence interval:
##  0.2120085 0.8630043
## sample estimates:
## probability of success 
##              0.5555556

Notes on the Sign Test

Two sample Komolgorov-Smirnov Test (Continuous data)

This is a test to check if two independent samples have the same distribution. We’re not trying to figure out what that distribution is, we’re just checking if it’s reasonable to say the two samples were drawn from it.

The Komolgovorv Smirnov test computes the empirical cumulative distribution of each sample, and calculates the largest vertical distance \(D\) between the two. Under the null hypothesis that the two samples come from the same distribution, this distance should be small. Under the null, this statistic \(D\) follows the Komolgorov-Smirnov distribution and we can calculate p-value probabilities just like all the other hypothesis tests we’ve gone over so far.

set.seed(1)
# Sample A: from standard normal distribution
sample_A <- rnorm(100, mean = 0, sd = 1)

# Sample B: from uniform distribution
sample_B <- runif(100, min = -2, max = 2)


# Set up a two-panel plot: histogram and ECDF
par(mfrow = c(1, 2))  # 1 row, 2 columns

# --- Plot 1: Histograms ---
hist(sample_A, col = rgb(1, 0, 0, 0.5), breaks = 15,
     xlim = range(c(sample_A, sample_B)), main = "Histograms",
     xlab = "Value", freq = FALSE)
hist(sample_B, col = rgb(0, 0, 1, 0.5), breaks = 15,
     add = TRUE, freq = FALSE)
legend("topright", legend = c("Sample A: Normal(0,1)", "Sample B: Uniform(-2,2)"),
       fill = c(rgb(1, 0, 0, 0.5), rgb(0, 0, 1, 0.5)), border = NA)

# --- Plot 2: ECDFs ---
plot(ecdf(sample_A), verticals = TRUE, col = "red", lwd = 2,
     main = "ECDFs and KS Test", xlab = "Value", ylab = "F(x)")
lines(ecdf(sample_B), verticals = TRUE, col = "blue", lwd = 2)
legend("bottomright", legend = c("Sample A: Normal", "Sample B: Uniform"),
       col = c("red", "blue"), lwd = 2)

# Perform the two-sample Kolmogorov-Smirnov test
ks_result <- ks.test(sample_A, sample_B)

# Print the KS test result
print(ks_result)
## 
##  Asymptotic two-sample Kolmogorov-Smirnov test
## 
## data:  sample_A and sample_B
## D = 0.29, p-value = 0.0004453
## alternative hypothesis: two-sided

Notes on the KS Test

What about data that doesn’t seem to fit any of these frameworks?

What about data that doesn’t seem to fit any of these frameworks?

  • Data
    • information from 20 households
    • the amount of water contamination in the household
    • whether or not the baby was born with a defect
    • rectangular matrix with 20 rows and 2 columns
      • contamination (continuous)
      • defect (categorical)
  • If you had this data, how would you check for association between contamination and birth defect?
    • split into groups by defect status and check for difference in contamination levels?
      • there’s only 2 observations in the defect group
  • We don’t have the full dataset, all we know is:
    • the 2 children born with defects were ranked 1st and 4th highest in contamination levels.
  • Can we say anything about the data?
    • it seems strange that the two babies with defects had the highest and 4th highest levels of contamination.
    • is there a way to say this mathematically?
      • if you randomly sampled 2 households, what are the chances their contamination levels are more extreme than 1st and 4th rank?
        • e.g. (1st and 3rd) or (1st and 2nd)
  • We need a way to capture the ‘signal’ in a sample
    • a measure that tells us how strong the effect is
      • sum the ranks of contamination levels
        • our ‘signal’ is 1 + 4
        • the lower the signal the more extreme the sample is
  • hypothesis test
    • null: there is no relation between contamination and birth defect
    • What’s the chance of observing a sample as extreme as what we’ve seen if we assume there’s no association?
      • if it’s not likely, then reject the null
  • idea #1.
    • Enumerate every possible way of drawing 2 households.
      If there’s no association between contamination and birth defect then every outcome has the same chance of occurring.
      • How many possible outcomes?
        • \(\binom{20}{2}\) = \(\frac{20!}{18! 2!}\) = 190
    • count the number of times we see an outcome more extreme than what we’ve observed.
      • (1,2), (1,3), (1,4), (2,3)
    • whats the p-value?
      • 4/190 = 0.021
    • this is only feasible with small sample sizes
      • it’s hard to enumerate all possible extreme cases by hand
  • idea #2 (resampling)
    • instead of enumerating every possible way
    • build a null distribution: what does your signal look like under the conditions of the null hypothesis
    • If there’s no relationship between contamination and birth defect then it doesn’t matter which two households we choose.
      • signal: a measure of the amount of contamination in the households with a birth defect (rank sum)
      • repeat many times
        • measure and collect the signal from two random households
      • the collection of reshuffled signals forms the null distribution and gives us an idea of what the fully enumerated set looks like.
    • We can use the null distribution to figure out how likely it would be to observe a signal as extreme as what we’ve observed
      • count the number of extreme outcomes from the null distribution.
      • #extreme / #total gives us our resampling p-value.
    • Can be done with large samples.
      • The accuracy is related to how many times you repeat to build the null distribution.

idea #2 (resampling)

If enumerating every possibility is not feasible, we can still get an idea of our signal by enumerating what we can.

Let’s test out this out on our problem.

Under the null hypothesis there’s no association between contamination and defect so reordering the labels should not make a difference. We want to build a null distribution by simulation. What does our signal look like with the ranks are randomly assigned?

#repeat this many times
K = 10000
null.ranks = sapply(1:K, function(i){
    #since we're only interested in the 2 families with birth defects
    #we don't need to resample all 20 to find the rank-sum
    defect.ranks = sample(1:20, size=2, replace=F)
    sum(defect.ranks)
})

head(null.ranks, 30)
##  [1] 29 21 10 23 22 21 12 30  9 26 28 12 18  9 27 29 32 19 18 27 29 11 16  5 21
## [26] 24 19 16 28 14
hist(null.ranks, breaks=20)

#how many of these null ranks as less than our observed signal?
n.extreme = sum(null.ranks <= (1+4))

resampling.pval = n.extreme / K
resampling.pval
## [1] 0.02

back to idea #1

Enumerate all possible values, and see how many of them are more extreme than what we’ve observed in our sample.

This test already has a name

If you can enumerate all the extreme signal values, then you can calculate the pvalue directly.

The p-value is:

4/choose(20,2)
## [1] 0.02105263

Or, you can work with the ranks and use the builtin procedures. If you know the ranks of one group, then you can take the set difference to get the ranks of the other group. With two sets of ranks, you can now run the Mann-Whitney Rank-Sum Test.

#ranks in group 1
ranks1 = c(1,4)
#ranks in group 2  (what's left over)
ranks2 = (1:20)[-ranks1] 

ranks1
## [1] 1 4
ranks2
##  [1]  2  3  5  6  7  8  9 10 11 12 13 14 15 16 17 18 19 20
wilcox.test(ranks1, ranks2, alternative="less")
## 
##  Wilcoxon rank sum exact test
## 
## data:  ranks1 and ranks2
## W = 2, p-value = 0.02105
## alternative hypothesis: true location shift is less than 0

Dealing with multiple comparisons

Dealing with multiple comparisons

What is type I error?

what is significance level (\(\alpha\))

When you’re running many hypothesis tests, the probability of making a type I error in at least one of them is lot higher than if you just look at a single test.

Dealing with multiple comparisons

What is p-Hacking?

Without multiple testing correction, p-hacking can easily produce misleading results that appear valid.

How do you address false positives when running multiple tests?

Do nothing:

Bonferroni:

FDR:

Especially in large-scale testing (e.g., genomics, neuroimaging, or psychology experiments), you may test thousands of hypotheses. Corrections like the False Discovery Rate (FDR) help control how many of your “discoveries” are likely to be false.

Multiple comparisons in R

The three methods above are already implemented in R and can be accessed through the ‘p.adjust’ function. You input the results of your tests as a list of p-values, specify the method you want to use, and it returns the new list of “adjusted p-values” that have been corrected for the multiple comparisons.

An Example

To illustrate the need to adjusting for multiple comparisons, we’re going to construct a dataset that simulates the results of many experimental runs we’re we’re trying to see if there’s a difference in the means between two groups. In most of the runs(n=1000), the two groups were generated from the same distribution, but in some of the runs(n=100) the two group come from different distributions.

We’ll see what happens when we don’t adjust for the multiple comparisons over all(n=1100) tests.

1. Set up the data

set.seed(1)
#create a fake dataset that has 1000 variables with 'no signal'.  
x.nosig <- as.data.frame(matrix(rnorm(100 * 1000), nrow = 100))  # 100 obs, 200 variables

#create another fake dataset that has 100 variables with 'signal'
x.sig = rbind(
  matrix(rnorm(100*50, mean=1, sd=1), nrow=50),
  matrix(rnorm(100*50, mean=1.5, sd=1), nrow=50)
)

#make a rectangular matrix for the data.  
#Variables are along the rows, subjects along the columns
x = t(cbind(x.nosig, x.sig))
dim(x)
## [1] 1100  100
#The condition to split the subjects by
group = rep(c("A", "B"), each = 50)

#make some fake names for the variables
rownames(x) = paste0("gene", 1:nrow(x))
#make some fake names for the subjects
colnames(x) = paste0(group, 1:50)


library("DT")
datatable(head(x))
datatable(tail(x))

2. Run the tests

# Compare means across variables
# loop over all the rows
#   run t-test split by group and return the p-value
pvals <- sapply(1:nrow(x), function(i){
  t.test(x[i,] ~ group)$p.value
})

3. Adjust for multiple comparisons

# Adjust p-values to account for the multiple comparisons over all the variables
padj.bonf <- p.adjust(pvals, method = "bonferroni")
padj.fdr <- p.adjust(pvals, method = "fdr")

#how many tests pass the (< 0.05) criteria?

#unadjusted
sum(pvals < 0.05)
## [1] 125
#bonferroni
sum(padj.bonf < 0.05)
## [1] 7
#FDR 
sum(padj.fdr < 0.05)
## [1] 27
#Which variables were significant at FDR < 0.05?
#get the indices of the rows that meet the threshold criteria 
ix = padj.fdr < 0.05

datatable(x[ix,])

Recap on the tests

  • Continuous outcomes
    • one sample
      • t-test with df=n-1
      • Wilcoxon signed rank-sum test
      • the sign test
    • paired samples
      • paired t-test
      • paired Rank Sum
      • Paired Sign test
    • two independent samples
      • Students’s t-test
      • Welch’s t-test
      • Mann-Whitney Rank-sum test
      • Kolmogorov-Smirnov test

Recap on the tests (One sample)

library("DT")
datatable(mtcars)

Question: Is the average MPG of these cars greater than 20?

  • t-test with df=n-1
t.test(x=mtcars$mpg, mu=20, alternative="greater")
## 
##  One Sample t-test
## 
## data:  mtcars$mpg
## t = 0.08506, df = 31, p-value = 0.4664
## alternative hypothesis: true mean is greater than 20
## 95 percent confidence interval:
##  18.28418      Inf
## sample estimates:
## mean of x 
##  20.09062
  • Wilcoxon signed rank-sum test
wilcox.test(x=mtcars$mpg, mu=20, alternative="greater")
## Warning in wilcox.test.default(x = mtcars$mpg, mu = 20, alternative =
## "greater"): cannot compute exact p-value with ties
## 
##  Wilcoxon signed rank test with continuity correction
## 
## data:  mtcars$mpg
## V = 249, p-value = 0.614
## alternative hypothesis: true location is greater than 20
  • the sign test
#first make sure to remove the observations that
#provide no useful information.  These would be the 
#cases where the mpg is exactly equal to 20.
#there are no cases like this in this example.
binom.test(x=sum(mtcars$mpg>20), length(mtcars$mpg), alternative="greater")
## 
##  Exact binomial test
## 
## data:  sum(mtcars$mpg > 20) and length(mtcars$mpg)
## number of successes = 14, number of trials = 32, p-value = 0.8115
## alternative hypothesis: true probability of success is greater than 0.5
## 95 percent confidence interval:
##  0.2872749 1.0000000
## sample estimates:
## probability of success 
##                 0.4375

Recap on the tests (Paired sample)

library("DT")
datatable(sleep)

Question: Is there a difference in sleep times in the two drugs?

  • paired t-test
#first check that the order is correct
#If not correct, you have to rearrange the rows to align the paired observations
sleep$ID[sleep$group==1] ==  sleep$ID[sleep$group==2]
##  [1] TRUE TRUE TRUE TRUE TRUE TRUE TRUE TRUE TRUE TRUE
x1 = sleep$extra[sleep$group==1]
x2 = sleep$extra[sleep$group==2]
t.test(x1, x2, paired = TRUE)
## 
##  Paired t-test
## 
## data:  x1 and x2
## t = -4.0621, df = 9, p-value = 0.002833
## alternative hypothesis: true mean difference is not equal to 0
## 95 percent confidence interval:
##  -2.4598858 -0.7001142
## sample estimates:
## mean difference 
##           -1.58
#another way to set it up is to look at the paired differences
#and treat it like a 1 sample comparison to a mean of 0.
t.test(x1-x2, paired=F)
## 
##  One Sample t-test
## 
## data:  x1 - x2
## t = -4.0621, df = 9, p-value = 0.002833
## alternative hypothesis: true mean is not equal to 0
## 95 percent confidence interval:
##  -2.4598858 -0.7001142
## sample estimates:
## mean of x 
##     -1.58
  • paired Rank Sum
wilcox.test(x1, x2, paired = TRUE)
## Warning in wilcox.test.default(x1, x2, paired = TRUE): cannot compute exact
## p-value with ties
## Warning in wilcox.test.default(x1, x2, paired = TRUE): cannot compute exact
## p-value with zeroes
## 
##  Wilcoxon signed rank test with continuity correction
## 
## data:  x1 and x2
## V = 0, p-value = 0.009091
## alternative hypothesis: true location shift is not equal to 0
#similarly, you can look at the paired differences and treat it as 1 sample comparison
wilcox.test(x1-x2)
## Warning in wilcox.test.default(x1 - x2): cannot compute exact p-value with ties
## Warning in wilcox.test.default(x1 - x2): cannot compute exact p-value with
## zeroes
## 
##  Wilcoxon signed rank test with continuity correction
## 
## data:  x1 - x2
## V = 0, p-value = 0.009091
## alternative hypothesis: true location is not equal to 0
  • Paired Sign test
#make a new variable that has the difference of the two drugs
extra.diff = x1 - x2


#be sure to take out non-informative rows 
extra.diff = extra.diff[extra.diff != 0]

#now check if the signs of the differences corresponds to an even coin flip
binom.test(x=sum(extra.diff > 0), n=length(extra.diff), alternative="two.sided")
## 
##  Exact binomial test
## 
## data:  sum(extra.diff > 0) and length(extra.diff)
## number of successes = 0, number of trials = 9, p-value = 0.003906
## alternative hypothesis: true probability of success is not equal to 0.5
## 95 percent confidence interval:
##  0.0000000 0.3362671
## sample estimates:
## probability of success 
##                      0

Recap on the tests (Two independant samples)

datatable(ToothGrowth)

Question: is there a difference in tooth growth between VC(dose=1.0) and OJ(dose=1.0)?

#make a smaller dataset that only has the dose 1.0 samples
tooth.dose1 = ToothGrowth[ToothGrowth$dose == 1.0,]
datatable(tooth.dose1)
  • Levene’s test
    • p < 0.05: reject equal variance assumption.
      • should not use Student’s t-test
    • p >= 0.05: no evidence of variance difference.
library("car")
## Loading required package: carData
leveneTest(len~supp, data = tooth.dose1)
## Levene's Test for Homogeneity of Variance (center = median)
##       Df F value Pr(>F)
## group  1  1.6722 0.2123
##       18
  • Students’s t-test
t.test(len~supp, data = tooth.dose1, var.equal=T)
## 
##  Two Sample t-test
## 
## data:  len by supp
## t = 4.0328, df = 18, p-value = 0.0007807
## alternative hypothesis: true difference in means between group OJ and group VC is not equal to 0
## 95 percent confidence interval:
##  2.840692 9.019308
## sample estimates:
## mean in group OJ mean in group VC 
##            22.70            16.77
  • Welch’s t-test
t.test(len~supp, data = tooth.dose1, var.equal=F)
## 
##  Welch Two Sample t-test
## 
## data:  len by supp
## t = 4.0328, df = 15.358, p-value = 0.001038
## alternative hypothesis: true difference in means between group OJ and group VC is not equal to 0
## 95 percent confidence interval:
##  2.802148 9.057852
## sample estimates:
## mean in group OJ mean in group VC 
##            22.70            16.77
  • Mann-Whitney rank-sum test
wilcox.test(len~supp, data = tooth.dose1)
## Warning in wilcox.test.default(x = DATA[[1L]], y = DATA[[2L]], ...): cannot
## compute exact p-value with ties
## 
##  Wilcoxon rank sum test with continuity correction
## 
## data:  len by supp
## W = 88.5, p-value = 0.00403
## alternative hypothesis: true location shift is not equal to 0
  • Kolmogorov-Smirnov test
ks.test(len~supp, data = tooth.dose1)
## 
##  Exact two-sample Kolmogorov-Smirnov test
## 
## data:  len by supp
## D = 0.8, p-value = 0.00158
## alternative hypothesis: two-sided

Notes