Pokémon sleepの睡眠タイプ決定ロジックが知りたい

# はじめに
みなさん、Pokémon sleep 遊んでいますか？
自分はすでに過去最長プレイ時間であるポケモン本編のポケモンブラックのプレイ時間、999:59を超え、1200時間遊んでいます[^1]。

そこまで爆ハマりしている Pokémon sleep ですが、強いポケモンと出会うためには睡眠タイプのコントロールが必要になります。

## Pokémon sleep の睡眠タイプ
例えばピカチュウと出会うためにはすやすやタイプの睡眠が必要になります。
Pokémon sleep には3種類の睡眠タイプである、うとうとタイプ・すやすやタイプ・ぐっすりタイプ、があり、この内訳は自分のこれまでの睡眠の平均との比較になります。すやすやタイプを出そうとすると、寝ている間の動きがある程度出るような睡眠を目指して、意図的にスマホをいい感じのところにセットするなど、睡眠計測アプリとしては意図されていない行為が必要になります。


## 今回の目的
睡眠タイプはこれまでの睡眠の平均と比較して決定されると明記されていますが、これがどの期間の睡眠との比較かは明示されていません。実際コントロールしようとしても とくちょうなしタイプ[^2] というすべてのタイプがごちゃまぜに出てくるタイプにぶち込まれてしまいます。
実際のところコントロールしようとは思いませんが、どの程度の期間の平均なのかは純粋に興味が出るところです。なので、今回は何日間の平均で睡眠タイプが決定されるのか、それを求めていきたいと思います。

# データの収集
## Pokémon sleep のデータ形式
Pokémon sleep は睡眠計測アプリですが、計測した睡眠データはアプリ内に閉じています。csvで今までのデータを取得する、なんて機能はないのです。
このままでは睡眠タイプの決定ロジックを知る以前の問題になってしまうので、なんとかしてゲーム内からデータを取得する必要があります。
睡眠データの詳細は日毎にしか表示できず、以下の画像のような形式になっています。
|<img width="30%" alt="睡眠データの例" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/395053/4cd314af-b3aa-ea7d-d4fe-522f15110a77.png"> |
|:---:|
|2023/08/27の睡眠データの例。|

この中から
* 日付
* 睡眠タイプ
* 各睡眠タイプの %
* 各睡眠タイプの時間

を取得する方法を考えていきます。

## 画像からテキストデータを取得する
一つ思いつく方法として、手作業でデータを入力していくことがありますが、エンジニアである以上面倒くさい作業はできるだけしたくありません。
画像からテキストデータを抜き出すので、OCRでなんとかしていきましょう。

