サブロウ丸

Sabrou-mal サブロウ丸

主にプログラミングと数学

Python: デバッグとプロファイル

本稿の目的と構成

研究(仮説)とプログラミング(検証)は密接に関わっており、バグの少ないコードは実験のやり直しを最小限にし、手法の検討に必要な正しい情報の収集をサポートする。 しかしながら、テストコードを書いたり、バグの原因を見つける作業は大変な労力を要するもの。ただ幸いなことに先人のノウハウやツールを我々は利用することができる。 本稿では、テスト、デバッグ、バグ原因特定に関する汎用的なテクニックをそれぞれに対応するPythonツールの使い方とともに紹介する。

紹介順序 対応ソフト
1 例外について
2 バグの根本的箇所の発見 pdb
3 バグの発見 pytest + alpha
4 再発防止 assertion
5 プロファイル cProfile

バグとは

プログラムが何らかの事情で処理を行えなくなる、もしくは挙動が意図したものとずれること。バグの例を挙げてみる。

  • 算術、浮動小数点演算
    • ゼロ割 / オーバーフロー / 桁落ち
  • メモリアクセス
    • 解放済みのメモリや配列の定義域外にアクセス
  • メモリ解放
    • メモリ解放忘れ (memory leak) / 二回メモリを解放 (double free)
  • プログラムミス
    • 平均と中央値を間違える / 無駄なメモリの使用 / etc...

ただ、システムがエラーを返さないバグもあり、それらはユーザーが頑張って見つける必要がある。

  • テストを作成して実行する
  • 計算結果に違和感を感じる、など...

例外 (Exception)

例外とは、エラーが生じたときに実行を強制終了させるのではなく、エラーに対して処理(例外処理)を行い実行を継続できるようにするためのもの。

Exceptionを活用することで、後で紹介するようにtry ~ exceptによる例外処理や、エラーの具体的な種類を把握することができる。そのため、まずはこの例外の紹介から始めよう。

Pythonだと組み込みクラスとしてExceptionクラスが存在し*1、それを継承していくつかの具体的な例外クラスが実装されている。

>>> Exception
<class 'Exception'>
>>> RuntimeError
<class 'RuntimeError'>
>>> RuntimeError.__mro__  # 親クラスを確認
(<class 'RuntimeError'>, <class 'Exception'>, <class 'BaseException'>, <class 'object'>)

実際にRuntimeErrorの親クラスを確認するとExceptionから継承(or 派生)して作成されていることがわかる。これらの例外はエラー発生時にそれに適したExceptionが返される(ようにPythonやライブラリ側で実装されている)。

>>> 1 / 0
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ZeroDivisionError: division by zero

また、自分でExceptionクラスを継承して新しい例外を作成することもできる。 ここでは1で割るとエラーを起こすOneDivisionErrorを実装しています。

>>> class OneDivisionError(Exception):
...     pass
>>> def divide(a, b):
...   if b == 1:
...     raise OneDivisionError
...   else:
...     return a / b 
>>> divide(3, 1)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 3, in divide
__main__.OneDivisionError: division by one

発生したエラーを捕まえて(catch)、そのエラーに対して処理を行うことを例外処理(もしくはエラーハンドリング)と呼ぶ。 この例外はtry ~ except 構文で記述できる。

>>> try:
...     x = divide(3, 1)
... except OneDivisionError as e:
...     print(type(e), e)
...     x = 1
<class '__main__.OneDivisionError'> division by one
>>> x
1

この例外のcatchの便利なところはどのフレームであっても例外をcatchすることができるところ。具体的には下記の状況を考えてみよう。

frame 関数
1     f(x) 内部で関数g(x)を呼び出し
2     g(x) 内部で関数h(x)を呼び出し
3     h(x) 内部で関数i(x)を呼び出し
4     i(x) エラー発生 (raise Exception)

関数fの中で関数gを呼び出し、gの中で関数hを呼び出し、、と実行が行われ、その最下部の関数iでエラーがあり、例外を発生させたとする。このとき関数f, g, hのどこであっても関数iで発生したエラーを捕まえることができる。たとえば関数fにのみ例外処理を書いている場合は関数g, hをすっ飛ばしてfの例外処理のコードが走る。そのため関数g, hはその例外処理のために何も変更しなくてよい。また関数g, hで例外処理を行なってさらにその中で同じ例外を発生させればその上のフレームで例外処理を行うこともできる(まぁそんな使い方はあまりしないと思いますが)。

