はてだBlog(仮称)

私的なブログど真ん中のつもりでしたが、気づけばWebサイト系のアプリケーション開発周りで感じたこと寄りの自分メモなどをつれづれ述べています。2020年6月現在、Elasticsearch、pandas、CMSなどに関する話題が多めです。...ですが、だんだんとより私的なプログラムのスニペット置き場になりつつあります。ブログで述べている内容は所属組織で販売している製品などに関するものではなく、また所属する組織の見解を代表するものではありません。

diffの変種っぽい視点で見るPandasのイディオム

はじめに

PandasのEDAに便利な部分を、データチェックに見立てて利用すると、目的外使用かもしれないけど、便利かも? という視点でのPandasのイディオムの寄せ集めです。

(よって、そう思って見なければ、Pandasの中途半端な入門例未満の断片集だったりします。テクニカルど真ん中のワードで検索してこのページに辿り着かれた方はご期待のものから大分遠いかもしれません。ご了承ください。あしからず。)

この記事の背景

ふとした話なのですが、Pandasって、Pandasの王道の利用方法もさることながら、ちょっとしたdiffとかファイルチェックにも便利だなと思ったのでした。

例え話になっているか分からないけど、例えてみます。

既存のとあるCSVのクロス集計帳票について、次の仕様変更/仕様拡張がありました。

  1. 従来のカンマ区切りのCSVからタブ区切りCSVに変更する
  2. 主要項目のうち3項目の仕様変更
  3. 残りのN項目の集計の実装のリファクタリング(実装のみで出力内容は変わらない)

3番目の要件をふまえると要件変更前のプログラムで出力されたファイルとの新旧比較チェックが何はともあれ安心材料としてのテストとして有効かつ効率的です。

ただし、上2つの要件がそれを邪魔します。

もちろん、真面目にテストコードを書けば良いのですが、それはそれとして何か惜しい気がします。

このような要件のアプリのテストを検討している時に、妙な既視感を感じることがありました。

一番似ているのは、良く分からないシステムのリバースエンジニアリングっぽい活動をしているとき、次に似ているのは(私がやっているのがDSレベルかはともかく)EDAに取り組んでいる時の肌感覚でした。

といところで、その既視感から連想するに、pandas.read_csvをはじめとする強力なメソッドによるデータソース〜DataFrameによるデータの抽象化および各種便利な演算が有効活用できるよねという感想に至りました。

ま、テストコードを書く良い文化があるところに無理に差し込んだり、Pythonが全く関係ないプロジェクトでは考えものかもしれません。

しかも、逆に言うとPythonが絡んでいるプロジェクトであれば、Pandas自体は利用してなくてもチェック用DSLに見立てて利用してみるという生活の知恵?もあるかなと思う次第です。

この記事について

Pandasに絡めていますが、実のところ、そのようなご都合のよろしい「チェック」「テスト」の加減が難しかったりするので、それがテーマです。

ジレンマですね。ある種効率的な「テスト」ができるとして、それをひねりだすためには、ここで例えた例などではビジネスドメインにダイブせねばなりません。

(ひとまずチェックの走行時間などはさほど問題にならないとしても要件の性質上)全件前後比較はかなわぬもののでも、条件を緩めるといい感じで、手間はぼちぼちで、それに近い効果が得られるかもしれないというのは、そこの駆け引き・差し引きに気づけるぐらい、なんらかその分野に習熟している必要があります。

ということで、Pandasを切り口にしてはいますが、自分の中ではこの記事の本題は↑こちらの部分で、このような観点を私自身が思い出すための、きっかけとなるワードを散文的に書き連ねたりしたものだったりします。

【参考】公式ドキュメントへのリンク

この記事で扱っているPandasもしかりですが、大抵のソフトウェアについては公式ドキュメントを見ましょう。

この記事はともすると邪道なので、リスペクトを込めて、まずは公式サイトへリンクしておきます。

pandas.pydata.org

pandas.pydata.org

※今回は、特にふれないものの、テストコードが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()

その他

今回の切り口、いや今回の切り口でなくてもですが、今回の切り口だと特に、次のあたりのメソッド・関数がイキイキしてきそうです。

Indexing and selecting data — pandas 1.0.3 documentation

Series — pandas 1.0.3 documentation