06 · Content revision 5
From transition counts to a bigram model
Count each adjacent training-token transition once, normalize a row, and distinguish a zero probability from a row that cannot be normalized.
Turn seven arrows into one prediction row
Chapter 5 built overlapping input–target views for training a sequence model. A bigram table needs a different view of the same data: the original training documents. If we counted through overlapping windows, a transition appearing in two windows would receive two votes. Here every source transition must contribute exactly once.
Use this fixed vocabulary order throughout the chapter:
| Token | BOS | EOS | A | B | C |
|---|---|---|---|---|---|
| ID | 0 | 1 | 2 | 3 | 4 |
C belongs to the vocabulary but does not occur in these two training documents.
The documents remain separate:
d1: BOS(0) → A(2) → A(2) → B(3) → EOS(1)
d2: BOS(0) → A(2) → B(3) → EOS(1)
Before reading on, predict four things:
- How many arrows will be counted?
- Which successors occur after
A, and how often? - Which token should be most likely after
A? - Is “
Cnever followedA” the same case as “nothing was observed afterC”?
The first document contributes four transitions and the second contributes three:
d1: BOS→A, A→A, A→B, B→EOS
d2: BOS→A, A→B, B→EOS
After aggregation, the only nonzero counts are
, , , and .
For current token A, keep the vocabulary column order
[BOS, EOS, A, B, C]:
counts after A = [0, 0, 1, 2, 0]
row total = 3
This is a bigram model: the current token selects one table row, and every
vocabulary token is a possible next-token column. It deliberately forgets
everything before the current token. Normalizing the observed A row gives the
maximum-likelihood estimate (MLE):
So B is the unique most likely successor. The entry for A→C is exactly zero,
but the row itself is well defined because three transitions leave A.
Now try to normalize row C. Its counts are [0,0,0,0,0] and its total is
zero. Dividing every cell by zero does not produce a probability distribution;
the entire MLE row is undefined. This is different from the defined zero in
the A row.
Add-one smoothing () adds one pseudocount to every candidate before
normalization. For A:
For C, the five equal pseudocounts give denominator and the
uniform row . That row is a fallback imposed by the
smoothing rule. It is not evidence that every successor is genuinely equally
likely after C, and it has no unique winner.
Finally, resist the tempting shortcut of concatenating the documents. The flat
sequence [0,2,2,3,1,0,2,3,1] contains eight adjacent pairs and invents exactly
one observation: EOS(1)→BOS(0). That pair belongs to neither source document.
Describe document-local counting and row normalization
The complete procedure is:
Read the first sum from the outside in. It visits each original training document separately. Positions are zero-based, just like Rust slice indices. In a document of length , position stops at , so is the final valid position at most. The superscript matters: both members of a pair come from the same document.
Current-token ID chooses a row, and candidate next-token ID chooses a column. The indicator contributes one only when the adjacent IDs at positions and equal and . Summing row over every vocabulary column produces its outgoing observation total .
MLE divides observed counts by , so it exists only when . Add- smoothing adds to all cells. The denominator must therefore gain , not merely ; this guarantees that the smoothed row still sums to one. Because , even a zero-total row has a positive denominator.
In memory, the Rust implementation stores row , column at a row-major offset. The table layout is an implementation choice; the probabilistic meaning is still “next token , given current token .”
Account for every symbol
| Symbol | Meaning in this chapter |
|---|---|
The set of original training documents, each already wrapped with BOS and EOS. Validation and test documents are excluded. | |
| One training document. | |
| $ | d |
| A zero-based position for which the next position is still inside . | |
| The token ID at zero-based position in document . | |
| The current-token ID; it selects a count-table row. | |
| A candidate next-token ID; it selects a count-table column. | |
| An index used to visit every token ID in while totaling one row. | |
| The vocabulary of possible token IDs. | |
| $ | V |
| The number of observed transitions from to . | |
| The total number of observed transitions leaving . | |
| An indicator: one when its condition is true and zero otherwise. | |
| The maximum-likelihood next-token estimate, defined only when . | |
| The positive pseudocount added to every candidate next token. This chapter uses . | |
| The add-α smoothed estimate of the probability that follows . |
Two phrases that sound similar now have precise locations in the table. An
unobserved successor after A is a zero cell in row A. A context with no
outgoing observations is an entire row whose total is zero. Confusing a column
with a row is the central misconception to avoid.
Use a transparent classical baseline without mistaking it for a strong one
Before neural language models, n-gram models estimated the next word from a fixed-size suffix of preceding words. A bigram uses the shortest nonempty such context: one current token. It is easy to inspect and fast to query, but two histories ending in the same token always receive the same distribution.
Chen and Goodman (1996) describe the bigram maximum-likelihood estimate as a pair count divided by its context count and explain why an unobserved n-gram receiving zero probability is a serious language-modeling problem. Their empirical study compares several smoothing families. Additive smoothing is a simple member of that landscape, not the best-performing practical choice: it moves equal pseudocount mass into every cell and cannot express which unseen continuations are more plausible.
Bengio, Ducharme, and Vincent (2000) describe conventional n-gram models as tables of conditional probabilities over short contexts. Their neural approach instead learns distributed word representations so that evidence can generalize through similarity—a capability the independent cells of our count table do not have.
This course keeps add-one smoothing for a narrower reason: every numerator and
denominator can be checked by hand. Its weakness is visible immediately. The
smoothed A row assigns not only to unseen A→C, but also to A→BOS, even
though BOS is reserved for document beginnings. Uniform pseudocounts know
nothing about token roles or syntax.
The papers do not prescribe our token IDs, document boundaries, , train-only helper, or tie order. Those are explicit teaching and implementation choices. The runnable Rust contrast below prints both MLE and add-one estimates from the same count table so their difference is observable rather than historical prose.
Make the evidence come from one checked Rust table
The executable fixture uses exactly the two documents calculated above. It passes them as separate slices, so the outer collection preserves their segmentation. The generic fitter still trusts its caller to supply one wrapped, unpadded training document per slice; a Rust slice alone cannot prove that precondition.
rust/demos/ch06-bigram-baseline/src/lib.rs#wrapped-training-fixture pub const DOCUMENT_1: &[u32] = &[BOS, A, A, B, EOS];
pub const DOCUMENT_2: &[u32] = &[BOS, A, B, EOS];
pub const TRAINING_DOCUMENTS: [&[u32]; 2] = [DOCUMENT_1, DOCUMENT_2];
pub fn fitted_model() -> Result<BigramModel, BigramError> {
BigramModel::fit_training_documents(VOCABULARY_SIZE, ALPHA, TRAINING_DOCUMENTS)
} fit_training_documents validates the vocabulary, smoothing amount, table size,
and every supplied token ID. Its outer loop keeps documents separate; only
windows(2) inside one document produces pairs. Empty and one-token slices add
no transitions, although every token ID they contain is still checked. The
partition-aware entry point explicitly requests Partition::Train; validation
and test documents never reach the count loop.
rust/crates/llm-from-scratch/src/bigram.rs#fit-training-documents /// Fits one count per adjacent pair in each caller-supplied training document.
///
/// Documents remain separate: the final token of one document is never paired
/// with the first token of the next document.
pub fn fit_training_documents<'a, I>(
vocabulary_size: usize,
alpha: f64,
training_documents: I,
) -> Result<Self, BigramError>
where
I: IntoIterator<Item = &'a [u32]>,
{
if vocabulary_size == 0 {
return Err(BigramError::EmptyVocabulary);
}
let smoothing_mass = alpha * vocabulary_size as f64;
if !alpha.is_finite() || alpha <= 0.0 || !smoothing_mass.is_finite() {
return Err(BigramError::InvalidAlpha);
}
let cell_count = vocabulary_size
.checked_mul(vocabulary_size)
.ok_or(BigramError::TableTooLarge)?;
let mut counts = Vec::new();
counts
.try_reserve_exact(cell_count)
.map_err(|_| BigramError::TableTooLarge)?;
counts.resize(cell_count, 0_u64);
let mut model = Self {
vocabulary_size,
alpha,
counts,
fitted_documents: 0,
fitted_transitions: 0,
};
for document in training_documents {
model.fitted_documents = model
.fitted_documents
.checked_add(1)
.ok_or(BigramError::TooManyDocuments)?;
for token in document {
model.token_index(*token)?;
}
for pair in document.windows(2) {
let from = model.token_index(pair[0])?;
let to = model.token_index(pair[1])?;
let cell = from * vocabulary_size + to;
model.counts[cell] = model.counts[cell]
.checked_add(1)
.ok_or(BigramError::TooManyTransitions)?;
model.fitted_transitions = model
.fitted_transitions
.checked_add(1)
.ok_or(BigramError::TooManyTransitions)?;
}
}
Ok(model)
}
/// Selects the original encoded training documents and never requests held-out data.
pub fn fit_encoded_training_partition(
vocabulary_size: usize,
alpha: f64,
partitions: &EncodedCorpusPartitions,
) -> Result<Self, BigramError> {
Self::fit_training_documents(
vocabulary_size,
alpha,
partitions
.documents(Partition::Train)
.iter()
.map(|document| document.token_ids()),
)
} The query API preserves the conceptual distinction in its return type.
Some(0.0) means the selected MLE row exists and this successor received no
count. None means the row total is zero, so no MLE distribution exists.
Smoothed queries remain defined for every valid context. When several cells share
the maximum count, most_likely_tokens reports all of them in ascending ID order;
that stable reporting order does not make the first ID more probable. Additive
smoothing preserves this count ordering and every tie because it adds the same
to each cell in a row and divides them all by the same denominator.
rust/crates/llm-from-scratch/src/bigram.rs#probability-rows /// Returns `None` when no outgoing transition was observed for `from`.
pub fn maximum_likelihood_distribution(
&self,
from: u32,
) -> Result<Option<Vec<f64>>, BigramError> {
let total = self.row_total(from)?;
if total == 0 {
return Ok(None);
}
Ok(Some(
self.counts_row(from)?
.iter()
.map(|count| *count as f64 / total as f64)
.collect(),
))
}
pub fn maximum_likelihood_probability(
&self,
from: u32,
to: u32,
) -> Result<Option<f64>, BigramError> {
self.token_index(to)?;
let total = self.row_total(from)?;
if total == 0 {
return Ok(None);
}
Ok(Some(self.count(from, to)? as f64 / total as f64))
}
pub fn smoothing_denominator(&self, from: u32) -> Result<f64, BigramError> {
Ok(self.row_total(from)? as f64 + self.alpha * self.vocabulary_size as f64)
}
pub fn smoothed_probability(&self, from: u32, to: u32) -> Result<f64, BigramError> {
let numerator = self.count(from, to)? as f64 + self.alpha;
Ok(numerator / self.smoothing_denominator(from)?)
}
pub fn smoothed_distribution(&self, from: u32) -> Result<Vec<f64>, BigramError> {
self.token_index(from)?;
(0..self.vocabulary_size)
.map(|to| {
let to = u32::try_from(to).map_err(|_| BigramError::TokenOutOfRange)?;
self.smoothed_probability(from, to)
})
.collect()
}
/// Returns every count-maximizing successor in ascending token-ID order.
pub fn most_likely_tokens(&self, from: u32) -> Result<Vec<u32>, BigramError> {
let row = self.counts_row(from)?;
let maximum = row
.iter()
.copied()
.max()
.expect("a vocabulary is non-empty");
row.iter()
.enumerate()
.filter(|(_, count)| **count == maximum)
.map(|(token, _)| u32::try_from(token).map_err(|_| BigramError::TokenOutOfRange))
.collect()
} The demo prints counts before estimates and includes both missing-evidence cases. It also names the transition that flattening would fabricate.
rust/demos/ch06-bigram-baseline/src/main.rs#learner-output println!("tokens: BOS=0 EOS=1 A=2 B=3 C=4");
println!("alpha: {ALPHA:.1}");
println!("training document d1: {DOCUMENT_1:?}");
println!("training document d2: {DOCUMENT_2:?}");
println!("counted transitions: {}", model.fitted_transitions());
println!(
"A counts: {} total={}",
format_counts(model.counts_row(A).expect("A is in the vocabulary")),
model.row_total(A).expect("A is in the vocabulary")
);
println!("A MLE: {}", format_probabilities(&a_mle));
println!(
"A add-alpha: {} denominator={:.0}",
format_probabilities(&a_smoothed),
model
.smoothing_denominator(A)
.expect("A is in the vocabulary")
);
println!(
"unseen successor A->C: MLE={:.3} add-alpha={:.3}",
model
.maximum_likelihood_probability(A, C)
.expect("A and C are in the vocabulary")
.expect("A has outgoing transitions"),
model
.smoothed_probability(A, C)
.expect("A and C are in the vocabulary")
);
println!(
"C counts: {} total={}",
format_counts(model.counts_row(C).expect("C is in the vocabulary")),
model.row_total(C).expect("C is in the vocabulary")
);
println!("C MLE: {c_mle}");
println!(
"C add-alpha: {} denominator={:.0}",
format_probabilities(&c_smoothed),
model
.smoothing_denominator(C)
.expect("C is in the vocabulary")
);
println!("flattening would invent: EOS({EOS})->BOS(0)"); Run the example:
./course run cargo run --quiet --locked -p ch06-bigram-baseline
The implementation preserves all five count rows, the seven-transition total,
the absent EOS→BOS cell, both normalized distributions, stable tie reporting,
and explicit invalid-input errors. Fitting the encoded training partition
produces the same table as supplying those wrapped training documents directly.
Compare every cell without losing the document evidence
Before reading the tables, predict the answers:
- Which cells receive a pseudocount?
- Does smoothing change the most likely successor after
A? - Does the smoothed
Crow gain a unique winner? - Which structurally forbidden successor of
Anevertheless receives probability mass?
Follow two count rows all the way to probabilities
The same Rust fixture supplies the separated training documents and both tables. Compare a known context with one missing successor against a context with no outgoing observations at all.
- Vocabulary size
- 5
- Smoothing amount
- 1.000
- Training documents
- 2
- Transitions counted
- 7
Evidence counted inside document boundaries
- Training document
d1 - Training document
d2
-
BOS0document-boundary token -
EOS1document-boundary token -
A2observed content token -
B3observed content token -
C4absent from these training documents
Every arrow within a document contributes once. No arrow connects the end of one line to the beginning of the next.
Known context: one successor is missing
Current token: A2
- Observed row total
- 3
- Smoothed denominator
- 8
| Next token | Observed count | Added pseudocount | Count plus pseudocount | MLE probability | Smoothed probability |
|---|---|---|---|---|---|
BOS0 | 0 | +1.000 | 1.000 | 0.000 | 0.125 |
EOS1 | 0 | +1.000 | 1.000 | 0.000 | 0.125 |
A2 | 1 | +1.000 | 2.000 | 0.333 | 0.250 |
B3 | 2 | +1.000 | 3.000 | 0.667 | 0.375 |
C4 | 0 | +1.000 | 1.000 | 0.000 | 0.125 |
The transition from A to C has count zero inside a row whose total is three. Its MLE probability is therefore a defined zero; add-one smoothing assigns one eighth.
Context with no outgoing observations
Current token: C4
- Observed row total
- 0
- Smoothed denominator
- 5
| Next token | Observed count | Added pseudocount | Count plus pseudocount | MLE probability | Smoothed probability |
|---|---|---|---|---|---|
BOS0 | 0 | +1.000 | 1.000 | undefined (row total is zero) | 0.200 |
EOS1 | 0 | +1.000 | 1.000 | undefined (row total is zero) | 0.200 |
A2 | 0 | +1.000 | 1.000 | undefined (row total is zero) | 0.200 |
B3 | 0 | +1.000 | 1.000 | undefined (row total is zero) | 0.200 |
C4 | 0 | +1.000 | 1.000 | undefined (row total is zero) | 0.200 |
No transition leaves C, so its row total is zero and an MLE row cannot be normalized. Add-one smoothing imposes a uniform fallback; it does not reveal evidence about C.
Transition that must not be counted
Flattening the two documents would insert EOS→BOS between them. Fitting documents separately prevents that fabricated observation.
Every document and number in the figure comes from a deterministic trace emitted by the same Rust fixture. Text labels, row totals, table structure, and the crossed boundary arrow make the calculation independently inspectable without relying on color.
Now check the predictions against the complete tables: every cell receives ;
B remains the sole maximum after A; every candidate ties after C; and even
the structurally forbidden continuation A→BOS receives nonzero mass.
Predict the result, then expose the arithmetic
Work from the two separate documents and the fixed column order
[BOS, EOS, A, B, C]. Do not run the demo until you have written down each
prediction.
- Enumerate all seven observed transitions, preserving their source document.
- Derive the complete count, MLE, and add-one rows for context
A. - Explain why while the MLE row for context
Cis undefined. - Concatenate the documents mentally. Which exact transition is fabricated, and how many pairs would the flattened sequence contain?
- Why would fitting from Chapter 5’s overlapping , windows miscount document
d1? - Recalculate the smoothed
Arow with . - Verify that both add-one rows shown in this chapter sum to one.
- State the result after
Aand afterC, including every tie. - Suppose validation data contains many
A→Ctransitions. Should the frozen count table change before Chapter 7 scores validation?
Check each prediction and calculation
d1contributesBOS→A,A→A,A→B, andB→EOS;d2contributesBOS→A,A→B, andB→EOS.- Counts are . MLE is . Add-one numerators are , their total is , and the row is .
- Row
Ahas total , so it can be normalized and its zeroCcell becomes probability . RowChas ; dividing its cells by the row total would divide by zero, so no MLE row exists. - Flattening inserts
EOS(1)→BOS(0)betweend1andd2. The flattened sequence has nine tokens and therefore eight adjacent pairs, one more than the source documents contain. - The two complete windows cover overlapping source spans. Both
A→AandA→Boccur in each span, so counting transitions through the windows would give those source transitions duplicate weight. - The denominator is . The numerators are , giving .
- The add-one
Anumerators total , equal to their denominator. TheCnumerators total , also equal to their denominator. Dividing each numerator by its row’s total therefore gives sum one. - After
A, onlyBhas the maximum count and smoothed probability. AfterC, all IDs[0,1,2,3,4]tie under add-one smoothing. Ascending ID is only the reporting order; there is no unique prediction. - No. Validation observations are evidence for evaluation, not fitting. Chapter 7 must score the already-frozen table; changing it would leak validation information into the model.
Misconception check: “C was not observed after A, therefore row C is
undefined” confuses a column with a row. The missing A→C observation is one
cell in the defined A row. Row C is undefined under MLE because no training
transition has C as its current token.
Freeze the first complete next-token model for scoring
The course now has a model that accepts a current token ID and returns one probability for every candidate next token . It is intentionally weak—it sees only one-token context and smoothing cannot learn similarity—but its output has the same essential shape as a decoder’s output distribution.
Chapter 7 will look up the probability assigned to each actual next token and aggregate those values into negative log-likelihood and perplexity. The count table remains frozen: scoring training examples does not add a second set of counts, validation is evaluation-only, and Chapter 7’s metric interface can select only training or validation. No gradient is needed to fit this count table; optimization of learned parameters belongs to later chapters.