Sunday, January 11, 2015

Coding A Better Strategy for Hangman

Happy New Year! In this post, I will describe a fun little project that came about while going through the lectures in The Caltech-JPL Summer School on Big Data Analytics course on Coursera (really a dump of the lecture notes and videos from the actual week-long sessions rather than a formal course), I came across an interesting assignment - implementing the ideas outlined in this lifehacker article - A Better Strategy for Hangman. Hangman is a simple word-guessing game played by two players, one of whom (the Proposer) thinks of a word and the other (the Solver) tries to guess it. The guessing is done one letter at a time - the strategy described in the article uses letter frequencies to inform these guesses. The article goes into much more detail, and you should read it to get a full understanding of the strategy.

In real life, the game is played between two people, each of whom have their own (probably different sized) vocabularies. In our code, we use the words from our /usr/share/dict/words file (in Ubuntu, and likely available for other Unix based systems as well). So both Proposer and Solver use the same list of words as their "vocabulary". The Proposer selects a random word from the list and the Solver uses letter frequencies to make letter guesses until he guesses the word or the allowed number of guesses (11) run out. Here is the code for the game.

 ```1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165``` ```# Source: src/hangman/game.py # -*- coding: utf-8 -*- import operator from random import random MAX_GUESSES_PER_GAME = 11 MAX_WORDS_IN_SUBSET = 25 ########################### Preprocessing ############################ non_ascii_mappings = list([ ('', 'a'), ('', 'a'), ('', 'a'), ('', 'a'), ('', 'a'), ('', 'a'), ('', 'c'), ('', 'c'), ('', 'e'), ('', 'e'), ('', 'e'), ('', 'e'), ('', 'i'), ('', 'n'), ('', 'o'), ('', 'o'), ('', 'o'), ('', 'o'), ('', 'u'), ('', 'u'), ('', '\''), ('', '"') ]) def ascii_fold(s): for x in non_ascii_mappings: s = s.replace(x, x) return s def preprocess(dictfile): fwords = open(dictfile, 'rb') wset = set() for line in fwords: word = line.strip().lower() word = ascii_fold(word) if word.endswith("'s"): word = word[:-2] word = word.replace(" ", "").replace("'", "").replace("\"", "") wset.add(word) fwords.close() return list(wset) ############################# Proposer Side ############################# def select_secret_word(words): widx = int(random() * len(words)) return words[widx] def find_all_match_positions(secret_word, guess_char): positions = [] curr_pos = 0 while curr_pos < len(secret_word): curr_pos = secret_word.find(guess_char, curr_pos) if curr_pos < 0: break positions.append(curr_pos) curr_pos += 1 return positions def update_guessed_word(guessed_word, matched_positions, guessed_char): for pos in matched_positions: guessed_word[pos] = guessed_char def is_solved(guessed_word): chars_remaining = len(filter(lambda x: x == '_', guessed_word)) return chars_remaining == 0 ############################# Solver Side ############################### letters = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z"] def most_frequent_char(words, previously_guessed): cmap = {x:0 for x in letters} for word in words: cset = set() for c in word: if c not in previously_guessed: cset.add(c) for c in cset: cmap[c] += 1 return sorted(map(lambda x: (x, cmap[x]), cmap.keys()), key=operator.itemgetter(1), reverse=True) def best_guess(words, word_len, bad_guesses, good_guesses, guessed_word): temp_words = filter(lambda x: len(x) == word_len, words) if len(bad_guesses) > 0: for bad_guess in bad_guesses: temp_words = filter(lambda x: x.find(bad_guess) == -1, temp_words) if len(good_guesses) > 0: for good_guess in good_guesses: temp_words = filter(lambda x: x.find(good_guess) > -1, temp_words) previously_guessed = set(filter(lambda x: x != '_', guessed_word)) return temp_words, most_frequent_char(temp_words, previously_guessed) def init_guess(wordlen): initial_guess = [] for i in range(wordlen): initial_guess.append("_") return initial_guess def match_words_against_template(words, guessed_word): if len(words) > MAX_WORDS_IN_SUBSET: return words matched_words = [] for word in words: word_chars = [c for c in word] merged = zip(guessed_word, word_chars) diff = len(filter(lambda x: x != x, filter(lambda x: x != '_', merged))) if diff == 0: matched_words.append(word) if len(matched_words) > 1: break return matched_words def replace_guessed_word(guessed_word, matched_word): matched_chars = [c for c in matched_word] for i in range(len(matched_chars)): guessed_word[i] = matched_chars[i] ################################# Game ################################### def single_round(words, debug=False): solver_wins = False secret_word = select_secret_word(words) if debug: print "secret word:", secret_word word_len = len(secret_word) bad_guesses = set() good_guesses = set() guessed_word = init_guess(word_len) for num_guesses in range(MAX_GUESSES_PER_GAME): filtered_words, guess_char = best_guess(words, word_len, bad_guesses, good_guesses, guessed_word) if debug: print "guessed char:", guess_char matched_positions = find_all_match_positions(secret_word, guess_char) if len(matched_positions) == 0: bad_guesses.add(guess_char) else: good_guesses.add(guess_char) update_guessed_word(guessed_word, matched_positions, guess_char) matched_words = match_words_against_template(filtered_words, guessed_word) if len(matched_words) == 1: replace_guessed_word(guessed_word, matched_words) if debug: print "#", num_guesses, "guess:", " ".join(guessed_word) if is_solved(guessed_word): solver_wins = True break return len(secret_word), solver_wins, num_guesses def multiple_rounds(words, num_games, report_file): fdata = open(report_file, 'wb') for i in range(num_games): word_len, solver_wins, num_guesses = single_round(words, False) fdata.write("%d,%d,%d\n" % (word_len, 1 if solver_wins else 0, num_guesses)) fdata.close() ################################# Main ################################### words = preprocess("/usr/share/dict/words") #single_round(words, True) multiple_rounds(words, 10000, "hangman.csv") ```

