Overview

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

  1. Import a dataset into R, screen it, and export results.
  2. Build and interpret frequency, percentage, cumulative and cross tables.
  3. Choose the right graph for categorical and continuous variables.
  4. Compute and interpret measures of central tendency, dispersion, shape and position.
  5. Match each technique to the variable’s measurement level.
  6. Report descriptive results in APA style.

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.

Packages

# install.packages(c("psych", "ggplot2", "readxl", "haven"))
library(psych)
library(ggplot2)

Step 0. Importing and screening the data

Every analysis starts with getting the data in and checking it. Most real analyses break at this step, so it deserves time.

Importing and exporting

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

Screening

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.

Part 1. Frequency and percentage tables

Tables are the simplest summary and the natural first look at any variable. They answer “how many” and “what share”.

Simple frequency tables

A frequency table lists each category with its count (f), percentage (%), cumulative frequency (cf) and cumulative percentage (c%).

  • Valid vs total percentage. If there are missing values, report the percentage of valid cases and state the missing count.
  • Cumulative columns only make sense when categories have an order, so they suit ordinal variables, not nominal ones.
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

Grouped frequency tables

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

Cross tables (crosstabs)

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.

Part 2. Graphical visualisation

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

  • Bar chart vs histogram. Bars have gaps because categories are separate. Histogram bars touch because the scale is continuous.
  • Pie charts work only for a few categories that sum to a whole. People compare bar lengths more accurately than angles.
  • The boxplot as a bridge. It is a graph, but it displays the median, quartiles, IQR and outliers. It connects Part 2 with Part 3.

Base R graphs

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")

ggplot2 graphs

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.

Part 3. Descriptive statistics

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.

Central tendency

  • Mean. The arithmetic average. Uses every value, so it is sensitive to outliers and skew.
  • Median. The middle value. Robust to outliers, preferred for skewed data.
  • Mode. The most frequent value. The only measure for nominal data.

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"

Dispersion

  • Range. Max minus min. Simple but driven by two extreme values.
  • Interquartile range (IQR). Q3 minus Q1, the spread of the middle 50%. Pairs with the median.
  • Variance and standard deviation. SD pairs with the mean and is in the original units.
  • Coefficient of variation. SD / mean × 100. Compares spread across variables with different units (ratio scale only).

\[ 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

Shape

  • Skewness. 0 is symmetric, positive is a long right tail, negative is a long left tail.
  • Kurtosis. Tail weight relative to the normal distribution (excess kurtosis 0).
  • A common rule of thumb treats skewness and kurtosis within ±1 (some sources use ±2) as approximately normal.
skew(df$score, na.rm = TRUE)
## [1] -0.2555708
kurtosi(df$score, na.rm = TRUE)
## [1] -0.3460029

Position

  • Percentiles and quartiles. The value below which a given percentage of cases fall.
  • z-scores. (x − mean) / SD. Shows how many SDs a case is from the mean.
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

Matching statistics to measurement level

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.

Full summary and descriptives by group

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

Part 4. Reporting in APA style (7th edition)

Results are only useful when reported clearly. APA style standardizes notation, tables and figures.

Notation and numbers

  • Statistical symbols in italics: M, SD, Mdn, n, N, z. Use N for the full sample, n for a subsample.
  • Report most statistics to two decimals and percentages to one or none. Be consistent.
  • Leading zero before decimals that can exceed 1 (0.75 cm), but not before ones that cannot (r = .45, p = .03).
  • Don’t repeat in text every number already in a table. Highlight the key points.

Tables and figures

  • Bold number (Table 1), italic title on the next line in title case.
  • Horizontal lines only, no vertical lines.
  • A note below the table explains abbreviations and missing data.
  • Figures follow the same numbering and title format (Figure 1).

APA-ready descriptives table

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"))
Table 1. Descriptive Statistics for Exam Scores by Gender
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.

Example write-up generated from the results

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).

Summary checklist and teaching tips

A descriptive analysis is complete when every item below is done.

Tips for the session

  • Start with a small early win, such as a frequency table in two lines of code, to ease statistics anxiety.
  • Let students hit errors themselves and read the messages together.
  • Ask “what does this number tell us about the students in the data?” after every output.
  • Close with a two-minute exercise. Each student writes a three-sentence APA paragraph about one variable.
  • Preview next week. Descriptives describe the sample; inferential statistics ask whether patterns hold for the population.