Today’s class covers descriptive analysis of a real dataset in R, from loading the data to an APA-style report. Students code along on their own laptops with the dataset from the shared Google Drive folder.
By the end of the class, students should be able to
The flow moves from the most intuitive to the most abstract: data screening, then tables, then graphs, then statistics, then reporting.
Teaching note. Descriptive statistics summarize the sample at hand. They describe, they don’t generalize. Generalizing to a population is the job of inferential statistics, which comes later in the semester.
# install.packages(c("psych", "ggplot2", "readxl", "haven"))
library(psych)
library(ggplot2)
Every analysis starts with getting the data in and checking it. Most real analyses break at this step, so it deserves time.
In class, students will import the dataset from the shared folder. The lines below are the templates.
# CSV
df <- read.csv("data/example.csv", stringsAsFactors = TRUE)
# Excel and SPSS
library(readxl); df <- read_excel("data/example.xlsx")
library(haven); df <- read_sav("data/example.sav")
So that this file runs on its own, the next chunk generates an example dataset. Replace it with one of the import lines above when using the class dataset.
set.seed(2026)
n <- 150
df <- data.frame(
id = 1:n,
gender = sample(c(1, 2), n, replace = TRUE, prob = c(.55, .45)),
age = round(rnorm(n, mean = 27, sd = 4)),
score = round(pmin(pmax(rnorm(n, mean = 70, sd = 11), 30), 100))
)
df$level <- cut(df$score, breaks = c(-Inf, 60, 78, Inf),
labels = c("Low", "Medium", "High"))
# Add realistic data problems for the screening exercise
df$score[c(12, 47, 103)] <- 99 # 99 used as a missing-value code
df$age[c(5, 88)] <- NA # genuine missing values
df$age[120] <- 250 # data entry error
Structure. Check variable types. Categorical variables should be factors.
str(df)
## 'data.frame': 150 obs. of 5 variables:
## $ id : int 1 2 3 4 5 6 7 8 9 10 ...
## $ gender: num 2 2 1 1 2 1 1 2 1 2 ...
## $ age : num 23 35 30 26 NA 22 33 22 21 27 ...
## $ score : num 83 64 61 75 78 67 75 75 74 64 ...
## $ level : Factor w/ 3 levels "Low","Medium",..: 3 2 2 2 2 2 2 2 2 2 ...
head(df)
## id gender age score level
## 1 1 2 23 83 High
## 2 2 2 35 64 Medium
## 3 3 1 30 61 Medium
## 4 4 1 26 75 Medium
## 5 5 2 NA 78 Medium
## 6 6 1 22 67 Medium
Missing values and out-of-range values.
colSums(is.na(df))
## id gender age score level
## 0 0 2 0 0
summary(df)
## id gender age
## Min. : 1.00 Min. :1.0 Min. : 17.00
## 1st Qu.: 38.25 1st Qu.:1.0 1st Qu.: 25.00
## Median : 75.50 Median :1.0 Median : 27.00
## Mean : 75.50 Mean :1.4 Mean : 28.88
## 3rd Qu.:112.75 3rd Qu.:2.0 3rd Qu.: 30.00
## Max. :150.00 Max. :2.0 Max. :250.00
## NAs :2
## score level
## Min. :44.00 Low :22
## 1st Qu.:63.00 Medium:96
## Median :72.00 High :32
## Mean :71.43
## 3rd Qu.:78.00
## Max. :99.00
##
Cleaning. Recode missing-value codes, fix impossible values and label factors.
df$score[df$score == 99] <- NA
df$age[df$age > 100] <- NA
df$gender <- factor(df$gender, levels = c(1, 2), labels = c("Female", "Male"))
df$level <- factor(df$level, levels = c("Low", "Medium", "High"), ordered = TRUE)
df$level[is.na(df$score)] <- NA
colSums(is.na(df))
## id gender age score level
## 0 0 3 3 3
Outliers. Check boxplots and z-scores beyond ±3. Decide whether a value is an error or a real extreme case before removing anything.
z <- as.numeric(scale(df$score))
df[which(abs(z) > 3), ]
## [1] id gender age score level
## <0 rows> (or 0-length row.names)
Exporting the clean data.
dir.create("output", showWarnings = FALSE)
write.csv(df, "output/clean_data.csv", row.names = FALSE)
Teaching note. Ask students to report how many cases were screened out and why. This becomes the first sentence of their APA results section.
Tables are the simplest summary and the natural first look at any variable. They answer “how many” and “what share”.
A frequency table lists each category with its count (f), percentage (%), cumulative frequency (cf) and cumulative percentage (c%).
f <- table(df$level)
pct <- prop.table(f) * 100
freq_tab <- data.frame(
Category = names(f),
f = as.vector(f),
pct = round(as.vector(pct), 1),
cf = as.vector(cumsum(f)),
cpct = round(as.vector(cumsum(pct)), 1)
)
freq_tab
## Category f pct cf cpct
## 1 Low 21 14.3 21 14.3
## 2 Medium 94 63.9 115 78.2
## 3 High 32 21.8 147 100.0
cat("Missing:", sum(is.na(df$level)), "\n")
## Missing: 3
For continuous variables, raw values rarely repeat, so group them into class intervals. A common rule is 5 to 15 intervals of equal width. Sturges’ rule gives k = 1 + 3.32 log10(n).
k <- ceiling(1 + 3.32 * log10(sum(!is.na(df$score))))
k
## [1] 9
table(cut(df$score, breaks = seq(30, 100, by = 10), right = FALSE, include.lowest = TRUE))
##
## [30,40) [40,50) [50,60) [60,70) [70,80) [80,90)
## 0 6 12 45 53 28
## [90,100]
## 3
A cross table shows the joint distribution of two categorical variables. The key teaching point is which percentage to report. Percentage within the groups you want to compare.
ct <- table(df$gender, df$level)
addmargins(ct)
##
## Low Medium High Sum
## Female 12 57 20 89
## Male 9 37 12 58
## Sum 21 94 32 147
# Row percentages (compare levels within each gender)
round(prop.table(ct, margin = 1) * 100, 1)
##
## Low Medium High
## Female 13.5 64.0 22.5
## Male 15.5 63.8 20.7
# Column percentages (compare genders within each level)
round(prop.table(ct, margin = 2) * 100, 1)
##
## Low Medium High
## Female 57.1 60.6 62.5
## Male 42.9 39.4 37.5
Interpretation example. “Half of the students were at the medium level, and most were at the medium level or below.” Students should write this sentence using their own numbers from the table.
The choice of graph depends on the variable type. Categorical variables show counts per category; continuous variables show the shape of a distribution.
| Variable situation | Recommended graphs | Avoid |
|---|---|---|
| One categorical | Bar chart, pie chart for few categories | Histogram |
| One continuous | Histogram, density plot, boxplot | Bar chart of raw values |
| Categorical × categorical | Clustered or stacked bar chart | Two separate pies |
| Continuous by groups | Side-by-side boxplots, overlaid densities | Bar chart of means alone |
| Continuous × continuous | Scatter plot | Line chart without time order |
Key distinctions to stress
barplot(table(df$level), main = "Level", ylab = "Frequency")
hist(df$score, breaks = 10, main = "Score", xlab = "Score")
plot(density(df$score, na.rm = TRUE), main = "Score density")
boxplot(score ~ gender, data = df, main = "Score by gender")
plot(df$age, df$score, xlab = "Age", ylab = "Score", main = "Age and score")
barplot(table(df$gender, df$level), beside = TRUE, legend.text = TRUE,
main = "Level by gender", ylab = "Frequency")
ggplot(df[!is.na(df$level), ], aes(x = level)) +
geom_bar() + labs(x = "Level", y = "Frequency") + theme_minimal()
ggplot(df, aes(x = score)) +
geom_histogram(bins = 10, colour = "white") + labs(x = "Score", y = "Frequency") + theme_minimal()
ggplot(df, aes(x = gender, y = score)) +
geom_boxplot() + labs(x = "Gender", y = "Score") + theme_minimal()
ggplot(df, aes(x = score, fill = gender)) +
geom_density(alpha = .4) + labs(x = "Score", y = "Density") + theme_minimal()
ggsave("output/score_boxplot.png", width = 6, height = 4)
Teaching note. Ask students to describe each graph in one sentence covering shape, centre, spread and unusual values. This prepares them for Part 3.
Descriptive statistics summarize a distribution in a few numbers. The main two groups are central tendency and dispersion, with shape and position measures completing the picture.
In a right-skewed distribution, mean > median > mode.
mean(df$score, na.rm = TRUE)
## [1] 70.87075
median(df$score, na.rm = TRUE)
## [1] 71
# Mode (no built-in function in R)
names(which.max(table(df$level)))
## [1] "Medium"
names(which.max(table(df$score)))
## [1] "70"
\[ s = \sqrt{\frac{\sum_{i=1}^{n}(x_i - \bar{x})^2}{n-1}} \]
range(df$score, na.rm = TRUE)
## [1] 44 93
diff(range(df$score, na.rm = TRUE))
## [1] 49
IQR(df$score, na.rm = TRUE)
## [1] 15
var(df$score, na.rm = TRUE)
## [1] 106.0996
sd(df$score, na.rm = TRUE)
## [1] 10.30047
sd(df$score, na.rm = TRUE) / mean(df$score, na.rm = TRUE) * 100 # CV
## [1] 14.53416
skew(df$score, na.rm = TRUE)
## [1] -0.2555708
kurtosi(df$score, na.rm = TRUE)
## [1] -0.3460029
quantile(df$score, probs = c(.25, .50, .75), na.rm = TRUE)
## 25% 50% 75%
## 63 71 78
quantile(df$score, probs = c(.10, .90), na.rm = TRUE)
## 10% 90%
## 58.6 83.0
df$z_score <- as.numeric(scale(df$score))
head(df[, c("id", "score", "z_score")])
## id score z_score
## 1 1 83 1.1775439
## 2 2 64 -0.6670327
## 3 3 61 -0.9582816
## 4 4 75 0.4008801
## 5 5 78 0.6921290
## 6 6 67 -0.3757838
| Measurement level | Central tendency | Dispersion |
|---|---|---|
| Nominal | Mode | Number of categories, frequency table |
| Ordinal | Median (mode) | IQR, range |
| Interval | Mean (median, mode) | SD, variance, IQR |
| Ratio | Mean (median, mode) | SD, variance, IQR, CV |
This links directly to last week’s variable types and measurement levels.
Reporting means and SDs by group is the bridge to inferential comparisons such as the t-test and ANOVA later in the semester.
describe(df[, c("age", "score")])
## vars n mean sd median trimmed mad min max
## age 1 147 27.37 4.1 27 27.29 4.45 17 38
## score 2 147 70.87 10.3 71 71.18 10.38 44 93
## range skew kurtosis se
## age 21 0.20 -0.28 0.34
## score 49 -0.26 -0.35 0.85
describeBy(df$score, group = df$gender)
##
## Descriptive statistics by group
## group: Female
## vars n mean sd median trimmed mad min max
## X1* 1 89 18.91 8.92 20 19.08 10.38 1 35
## range skew kurtosis se
## X1* 34 -0.16 -0.95 0.95
## ------------------------------------------
## group: Male
## vars n mean sd median trimmed mad min max
## X1* 1 58 14.47 6.97 14 14.46 7.41 1 27
## range skew kurtosis se
## X1* 26 0.07 -1 0.92
aggregate(score ~ gender, data = df,
FUN = function(x) round(c(M = mean(x), SD = sd(x), n = length(x)), 2))
## gender score.M score.SD score.n
## 1 Female 71.51 10.39 89.00
## 2 Male 69.90 10.17 58.00
Results are only useful when reported clearly. APA style standardizes notation, tables and figures.
tab_g <- describeBy(df$score, group = df$gender, mat = TRUE)
tab_t <- describe(df$score)
apa_tab <- data.frame(
Group = c(as.character(tab_g$group1), "Total"),
n = c(tab_g$n, tab_t$n),
M = c(tab_g$mean, tab_t$mean),
SD = c(tab_g$sd, tab_t$sd),
Mdn = c(tab_g$median, tab_t$median),
Skewness = c(tab_g$skew, tab_t$skew),
Kurtosis = c(tab_g$kurtosis, tab_t$kurtosis)
)
apa_tab[, -1] <- round(apa_tab[, -1], 2)
knitr::kable(apa_tab,
caption = "Table 1. Descriptive Statistics for Exam Scores by Gender",
col.names = c("Group", "*n*", "*M*", "*SD*", "*Mdn*", "Skewness", "Kurtosis"))
| Group | n | M | SD | Mdn | Skewness | Kurtosis |
|---|---|---|---|---|---|---|
| Female | 89 | 18.91 | 8.92 | 20 | -0.16 | -0.95 |
| Male | 58 | 14.47 | 6.97 | 14 | 0.07 | -1.00 |
| Total | 147 | 70.87 | 10.30 | 71 | -0.26 | -0.35 |
Note. Values come from the example dataset. Optional
packages for Word-ready APA tables include apaTables,
papaja and flextable.
Inline R code fills in the numbers automatically, so the paragraph updates when the data change.
Data from 147 students were analysed after screening; 3 cases with missing or invalid scores were excluded. Exam scores ranged from 44 to 93 (M = 70.87, SD = 10.30). The distribution was approximately symmetric (skewness = -0.26, kurtosis = -0.35). Female students (M = 18.91, SD = 8.92) and male students (M = 14.47, SD = 6.97) had similar mean scores. Of the students, 63.9% were at the medium level (see Table 1).
A descriptive analysis is complete when every item below is done.
Tips for the session