knitr::opts_chunk$set(echo = FALSE, collapse=TRUE)
# load libraries
library(tidyverse)
library(here)
library(broom)
library(knitr)
library(kableExtra)
library(plotly)
library(hrbrthemes)
library(htmltools)
# load raw data
alignments <- read_tsv(here("data", "alignments.csv"))
episodes <- read_tsv(here("data", "episodes.csv"))
# set theme
theme_set(hrbrthemes::theme_ipsum_gs())
I recently stumbled across an old John Oliver episode from Season 1. The pace of that episode was noticeably slower — so slow in fact that I thought I had YouTube on half speed.
My interest was piqued: has John Oliver become more
angry fast-paced over time?
(For attention I asked “Has John become more angry?” — but I’m a data scientist, not a loudmouthed journalist, so we’ll leave that inference to the expertise and analysis of a corpus phonetician. And regardless of whether John indeed has become more “angry”, I thank him for being angry with us and making everything slightly more bearable.)
For comparison: 100—120 wpm is generally considered “slow”, 120—150 “conversational”, and by 200+, we’re nearing auctioneer-like speech styles. A speaking rate of 160 wpm is a threshold for comfortably processing complex information. There is, after all, a recommendation to keep presentations to between 100—150 wpm.1
The increase in John’s speech is really quite noticeable — and keep in mind that it is measured across comedy segments with considerably more space taken by audience reactions than your average TED talk or business presentation.
See the Appendix for an interactive (experimental) version of this plot.
Moving on — let’s dive straight in to our main story!
The key insights:
Task: This type of questions and the data are straightforward corpus linguistic tasks: we count words and calculate the speech rate in words per minute, most simply at the episode-level. With more advanced NLP tools, we measure syllables per second and pauses between words at the word-level. The word-level is much more fine-grained, which allows us to exclude the effects of longer stretches of silence, for example.
Challenge: How do we deal with artifacts in the data unrelated to changing speech styles? For example, the color of the dots shows that the main segments in Last Week Tonight have also increased in duration. In statistical terms, that could be a confounding factor. It could well be that the increase in speaking rate is simply due to the unrelated fact that breaks, silences, intros/outros and/or stretches by other people are relatively less influential in longer segments of text.
tl;dr — in this data, text length has no association with style (and neither have clips by other people).2
But let’s see.
Using yt-dlp, I fetched the audio, the auto-generated subtitles and meta data such as file duration, viewers, likes and comments from a list of 314 episodes of Last Week Tonight.
The data is only from the main segments of each episode, which range in duration from 6 to 34 minutes. Until last year, those were the only doses of John available to most people in the outside world without a TV or cable subscription. (I fetched all data in late 2024.)
More information and the data is in the project’s Github repo.
For the graph above, I used the simplest possible method, measuring
speed in words per minute. NA in a row indicates that
either the main segment or the auto-generated subtitles were
unavailable.
The measurement Words per minute is simply the number of words per episode divided by the length of the episode, here expressed in seconds to make calculations easier:
# subset episodes, rename columns for readability, and determine wpm speech rate
episodes_clean <- episodes |>
select(season, episode_total, main_segment, duration, length, wrds) |>
rename(Season = season,
Episode = episode_total,
Title = main_segment,
Duration = duration,
Length = length,
Words = wrds) |>
mutate(`Words per minute` = Words/(Length/60))
# output table
episodes_clean |>
head(10) |>
kable(digits = 0, caption = "Episode list for the first 10 episodes in Season 1.") |>
kable_styling(
full_width = FALSE,
bootstrap_options = c("striped", "hover", "condensed")) |>
row_spec(0, bold = TRUE, color = "white", background = "grey50")
| Season | Episode | Title | Duration | Length | Words | Words per minute |
|---|---|---|---|---|---|---|
| 1 | 1 | POM Wonderful LLC v. Coca-Cola Co. | NA | NA | NA | NA |
| 1 | 2 | Death Penalty | 00:12:23 | 743 | 1757 | 142 |
| 1 | 3 | Climate change denial | NA | NA | NA | NA |
| 1 | 4 | Right to be forgotten | 00:06:59 | 419 | 1009 | 144 |
| 1 | 5 | Net neutrality | 00:13:18 | 798 | 1930 | 145 |
| 1 | 6 | FIFA | 00:13:13 | 793 | 1966 | 149 |
| 1 | 7 | Immigration reform in the United States | NA | NA | NA | NA |
| 1 | 8 | Dr. Oz and Nutritional Supplements | 00:16:25 | 985 | 2297 | 140 |
| 1 | 9 | Hobby Lobby | 00:06:10 | 370 | 976 | 158 |
| 1 | 10 | Wealth Gap | 00:14:10 | 850 | 2304 | 163 |
At the word-level, we can exclude some of the artefacts that affect the episode-level. Because if we measure syllables per second for each word or the pause between words, then silent periods or audience interruptions become largely irrelevant.
So, for a random subset of 108 segments, I used Gentle to force-align the audio with the subtitles. Gentle tries to determine the beginning and the end of each word, from which we can calculate its duration.
Gentle is the only forced-alinger that works reasonably well on audio files longer than a few seconds. This comes at the cost of accuracy, but patterns will be clear enough for over 370,000 data points. Gentle also returns all time stamps in centiseconds (1/100th of a second), whereas small-data “proper phonetics” usually works in milliseconds.
The package {nsyllables} counts the syllables to give us syllables
per second of that particular word. Pauses are calculated as the
difference between the end of one word and the beginning of the next,
and are NA where Gentle could not align the next word.
This table illustrates this process for Episode 18 in Season 8, Housing Discrimination:
# adds duration, pauses, medians
alignments <- alignments |>
group_by(ep_id) |>
# normalize
mutate(beg = round(beg, 4),
end = round(end, 4)) |>
mutate(pause = lead(beg) - end,
dur = end - beg) |>
mutate(syl = nsyllable::nsyllable(word)) |>
mutate(sps = (syl/dur)) |>
ungroup()
# illustrate for one episode
alignments |>
filter(ytid == '_-0J49_9lwc') |>
# column names that are better to read
rename(Date = ytdate,
Word = word,
Recognised = recog,
`No of syllables` = syl,
Begin = beg,
End = end,
Duration = dur,
`Syllables per second` = sps,
`Pause to next word` = pause) |>
select(Word:End, Duration, `Pause to next word`, `No of syllables`, `Syllables per second`) |>
head(12) |>
kable(digits = 2, caption = "Forced-alignment. NA result from alignment errors.") |>
kable_styling(
full_width = FALSE,
bootstrap_options = c("striped", "hover", "condensed")) |>
row_spec(0, bold = TRUE, color = "white", background = "grey40") |>
# highlight rows with alignment errors
row_spec(3:4, color = "grey70")
| Word | Recognised | Begin | End | Duration | Pause to next word | No of syllables | Syllables per second |
|---|---|---|---|---|---|---|---|
| moving | moving | 4.82 | 5.17 | 0.35 | 0.00 | 2 | 5.71 |
| on | on | 5.17 | 5.57 | 0.40 | NA | 1 | 2.50 |
| our | NA | NA | NA | NA |