TLDR: Escrevi um bot solucionador do Wordle com Javascript e UIlicious. Você pode reexecutar ou editar este snippet a qualquer dia para obter sua solução Wordle diária. Tente e veja se consegue uma pontuação melhor do que o bot! Sinta-se à vontade para editá-lo e otimizar o algoritmo do solucionador!
Divulgação completa: sou o cofundador e CTO da Uilicious.com (apresentado neste artigo)
O solucionador de palavras é coberto em 3 partes, que cobrem o
- Código de interação da interface do usuário (link aqui)
- Modelo estatístico Wordle e a matemática por trás dele (este artigo)
- Teste de unidade e benchmarking do wordle solver (@todo)
Todos os exemplos estatísticos podem ser encontrados no seguinte link: https://uilicio.us/wordle-statistics-sample E é gerado via código utilizado aqui: https://github.com/uilicious/wordle-solver-and-tester
Isenção de responsabilidade, não reivindico que esta seja a melhor estratégia do WORDLE (ainda), mas é bastante boa =)
Antes de entrar nas estatísticas, vamos primeiro cobrir nossa estratégia WORLDE.
Uma coisa que os humanos fazem muito bem e os computadores clássicos são ruins é descobrir as coisas "intuitivamente". A menos que eu planeje treinar uma rede neural, o programa de computador que estou desenvolvendo precisará usar uma lista clássica de palavras do dicionário para fazer suas suposições.
A única coisa que os computadores fazem bem, entretanto, é memorizar listas gigantes de palavras ou dados. E realizando matemática nele. Então, vamos usar isso a nosso favor fazendo a seguinte sequência.
Dada a lista de 2 palavras, uma cheia de respostas possíveis (~2.3k palavras) e outra com a lista completa de palavras (13k palavras)...
Filtre as palavras na lista de respostas possíveis contra o estado atual do jogo a partir de suposições anteriores.
Conte o número de vezes que cada caractere aparece na lista de palavras de resposta em suas respectivas posições de palavras.
Na lista completa de palavras, escolha a palavra com maior probabilidade de encontrar um palpite de caractere correto. Pontue de forma independente, preferindo palavras que fornecem mais informações nas primeiras 4 rodadas com uma correspondência exata ou parcial.
Escolha a palavra com maior pontuação e experimente.
Repita a partir do topo, se necessário.
Também para esclarecer: Não memorizamos as soluções anteriores do Wordle (senti que era trapaça, pois o sistema poderia acabar apenas memorizando a lista do dia-a-dia em sequência).
Enquanto os detalhes exatos da pontuação mudam ligeiramente dependendo da rodada - para otimizar as coisas. Não altera o conceito geral em alto nível.
Então, como isso funciona na prática? Para nossa iteração atual da estratégia Wordle, veremos isso na prática passo a passo (sem código).
Um pseudônimo de sain, significando: fazer o sinal da cruz (a si mesmo) para abençoar ou proteger do mal ou do pecado
Tem havido muitos conselhos sobre as 2 palavras iniciais. E faz sentido que esta palavra siga com:
Mas vamos descobrir por que o solucionador escolhe essa palavra olhando para os números.
Com base nas estatísticas. "SAINE" tem a maior chance de encontrar uma correspondência exata de caractere verde como palavra de abertura, embora tenha 3 das principais vogais.
A leitura da tabela de distribuição bruta pode ser compreensivelmente difícil de digerir. Então deixe-me recontextualizar esses números aqui. A SAINE tem…
Há uma chance muito alta de obter pelo menos uma ou duas pistas principais. Inversamente, como há poucas palavras sem A, I e E, a falta de correspondência é uma "grande pista".
Nada mal para uma abertura hein?
E quanto a outras palavras de abertura populares, como "CRANE" e "ADEPT"?
A única vantagem importante para "CRANE / ADEPT" é que ambos têm 0,04% de chance de adivinhar com sucesso uma palavra. Suspeito que a falha da análise pública anterior foi como eles estavam limitando a palavra de abertura à lista de respostas conhecidas. Acredito, no entanto, que deveríamos usar a lista completa de palavras, para maximizar as probabilidades de pistas em favor de uma chance muito estreita de adivinhar uma palavra.
Mais importante ainda, o SAINE tem uma chance significativamente maior (cerca de 7%) de adivinhar uma correspondência exata (verde) na primeira tentativa. O que é incrivelmente útil como uma pista.
Com o debate da palavra inicial resolvido, veremos como o sistema reage com os diferentes resultados!
Vejamos então como a 2ª palavra é escolhida para a resposta "PAUSE" (lado esquerdo).
Nos são dadas as seguintes informações:
Letra I & N não está na palavra.
A é o 2º caractere, E é o 5º caractere.
S é o 3º ou 4º caractere (não é o 1º caractere).
Apenas 12 respostas possíveis estão na lista de palavras
Coisas bastante comuns do Wordle. Mas vamos ver como isso afeta as estatísticas nas respostas possíveis restantes. E como “PLUTÃO” foi escolhido.
Com apenas 12 palavras restantes, vamos encontrar a maneira mais eficiente de eliminar nossas opções.
Das seguintes restrições, a única palavra válida para tentar era PLUTO. Embora saibamos que a letra O não está na resposta final, não existe uma palavra para “PLUTC”. Além disso, embora a palavra PLUTO não estivesse na lista de respostas, ela estava na lista completa de palavras, tornando-a uma suposição válida.
Após o envio, o sistema agora sabe o seguinte:
Isso significa que não preciso mais de estatísticas porque só há uma verdade: PAUSE.
O Mohur é uma moeda de ouro que foi anteriormente cunhada por vários governos, incluindo a Índia Britânica e alguns dos estados principescos que existiam ao lado dela.
As estatísticas aqui estão sendo avaliadas ao vivo e as mudanças dependem do resultado de cada rodada. Portanto, o resultado diferiu onde:
Letra S, A, I, N não está na palavra.
A letra E pode estar nas posições 1, 2, 3, 4, mas não na 5.
Filtrar a lista de possíveis respostas leva às seguintes estatísticas:
Isso pode não fazer sentido a princípio porque a palavra não começa com C ou B. Com 187 palavras restantes, é aqui que os detalhes da pontuação começam a importar.
Esta pode não ser a escolha ideal dada a lista de respostas conhecida. No entanto, isso é intencional porque queremos nos concentrar nas informações novamente até termos uma confiança muito alta em uma palavra. E, nesse processo, penalizaríamos cartas duplicadas para focar na redução do número de opções. (Sem dúvida, há espaço para melhorias aqui)
Os resultados do palpite foram interessantes.
Por mais estranha e confusa que fosse a palavra MOHUR, o resultado reduziu a possibilidade para 12 palavras. Mais uma vez, ele tenta priorizar a tentativa de novos personagens e dar a palavra obscura de BLYPE.
BLYPE: um pedaço ou fragmento de pele
Esta palavra reduz a lista de possibilidades a uma palavra - ÚLCERA, que é a resposta final.
Nota lateral: se você perceber, ele está disposto a experimentar personagens que sabe que não estão na lista de respostas oficial. Isso é intencional. Leve em consideração os clones do Wordle porque, se a resposta real escolhida não estiver na lista de respostas original, o sistema voltará automaticamente a usar a lista de palavras completa. Tornando isso mais resistente contra variantes do Wordle.
⚠️ Aviso de código à frente: se você estava aqui apenas para matemática e estatísticas, pule para o final. O restante do conteúdo deste artigo consiste em código JS.
A aula completa de resolução pode ser encontrada aqui .
Este artigo se concentrará na funcionalidade principal necessária para fazer esse processo funcionar e não em todas as partes do código. Se você não leu a Parte 1, leia aqui .
O código aqui foi simplificado para ignorar o material "padrão".
O solucionador precisa fazer o seguinte:
Vamos decompô-lo, pedaço por pedaço.
Para cada rodada, o estado do jogo seria gerado com:
Isso é gerado usando as informações na tela na parte 1.
No entanto, para nosso caso de uso, precisaríamos normalizar alguns dos conjuntos de dados comuns de que precisaríamos. Por meio da função _normalizeStateObj
, obtemos o seguinte.
Isso é facilmente gerado iterando .history
, e os dados .pos
para construir a lista de bons caracteres primeiro. Em seguida, use isso para construir a lista de caracteres ruins inversamente em relação à lista de palavras históricas.
/** * Given the state object, normalize various values, using the minimum "required" value. * This does not provide as much data as `WordleSolvingAlgo` focusing on the minimum required * to make the current system work * * @param {Object} state * * @return {Object} state object normalized */ function _normalizeStateObj( state ) { // Setup the initial charset state.badCharSet = new Set(); state.goodCharSet = new Set(); // Lets build the good charset for(let i=0; i<state.wordLength; ++i) { if( state.pos[i].foundChar ) { state.goodCharSet.add(state.pos[i].foundChar); } for(let char of state.pos[i].hintSet) { state.goodCharSet.add(char); } } // Lets iterate history and build badCharSet for(let i=0; i<state.history.length; ++i) { const word = state.history[i]; for( let w=0; w<word.length; ++w ) { // check the individual char let char = word.charAt(w); // If char is not in good set if( !state.goodCharSet.has(char) ) { // its in the bad set state.badCharSet.add(char); } } } // Return the normalize state object return state; }
Agora que temos o estado atual do jogo, vamos filtrar a lista de palavras:
/** * Given the wordList, filter only for possible answers, using the state object. * And returns the filtered list. This function just returns the wordList, if state == null * @param {Array<String>} wordList * @param {Object} state */ function filterWordList( wordList, state ) { // Skip if its not setup if( state == null || wordList.length <= 0 ) { return wordList; } // Get the word length const wordLength = wordList[0].length; // Filter and return return wordList.filter(function(s) { // Filtering logic // .... // all checks pass, return true return true; }); }
Para a lógica de filtragem, primeiro removemos as palavras dentro do badCharSET.
// filter out invalid words (aka hard mode) for(const bad of state.badCharSet) { // PS : this does nothing if the set is empty if(s.includes(bad)) { return false; } }
Seguido por filtrar as palavras com localizações de dicas erradas:
// filter out words with wrong hint locations, for each character position for(let i=0; i<wordLength; ++i) { // Get the word character let sChar = s.charAt(i); // Check if the chracter, conflicts with an existing found char (green) if(state.pos[i].foundChar && sChar != state.pos[i].foundChar) { return false; } // Check if the character is already a known mismatch (yellow, partial match) // for each position for(const bad of state.pos[i].hintSet) { if(sChar == bad) { return false; } } }
Para as palavras subsequentes sem todas as correspondências encontradas (exatas e parciais):
// filter out words WITHOUT the hinted chars // PS : this does nothing if the set is empty for(const good of state.goodCharSet) { if(!s.includes(good)) { return false; } }
Além disso, temos uma variante para filtrar palavras únicas para filterForUniqueWordList
. Isso não tem duplicatas de personagem e é usado nas primeiras rodadas:
let wordCharSet = new Set(); // iterate the characters for(const char of s) { // Update the word charset wordCharSet.add(char); } // There is duplicate characters if( wordCharSet.size != s.length ) { return false; }
Depois de filtrar todas as respostas possíveis restantes, as estatísticas são geradas por meio charsetStatistics( dictArray )
Isso é feito construindo um objeto para o tipo de estatísticas. Iterar a lista de palavras e incrementar os números:
/** * Analyze the given dictionary array, to get character statistics * This will return the required statistics model, to be used in guessing a word. * * Which is provided in 3 major parts, using an object, which uses the character as a key, followed by its frequency as a number * * - overall : Frequency of apperance of each character * - unique : Frequency of apperance of each character per word (meaning, duplicates in 1 word is ignored) * - positional : An array of object, which provides the frequency of apperance unique to that word position * * Note that because it is possible for the dataset to not have characters in the list / positional location, * you should assume any result without a key, means a freqency of 0 * * @param {Array<String>} dictArray - containg various words, of equal length * * @return Object with the respective, overall / unique / positional stats **/ charsetStatistics( dictArray ) { // Safety check if( dictArray == null || dictArray.length <= 0 ) { throw `Unexpected empty dictionary list, unable to perform charsetStatistics / guesses`; } // The overall stats, for each character let overallStats = {}; // The overall stats, for each unique charcter // (ignore duplicates in word) let overallUniqueStats = {}; // The stats, for each character slot let positionalStats = []; // Lets initialize the positionalStats let wordLen = dictArray[0].length; for(let i=0; i<wordLen; ++i) { positionalStats[i] = {}; } // Lets iterate the full dictionary for( const word of dictArray ) { // Character set for the word const charSet = new Set(); // For each character, populate the overall stats for( let i=0; i<wordLen; ++i ) { // Get the character const char = word.charAt(i); // Increment the overall stat this._incrementObjectProperty( overallStats, char ); // Populate the charset, for overall unique stats charSet.add( char ); // Increment each positional stat this._incrementObjectProperty( positionalStats[i], char ); } // Populate the unique stats for( const char of charSet ) { // Increment the overall unique stat this._incrementObjectProperty( overallUniqueStats, char ); } } // Lets return the stats obj return { overall: overallStats, unique: overallUniqueStats, positional: positionalStats } }
Isso é bastante direto para loops em cada palavra e cada incremento de caractere dentro da respectiva contagem estatística.
A única pegadinha é que não podemos fazer um incremento ++ em uma propriedade de objeto quando ela não está inicializada. Isso resultaria no seguinte erro:
// This will give an exception for // TypeError: Cannot read properties of undefined (reading 'a') let obj; obj["a"]++;
Portanto, precisaríamos usar uma função auxiliar simples para incrementar nosso caso de uso necessário corretamente:
/** * Increment an object key, used at various stages of the counting process * @param {Object} obj * @param {String} key **/ _incrementObjectProperty( obj, key ) { if( obj[key] > 0 ) { obj[key]++; } else { obj[key] = 1; } }
No coração do solucionador está a lógica de pontuação. Que é classificado em cada entrada de palavra possível com as estatísticas e o estado fornecidos.
Isenção de responsabilidade: não afirmo que esta seja a função de pontuação de palavras ideal para o Wordle. Definitivamente pode ser melhorado, mas é muito bom em meus testes até agora. =)
/** * The heart of the wordle solving system. * * @param {Object} charStats, output from charsetStats * @param {String} word to score * @param {Object} state object (to refine score) * * @return {Number} representing the word score (may have decimal places) **/ function scoreWord( charStats, word, state = null ) { // Character set for the word, used to check for uniqueness const charSet = new Set(); // the final score to return let score = 0; // Wordle Strategy note: // // - Penalize duplicate characters, as they limit the amount of information we get // - Priotize characters with high positional score, this helps increase the chances of "exact green matches" early // reducing the effort required to deduce "partial yello matches" // - If there is a tie, in positional score, tie break it with "unique" score and overall score // this tends to be relevent in the last <100 matches // // - We used to favour positional score, over unique score in the last few rounds only // but after several trial and errors run, we found it was better to just use positonal score all the way // Lets do scoring math // ... // Return the score return score; }
Isso passa por vários estágios: primeiro, adicionamos uma rede de segurança para impedir que o sistema sugira uma palavra novamente (pontuação negativa enorme).
// Skip attempted words - like WHY ??? if( state && state.history ) { if( state.history.indexOf(word) >= 0 ) { return -1000*1000; } }
Em seguida, iteramos cada caractere da palavra e os pontuamos, respectivamente:
// For each character, populate the overall stats for( let i=0; i<word.length; ++i ) { // Get the character const char = word.charAt(i); // Does scoring for each character // ... }
Penalizar palavras com caracteres repetidos ou caracteres conhecidos:
// skip scoring of known character matches // or the attempted character hints if( state ) { // Skip known chars (good/found) if( state.pos && state.pos[i].foundChar == char ) { score += -50; charSet.add( char ); continue; } // Skip scoring of duplicate char if( charSet.has( char ) ) { score += -25; continue; } // Skip known chars (good/found) if( state.goodCharSet && state.goodCharSet.has(char) ) { score += -10; charSet.add( char ); continue; } } else { // Skip scoring of duplicate char if( charSet.has( char ) ) { score += -25; continue; } } // Populate the charset, we check this to favour words of unique chars charSet.add( char );
Por fim, calculamos a pontuação para cada estatística posicional com a pontuação de personagem única sendo usada como desempate:
// Dev Note: // // In general - we should always do a check if the "character" exists in the list. // This helps handle some NaN situations, where the character has no score // this is possible because the valid list will include words, that can be inputted // but is not part of the filtered list - see `charsetStatistics` if( charStats.positional[i][char] ) { score += charStats.positional[i][char]*10000; } if (charStats.unique[char]) { score += charStats.unique[char] } // -- Loops to the next char -- //
Agora que temos a função de pontuação, podemos começar a juntar todas as partes para a função "suggestWord".
Temos estatísticas que podem ser usadas para pontuar palavras. Agora, vamos juntá-lo para sugerir a melhor palavra de pontuação.
Começamos recebendo o estado do jogo:
/** * Given the minimum state object, suggest the next word to attempt a guess. * * --- * # "state" object definition * * The solver, requires to know the existing wordle state information so this would consist of (at minimum) * * .history[] : an array of past wordle guesses * .pos[] : an array of objects containing the following info * .hintSet : set of characters that are valid "hints" * .foundChar : characters that are confirmed for the given position * * The above is compliant with the WordleAlgoTester state object format * Additional values will be added to the state object, using the above given information * --- * * @param {Object} state * * @return {String} word guess to perform */ suggestWord( state ) { // Normalize the state object state = this._normalizeStateObj(state); // Let'sLets get the respective wordlist let fullWordList = this.fullWordList; let filteredWordList = this.filterWordList( this.filteredWordList, state ); let uniqueWordList = this.filterForUniqueWords( this.uniqueWordList, state ); // As an object let wordList = { full: fullWordList, unique: uniqueWordList, filtered: filteredWordList }; // Lets do work on the various wordlist, and state // this is refactored as `suggestWord_fromStateAndWordList` // in the code base // .... }
Uma vez que temos os vários estados do jogo e listas de palavras, podemos decidir sobre a "lista de palavras estatísticas", que usamos para gerar o modelo estatístico.
// Let's decide on which word list we use for the statistics // which should be the filtered word list **unless** there is // no possible answers on that list, which is possible when // the system is being used against a WORDLE variant // // In such a case, lets fall back to the filtered version of the "full // word list", instead of the filtered version of the "answer list". let statsList = wordList.filtered; if( wordList.filtered == null || wordList.filtered.length <= 0 ) { console.warn("[WARNING]: Unexpected empty 'filtered' wordlist, with no possible answers : falling back to full word list"); statsList = this.filterWordList( wordList.full, state ); } if( wordList.filtered == null || wordList.filtered.length <= 0 ) { console.warn("[WARNING]: Unexpected empty 'filtered' wordlist, with no possible answers : despite processing from full list, using it raw"); statsList = wordList.full; }
Depois de decidir sobre a lista de palavras de estatísticas, recebemos as estatísticas:
// Get the charset stats const charStats = this.charsetStatistics(statsList);
Agora decidimos a lista de palavras a ser usada para decidir uma palavra. Referimo-nos a esta lista como "scoredList".
Nas primeiras rodadas, pretendemos usar palavras únicas o máximo possível. O que não incluiria caracteres que tentamos anteriormente. Isso pode incluir palavras que sabemos que não estão na lista de respostas possíveis.
Isso é intencional, pois estamos otimizando para obter informações, mas, em vez disso, para uma pequena chance aleatória de sucesso inicial.
No entanto, quando ela estiver vazia ou o jogo estiver nas rodadas finais, retornaremos à lista completa. Durante a rodada final, sempre adivinharemos usando a lista filtrada quando possível: (basta dar a nossa melhor resposta).
// sort the scored list, use unique words in first few rounds let scoredList = wordList.unique; // Use valid list from round 5 onwards // or when the unique list is drained if( scoredList.length == 0 || state.round >= 5 ) { scoredList = wordList.full; } // Use filtered list in last 2 round, or when its a gurantee "win" if( wordList.filtered.length > 0 && // (wordList.filtered.length < state.roundLeft || state.roundLeft <= 1) // ) { scoredList = wordList.filtered; }
Uma vez que decidimos a lista de pontuação para aplicar as estatísticas, vamos pontuar e classificar:
// Self reference const self = this; // Score word sorting scoredList = scoredList.slice(0).sort(function(a,b) { // Get the score let bScore = self.scoreWord( charStats, b, state, finalStretch ); let aScore = self.scoreWord( charStats, a, state, finalStretch ); // And arrange them accordingly if( bScore > aScore ) { return 1; } else if( bScore == aScore ) { // Tie breakers - rare // as we already have score breakers in the algori if( b > a ) { return 1; } else if( a > b ) { return -1; } // Equality tie ??? return 0; } else { return -1; } });
E retorne o item de maior pontuação:
// Return the highest scoring word guess return scoredList[0];
Com o código de interação da interface do usuário feito na parte 1. Clique no botão "Executar" para ver como nosso bot Wordle se sai!
Ei, nada mal, meu bot resolveu o Wordle de hoje!
Porque os bots usarão uma técnica bastante "desumana" de calcular probabilidades com dicionários gigantes.
A maioria dos humanos descobrirá que isso está no limite de fazer suposições realmente estranhas e malucas. Acredite na matemática porque funciona.
Ao jogar pelo time humano, a conclusão deste artigo é que você deve começar com a palavra "SAINE" ou qualquer palavra que desejar.
Cabe a você, pois este é o seu jogo, afinal! =) Divirta-se.
Feliz Wordling! 🖖🏼🚀
Publicado pela primeira vez aqui