今回は日本語対応しているかつ、サクッと使える Google Cloud Vision API を試してみました。公式のチュートリアルである、[クライアント ライブラリを使用して画像内のラベルを検出する](https://cloud.google.com/vision/docs/detect-labels-image-client-libraries?hl=ja) をなぞるだけで使えるようになります。

睡眠データの画像をそのままVision API に入れると、いらないデータや、構造化されていないデータに悩まされることになります。
* 画像左上にある `Zzz` の文字を認識してしまう
* 0%, 23%, 77% 等の文字は取れるが、出てくる順序がバラバラなのでどの睡眠タイプに対応しているかわからない
* 寝付くまでにかかった時間 等のデータはいらない

そこで、前処理として画像の必要な部分だけを切り取り、Vision APIに渡すようにします。
以下が実際に使用したコードになります。
iPhone 12mini の解像度決め打ちですが、自分の睡眠データ以外を扱うことはないので支障はありません（コードは汚い）。余裕があればパターンマッチングを使って必要な部分だけを切り取るようにしたほうが良いと思います。

```go
func prepareCropImages(filename string) {
	// Read image
	im := readImage(filename)

	cropped := cropImage(im, image.Rect(180, 210, 180+500, 210+90))
	saveImage(cropped, "tmp/date.png")

	cropped = cropImage(im, image.Rect(285, 320, 285+548, 320+100))
	saveImage(cropped, "tmp/type.png")

	cropped = cropImage(im, image.Rect(270, 630, 280+100, 630+65))
	saveImage(cropped, "tmp/u_percent.png")

	cropped = cropImage(im, image.Rect(610, 630, 610+100, 630+65))
	saveImage(cropped, "tmp/s_percent.png")

	cropped = cropImage(im, image.Rect(940, 630, 940+100, 630+65))
	saveImage(cropped, "tmp/g_percent.png")

	cropped = cropImage(im, image.Rect(734, 1781, 734+326, 1781+100))
	saveImage(cropped, "tmp/u_time.png")

	cropped = cropImage(im, image.Rect(734, 1920, 734+326, 1920+100))
	saveImage(cropped, "tmp/s_time.png")

	cropped = cropImage(im, image.Rect(734, 2067, 734+326, 2067+100))
	saveImage(cropped, "tmp/g_time.png")
}
```

これらの前処理した画像を Vision APIへと渡し、結果のテキストをcsvとして保存します。
```go
func analizeImageText(ctx context.Context, client *vision.ImageAnnotatorClient) string {
	filelist := []string{
		"tmp/date.png",
		"tmp/type.png",
		"tmp/u_percent.png",
		"tmp/s_percent.png",
		"tmp/g_percent.png",
		"tmp/u_time.png",
		"tmp/s_time.png",
		"tmp/g_time.png",
	}

	str := ""
	for _, file := range filelist {
		file, err := os.Open(file)

		if err != nil {
			log.Fatalf("Failed to read file: %v", err)
		}
		defer file.Close()
		image, err := vision.NewImageFromReader(file)
		if err != nil {
			log.Fatalf("Failed to create image: %v", err)
		}

		labels, err := client.DetectDocumentText(ctx, image, nil)
		if err != nil {
			log.Fatalf("Failed to detect labels: %v", err)
		}

		for _, label := range labels.Pages {
			for _, block := range label.Blocks {
				for _, paragraph := range block.Paragraphs {
					for _, word := range paragraph.Words {
						s := []string{}
						for _, symbol := range word.Symbols {
							s = append(s, symbol.Text)
						}
						str += strings.Join(s, "")
					}
					str += ","
				}
			}
		}
	}
	return str
}
```
睡眠データの例を取り込むと以下のようなデータが得られます。
```csv
2023年8月27日日曜日,とくちょうなしタイプ,0%,23%,77%,0分,1時間57分,6時間25分,
```
これを 2023/08/01 ~ 2023/12/05 分の計測データまで繰り返し、いい感じにデータが整えられました。
使用したコード全体は以下まで。

https://github.com/Nokoyohei/pokesle-gocr

# データの分析
データの収集ができたのでデータの分析に移っていきます。
改めて、今回知りたいことは「睡眠タイプがどう決定されるのか」です。
これをなんとかして探っていきます。

|<img width="30%" alt="睡眠データの例" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/395053/84f97e57-6002-9714-89f8-8963495b8eec.jpeg"> |
|:---:|
|睡眠タイプ。<br>この画面を閉じたらもう見られず、後から参照もできない。|

## データの前処理
まずはデータの中の不要なものを掃除していきます。
データの振り返り：`2023年8月27日日曜日,とくちょうなしタイプ,0%,23%,77%,0分,1時間57分,6時間25分,`
上記のデータには以下の不都合な点があります。
* 日付のフォーマットが扱いにくい
    * ◯曜日を削除する
* 〇〇タイプはそのままでは扱いにくい
    * 0~3にcategorizeする
* `%` がいらない
* ◯時間◯分と◯分が混在していて扱いにくい
    * 分に統一する

データをpandasで読み込み、上記の処理をしていきます。
```python
import pandas as pd

df = pd.read_csv(data)

# 日付を消す
df2 = df.copy()
df2['date'] = df['date'].replace('[日月火水木金土]曜日', '', regex=True)
df2['date'] = pd.to_datetime(df2['date'], format='%Y年%m月%d日')

# 分に揃える
import re

def convert_to_minutes(time_str):
    # 時間と分を抽出する正規表現パターン
    pattern = re.compile(r'(?:(\d+)時間)?(?:(\d+)分)?')

    match = pattern.match(time_str)

    if match:
        hours = int(match.group(1)) if match.group(1) else 0
        minutes = int(match.group(2)) if match.group(2) else 0

        # 時間を分に変換して合計を計算
        total_minutes = hours * 60 + minutes
        return total_minutes
    else:
        return None

df2['s_time'] = df['s_time'].map(lambda x: convert_to_minutes(x))
df2['u_time'] = df['u_time'].map(lambda x: convert_to_minutes(x))
df2['g_time'] = df['g_time'].map(lambda x: convert_to_minutes(x))

# %を消す
df2 = df2.replace('%', '', regex=True)
df2['u_percent'] = df2['u_percent'].astype(float)
df2['s_percent'] = df2['s_percent'].astype(float)
df2['g_percent'] = df2['g_percent'].astype(float)
df2['sub'] = df2['sub'].astype(float)

# categorical
typeMap = {'うとうとタイプ': 0, 'すやすやタイプ': 1, 'ぐっすりタイプ': 2, 'とくちょうなしタイプ': 3}
df2['type'] = df['type'].map(lambda x: typeMap[x])
df2['type'] = df2['type'].astype('category')
```

上記のデータに加え、事前にメモしておいた睡眠タイプのデータを加えて分析に移ります。

|<img width="30%" alt="睡眠データの例" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/395053/47560f18-f7cf-c805-11bc-115e3b6c5d59.jpeg"> |
|:---:|
|コツコツメモしておいた<br>「過去のデータと比べて<睡眠タイプ>が〇〇%多かったようです」|

## 分析
### 方針
睡眠タイプは過去の睡眠の平均と比較して決定されると明記されています。
そこで過去N日間の移動平均を求め、実際に観測した「過去のデータと比べて<睡眠タイプ>が〇〇%多かったようです」の〇〇%との差が最小になる Nを求めることにしました。

例えば、ある日の睡眠が以下の条件であったとします。
* ぐっすりタイプ
* その日の睡眠でのぐっすりは6時間32分で、睡眠全体の76%
* 過去のデータと比べてぐっすりが19%多かったようですと表示
* 14日間の平均ぐっすりは53%

このとき、差は 53% - (76% - 19%) = -3 になります。これをすべての日付に対して計算しておき、絶対値をとって足したものが一番小さくなるようにNを求めていきます。

### 計算
例えば、14日間の移動平均に対しての差分の合計を求めるコードが以下になります。
pandasではindexをdateにしておくと、`rolling('{N}D')`　でいい感じに移動平均を算出してくれます。
```python
df3 = df2.copy()
df3 = df3.set_index('date')
df3 = df3.iloc[::-1]
df3['u_percent_14d'] = df3.rolling(f'14D').mean()['u_percent'] - (df3['u_percent'] - df3['sub'])
df3['s_percent_14d'] = df3.rolling(f'14D').mean()['s_percent'] - (df3['s_percent'] - df3['sub'])
df3['g_percent_14d'] = df3.rolling(f'14D').mean()['g_percent'] - (df3['g_percent'] - df3['sub'])

df3 = df3.dropna(subset=['sub'])
tmp = 0
for idx, row in df3.iterrows():
match row['type']:
  case 0: tmp += abs(row['u_percent_14d'])
  case 1: tmp += abs(row['s_percent_14d'])
  case 2: tmp += abs(row['g_percent_14d'])

# => 122.48095238095237 これが0に近ければ近いほど妥当な平均日数になる
```

あとは1日平均から90日平均までを全探索し、一番差分が小さくなる日付を探します。
最小を1日にしたのは、計算コストが高くないしどうせなら全データで見たいなと思ったからです。
最大を90日にしたのは、メモしていた「過去のデータと比べて<睡眠タイプ>が〇〇%多かったようです」から考えると、参照できる睡眠データが90日くらいしかなかったためです。
あとは、どこかしらキリのいい日付で平均が切られていると考えたので、「7日、14日、30日、60日、90日」あたり見ておけば足りるかなとも思ったのもあります。

```python
s = {}
for i in range(1, 91):
  df3 = df2
  df3 = df3.set_index('date')
  df3 = df3.iloc[::-1]
  df3['u_percent_nd'] = df3.rolling(f'{i}D').mean()['u_percent'] - (df3['u_percent'] - df3['sub'])
  df3['s_percent_nd'] = df3.rolling(f'{i}D').mean()['s_percent'] - (df3['s_percent'] - df3['sub'])
  df3['g_percent_nd'] = df3.rolling(f'{i}D').mean()['g_percent'] - (df3['g_percent'] - df3['sub'])

  df3 = df3.dropna(subset=['sub'])
  tmp = 0
  for idx, row in df3.iterrows():
    match row['type']:
      case 0: tmp += abs(row['u_percent_nd'])
      case 1: tmp += abs(row['s_percent_nd'])
      case 2: tmp += abs(row['g_percent_nd'])
  s[i] = tmp
```

## 結果
全探索の結果をグラフにまとめたものが以下になります。
|<img width="90%" alt="睡眠データの例" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/395053/c2b6ec97-98ed-1a02-5685-c9d1be8e8bd1.png"> |
|:---:|
|差分の計算結果。<br>縦軸：全日数での差分の合計値。<br>横軸：N日の移動平均で計算したときのN。|

グラフを見ると（わかりにくいですが）30日で差分が一番小さくなっています。
（周辺のデータはこんな感じ）
```
 27: 44.93341094749603,
 28: 40.48072530685851,
 29: 32.583883880683445,
 30: 24.924433512407905,
 31: 25.467286800588518,
 32: 25.254291905240052,
 33: 28.601238614396514,
 ```
というわけでキリもいいので本記事では「過去30日の平均と、今回の睡眠データを比較して睡眠タイプが決定している」と結論づけることにします。

今回使ったnotebookのリンクを貼っておくので、よりよい方法が思いついている方はぜひ試していってください。
https://colab.research.google.com/drive/1P8aaj6Sw2PvAjL339JmgU5B-wXhRtchM?hl=ja#scrollTo=Ok4H8YW4FxI4


# おわりに
今回はPokémon sleepの睡眠タイプ決定ロジックを求めていきました。
時間があまりなく、ゴリ押しの方法しか試せませんでしたが、どうやら過去30日分のデータの平均を参照していそうだというところまであたりが付きました。
しかし、睡眠タイプの%ではなく時間で計算したほうがより詳細に求まりそうなことや、連続して同じ睡眠タイプを出し続けた場合に補正があるのかなどは調査しきれませんでした。昼寝を計測した場合の処理なんかもできていません。
また時間があるときにやり残したところも追求していきます。
それでは、よき睡眠ライフを。

# 参考
* https://developers.freee.co.jp/entry/2019/12/11/130506
* https://note.nkmk.me/python-pandas-rolling/

[^1]: ただ寝ているだけ。
[^2]: すべての睡眠タイプのポケモンがごちゃまぜに出てくるので、狙ったポケモンが出しづらい。ガチ勢からはハズレとされている。睡眠アプリガチ勢ってなんだ？