numpyでも型ヒントチェックしたいと思った


## 概要

Python3.5以降の[PEP484](https://www.python.org/dev/peps/pep-0484/)で追加された型ヒント。
[numpy](https://numpy.org)のndarrayにも適用できないかと思い、型ヒント静的チェックツールである[mypy](https://mypy.readthedocs.io/en/latest/index.html)や、サードパーティモジュールへの対処などｗｐ調べた結果についてまとめる。

## 結論

結論からいうと、mypyを用いたnumpy.ndarrayの型ヒントチェックは[numpy-stubs](https://github.com/numpy/numpy-stubs)を用いれば可能。
ただし、現（2020年1月）時点ではndarrayのdtype, shapeを指定してのmypyチェックはできない。
一方、dtype, shapeを含めた型ヒントを可読性のためのアノテーションとしてつけたいというのであれば、[nptyping](numpy-type-hints-in-python-pep-484)を用いるという選択肢がよさそう。

## 準備

最低限以下を`pip`でインストールしておく。（カッコ内は筆者の検証時の環境）

- numpy (1.18.1)
- mypy (0.761)

## mypyによる型チェック

まず型チェックとは何ぞやという状態だったので実験してみた。

```python
# ex1.py

from typing import List, Tuple

def calc_center(points: List[Tuple[int, int]]) -> Tuple[float, float]:
    '''点のリストから重心を求める'''
    n = len(points)
    x, y = 0, 0
    for p in points:
        x += p[0]
        y += p[1]
    return x/n, y/n

points_invalid = [[1, 1], [4, 2], [3, 6], [-1, 3]]

print(calc_center(points_invalid))  # TypeHint Error
```

上記のコード、もちろん正常終了するが、mypyで型ヒントチェックしてみる。
pip等でmypyをインストールしていれば、ターミナルで`mypy`を実行できるはずだ。

```
>mypy ex1.py
ex1.py:16: error: Argument 1 to "calc_center" has incompatible type "List[List[int]]"; expected "List[Tuple[int, int]]"
Found 1 error in 1 file (checked 1 source file)
>python ex1.py
(1.75, 3.0)
```

このように、型ヒントが違反している箇所を指摘してくれる。以下のように修正するとmypyによるチェックは通る。

```python
# ex2.py

from typing import List, Tuple

def calc_center(points: List[Tuple[int, int]]) -> Tuple[float, float]:
    '''点のリストから重心を求める'''
    n = len(points)
    x, y = 0, 0
    for p in points:
        x += p[0]
        y += p[1]
    return x/n, y/n

points = [(1, 1), (4, 2), (3, 6), (-1, 3)]

print(calc_center(points))  # Success
```

```
>mypy ex2.py
Success: no issues found in 1 source file
```

## サードパーティモジュールに対する型チェック

座標点の計算などはnumpyのndarrayが便利であるのでそのように変更したい。
しかし、先ほどのコードに`import numpy`を追加してmypyを走らすと以下エラーが出てしまう。

```python
# ex3.py

from typing import List, Tuple
import numpy as np

def calc_center(points: List[Tuple[int, int]]) -> Tuple[float, float]:
    '''点のリストから重心を求める'''
    n = len(points)
    x, y = 0, 0
    for p in points:
        x += p[0]
        y += p[1]
    return x/n, y/n

points = [(1, 1), (4, 2), (3, 6), (-1, 3)]

print(calc_center(points))  # Success
```

```
>mypy ex3.py
ex3.py:4: error: No library stub file for module 'numpy'
ex3.py:4: note: (Stub files are from https://github.com/python/typeshed)
Found 1 error in 1 file (checked 1 source file)
```

エラーの原因はnumpyパッケージ自体が型ヒントに対応してないからである。
じゃあどうすんのということで以下3つのケースで対策を分けてみる。

### 方法1. サードパーティ製の型ヒントは無視

これが一番楽な方法で一般的なようだ。
やり方は、mypy.iniという名前でファイルを作成し、以下のように記述した後カレントディレクトリに置く。

```ini
[mypy]

[mypy-numpy]
ignore_missing_imports = True
```

3,4行目がnumpyに対して、型ヒントチェックのエラーを無視するような設定になる。
他のサードパーティモジュールにも適用したい場合は3,4行目をコピペし、`numpy`の部分を変えればよい。
その他、mypy.iniに関する仕様は公式の[こちら](https://mypy.readthedocs.io/en/latest/config_file.html)のページを参照してほしい。

これでmypyチェックを正常に走らすことができる。しかし、ndarray自体の型ヒントチェックも無視されるので注意（最後の行）。

```python
# ex4.py (ignore_missing_imports)

from typing import List, Tuple
import numpy as np

def calc_center(points: List[Tuple[int, int]]) -> Tuple[float, float]:
    '''点のリストから重心を求める'''
    n = len(points)
    x, y = 0, 0
    for p in points:
        x += p[0]
        y += p[1]
    return x/n, y/n

def calc_center_np(points: np.ndarray) -> np.ndarray:
    '''点のリストから重心を求める(ndarray版)'''
    return np.average(points, axis=0)

points = [(1, 1), (4, 2), (3, 6), (-1, 3)]

print(calc_center(points))  # Success

np_points = np.array(points, dtype=np.int)

print(calc_center_np(np_points))  # Success
print(calc_center_np(points))  # Success ?
```

```
>mypy ex4.py
Success: no issues found in 1 source file
```

### 方法2. 型ヒント用スタブを作成

使いたいモジュールの型ヒント用の空っぽの関数（スタブ）を作成することで、mypyが代わりにそれらを見てくれる。スタブファイルは.pyiという拡張子で管理されている。

githubにnumpy用のスタブ[numpy-stubs](https://github.com/numpy/numpy-stubs)が公開されていて利用できる。

まず`git clone https://github.com/numpy/numpy-stubs.git`等で"numpy-stubs"フォルダを持ってくる。
"numpy-stubs"フォルダを"numpy"に変更する。

フォルダ構成としては以下のようになる。

```
numpy-stubs/
└── numpy
    ├── __init__.pyi
    └── core
        ├── numeric.pyi
        ├── numerictypes.pyi
        ├── _internal.pyi
        └── __init__.pyi
```

さらに、`MYPYPATH`という環境変数にスタブが置かれているルートフォルダパスを追加して実行する。

```python
# ex5.py (numpy-stubs)

from typing import List, Tuple
import numpy as np

def calc_center(points: List[Tuple[int, int]]) -> Tuple[float, float]:
    '''点のリストから重心を求める'''
    n = len(points)
    x, y = 0, 0
    for p in points:
        x += p[0]
        y += p[1]
    return x/n, y/n

def calc_center_np(points: np.ndarray) -> np.ndarray:
    '''点のリストから重心を求める(ndarray版)'''
    return np.average(points, axis=0)

points = [(1, 1), (4, 2), (3, 6), (-1, 3)]

print(calc_center(points))  # Success

np_points = np.array(points, dtype=np.int)
np_points_float = np.array(points, dtype=np.float)

print(calc_center_np(np_points))  # Success
print(calc_center_np(np_points_float))  # Success
print(calc_center_np(points))  # TypeHint Error
```

```
>set "MYPYPATH=numpy-stubs"
>mypy ex5.py
ex5.py:28: error: Argument 1 to "calc_center_np" has incompatible type "List[Tuple[int, int]]"; expected "ndarray"
Found 1 error in 1 file (checked 1 source file)
```

これで、ndarray自体の型ヒントチェックが機能する。しかし、dtype, shapeの指定した上でのチェックはできない、また、いちいち環境変数を設定しなくてはいけないのが若干ネックである。

#### stubgen

mypyには[stubgen](https://mypy.readthedocs.io/en/latest/stubgen.html)というスクリプトがついており、自動的に型ヒント用のファイル（.pyi拡張子）を生成してくれる。

```
>stubgen -p numpy
```

`-p`はパッケージ用に再帰的にスタブを生成するオプションである。
実行するとカレントディレクトリに`out`フォルダが生成されておりその中にnumpyのスタブファイルが詰まっている。

しかし、stubgenがうまくnumpyの構造を抽出できてないせいか、mypyチェックを実行すると別のエラーが出てしまう。numpy-stubsのようにスタブが有志で公開されてるケースもあるので、できればそちらを使う方が無難である。

### 方法3. nptypingも使う

方法1., 方法2.のどちらかを取ったうえで、ndarrayのdtype, shapeを含めた型ヒントを組みたいというのであれば、[nptyping](numpy-type-hints-in-python-pep-484)を用いるとよい。

PyPiから`pip install nptyping`でインストールできる。

nptypingはmypyによる静的な型ヒントチェックには対応していないものの、ndarrayのdtype, shapeを指定した型ヒントを`Array`というエイリアスを用いて指定できる。

以下は公式のサンプル。pandasのDataFrameのような型が入り混じった配列もOK。

```python
from nptyping import Array
Array[str, 3, 2]    # 3 rows and 2 columns
Array[str, 3]       # 3 rows and an undefined number of columns
Array[str, 3, ...]  # 3 rows and an undefined number of columns
Array[str, ..., 2]  # an undefined number of rows and 2 columns
Array[int, float, str]       # int, float and str on columns 1, 2 and 3 resp.
Array[int, float, str, ...]  # int, float and str on columns 1, 2 and 3 resp.
Array[int, float, str, 3]    # int, float and str on columns 1, 2 and 3 resp. and with 3 rows
```

`isinstance`を用いたインスタンスチェックも可能である。


```python
# ex6.py (nptyping)

from typing import List, Tuple
import numpy as np
from nptyping import Array

def calc_center(points: List[Tuple[int, int]]) -> Tuple[float, float]:
    '''点のリストから重心を求める'''
    n = len(points)
    x, y = 0, 0
    for p in points:
        x += p[0]
        y += p[1]
    return x/n, y/n

def calc_center_np(points: Array[int, ..., 2]) -> Array[float, 2]:
    '''点のリストから重心を求める(ndarray版)'''
    print(isinstance(points, Array[int, ..., 2]))
    return np.average(points, axis=0)

points = [(1, 1), (4, 2), (3, 6), (-1, 3)]

np_points = np.array(points, dtype=np.int)
np_points_float = np.array(points, dtype=np.float)

print(isinstance(calc_center_np(np_points), Array[float, 2]))  # 引数: True, 戻り値: True
print(isinstance(calc_center_np(np_points_float), Array[float, 2]))  # 引数: False, 戻り値: True
print(isinstance(calc_center_np(points), Array[float, 2]))  # 引数: False, 戻り値: True
```

mypy.iniでnptypingを`ignore_missing_imports = True`に設定するのを忘れずに。
実行結果は以下。

```
>mypy ex6.py
Success: no issues found in 1 source file
>python ex6.py
True
True
False
True
False
True
```

## まとめ

numpyまわりの型ヒントについてざっくりまとめた。
座標・表データなどの情報をndarrayとして扱い、幾何的・統計的な演算を実装することはよくあると思う。
その際に「何次元のndarrayをこねくり回してるのか？」などと自分が書いたコードでもよく悩んだりする。
可読性や保守性も踏まえてnptypingのような型ヒントは有用だと思った。
将来的にmypyによる型チェックにも対応できるとより有用性が高まるのではないかと思う。

## 参考記事

https://stackoverflow.com/questions/52839427/
https://www.sambaiz.net/article/188/
https://masahito.hatenablog.com/entry/2017/01/08/113343