Lab 10: Frequency Data, Confidence Intervals for Proportions & the Poisson Distribution

BI 255 · Bethune-Cookman University · Fall 2026

Author

Dr. Rosie Stanbrook-Buyer

Published

September 14, 2026


Counting and Categorising: Frequency Data in Biology

Much of the data collected in biology is not a measurement on a continuous scale but a count or a category. How many patients responded to a treatment? What proportion of offspring show a recessive phenotype? How many mutations occur per genome replication? These are all questions about frequency data.

In this lab we cover three connected ideas: summarising categorical data with frequency tables, estimating proportions with confidence intervals, and modelling rare biological events with the Poisson distribution.

NoteLearning Objectives

By the end of this lab you will be able to:

  1. Create frequency tables with table() and prop.table()
  2. Visualise frequency data with bar charts
  3. Compute a confidence interval for a proportion using prop.test()
  4. Explain what the Poisson distribution models
  5. Use dpois(), ppois(), and rpois() to work with Poisson probabilities
  6. Recognise which biological questions call for Poisson modelling

Part 1: Frequency Tables — ABO Blood Types

The ABO blood group system is determined by alleles at a single locus and follows predictable population frequencies. In the United States the approximate frequencies are: O = 44%, A = 42%, B = 10%, AB = 4%.

Suppose you blood-type 200 students at a university health clinic:

# Simulated blood type data from 200 students
set.seed(42)
blood_types <- sample(
  x       = c("O", "A", "B", "AB"),
  size    = 200,
  replace = TRUE,
  prob    = c(0.44, 0.42, 0.10, 0.04)
)

# First six observations
head(blood_types, 20)
 [1] "B"  "B"  "O"  "A"  "A"  "A"  "A"  "O"  "A"  "A"  "A"  "A"  "B"  "O"  "A" 
[16] "B"  "AB" "O"  "A"  "A" 

Counts with table()

# Count how many students fall into each blood type
bt_counts <- table(blood_types)
bt_counts
blood_types
 A AB  B  O 
92  7 21 80 
# Sort from most to least common
sort(bt_counts, decreasing = TRUE)
blood_types
 A  O  B AB 
92 80 21  7 

Proportions with prop.table()

# Convert counts to proportions (values between 0 and 1)
bt_props <- prop.table(bt_counts)
round(bt_props, 3)
blood_types
    A    AB     B     O 
0.460 0.035 0.105 0.400 
# Convert to percentages
round(bt_props * 100, 1)
blood_types
   A   AB    B    O 
46.0  3.5 10.5 40.0 
Tiptable() vs prop.table()

table() gives you raw counts — how many of each category.

prop.table() takes a table and converts counts to proportions that sum to 1.

Use prop.table(table(x)) when you want to compare across groups of different sizes.

Visualising with a Bar Chart

# Order by frequency for a cleaner plot
bt_ordered <- sort(bt_counts, decreasing = TRUE)

barplot(bt_ordered,
        col    = c("#2C5F8A", "#5B8DB8", "#A8C4D9", "#D6E8F5"),
        ylab   = "Number of Students",
        xlab   = "Blood Type",
        main   = "ABO Blood Type Distribution\n200 University Students (Simulated)",
        ylim   = c(0, 120))

# Add reference line at expected US frequency
abline(h = 200 * 0.44, col = "red", lty = 2)
text(4.5, 200 * 0.44 + 3, "Expected O frequency", col = "red", cex = 0.8)


Part 2: Two-Way Frequency Tables

We can cross-tabulate two categorical variables at once. Suppose we also recorded whether each student is Rh-positive or Rh-negative (approximately 85% of people are Rh+):

set.seed(7)
rh_factor <- sample(c("Rh+", "Rh-"), size = 200, replace = TRUE, prob = c(0.85, 0.15))

# Two-way table: blood type by Rh factor
two_way <- table(blood_types, rh_factor)
two_way
           rh_factor
blood_types Rh- Rh+
         A    8  84
         AB   2   5
         B    5  16
         O   12  68
# Row proportions: within each blood type, what fraction is Rh+?
round(prop.table(two_way, margin = 1), 3)
           rh_factor
blood_types   Rh-   Rh+
         A  0.087 0.913
         AB 0.286 0.714
         B  0.238 0.762
         O  0.150 0.850
Notemargin in prop.table()
  • margin = 1 → proportions calculated within each row (row sums to 1)
  • margin = 2 → proportions calculated within each column (column sums to 1)
  • No margin argument → all cells sum to 1 (overall proportions)
# Visualise as a grouped bar chart
barplot(t(two_way),
        beside  = TRUE,
        col     = c("#2C5F8A", "#C8102E"),
        legend  = rownames(t(two_way)),
        xlab    = "Blood Type",
        ylab    = "Count",
        main    = "ABO Blood Type by Rh Factor")


Part 3: Confidence Intervals for Proportions

A frequency or proportion from a sample is just an estimate of the true population proportion. We need a confidence interval to express our uncertainty.

