🐍

なぜ私が Jupyter notebook の外の世界において pandas や polars, dict を消したがるのか

2024/03/12に公開

はじめに

この文章は社内の新人 Data Scientist (≒ Machine Learning Researcher/Developer) 向けに書いた文章をもう少し清書し、また社内向けの内容を削ったものです。

また、 私がはてなBlogに投稿した
Better python code, better naming, better programming, better productivity 記事の中にある同セクション「なぜ私が Jupyter notebook の外の世界において pandas や polars, dict を消したがるのか」の内容です。記事においては「別記事予定」と記載しているため、こちらに投稿します。

以下の区切り線以降がその内容です。


なぜ私が Jupyter notebook の外の世界において pandas や polars, dict を消したがるのか

結論

Jupyter notebook の外において、たかだか2次元配列くらいならそのまま書いたほうがわかりやすいし型情報がなくなるから。
何より、dict と同じ弱点を持っている。

  1. どんな key で値が入っているのか、全て遡らないとわからない
  2. ある key に入っている value が一体なんなのか、全て遡らないとわからない
  3. その value が一体なんの型なのか、遡らないとわからない
  4. key の typo に弱い。

概要

csvなどのLoadをするのに pandas や polars は便利だ。読み込んだデータを二次元配列化してくれるし、それに準拠した型を用意すればそこへCastもしてくれる。
ただ、たかだか二次元配列、つまりExcelで扱っているような範囲のデータをこねくり回すなら必要ない。
しかし、 pandas では二次元配列までしかなく、外界とのインターフェース以上の価値を私は見いだせない。
外界とのインターフェース、つまりFileをはじめとしたIOの入出力や可視化である。例えば以下のようなCSVを考える。

都道府県 人口
東京都 13942856
神奈川県 9204010
大阪府 8839469
愛知県 7483128
埼玉県 7266534

2021年の推計人口(総務省統計局より)

たとえば、 pandas を使うと以下のようなコードで平均値を出せる

data_frame = pd.read_csv('population.csv')
average_population = data_frame['人口'].mean()
print(average_population)

このようなケースには pandas はとても強力な力を発揮する。
CSVを読み込み、 それを列にも行にも同じ [] を使ってアクセスすることができる。そしてこれをそのまま可視化することもできる。
しかし、例えば「二次元配列」が与えられたとき、それをわざわざ pandas を使う理由はあるだろうか?
つまり以下のようなコードである。

data = {
    "都道府県": ["東京都", "神奈川県", "大阪府", "愛知県", "埼玉県"],
    "人口": [13929286, 9200166, 8839469, 7552873, 7337330]
}

data_frame = pd.DataFrame(data)
average_population = data_frame['人口'].mean()
print(average_population)

事実上↑のCSVを等価の dict が存在し、これが二次元配列のため DataFrame として取り込めば pandas の強力なメソッドが使えてハッピーなわけだが、私は以下のようなコードを好む

from pydantic import BaseModel

class Population(BaseModel):
    prefecture_name: str
    count: int

data = {
    "都道府県": ["東京都", "神奈川県", "大阪府", "愛知県", "埼玉県"],
    "人口": [13929286, 9200166, 8839469, 7552873, 7337330]
}

populations: List[Population] = []
for index in range(len(data['都道府県'])):
    populations.append(Population(prefecture_name=data['都道府県'][index], count=data['人口'][index]))

average_count = sum(pop.count for pop in populations) / len(populations)
print(average_count)

これの一体何が良いのだろうか?
記述量は倍近いし、結局やってることは DataFrame#mean よりも遅い (なぜかというと dara_frame['人口'] でアクセスした先は pandas.Series であり、一次元配列を一回走査で計算できるため、 Series#mean は高速に計算できるようになっている)

pandasの弱点、Dictの弱点、私がなぜDictを嫌うのか

ここで前述した私が 弱点とした内容を改めて列挙する

  1. どんな key で値が入っているのか、全て遡らないとわからない
  2. ある key に入っている value が一体なんなのか、全て遡らないとわからない
  3. その value が一体なんの型なのか、遡らないとわからない
  4. key の typo に弱い。

どういうことか。
ある100行の関数があったとして、例えば以下のようになっているとする

def awesome_func() -> bool:
  df = pd.DataFrame()
  df['key_1'] = ['value', 'v', 'val']
  # ..50行後で.....
  df['key_1'].apply(lamdba x: ....)

まずは、 df[‘key_1’] に一体なにが入っているのか、50行遡らないとわからない。
書いてる瞬間は良いだろう。自分が3分前に書いたコードにおいて、 「key_1は文字列の配列が入ってるから〜」とおぼえている。
それが翌日には? 一週間後は? 半年後は? それを覚えているだろうか?
2も同じだ。 df に「どんなkeyがあるのか」も遡らないとわからない。
しかも、Keyは「いつ加えられているのか」がわからない。
どういうことかというと、以下のようなことだ。

