【ポケポケ】初期手札の「たねポケが1枚以上含まれる」ロジックを「統計的仮説検定」で徹底検証！

## 背景

『Pokémon Trading Card Game Pocket（ポケポケ）』では、ゲームの開始時に手札として5枚のカードが配られます。その際、手札には必ず「たねポケが1枚以上含まれる」ようになっています。しかし、実際にどのようなロジックでこの処理が行われているかはユーザーに公開されていません。[@Davoiさんの記事](https://qiita.com/Davoi/items/8e6393f6833c9492da4e)ではこの処理について以下の3つが考察されています。
1. **初めにたねポケの中から1枚引き、残り4枚をたねポケ含めた全ての山札から引く**
2. **山札から5枚引き、その中にたねポケが含まれなかった場合、再度山札から5枚引き直す**
3. **山札から5枚引き、その中にたねポケが含まれなかった場合、うち1枚をたねポケ1枚と入れ替える**

これら3つのどれを採用していても、最初に「たねポケが1枚以上含まれる」という点は同じですが、「たねポケを2枚引く確率」や「ある特定のたねポケのみを引く確率」などはロジックごとに異なります。[@Davoiさんの記事](https://qiita.com/Davoi/items/8e6393f6833c9492da4e)では、これら3つのロジックについての検証が行われていましたが、試行回数がやや少なく、統計的有意性を判断するには不十分でした。


そこで本記事では、**対戦操作を自動化**することで大量の対戦データを収集し、**Z検定による統計的な仮説検定**を通じて、**どのロジックが実際の挙動に最も近いか**を明らかにすることを目的とします。具体的には、ロジックごとに確率が異なる以下の2点を検証対象とします。

- **検証1：たねポケを2枚引く確率**
- **検証2：ネッコアラのみを引く確率**

各検証にはそれぞれ1,000回の対戦データを収集し、分析を行いました。


## 方法
以下の手順に従って検証を行いました。各手順で用いたプログラムは記事末尾の「**使用したプログラム**」に掲載しています。
1. 手順1：`pyautogui`により、2種類のデッキで各1,000回の対戦を自動実行
2. 手順2：画像マッチングにより、スクリーンショットからカードの種類と枚数を自動で判定
3. 手順3：Z検定により、集計結果と理論値に統計的な有意差があるかどうかを評価

---

## 検証1：たねポケを2枚引く確率
### 使用したデッキ
まず、[@Davoiさんの記事](https://qiita.com/Davoi/items/8e6393f6833c9492da4e)を参考に、たねポケが2枚のみのデッキを用いて、最初に「たねポケを2枚引く確率」について検証します。使用したデッキは以下の通りです。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4102743/b0350d76-f149-4437-bf99-e10f29518d69.png" width=50%>
<p style="text-align: center; font-size: 0.9em; color: #666;">
    図1. 検証1で使用したデッキ（たねポケ2枚、その他18枚）
</p>

### 集計結果
以下の表は、1,000回の対戦において、手札に含まれていた各カードの枚数別出現回数をまとめたものです。たねポケを2枚引いた回数は**62回**で、全体の<b>6.2%</b>となりました。

| カード名 | 0枚 | 1枚 | 2枚 | 枚数期待値 |
|---------|-----|-----|-----|---------|
| コイキング | 0 | 938 | 62 | 1.062 |
| きずぐすり | 604 | 352 | 44 | 0.440 |
| スピーダー | 605 | 353 | 42 | 0.437 |
| モンスターボール | 590 | 370 | 40 | 0.450 |
| ハンドスコープ | 571 | 388 | 41 | 0.470 |
| ポケモン図鑑 | 610 | 347 | 43 | 0.433 |
| レッドカード | 644 | 325 | 31 | 0.387 |
| 幻の石板 | 628 | 331 | 41 | 0.413 |
| ポケモンの笛 | 593 | 365 | 42 | 0.449 |
| 博士の研究 | 588 | 365 | 47 | 0.459 |


### 理論値計算
続いて、3つのロジックについて、「たねポケを2枚引く確率」を理論的に求めます。

**ロジック1**
<INS>**初めにたねポケの中から1枚引き、残り4枚をたねポケ含めた全ての山札から引く**</INS>
この場合は、「山札20枚からたねポケ1枚を除き、残った19枚から4枚を引いた時にもう1枚のたねポケを引く確率」で考えることができます。このロジックの場合、「たねポケを2枚引く確率」は<b>21.1%</b>となります。

```math
\begin{align}
p_1 &= \frac{たねポケ2枚以外のカード3枚の組み合わせ}{カード4枚の組み合わせ} \\
&= \frac{_{18}C_3}{_{19}C_4} \\
&= 0.21053
\end{align}
```

**ロジック2**
<INS>**山札から5枚引き、その中にたねポケが含まれなかった場合、再度山札から5枚引き直す**</INS>
この場合は、「山札20枚から5枚引いてたねポケを1枚以上引いた場合に、たねポケを2枚とも引いている確率」で考えることができます。このロジックの場合、「たねポケを2枚引く確率」は<b>11.8%</b>となります。

```math
\begin{align}
p_2 &= \frac{たねポケ2枚以外のカード3枚の組み合わせ}{カード5枚中たねポケが1枚以上含まれる組み合わせ} \\
&= \frac{_{18}C_3}{_{20}C_5 - {}_{18}C_5} \\
&= 0.11765
\end{align}
```

**ロジック3**
<INS>**山札から5枚引き、その中にたねポケが含まれなかった場合、うち1枚をたねポケ1枚と入れ替える**</INS>
この場合は、「山札20枚から5枚引いて、たねポケを2枚とも引いている確率」と考えることができます。このロジックの場合、「たねポケを2枚引く確率」は<b>5.26%</b>となります。

```math
\begin{align}
p_3 &= \frac{たねポケ2枚以外のカード3枚の組み合わせ}{カード5枚の組み合わせ} \\
&= \frac{_{18}C_3}{_{20}C_5} \\
&= 0.05263
\end{align}
```

### 統計的検定

集計結果と各ロジックによって導かれた理論値との間に、統計的に有意な差が存在するかを検定します。

まず、1回の試行で「たねポケを2枚引く」確率を$p$とし、このとき成功（たねポケを2枚引く）を$1$、失敗（それ以外）を$0$とする確率変数$X \in \lbrace 0, 1 \rbrace$を定義します。この試行を独立に$n$回繰り返すと、成功回数$K= \sum_{j=1}^{n} X_j$は二項分布$Bin(n, p)$に従います。
ここで、$n$が十分大きい場合、二項分布はその期待値$\mu=np$, 分散$\sigma^2=np(1-p)$に基づいて正規分布に近似できます。つまり、$K$を標準正規分布に変換した統計量$z$は、
```math
z = \frac{K - np}{\sqrt{np(1-p)}}
```
と導出でき、この$z$は近似的に標準正規分布$N(0,1)$に従うと考えられます。

ここで、帰無仮説$H_0$を「たねポケを2枚引く確率が理論通り$p$である」とし、対立仮説を「たねポケを2枚引く確率が$p$ではない」と定義します。有意水準は$\alpha=0.05$（両側検定）とし、計算された$z$に対応する$p$値が$\alpha$より小さい場合は、$H_0$を棄却します。

以下は、集計結果に基づいて検定を行った結果です。試行回数は$n = 1000$、成功回数は$K = 62$です。

**ロジック1**
理論値：$p = 0.21053$
```math
z = \frac{62 - 210.53}{\sqrt{210.53(1-0.21053)}} = -11.521
```
このときの$p$値は$p \approx 0.0000000\\%$となり、有意水準$\alpha=5\\%$を大きく下回ります。よって、帰無仮説$H_0$は棄却され、**ロジック1は有意に異なると判断できます**。

**ロジック2**
理論値：$p = 0.11765$
```math
z = \frac{62 - 117.65}{\sqrt{117.65(1-0.11765)}} = -5.462
```
このときの$p$値は$p \approx 0.0000047\\%$となり、有意水準$\alpha=5\\%$を大きく下回ります。よって、帰無仮説$H_0$は棄却され、**ロジック2は有意に異なると判断できます**。

**ロジック3**
理論値：$p = 0.05263$
```math
z = \frac{62 - 52.63}{\sqrt{52.63(1-0.05263)}} = 1.327
```
このときの$p$値は$p \approx 18.46\\%$となり、有意水準$\alpha=5\\%$を十分に上回ります。よって、帰無仮説$H_0$は棄却されず、**ロジック3は理論値と集計結果の間に統計的有意差は見られません**。

### 考察

以上の結果から、**ロジック3のみが集計結果と統計的に矛盾しない**と判断されます。
したがって、「**初手で引く5枚に1枚以上たねポケが入る**」ときに使用されるロジックは、ロジック3の「<INS>**山札から5枚引き、その中にたねポケが含まれなかった場合、うち1枚をたねポケ1枚と入れ替える**</INS>」であると統計的に裏付けられました。

---

## 検証2：ネッコアラのみを引く確率

### 使用したデッキ
次に、前シーズン（A3）にXで話題になった、ダークライギラティナデッキにネッコアラを1枚入れたとき、最初に「ネッコアラのみを引く確率」について検証します。使用したデッキは以下の通りです。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4102743/bed2bcb1-01b3-47fb-9e46-23ba0de1e7d3.png" width=50%>
<p style="text-align: center; font-size: 0.9em; color: #666;">
    図2. 検証2で使用したデッキ（ネッコアラ1枚、ダークライ2枚、ギラティナ2枚、その他15枚）
</p>

### 集計結果
以下の表は、1,000回の対戦において手札に含まれていたたねポケの組み合わせ別の出現回数をまとめたものです。ネッコアラのみを引いた回数は**128回**で、全体の<b>12.8%</b>となりました。

| ネッコアラ | ダークライ | ギラティナ | 回数 |
|-----------|-----------|-----------|------|
|なし | 1枚以上 | なし | 294|
|なし | なし | 1枚以上 | 285|
|なし | 1枚以上 | 1枚以上 | 130|
|1枚 | なし | なし | 128|
|1枚 | 1枚以上 | なし | 72|
|1枚 | なし | 1枚以上 | 68|
|1枚 | 1枚以上 | 1枚以上 | 23|

### 理論値計算

それぞれのロジックについて、「ネッコアラのみを引く確率」を理論的に求めます。

**ロジック1**
<INS>**初めにたねポケの中から1枚引き、残り4枚をたねポケ含めた全ての山札から引く**</INS>
この場合は、「初めのたねポケとしてネッコアラを引き、残った19枚から4枚を引いた時にたねポケが含まれない確率」で考えることができます。このロジックの場合、「ネッコアラのみを引く確率」は<b>7.04%</b>となります。

```math
\begin{align}
p_1 &= 初めにネッコアラを引く確率 \times \frac{カード4枚中たねポケが含まれない組み合わせ}{カード4枚の組み合わせ} \\
&= \frac{1}{5} \times \frac{_{15}C_4}{_{19}C_4} \\
&= 0.07043
\end{align}
```

**ロジック2**
<INS>**山札から5枚引き、その中にたねポケが含まれなかった場合、再度山札から5枚引き直す**</INS>
この場合は、「山札20枚から5枚引いてたねポケを1枚以上引いた場合に、ネッコアラのみを引いている確率」で考えることができます。このロジックの場合、「ネッコアラのみを引く確率」は<b>10.9%</b>となります。

```math
\begin{align}
p_2 &= \frac{カード4枚中たねポケが含まれない組み合わせ}{カード5枚中たねポケが1枚以上含まれる組み合わせ} \\
&= \frac{_{15}C_4}{_{20}C_5 - {}_{15}C_5} \\
&= 0.10919
\end{align}
```

**ロジック3**
<INS>**山札から5枚引き、その中にたねポケが含まれなかった場合、うち1枚をたねポケ1枚と入れ替える**</INS>
この場合は、「山札20枚から5枚引いてネッコアラのみを引く確率と、たねポケが引けなかったときにネッコアラを引く確率を足したもの」と考えることができます。このロジックの場合、「ネッコアラのみを引く確率」は<b>12.7%</b>となります。

```math
\begin{align}
p_3 &= \frac{カード4枚中たねポケが含まれない組み合わせ}{カード5枚の組み合わせ} + \frac{カード5枚中たねポケが含まれない組み合わせ}{カード5枚の組み合わせ} \times 入れ替えるときにネッコアラを引く確率 \\
&= \frac{_{15}C_4}{_{20}C_5} + \frac{_{15}C_5}{_{20}C_5} \times\frac{1}{5} \\
&= 0.12678
\end{align}
```

### 統計的検定

検証2についても、集計結果と理論値との間に統計的に有意な差が存在するかを検定します。
ここでは、1回の試行で「ネッコアラのみを引く」確率を$p$とし、このとき成功を$1$、失敗を$0$とする確率変数$X \in \lbrace 0, 1 \rbrace$を定義します。この試行を独立に$n$回繰り返すと、成功回数$K= \sum_{j=1}^{n} X_j$は二項分布$Bin(n, p)$に従います。$n$が十分大きい場合、統計量$z$は、
```math
z = \frac{K - np}{\sqrt{np(1-p)}}
```
と導出でき、標準正規分布$N(0,1)$に従う確率変数として扱うことができます。

検証2では、帰無仮説$H_0$を「ネッコアラのみを引く確率が$p$である」、対立仮説を「ネッコアラのみを引く確率が$p$ではない」と定義し、有意水準$\alpha=0.05$の両側検定を行います。計算された$z$に対応する$p$値が$\alpha$より小さい場合は、$H_0$を棄却します。

以下は、集計結果に基づいて検定を行った結果です。試行回数は$n = 1000$、成功回数は$K = 128$です。

**ロジック1**
理論値：$p = 0.07043$
```math
z = \frac{128 - 70.43}{\sqrt{70.43(1-0.07043)}} = 7.114
```
このときの$p$値は$p \approx 0.0000000\\%$となり、有意水準$\alpha=5\\%$を大きく下回ります。よって、帰無仮説$H_0$は棄却され、**ロジック1は有意に異なると判断できます**。

**ロジック2**
理論値：$p = 0.10919$
```math
z = \frac{128 - 109.19}{\sqrt{109.19(1-0.10919)}} = 1.907
```
このときの$p$値は$p \approx 5.651\\%$となり、有意水準$\alpha=5\\%$をわずかに上回ります。よって、帰無仮説$H_0$はぎりぎり棄却できない範囲に収まりますが、**ロジック2は有意差の境界上にあると評価できます**。

**ロジック3**
理論値：$p = 0.12678$
```math
z = \frac{128 - 126.78}{\sqrt{126.78(1-0.12678)}} = 0.116
```
このときの$p$値は$p \approx 90.77\\%$となり、有意水準$\alpha=5\\%$を十分に上回ります。よって、帰無仮説$H_0$は棄却されず、**ロジック3は理論値と集計結果の間に統計的有意差は見られません**。


### 考察

以上の結果から、ロジック2とロジック3はいずれも統計的に有意差が見られず、帰無仮説は棄却されませんでした。しかし、検定結果の$p$値の大きさを踏まえると、**ロジック3のほうがより実際の挙動と近い**と判断できます。
したがって、2つの検証結果を総合的に踏まえると、「**初手で引く5枚に1枚以上たねポケが入る**」ときに使用されるロジックは、ロジック3の「<INS>**山札から5枚引き、その中にたねポケが含まれなかった場合、うち1枚をたねポケ1枚と入れ替える**</INS>」と統計的に裏付けられました。

---

## まとめ

本記事では、『Pokémon Trading Card Game Pocket（ポケポケ）』において、「**初手で引く5枚に1枚以上たねポケが入る**」という仕様について、実際にどのようなロジックが使われているのかを、**統計的な手法**（**Z検定**）を用いて検証しました。
検証対象としたロジックは以下の3つです。

1. **初めにたねポケの中から1枚引き、残り4枚をたねポケ含めた全ての山札から引く**
2. **山札から5枚引き、その中にたねポケが含まれなかった場合、再度山札から5枚引き直す**
3. **山札から5枚引き、その中にたねポケが含まれなかった場合、うち1枚をたねポケ1枚と入れ替える**

この3つのロジックについて、以下の2つの観点から実データ（各1,000回の試行）と理論値の比較を行いました。

- **検証1：たねポケを2枚引く確率**
- **検証2：ネッコアラのみを引く確率**

両方の検証結果から共通してわかったことは、**ロジック3**の「<INS>**山札から5枚引き、その中にたねポケが含まれなかった場合、うち1枚をたねポケ1枚と入れ替える**</INS>」が最も近いということです。特に、検証1では、ロジック1、2が大きく乖離する一方で、ロジック3のみが理論値と非常に近い値を示しました。

この結果は、本家『Pokémon Trading Card Game（ポケカ）』で採用されているマリガン（ロジック2）とは異なる結果となりました。ポケポケでは、よりスムーズにゲームを開始できるような仕様になっていると言えます。


---

## 使用したプログラム

それぞれの手順で用いたプログラムを載せておきます。

### 手順1：`pyautogui`による自動対戦
`pyautogui`を用いてBlueStacks上の「ひとりで」モード対戦を自動化させ、2種類のデッキで1,000回ずつ、合計2,000回実行しました。1回の対戦に要する時間は約36秒であるため、約20時間にわたって実行を行いました。また、処理が正しく進んでいるかを確認するために、`OpenCV`の画像マッチングを用いて対戦画面の左下に表示されるメニューアイコン（三本線）が映っているかどうかを随時チェックしています。
- `run_cycle()`：1回分の対戦を行い、終了までの一連の操作（デッキ選択・バトル開始・降参など）を自動で実行する
- `check_image_similarity()`：スクリーンショット内にメニューアイコンが含まれているかをテンプレートマッチングで確認する
- `main()`：これらの処理を1,000回繰り返して実行する

```python
import pyautogui
import time
import glob
import os
import sys
import cv2

pyautogui.FAILSAFE = True  # 緊急停止用（マウスを左上に移動）

def run_cycle():
    print('対戦デッキ選択')
    pyautogui.click(942, 499)  # 対戦デッキ選択
    time.sleep(4)

    print('バトル開始')
    pyautogui.click(942, 883)  # バトル開始
    time.sleep(14)

    print('スクリーンショット')
    pyautogui.hotkey('ctrl', 'shift', 's')  # スクリーンショット
    time.sleep(2)

    print('メニュー')
    pyautogui.click(705, 862)  # メニュー
    time.sleep(1)

    print('降参ボタン1回目')
    pyautogui.click(904, 686)  # 降参ボタン1回目
    time.sleep(1)

    print('降参ボタン2回目')
    pyautogui.click(942, 606)  # 降参ボタン2回目
    time.sleep(8)

    print('タップ')
    pyautogui.click(942, 974)  # タップ
    time.sleep(2)

    print('次へ')
    pyautogui.click(942, 974)  # 次へ
    time.sleep(4)


def check_image_similarity():
    image_pattern = "./pictures/*"
    template_path = "./templates/menu.png"

    # 名前が一番下の画像を取得
    png_files = sorted(glob.glob(image_pattern))
    if not png_files:
        print("画像ファイルが見つかりませんでした。")
        return
    
    latest_image_path = png_files[-1]
    filename = os.path.splitext(os.path.basename(latest_image_path))[0]
    print(f"比較対象画像: {filename}")

    # 画像を読み込み
    img = cv2.imread(latest_image_path)
    template = cv2.imread(template_path)
    if img is None or template is None:
        print("画像の読み込みに失敗しました。")
        return

    # テンプレートマッチングを実行
    result = cv2.matchTemplate(img, template, cv2.TM_CCOEFF_NORMED)
    _, max_val, _, _ = cv2.minMaxLoc(result)
    print(f"テンプレートマッチスコア: {max_val:.3f}")

    # 閾値未満なら停止（類似度が低い）
    if max_val <= 0.95:
        print("テンプレートが画像にマッチしませんでした。自動操作を停止します。")
        sys.exit()

def main():
    n = 1000  # 対戦回数
    time.sleep(3)  # 開始までの猶予時間

    for i in range(n):
        print(f"\n--- {i + 1} 回目の実行 ---")
        run_cycle()  # 対戦実行
        check_image_similarity()  # スクリーンショットにメニューの3本線があるか

    print(f"\n{n}回の自動操作が完了しました。")

if __name__ == '__main__':
    main()
```
---
### 手順2：画像マッチングによるカード枚数判定
当初はOCR（光学文字認識）によってスクリーンショットからカード名を読み取ろうとしましたが、文字認識の精度が足りませんでした。そこで、手順1と同様に、カードの左側の一部分をテンプレート画像として用意し、`OpenCV`の画像マッチングでスクリーンショット内に該当部分が何箇所あるかを検出する方法に切り替えました。
- `suppress_close_matches(points, distance_thresh=10)`：画像マッチングによって得られた候補点`points`のうち、近すぎて重複しているものをまとめて１つにする
- `process_images(image_pattern, templates, output_dir, anomaly_dir)`：スクリーンショットを読み込み、各テンプレートについて一致箇所を検出する。各カードの枚数が0～2枚の範囲に収まっている、かつカードの合計が5枚になっているかを確認し、問題なければ`output_dir`へ、異常があれば`anomaly_dir`へ検出結果を保存する。最後に全データをCSVに保存する
- `main()`：ディレクトリを定義して実行する

```python
import cv2
import numpy as np
import os
import glob
import csv

def suppress_close_matches(points, distance_thresh=10):
    filtered = []
    for pt in points:
        if all(np.linalg.norm(np.array(pt) - np.array(other)) > distance_thresh for other in filtered):
            filtered.append(pt)
    return filtered

def process_images(image_pattern, templates, output_dir, anomaly_dir):
    # 入力画像一覧
    image_paths = glob.glob(image_pattern)
    if not image_paths:
        print("画像が見つかりませんでした。")
        return
    print(f"画像枚数: {len(image_paths)}")

    # 出力ディレクトリ
    os.makedirs(output_dir, exist_ok=True)
    os.makedirs(anomaly_dir, exist_ok=True)

    # 全体結果を格納
    all_results = []
    template_names = list(templates.keys())

    # 各画像の処理
    for main_path in image_paths:
        filename = os.path.splitext(os.path.basename(main_path))[0]
        print(f"\n画像処理中: {filename}")

        # 入力画像を読み込み
        main_img = cv2.imread(main_path)
        if main_img is None:
            print(f"読み込み失敗: {main_path}")
            continue
        main_color = main_img.copy()
        counts = {}

        # 各テンプレート画像で探索
        for name, data in templates.items():
            template_path = data["path"]
            threshold = data["threshold"]

            if not os.path.exists(template_path):
                print(f"{name}: テンプレートが見つかりません")
                counts[name] = 0
                continue
            
            # テンプレート画像を読み込み
            template = cv2.imread(template_path)
            h, w = template.shape[:2]

            # テンプレートマッチング実行
            res = cv2.matchTemplate(main_color, template, cv2.TM_CCOEFF_NORMED)

            # 指定された閾値以上の座標を抽出
            loc = np.where(res >= threshold)
            raw_points = list(zip(*loc[::-1]))

            # 近すぎるマッチを除去
            filtered_pts = suppress_close_matches(raw_points, distance_thresh=max(w, h) * 0.5)

            # 有効な検出数を記録
            count = len(filtered_pts)
            counts[name] = count

            # 検出箇所に赤い矩形を描画
            for pt in filtered_pts:
                cv2.rectangle(main_img, pt, (pt[0] + w, pt[1] + h), (0, 0, 255), 2)

        # 合計数
        total = sum(counts.values())

        # 保存先フォルダに画像保存
        card_counts_valid = all(0 <= v <= 2 for v in counts.values())  # 各カードの枚数が0〜2枚の範囲にあるか確認
        if total == 5 and card_counts_valid:  # 合計枚数が5枚かを確認
            save_dir = output_dir
        else:
            save_dir = anomaly_dir
            summary_line = ', '.join([f"{k}: {v}" for k, v in counts.items()])
            print(f"バグ検出: {filename} | カード種類: {summary_line}, 合計: {total} 枚")

        result_img_path = os.path.join(save_dir, f"{filename}_result.png")
        cv2.imwrite(result_img_path, main_img)

        # 結果をall_resultsに追加
        row = [filename] + [counts.get(name, 0) for name in template_names]
        all_results.append(row)

    # 最後にCSV出力
    summary_path = os.path.join(output_dir, "summary.csv")
    with open(summary_path, "w", newline="", encoding="utf-8") as f:
        writer = csv.writer(f)
        header = ["name"] + template_names
        writer.writerow(header)
        writer.writerows(all_results)

def main():
    # テンプレート画像
    templates = {
        "hoge": {"path": "./templates/hoge.png", "threshold": 0.7},
        "fuga": {"path": "./templates/fuga.png", "threshold": 0.8},
    }
    image_pattern = "./pictures/*"  # 手札画像
    output_dir = "./output"  # 成功した画像を出力
    anomaly_dir = "./anomaly"  # バグ検出した画像を出力
    process_images(image_pattern, templates, output_dir, anomaly_dir)

if __name__ == '__main__':
    main()
```

以下は、画像マッチングによりカードを検出した結果です。テンプレートに一致した領域には赤枠が描画されています。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4102743/cd6585e4-5fbd-4bff-83df-2d6872cd3d30.png" width=50%>
<p style="text-align: center; font-size: 0.9em; color: #666;">
    図3. カード検出の結果（赤枠は各カードに対応するテンプレートが一致した領域）
</p>

---

### 手順3：Z検定による有意差の検証
手順2で得られた集計結果をもとに、3つのロジックについて、Z検定を用いて有意差の有無を判定します。

- `perform_z_test(n, k, p0, alpha=0.05)`：試行回数`n`、成功回数`k`、仮説となる理論確率`p0`から両側Z検定を行う
- `main1()`：「たねポケを2枚引く確率」について検定を行う
- `main2()`：「ネッコアラのみを引く確率」について検定を行う

```python
import pandas as pd
import math

def perform_z_test(n, k, p0, alpha=0.05):
    """Z検定を行い結果を表示する"""
    def phi(z):
        return 0.5 * (1 + math.erf(z / math.sqrt(2)))

    p_hat = k / n
    var = p0 * (1 - p0) / n
    z = (p_hat - p0) / math.sqrt(var)
    
    p_value = 2 * min(phi(z), 1 - phi(z))

    print(f"p = {p0:.5f}")
    print(f"Z値 = {z:.3f}")
    print(f"p値(両側) = {p_value:.10%}")

    if p_value < alpha:
        print(f"有意差あり (H0を棄却)\n")
    else:
        print(f"有意差なし (H0を棄却できない)\n")

def main1():
    """検証1：たねポケを2枚引く確率"""

    # CSVを読み込み
    csv_path = "./deck1/summary.csv"
    df = pd.read_csv(csv_path)

    # 試行回数と成功回数を取得
    n_trials = len(df)
    k_success = (df["tane"] == 2).sum()  # たねポケが2枚出現した行数をカウント
    print(f"n = {n_trials}, K = {k_success}\n")

    # ロジックごとの理論確率を定義
    p1 = math.comb(18, 3) / math.comb(19, 4)  # ロジック1
    p2 = math.comb(18, 3) / (math.comb(20, 5) - math.comb(18, 5))  # ロジック2
    p3 = math.comb(18, 3) / math.comb(20, 5)  # ロジック3

    # 各ロジックに対して検定を実行
    for i, p0 in enumerate([p1, p2, p3]):
        print(f"ロジック{i + 1}")
        perform_z_test(n_trials, k_success, p0)


def main2():
    """検証2：ネッコアラのみを引く確率"""

    # CSVを読み込み
    csv_path = "./deck2/summary.csv"
    df = pd.read_csv(csv_path)

    # Only_koara 列を追加（koaraが1枚、他が0枚のとき1、それ以外は0）
    df["Only_koara"] = ((df["koara"] == 1) & (df.drop(columns=["name", "koara"]) == 0).all(axis=1)).astype(int)

    # 試行回数と成功回数を取得
    n_trials = len(df)
    k_success = df["Only_koara"].sum()
    print(f"n = {n_trials}, K = {k_success}\n")

    # ロジックごとの理論確率を定義
    p1 = (1 / 5) * (math.comb(15, 4) / math.comb(19, 4))  # ロジック1
    p2 = math.comb(15, 4) / (math.comb(20, 5) - math.comb(15, 5))  # ロジック2
    p3 = (math.comb(15, 4) / math.comb(20, 5)) + (math.comb(15, 5) / math.comb(20, 5)) * (1 / 5)  # ロジック3

    # 各ロジックに対して検定を実行
    for i, p0 in enumerate([p1, p2, p3]):
        print(f"ロジック{i + 1}")
        perform_z_test(n_trials, k_success, p0)

if __name__ == '__main__':
    main1()
    main2()
```

### 実行環境
- アプリ：Pokémon TCG Pocket 1.2.5
- エミュレータ：BlueStacks 5
- OS：Windows 11
- Python：3.10.12

### おわりに
ここまで読んでいただきありがとうございました。
もしご意見やご指摘、改善点などございましたら、ぜひコメントで教えていただけると幸いです！