はじめに
PandasのEDAに便利な部分を、データチェックに見立てて利用すると、目的外使用かもしれないけど、便利かも? という視点でのPandasのイディオムの寄せ集めです。
(よって、そう思って見なければ、Pandasの中途半端な入門例未満の断片集だったりします。テクニカルど真ん中のワードで検索してこのページに辿り着かれた方はご期待のものから大分遠いかもしれません。ご了承ください。あしからず。)
- はじめに
- この記事の背景
- この記事について
- ゆるdiff観点集
- CSVとTSVでも、テーブルのダンプデータでもDataFrameに読み込んでしまえば、データ内容に注目して、マルッとdiffできる
- 特定の列でソートすれば同じ。なら、ソートして比較してやれば良い。
- フォーマットの違いもあり、列順は異なるが、名称が同じ列は同じ値をとる
- 全てを比較した場合に同一とはならないが、条件を限定すると釣り合う場合
- 数値の比較では、gt、ltなどは、fill_valueを使って、何かと新旧比較しづらい/例外になりがちなnull(NaN)をマルッと取り回せる(入り口になる)
- describeしてある数値が得られた列に限り新旧に関係性が見出せるはずなので、それを比較
- ベスト5を宣言的に取り出す(ので、その後はお好きに料理する)
- 比較ではないが、基本に立ち返ってShapeとして捉えて俯瞰
- 欠損値やNULL値を除外(もしくは、「これらに対する性質は新旧前後で変わらない」という場合に、その視点でチェック
- カテゴリ変数の機構を悪用(?)
- 実際は単純diffは難しいものの、特定の条件に該当する行は、新旧で釣り合っている...という関係に着目
- 特定の関係の場合は、釣り合うという視点(ある列の値が同じレコードは、それ以外の値が同じになる)
- チェックサム的発想
- おおよそ一緒かどうか(閾値)(分布が似た集合であれば、describeは、同じDataFrame形式になることを前提に差を取ってみる > 差が小さい と似ている と考えることもできる)
- 大小関係が保証されていることを確認
- グループ化して比較
- ユニークじゃないよね系
- 乱択(seedに気をつけること)
- その他
この記事の背景
ふとした話なのですが、Pandasって、Pandasの王道の利用方法もさることながら、ちょっとしたdiffとかファイルチェックにも便利だなと思ったのでした。
例え話になっているか分からないけど、例えてみます。
既存のとあるCSVのクロス集計帳票について、次の仕様変更/仕様拡張がありました。
3番目の要件をふまえると要件変更前のプログラムで出力されたファイルとの新旧比較チェックが何はともあれ安心材料としてのテストとして有効かつ効率的です。
ただし、上2つの要件がそれを邪魔します。
もちろん、真面目にテストコードを書けば良いのですが、それはそれとして何か惜しい気がします。
このような要件のアプリのテストを検討している時に、妙な既視感を感じることがありました。
一番似ているのは、良く分からないシステムのリバースエンジニアリングっぽい活動をしているとき、次に似ているのは(私がやっているのがDSレベルかはともかく)EDAに取り組んでいる時の肌感覚でした。
といところで、その既視感から連想するに、pandas.read_csvをはじめとする強力なメソッドによるデータソース〜DataFrameによるデータの抽象化および各種便利な演算が有効活用できるよねという感想に至りました。
ま、テストコードを書く良い文化があるところに無理に差し込んだり、Pythonが全く関係ないプロジェクトでは考えものかもしれません。
しかも、逆に言うとPythonが絡んでいるプロジェクトであれば、Pandas自体は利用してなくてもチェック用DSLに見立てて利用してみるという生活の知恵?もあるかなと思う次第です。
この記事について
Pandasに絡めていますが、実のところ、そのようなご都合のよろしい「チェック」「テスト」の加減が難しかったりするので、それがテーマです。
ジレンマですね。ある種効率的な「テスト」ができるとして、それをひねりだすためには、ここで例えた例などではビジネスドメインにダイブせねばなりません。
(ひとまずチェックの走行時間などはさほど問題にならないとしても要件の性質上)全件前後比較はかなわぬもののでも、条件を緩めるといい感じで、手間はぼちぼちで、それに近い効果が得られるかもしれないというのは、そこの駆け引き・差し引きに気づけるぐらい、なんらかその分野に習熟している必要があります。
ということで、Pandasを切り口にしてはいますが、自分の中ではこの記事の本題は↑こちらの部分で、このような観点を私自身が思い出すための、きっかけとなるワードを散文的に書き連ねたりしたものだったりします。
【参考】公式ドキュメントへのリンク
この記事で扱っているPandasもしかりですが、大抵のソフトウェアについては公式ドキュメントを見ましょう。
この記事はともすると邪道なので、リスペクトを込めて、まずは公式サイトへリンクしておきます。
※今回は、特にふれないものの、テストコードがPandas依存上等であれば、Pandas自体のTesting functions(つまりassertなど)やintrospectionを使いこなす案はありそう。 pandas.pydata.org
ゆるdiff観点集
以降のイディオムは、全て、下記のコードが実行されている前提とします。
import pandas as pd d1 = pd.read_csv('iris.csv') d2 = pd.read_csv('iris.cp.csv')
また、そもそもですが、確認に使ったPandasは1.0.1です。ですが、強くバージョンに依存するような例は入っていないと思います。
import pandas as pd は良いでしょう。
ファイルは、いわゆるiris.csvです。
ですが、ファイルの内容は実際は各事例と厳密に連動していません。
あくまで、サンプルイディオムで変数未定義エラーなどにならないようにするためのものですので、ご了承ください。
また、同じ背景により、iris.cp.csvは、iris.csvと同じ内容のファイルです。
CSVとTSVでも、テーブルのダンプデータでもDataFrameに読み込んでしまえば、データ内容に注目して、マルッとdiffできる
d1 == d2
特定の列でソートすれば同じ。なら、ソートして比較してやれば良い。
d1.sort_values('SepalLength') == d2.sort_values('SepalLength')
フォーマットの違いもあり、列順は異なるが、名称が同じ列は同じ値をとる
d1c = d1.columns d2c = d2.columns comcol =list(set(d1c) & set(d2c)) d1[comcol] == d2[comcol]
全てを比較した場合に同一とはならないが、条件を限定すると釣り合う場合
d1cond = d1.select_dtypes(include=['number']) > 0.5 d2cond = d2.select_dtypes(include=['number']) > 0.5 d1[d1cond].sum() == d2[d2cond].sum()
数値の比較では、gt、ltなどは、fill_valueを使って、何かと新旧比較しづらい/例外になりがちなnull(NaN)をマルッと取り回せる(入り口になる)
(pd.Series([1,2,3,4,5,None])).gt(3,fill_value=100)
describeしてある数値が得られた列に限り新旧に関係性が見出せるはずなので、それを比較
d1desc = d1.describe.T cond1 = d1desc['mean'] > 3 x = list(cond1.index) # 以下、本来なら、d2のx相当を取得し、比較してみるところだが、割愛
ベスト5を宣言的に取り出す(ので、その後はお好きに料理する)
d1.nlargest(5, 'SepalLength') d1.nsmallest(5,'SepalLength')
比較ではないが、基本に立ち返ってShapeとして捉えて俯瞰
# 型の情報プロフィール d1.dtypes # データフレームのShape d1.shape
欠損値やNULL値を除外(もしくは、「これらに対する性質は新旧前後で変わらない」という場合に、その視点でチェック
d1.notnull()
d1.notnull().all()
d1.notna().all()
d1.isna().any()
d1.isnull().any()
d1.iloc[:,1].hasnans
ここで、all()とかany()になれておくと、全てXXXとか一つでもXXXであるのようなものを選択したり、判定できるので、応用が効く気がします。
また、そもそも、NULLのわけないとか、(クロス集計なのに)こんなに0ばっかりのセルのはずはないとか、いろいろあります。
カテゴリ変数の機構を悪用(?)
d1x = d1.copy() d1x['NameCode'], _ = pd.factorize(d1x['Name']) # あるいは codes, _ = pd.factorize(d1['Name']) d1y = d1.assign(NameCode2=codes)
何が嬉しいかというと、注目している列の値(Iris-virginicaなど)を、数値の通番風のコード値に置き換えて、このあと参照しやすくするという、質的変数に対する冒涜?です。
実際は単純diffは難しいものの、特定の条件に該当する行は、新旧で釣り合っている...という関係に着目
# 注:これ自体は比較ではありませんが、行の中の数字型の全ての項目が0.3より大きい値を取る行のみ抜き出す (d1.select_dtypes(include=['number']) > 0.3).all()
特定の関係の場合は、釣り合うという視点(ある列の値が同じレコードは、それ以外の値が同じになる)
d1_ = d1.apply(lambda s: pd.Series([s['SepalLength'],list(s)]),axis=1) d2_ = d2.apply(lambda s: pd.Series([s['SepalLength'],list(s)]),axis=1) pd.merge(d1_,d2_,how='inner',on=0).apply(lambda s: s['1_x'] == s['1_y'],axis=1)
チェックサム的発想
といっても、合計してみれば、絶対に合計が0になるはずが無いとか、不自然な最大値が発生するはずがないといったことに、集計などの演算により不自然な値に気づけるかもというシンプルな話。
print(d1.describe()) # describe()の戻り値はDataFrame (Pandasのこういうところがスキです。わたくし。) print(type(d1.describe())) print(d1.describe() == d2.describe() )
おおよそ一緒かどうか(閾値)(分布が似た集合であれば、describeは、同じDataFrame形式になることを前提に差を取ってみる > 差が小さい と似ている と考えることもできる)
describeの差を取ってみて
d1.describe() - d2.describe()
↓ 結果イメージ
""" SepalLength SepalWidth PetalLength PetalWidth count 0.000000 0.000000 0.000000 0.000000 mean 0.044000 -0.038667 0.125333 0.057333 std -0.023316 -0.006546 -0.104129 -0.051005 min 0.000000 -0.200000 0.000000 0.000000 25% 0.000000 0.050000 0.050000 0.100000 50% 0.200000 0.000000 0.300000 0.100000 75% 0.000000 -0.050000 0.000000 0.000000 max 0.200000 0.000000 -0.200000 -0.100000 """
まあ、そんなに悪く無いかな〜、少なくとも処理対象件数のボリュームは間違っていない、新ロジックが空振りしてなさそう、みたいなところを安心材料としてつかむ。
大小関係が保証されていることを確認
旧に対して、新では新たに集計項目の条件を増やした...場合などに、同じインプットデータを新旧で処理すると、大小関係がなりたつハズだ、みたいなのもあるでしょう。
d1.describe() > d2.describe()
グループ化して比較
describeなど、統計量を見て見たりといったことは、DataFrameGroupByオブジェクトについても可能。
ここまで述べたようなことは、gropubyして得られるグループごとのデータでもそれなりに応用できる。
d1.groupby('Name').describe() #describeがDataFramGroupByについても使える d1.groupby('Name').sum() #sum()も使える
あと、グループごとに、親レコードが存在すべきデータなどをチェックするといった、試験用にコーディングするのがだるい例などもも比較的容易。
d1.groupby('Name').apply(lambda df: df > 2) # これはあまり意味のない例だが...
↓ 結果イメージ
""" Out[98]: SepalLength SepalWidth PetalLength PetalWidth 0 True True False False 1 True True False False 2 True True False False 3 True True False False 4 True True False False .. ... ... ... ... 145 True True True True 146 True True True False 147 True True True False 148 True True True True 149 True True True False """
ユニークじゃないよね系
d1.groupby('Name').apply(lambda df: len(df.SepalLength.unique()) < len(df.SepalLength)) d1.groupby('Name').apply(lambda df: (df == 0).any()) d1.groupby('Name').apply(lambda df: (df > 0.1).all()) d1.groupby('Name').apply(lambda df: df.apply(lambda s: s.is_monotonic))
any()とかall()は、「テスト」っぽい使い方に便利。
乱択(seedに気をつけること)
# 件数指定 d1.sample(100).describe() d2.sample(100).describe() # 割合指定 frac = 0.5 d1.sample(frac=frac).describe() d2.sample(frac=frac).describe()
その他
今回の切り口、いや今回の切り口でなくてもですが、今回の切り口だと特に、次のあたりのメソッド・関数がイキイキしてきそうです。