def awesome_func(datum_list: list[str]) -> bool:
  df = pd.DataFrame(datum_list)
  df['hoge'] = ['val', 'v', ...]
  # 30行後に
  df['hoge'] = [1, 2, 3,]
  # 50行後に
  print(df['hoge']) #=> 自分「あれ? 3行目で文字列いれてるはずなんだけど……」
  #=> Cmd+F で df['hoge' を探して、遡って……って探す

DataFrame や dict には「keyの挿入を制限」したり「keyの値のvalueの制限をしたり」する機能に乏しい (まったくないわけではない)

これもまた、書いてる瞬間は良いだろう。30行後のコードは、おそらく一週間後の自分が追加したコードだろう。よくやるのだこういうのは。

3もそうだ。ある列、あるいはある行に「どんな型の値が入っているのか」、一切の制約をつけることができない。

df['somekey'] でアクセスした先にある値は文字列? 数字? あるいは list[byte] ? はたまた別の DataFrame ? これまた遡らないと、やはり何がなんだかわからないのだ。
それが怖いから常にCastをする、 None チェックをする。この方法は安全に見えて例外ケースに弱い。

4 はもっとも簡単で、もっとも多くやりがちなミスだ。

d = {'key1': 'value'}
print(d['key_1']) #=> KeyError!

typo, typo, typo … typoとの戦いは常にある。コード上の文字列での情報の結合はあまりにもTypoに弱く、あまりにも便利で、そして簡単にぶっ壊せるのだ。
(Reduxなどの一部のフレームワークでreduerなどを作って、文字列のKeyで条件分岐をする……といったようにそれを実装する結果、エディターのプラグインなどでそれを追跡できるようになっているが、私が調べた範囲で pandas においてはそのようにするプラグインは見当たらなかった)

翻って、では私が好む class 宣言は何が良いのか? pydantic を使うことでこのほとんど全てを防げるし、pydanticを使わなくても、現代のEditorやIDEならば問題なく防げる。

from pydantic import BaseModel

class Population(BaseModel):
    prefecture_name: str
    count: int

p = Population(prefecture_name: 1, count: 1) 
#=> Error! prefecture_name に文字列(str)以外のものを投入しようとするとエラーになる
#     現代的なEditor,IDEなら実行前にこれを警告してくれる。
p = Population(prefecture_name: '千葉', count: 1) 
print(p.prefecture) 
#=> Unresolved reference。もちろん Editor が指摘してくれる。
# 何行先であっても、 p.prefrence_name が利用されている箇所はCode Jump(jump to  definition, jump to called)で飛べる。
# もしImmutableにしたければ、
class Population(BaseModel):
  class Config:
    frozen = true

# のように Frozen を設定する。あるいは各 propertyで
from pydantic import Field
class Population(BaseModel):
  prefecture_name: Field()

この世の dict は全てclassを宣言するべき、とは言わない。dictはdictで使い道が無限に存在する。
しかし、殆どの場合において、特に Jupyter notebook ののコードでは、可能な限り dict, DaraFrame は避けるべきだと私は思う。

つまり、あなたが Jupyter notebook で作った素晴らしいMLモデルを Fast API などでAPI化し、本番にDeployしようとする時とかのことだ。 pickle モデルをロードし、API Request をParseしML ModelでPredictした結果を返す……そういう簡単なAPIにおいて、だ。

コラム: pydantic は?

ありとあらゆる文脈で必要とされるライブラリであり、 python 標準機能 dataclass の上位互換のようなものである。
https://docs.python.org/ja/3/library/dataclasses.html
が、 Python 3.11 において dataclass も多くの拡張が行われ、それで十分であるという意見も理解できる。
プロジェクトの性質や制約で使うべきかどうかを検討するべきだろう。

終わりに

pandas(polars) の DataFrame はとても強力な機能であり、そして Jupyter notebook における簡便さとエラー&確認&修正&実行の強力で迅速なサイクルにおいて、いちいち class を宣言するのは面倒だし煩雑だし速度に対してネガティブであることは言うまでもない。

しかし、一度その Jupyter notebook の外で、継続的にメンテナンスされることが前提になるソフトウェアにおいて、「制約が少ないこと」はかえってその後の修正に対して大きな制約をつけることになる。

もしあなたのResponsibilityが「Research」だけでよいならいいかもしれないが、 往々にして R&D と呼ばれるようにその責任は研究だけに留まらないだろう。

最近では ML Ops 、 ML Engineer といったそのProductionまでのランウェイを精緻化、プロセス化しCI/CDを実行してMLの価値を届けることを主目的とする職種も生まれている。

素晴らしいモデルが構築できたところで、それをデリバリーできなければ意味がないのだ。少しでも Jupyter notebook の外の世界に思いを馳せてみる時を作っていただけると幸いである。

refs

Discussion