決定表 (デシジョンテーブル) の自動生成スクリプトをpythonのテスト駆動開発で書いてみた



テストケースを作成する際に、決定表の論理テーブルの部分を書くのが面倒くさかったので、pythonで自動化スクリプトを書いてみました :muscle::muscle:

各因子の水準数のリストをインプットに、下図の赤枠の中の部分をcsvに出力する関数です :raised_hands::raised_hands:


<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/287280/185b9637-e6f9-f3cd-b12a-ce5b129231b1.png" height="200" title="サンプル">

以下のような読者の方は参考になるのではないでしょうか？
・決定表を作成するスクリプトが欲しい方
・テスト駆動開発の題材が欲しい方
・pythonでのユニットテストの書き方を知りたい方

## 環境 ・ ディレクトリ情報
`Python` 3.9.0
`numpy`                                  1.16.6
※`unittest`は組み込みで使えます

```
- root
    - main.py
    - make_decision_table.py
    - test.py
    - make_decision_table[2, 3, 2, 2, 2, 2].csv
```

## 完成品

- `main.py` から `make_decisioni_table()`を呼んでいます。
- level_list (水準リスト) に自分の欲しい各因子の水準数を追加することで、csv形式の決定表が出力されます。

```python:main.py
import numpy as np
from make_decision_table import make_decision_table
def main():
    level_list = [2,3,2,2,2,2]
    table = make_decision_table(level_list)
    np.savetxt(f'make_decision_table{level_list}.csv', table, delimiter=',', fmt='%s')

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

```python:make_decisioni_table.py
import numpy as np

def make_decision_table(level_list:list):

    def recursive_make_table(n:int):

        if n == 1:
            res = table_basis(level_list[0])
            return res

        else:
            lower_table = recursive_make_table(n-1)
            top = np.repeat(table_basis(level_list[n-1]), lower_table.shape[1],  axis=1)
            bottom = np.tile(lower_table, level_list[n-1])
            res = np.vstack([top, bottom])
            return res

    n_level = len(level_list)
    return recursive_make_table(n_level)


def table_basis(level:int):

    res = np.full((level, level), '-')    
    for i in range(level):
        for j in range(level):
            if (i == j):
                res[i,j] = 'Y'
    return res
```

## テスト駆動開発でのプロセス
それではテスト駆動でどのようにこのスクリプトを作り上げていったかを追って説明していきます
まずは、因子:1, 水準数:2の最も単純なテストケースを作成し、それを通るようにコードを書いていきます :writing_hand::writing_hand:

```python:test.py
import numpy as np
import unittest
from make_decision_table import make_decision_table

class TestMakeDecisionTable(unittest.TestCase):

    def test_2(self):
        expected = np.array(
            [
                ["Y", "-"],
                ["-", "Y"]
            ]
        )
        level_list = [2]
        actual = make_decision_table(level_list)
        self.assertEqual(expected, actual)
```

```python:make_decisioni_table.py

import numpy as np

def make_decision_table(level_list:list):
    return np.array(
            [
                ["Y", "-"],
                ["-", "Y"]
            ]
        )
```
テストの期待値と全く同じ配列を返すように実装します。これで１つのテストケースは必ず通るはずですね :ok_hand::ok_hand:

```
decesion_table % python3 -m unittest test
...
    if not first == second:
ValueError: The truth value of an array with more than one element is ambiguous. Use a.any() or a.all()
```

おっと、`unittest.assertEqual` は `numpy.ndarray` に対応していないのでテストに落ちてしまいました。
先に進みたいので、 `_assertEqual` を自分で用意しました。

```python:test.py
def _assertEqual(self, arrayExpected, arrayActual) -> bool:
        for i in range(len(arrayExpected)):
            for j in range(len(arrayExpected)):
                if (arrayExpected[i,j] != arrayActual[i,j]):
                    return False
```
```python:test.py
def test_2(self):
        expected = np.array(
            [
                ["Y", "-"],
                ["-", "Y"]
            ]
        )
        level_list = [2]
        actual = make_decision_table(level_list)
        self.assertTrue(self._assertEqual(expected, actual))
        print('test_2: ',actual, sep='\n')
```

```python:make_decesion_table 
% python3 -m unittest test
test_2: 
[['Y' '-']
 ['-' 'Y']]
.
----------------------------------------------------------------------
Ran 1 tests in 0.001s

OK
```
はい、1つ目のテストケースが通りました:v::v:
2つ目に移りましょう !! 
因子:1, 水準:3のケースを追加します。

```python:test.py
import numpy as np
import unittest
from make_decision_table import make_decision_table