The /usr/share/dict/words file has 99,171 English words, and contains several letters with diacritics for words that are adopted from other languages. We intially normalize these words so they are all in lowercase, and replace letters with diacritics with their corresponding ASCII letters from the range 'a' through 'z'. We also replace words ending with 's with their base forms, and remove duplicates. At the end of this, we are left with 72,167 words.

At the beginning of the game, the solver knows the number of letters in the word, so in order to maximize the chances of getting a letter, it makes sense to guess the most frequent letter for that size of word in the dictionary. If we get the letter, we are obviously one step closer to a win. However, even if we don't get a letter, it still gives us some information which we can use for subsequent guesses - ie, our subset of word candidates do not contain this letter.

This approach of consistently choosing the most frequent letter (minus any letters known to not exist in the word) works upto a point. Often, it is possible to use the word template generated from previous guesses to match against the word corpus. If the template matches to a single word, we can just guess that word and win. I have implemented this strategy in the match_words_against_template() function in the code above.

An optimization for cases where the template matches multiple words would be to select a letter that resolves the ambiguity (such as a guess of "s" for the pattern "_ e t t l e" where the word could be either "settle" or "kettle") rather than the most frequent character.

Here are traces from a few games. You can run a single game by calling the single_round() function with debug set to True. The word is selected randomly from the word list, but it is quite simple to make the change to take in a specific word instead if you want to do that.

 ```secret word: undergo guessed char: e # 0 guess: _ _ _ e _ _ _ guessed char: s # 1 guess: _ _ _ e _ _ _ guessed char: r # 2 guess: _ _ _ e r _ _ guessed char: a # 3 guess: _ _ _ e r _ _ guessed char: i # 4 guess: _ _ _ e r _ _ guessed char: o # 5 guess: _ _ _ e r _ o guessed char: d # 6 guess: _ _ d e r _ o guessed char: l # 7 guess: _ _ d e r _ o guessed char: n # 8 guess: _ n d e r _ o guessed char: u # 9 guess: u n d e r _ o guessed char: b # 10 guess: u n d e r g o ``` ```secret word: steakhouses guessed char: e # 0 guess: _ _ e _ _ _ _ _ _ e _ guessed char: i # 1 guess: _ _ e _ _ _ _ _ _ e _ guessed char: r # 2 guess: _ _ e _ _ _ _ _ _ e _ guessed char: s # 3 guess: s _ e _ _ _ _ _ s e s guessed char: a # 4 guess: s _ e a _ _ _ _ s e s guessed char: l # 5 guess: s _ e a _ _ _ _ s e s guessed char: t # 6 guess: s t e a _ _ _ _ s e s guessed char: n # 7 guess: s t e a _ _ _ _ s e s guessed char: o # 8 guess: s t e a k h o u s e s ``` ```secret word: coffers guessed char: e # 0 guess: _ _ _ _ e _ _ guessed char: s # 1 guess: _ _ _ _ e _ s guessed char: r # 2 guess: _ _ _ _ e r s guessed char: a # 3 guess: _ _ _ _ e r s guessed char: i # 4 guess: _ _ _ _ e r s guessed char: o # 5 guess: _ o _ _ e r s guessed char: t # 6 guess: _ o _ _ e r s guessed char: c # 7 guess: c o _ _ e r s guessed char: u # 8 guess: c o _ _ e r s guessed char: k # 9 guess: c o _ _ e r s guessed char: p # 10 guess: c o _ _ e r s ``` ```secret word: soho guessed char: a # 0 guess: _ _ _ _ guessed char: e # 1 guess: _ _ _ _ guessed char: o # 2 guess: _ o _ o guessed char: s # 3 guess: s o _ o guessed char: t # 4 guess: s o _ o guessed char: l # 5 guess: s o _ o guessed char: b # 6 guess: s o _ o guessed char: n # 7 guess: s o _ o guessed char: p # 8 guess: s o _ o guessed char: d # 9 guess: s o _ o guessed char: w # 10 guess: s o _ o ```

