Selection of guesses
The task described in the discourse discussion mentioned in the previous tutorial was to determine an optimal first guess in Wordle, using the criterion of minimizing the expected pool size after the guess is scored.
First attach the packages that will be used
begin
using CairoMakie # graphics package
using Chain # sophisticated pipes
using DataFrameMacros # convenient syntax for df operations
using DataFrames
using PlutoUI # User Interface components
using Primes # prime numbers
using Random # random number generation
using StatsBase # basic statistical summaries
using Wordlegames
end
In the Wordle game shown on the Wikipedia page the first guess is "arise".
PlutoUI.Resource(
"https://upload.wikimedia.org/wikipedia/commons/thumb/e/ec/Wordle_196_example.svg/440px-Wordle_196_example.svg.png",
)
Before the guess is scored the size of the target pool is 2315. The score for this guess in this game is 01011 as a base-3 number or 31 as a decimal number. Of all the targets in the target pool, only 20 will return this score.
To verify this, first create a GamePool from the wordle targets.
begin
datadir = joinpath(pkgdir(Wordlegames), "data")
wordle = GamePool(collect(readlines(joinpath(datadir, "Wordletargets.txt"))))
end;
Then determine the index of "arise" in the guess pool.
only(findall(x -> x == ('a', 'r', 'i', 's', 'e'), wordle.guesspool))
106
Here, findall returns a vector of all positions in wordle.guesspool that return true from the anonymous function checking if the argument, x, is equal to ('a', 'r', 'i', 's', 'e'). The only function checks that there is only one such index and, if so, returns it.
The anonymous function to compare an element of wordle.guesspool to ('a','r','i','s','e') can be written more compactly as ==(('a','r','i','s','e')).
The score for the guess "arise" on the target "rebus" is 31 as a decimal number.
Int(wordle.allscores[only(findall(==(('r', 'e', 'b', 'u', 's')), wordle.guesspool)), 106])
31
Next, check how many of the pre-computed scores in the 106th column of wordle.allscores are equal to 31.
sum(==(31), view(wordle.allscores, :, 106))
20
A view provides access to a subarray of an array without copying the contents. In this case the subarray is all the rows (the : argument in the rows position) and the 106th column. The comparison function ==(31) will return true or false, values that will be converted for 1 or 0 for the summation function sum. Thus sum(==(31), v) returns the number of elements of v that are equal to 31.
The distribution of scores for a guess
In this case the first guess reduced the size of the target pool from 2315 to 20, after this guess was scored. Ideally we want a guess to reduce the size of the target pool as much as possible but we don't know what the score is going to be. However, we can evaluate the distribution of pool sizes that will result from a particular guess.
To do this we "bin" the scores for a guess on the active targets into the 243 possible values for an NTuple{5,Char}.
bincounts!(wordle, 106).counts
243-element Vector{Int64}:
168
121
61
80
41
17
17
⋮
0
0
0
0
0
1
The i'th element of this vector is the number of targets that will give a score of i - 1 for guess = wordle.guesspool[106], which is "arise"
The most common score is 0 which is returned for 168 of the 2315 targets currently in the target pool.
sum(iszero, view(wordle.allscores, :, 106))
168
Collecting the bin sizes and the corresponding scores in a data frame allows us to sort them by decreasing count size and eliminate the scores that give counts of zero.
df106 = @chain DataFrame(score=tiles.(0:242, 5), counts=wordle.counts) begin
@subset(:counts > 0)
sort(:counts; rev=true)
end
| score | counts |
|---|---|
| "🟫🟫🟫🟫🟫" | 168 |
| "🟨🟫🟫🟫🟫" | 154 |
| "🟫🟫🟫🟫🟨" | 121 |
| "🟫🟫🟨🟫🟫" | 107 |
| "🟫🟨🟫🟫🟨" | 100 |
| "🟫🟫🟫🟨🟫" | 80 |
| "🟨🟫🟫🟫🟨" | 79 |
| "🟫🟨🟫🟫🟫" | 64 |
| "🟨🟨🟫🟫🟫" | 62 |
| "🟫🟫🟫🟫🟩" | 61 |
| ... | |
| "🟩🟩🟩🟩🟩" | 1 |
A bar plot of the bin sizes, ordered from largest to smallest is
barplot(df106.counts)
The Wordlegames package provides two algorithms of choosing a guess based on the distribution of the scores.
function optimalguess(gp::GamePool{N,S,MaximizeEntropy}) where {N,S}
gind, xpctd, entrpy = 0, Inf, -Inf
for (k, a) in enumerate(gp.active)
if a
thisentropy = entropy2(bincounts!(gp, k))
if thisentropy > entrpy
gind, xpctd, entrpy = k, expectedpoolsize(gp), thisentropy
end
end
end
return gind, xpctd, entrpy
end
function optimalguess(gp::GamePool{N,S,MinimizeExpected}) where {N,S}
gind, xpctd, entrpy = 0, Inf, -Inf
for (k, a) in enumerate(gp.active)
if a
thisexpected = expectedpoolsize(bincounts!(gp, k))
if thisexpected < xpctd
gind, xpctd, entrpy = k, thisexpected, entropy2(gp)
end
end
end
return gind, xpctd, entrpy
end
The first method is to maximize the entropy of the distribution, which is an information-theory concept that measures how "spread out" the distribution is. It depends only on the probabilities of the scores, not on the scores themselves. The base-2 entropy, measured in bits, of a discrete distribution with probabilities $p_i, i=1,\dots,n$ is defined as
$$H_2(X) = - \sum_{i=1}^n p_i\,\log_2(p_i)$$
The Wordlegames package exports the entropy2 function that returns this quantity from the current counts.
function entropy2(counts::AbstractVector{<:Real})
countsum = sum(counts)
return -sum(counts) do k
x = k / countsum
xlogx = x * log(x)
iszero(x) ? zero(xlogx) : xlogx
end / log(2)
end
entropy2(gp::GamePool) = entropy2(gp.counts)
entropy2(wordle.counts)
5.820939700886001
or, equivalently
entropy2(bincounts!(wordle, 106))
5.820939700886001
The
... do k
...
end
block, called a "thunk", in this code - yet another way of writing an anonymous function - is described later.
Roughly, the numerical result means that the distribution of target pool sizes after an initial guess of "arise" is, according to this measure, about as spread out as a uniform distribution on 56.5 possible responses.
2^(entropy2(wordle))
56.529800516800876
The second method is to minimize the expected pool size after the guess is scored.
By definition this is the sum of the bin size (or count) for each of the bins multiplied by the probability of the target being in the bin. But that probability is the bin size divided by the total number of active targets. Thus the expected pool size after the guess can be evaluated from the bin sizes alone.
function expectedpoolsize(gp::GamePool)
return sum(abs2, gp.counts) / sum(gp.counts)
end
sum(abs2, wordle.counts) / sum(wordle.counts) # abs2(x) returns x * x
63.72570194384449
which is available as expectedpoolsize
expectedpoolsize(bincounts!(wordle, 106))
63.72570194384449
This is a measure of how successful an initial guess of "arise" will be. On average it will reduce the target pool size from 2315 to 63.73.
The best initial guess?
We can choose an initial guess (and, also, subsequent guesses) to maximize the entropy of the distribution of scores or to minimize the expected pool size for the next guess.
For both of these criteria, a slight modification on "arise", exchanging the first two letters to form "raise", at index 1535, is a bit better than "arise".
string(wordle.guesspool[1535]...)
"raise"
entropy2(bincounts!(wordle, 1535))
5.877909690821478
expectedpoolsize(wordle)
61.00086393088553
It turns out that "raise" is the best initial guess for both of these criteria, if we restrict outselves to guesses from the initial target pool.
One of the parameters of the GamePool type is the method of choosing the next guess, either MaximizeEntropy, the default, or MinimizeExpected,
typeof(wordle)
GamePool{5, UInt8, MaximizeEntropy}
allowing for automatic game play.
showgame!(wordle, "rebus")
| poolsz | index | guess | expected | entropy | score | sc |
|---|---|---|---|---|---|---|
| 2315 | 1535 | "raise" | 61.0009 | 5.87791 | "🟩🟫🟫🟨🟨" | 166 |
| 2 | 1558 | "rebus" | 1.0 | 1.0 | "🟩🟩🟩🟩🟩" | 242 |
That game ended suspiciously quickly but notice that, after the first guess, "raise", is scored as 🟩🟫🟫🟨🟨 in tiles or 166 in decimal, the target pool size is reduced to 2,
[string(wordle.targetpool[i]...) for i in findall(==(166), view(wordle.allscores, :, 1535))]
2-element Vector{String}:
"rebus"
"reset"
giving a 50% chance of a correct second guess.
In the case of ties like this the target with the lowest index in the targetpool is returned. This strategy can result in long series of guesses trying to isolate a single letter if that letter is toward the end of the alphabet
showgame!(wordle, "watch")
| poolsz | index | guess | expected | entropy | score | sc |
|---|---|---|---|---|---|---|
| 2315 | 1535 | "raise" | 61.0009 | 5.87791 | "🟫🟩🟫🟫🟫" | 54 |
| 91 | 2012 | "tangy" | 7.48352 | 4.03061 | "🟨🟩🟫🟫🟫" | 135 |
| 13 | 334 | "caput" | 2.84615 | 2.4997 | "🟨🟩🟫🟫🟨" | 136 |
| 5 | 160 | "batch" | 3.4 | 0.721928 | "🟫🟩🟩🟩🟩" | 80 |
| 4 | 959 | "hatch" | 2.5 | 0.811278 | "🟫🟩🟩🟩🟩" | 80 |
| 3 | 1102 | "latch" | 1.66667 | 0.918296 | "🟫🟩🟩🟩🟩" | 80 |
| 2 | 1206 | "match" | 1.0 | 1.0 | "🟫🟩🟩🟩🟩" | 80 |
| 1 | 2233 | "watch" | 1.0 | -0.0 | "🟩🟩🟩🟩🟩" | 242 |
but it is not clear that any other strategy will be more successful across all possible targets. (This target did occur on the official Wordle web site in March of 2022.)
To play by the MinimizeExpected strategy requires specifying this as the guesstype when creating the GamePool.
wordlexpct = GamePool(
collect(readlines(joinpath(datadir, "Wordletargets.txt")));
guesstype=MinimizeExpected,
);
showgame!(wordlexpct, "rebus")
| poolsz | index | guess | expected | entropy | score | sc |
|---|---|---|---|---|---|---|
| 2315 | 1535 | "raise" | 61.0009 | 5.87791 | "🟩🟫🟫🟨🟨" | 166 |
| 2 | 1558 | "rebus" | 1.0 | 1.0 | "🟩🟩🟩🟩🟩" | 242 |
showgame!(wordlexpct, "watch")
| poolsz | index | guess | expected | entropy | score | sc |
|---|---|---|---|---|---|---|
| 2315 | 1535 | "raise" | 61.0009 | 5.87791 | "🟫🟩🟫🟫🟫" | 54 |
| 91 | 2012 | "tangy" | 7.48352 | 4.03061 | "🟨🟩🟫🟫🟫" | 135 |
| 13 | 334 | "caput" | 2.84615 | 2.4997 | "🟨🟩🟫🟫🟨" | 136 |
| 5 | 160 | "batch" | 3.4 | 0.721928 | "🟫🟩🟩🟩🟩" | 80 |
| 4 | 959 | "hatch" | 2.5 | 0.811278 | "🟫🟩🟩🟩🟩" | 80 |
| 3 | 1102 | "latch" | 1.66667 | 0.918296 | "🟫🟩🟩🟩🟩" | 80 |
| 2 | 1206 | "match" | 1.0 | 1.0 | "🟫🟩🟩🟩🟩" | 80 |
| 1 | 2233 | "watch" | 1.0 | -0.0 | "🟩🟩🟩🟩🟩" | 242 |
There are no differences between the two strategies in these games.
However, if we play all possible games using each of the two strategies and count the number of guesses to solution we can see that the two strategies do not always give the same length of game.
gamelen = let
inds = axes(wordle.targetpool, 1)
DataFrame(;
index=inds,
entropy=[length(playgame!(wordle, k).guesses) for k in inds],
expected=[length(playgame!(wordlexpct, k).guesses) for k in inds],
)
end
| index | entropy | expected |
|---|---|---|
| 1 | 3 | 3 |
| 2 | 3 | 3 |
| 3 | 3 | 3 |
| 4 | 4 | 4 |
| 5 | 3 | 4 |
| 6 | 4 | 3 |
| 7 | 3 | 3 |
| 8 | 4 | 4 |
| 9 | 3 | 3 |
| 10 | 3 | 3 |
| ... | ||
| 2315 | 5 | 4 |
For example,
showgame!(wordle, 5)
| poolsz | index | guess | expected | entropy | score | sc |
|---|---|---|---|---|---|---|
| 2315 | 1535 | "raise" | 61.0009 | 5.87791 | "🟫🟨🟫🟫🟫" | 27 |
| 92 | 766 | "float" | 4.54348 | 4.86323 | "🟫🟫🟨🟨🟩" | 14 |
| 1 | 5 | "abbot" | 1.0 | -0.0 | "🟩🟩🟩🟩🟩" | 242 |
is different from
showgame!(wordlexpct, 5)
| poolsz | index | guess | expected | entropy | score | sc |
|---|---|---|---|---|---|---|
| 2315 | 1535 | "raise" | 61.0009 | 5.87791 | "🟫🟨🟫🟫🟫" | 27 |
| 92 | 414 | "cloak" | 4.17391 | 4.64926 | "🟫🟫🟨🟨🟫" | 12 |
| 5 | 88 | "annoy" | 1.0 | 2.32193 | "🟩🟫🟫🟩🟫" | 168 |
| 1 | 5 | "abbot" | 1.0 | -0.0 | "🟩🟩🟩🟩🟩" | 242 |
The mean and standard deviation of the game lengths are smaller when maximizing the entropy than when minimizing the expected pool size.
describe(gamelen[!, [:entropy, :expected]], :min, :max, :mean, :std)
| variable | min | max | mean | std |
|---|---|---|---|---|
| :entropy | 1 | 8 | 3.59914 | 0.849016 |
| :expected | 1 | 8 | 3.62462 | 0.857827 |
The counts of the game lengths under the two strategies and a comparative barplot show the shift toward shorter game lengths when maximizing the entropy.
gamelengths = let
entropy = countmap(gamelen.entropy)
expected = countmap(gamelen.expected)
allcounts = 1:maximum(union(keys(entropy), keys(expected)))
DataFrame(;
count=allcounts,
entropy=[get!(entropy, k, 0) for k in allcounts],
expected=[get!(expected, k, 0) for k in allcounts],
)
end
| count | entropy | expected |
|---|---|---|
| 1 | 1 | 1 |
| 2 | 131 | 131 |
| 3 | 999 | 957 |
| 4 | 919 | 946 |
| 5 | 207 | 224 |
| 6 | 47 | 42 |
| 7 | 9 | 11 |
| 8 | 2 | 3 |
let
stacked = stack(gamelengths, 2:3)
typeint = [(v == "entropy" ? 1 : 2) for v in stacked.variable]
barplot(
stacked.count,
stacked.value;
dodge=typeint,
color=typeint,
axis=(xticks=1:8, xlabel="Game length", ylabel="Number of targets"),
)
end
Wordle-like games
Wordle has spawned many similar games, one of which is Primel, where the targets are 5-digit prime numbers. Because leading zeros are not allowed in these primes, the targets are prime numbers between 10,000 and 99,999.
primel = GamePool(primes(10_000, 99_999)); # underscores are ignored in numbers
To play a game with a random, but reproducible, target, we initialize a random number generator and pass it as the second argument to showgame!.
showgame!(primel, Random.seed!(1234321))
| poolsz | index | guess | expected | entropy | score | sc |
|---|---|---|---|---|---|---|
| 8363 | 313 | "12953" | 124.384 | 6.63227 | "🟨🟨🟫🟫🟫" | 108 |
| 201 | 1141 | "21067" | 5.92537 | 5.47937 | "🟨🟨🟫🟨🟨" | 112 |
| 10 | 3556 | "46271" | 1.2 | 3.12193 | "🟩🟩🟩🟩🟩" | 242 |
The size of the target pool is larger than for Wordle
length(primel.targetpool)
8363
but the number of possible characters at each position (9 for the first position, 10 for the others) is smaller than for Wordle, leading to a larger mean number of guesses but a smaller standard deviation in the number of guesses.
As for Wordle, the strategy of choosing guesses to minimize the expected pool size is less effective than maximizing the entropy.
primelxpectd = GamePool(primes(10_000, 99_999); guesstype=MinimizeExpected);
allprimel = let
inds = 1:length(primel.targetpool)
DataFrame(;
index=inds,
entropy=[length(playgame!(primel, k).guesses) for k in inds],
expected=[length(playgame!(primelxpectd, k).guesses) for k in inds],
)
end
| index | entropy | expected |
|---|---|---|
| 1 | 4 | 4 |
| 2 | 4 | 4 |
| 3 | 3 | 4 |
| 4 | 3 | 4 |
| 5 | 3 | 4 |
| 6 | 3 | 3 |
| 7 | 4 | 4 |
| 8 | 4 | 4 |
| 9 | 4 | 4 |
| 10 | 3 | 4 |
| ... | ||
| 8363 | 5 | 4 |
primelengths = let
entropy = countmap(allprimel.entropy)
expected = countmap(allprimel.expected)
allcounts = 1:maximum(union(keys(entropy), keys(expected)))
DataFrame(;
count=allcounts,
entropy=[get!(entropy, k, 0) for k in allcounts],
expected=[get!(expected, k, 0) for k in allcounts],
)
end
| count | entropy | expected |
|---|---|---|
| 1 | 1 | 1 |
| 2 | 215 | 209 |
| 3 | 3173 | 2743 |
| 4 | 4477 | 4797 |
| 5 | 482 | 589 |
| 6 | 15 | 24 |
describe(allprimel[!, Not(1)], :min, :max, :mean, :std)
| variable | min | max | mean | std |
|---|---|---|---|---|
| :entropy | 1 | 6 | 3.63004 | 0.641331 |
| :expected | 1 | 6 | 3.69784 | 0.647833 |
let
stacked = stack(primelengths, 2:3)
typeint = [(v == "entropy" ? 1 : 2) for v in stacked.variable]
barplot(
stacked.count,
stacked.value;
dodge=typeint,
color=typeint,
axis=(xticks=1:8, xlabel="Game length", ylabel="Number of targets"),
)
end
Some Julia syntax used in this code
Several Julia syntax features have been used in the code for this tutorial. For example, the code block defining gamelengths is a let block. This is similar to a begin/end block in that it groups multiple expressions, including assignments, so that they function as a single expression evaluation. The difference between let and begin is that assignments within a let block are local to the block.
For example, allcounts is given a value within that block because it is used in several places when creating the DataFrame but it is not needed outside that block.
Notice also the expressions like get!(entropy, k, 0). This is extraction by key from a collection, like entropy[k] or, equivalently, getindex(entropy, k) except that it provides a default, 0 in this case, if there is no key k in the collection. Furthermore, it modifies the collection by inserting the default value for key k.
An ellipsis, "...", is used with arguments as in string(wordle.guesspool[1535]...). This use is called a "splat" (and there is another use of an ellipsis called a "slurp" - the designers of this language are very serious-minded folk). As a "splat" the ellipsis expands an argument such as a vector or, in this case, the tuple ('r','a','i','s','e') to multiple arguments, in this case, 5 Char arguments.
Another fun name for a construct is a "thunk", which is a way of specifying an anonymous function. For example there are two methods defined for the entropy2 generic
function entropy2(counts::AbstractVector{<:Real})
countsum = sum(counts)
return -sum(counts) do k
x = k / countsum
xlogx = x * log(x)
iszero(x) ? zero(xlogx) : xlogx
end / log(2)
end
entropy2(gp::GamePool) = entropy2(gp.counts)
In the first method we wish to evaluate $-\sum_{i}p_i\,\log_2(p_i)$ which is sometimes called an xlogx function. There is a sum(f, itr) method where f is a function and itr is an iterator, such as an AbstractVector. In this case we want a function that evaluates x = k / countsum then xlogx(x) but xlogx requires some care. If x is zero, the result should be zero but of the same type as x * log(x) for non-zero x. That's why x * log(x) is evaluated first - to get the value type. It will return NaN for x = 0, which is then converted to a zero but of the type that is consistent with the other values of xlogx.
For example, to evaluate the base-2 entropy for the initial guess in the BigFloat extended precision type, we convert from
bincounts!(reset!(wordle), 1535); # reset the game to the initial state
entropy2(wordle.counts)
5.877909690821478
to
entropy2(big.(wordle.counts))
5.877909690821480658631076345837703704414854243834085634605204301304453462519103
The second method definition for entropy2 shows the compact form for defining "one-liner" methods. It is a common idiom to have one method for a generic function that "does the work" and others that simply re-arrange the arguments to the form required by this "collector" method.
Built with Julia 1.7.2 and
CairoMakie 0.7.5Chain 0.4.10
DataFrameMacros 0.2.1
DataFrames 1.3.2
PlutoUI 0.7.38
Primes 0.5.2
StatsBase 0.33.16
Wordlegames 0.3.0
To run this tutorial locally, download this file and open it with Pluto.jl.