library(digest)
library(dplyr)
library(tidyr)
library(tibble)
library(stringr)
library(purrr)
library(readr)
source("R/inaugural-corpus.R")
split_path <- "data/inaugural/next-token-splits.csv"
validation_path <- "data/inaugural/next-token-validation.csv"
test_path <- "data/inaugural/next-token-test.csv"
metadata_path <- "data/inaugural/next-token-study-metadata.csv"
split_assignments <- read_csv(
split_path,
na = character(),
col_types = cols(
speech_id = col_character(),
era = col_character(),
split = col_character()
)
)
validation_results <- read_csv(
validation_path,
na = c("", "NA"),
col_types = cols(
split = col_character(),
model = col_character(),
context_tokens = col_integer(),
interpolation_strength = col_double(),
next_token_rows = col_integer(),
oov_target_share = col_double(),
seen_context_share = col_double(),
mean_log_loss = col_double(),
perplexity = col_double(),
selected = col_logical(),
selection_reason = col_character()
)
)
test_results <- read_csv(
test_path,
na = c("", "NA"),
col_types = cols(
split = col_character(),
model = col_character(),
context_tokens = col_integer(),
interpolation_strength = col_double(),
next_token_rows = col_integer(),
oov_target_share = col_double(),
seen_context_share = col_double(),
mean_log_loss = col_double(),
perplexity = col_double(),
top_1_accuracy = col_double(),
role = col_character()
)
)
test_by_speech <- read_csv(
"data/inaugural/next-token-test-by-speech.csv",
na = character(),
col_types = cols(
speech_id = col_character(),
next_token_rows = col_integer(),
model = col_character(),
perplexity = col_double()
)
)
study_metadata <- read_csv(
metadata_path,
na = character(),
col_types = cols(
artifact = col_character(),
description = col_character(),
settings = col_character(),
source = col_character(),
license = col_character(),
built_on = col_character(),
fingerprint = col_character()
)
)
split_counts <- split_assignments |>
mutate(
split = factor(
split,
levels = c("training", "validation", "test")
),
era = factor(
era,
levels = c("before 1900", "1900 or later")
)
) |>
count(split, era, name = "speeches") |>
arrange(split, era)Predicting the next token
Count context and select a model before the final test
Nia builds an autocomplete demonstration for an archive. After a reader types we, the tool must decide which token could come next.
A language model assigns probabilities to token sequences. In next-token prediction, the input is the context already seen and the output is a probability for each possible next token. This is one language-model training objective. It is not a definition of every language model or every text-generation system.
This lesson shows how to:
- keep training, validation, and test speeches separate
- build next-token rows without crossing paragraph boundaries
- fit unigram, bigram, and trigram count models
- compare greedy choice with probability-weighted sampling
- select context length and interpolation strength with validation perplexity
- explain what held-out perplexity does not measure.
Load the split and study results
The inaugural corpus contains 60 public-domain speeches. The committed study uses 37 speeches for training, 10 for model selection, and 13 for the final test. A speech belongs to one split only. Assignment by era places both eras in every part. The split separates speeches, not speakers. Presidents with more than one inaugural address can appear in multiple splits.
| Split | Era | Speeches |
|---|---|---|
| training | before 1900 | 17 |
| training | 1900 or later | 20 |
| validation | before 1900 | 5 |
| validation | 1900 or later | 5 |
| test | before 1900 | 6 |
| test | 1900 or later | 7 |
The study uses validation speeches to select a candidate model. It uses test speeches only after that choice is final. Calling both sets βheld outβ would obscure their different purposes.
Build paragraph-bounded next-token rows
The study uses an explicit word-like pattern so token counts do not depend on the rendering machineβs word-boundary library. It then keeps lowercase ASCII alphabetic tokens. The 2,499 most common training token types receive separate vocabulary entries. Every other token maps to <unk>, the unknown-token entry. The model vocabulary therefore contains 2,500 tokens.
Each prediction row keeps its paragraph and speech ID. previous_1 is the token immediately before the target. previous_2 is the token before previous_1. The first two tokens in each paragraph lack a full two-token context, so the evaluation excludes those rows.
No transition joins the end of one paragraph to the beginning of another. This model therefore does not learn how to start a paragraph.
paragraphs <- inaugural_paragraphs()
token_pattern <- paste0(
"(?:[a-z]\\.){2,}|",
"[a-z0-9]+(?:['\\x{2019}][a-z0-9]+)*"
)
training_tokens <- paragraphs |>
select(paragraph_id, speech_id, paragraph) |>
inner_join(
split_assignments |>
filter(split == "training") |>
select(speech_id),
by = join_by(speech_id)
) |>
mutate(
token = str_extract_all(
str_to_lower(paragraph, locale = "en"),
regex(token_pattern)
)
) |>
select(-paragraph) |>
unnest_longer(token) |>
filter(str_detect(token, "^[a-z]+$")) |>
mutate(position = row_number(), .by = paragraph_id)
training_vocabulary <- training_tokens |>
count(token, sort = TRUE) |>
slice_head(n = 2499L) |>
pull(token)
model_vocabulary <- c(training_vocabulary, "<unk>")
training_next_tokens <- training_tokens |>
mutate(
model_token = if_else(
token %in% training_vocabulary,
token,
"<unk>"
)
) |>
arrange(paragraph_id, position) |>
mutate(
previous_1 = lag(model_token, 1L),
previous_2 = lag(model_token, 2L),
.by = paragraph_id
) |>
filter(position >= 3L) |>
transmute(
paragraph_id,
speech_id,
previous_2,
previous_1,
next_token = model_token
)
training_summary <- tibble(
measure = c(
"training speeches",
"training paragraphs",
"training tokens",
"next-token rows",
"model vocabulary"
),
value = c(
n_distinct(training_next_tokens$speech_id),
n_distinct(training_tokens$paragraph_id),
nrow(training_tokens),
nrow(training_next_tokens),
length(model_vocabulary)
)
)| Training item | Value |
|---|---|
| training speeches | 37 |
| training paragraphs | 867 |
| training tokens | 83458 |
| next-token rows | 81724 |
| model vocabulary | 2500 |
Count what followed each token
A unigram ignores context and uses overall token frequency. A bigram uses one previous token. A trigram uses two. More context can help, but each longer context appears less often.
Raw counts assign probability zero to a next token that never followed the context in training. The study avoids zero probabilities through interpolation, which combines longer-context counts with the shorter model. The interpolation strength controls the weight of the shorter model. A larger value gives that model more weight. The study compares six values on validation speeches. The selected trigram uses 100.
selected_validation <- validation_results |>
filter(selected)
interpolation_strength <- selected_validation$interpolation_strength
unigram_counts <- training_tokens |>
mutate(
model_token = if_else(
token %in% training_vocabulary,
token,
"<unk>"
)
) |>
count(model_token, name = "unigram_count")
training_token_total <- sum(unigram_counts$unigram_count)
unigram_probabilities <- tibble(
model_token = model_vocabulary
) |>
left_join(unigram_counts, by = join_by(model_token)) |>
mutate(
unigram_count = replace_na(unigram_count, 0L),
unigram_probability = (unigram_count + 1) /
(training_token_total + length(model_vocabulary))
)
bigram_counts <- training_next_tokens |>
count(previous_1, next_token, name = "bigram_count")
trigram_counts <- training_next_tokens |>
count(
previous_2,
previous_1,
next_token,
name = "trigram_count"
)
predict_after_one <- function(previous_token) {
context_counts <- bigram_counts |>
filter(previous_1 == previous_token)
tibble(next_token = model_vocabulary) |>
left_join(
unigram_probabilities,
by = join_by(next_token == model_token)
) |>
left_join(context_counts, by = join_by(next_token)) |>
mutate(
bigram_count = replace_na(bigram_count, 0L),
context_count = sum(bigram_count),
bigram_probability = (
bigram_count +
interpolation_strength * unigram_probability
) / (
context_count +
interpolation_strength
)
) |>
arrange(desc(bigram_probability), next_token)
}
predict_after_two <- function(previous_2_token, previous_1_token) {
context_counts <- trigram_counts |>
filter(
previous_2 == previous_2_token,
previous_1 == previous_1_token
)
predict_after_one(previous_1_token) |>
left_join(context_counts, by = join_by(next_token)) |>
mutate(
trigram_count = replace_na(trigram_count, 0L),
context_count = sum(trigram_count),
probability = (
trigram_count +
interpolation_strength * bigram_probability
) / (
context_count +
interpolation_strength
),
context = str_c(
previous_2_token,
previous_1_token,
sep = " "
)
) |>
arrange(desc(probability), next_token)
}
context_predictions <- bind_rows(
predict_after_two("we", "must") |>
slice_head(n = 5),
predict_after_two("the", "united") |>
slice_head(n = 5)
) |>
select(context, next_token, trigram_count, probability)| Previous two tokens | Possible next token | Training count | Probability |
|---|---|---|---|
| we must | be | 9 | 0.1725 |
| we must | <unk> | 8 | 0.0864 |
| we must | not | 5 | 0.0474 |
| we must | do | 5 | 0.0380 |
| we must | show | 4 | 0.0288 |
| the united | states | 112 | 0.7189 |
| the united | nations | 6 | 0.0386 |
| the united | <unk> | 2 | 0.0275 |
| the united | and | 1 | 0.0172 |
| the united | the | 0 | 0.0139 |
After the united, the model assigns about 0.72 probability to states. That reflects a repeated phrase in US inaugural addresses. It does not show a general English rule. After we must, several continuations remain plausible.
The <unk> row represents any token outside the training vocabulary. Predicting that category does not recover the original word.
Select a next token
The probability table does not specify a text-generation rule. Greedy decoding takes the highest-probability token. Sampling draws according to the full distribution. The seed below makes five draws reproducible.
greedy_next <- we_must_distribution$next_token[[1]]
set.seed(6303)
sampled_next <- sample(
we_must_distribution$next_token,
size = 5,
replace = TRUE,
prob = we_must_distribution$probability
)
choice_table <- tibble(
method = c("greedy", "five seeded samples"),
output = c(
greedy_next,
str_c(sampled_next, collapse = ", ")
)
)| Choice rule | Next-token output after βwe mustβ |
|---|---|
| greedy | be |
| five seeded samples | be, races, maintain, strive, change |
Greedy choice always selects the most common continuation. Sampling can select less common tokens and will change when the seed changes. Neither rule establishes whether a continuation is factual, useful, or appropriate for a reader.
Select context length and smoothing before the final test
The builder compares six interpolation strengths for both the bigram and trigram, plus the unigram baseline, on validation speeches. Log loss penalizes probability assigned away from the observed next token. Perplexity is the exponentiated mean log loss. Lower values are better when the tokenizer, vocabulary, and evaluation rows are identical.
validation_display <- validation_results |>
filter(
model == "unigram" |
(model == "bigram" & interpolation_strength == 100) |
(
model == "trigram" &
interpolation_strength %in% c(10, 100, 1000)
)
) |>
arrange(context_tokens, interpolation_strength) |>
transmute(
model,
context_tokens,
interpolation_strength = if_else(
is.na(interpolation_strength),
"not applicable",
as.character(interpolation_strength)
),
seen_context = if_else(
is.na(seen_context_share),
"not applicable",
sprintf("%.1f%%", 100 * seen_context_share)
),
perplexity = round(perplexity, 1),
selected = if_else(selected, "yes", "no")
)| Candidate | Context tokens | Interpolation strength | Validation contexts seen in training | Validation perplexity | Selected |
|---|---|---|---|---|---|
| unigram | 0 | not applicable | not applicable | 238.0 | no |
| bigram | 1 | 100 | 100.0% | 134.8 | no |
| trigram | 2 | 10 | 74.9% | 194.4 | no |
| trigram | 2 | 100 | 74.9% | 133.3 | yes |
| trigram | 2 | 1000 | 74.9% | 143.6 | no |
The trigram with interpolation strength 100 has the lowest validation perplexity, 133.3. The best bigram is close at 134.8. The compared strengths above and below 100 score worse. The selected value is therefore not a boundary of the candidate grid.
Only 74.9 percent of the trigramβs two-token validation contexts appeared in training. When a context is unseen, its estimate relies entirely on the bigram and then the unigram.
The bigramβs 100 percent seen-context rate results from mapping every token into the training vocabulary, which contains <unk>. It does not show that the model generalizes to every word.
Open the test speeches once
The study selects the trigram and its interpolation strength before the final test. It then scores the trigram, the predeclared unigram baseline, and a uniform 2,500-token comparison model on the 13 test speeches.
test_display <- test_results |>
transmute(
model,
role,
next_token_rows,
oov_targets = sprintf("%.1f%%", 100 * oov_target_share),
perplexity = round(perplexity, 1),
top_1_accuracy = if_else(
is.na(top_1_accuracy),
"not applicable",
sprintf("%.1f%%", 100 * top_1_accuracy)
)
)
perplexity_reduction <- 1 -
test_results$perplexity[test_results$model == "trigram"] /
test_results$perplexity[test_results$model == "unigram"]
test_comparison_by_speech <- test_by_speech |>
select(speech_id, model, perplexity) |>
pivot_wider(
names_from = model,
values_from = perplexity
)
speech_people <- paragraphs |>
distinct(speech_id, president)
training_presidents <- split_assignments |>
filter(split == "training") |>
inner_join(speech_people, by = join_by(speech_id)) |>
pull(president)
test_speaker_overlap <- split_assignments |>
filter(split == "test") |>
inner_join(speech_people, by = join_by(speech_id)) |>
mutate(
president_seen_in_training = president %in% training_presidents
)
paired_test <- test_comparison_by_speech |>
summarise(
test_speeches = n(),
trigram_wins = sum(trigram < unigram),
ties = sum(trigram == unigram),
trigram_losses = sum(trigram > unigram)
)
speaker_overlap_test <- test_speaker_overlap |>
summarise(
repeated_president_speeches = sum(president_seen_in_training),
other_speeches = sum(!president_seen_in_training)
)
non_overlap_test <- test_comparison_by_speech |>
inner_join(
test_speaker_overlap |>
select(speech_id, president_seen_in_training),
by = join_by(speech_id)
) |>
filter(!president_seen_in_training) |>
summarise(
speeches = n(),
trigram_wins = sum(trigram < unigram)
)| Model | Role | Test next-token rows | Targets mapped to <unk> |
Test perplexity | Top-1 accuracy |
|---|---|---|---|---|---|
| uniform | uniform vocabulary floor | 24337 | 11.7% | 2500.0 | not applicable |
| unigram | predeclared frequency baseline | 24337 | 11.7% | 240.7 | 11.7% |
| trigram | validation-selected model | 24337 | 11.7% | 145.3 | 19.2% |
The uniform model has perplexity 2,500 because it gives every vocabulary item the same probability. The selected trigram reduces test perplexity by about 39.6% relative to the unigram baseline. It has lower perplexity in the paired comparison for all 13 test speeches.
Its highest-probability guess is right on 19.2 percent of test rows. The unigram scores 11.7 percent because its most likely token is <unk>, the category for many rare training words. A correct unknown-token prediction does not recover the original word. Roughly one third of the trigramβs correct guesses are also unknown-token matches rather than recovered words.
Presidents who also appear in training delivered eight of the 13 test speeches. The test separates speeches, not speakers. Repeated presidents can therefore contribute similar phrasing to different splits. The trigram still has lower perplexity than the unigram on all five test speeches whose president is absent from training.
Perplexity would change under another vocabulary, tokenizer, boundary rule, or test set. The values here cannot be compared with the LDA perplexity in Lesson 55 or a published model score from another tokenization.
The result does not show that the model understands a speech, writes a good report, or produces facts. The next lesson uses these same measurements to show how a report can retain its evidence references.
What to remember
- Next-token prediction assigns probabilities after a context.
- Paragraph boundaries prevent invented transitions between source records.
- Validation data select context length and interpolation strength. Test data measure performance after that choice.
- The study selects the trigram and interpolation strength together on validation data.
- The selected trigram has lower perplexity than the unigram on all 13 test speeches here. Its top choice is still wrong on about four out of five rows.
- Perplexity is comparable only under the same tokens, vocabulary, and test rows.
- A likely next token is not necessarily factual or useful.
Sources
- Jurafsky and Martin, Speech and Language Processing, chapter on n-gram language models
- Bengio et al., A Neural Probabilistic Language Model
- Chen and Goodman, An Empirical Study of Smoothing Techniques for Language Modeling
- Hugging Face documentation on perplexity for fixed-length models
- Meister and Cotterell, Language Model Evaluation Beyond Perplexity
- quanteda inaugural-address corpus documentation