はじめに
Wordleは、5文字の英単語を推測するゲームです
6回の推測ターンのうち何回でクリアできたか、また、どの箇所の文字から推測が当たっていったのかをシェアする機能があり、Twitterで多くのシェアを確認することができます
https://twitter.com/search?q=%23Wordle&src=typed_query&f=live
はじめはこれを見ながら、正解するまでのターン数をヒストグラムにしたらどうなるのだろうかと考えたのですが、それはすでに以下のTwitterアカウントできちんと作られています
そこで、新たにアイデアとして、各文字が平均的にどのように推測されているのかというのを可視化してみようと考え、実際のページを作ってみました
できたもの
スコアを選ぶと、そのスコアで正解した人の平均的な文字の埋まり方を可視化します
また、各マスで選ばれた色の確率を次のように見ることもできるようになっています
Pythonのstreamlitで作ったものをHerokuにデプロイしており、Herokuのscheduler(crontabのようなもの)で1時間に1回、最大1万件のツイートを取得しており、(正常に動いていれば)データが更新されます
システム構成
成績データの取得と保存を行う処理と、成績データから平均や確率の計算と可視化を行う処理の2つに分かれています
成績データ取得と保存
Wordleの推測の結果のデータをTwitter APIで取得します。
Wordleは1日1個ずつIDが振られており、そのIDとともにツイートされるので、'"#Wordle 238"'
という文字列で検索を行い、その結果を取得します
class Twitter(): # 検索用のクラス
def __init__(self):
self.OAuth = OAuth1(API_KEY, API_KEY_SECRET, ACCESS_TOKEN, ACCESS_TOKEN_SECRET)
def search_query_by_request(self, query, max_results=10000, next_token=None):
url = 'https://api.twitter.com/2/tweets/search/recent'
#query += " exclude:nativeretweets filter:media"
params = {'query': query, 'max_results': max_results, 'next_token': next_token}
request = requests.get(url, params=params, auth=self.OAuth)
requestJson = json.loads(request.text)
return requestJson
def search_query(self, query, max_results=10000):
data = []
next_token = None
while len(data) < max_results:
res = self.search_query_by_request(query, next_token=next_token)
data += res['data']
if 'next_token' not in res['meta']: break
next_token = res['meta']['next_token']
return {'data': data, 'meta': res['meta']}
t = Twitter()
res = t.search_query(f'"#Wordle {wordle_id}"') # wordle_idは例えば238などの数値
得られたツイート本文のうち、必要なのは四角の絵文字で表される部分だけなので、正規表現等で、⬜🟨🟩 を WYG
の文字列に置き換えます(W: White, Y: Yellow, G: Green)
def parse_colors(text):
p, s = "^^**", "**$$"
text = text\
.replace("⬛️", f"{p}W{s}")\
.replace("⬛", f"{p}W{s}")\
.replace("⬜", f"{p}W{s}")\
.replace("\U0001f7e8", f"{p}Y{s}")\
.replace("\U0001f7e9", f"{p}G{s}")\
.replace("\n", "")
match = re.search("\^\^\*\*.*\*\*\$\$", text)
if match is None: return None
sub_text = re.sub("[\^\*\$]", "", match.group())
if len([None for s in sub_text.strip() if re.match("[^WYG]", s)]) !=0: return None
return sub_text
これを先程取得したツイート全体に適用して配列に格納していきます。
colors = []
for d in res['data']:
color = parse_colors(d['text'])
if color is not None and len(color) % 5 == 0:
colors.append(list(color))
そのようにすると、pd.DataFrame(colors)
としたときに、以下のようなデータが出来上がります。
Herokuでデータを保持しておくために、この状態でcolors
という名前のテーブルでDBに保存しておきます。
from sqlalchemy import create_engine
import sqlalchemy.orm
import sqlalchemy.ext.declarative
engine = create_engine(db_url)
engine.execute('DELETE FROM colors') # 一旦空っぽにする
pd.DataFrame(colors).to_sql("colors", con=engine, if_exists="replace", index=False)
crontabで定期実行するのはここまでです。
平均データと確率データの可視化
ページの表示の際に、平均的な埋まり方と各色の確率の可視化を行う部分を説明します
スコア(「4/6」とか)ごとに結果を分けてみたいため、DBに保存してある成績データのうち、埋まっている文字の数で区切って取り出すメソッドを定義しました。
def set_dataframe_per_score():
engine = create_engine(db_url)
res = engine.execute('SELECT * FROM colors').fetchall()
df = pd.DataFrame(res)
df_by_score = {"All": df}
for score in scores:
df_by_score[f"{score}/6"] = filter_dataframe_by_score(df, score)
return df_by_score
def filter_dataframe_by_score(df, score):
if score == 6:
return df[df["29"]=="G"]
else:
char_len = score * 5
return df[df[f"{char_len-1}"] == "G"][df[f"{char_len}"].isin([None]) & df["29"].isin([None])]
これにより、以下のようにして、スコアごとの結果の一覧が手に入ります
df_by_score = set_dataframe_per_score()
df = df_by_score[score] # scoreは"4/6"など
各ターンの各文字で最も頻度が高い色を調べるためには、以下のようにdf.mode()
を利用することができます。
chars = [v for v in list(df.mode().values[0]) if isinstance(v, str)]
#=> 'WYWWWYYYWWWWYYGGGGGG'
arry = np.array([c for c in chars]).reshape(-1, 5)
あとは、この結果をpandasのstyleを指定しつつ表示しています
import streamlit as st
def highlight_color(v):
if v is None: return None
codes = {"G": "78B15A", "Y": "FBCB59", "W": "E6E7E8"}
return f'color: #{codes[v]}; background-color: #{codes[v]};'
df_colored = pd.DataFrame(arry).style.applymap(highlight_color)
st.dataframe(df_colored)
次に、一番頻度の高い色を表示するだけではもったいないので、各マスにおける各色の確率を可視化してみることにしました
例えば1ターン目の1文字目の各色の頻度を調べるためには、以下のように、pandasのvalue_counts()
メソッドを使うとちょうど良い感じに計算ができそうです
この値をデータ数で割ることで、そのマスの特定の色の確率を調べることができます。
色を指定したときに、各マスごとにその色が選ばれる確率を表したDataFrameを返す関数を定義します
def prob_dataframe_by_color(df, color):
arry = np.array([df[i].value_counts().get(color, 0)/ df.shape[0] for i in df.columns]).reshape(-1, 5)
return pd.DataFrame(arry)
そして、rgbaのアルファチャンネルで確率をヒートマップとして可視化したDataFrameを作成しました
def highlight_white(v): return f'background-color: rgba(230,231,232,{v});'
def highlight_yellow(v): return f'background-color: rgba(251,203,89,{v});'
def highlight_green(v): return f'background-color: rgba(120,177,90,{v});'
for color in ["W", "Y", "G"]:
df_prob = prob_dataframe_by_color(df, color)[0:score_i]
if color == "W":
df_opacity = df_prob.style.applymap(highlight_white)
elif color == "Y":
df_opacity = df_prob.style.applymap(highlight_yellow)
elif color == "G":
df_opacity = df_prob.style.applymap(highlight_green)
st.dataframe(df_opacity)
以上になります
おわりに
自分のスコアと照らし合わせて、どの部分から空いていったのかを見てみると面白いかもしれません