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 | NA | 2 | NA |
| main | NA | NA | NA | NA | NA | 1 | NA |
| story | story | 6.03 | 6.31 | 0.28 | 0.00 | 2 | 7.14 |
| tonight | tonight | 6.31 | 6.60 | 0.29 | 0.00 | 2 | 6.90 |
| concerns | concerns | 6.60 | 7.18 | 0.58 | 0.11 | 2 | 3.45 |
| land | land | 7.29 | 7.86 | 0.57 | 0.10 | 1 | 1.75 |
| specifically | specifically | 7.96 | 8.68 | 0.72 | 0.07 | 4 | 5.56 |
| the | the | 8.75 | 8.93 | 0.18 | 0.00 | 1 | 5.56 |
| history | history | 8.93 | 9.47 | 0.54 | 0.02 | 3 | 5.56 |
| of | of | 9.49 | 9.69 | 0.20 | 0.00 | 1 | 5.00 |
Explanations:
It is worth noting that (longer) pauses between words generally occur (i) at phrase boundaries, much like a “spoken” comma you’d expect after specifically, or (ii) for dramatic effect, like before land — both are clearly audible in the segment.
If we now average speed and pauses over all words in each segment, we get a visual confirmation that John’s speech — and arguably the show’s pace in general — have become much faster.
Over time, words are spoken more quickly and there are shorter pauses between words:
Of course in faster-paced speech, pauses between words also get shorter. That being said — in connected speech, pauses between words are usually 0 at any speech rate, especially within phrases. Therefore, an average decrease from 120ms to 90ms does suggest John is combining phrases and sentences increasingly more quickly, adding to the big-picture impression of the show’s faster pace in general.
In data terms, it is worth pointing out that the linear regression
line for syllables per word (as shown here in pink
(method = "lm") is near identical to a line from a method
that would detect non-linear relationships (i.e.,
method = "gam"). Hence, we can turn to a linear regression
as a suitable modelling technique.
Of course, by now we have a pretty solid picture that the initial auditory impression was indeed correct. Nonetheless, we can use this data set to illustrate the advantage of a linear regression. Because at face value, all we have in data terms is this: “an increase in words per minute” and “an increase in amount of text”. In other words, we don’t know if the increase in words per minute is an artifact of the increase in amount of text.
We could model (or predict) either words per minute or
syllables per second. The latter is more fine-grained, thus
truer to the original question, and has vastly more data points to work
with. However, the word-level is also vastly more complex to model, as
it comes with additional complications. For instance, we would have to
control for word properties and/or the words themselves (of which there
are over 22,000 types). That’s for another day — or for another
expert.
Alternatively, if we predict the aggregate
syllables per seconds at the episode-level, we only have
roughly a third of the data points than we have for
words per minute.
From the original question, it is clear which factors to include: we
want to predict wpm as a proxy of speaking rate from
season as a proxy of time, controlled for
length of segments.
But for the sake of illustration, let’s discuss a few additional predictors. They also explain why some files were excluded for the analysis:
ep_within — the episode number within a season — for
model stability, we exclude 6 segments from the only season with those
high number of episodes,align_errors — measures the proportion of words in an
episode which Gentle could not align properly, and,pause_mean — the average pause between the end of one
word and the beginning of the next.A correlation matrix shows:
wpm, length and
season — are all expectedly highly correlated.align errors) and pauses
(pause_mean) are strongly correlated. This is almost
entirely due to a handful of files with extreme alignment errors —
leading to 30s+ pauses. I thus remove(d) a handful of segments with an
implausibly high average pause of over 200ms.Our simple model is wpm ~ season + length + ep_within,
over the 294 segments. It tries to ‘explain’ the increase in words per
minute with time, segment length, and the phase of a season.
| term | estimate | std.error | statistic | p.value |
|---|---|---|---|---|
| (Intercept) | 153.948 | 1.431 | 107.564 | 0.000 |
| season | 2.297 | 0.226 | 10.187 | 0.000 |
| length | -0.620 | 0.678 | -0.914 | 0.361 |
| ep_within | 0.539 | 0.517 | 1.042 | 0.298 |
Reading this table is straightforward: where the estimate (coefficient) is positive, an increase in that variable leads to an increase in the outcome variable. If a predictor’s p-value is less than 0.05, it is conventionally assumed that this is influence beyond chance.
In essence: only season is a significant predictor of
wpm. Neither the increased segment length over time, nor
the number of an episodes within a season are significantly associated
with the increased speaking rate above chance.
Since season was not numerically transformed, we can
interpret its coefficient 2.297 directly and in ‘raw’ terms:
Note: for those more versed in linear models, the variance explained is moderate (R2 = 0.37). That being said, I’m used to far lower values in linguistics. A low R2 is usually the result of complexity that is operationalised by extreme simplification. Model performance tools detected no issue, suggesting the linear model is an adequate approach, albeit simple.
For what it’s worth: running a comparable model on the smaller sample
with the more fine-grained predictors syllables per second
or pause_mean leads to an identical interpretation:
substantially faster-paced speech over time, and no effect of segment
length.
Hence, we go with the simpler model that is easier to understand and more intuitive to interpret.
Overall, the auditory impression is confirmed.
The actual numbers — 25 wpm more in 2024 than in 2014 — need to be taken with a grain of salt. Let’s not forget that this average is measured across an entire segment, including intros/outros, audience reactions, pauses for dramatic effect, or the segments of other people. In general, we assume that this ‘noise’ is relatively constant across segments (or episodes, probably), but many of the mentioned factors probably ‘drag’ the average down a bit when compared to the speaking rates of other people or contexts.
As for the reasons why we see John’s pattern — pure speculation, and not my area of expertise at all.
An natural reaction is that John is indeed also angrier — we intuitively associate emotional speech with greater speed. And there is after all a significant amount of stuff that’s been getting progressively more worth being angry about since 2014.
Whether emotion of this kind can be measured in this data? May be. It’ll probably need some form of pitch analysis and a skilled computational phonetician to get something out of audio files of very poor quality.
A topic-modelling analysis might be interesting to answer whether more political segment topics (but how define?) are associated with more emotional speech styles. The obvious issue: all of John’s stories are somewhat political and equally enraging. Exploring the patterns — see the interactive plot below — doesn’t seem to suggest that it has anything to do with the topic. But that’d be for another day.
This code generated the graph at the beginning:
# plot
episodes |>
mutate(wpm = wrds/length * 60) |>
# filter episodes without words (missing subtitle file)
filter(!(is.na(wpm))) |>
# this episode is an extreme outlier - with Vietnamese subtitles...
filter(ytdate != "2021-08-16") |>
select(wpm, ytdate, wrds, length, period) |>
# plot
ggplot(aes(ytdate, wpm, color = wrds)) +
geom_point(alpha = 1, size = 3) +
labs(
title = "Last Week Tonight's John Oliver: faster! ...angrier?",
subtitle = "John's pace has increased from 150 to almost 180 words per minute",
x = '',
y = 'Words per minute',
caption = "Each dot one episode; based on 298 auto-generated subtitle files."
) +
scale_color_viridis_c(option = "viridis") +
coord_cartesian(ylim = c(100, 200)) +
scale_x_date(date_breaks = '1 year', date_labels = "%Y") +
scale_y_continuous(breaks = seq(100, 200, by = 10)) +
hrbrthemes::theme_ipsum_gs() +
theme(
legend.position = "top",
legend.direction = "horizontal",
panel.grid.minor = element_blank()
) +
# the legend for the segment duration (and only that legend)
guides(
size = "none",
color = guide_colorbar(
title = "Segment length (in words)",
title.position = "top",
direction = "horizontal",
barwidth = unit(12, "cm"),
barheight = unit(0.2, "cm")
)
) +
# pre-covid, covid, post-covid episode indicator
geom_vline(
xintercept = as.Date(c("2020-03-10", "2021-09-01")),
linetype = "dashed",
color = "black",
linewidth = 0.8
) +
annotate(
"label",
x = as.Date("2020-12-01"),
y = 150,
label = "Covid",
fill = "white",
color = "grey45"
)
ggsave("data/graphs/LWT_overall.png", width = 7.2, height = 5, dpi = 300)
Or perhaps an experimental version of interactive plotly? Hover over the scatters for information on the episodes.
It’s experimental because, at the time of writing, plotly cannot properly display several features that were used above.
## Warning in geom2trace.default(dots[[1L]][[1L]], dots[[2L]][[1L]], dots[[3L]][[1L]]): geom_GeomLabel() has yet to be implemented in plotly.
## If you'd like to see this geom implemented,
## Please open an issue with your example code at
## https://github.com/ropensci/plotly/issues
I use AI — in pretty much the same way I have (always) been using strackoverflow. I use it for inspiration on how to get code to do what I want and how to interpret error messages — in any coding language I use. I then test and amend relevant output for the purpose at hand.
I am proficiently functional bordering fluent in R, but I don’t eat and sleep its thousands of cool packages for the varied tasks I use it for alongside all my other jobs and interests, so there’s that.
As for writing, I write all my own stuff. There was a period where I used AI for proof-reading, as to not make me look like a fool when claiming I am a thorough person with attention to detail. But I discontinued that use of AI considering how much time I wasted undoing AI’s patronising suggestions of “style” and “conciseness” — and accepted that I’ll happily live with my own occasional typos in the same way I happily live with everybody else’s typos. They usually result from honest attempts at making a piece of text more legible — it’s not a reflection on anyone’s carelessness.
If anyone’s rightfully and critically wondering: yes, starting the y-axis above 0 can be bad and deceptive practice. However, 100 wpm is the far more natural starting point for one’s pace of speaking than 0 — or at least I should hope so! In other words, the y-axis in this graph starts at “slow speech”.↩︎
To assess if other people’s contributions affected the patterns, I manually checked about two dozen randomly chosen transcripts. Non-John data did in fact change the numbers, but in the other direction — John’s speaking rate would be even higher.↩︎