Now that the game had been reduced to closed-world (ie vocabulary drawn from the same list) interactions between two entities which had computers for minds, I wanted to see how successful the Solver was against the Proposer. For this, I ran a small experiment and let the code run for 10,000 games and recorded their results. The data is generated by calling the multiple_rounds() function with the number of runs desired and the output filename.

Here is some code that reads the resulting file and generates the necessary charts for visualization.

 ```1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39``` ```# Source: src/hangman/gamestats.py # -*- coding: utf-8 -*- from __future__ import division import pandas as pd import matplotlib.pyplot as plt df = pd.read_csv("hangman.csv", header=None, names=["WORD_LEN", "SOLVER_WINS", "NUM_GUESSES"]) # wins vs losses nloss = df[df["SOLVER_WINS"] == 0].count()["SOLVER_WINS"] nwins = df[df["SOLVER_WINS"] == 1].count()["SOLVER_WINS"] print "probability of winning=", nwins / (nwins + nloss) print "probability of losing=", nloss / (nwins + nloss) ## probability of winning and losing for different word lengths df2 = df.drop("NUM_GUESSES", 1) df2_wins = df2[df2["SOLVER_WINS"] == 1].groupby("WORD_LEN").count().reset_index() df2_losses = df2[df2["SOLVER_WINS"] == 0].groupby("WORD_LEN").count().reset_index() df2_losses.rename(columns={"SOLVER_WINS": "SOLVER_LOSES"}, inplace=True) df2_merged = df2_wins.merge(df2_losses, how="inner", on="WORD_LEN") df2_merged.plot(kind="bar", stacked=True, x="WORD_LEN", title="Win/Loss Counts by Word Length") plt.show() df2_merged["NUM_GAMES"] = df2_merged["SOLVER_WINS"] + df2_merged["SOLVER_LOSES"] df2_merged["SOLVER_WINS"] = df2_merged["SOLVER_WINS"] / df2_merged["NUM_GAMES"] df2_merged["SOLVER_LOSES"] = df2_merged["SOLVER_LOSES"] / df2_merged["NUM_GAMES"] df2_merged.drop("NUM_GAMES", axis=1, inplace=True) df2_merged.plot(kind="bar", stacked=True, x="WORD_LEN", title="Win/Loss Probabilities by Word Length") plt.show() # how number of guesses to win varies with word length (winning games only) df3 = df[df["SOLVER_WINS"] == 1] df3.drop("SOLVER_WINS", 1) df3_grouped = df3.drop("SOLVER_WINS", 1).groupby("WORD_LEN").mean().reset_index() df3_grouped.plot(kind="bar", x="WORD_LEN", title="Avg Guesses for Different Word Lengths") plt.show() ```

The empirical probability of winning the game, based on this data, is about 0.66. As expected, the distribution of word lengths thought up by the Proposer follows a roughly normal distribution. However, shorter words have a lower probability of getting solved than longer words. This is more easily seen in the chart showing win/loss probabilities. So treating win probability as a proxy for difficulty, winning at Hangman is actually harder for shorter words than for longer words.  I also plotted the average number of guesses the solver made to guess different sized words thought up by the Proposer. This time the distribution is more uniform, although it falls off to the right for long words.

This is all I have for today. I haven't been posting these past few weeks because I was out of town on vacation. Hopefully I should get back to a more regular schedule in the New Year.