class TestMakeDecisionTable(unittest.TestCase):

    def test_2(self):
        expected = np.array(
            [
                ["Y", "-"],
                ["-", "Y"]
            ]
        )
        level_list = [2]
        actual = make_decision_table(level_list)
        print('test_2: ',actual, sep='\n')
        self.assertTrue(self._assertEqual(expected, actual))
        
        

    def test_3(self):
        expected = np.array(
            [
                ["Y", "-", "-"],
                ["-", "Y", "-"],
                ["-", "-", "Y"]
            ]
        )
        level_list = [3]
        actual = make_decision_table(level_list)
        print('test_3: ',actual, sep='\n')
        self.assertTrue(self._assertEqual(expected, actual))
        


    def _assertEqual(self, arrayExpected, arrayActual) -> bool:
        if (arrayExpected.shape != arrayActual.shape):
            print("決定表の配列のサイズが間違っています")
            return False

        for i in range(len(arrayExpected)):
            for j in range(len(arrayExpected)):
                if (arrayExpected[i,j] != arrayActual[i,j]):
                    print(f"決定表の要素[{i},{j}]が異なります")
                    return False

        return True
```

とりあえず、テストを実行してみましょう。

```
decesion_table % python3 -m unittest test
test_2: 
[['Y' '-']
 ['-' 'Y']]
.決定表の配列のサイズが間違っています
F
======================================================================
FAIL: test_3 (test.TestMakeDecisionTable)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/Users/nakaitaketo/workspace/decesion_table/test.py", line 30, in test_3
    self.assertTrue(self._assertEqual(expected, actual))
AssertionError: False is not true

----------------------------------------------------------------------
Ran 2 tests in 0.001s

FAILED (failures=1)
```

想定通り2個目のテストケースで落ちたので、このケースを通すようにコードを修正しましょう。因子が1の時、水準数がいくつでも正しい決定表が作成できるように修正します。


```python:make_decision_table.py
def make_decision_table(level_list:list):
    # return np.array(
    #         [
    #             ["Y", "-"],
    #             ["-", "Y"]
    #         ]
    #     )
    factor = level_list[0]
    res = np.full((factor, factor), '-')
    
    for i in range(factor):
        for j in range(factor):
            if (i == j):
                res[i,j] = 'Y'
    return res
```

```
decesion_table % python3 -m unittest test
test_2: 
[['Y' '-']
 ['-' 'Y']]
.test_3: 
[['Y' '-' '-']
 ['-' 'Y' '-']
 ['-' '-' 'Y']]
.
----------------------------------------------------------------------
Ran 2 tests in 0.001s

OK
```
はい、通りました！ それでは、次のステップでは複数の因子に対応できるようにしたいです！まずは、
因子:2, 水準:[2, 2]のテストケースを追加します。

```python:test.py
def test_2by2(self):
        expected = np.array(
            [
                ["Y", "Y", "-", "-"],
                ["-", "-", "Y", "Y"],
                ["Y", "-", "Y", "-"],
                ["-", "Y", "-", "Y"]
            ]
        )
        level_list = [2, 2]
        actual = make_decision_table(level_list)
        print('test_2by2: ',actual, sep='\n')
        self.assertTrue(self._assertEqual(expected, actual))
```


```python:make_decision_table.py

import numpy as np

def make_decision_table(level_list:list):
    # factor = level_list[0]
    # res = np.full((factor, factor), '-')

    # for i in range(factor):
    #    for j in range(factor):
    #       if (i == j):
    #            res[i,j] = 'Y'
    # return res

    if len(level_list) == 1:

        res = np.full((level_list[0], level_list[0]), '-')
        
        for i in range(level_list[0]):
            for j in range(level_list[0]):
                if (i == j):
                    res[i,j] = 'Y'

        return res

    elif len(level_list) == 2:
        len_of_one_side = sum(level_list)
        res = np.full((len_of_one_side, len_of_one_side), '-')
        bottom_piece = make_decision_table( [ level_list[0] ] )
        bottom = np.hstack([bottom_piece, bottom_piece])
        top = top_maker(level1=level_list[0], level2=level_list[1])
        res = np.vstack([top, bottom])
        return res

    else:
        return None


def top_maker(level1:int, level2:int):

    level1_table = make_decision_table( [ level1 ] )
    res = np.repeat(level1_table, level2,  axis=1)

    return res
```
少し複雑になってきましたが、因子数(`level_list`)が1のときと2のときでif文で処理を分けています。これで、因子=2のときにも対応できるようにします。

テストを実行してみましょう。

```
nakaitaketo@k3-nakai decesion_table % python3 -m unittest test
test_2: 
[['Y' '-']
 ['-' 'Y']]