この例外処理の仕組みをハックすることでエラーハンドリング以外でも例外を活用することができる。一つの例としてOptunaでの使用例を紹介しよう。

このOptunaはニューラルネットワークのハイパーパラメータ(以下ハイパラ)の探索のためのライブラリで、TPEアルゴリズム(や進化計算)をベースとしたtry & error的な探索を行う。大まかな流れは

  1. ユーザーはニューラルネットワークを構築し、そのハイパラについての情報をOptunaに教える。
  2. ハイパラをアルゴリズムにより決定。
  3. そのサンプリングされたハイパラでニューラルネットワークの学習を実行。
  4. 学習されたニューラルネットワークの性能を記録し1に戻る。蓄積したデータは1のハイパラのサンプリングに活用される。

というのを何回か繰り返すもの。このとき、ニューラルネットワークの学習には時間がかかるのがキモで、3を実行中にニューラルネットワークの学習がうまくいかなさそうなら早めに切り上げて次のハイパラ探索に進みたい。そこで、ニューラルネットワークの学習が上手くいっていないと判断した場合は、学習を切り上げる例外を発生させる。それによりニューラルネットワークの学習が止まり、その例外はOptunaの中でcatchされ、次のハイパラ探索に進む例外処理が行われる。このとき、例外の性質によりユーザーのニューラルネットワークを定義したプログラムコードを変更する必要がなく、見た目上はクリーンな状態でこのような学習の早期切り上げの処理を行うことができる。


デバッガ

次はデバッガの紹介。デバッガを用いることでプログラムの実行を任意のタイミングで停止させて、変数の値や関数の呼びだし経路を確認したり、停止地点から実行を少しずつ進めることで、プログラム実行が意図したものに沿っているかをチェックできる。

もっとリッチなものだと実行を巻き戻したり、変数の更新を検知していい感じに出力してくれる。下記は代表的(標準的)なデバッガ一覧。

言語 デバッガ
Python pdb
C/C++ gdb
Docker buildg

プログラムの実行は次のような箇所で停止させることができる。

  • プログラマが指定した箇所(breakpoint)
  • エラーが発生した瞬間
  • 警告が発生した瞬間

pdb

Pythonの標準対話型デバッガ(python debugerの略だと思う)。 Pythonスクリプト実行に-mオプションを付けてpdbを起動したり

python -m pdb test.py

ソースコードに、直接breakpointを書き込むと、その地点に到達した瞬間にpdbが起動する。

a = 4
breakpoint()
a += 3

pdb > コマンド

デバッグ起動時に使用できるコマンド。だいたい他のデバッガでも同じコマンドが使える。ので、どれか一つのデバッガでこれらを使えるようになろう。まず、停止した地点から次の停止ポイントへ実行を進めるためのコマンドの一覧をまとめる。

コマンド 説明
c continue 実行を続ける
s step ステップ実行(関数の中に入る)
n next 次の行へ
l list ソースコードの表示
b breakpoint ブレークポイントの設定
p print 変数の表示
q quit 終了

pdb > フレーム移動

どの関数を経由して、今の停止地点に到達しているのかを確認したり、経由した関数を遡ったりするのに使用。私はかなり使います。

コマンド 説明
bt backtrace バックトレースの表示
u up 1つ上のフレームへ
d down 1つ下のフレームへ
w where 現在のフレームの表示

u 3 とすると、3つ上のフレームへ移動する。 d 2 とすると、2つ下のフレームへ移動する。

練習問題

test.py; x = ["apple"], y = [10]の二つのリストを作成するプログラム

def generate_empty_list(x=[]):
    return x

# create list x as ["apple"]
x = generate_empty_list()
x.append("apple")

# create list y as [0]
y = generate_empty_list()
y.append(0)

z = y[0] + 10
print(z)

実行してみる(python test.py)と次のエラーを検出。

Traceback (most recent call last):
  File "/Users/tateiwa/test.py", line 10, in <module>
    z = y[0] + 10
TypeError: can only concatenate str (not "int") to str

そこで

python -m pdb test.py

で実行すると

