Python Wordle on GitHub

Our Tuesday evening Friday Geeks Night Out Zoom Ensemble is getting sucked into Ken Pugh’s work with Wordle. I have caught the virus and am going to start a new Python project.

My friend Ken Pugh has been in the Zoom Ensemble for a while, and he has been working on learning Python by analyzing the Wordle game. After about the third week of him showing us his code and asking for advice, he’s starting to suck us into his whirlpool. Today, I’m drawn in.

I’ll not describe the game details here, though as we work, we’ll certainly cover the basics. If you are one of today’s lucky 10,000 who have not heard of it, a quick Google Search will find the New York Times game, which has been a craze for a while now. Briefly, you get six tries to guess a five-letter word. For each guess you’re told whether each letter in your word occurs in the target word and whether your guess has the letter in the right position.

Roughly what we want to do is, given the possible words that can be guessed (including solution words), do some analysis to come up with good words to guess and strategies for guessing. At this moment, that vague description is all I’ve got. I imagine that I’ll come up with a prompting program for playing the actual game with computer assist, just because that’s more fun than actually thinking while playing the game. But before that, I expect to try other ideas.

Aside

I am reminded of my work on Sudoku years ago. In that exercise, I made a point of not looking up well-known strategies for Sudoku, and after not too long I just abandoned the effort. Some readers mocked me viciously for that, and took it as proof that nothing I’ve ever espoused could possibly work, because I “failed” as Sudoku. So that was amusing, but I certainly did abandon it.

Perhaps I’ll abandon Wordle too. I’m here to have fun and learn things. When I stop learning or stop having fun, I’m off to something else. No one is paying me to work on Wordle, after all. (I would consider accepting a lucrative Wordle contract if you would care to offer one.)

We’ll see what happens. Let’s get started.

Diving In

I found word lists on the Kaggle.com site. I am not familiar with Kaggle but it seems to be for data scientists and the like and has lots of interesting data files.

I create a new PyCharm project, “wordle” and dump those two files into it, renamed as txt files because otherwise PyCharm wanted to open excel for me, which I do not have, being a Mac user, so then it wanted to open Numbers for me, which I do have but did not want. The files, being csv files at heart, except with no commas doing any separating, started with “word”, the column heading, so I removed that and now they are just big alphabetic lists of five-letter words.

I now have three tests:

class TestWords:
    def test_hookup(self):
        assert 2 + 2 == 4

    def test_reading_guesses(self):
        with open("valid_guesses.txt", "r") as guesses:
            lines = guesses.readlines()
        assert len(lines) == 10657

    def test_reading_solutions(self):
        with open("valid_solutions.txt", "r") as solutions:
            lines = solutions.readlines()
        assert len(lines) == 2315

Those values agree with the list sizes as stated on Kaggle. Life is good so far.

We are under way. We have no idea where we should go. Let’s speculate about things we will / might / could want:

Word Object
Surely we will have a Word class, instance for each word, that knows handy things about the word, including of course its letters.
Word Collection
I plan to start with type-specific collections early on, so that I do not fall into the trap of using regular lists. My experience is that this is [almost?] always the best course of action.
Scoring
Presumably we’ll want to take two words and create the Wordle score information, which I think of as a five-cell thingie containing No, Yes, or Exact, or red, yellow, green.
Display
I’ll surely want to display things. I have pygame, which can probably do it. I might do better with tkinter or whatever its name is. We’ll see.
Purpose
I suppose we should have a purpose for this exercise. It will be something like “Analyze these word lists with an eye to learning the best words to guess next and the best strategies to use …”.

That said, for now, my purpose is intentionally and unavoidably vague. I’m going to play with this data and see what I can make of it and what it suggests to me.

Let’s get back to it.

Word Class

Let’s create a Word class and get a list of words, and then move quickly to a WordCollection.

    def test_word_class_exists(self):
        word = Word("abate")

That should do it. I was thinking about whether to check the Word being created for having five letters and such. I think not. We’ll see if we regret that. This is enough to drive out a class.

Oh, I forgot to put this under Git. Let me do that right here and now. That done, I let PyCharm create a Word class right in the test file (for now) and make the test run:

class Word:
    def __init__(self, word):
        self.word = word

I think I’ll move directly to the WordCollection while I’m at it.

    def test_drive_word_collection(self):
        w1 = Word("avast")
        w2 = Word("matey")
        words = WordCollection()
        words.append(w1)
        words.append(w2)
        assert len(words) == 2

This might be a bit ambitious but seems attainable.

class WordCollection:
    def __init__(self):
        self.words = []

    def append(self, word):
        self.words.append(word)

    def __len__(self):
        return len(self.words)