.test_2by2: 
[['Y' 'Y' '-' '-']
 ['-' '-' 'Y' 'Y']
 ['Y' '-' 'Y' '-']
 ['-' 'Y' '-' 'Y']]
.test_3: 
[['Y' '-' '-']
 ['-' 'Y' '-']
 ['-' '-' 'Y']]
.
----------------------------------------------------------------------
Ran 3 tests in 0.001s

OK
```
テストが通りました！ 
さらに汎用化することを前提に、ここまでの結果を元にどうやって決定表を作成できるかを考察してみましょう。
まず、因子:2, 水準: [2,2] のケースを考えます。具体的にはこれを作ることを考えましょう。

```
[['Y' 'Y' '-' '-']
 ['-' '-' 'Y' 'Y']
 ['Y' '-' 'Y' '-']
 ['-' 'Y' '-' 'Y']]
```
因子:1, 水準: [2] の決定表はこれでした。ここから作れないでしょうか？

```
[['Y' '-']
 ['-' 'Y']]
```
このように考えました。まず、目標のマトリックスを上下で2つに分けます。

- 上部は、因子:1, 水準: [2] の決定表を列方向に２倍に引き延ばすことで作れます。

```
[['Y' 'Y' '-' '-']
 ['-' '-' 'Y' 'Y']
```

- 下部は、因子:1, 水準: [2] の決定表を列方向に２倍することで作れます。
 
```
['Y' '-' 'Y' '-']
 ['-' 'Y' '-' 'Y']]
```

これらの処理はpythonの`np.repeat`、`np.tile`関数を用いて実現することができます！
numpyさすがです！ Ward Cunningham(ウォード・カニンガム) が、

美しいコードは、その言語がまるでその問題を解決するために作られたかのように見せる

と言っていたのを思い出しました。numpyは、この問題を解くために作られたかのようにエレガントですね :clap::clap:
考察をコードに反映させましょう。


```python:make_decision_table.py
import numpy as np

def make_decision_table(level_list:list):

    if len(level_list) == 1:

        res = np.full((level_list[0], level_list[0]), '-')
        
        for i in range(level_list[0]):
            for j in range(level_list[0]):
                if (i == j):
                    res[i,j] = 'Y'

        return res

    elif len(level_list) == 2:
        # len_of_one_side = sum(level_list)
        # res = np.full((len_of_one_side, len_of_one_side), '-')
        # bottom_piece = make_decision_table( [ level_list[0] ] )
        # bottom = np.hstack([bottom_piece, bottom_piece])
        # top = top_maker(level1=level_list[0], level2=level_list[1])
        top = top_maker(level_list[0], level_list[1])
        bottom = bottom_maker(level_list[0], level_list[1])
        res = np.vstack([top, bottom])
        return res

    else:
        return None

def top_maker(level1:int, level2:int):

    top_piece = make_decision_table( [ level1 ] )
    res = np.repeat(top_piece, level2,  axis=1)
    return res

def bottom_maker(level1:int, level2:int):

    bottom_piece = make_decision_table( [ level1 ] )
    res = np.tile(bottom_piece, level2)
    return res


```
因子数=3のケースに進みましょう!


```python:test.py
def test_3by2by2(self):
        expected = np.array(
            [
                ["Y", "Y", "Y", "Y", "-", "-", "-", "-", "-", "-", "-", "-"],
                ["-", "-", "-", "-", "Y", "Y", "Y", "Y", "-", "-", "-", "-"],
                ["-", "-", "-", "-", "-", "-", "-", "-", "Y", "Y", "Y", "Y"],
                ["Y", "Y", "-", "-", "Y", "Y", "-", "-", "Y", "Y", "-", "-"],
                ["-", "-", "Y", "Y", "-", "-", "Y", "Y", "-", "-", "Y", "Y"],
                ["Y", "-", "Y", "-", "Y", "-", "Y", "-", "Y", "-", "Y", "-"],
                ["-", "Y", "-", "Y", "-", "Y", "-", "Y", "-", "Y", "-", "Y"]
            ]
        )
        level_list = [2, 2, 3]
        actual = make_decision_table(level_list)
        print('test_3by2by2: ',actual, sep='\n')
        self.assertTrue(self._assertEqual(expected, actual))
```

ここまでくると、因子数によってif文で処理を分けるのは厳しくなってきますね :sweat_smile::sweat_smile:
しかし、先ほどの考察のおかげで、この問題には再帰的な構造があることに気付きました。つまり、より大きな決定表はそれより小さい決定表を使って作成することができるということです。従って、再帰のアルゴリズムを意識しながら、コードを書き換えてみました。

```python:make_decision_table.py
import numpy as np