The prop.test() Function

Suppose in our sample, 88 out of 200 students have blood type A. We want to estimate the true proportion of type-A individuals in the population with a 95% confidence interval.

# 88 successes (type A) out of 200 students
result <- prop.test(x = 88, n = 200, conf.level = 0.95)
result

    1-sample proportions test with continuity correction

data:  88 out of 200, null probability 0.5
X-squared = 2.645, df = 1, p-value = 0.1039
alternative hypothesis: true p is not equal to 0.5
95 percent confidence interval:
 0.3705669 0.5117760
sample estimates:
   p 
0.44 
# Extract just the confidence interval
result$conf.int
[1] 0.3705669 0.5117760
attr(,"conf.level")
[1] 0.95
# Extract the point estimate (sample proportion)
result$estimate
   p 
0.44 
NoteReading prop.test() Output
Output element Meaning
X-squared Chi-square statistic
p-value Tests H₀: p = 0.5 by default
95 percent confidence interval The range the true proportion likely falls in
sample estimates: p Your observed proportion

The confidence interval is what you usually want. For a two-sided 95% CI, you can be 95% confident the true proportion lies within those bounds.

Testing Against a Known Proportion

The US population frequency of blood type A is 0.42. Is our sample consistent with this?

# H₀: true proportion of type A = 0.42
# H₁: true proportion ≠ 0.42
prop.test(x = 88, n = 200, p = 0.42, conf.level = 0.95)

    1-sample proportions test with continuity correction

data:  88 out of 200, null probability 0.42
X-squared = 0.25144, df = 1, p-value = 0.6161
alternative hypothesis: true p is not equal to 0.42
95 percent confidence interval:
 0.3705669 0.5117760
sample estimates:
   p 
0.44 
# Visualise: observed proportion with CI
obs_prop <- 88 / 200
ci       <- prop.test(88, 200)$conf.int

plot(x    = 1,
     y    = obs_prop,
     xlim = c(0.5, 1.5),
     ylim = c(0.30, 0.60),
     pch  = 16, cex = 1.5,
     col  = "#2C5F8A",
     xaxt = "n",
     xlab = "",
     ylab = "Proportion",
     main = "Observed Proportion of Blood Type A\nwith 95% Confidence Interval")
arrows(1, ci[1], 1, ci[2], angle = 90, code = 3, length = 0.1, col = "#2C5F8A", lwd = 2)
abline(h = 0.42, col = "red", lty = 2)
text(1.2, 0.42, "US expected (0.42)", col = "red", cex = 0.8)


Part 4: The Poisson Distribution — Modelling Rare Biological Events

The Poisson distribution describes the probability of a given number of events occurring in a fixed interval of time or space, when those events happen at a known average rate and are independent of each other.

In biology, it models:

  • The number of mutations per genome replication
  • The number of ion channel openings per second
  • The number of cells in a haemocytometer grid square
  • The number of rare disease cases per 100,000 people per year
ImportantThe Key Parameter: Lambda (λ)

The Poisson distribution has a single parameter, λ (lambda) = the mean number of events per interval.

If λ = 2 mutations per genome replication, then the Poisson distribution tells you the probability of seeing 0, 1, 2, 3… mutations in any given replication event.

Crucially: the mean equals the variance in a Poisson distribution. This is a useful diagnostic check — if your data’s variance is much larger than its mean, the Poisson may not be the right model.

Poisson Probability Functions

# Suppose the spontaneous mutation rate is 2 mutations per genome replication (lambda = 2)
lambda <- 2

# P(exactly 0 mutations)
dpois(0, lambda = lambda)
[1] 0.1353353
# P(exactly 3 mutations)
dpois(3, lambda = lambda)
[1] 0.180447
# Full probability distribution: P(0 through 10 mutations)
k        <- 0:10
probs    <- dpois(k, lambda = lambda)
names(probs) <- k
round(probs, 4)
     0      1      2      3      4      5      6      7      8      9     10 
0.1353 0.2707 0.2707 0.1804 0.0902 0.0361 0.0120 0.0034 0.0009 0.0002 0.0000 
# Visualise the distribution
barplot(probs,
        names.arg = k,
        col       = "#2C5F8A",
        xlab      = "Number of Mutations per Replication",
        ylab      = "Probability",
        main      = paste0("Poisson Distribution (lambda = ", lambda, ")\nMutation Rate Model"))

Cumulative Probabilities with ppois()

# P(3 or fewer mutations) — cumulative
ppois(3, lambda = lambda)
[1] 0.8571235
# P(more than 5 mutations) — upper tail
ppois(5, lambda = lambda, lower.tail = FALSE)
[1] 0.01656361
Tipdpois() vs ppois()
  • dpois(k, lambda) — probability of exactly k events
  • ppois(k, lambda) — probability of k or fewer events (cumulative)
  • ppois(k, lambda, lower.tail = FALSE) — probability of more than k events