Green. I’m already at least one commit behind. Commit: Word and WordCollection initial classes.

Let’s get some statistics for fun, like most frequent letters or some such.

    def test_frequency(self):
        from collections import defaultdict
        def default_value():
            return 0
        freq = defaultdict(default_value)
        letters = "aaaabbbccd"
        for c in letters:
            freq[c] += 1
        assert freq["a"] == 4
        assert freq["d"] == 1

Here I try out the default dictionary from collections. It does what I expected.

Let’s get a list of words from a file. How should we do that? Should we have a method on WordCollection? Sure, why not: it implicitly knows that it has Word instances in it.

    def test_words_from_file(self):
        wc = WordCollection.from_file("valid_solutions.txt")
        from collections import defaultdict
        def default_value():
            return 0
        freq = defaultdict(default_value)
        for word in wc:
            for c in word.word:
                freq[c] += 1
        assert freq["e"] == 0

Remind me to pause after this works. We need to think about things like whether Words can iterate over their characters.

I do this much, when Python informs me that WordCollection isn’t iterable:

    @classmethod
    def from_file(cls, file_name):
        result = cls()
        with open(file_name, "r") as word_file:
            lines = word_file.readlines()
            for word in (Word(line) for line in lines):
                result.append(Word(word))
        return result

I see here on the internet that I need to implement __iter__ and possibly __next__. I think I can just do this, in both WordCollection and word:

class WordCollection:
    def __iter__(self):
        return iter(self.words)

class Word:
    def __iter__(self):
        return iter(self.word)
Note:
I wind up removing some of this iteration, and may remove more of it in the future. I think that putting intelligent queries into the objects is better than iterating them generically from the outside.

The test runs and says:

>       assert freq["e"] == 0
E       assert 1233 == 0

I could just accept that answer, but let’s instead write a test where we know the answer.

Before we do that, let’s move the logic here into a method on WordCollection.

    def test_words_from_file(self):
        wc = WordCollection.from_file("valid_solutions.txt")
        freq = wc.frequencies()
        assert freq["e"] == 1233

    def frequencies(self):
        def default_value():
            return 0
        freq = defaultdict(default_value)
        for word in self.words:
            for c in word.word:
                freq[c] += 1
        return freq

With that in place I no longer need the __iter__ methods, so I remove them. As a rule, I think I’ll do better to put word-specific functions into Word and WordCollection, rather than fall back to iterating over them outside.

Let’s do check the frequencies method, however:

    def test_known_frequencies(self):
        wc = WordCollection(["aaaabbb", "aacc"])
        freq = wc.frequencies()
        assert freq["a"] == 6
        assert freq["b"] == 3
        assert freq["c"] == 1

I decided that I’d like to be able to pass in a list to WordCollection. So let’s make that happen:

class WordCollection:
    def __init__(self, words=None):
        if words:
            self.words = words
        else:
            self.words = []

I think but am not certain that saying words=[] in the init is asking for trouble. If I recall something I’ve read, Python will use the same empty collection, defined at compile time, for all the instances of WordCollection. That would be bad. I could be wrong but this will work in any case.

Oh Farquhar!1
I told you I should take a break. I’m going too fast. I don’t want a list of bare strings in there. Let me unwind this and do it more straighforwardly. (Is that a word? It is now.)
    def test_known_frequencies(self):
        wc = WordCollection()
        for string in ["aaaabbb", "aacc"]:
            wc.append(Word(string))
        freq = wc.frequencies()
        assert freq["a"] == 6
        assert freq["b"] == 3
        assert freq["c"] == 2

I had to correctly count the number of “c” characters in my test. It is two, not one. Test passes. I now believe the frequencies method.

Commit: frequencies.

Break and Reflect

I think I got ahead of myself a bit. Started taking bigger steps and it was definitely a mistake to think of passing in a list of strings without thinking about converting them to words. We might well want to do that, since readlines does return a list of strings, but it should be something that has had at least a modicum of consideration.

That aside, I think we are OK. Let’s get back to it.

Back To It

What do I really want from frequencies? From a human viewpoint, I would like to know the frequencies in order most frequent to least. We presently have a dictionary.

Let’s just print the frequencies. We have a dictionary from letter to count. We want to print letter, count, in order of count, highest first.

This test:

    def test_words_from_file(self):
        wc = WordCollection.from_file("valid_solutions.txt")
        freq = wc.frequencies()
        assert freq["e"] == 1233
        counts = [(c, freq[c]) for c in freq.keys()]
        ordered = sorted(counts, key=lambda pair: pair[1], reverse=True)
        for c, f in ordered:
            print(f"{c:s}: {f:4d}")
        assert False