def make_decision_table(level_list:list):

    def recursive_make_table(n:int):

        if n == 1:

            res = table_basis(level_list[0])
            return res

        # elif len(level_list) == 2:
            
        #     bottom = bottom_maker(level_list[0], level_list[1])
        #     top = top_maker(level_list[0], level_list[1])
        #     res = np.vstack([top, bottom])
        #     return res

        # else:
            # return None

        else:

            top = np.repeat(table_basis(level_list[n-1]), len(recursive_make_table(n-1)),  axis=1)
            bottom = np.tile(recursive_make_table(n-1), level_list[n-1])
            res = np.vstack([top, bottom])
            return res

    n_level = len(level_list)
    return recursive_make_table(n_level)


def table_basis(level:int):

    res = np.full((level, level), '-')    
    for i in range(level):
        for j in range(level):
            if (i == j):
                res[i,j] = 'Y'
    return res
```

急に出てきた`table_basis`関数は、この再帰的なロジックの基礎的な部分を構築するのを手助けしてくれます。 再帰関数はマトリョーシカのように、自分より小さい自分自身と相似な構造を次々に呼んでいきますが、必ず、最も小さい部分が出てきます。それは基底 (basis) と呼ばれるものであり、今回の場合では、水準数nの１つ目の因子で、n x n の対角に'Y'がならんだ行列が基底です。加えて、考察のところで見たように、n > 2の場合でも、上部の部分の構造を作るのに必要な関数になってきます。

```
decesion_table % python3 -m unittest test
test_2: 
[['Y' '-']
 ['-' 'Y']]
.test_2by2: 
[['Y' 'Y' '-' '-']
 ['-' '-' 'Y' 'Y']
 ['Y' '-' 'Y' '-']
 ['-' 'Y' '-' 'Y']]
.test_3by2by2: 
[['Y' 'Y' 'Y' 'Y' '-' '-' '-' '-' '-' '-' '-' '-']
 ['-' '-' '-' '-' 'Y' 'Y' 'Y' 'Y' '-' '-' '-' '-']
 ['-' '-' '-' '-' '-' '-' '-' '-' 'Y' 'Y' 'Y' 'Y']
 ['Y' 'Y' '-' '-' 'Y' 'Y' '-' '-' 'Y' 'Y' '-' '-']
 ['-' '-' 'Y' 'Y' '-' '-' 'Y' 'Y' '-' '-' 'Y' 'Y']
 ['Y' '-' 'Y' '-' 'Y' '-' 'Y' '-' 'Y' '-' 'Y' '-']
 ['-' 'Y' '-' 'Y' '-' 'Y' '-' 'Y' '-' 'Y' '-' 'Y']]
.test_3: 
[['Y' '-' '-']
 ['-' 'Y' '-']
 ['-' '-' 'Y']]
.
----------------------------------------------------------------------
Ran 4 tests in 0.003s

OK
```

はい、通りました！　完成です！:raised_hands::raised_hands:
整理したものを以下にあげます。


```python:make_decision_table.py
import numpy as np

def make_decision_table(level_list:list):

    def recursive_make_table(n:int):

        if n == 1:
            res = table_basis(level_list[0])
            return res

        else:
            lower_table = recursive_make_table(n-1)
            top = np.repeat(table_basis(level_list[n-1]), lower_table.shape[1],  axis=1)
            bottom = np.tile(lower_table, level_list[n-1])
            res = np.vstack([top, bottom])
            return res

    n_level = len(level_list)
    return recursive_make_table(n_level)


def table_basis(level:int):

    res = np.full((level, level), '-')    
    for i in range(level):
        for j in range(level):
            if (i == j):
                res[i,j] = 'Y'
    return res    
```

```python:main.py
import numpy as np
from make_decision_table import make_decision_table
def main():
    level_list = [2,3,2,2,2,2]
    table = make_decision_table(level_list)
    np.savetxt(f'make_decision_table{level_list}.csv', table, delimiter=',', fmt='%s')

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

## まとめ
テスト先行で実装することでインクリメンタルに開発を進めることができます。数学の問題を解く時も、簡単なケースから初めて具体的な例を書き出すことを皆さんやられたのではないかなと思います。その感覚と同じで、一見複雑に見える再帰プログラムでも、問題を分割して少しずつ解いていけば解くことができます。一回でポンと正解を導くのではなく、少しずつ問題を理解しながら解いていくのは、問題解決においてとても有効なプロセスではないでしょうか？今回の投稿では、それを実感できたこと、numpyの素晴らしさを再確認できたことがよかったと思います。皆さんも良いテスト駆動LIFEを！

最後までご覧いただき、ありがとうございました！