The same logic applies to dnorm/pnorm and other distribution functions in R.

Simulating Poisson Data

# Simulate 1000 genome replications, each with lambda = 2 mutations
set.seed(123)
simulated_mutations <- rpois(1000, lambda = 2)

cat("Mean mutations per replication:    ", round(mean(simulated_mutations), 3), "\n")
Mean mutations per replication:     1.994 
cat("Variance of mutations:             ", round(var(simulated_mutations), 3), "\n")
Variance of mutations:              1.982 
cat("(For Poisson, mean ≈ variance)\n")
(For Poisson, mean ≈ variance)
# Compare simulated data to theoretical Poisson
obs_freq   <- table(simulated_mutations) / 1000
theory_prob <- dpois(as.numeric(names(obs_freq)), lambda = 2)

barplot(rbind(obs_freq, theory_prob),
        beside    = TRUE,
        col       = c("#2C5F8A", "#C8102E"),
        legend    = c("Simulated", "Theoretical Poisson"),
        xlab      = "Number of Mutations",
        ylab      = "Proportion",
        main      = "Simulated vs Theoretical Poisson(lambda=2)")

Real Application: Rare Disease Incidence

A disease occurs at a rate of 3.5 cases per 100,000 people per year. A city of 500,000 people is monitored. On average, how many cases do we expect per year, and what is the probability of seeing 25 or more cases?

# Expected cases in a city of 500,000
lambda_city <- 3.5 * (500000 / 100000)
cat("Expected cases per year:", lambda_city, "\n")
Expected cases per year: 17.5 
# Probability of 25 or more cases
p_25_plus <- ppois(24, lambda = lambda_city, lower.tail = FALSE)
cat("P(25 or more cases):", round(p_25_plus, 4), "\n")
P(25 or more cases): 0.0532 
# Plot the distribution across plausible outcomes
k_range <- 0:35
plot(k_range, dpois(k_range, lambda = lambda_city),
     type = "h",
     lwd  = 3,
     col  = ifelse(k_range >= 25, "#C8102E", "#2C5F8A"),
     xlab = "Number of Cases per Year",
     ylab = "Probability",
     main = "Poisson Distribution: Rare Disease Cases\nCity of 500,000 (lambda = 17.5)")
legend("topright",
       legend = c("Expected range", "25+ cases (unusual)"),
       col    = c("#2C5F8A", "#C8102E"),
       lty    = 1, lwd = 3)


3-Minute Knowledge Check

Close your notes. Answer these on your own — you have 3 minutes. We’ll go through the answers together after.

CautionKnowledge Check Questions

1. You survey 150 patients and find 45 have hypertension. What R function would you use to calculate a 95% confidence interval for the proportion of hypertensive patients?

a) `t.test(45, 150)`    b) `prop.test(45, 150)`    c) `table(45, 150)`    d) `pnorm(45, 150)`

2. prop.table(table(x), margin = 1) calculates proportions:

a) Across all cells (total = 1)    b) Within each row    c) Within each column    d) Divided by the number of columns

3. A Poisson distribution with lambda = 4 models an event that happens on average 4 times per interval. What is the relationship between the mean and variance of this distribution?

4. Write the R code to calculate the probability of observing exactly 2 events from a Poisson distribution with lambda = 5.

5. True or False: The Poisson distribution is appropriate for modelling continuous measurements like protein concentration.

1. b) prop.test(45, 150) — This is the correct function for a confidence interval on a proportion. x = number of successes, n = total observations.

2. b) Within each row — margin = 1 calculates proportions so that each row sums to 1. Use this to compare groups when rows represent different categories.

3. They are equal — in the Poisson distribution, the mean (λ) equals the variance. This is a defining property of the Poisson. If your data shows variance much larger than the mean, a negative binomial distribution may be more appropriate.

4. dpois(2, lambda = 5)dpois gives the probability of exactly k events; here k = 2, lambda = 5.

5. False — The Poisson distribution models count data (discrete, non-negative integers): number of mutations, number of events per unit time, number of organisms in a quadrat. Continuous measurements like concentration are modelled with the normal distribution or others.


Lab 10 Checklist

Before you leave, make sure you can:

TipBonus Challenge

The spontaneous mutation rate of E. coli is approximately 1 × 10⁻³ mutations per genome replication. If we observe 50,000 replication events, what lambda would we use? Calculate P(0 mutations), P(exactly 50 mutations), and P(more than 100 mutations). Plot the distribution.


Before Next Class (Wednesday)

  • Wednesday (Lab 11): Hypothesis Testing and the Null Hypothesis — we go deeper on p-values, Type I/II errors, and effect sizes
  • Read Wilke 2021 Ch. 1, 2 (link on Canvas)
ImportantQuiz 3 — This Friday!

Quiz 3 covers Labs 10 and 11: frequency tables, proportions, CI for proportions, hypothesis testing, and the Poisson distribution.

Best preparation: be able to explain what a p-value means in plain English and know when to use prop.test() vs t.test().