> /Users/tateiwa/test.py(1)<module>()
-> def generate_empty_list(x=[]):

対話型デバッグモードが起動される。上記のコマンドを参考に実行を進めてバグの原因を突き止めよう。

Tips for Python Debugger

ipdb

pdbの拡張版パッケージ。出力がちょっとリッチになったり、追加の情報表示機能がある。(pip install ipdbでインストール)

python -m ipdb test.py

github: https://github.com/gotcha/ipdb 解説記事: https://qiita.com/Kobayashi2019/items/98e74110d74e4c60f617

watchpoints

変数の変更を表示できるパッケージ。変数が変更されたことを表示したり、pdbで対話モードに移ることが可能。(pip install watchpointsでインストール)

import watchpoints

watchpoints.watch.config(pdb=True)  # 指定した変数に変更があればpdbに移行する

a = 0
watchpoints.watch(a) # 変数の監視を開始
a = 1

github: https://github.com/gaogaotiantian/watchpoints 解説記事: https://opensource.com/article/21/4/monitor-debug-python

Warningのcatch

通常だと警告(warning)はデバッガーでスルーされるが、警告に対するデバッグを行いたいときに警告発生地点で実行を止めることもできる。(参考: https://inarizuuuushi.hatenablog.com/entry/2022/06/06/090000

import numpy as np
x = np.ones((2, 2), dtype=np.float16)
x[0, 0] = 1e4
y = x ** 2
python -W error -m pdb tmp.py

と実行することで、warningの部分で実行を止めることができる。仕組み的にはwarningをerrorと同一視させることで、デバッガがエラー箇所で実行を止める機能がwarningにも適用される。


Test

テストはバグの発見だけでなく、プログラムの変更がこれまで正常に動作した機能を壊していないかの確認(リグレッションテスト)にも使える。

それぞれのテストはなるべく最小限の構成で行う。これらはテスト失敗時の原因箇所の特定の容易化や計算時間の短縮に貢献してくれる。

  • 小さな入力 (行列サイズを最小限にするとか)
  • 関数単位など小さい構成

テストケース設計

testは(入力, 出力)のペアを用いて、モジュールの処理(入力) == 出力、になるかを確認する*2。テスターはどのようなテストケース(入力、出力)を設計すれば良いのだろうか?

テストケース設計技法は、大きく次の二つに分類される。

ブラックボックス

モジュール(プログラムのかたまり)をブラックボックスとみなす、すなわちモジュールの内部構造には全く着目せずにテストを設計する。

  • 同値分割
    • 入力データをいくつかのクラスに分割して、各クラスを代表する値をテストデータとする*3
  • 限界値分析(境界値分析)
    • それぞれのクラスの境界値をテストデータとして設定する*4

ホワイトボックス

プログラム内部に着目してテストを設計する。

  • 命令網羅
    • 全ての命令を少なくとも1回は実行*5
  • 判定条件網羅(分岐網羅)
    • 判定条件で真となる場合と偽となる場合をそれぞれ少なくとも1回は実行*6
  • 条件網羅
    • 条件判定が複合的(A or B)の場合に、それぞれの条件の真偽の組み合わせを網羅(この場合はAが真、偽の場合、Bが真、偽の場合を組み合わせて4通り)*7

CI/CD

Continuous Integration/Continuous Delivery(CI/CD)とは、ソフトウェアの変更を常にテストして自動で本番環境にリリース可能な状態にしておく、ソフトウェア開発の手法のこと。(参考: https://codezine.jp/article/detail/11083)

こまめにテストを実行して常にクリーンな状態でソースコードを管理しましょう、ということ。

pytest-cov

pytest(Python単体テスト用のライブラリ)の拡張パッケージ。テスト実行時のプログラムの網羅性を可視化してくれる。(pip install pytest-covでインストール)

pytest -v --cov=<directory> --cov-report=html

githu: https://github.com/pytest-dev/pytest-cov 解説記事: https://qiita.com/kg1/items/e2fc65e4189faf50bfe6

Act

Github actionをローカルで実行できるツール。仮想環境を作成してテストできるので、様々な環境での動作検証に有用。パッケージを配布する前のテストにも使える。

githu: https://github.com/nektos/act 解説記事: https://qiita.com/wwalpha/items/6c303dcf04e236238315

Tips for Prevention of Bugs

ここではバグの再発を防ぐための工夫をいくつか紹介する。

assertion

成立してほしい条件を確認するための構文。もし条件を満たしていない場合はAssertionErrorをメッセージと共にraiseする。

メッセージの部分にバグの原因などを書く*8*9ソースコードに情報が残るのが良い。記法は下記。

assert 条件式, "メッセージ"  # "メッセージ"は省略可能

例えばtest.pyに追記するならこんな感じ。

def generate_empty_list(x=[]):
    return x

# create list x as ["apple"]
x = generate_empty_list()
x.append("apple")
assert x == ["apple"]  # 追加!!

# create list y as [0]
y = generate_empty_list()
y.append(0)
# assert y == [0]  # 追加!!
assert y == [0], f"y must be [0], but got {y}"   # 追加!!

ロギング

実行中のさまざまな情報をログとして出力することで、プログラムの挙動を追跡できるようにすることで、バグの検出を容易にする。(参考: https://inarizuuuushi.hatenablog.com/entry/2020/12/12/225907

printとの違い - 情報の出力場所(ファイル名、関数、行数)や時刻を一緒に表示できる - 複数のファイルに分けてログを保存できる - レベル(DEBUG, INFO, WARNING, ERROR, CRITICAL)ごとに色分けで表示できる(colorlogを使えば)

型ヒント

型ヒントを書くことで、ユーザーに変数の型を教える。また、linterを用いた静的解析もできる。(参考: https://docs.python.org/ja/3/library/types.html)

def generate_empty_list(x: list = []) -> list:
    return x

Profile

プログラム実行時の関数ごとの実行時間やメモリ使用量を計測すること。プログラム改善の目安やその改善度合いの確認に使用。

言語 用途 プロファイラ
Python 実行速度 cProfile
Python メモリ使用量 memray, tracemalloc
C/C++ 実行速度 gprof
C/C++ メモリ使用量 valgrind

cProfile

Python標準のプロファイラ。pdbと同様に-m cProfileをつけて起動できる。後で可視化するため-o オプションでファイル出力を行う。(-o をつけない場合は標準出力に結果が出力される)

python -m cProfile -o stat test3.py

出力されたstatファイルを元に結果の可視化してブラウザで確認できる。(参考: https://kazuhira-r.hatenablog.com/entry/2019/04/13/173643)

pip install snakeviz
snakeviz stat

練習問題

test3.pytest4.pyのプロファイルを比較し、実行速度やその違いの原因を考察しよう。

test3.py; フィボナッチ数列計算

def fib(n):
    if n == 0:
        return 0
    elif n == 1:
        return 1
    else:
        return fib(n - 1) + fib(n - 2)

print("fib(35)", fib(35))

test4.py

import functools

@functools.cache
def fib(n):
    if n == 0:
        return 0
    elif n == 1:
        return 1
    else:
        return fib(n - 1) + fib(n - 2)

print("fib(35)", fib(35))

*1:C++ではstd::exception。だいたいこの辺はどのプログラミング言語も同じような用語を用いている。

*2:ただ単に処理(入力)が問題なく終わることを確認する場合は出力は不要。また処理(入力)が出力に対して小さい、大きい、ある程度の誤差を許容して等しいことを確認したり、というケースもある。

*3:例: $f(x)$の入力$x \in \mathbb{Z}$を[-inf, 0), [0, 100], (100, inf)の区間を分けてそれぞれから-1, 50, 150を選択する。

*4:1の例を用いると、-1, 0, 100, 101が選択される。

*5:要するにプログラムの全ての行が実行する

*6:if x < 3: の条件文の場合はこの条件文において x < 3とx >= 3のどちらのケースも実行する

*7:if x < 3 or y > 4の条件文の場合はこの条件文において (x < 3, y > 4), (x < 3, y <= 4), (x >= 3, y > 4), (x >= 3, y <= 4)をテストする

*8:入力される変数の型の違いを教えてあげたり、より直接的な要因"入力ファイルのフォーマットはcsv出なければならない"などを教えてあげる。

*9:NASAのコーディング規約では関数ごとに最低2つのassertionが義務付けられている。https://qiita.com/tmokmss/items/1b5625d429ea4aa1ac32, Rule 5。