Wordleの平均的な文字の埋まり方を可視化したページを作った

# はじめに
[Wordle](https://www.nytimes.com/games/wordle/index.html)は、5文字の英単語を推測するゲームです

6回の推測ターンのうち何回でクリアできたか、また、どの箇所の文字から推測が当たっていったのかをシェアする機能があり、Twitterで多くのシェアを確認することができます
https://twitter.com/search?q=%23Wordle&src=typed_query&f=live

はじめはこれを見ながら、正解するまでのターン数をヒストグラムにしたらどうなるのだろうかと考えたのですが、それはすでに以下のTwitterアカウントできちんと作られています

https://twitter.com/WordleStats

そこで、新たにアイデアとして、各文字が平均的にどのように推測されているのかというのを可視化してみようと考え、実際のページを作ってみました

# できたもの
https://average-wordle.herokuapp.com/

スコアを選ぶと、そのスコアで正解した人の平均的な文字の埋まり方を可視化します
![スクリーンショット 2022-02-12 20.35.51.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/638747/6905eec2-5e76-3459-e6ab-e5e54743f949.png)

また、各マスで選ばれた色の確率を次のように見ることもできるようになっています
![スクリーンショット 2022-02-12 23.14.12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/638747/f3438285-471c-6f60-b7f0-369b2d9fe612.png)


Pythonのstreamlitで作ったものをHerokuにデプロイしており、Herokuのscheduler（crontabのようなもの）で1時間に1回、最大1万件のツイートを取得しており、（正常に動いていれば）データが更新されます

# システム構成
成績データの取得と保存を行う処理と、成績データから平均や確率の計算と可視化を行う処理の2つに分かれています

## 成績データ取得と保存
[スクリプト](https://github.com/sentencebird/wordle-average-matrix/blob/main/cron.py)

Wordleの推測の結果のデータをTwitter APIで取得します。
Wordleは1日1個ずつIDが振られており、そのIDとともにツイートされるので、`'"#Wordle 238"'`という文字列で検索を行い、その結果を取得します


```python
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）

```python
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
```

これを先程取得したツイート全体に適用して配列に格納していきます。

```python
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)`としたときに、以下のようなデータが出来上がります。

![スクリーンショット 2022-02-12 20.00.57.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/638747/3a30cfa1-9ef2-4ccb-d2b0-ee994630f183.png)

Herokuでデータを保持しておくために、この状態で`colors`という名前のテーブルでDBに保存しておきます。

```python
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で定期実行するのはここまでです。

## 平均データと確率データの可視化
[スクリプト](https://github.com/sentencebird/wordle-average-matrix/blob/main/app.py)

ページの表示の際に、平均的な埋まり方と各色の確率の可視化を行う部分を説明します

スコア（「4/6」とか）ごとに結果を分けてみたいため、DBに保存してある成績データのうち、埋まっている文字の数で区切って取り出すメソッドを定義しました。

```python
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])] 
```

これにより、以下のようにして、スコアごとの結果の一覧が手に入ります

```python
df_by_score = set_dataframe_per_score()
df = df_by_score[score] # scoreは"4/6"など
```

各ターンの各文字で最も頻度が高い色を調べるためには、以下のように`df.mode()`を利用することができます。

```python
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を指定しつつ表示しています

```python
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()`メソッドを使うとちょうど良い感じに計算ができそうです

![スクリーンショット 2022-02-12 20.17.53.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/638747/d75b1cb3-9422-86b6-aedc-2f0a34176e1e.png)

この値をデータ数で割ることで、そのマスの特定の色の確率を調べることができます。
色を指定したときに、各マスごとにその色が選ばれる確率を表したDataFrameを返す関数を定義します

```python
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を作成しました

```python
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)
```

以上になります

# おわりに
自分のスコアと照らし合わせて、どの部分から空いていったのかを見てみると面白いかもしれません