printデバッグを効率化するicecreamパッケージとそのカスタマイズ方法

Printデバッグを効率化するicecreamパッケージとそのカスタマイズ方法

HACARUS所属のデータサイエンティストの安本です。

今回はPythonでprintデバッグする時に便利なicecreamパッケージの概要と、HACARUSの業務の一環で追加したカスタマイズ機能について紹介します。 実行にはicecream v2.1.2以降をpip install icecreamしてください。

prinデバッグの例として、関数の中にy = x + 1という代入文があったとして、x + 1の実行結果を確認したいとします。

単純にprint(y)としたのでは、yがどんな処理の結果か分かりにくいですし、スクリプト中で他にもprint関数を実行していた場合に区別がつきません。

従って、やや冗長ですがprint("x + 1:", y)と書くことになります。 デバッグする箇所を変える度に、適切な記述をするのは面倒ですね。

ところがicecream.ic関数(以下ic関数)を使うと、y = x + 1y = ic(x + 1)と書き換えるだけで済みます。 また、print関数は返り値がNoneですが、ic関数は引数をそのまま返してくれるため、式の途中でy = ic(x + 1)といった具合に使える点も魅力です。

以下にic関数の実用例を載せます。 f関数内でic関数を利用しており、f関数を実行した時にic| x + 1: 2と表示される点に注目してください。

>>> from icecream import ic
>>>
>>> def f(x):
>>>     y = ic(x + 1)
>>>     return y
>>>
>>> # icecreamでデバッグしながら実行
>>> y = f(1)
ic| x + 1: 2
>>>
>>> # 返り値の確認
>>> print(y)
2

ic関数はオブジェクトの中身を分かりやすく表示するために、内部でpprint.pprint関数を使っています(参考「Pythonのpprintの使い方(リストや辞書を整形して出力)」)

しかし、pprint.pprint関数は標準ライブラリが提供しているため、Numpy Arrayなど外部モジュールが提供するクラスのインスタンスを表示するには力不足な場面があります。 たとえば100行100列のNumpy Arrayをic関数に与えたところで、ユーザーには実数の大きな配列が入ってるくらいの理解しか得られません。 特にデータサイエンスの現場では、オブジェクトの値そのものよりも、shapeや値の分布などを確認したいことがあります。

>>> import numpy as np
>>>
>>> x = np.random.RandomState(0).normal(size=(100, 100))
>>> ic(x)
ic| x: array([[ 1.76405235,  0.40015721,  0.97873798, ...,  1.78587049,
                0.12691209,  0.40198936],
              [ 1.8831507 , -1.34775906, -1.270485  , ...,  0.82350415,
                2.16323595,  1.33652795],
              [-0.36918184, -0.23937918,  1.0996596 , ...,  0.58295368,
               -0.39944903,  0.37005589],
              ...,
              [-0.05524379,  0.13064302,  0.44069106, ...,  0.97220715,
               -0.91895048,  0.6632405 ],
              [-0.1334914 , -1.56637034, -1.74865144, ...,  0.0708476 ,
                2.43572851,  0.9716812 ],
              [-0.93296221,  2.86520354, -1.79204799, ...,  0.51687218,
               -0.03292069,  1.29811143]])

こういった要望に対し、HACARUSではic関数の柔軟かつ簡単なカスタマイズ方法を実装し、本家に提案し、無事に採用されました。

先程、ic関数が内部でpprint.pprint関数を使っていると述べましたが、それをラップするicecream.argumentToStringという関数を利用しています。 この関数にシングルディスパッチという引数の型に応じて処理を選択する機能を追加しました。 これにより、ユーザーサイドで型に応じた処理も追加・削除が可能になりました。

処理の追加には、argumentToString.registerメソッドをデコレータとして用いた関数を定義します。 この時、obj: np.ndarrayなどと型アノテーションしておくのがポイントです。 すると、ic関数は入力されたデータの型に応じて処理を選択するようになります。

例として、ic関数にNumpy Arrayを渡すと、配列のshape, dtype, 最小値、最大値を表示する機能を追加してみましょう。

>>> from icecream import ic, argumentToString
>>> import numpy as np
>>>
>>> # Register a function to summarize numpy array
>>> @argumentToString.register
>>> def argumentToString_ndarray(obj: np.ndarray):
>>>     return (
>>>          f"ndarray, shape={obj.shape}, dtype={obj.dtype}, "
>>>          f"min={obj.min()}, max={obj.max()}"
>>>     )
>>>
>>> ic(x)
ic| x: ndarray, shape=(100, 100), dtype=float64, min=-3.740100637951779, max=3.8016602149671153

ここではNumpy Array用の処理をargumentToString_ndarray関数に記述しましたが、名前に制限はありません。 関数名を指定して呼び出す予定がなければ、_を名前にしても良いでしょう。

型ごとに登録した関数の一覧を見るには、argumentToString.registryプロパティを参照します。 中身はMappingProxyTypeのインスタンスになっています。 これはdictを読み出し専用にしたものと思って頂ければOKです。

>>> print(argumentToString.registry)
mappingproxy({object: <function icecream.icecream.argumentToString(obj)>,
              numpy.ndarray: <function __main__._(obj: numpy.ndarray)>})

また、登録した関数を削除するには、argumentToString.unregisterメソッドに削除したい処理の型を指定します。

>>> argumentToString.unregister(np.ndarray)

今回実装した機能はfunctools.singledispatch関数を使ったものです(参考「公式ドキュメント」)。 素朴には以下のようにたった2行を追加するだけで実現できます。

+ from functools import singledispatch
+ @singledispatch
  def argumentToString(obj):

しかし、実際のPull Requestはレビュアーからの要望を受けてもう少し複雑なものになりました(https://github.com/gruns/icecream/pull/115)。

  • Python 2にfunctools.singledispatchはないので、シングルディスパッチできなくても良いがic関数は使えるままにして欲しい
  • オリジナルのfunctools.singledispatchには関数を登録する機能はあるが、削除する機能がないので追加して欲しい

今だにPython 2をサポートする気があるのには驚きですね。 このあたりの思想の違いを楽しむのはOSS開発の醍醐味の一つだと思います。 また後者は実装に悩みましたが、幸い、StackOverflowでドンピシャの回答を見つけられてことなきを得ました。

皆さんもicecreamパッケージで甘い一時を過ごせますように! より詳しい情報や、他言語への移植に関する情報は公式レポジトリから得られます。

https://github.com/gruns/icecream

ニュースレター購読Newsletter

登録はこちら