Skip to content
NLP/Playground
All decision records

Decision record · DR-002

A context n-gram Hangman guesser that looks at both neighbours

Status
Accepted
Date
2024 Semester 1 (submitted), analysis added 2026-10
Applies to
Assignment 2, Questions 6 and 7 (/hangman, /hangman/benchmark)

Decision in one line

For each blank I add up a letter distribution chosen by how much context is visible: "left _ right" counts when both neighbours are known, a one-sided table when one is known, and unigram counts when neither is. I guess the highest-scoring letter that has not been tried.

Context

Question 6 was open-ended. A guesser earned full marks if it averaged fewer than 7.6 mistakes over 1,000 held-out word types from the Brown corpus, with a budget of 26 mistakes per word. The best baseline from Question 5, a bigram model that scores each blank by its left neighbour, averaged 8.669 in my submitted notebook. My notes in the notebook record a two-minute limit on running time, which ruled out training a neural model.

Decision

I built three count tables from the training words. The first counts the letter between each pair of neighbours, which applies the CBOW idea to characters. The second counts which letter comes before each letter ("reverse bigram"). The third is the unigram table from Question 3. For every blank, the guesser picks the table that matches the visible context, adds smoothed scores for every unguessed letter, and sums those scores across blanks.

Options considered

  1. Keep the Question 5 bigram guesser. It averaged 8.669, above the 7.6 threshold.
  2. Longer left contexts (trigrams and up). With about 39,000 training words, most long contexts are rare, so the counts get sparse quickly.
  3. A small neural model on masked words. Ruled out by the time limit.
  4. Guess vowels first. I tried it and dropped it. The notebook comment says it could waste up to four guesses.
  5. Choose the table by visible context and sum over blanks (chosen).

Why

Two known neighbours constrain a blank much more than one, and after a few correct guesses most blanks have at least one known neighbour. All three tables come from one pass over the training words, so the model trains in seconds.

What happened

  • Submitted result. 7.493 mistakes per word on the seed-40 test words. The bigram guesser's 8.669 came from a different test split (seed 1), so the two numbers were never a like-for-like comparison.
  • Reproducible re-run. With Python's hash seed pinned, the guesser averages 7.371 (bootstrap 95% CI 7.129 to 7.618). The 7.6 threshold sits inside that interval, so on a different sample of words the guesser could plausibly miss it.
  • A train/test leak in the fallback table. The submitted guesser's unigram fallback defaults to the Question 3 table, which was fitted on the seed-1 training words. The two 1,000-word test sets share only 25 words, so the seed-1 training words contain 975 of the 1,000 seed-40 test words. The submitted 7.493 and the re-run both used a fallback table that had seen almost every test word. Refitting that table on the seed-40 training words, as DR-004 does, moves the re-run average from 7.371 to 7.381. The leak made no practical difference here, but it was a leak, and the submitted number carries it.
  • Paired comparison. On the common split described in DR-004, where every guesser is trained on the same words and plays the same 1,000 test words, the context guesser averages 7.381 (7.140 to 7.628) and the bigram guesser 8.820 (8.577 to 9.063). The paired difference is 1.44 fewer mistakes per word (bootstrap 95% CI 1.24 to 1.63, Cohen's d_z = 0.44). The context guesser makes fewer mistakes on 611 words, the same number on 132 and more on 257.
  • Seed choice. My Question 7 answer says the split seed moved the average between 7.2 and 7.8, and I kept seed 40. Choosing a seed after seeing test scores flatters the result. The paired comparison above uses the same seed-40 words, so it does not remove that bias.
  • A defect I found while porting. When only the left neighbour is known, the code looks up the reverse table under that neighbour. That gives the letters that come before the left neighbour, not the letters that follow it. At the start of a word the lookup is uniform, because the reverse table has no start symbol. A copy of the guesser that uses forward counts for that one branch (scripts/analyse_q6_left_context.py) changes the average by −0.029 mistakes per word (bootstrap 95% CI −0.119 to 0.060) on the same 1,000 words. The bug is real, but its effect is too small to measure on this test set. I have not measured why. One likely reason is that blanks with only a left neighbour are uncommon once a game is under way.
  • Smoothing. Each letter gets 1 added to its count, but the denominator also only gets 1, so a blank's scores do not sum to one. Blanks with sparse context therefore weigh as much in the sum as blanks with rich context.

What I'd change

  • Fix the left-context lookup and use proper add-k smoothing over the 26 letters, then check the result on words the model has never been tuned on.
  • Replace the summed scores with one model of P(letter | left, right) that backs off to shorter contexts, for example interpolated Kneser-Ney.
  • Use the position in the word and the word length, which the length-conditioned unigram guesser shows carry information.
  • Fix the split seed before looking at any test score, and report results over several seeds.