Pythonで〇×ゲームのAIを一から作成する　その177　正規分布に従う離散型確率分布の計算

# 目次と前回の記事

https://qiita.com/ysgeso/items/2381dd4e3283cbed49a0

https://qiita.com/ysgeso/items/21f5991c80f4a83455c7

## これまでに作成したモジュール

以下のリンクから、これまでに作成したモジュールを見ることができます。

| リンク | 説明 |
|:--|:--|
| [marubatsu.py](https://github.com/ysgeso/marubatsu/blob/master/177/marubatsu.py)  | Marubatsu、Marubatsu_GUI クラスの定義|
| [ai.py](https://github.com/ysgeso/marubatsu/blob/master/177/ai.py)  |  AI に関する関数 |
| [test.py](https://github.com/ysgeso/marubatsu/blob/master/177/test.py)  |  テストに関する関数 |
|  [util.py](https://github.com/ysgeso/marubatsu/blob/master/177/util.py) |  ユーティリティ関数の定義 |
| [tree.py](https://github.com/ysgeso/marubatsu/blob/master/177/tree.py)  | ゲーム木に関する Node、Mbtree クラスなどの定義  |
| [gui.py](https://github.com/ysgeso/marubatsu/blob/master/177/gui.py)  |  GUI に関する処理を行う基底クラスとなる GUI クラスの定義  |

AI の一覧とこれまでに作成したデータファイルについては、下記の記事を参照して下さい。

https://qiita.com/ysgeso/items/10d1d01192c014173e4b

# 用語と記号

今回の記事では[前回の記事](https://qiita.com/ysgeso/items/21f5991c80f4a83455c7#%E7%94%A8%E8%AA%9E)に引き続き下記の表の用語と記号を用いることにします。

| 用語と記号 | 意味 |
| :-- | :-- |
| __正確なミニマックス値__ | 深さの制限がないゲーム木の探索によって計算される、局面の正確なミニマックス値のこと |
| __近似値__ | __静的評価関数が計算する__ 正確なミニマックス値とは異なる __不正確な値が計算される場合がある__ 評価値のこと |
| __近似値のミニマックス値__ | __深さ制限探索__ によって計算される、静的評価関数が計算する __近似値に対するミニマックス値__ のこと |
| $\boldsymbol{d}$ | __[局面の形勢判断の難しさ](https://qiita.com/ysgeso/items/21f5991c80f4a83455c7#%E5%B1%80%E9%9D%A2%E3%81%AE%E5%BD%A2%E5%8B%A2%E5%88%A4%E6%96%AD%E3%81%AE%E9%9B%A3%E3%81%97%E3%81%95%E3%81%AE%E6%A6%82%E5%BF%B5%E3%81%AE%E5%B0%8E%E5%85%A5)__ を表す数値 |
| $\boldsymbol{N(d)}$ | 局面の形勢判断の難しさが $d$ の __局面の集合__|
| $\boldsymbol{M(d)}$ | $N(d)$ の局面に対して静的評価関数が計算する __近似値の平均値__|
| $\boldsymbol{S(d)}$ | $N(d)$ の局面に対して静的評価関数が計算する __近似値の標準偏差__（standard variation）|
| $\boldsymbol{N_{mean}(s)}$ | $M(d) = s$ となる局面の集合。静的評価関数の __ばらつきをなくした場合__ にその局面に対して __計算される近似値__ が $s$ となる局面の集合　 |

## 平均値と期待値の用語について

前回の記事では脚注で __確率分布の平均値__ のことを、データの集合から複数のデータを取り出した場合に __想定される平均値__ であることから、__期待値__ と呼ぶと説明しました。一方で、今回の記事で紹介する __確率分布の一種__ である __正規分布__ の下記の Wikipedia などを見ると、期待値ではなく __平均という用語が使われている__ ようです。

これまでは、本記事では平均値と期待値の用語を区別して使い分けてきましたが、上記のようなことがあることから、本記事では以後は区別せずに __平均__ と表記することにします。

https://ja.wikipedia.org/wiki/%E6%AD%A3%E8%A6%8F%E5%88%86%E5%B8%83

:::note
「平均」と「平均値」は同じ意味の用語だと思います。これまでは平均値と記述してきましたが、上記の Wikipedia に倣って以後は平均と表記することにします。
:::

:::note
正規分布で期待値ではなく、平均という用語が一般的に使われている理由については、用語が用いられる文脈の違いではないかと思います。ただし、正直にいうと筆者もそのことを正確に説明できる自信はありませんので説明は省略します。興味がある方は調べてみて下さい
:::

# `calc_pdist` の改良

[前回の記事](https://qiita.com/ysgeso/items/21f5991c80f4a83455c7#calc_pdict-%E3%81%AE%E5%AE%9A%E7%BE%A9)では、__深さの上限が 1__ の場合の深さ制限探索で計算される __近似値のミニマックス値__ の __確率分布の計算__ を行う __`calc_pdist`__ を定義しましたが、`calc_pdist` には以下のような欠点があります。

* __2 つの子ノード__ を持つ __max ノード__ の場合しか計算できない

そこで、下記のように `calc_pdist` 修正することで、__任意の数の子ノード__ と、ルートノードが __min ノードの場合にも対応できる__ ように修正することにします。

* __複数の子ノード__ が計算する近似値の確率分布（probability distribution）の一覧を表す list を代入する仮引数 __`pdistlist`__ を追加し、仮引数 `pdistA` と `pdistB` を削除する
* ルートノードが __max ノードであるかどうか__ を表す仮引数 __`maxnode`__ を追加する

下記はそのように `calc_pdist` を修正したプログラムです。

* __4 行目__：仮引数を `pdistlist` と、デフォルト値を `True` とする `maxnode` に修正する
* __6 行目__：`pdistlist` のそれぞれの要素を、dict の `items` メソッドの返り値に置き換える。この処理が必要な理由について忘れた方は[前回の記事](https://qiita.com/ysgeso/items/21f5991c80f4a83455c7#product-%E3%81%AB%E3%82%88%E3%82%8B%E7%A2%BA%E7%8E%87%E5%88%86%E5%B8%83%E3%82%92%E8%A1%A8%E3%81%99%E9%9B%86%E5%90%88%E3%81%AE%E7%9B%B4%E7%A9%8D%E3%81%AE%E8%A8%88%E7%AE%97%E6%96%B9%E6%B3%95)を復習すること
* __7 行目__：[前回の記事](https://qiita.com/ysgeso/items/21f5991c80f4a83455c7#product-%E3%81%AB%E3%82%88%E3%82%8B%E7%9B%B4%E7%A9%8D%E3%81%AE%E8%A8%88%E7%AE%97%E6%96%B9%E6%B3%95)で説明した `product` は、実引数に記述されたデータの直積を計算するので、[以前の記事](https://qiita.com/ysgeso/items/474db9ea1805993eaa34#%E5%8F%8D%E5%BE%A9%E5%8F%AF%E8%83%BD%E3%82%AA%E3%83%96%E3%82%B8%E3%82%A7%E3%82%AF%E3%83%88%E3%81%AE%E5%B1%95%E9%96%8B)で説明した反復可能オブジェクトの展開を利用して `pdistlist` の各要素の直積を計算する
* __8 行目__：直積の集合の各要素に対する繰り返し処理を行う
* __9 行目__：直積の要素が代入された `data` には、それぞれの子ノードの `(近似値, 確率)` という tuple を要素に持つ list が代入されている。先頭の list の要素から最初の子ノードで計算された近似値とその確率を取り出して `score` と `prob` に代入する
* __10 行目__：`data` の 2 番目以降の要素に対する繰り返し処理を行う
* __11 ~ 14 行目__：ノードの種類に応じた `score` の値の更新を行う
* __15 行目__：`data` に記録された子ノードの近似値の組み合わせが計算される確率は、それぞれの近似値が計算される確率を乗算した値となるので、その計算を行う
* __16、17 行目__：元のプログラムと同じ

```python
 1  from collections import defaultdict
 2  from itertools import product
 3  
 4  def calc_pdist(pdistlist, maxnode=True):
 5      pdist = defaultdict(int)
 6      pdistlist = [pd.items() for pd in pdistlist]
 7      productdata = product(*pdistlist)
 8      for data in productdata:
 9          score, prob = data[0]
10          for s, p in data[1:]:
11              if maxnode:
12                  score = max(score, s)
13              else:
14                  score = min(score, s)
15              prob *= p
16          pdist[score] += prob
17      return pdist
```

<details><summary><strong>行番号のないプログラム</strong></summary>

```python
from collections import defaultdict
from itertools import product

def calc_pdist(pdistlist, maxnode=True):
    pdist = defaultdict(int)
    pdistlist = [pd.items() for pd in pdistlist]
    productdata = product(*pdistlist)
    for data in productdata:
        score, prob = data[0]
        for s, p in data[1:]:
            if maxnode:
                score = max(score, s)
            else:
                score = min(score, s)
            prob *= p
        pdist[score] += prob
    return pdist
```
</details>

<details><summary><strong>修正箇所</strong></summary>

```diff_python
from collections import defaultdict
from itertools import product

-def calc_pdist(pdistA, pdistB):
+def calc_pdist(pdistlist, maxnode=True):
    pdist = defaultdict(int)
+   pdistlist = [pd.items() for pd in pdistlist]
-   productdata = product(pdistA.items(), pdistB.items())
+   productdata = product(*pdistlist)
-   for ((scoreA, probA), (scoreB, probB)) in productdata:
-       score = max(scoreA, scoreB)
-       prob = probA * probB
-       result[score] += prob
+   for data in productdata:
+       score, prob = data[0]
+       for s, p in data[1:]:
+           if maxnode:
+               score = max(score, s)
+           else:
+               score = min(score, s)
+           prob *= p
+       pdist[score] += prob
    return pdist
```
</details>

上記の修正後に、__[前回の記事](https://qiita.com/ysgeso/items/21f5991c80f4a83455c7#%E7%A2%BA%E7%8E%87%E5%88%86%E5%B8%83%E3%82%92%E8%A1%A8%E3%81%99%E3%83%87%E3%83%BC%E3%82%BF%E6%A7%8B%E9%80%A0)と同じ__ 下記の設定で深さの上限を 1 とした深さ制限探索での __ノード__ $\boldsymbol{N}$ の近似値のミニマックス値の __確率分布の計算__ を行い、__結果が同じになる__ ことで __`calc_pdist`__ が __正しく動作することを確認__ することにします。

* 静的評価関数が任意の $d$ に対して下記の表の確率分布で近似値を計算する
* max ノードであるノード $N$ が下図のように子ノード $A$ と $B$ を持つ。なおノードの黒枠が max ノードを、赤枠が min ノードを表す
* ノード $A$ と $N$ は $N_{mean}(0)$、ノード $B$ は $N_{mean}(-1)$ の集合に属する。図のノードの中の数値がそのノードが属する $N_{mean}(s)$ の集合の $s$ を表す

|近似値の平均 $\boldsymbol{M(d)}$ との差|-2|-1|0|1|2|
|:--|--:|--:|--:|--:|--:|
| __局面の割合__ |5%|20%|50%|20%|5%|

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3524802/b866a8d7-c492-48fc-994f-d286ddfd8113.png" width="250">　

下記はその計算を行うプログラムで __[前回の記事](https://qiita.com/ysgeso/items/21f5991c80f4a83455c7#calc_pdict-%E3%81%AE%E5%AE%9A%E7%BE%A9)と同じ計算結果__ になることが確認できます。


```python
from pprint import pprint
from tree import calc_stval

pdistA = {
    -2: 0.05,
    -1: 0.2,
     0: 0.5,
     1: 0.2,
     2: 0.05
}
pdistB = {
    -3: 0.05,
    -2: 0.2,
    -1: 0.5,
     0: 0.2,
     1: 0.05
}
pdistNmax = calc_pdist([pdistA, pdistB], maxnode=True)
pprint(pdistNmax)
print(calc_stval(pdistNmax))
```

実行結果

```
defaultdict(<class 'int'>,
            {-2: 0.012500000000000002,
             -1: 0.17500000000000002,
             0: 0.525,
             1: 0.23750000000000004,
             2: 0.05000000000000001})
(0.13750000000000007, 0.6435937500000002, 0.8022429494860022)
```

次に、下図のようにノード $N$ を __min ノードとした場合__ の計算を行うことにします。ノード $N$ は __min ノード__ なので、下図のように $\boldsymbol{N_{mean}(-1)}$ __の集合に属する__ ことになります。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3524802/e1a851ab-1084-4cdc-8cb3-d340951206d7.png" width="250">　

下記はその計算を行うプログラムです。

```python
pdistNmin = calc_pdist([pdistA, pdistB], maxnode=False)
pprint(pdistNmin)
print(calc_stval(pdistNmin))
```

実行結果

```
defaultdict(<class 'int'>,
            {-3: 0.05000000000000001,
             -2: 0.23750000000000004,
             -1: 0.525,
             0: 0.17500000000000002,
             1: 0.012500000000000002})
(-1.1375000000000002, 0.6435937500000001, 0.802242949486002)
```

__min ノードと max ノード__ で行われる __処理の違い__ は、子ノードの近似値の __最小値__ を計算するか __最大値__ を計算するかです。そのため、__max ノード__ の場合に計算される __確率分布__ は、__min ノード__ の場合の確率分布と下記の表のように __反転します__。なお、表の数値は小数点以下第 5 桁で四捨五入しました。

|近似値など|max ノード|min ノード|
|--:|--:|--:|
|__-3__|0.0000|0.05000|
|__-2__|0.0125|0.02375|
|__-1__|0.1750|0.05250|
|__0__| 0.5250|0.01750|
|__1__| 0.2375|0.01250|
|__2__| 0.0500|0.00000|
|__平均__| 0.1375| -1.1375|
|__標準偏差__| 0.8022 | 0.8022 |

__max ノード__ の場合は $N$ は $\boldsymbol{N_{mean}(0)}$ の集合に属しており、計算される __確率分布の平均__ は __0 から 0.1375 多い値__ になります。一方、min ノードの場合は $N$ は $\boldsymbol{N_{mean}(-1)}$ の集合に属しており、計算される __確率分布の平均__ は __-1 から 0.1375 小さい値__ となります。このように、__平均__ も max ノードと min ノードの場合で __反転している__ ことがわかります。

__確率分布の形状__ は __反転している__ ので、ばらつきをあらわす __標準偏差は同じ__ になります。

次に、`calc_pdist` が __3 つ以上の子ノード__ に対応していることを確認することにします。下記は下図のように max ノード $N$ の __3 つ目の子ノード__ として __ノード__ $\boldsymbol{B}$ __と同じ確率分布__ で近似値を計算する __ノード__ $\boldsymbol{C}$ が加わった場合の計算を行うプログラムです。実行結果から子ノードが 2 つの場合と比較して __平均__ が 0.138 から 0.229 に __少し上昇し__、__標準偏差__ が 0.802 から 0.755 に __少し減少する__ ことが確認できます。なお、子ノードの数と平均、標準偏差の具体的な関係については次回の記事で説明します。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3524802/37df5198-fba7-4dda-bf15-0da2da8b2df2.png" width="350">　

```python
pdistN3 = calc_pdist([pdistA, pdistB, pdistB], maxnode=True)
pprint(pdistN3)
print(calc_stval(pdistN3))
```

実行結果

```
defaultdict(<class 'int'>,
            {-2: 0.0031250000000000006,
             -1: 0.1375,
             0: 0.5362500000000001,
             1: 0.2731250000000001,
             2: 0.05000000000000002})
(0.22937500000000013, 0.5705121093750002, 0.7553225201031677)
```

# 確率分布の視覚化

上記のような __確率分布を表す dict__ を見ても __実感がわかない__ と思いますので、実引数に記述した __確率分布のグラフを表示__ する __`draw_pdist`__ を定義することにします。

## `draw_pdist` の定義

下記は `draw_pdist` を定義するプログラムです。[以前の記事](https://qiita.com/ysgeso/items/23c4d441e6fab315456a#%E3%83%87%E3%83%BC%E3%82%BF%E3%81%AE%E5%88%86%E5%B8%83%E3%81%AB%E3%82%88%E3%82%8B%E6%A8%99%E6%BA%96%E5%81%8F%E5%B7%AE%E3%81%AE%E6%AF%94%E8%BC%83)のノートで matplotlib の __`hist` を利用__ して __ヒストグラムを描画__ する方法を説明しました。`hist` は 20 代（20 以上 29 以下）の人数を描画するヒストグラムのように、__範囲を持つデータの個数__ を描画する際には __便利__ ですが、静的評価関数の確率分布のように、確率変数が __範囲を持たない 1 種類のデータ__ の場合は __棒グラフを描画__ する __`bar`__ を利用したほうが __簡単に描画できる__ ことに気が付きましたので `bar` を利用して描画することにします。`bar` の使い方はは折れ線グラフを描画する __`plot` とほぼ同じ__ ですが、__棒の幅__ を表す __仮引数 `width`__ を持つ点などが異なります。

`bar` の詳細については下記のリンク先を参照して下さい。

https://matplotlib.org/stable/api/_as_gen/matplotlib.axes.Axes.bar.html

* __2 行目__：文字化けが起きないようにするために [`japanize_matplotlib`](https://qiita.com/ysgeso/items/ab2770693c947c108dd0#%E8%AD%A6%E5%91%8A%E3%83%A1%E3%83%83%E3%82%BB%E3%83%BC%E3%82%B8%E3%81%8C%E5%87%BA%E3%82%8B%E5%A0%B4%E5%90%88%E3%81%AE%E5%AF%BE%E8%B1%A1%E6%96%B9%E6%B3%95) をインポートする
* __5 行目__：`pdist` のキーが確率変数の値、キーの値が確率変数に対する確率を表すので、`pdist.keys()` と  `pdist.values()` を `bar` の実引数に記述して棒グラフを描画する。また、`width=1` を記述して棒の幅を 1 にして棒と棒がくっついて描画されるようにした 
* __6, 7 行目__：x 軸と y 軸のラベルを描画する

```python
1  import matplotlib.pyplot as plt
2  import japanize_matplotlib
3  
4  def draw_pdist(pdist):
5      plt.bar(pdist.keys(), pdist.values(), width=1)
6      plt.xlabel("近似値")
7      plt.ylabel("確率")
```

<details><summary><strong>行番号のないプログラム</strong></summary>

```python
import matplotlib.pyplot as plt
import japanize_matplotlib

def draw_pdist(pdist):
    plt.bar(pdist.keys(), pdist.values(), width=1)
    plt.xlabel("近似値")
    plt.ylabel("確率")
```
</details>

上記の定義後に下記のプログラムで `pdistA` の確率分布を描画すると、実行結果のようなグラフ描画されます。

```python
draw_pdist(pdistA)
```

実行結果

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3524802/226f12d5-78c9-49ab-9a7d-8a19cd6621e7.png" width="350">

下記は `pdistA` と先程計算した `pdistNmax` の確率分布を __同時に描画__ するプログラムです。

```python
draw_pdist(pdistA)
draw_pdist(pdistNmax)
```

実行結果

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3524802/c95bcd00-2631-4469-8ce4-78aa0264889d.png" width="350">

__2 つの確率分布のグラフ__ を `draw_pdist` で描画すると、実行結果のように __重なった部分__ が後から描画したグラフで __上書きされてしまう__ という問題があることがわかります。

## `draw_pdist` の改良

matplotlib の `plot` や `bar` などでグラフを描画する際に、__透明度__ を表す __キーワード引数 `alpha`__ を記述することで __半透明なグラフ__ を描画することができます。__透明度__ は __0 以上 1 以下の実数__ で表現し、__0 が透明__、__1 が不透明__ を表します。そこで、__`draw_pdist`__ の __仮引数__ にグラフの __透明度を表す `alpha` を追加__ して半透明なグラフを描画できるようにします。

また、__複数のグラフを一度に描画__ する場合は __凡例を描画__ したほうがわかりやすいので、凡例に表示する __グラフのラベルを代入する仮引数を追加__ することにします。

下記はそのように __`draw_pdist`__ を改良したプログラムです。

* __1 行目__：グラフのラベルを代入する仮引数 `label` と、デフォルト値を不透明を表す 1.0 とする仮引数 `alpha` を追加した
* __2 行目__：キーワード引数 `label=label` と `alpha=alpha` を追加した
* __5 行目__：凡例を表示するようにした

```python
1  def draw_pdist(pdist, label, alpha=1.0):
2      plt.bar(pdist.keys(), pdist.values(), width=1, label=label, alpha=alpha)
3      plt.xlabel("近似値")
4      plt.ylabel("確率")
5      plt.legend()
```

<details><summary><strong>行番号のないプログラム</strong></summary>

```python
def draw_pdist(pdist, label, alpha=1.0):
    plt.bar(pdist.keys(), pdist.values(), width=1, label=label, alpha=alpha)
    plt.xlabel("近似値")
    plt.ylabel("確率")
    plt.legend()
```
</details>

<details><summary><strong>修正箇所</strong></summary>

```diff_python
-def draw_pdist(pdist):
+def draw_pdist(pdist, label, alpha=1.0):
-   plt.bar(pdist.keys(), pdist.values(), width=1)
+   plt.bar(pdist.keys(), pdist.values(), width=1, label=label, alpha=alpha)
    plt.xlabel("近似値")
    plt.ylabel("確率")
+   plt.legend()
```
</details>

上記の修正後に下記のプログラムを実行すると、実行結果のように __`pdistNmax` のグラフ__ が透明度が 0.8 の __半透明で描画__ されて __`pdistA` のグラフが透けて見える__ ようになります

```python
draw_pdist(pdistA, label="pdistA")
draw_pdist(pdistNmax, label="pdistNmax", alpha=0.8)
```

実行結果

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3524802/20378060-0a1c-49ab-b6cc-616597a1c1ca.png" width="350">

# 正規分布に従う離散型確率分布の作成

これまでに作成した確率分布は確率変数が 5 つしかないシンプルなものでしたが、__もっと多い確率変数__ を持つ確率分布を __記述するのは大変__ です。また、__特定の標準偏差__ を持つ確率分布を __作成するため__ には、確率変数とその確率を __何度も調整する必要がある__ ため __大変__ です。

統計学では __正規分布__ という __確率分布__ が良く使われており、Python には __指定した平均と標準偏差__ を持つ __正規分布を計算するモジュール__ が存在するので、それを使って静的評価関数が計算する近似値の __確率分布を作成する関数を定義__ することにします。

## 正規分布とは何か

関数を定義する前に、__正規分布__ について簡単に説明します。正規分布は、下図のような __ベル（釣り鐘）のような形状をしたグラフ__ で表される __確率分布__ で、学校で同じ学年の人間の身長や体重を計測した際の分布など、__多くの統計データ__ が __正規分布に似た分布__ になることが知られています。そのため、自然科学や社会科学などの様々な場面で、__複雑な現象を近似的に表す分布__ として __よく用いられます__。__正規分布の性質を持つ分布__ の事を、__正規分布に従う分布__ と表現します。

なお、下記のグラフの描画方法については、後で説明します。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3524802/3aa4a5a2-2b16-4bc9-ad6d-314168b9812b.png" width="350">

正規分布を表すグラフは、上図のように平均が最も多く、平均から離れるほど小さくなり、平均を中心とした左右対称になります。これは[以前の記事](https://qiita.com/ysgeso/items/23c4d441e6fab315456a#%E9%9D%99%E7%9A%84%E8%A9%95%E4%BE%A1%E9%96%A2%E6%95%B0%E3%81%AE%E8%A8%AD%E5%AE%9A)で __設定した静的評価関数の性質を満たします__。そこで、本記事では局面の形勢判断の難しさが同じ局面に対して __静的評価関数が計算する近似値__ が、__正規分布に従う__ ものとして以後の計算を行うことにします。

参考までに正規分布の Wikipedia のリンクを下記に示します。

https://ja.wikipedia.org/wiki/%E6%AD%A3%E8%A6%8F%E5%88%86%E5%B8%83

## 離散型確率分布と連続型確率分布

確率分布には __離散型確率分布__ と __連続型確率分布__ があり、正規分布に従う離散型確率分布を作成する際に必要となるのでその性質について説明します。

参考までに確率分布の Wikipedia のリンクを下記に示します。

https://ja.wikipedia.org/wiki/%E7%A2%BA%E7%8E%87%E5%88%86%E5%B8%83

先程のグラフは __平均が 0__、__標準偏差が 1__ の __正規分布__ のグラフです。そのような正規分布のことを __標準正規分布__ と呼び、以後の説明では __標準正規分布のグラフ__ を使って __説明を行う__ ことにします。

### 離散型確率分布

本記事では静的評価関数が計算する __近似値を整数とした__ ので、これまでの確率分布の __確率変数__ は -2, -1, 0, 1, 2 のような __整数__ でした。整数のような __飛び飛びの値__ を取る数値のことを __離散的__ と呼び、__離散的な確率変数__ を持つ __確率分布__ のことを __離散型確率分布__ と呼びます。離散型確率分布には以下のような用語と性質があります

* 確率変数は離散的な値をとるので __離散型確率変数__ と呼ぶ
* [前回の記事](https://qiita.com/ysgeso/items/23c4d441e6fab315456a#%E7%A2%BA%E7%8E%87%E5%88%86%E5%B8%83%E3%81%AE%E6%9C%9F%E5%BE%85%E5%80%A4%E3%81%A8%E6%A8%99%E6%BA%96%E5%81%8F%E5%B7%AE)で説明した、確率変数 $X$ の要素 である $x$ に対応する __確率を計算__ する $\boldsymbol{P(x)}$ のことを __確率質量関数__ と呼ぶ
* 確率分布を表す __確率質量関数__ をグラフ化すると、__ヒストグラムのようなグラフ__ でグラフ化される。ただし、ヒストグラムの縦軸が個数を表す 0 以上の整数であるのに対して、確率質量関数のグラフの __縦軸は確率を表す__ ので __0 以上 1 以下の実数__ となる

__離散型確率分布__ の __具体例__ としては、__サイコロの出目__ に対応する __確率分布__ などがあります。

### 連続型確率分布

__実数__ のような __連続した値__ を取る数値のことを __連続的__ と呼び、__連続的な確率変数__ を持つの __確率分布__ のことを __連続型確率分布__ と呼びます。連続型確率分布には以下のような用語と性質があります

* 確率変数は連続的な値ととるので __連続型確率変数__ と呼ぶ
* 連続型確率分布は確率変数 $X$ の要素である $x$ に対応した $f(x)$ を計算する __確率密度関数__ と呼ばれる関数で表される
* 確率質量関数と異なり、確率密度関数 $\boldsymbol{f(x)}$ __そのもの__ は __確率を表さない__
* 確率変数 $X$ が $\boldsymbol{a ≦ X ≦ b}$ __となる確率__ は、$\boldsymbol{a ≦ X ≦ b}$ __の範囲__ の確率密度関数 $\boldsymbol{f(x)}$ __と x 軸の間__ の __面積__ で表される
* __確率密度関数__ と __x 軸との間の面積__ は __1__ となる。確率密度関数と x 軸との間の面積は 1 となる理由は、$X$ が $-∞ ≦ X ≦ ∞$ となる確率が 1 だからである

例えば、下図の __確率密度関数__ を表す __青色のグラフ__ では、__赤色で塗りつぶされた面積__ が確率変数 $X$ が $\boldsymbol{a ≦ X ≦ b}$ __となる確率__ を表します。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3524802/b90a067f-6059-4e48-9bb8-a0ce735ee4c7.png" width="350">

:::note
確率密度関数 $f(x)$ が確率を表さない理由は下記の例から明らかです。

例えば下図の平均が 0、標準偏差が 1 の正規分布の確率密度関数では $f(0)$ の値は約 0.4 であることが読み取れます。また 0 の付近の $f(0.1)$ や $f(0.2)$ も 約 0.4 であることがわかります。もし、$f(0)$ が確率変数が 0 となる確率を表すとした場合は、確率変数が 0、0.1、0.2 となる __確率の合計__ が 0.4 + 0.4 + 0.4 = 1.2 のように __1 を超えてしまう__ という、おかしなことになります。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3524802/3aa4a5a2-2b16-4bc9-ad6d-314168b9812b.png" width="350">

別の考え方としては、__連続型確率変数__ の確率変数の __種類は無限にある__ ことから、__特定の確率変数が起きる確率は 0__ となり、確率変数の __範囲を指定する__ ことではじめてその __確率を求めることができる__ ようになります。
:::

:::note
上記の確率密度関数に関する性質は積分を使った下記の式で表現されます。なお、本記事では積分を使った計算の説明を行う予定は今の所ありませんので、下記の式の意味がわからない人は無視してもかまいません。

* 確率変数 $X$ が $a ≦ X ≦ b$ となる確率は $\int_{a}^{b}f(x)dx$
* $\int_{-∞}^{∞}f(x)dx = 1$
:::

### 確率密度関数の意味

下図の __オレンジ色のグラフ__ は、__青色の確率密度関数__ の確率分布の __確率変数__ を -0.5 ~ 0.5、0.5 ~ 1.5 のように __整数を中心とした幅が 1 の範囲__ で区切った場合 の __それぞれの範囲の確率__ を表すグラフです。このように、__確率密度関数__ の確率変数を __一定の範囲で区切ったグラフ__ は __確率質量関数のグラフ__ になります。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3524802/48da57a2-0186-4e6c-b8ac-4bb7385cec26.png" width="350">

下図は __確率変数__ を __0.5__、__0.2__、__0.1 ずつ区切った場合__ のグラフで、__区切る範囲が狭くなるほど__ 青色の __確率密度関数のグラフと同じ形に近くなる__ ことがわかります。このことから、__確率密度関数__ は __確率変数__ を __限りなく 0 に近い範囲__ で区切った場合の __確率質量関数のグラフ__ であることがわかります。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3524802/26312832-8485-48e8-9515-363f29db85e2.png" width="350">

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3524802/aecc3839-fa22-4ef3-be91-e4b0733f6f62.png" width="350">

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3524802/07ec26f7-0e4a-48ac-bbeb-d6e96f1b5cf3.png" width="350">

### 累積分布関数

__連続型確率分布__ の確率変数 $\boldsymbol{X}$ が $\boldsymbol{x}$ __以下となる確率__ を表す関数を __累積分布関数__ と呼びます。累積分布関数を $\boldsymbol{F(x)}$ と記述した場合に $F(x)$ は下図の __赤色の面積__ を表します。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3524802/ad027399-d4cf-4701-86fd-3e1849cf88c0.png" width="350">

:::note
累積分布関数は確率密度関数の積分を使って下記のように定義できます。

$F(a) = \int_{-∞}^{a}f(x)dx$
:::

平均が 0、標準偏差が 1 の正規分布の __累積分布関数のグラフ__ は下図のようになります。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3524802/9f0a7c85-bbe7-43d4-839f-b0e1e2eaa190.png" width="350">

__累積分布関数__ はその定義から __下記の性質__ があります。

* $F(-∞) = 0$
* $F(∞) = 1$

従って確率変数 $X$ が $\boldsymbol{a ≦ X ≦ b}$ __となる確率__ は __累積分布関数を利用した下記の式__ で計算することができます。

|a と b の条件|確率変数の範囲|確率|
|:--|:--|:--|
|$\boldsymbol{a = -∞ < b < ∞}$|$X ≦ b$|$F(b)$|
|$\boldsymbol{-∞ < a < b < ∞}$ |$a ≦ X ≦ b$|$F(b) - F(a)$|
|$\boldsymbol{-∞ < a < b = ∞}$ |$a ≦ X$|$1 - F(a)$|

### 正規分布と標準偏差の関係

平均を $μ$、標準偏差を $σ$、確率変数を $X$ とする __正規分布__ には下記の性質があります。

* 確率変数が $\boldsymbol{μ - σ ≦ X ≦ μ + σ}$ の範囲を取る確率は __約 68 %__ である
* 確率変数が $\boldsymbol{μ - 2σ ≦ X ≦ μ + 2σ}$ の範囲を取る確率は __約 95 %__ である
* 確率変数が $\boldsymbol{μ - 3σ ≦ X ≦ μ + 3σ}$ の範囲を取る確率は __約 97 %__ である

例えば、下記の __平均が 0__、__標準偏差が 1__ の __正規分布__ の確率密度関数のグラフでは、$-1 ≦ x ≦ 1$ の範囲の __赤色の面積が約 0.68__ となります。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3524802/8b5198bf-e8f5-4705-a84a-a79391c72985.png" width="350">


上記を覚えておくことで、__データの分布__ が __正規分布にほぼ従う__ ことがわかっている場合は、その __平均と標準偏差を計算__ することで、データが __上位何 % であるか__ を __簡単に知る__ ことができるようになります。これが __正規分布に従うと仮定__ した分析が __良く行われる理由__ です。なお、当然ですが __正規分布に従わない場合__ は __上記の性質は満たされません__。そのため、正規分布であると __安易に仮定して分析を進めるのは危険である__ ことに注意して下さい。

:::note
__%%timeit__ で表示される下記の内容は、JupyterLab のセルに記述したプログラムを 7 × 1000 = __7000 回行った際__ の __処理時間の平均が 120 s__、__標準偏差が 20 s__ であったことを表します。このことから、セルに記述したプログラムの __処理時間__ が __正規分布に従う場合__ は、その __68 % の処理__ が __120 ± 20 s__（100 秒以上 140 秒以下）、__95 % の処理__ が __120 ± 40 s__（80 秒以上 160 秒以下）__で行われた__ ことを意味します。

```
120 s ± 20 s per loop (mean ± std. dev. of 7 runs, 1,000 loops each)
```

なお、__一般的__ にはプログラムの __処理時間__ は __正規分布にほぼ従います__ が、プログラムが行う __処理の種類によって__ は __そうでない場合もある__ 点に注意が必要です。
:::

:::note
テストの結果などで表記される __偏差値__ は、テストの結果を __平均が 50__、__標準偏差が 10__ のデータに __変換した時の値__ を表します。__偏差値が 60__ の場合は、テストの結果が正規分布に従っていれば __累積分布関数__ から __上位約 15 % の成績__ であることを意味します。なお、テストが簡単すぎる場合など、テストの結果が __正規分布に従わない場合__ は __上位 15 % ではない__ ので、__偏差値は目安である__ ことに注意して下さい。
:::

## 正規分布を扱う NormalDist クラス

Python には正規分布などの __統計学__（statistics）に関する関数を集めた __statistics モジュール__ があり、その中で __正規分布__（normal distribution）を扱う __NormalDist というクラスが定義__ されています。__正規分布__ は __平均と標準偏差に__ よって __形状が決まる分布__ で、__`NormalDist`__
の実引数に平均と標準偏差を記述して呼び出すことで、その平均と標準偏差を持つ __正規分布に従う連続型確率分布__ を表す NormalDist のインスタンスが作成されます。

下記のプログラムは、__平均が 0、標準偏差が 1 の正規分布__ を表す NormalDist のクラスのインスタンスを作成し、__`ndist` に代入__ しています。

```python
from statistics import NormalDist

ndist = NormalDist(0, 1)
```

NormalDist クラスの詳細については下記のリンク先を参照して下さい。

https://docs.python.org/ja/3.13/library/statistics.html#normaldist-objects

:::note
正規分布のように、__特定のパラメータ__（値）の __組み合わせ__ によって __形状が決まる分布__ の事を __パラメトリック分布__ と呼び、様々なパラメトリック分布が存在します。

例えばサイコロを振った時の出目の確率分布のように、__すべての確率変数__ に対応する確率が 1/6 で __同じになる確率分布__ を __一様分布__ と呼びますが、一様分布もパラメトリック分布です。

特定の __パラメータ__ で __形状が決まらない__ 分布のことを __ノンパラメトリック分布__ と呼びます。ノンパラメトリック分布には正規分布のような __特定の名前はありません__。
:::

### 確率密度関数を計算する `pdf` メソッド

__正規分布__ の __確率密度関数__（probability density function）は Normaldist の __`pdf`__ というメソッドで計算できます。そのため、__確率密度関数__ は下記のプログラムで __グラフ化__ できます。

* __1 行目__：__-5 ~ 5__ までの数値を __0.01 刻み__ で要素として持つ __list を `X` に代入__ する。__`range` は整数しか扱えない__ ため、__小数点以下の数値を規則正しく増やす__ 処理を __`range` で行う__ 場合は、このように記述する必要がある[^1]
* __2 行目__：`X` のそれぞれの要素に対する正規分布の __確率密度関数の値__ を __`pdf` で計算__ して __`Y` に代入__ する
* __3 行目__：`X` と `Y` を `plot` メソッドでグラフ化する

```python
X = [x / 100 for x in range(-500, 500)]
Y = [ndist.pdf(x) for x in X]
plt.plot(X, Y)
```

実行結果

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3524802/3aa4a5a2-2b16-4bc9-ad6d-314168b9812b.png" width="350">

:::note
グラフの塗りつぶしは `fill_between` というメソッドを利用して描画することができます。また、x 軸のラベルは `xticks` というメソッドを利用して描画することができます。下記は先ほどのグラフを描画するプログラムです。

```python
X = [x / 100 for x in range(-500, 500)]
Y = [ndist.pdf(x) for x in X]
plt.plot(X, Y)
plt.fill_between(X[350:550], 0, Y[350:550], color="r")
plt.xticks([-1.5, 0.5], labels=["a", "b"])
```

実行結果

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3524802/b90a067f-6059-4e48-9bb8-a0ce735ee4c7.png" width="350">

`fill_between` と `xticks` の詳細については下記のリンク先を参照して下さい。
:::

https://matplotlib.org/stable/api/_as_gen/matplotlib.axes.Axes.fill_between.html

https://matplotlib.org/stable/api/_as_gen/matplotlib.axes.Axes.set_xticks.html

### 確率密度関数を計算する `cdf` メソッド

正規分布の __累積分布関数__（cumulative distribution function）は Normaldist の __`cdf`__ というメソッドで計算できるので、累積分布関数は下記のプログラムでグラフ化できます。

```python
X = [x / 100 for x in range(-500, 500)]
Y = [ndist.cdf(x) for x in X]
plt.plot(X, Y)
```

実行結果

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3524802/9f0a7c85-bbe7-43d4-839f-b0e1e2eaa190.png" width="350">

## 正規分布に従う離散型確率分布の計算

__正規分布に従う__、__確率変数が整数__ の __離散型確率分布__ は下記の手順で __NomalDist のインスタンスを利用して作成__ することができます。

* __平均と標準偏差を記述__ して NormalDist のインスタンス __`ndist` を作成__ する
* __整数の確率変数__ $X$ に対する __確率__ を、 $\boldsymbol{X - 0.5 ≦ x ≦ X + 0.5}$ __の範囲__ の `ndist` の __確率__ を表す __`ndist.cdf(X + 0.5) - ndist.cdf(X - 0.5)`__ で計算する

ただし、__正規分布__ の __確率変数の範囲__ は __実数全体__ であり、__無限に存在する確率変数__ に対する確率を __計算することは不可能__ なので、本記事では __平均を中心とした特定の範囲__ の確率変数の確率を計算することにします。具体的には下記のような関数を定義することにします。

__名前__：正規分布（normal distribution）に従う離散型（discrete）確率分布を計算するので、`calc_discrete_ndist` と命名する
__処理__：指定した平均と標準偏差を持ち、指定した範囲の確率変数に対する確率が計算された離散型確率分布を計算する
__入力__：仮引数 `m` に平均、`s` に標準偏差、`area` に確率変数の平均からの範囲を代入する
__出力__：計算した確率分布を返り値として返す

下記は `calc_discrete_ndist` の定義です。

* __1 行目__：仮引数 `m`、`s`、`area` を持つ関数として定義する
* __2 行目__：平均を `m`、標準偏差を `s` とする正規分布を表すデータを計算する
* __3 行目__：作成する離散型確率分布を表す `pdist` を空の dict で初期化する
* __4 行目__：平均から `area` の範囲の確率変数の繰り返し処理を行う
* __5、6 行目__：確率変数が `m - area` の場合は、`m - area` 以下の範囲の確率を表す `ndist.cdf(x + 0.5)` を計算する
* __7、8 行目__：確率変数が `m + area` の場合は、`m + area` 以上の範囲の確率を表す `ndist.cdf(x - 0.5)` を計算する
* __9、10 行目__：それ以外の場合は確率変数 `x` の確率を表す `ndist.cdf(x + 0.5) - ndist.cdf(x - 0.5)` を計算する
* __11 行目__：計算した確率分布を返り値として返す 

```python
 1  def calc_discrete_ndist(m, s, area):
 2      ndist = NormalDist(m, s)
 3      pdist = {}
 4      for x in range(m - area, m + area + 1):
 5          if x == m - area:
 6              pdist[x] = ndist.cdf(x + 0.5)
 7          elif x == m + area:
 8              pdist[x] = 1 - ndist.cdf(x - 0.5)
 9          else:
10              pdist[x] = ndist.cdf(x + 0.5) - ndist.cdf(x - 0.5)
11      return pdist
```

<details><summary><strong>行番号のないプログラム</strong></summary>

```python
def calc_discrete_ndist(m, s, area):
    ndist = NormalDist(m, s)
    pdist = {}
    for x in range(m - area, m + area + 1):
        if x == m - area:
            pdist[x] = ndist.cdf(x + 0.5)
        elif x == m + area:
            pdist[x] = 1 - ndist.cdf(x - 0.5)
        else:
            pdist[x] = ndist.cdf(x + 0.5) - ndist.cdf(x - 0.5)
    return pdist
```
</details>

下記のプログラムは __平均が 0__、__標準偏差が 1__、__確率変数の範囲を 10__ とした場合の __正規分布に従う離散型確率分布__ を計算し、その __平均と標準偏差と確率分布のグラフ__ を表示するプログラムです。実行結果から、小数点以下第 4 桁で四捨五入すると __平均が 0、標準偏差が 1.04__ の離散型確率分布が計算できていることがわかります。なお、連続型確率分布から離散型確率分布を計算する場合は、どうしても誤差が発生して __標準偏差が少しだけずれてしまう__ ので 1 にはなりませんが、1.04 なので __ほぼ 1 になっている__ ことが確認できます。

```python
pdist = calc_discrete_ndist(0, 1, 10)
print(calc_stval(pdist))
draw_pdist(pdist, label="平均=0, 標準偏差=1")
```

実行結果

```
(1.1102230246251565e-16, 1.0833333223611192, 1.0408329944621852)
```

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3524802/c855e425-39c1-4ef0-b891-dbe4f5f6f258.png" width="350">

下記のプログラムは __平均が 5__、__標準偏差が 5__、__確率変数の範囲を 10__ とした場合の正規分布に従う離散型確率分布を計算します。`calc_pdict` は __両端の確率変数に対する確率__ として、その __確率変数以下__（または以上）__の範囲__ の確率を計算するため、__両端の確率が他よりも高く計算__ されてしまいます。そのため、実行結果の確率分布の図からわかるように、標準偏差を 5 にすると、 __両端がおかしな形になる__ ことがわかります。また、__標準偏差__ も 4.69 のように __5 から離れた値__ になっています。

```python
pdist = calc_discrete_ndist(5, 5, 10)
print(calc_stval(pdist))
draw_pdist(pdist, label="平均=0, 標準偏差=5")
```

実行結果

```
(5.054260696265926, 22.016883005931323, 4.692215149151979)
```

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3524802/9b45c6d8-6da3-4657-abcf-27ea0269a6aa.png" width="350">

この問題は、__両端の付近の確率変数に対する確率__ が __ある程度以上小さければ__ ほとんど __目立たなくなります__。上記の場合は下記のプログラムのように __確率変数の範囲を 20 に増やす__ ことで両端の確率が大きいことがわからなくなります。また、__標準偏差__ は 5.01 と __ほぼ 5 が計算__ されており、確率分布の __グラフもきれいな形になる__ ことが確認できます。

```python
pdist = calc_discrete_ndist(5, 5, 20)
print(calc_stval(pdist))
draw_pdist(pdist, label="平均=0, 標準偏差=5")
```

実行結果

```
(5.000411582556574, 25.069427605637735, 5.006937947052843)
```

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3524802/bbe7850b-3b2d-4a14-ba3d-c557c79a24d4.png" width="350">

## `calc_pdist` での利用

今回の記事の最初で行った下図の max ノード N の近似値のミニマックス値の確率分布の計算を、静的評価関数が __標準偏差が 5 の正規分布__ に従う離散型確率分布で近似値の計算を行う場合で計算することにします。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3524802/b866a8d7-c492-48fc-994f-d286ddfd8113.png" width="250">　

下記はその計算を行うプログラムです。実行結果から __平均が 0 から 2.35 に増える__ 一方で、__標準偏差が 5 から 4.15 に減る__ ことが確認できます。また、グラフからオレンジ色のグラフが __右にずれて平均が増え__、__幅が狭く__ なり __ばらつき（標準偏差）が減る__ こと確認できます。

```python
pdistA = calc_discrete_ndist(0, 5, 20)
pdistB = calc_discrete_ndist(-1, 5, 20)
pdistN = calc_pdist([pdistA, pdistB], maxnode=True)
print(calc_stval(pdistN))
draw_pdist(pdistA, label="pdistA")
draw_pdist(pdistN, alpha=0.8, label="pdistN")
```

実行結果

```
(2.349041216719883, 17.213367568249353, 4.148899561118508)
```

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3524802/73ff51d3-55b9-4d6e-941b-1e74e5d79cea.png" width="350">

# 今回の記事のまとめ

今回の記事では `calc_pdist` の改良を行い、`draw_pdist` を定義して確率分布の視覚化を行うことができるようにしました。また、正規分布について説明し、正規分布に従う離散型確率変数を計算する関数を定義しました。

# 本記事で入力したプログラム

今回の記事で定義した `draw_pdist` と `calc_discrete_ndist` は `tree.py` に保存することにします。

| リンク | 説明 |
|:--|:--|
| [marubatsu.ipynb](https://github.com/ysgeso/marubatsu/blob/master/177/marubatsu.ipynb)  | 本記事で入力して実行した JupyterLab のファイル |
| [tree.py](https://github.com/ysgeso/marubatsu/blob/master/177/tree_new.py)  | 本記事で更新した tree_new.py  |

[^1]:本記事ではまだ紹介していない numpy というモジュールの `arange` という関数を利用することで、実数に対して規則正しく増えていくような処理をより簡潔に記述することができます