Provides this result:

: 2315
e: 1233
a:  979
r:  899
o:  754
t:  729
l:  719
i:  671
s:  669
n:  575
c:  477
u:  467
y:  425
d:  393
h:  389
p:  367
m:  316
g:  311
b:  281
f:  230
k:  210
w:  195
v:  153
z:   40
x:   37
q:   29
j:   27

That first one? Probably a newline. We have not trimmed our words and readlines surely includes the newline at the end.

There is a better way to read, according to the Internet:

class WordCollection:
    @classmethod
    def from_file(cls, file_name):
        result = cls()
        with open(file_name, "r") as word_file:
            lines = word_file.read().splitlines()
            for word in (Word(line) for line in lines):
                result.append(Word(word))
        return result
Note:
The above code wraps a Word in a Word. It is wrong. Somewhere down below I fix that. I had stopped writing and was thrashing at the time. This was part of my confusion.

That gives me the list I want. Commit: test checking letter frequencies.

I’ve been at this for 2 hours and 45 minutes so far. Not a record but getting time for a break. I’m not really tired yet.

Let’s break out the files for the classes. PyCharm will move them for me. Easy. Commit: break out files.

Let’s take a cut at scoring a guess against a solution. The game looks like this:

wordle game showing uncolored, yellow, and green letters

When you make a guess, a letter’s background will stay dark if the letter does not appear in the solution, turn yellow if it appears in a position other than the one you have it in, and turn green if it is in the same position in the solution as in your guess.

So, I suppose, our score should be an array of 5 somethings, indicating not present, present elsewhere, and present where we looked.

I’ll need a very fast way of doing this, I expect, but first I need a way of doing it at all.

    def test_score(self):
        guess = Word("abcde")
        solution = Word("ecbdx")
        score = guess.score(solution)
        assert score == [0, 1, 1, 2, 1]

I’ll start by supposing that we return a list. I expect we’ll want some kind of object here but let’s get started simply.

I think those are the right numbers. (They weren’t, but they are now.)

    def score(self, solution):
        # word:  abcde
        # sol:   ecbdx
        # score: 01121
        score = [0, 0, 0, 0, 0]
        sol = solution.word
        for i, c in enumerate(self.word):
            if sol[i] == c:
                score[i] = 2
            elif c in sol:
                score[i] = 1
        return score

The test passes. I think the code is right. Commit: Word.score(solution)

Maybe one more thing. I assume that all the legal solutions are in the big word list, but I don’t know that for sure. Let’s check.

This took me long enough to tell me that I need to break. Here’s my test:

    def test_all_solutions_in_guesses(self):
        sols = WordCollection.from_file("valid_solutions.txt")
        guesses = WordCollection.from_file("valid_guesses.txt")
        for solution_word in sols:
            assert isinstance(solution_word, Word)
            assert guesses.has_word(solution_word), f"guesses does not include {solution_word}"

And here is has_word:

    def has_word(self, word):
        for my_word in self.words:
            if my_word.word == word.word:
                return True
        return False

We really should implement __eq__ or whatever it is on Word, but my experience thrashing with this tells me that it is break time. Commit: has_word works and needs improvement.

Let’s sum up.

Summary

I think we have some good stuff here, and it is all rather well tested. We have:

  • learned how to read files in Python. (Yes, very rudimentary but until now I’ve never done it.)
  • created a Word object that can compute its score against another.
  • created a WordCollection to hold and process words, and answer whether another word is in it.
  • discovered that the solution words are not necessarily in the valid words. (We do not yet know whether the sets are disjoint)

This isn’t much, but I think it’s actually nearly enough to play a game of Wordle. We’d have to do the typing and displaying but we kind of know how to do that.

What Ken has been working on, and therefore what I’ll be working on, is driving out information that leads to a good set of words to guess, and a strategy for playing. In doing that, I am sure that we’ll wind up with code that knows things and can do things that an unassisted human cannot. That’ll be fine.

What is not clear to me, or to the others on the Zoom, is whether / when one should try to guess letters that have lots of matches in the possible solutions, and when to guess letters that have very few. What one wants, of course, is to narrow down the list of possibilities rapidly. The objective, I think, is to have an average game cycle of between three and four guesses, and ideally (with perfect play) to guess all Wordles inside six tries.

It seems wrong to base one’s guesses on knowledge of the possible answers, so we’ll have to dump the possible answers in with the guesses. I guess.

Now for some thinking. See you next time!



  1. One of my favorite epithets. When I am trying to draw something and fail, I sign the picture “Farquhar”. I mean no insult to my Scottish brethren but note the pronunciation is much like an English word which I am sure that none of you know.