はじめに
データサイエンス的な世界に限らずですが、本処理以上に「前処理」が勝負というところがあります。
ここで、「本格的な『前処理』の更に前段の『前処理』が必要になった」という経験はありませんか。
まあ、単なる言葉遊びに過ぎないところもありますが、普通の「前処理」が、「使い物になるめどがあるデータを使いやすい形に加工する」だとすると、「『前処理』の『前処理』」は「そのデータが使い物になる可能性があるのかを見極める調査」というところでしょうか。
データサイエンスの分野だとEDA(探索的データ分析/解析)と呼ばれる(らしい)範囲の活動にあたるのかな。
... と話を膨らませておいて言うのもなんですが、別にデータサイエンスでなくて、ある既存アプリの改善プロトタイプの検討なんかでも、よくある話だと思いますし、むしろデータサイエンスど真ん中でない分野で発生する似たようなケースの方が 人間の業のようなものが滲み出てくるケースに遭遇するので、悲惨な気もします。
悲惨かどうかはともかく、私なぞは実際その界隈で日々地味に稼働を要している人なんではと最近思う次第です。
その人「これ現行のデータ(で実績もあるもので仕様書もある)だから、今日のところはサクッと読み込みだけして、今日は美味いものでも食いに行こうぜ」
↓
ぼく「了解!」
↓ しばらくして...
ぼく(のこころの声)「これって、仕様書っぽいものには、「idカラム」がユニークキーだと書いてあるけど、単にAUTO_INCREMENT指定されてユニーク保証されているだけでは...? そして、『備考欄1カラム』にビジネルルール上のユニークIDっぽいものが入っていて、どう見ても関連テーブルBとはこちらを結合キーにしているように見えるけどどうじゃろ。」
↓ またしばらくして...
ぼく(のこころの声)「【契約明細】テーブルてな名前のくせに、「論理削除フラグ」というカラムがある。まあケースバイケースだからそれはしかたないとして、オンライン申込時にはあったけど契約成立時は除外された商品の発注分とかかな、いやよく見たら隣のカラムに「契約時除外フラグ」というのがあって、と思って見てたら、【契約明細(確定)】という別テーブルもあって、俺が見てた【契約明細】はいったいなんなんだろうか。」
とまあ、多少デフォルメした例ですが、現場の難しさもあり、こんなこともしばしばあるでしょうし、私が「ぼく」というわけでなく、「その人」の方が私で、「ぼく」はまた別の人ということもあるわけです。
この記事の内容
この記事では、前項のような「『前処理』の『前処理』」として「そのデータが使い物になる可能性があるのかを見極める調査」について(ついても)Pandasが便利そうだったので、いくつかスニペット例をまとめたものになります。
(記事を書いた後になってみると実際に使うことはありませんでしたが、)「『前処理』の『前処理』」として「そのデータが使い物になる可能性があるのかを見極める調査」のことを「前捌き」と呼ぶことにします。
チョイスした内容は、最近*1の体感から、この類のことをいつもやっているな、次回やるならこのワンライナー(実際はワンライナーではありませんが、コピペで起動できるレベル)でやってしまえば、ゼロからタイプしたりいろいろ考えたりする時間を節約できるなというものです。 よって、私の嗜好・志向・思考に偏っています。まあ、これは個人の野良ブログなので当たり前ですが。
- はじめに
- この記事の内容
- 前捌きスニペット例
- (1) CSVやDataFrameを雑にJOINするシリーズ
- (2) 一括読み込みシリーズ
- (3) ユーティリティ
- (4) Pythonのワンライナー
- (5) DataFrameデータのprintの変種
- (6) 変わり種のgrep
- (7) 複数ファイルを一括で読み込んでいろいろレポートする
- 類似のテーマの別記事
前捌きスニペット例
ということで、今回のチョイスのスニペット例です。
各スニペットの関数起動の前に下記をインポートしてください。関数によっては依存していないものが入っていますというか、大半のものは、pandasのみに依存していますが、見てのとおり標準の範囲のものが大半でしょうから、IPYTHONならスタートアップスクリプトに入っている人も多いかと思います...というレベルです。 *2
import pandas as pd import glob import sys import itertools import re import json import collections import itertools import io
見てのとおり、関数の列挙なので、全部入り1ファイル(モジュール扱いにはしていませんが)は次のgistに貼り付けてあります。
(1) CSVやDataFrameを雑にJOINするシリーズ
楽観的なJOIN
楽観的なJOINと名前をつけたものの名前とあっていないかも。
もともとは、ワイドな行となる契約明細テーブルが様々な事情により、契約明細1、契約明細2、契約明細3...のような複数ファイルに分割されている。 これを元の契約明細テーブルの形に戻す(元のレコードのキーに該当するカラムで結合する) ... という例です。
# CSVやDataFrameを雑にJOINするシリーズ def rakkan_join(*args, join='inner', **kwargs): """ ワイドな行となる契約明細テーブルが様々な事情により、契約明細1、契約明細2、契約明細3...のような複数ファイルに分割されている。 これを元の契約明細テーブルの形に戻す(元のレコードのキーに該当するカラムで結合する) 関数(ファイル名1(またはDataFrame) キー1 ファイル名2(またはDataFrame) キー2 ファイル名3 .... ) xyz = pd.read_csv('iris.csv') #同じファイルなので、同じ内容が横方向に繰り返されるチープな例となるがご了承のこと rakkan_join(xyz, 'Name', 'iris.csv', 'Name') rakkan_join('iris.csv','Name','iris.csv','Name') """ a = [i for i in args] d = [] for f, c in zip(a[0::2], a[1::2]): #DataFrameであればそれ、そうでなければファイル名だと見なしてファイル読み込みする _ = f if isinstance(f,pd.DataFrame) else pd.read_csv(f, **kwargs) _ = _.set_index(c) d.append(_) return pd.concat(d, axis=1, join=join)
ハブとなるテーブルを中心に複数ファイルをJOIN
def parent_join(parent_file, *args, **kwargs): """ 契約明細 + 商品コードマスタ + 担当者コードマスタ + ... のような、ハブとなるテーブルを中心とした関係の複数テーブル由来の複数CSVを結合して、 契約明細としての1エントリのデータを生成する xyz = pd.read_csv('iris.csv') #同じファイルなので、同じ内容が横方向に繰り返されるチープな例となるがご了承のこと parent_join(xyz, (xyz, 'Name', 'Name'), (xyz, 'Name', 'Name')) """ pa = parent_file if isinstance(parent_file,pd.DataFrame) else pd.read_csv(parent_file, **kwargs) for i in args: # i[0] ファイル名 1. left_on 2. right_on 3. how(省略可能) how = 'inner' if len(i) == 3 else i[3] f = i[0] _ = f if isinstance(f,pd.DataFrame) else pd.read_csv(f, **kwargs) pa = pd.merge(pa,_,left_on=i[1],right_on=i[2],how=how) return pa
中間テーブルを介した結合
def junction_join(file1, juncfile, file2, junc1, junc2, **kwargs): #leftjuncとrightjuncは結合キー名のtupleを想定 """ 学生、科目、履修科目(学生-科目関係テーブル)のような、N対Nの関係のテーブル(中間テーブル(juncfile))を介したデータを 結合したデータを取得する """ _func = lambda f : f if isinstance(f,pd.DataFrame) else pd.read_csv(f, **kwargs) file1 = _func(file1) juncfile = _func(juncfile) file2 = _func(file2) wrk = pd.merge(juncfile,file1, how='inner', on=junc1).copy() wrk = pd.merge(wrk, file2, how='inner', on=junc2) return wrk
1対NのデータのNのデータをサブコレクションとして1の方に腹持ちしたデータセットに変換
def junction_table_as_list(groupkey, reldf, left_on, dispdf, right_on, listcolname='_'): """ 取引情報 - N個の契約明細 のような関係のテーブルについて、契約明細部分をdictのlistとして1カラムに押し込めたものを 親となる取引情報にくっつけたデータを生成する。 (正規化を崩して、取引情報エンティティとして見たデータを生成し、その後の処理で取り回ししやすいようにする。) """ d = pd.merge(reldf, dispdf, left_on=left_on, right_on=right_on,how='inner') d[valcolname] = d.groupby(groupkey).apply(lambda s: {k: v for k, v in s.items()}, axis=1) return d.groupby(groupkey)[listcolname].apply(list).reset_index()
difflib.get_close_matchesを用いてあいまい(多少の表記揺れ)を考慮した2つのデータセットの結合
def fuzzy_join(df1, df2, left_on, right_on, *, cutoff=0.6, **kwargs): """ 2つのデータセットの間のある項目の間におおよその対応関係がありこれらをキーに結合したい。 ただし、結合のための項目に表記揺れがあることから精度に課題があっても構わないので、 これらをあいまいな条件で結合してどのようなデータが得られるか確認したい。 表記揺れの実例イメージ → ファイルAは電話番号に「-」あり、ファイルBは無し。 あいまいな条件で結合 → difflib.get_close_matchesを用いて、結合できそうな値のキーを結合する。 あいまいさのパラメータ: cutoffで設定 """ import difflib dummycol = '____' d1 = df1.copy() d2 = df2.copy() on1 = left_on + dummycol on2 = right_on + dummycol + dummycol d1[on1] = d1[left_on].astype(str).fillna('') d2[on2] = d1[right_on].astype(str).fillna('') d2_on2_list = list(set(list(d2[on2]))) wrapper = lambda x: x[0] if len(x) > 0 else None d1[dummycol] = d1[on1].apply(lambda x: wrapper(difflib.get_close_matches(x, d2_on2_list, n=1, cutoff=cutoff))) df = pd.merge(d1, d2, left_on=dummycol, right_on=on2, how='inner', **kwargs) # 注: 空文字列どおしがマッチ扱いになるのはひとまず仕様通り df.drop(columns=[on1,on2,dummycol],inplace=True) return df
(2) 一括読み込みシリーズ
read_csvが好きなんです...
# 一括読み込み --------------- # メモ1:本関数群の用途をふまえて、CSVの読み込み時に強制的にdtype=str,fillnaを適用する方針とした。 # メモ2: 一部の関数では、元のCSVファイルの全ての値が欠損値の列はあらかじめ削除して、レポート表示を簡潔ににしている。 def bulkread_csv(files, **kwargs): """ ファイル名のリストを受け取って、ファイル名をキーとするDataFrameのdictを形成 """ return {f:pd.read_csv(f,dtype=str,**kwargs).fillna('') for f in files} def read_samecsv(files, **kwargs): """ 同じフォーマットのファイル名のリストを与えて1つのDataFrameに取り込み """ df_ = [] for f in files: df_.append(pd.read_csv(f,dtype=str,**kwargs).fillna('')) return pd.concat(df_)
(3) ユーティリティ
... といったものの一つだけ。
ヘボン式のローマ字変換
def roma(s): """ ヘボン式のローマ字変換 """ _roma = "a,あ,i,い,u,う,e,え,o,お,ka,か,ki,き,ku,く,ke,け,ko,こ,sa,さ,shi,し,su,す,se,せ,so,そ,ta,た,chi,ち,tsu,つ,te,て,to,と,na,な,ni,に,nu,ぬ,ne,ね,no,の,ha,は,hi,ひ,fu,ふ,he,へ,ho,ほ,ma,ま,mi,み,mu,む,me,め,mo,も,ya,や,yu,ゆ,yo,よ,ra,ら,ri,り,ru,る,re,れ,ro,ろ,wa,わ,あ,a,い,i,う,u,え,e,お,o,か,ka,き,ki,く,ku,け,ke,こ,ko,さ,sa,し,shi,す,su,せ,se,そ,so,た,ta,ち,chi,つ,tsu,て,te,と,to,な,na,に,ni,ぬ,nu,ね,ne,の,no,は,ha,ひ,hi,ふ,fu,へ,he,ほ,ho,ま,ma,み,mi,む,mu,め,me,も,mo,や,ya,ゆ,yu,よ,yo,ら,ra,り,ri,る,ru,れ,re,ろ,ro,わ,wa".split(',') roma = {k: v for k, v in zip(_roma[0::2], _roma[1::2])} # ひらがな → ローマ字 x = roma.get(s) if x: return x # ローマ字 → ひらがな y = [k for k, v in _roma.items() if v == s] if len(y) > 0: return y[0] else: #変換をあきらめる return None
(4) Pythonのワンライナー
ちょっと箸休めですが、Pythonもワンライナーいけるんですね。「-c」を付けて起動みたいです。
実はタイプ量は増えるが、その分(ワンライナーの割に)後から見ても解読しやすい?、かも
python3 -c 'import pandas as pd ; i = pd.read_csv("iris.csv",dtype=str).fillna(""); i.apply(lambda s: print("\t".join([x+":"+y for x,y in zip(i.columns,s) ])),axis=1)'
(5) DataFrameデータのprintの変種
DataFrameのトランスフォームの途中経過をprintしたい、そもそもカラム数の無駄に多いCSVをLTSV風に出力したいなど
全体的に、入力されたDataFrameをそのままreturn しているので、DataFrame.pipe()に対応し、チェーンされることを意識。
ここでは4種類。
# DataFrameデータのprintの変種 --------------- def print_ltsv(df): """ LTSV出力 *DataFrameをそのまま戻して「pipe」に対応している """ df.apply(lambda s: print("\t".join([x+":"+str(y) for x,y in zip(df.columns,s) ])),axis=1) return df def print_colnum_tsv(df): """ カラム番号型LTSV風出力 *DataFrameをそのまま戻して「pipe」に対応している """ df.apply(lambda s: print("\t".join([ str(i)+":"+str(item) for i,item in enumerate(s) ])),axis=1) return df def print_colnum_and_colname(df): """ カラム番号と項目名の対を出力 *DataFrameをそのまま戻して「pipe」に対応している """ cols = df.columns.values print("\t".join([str(i)+":"+str(item) for i,item in enumerate(cols) ])) return df def to_stdout(df, **kwargs): """ ものぐさな to_csv *DataFrameをそのまま戻して「pipe」に対応している """ df.to_csv(__import__('sys').stdout,**kwargs) return df
ファイルの内容を1行に収める
真面目に比較するならdiffコマンドですが、力任せにかなりの数のファイルを比べて(おそらく数ファイル微妙に違うものがある)というのをあぶり出したい場合があります。 そんな時に、1ファイル1行のデータに変換しておくと便利なことがあります。少なくとも私の知っているケースでは。
def eachfile_to_oneline(files,opt_func=lambda s:s): """ 複数のファイルに対して、 主要な制御文字などを取り除くとともに、1ファイルを1行に納めて、Unixパイプに流す(Unix系コマンドで活用をイメージ)。 ※sedやtrで可能な範囲だが、明示的に処理するところがポイント。 ※opt_funcパラメータで、変換関数を追加指定できる。 """ def edit(data, opt_func): def default_func(str): _str = str #制御文字などの除去はTODO return _str dst = '' c = itertools.count(1) for l in data.split('\n'): dst += opt_func(default_func(l)) + '【改行' + str(next(c)) + '】' #あえてのダサ【改行】 return dst d = {} for i in files: with open(i) as f: d[i] = edit(f.read(),opt_func) for f, x in d.items(): print(f,'\t',x)
ファイルのN行目からX行目を抜き出す
def head2tail(frm, num, df=None, file=None, **kwargs): """ Un*x系のtailとheadだといざという時にオプションを忘れてしまうので何かとめんどくさい「ファイルのN行目からX行抜き出す」の対応 d = pd.read_csv('iris.csv',dtype=str) head2tail(2, 4, df=d) print('aaaaa') head2tail(2, 4,file='iris.csv') """ if isinstance(df,pd.DataFrame): print_ltsv(df.iloc[frm - 1 : frm + num -1 ]) return None if file: f = file else: _ = sys.stdin.read() f = io.StringIO(_) df = pd.read_csv(f, dtype=str, **kwargs).dropna(how='all', axis=1).fillna('') print_ltsv(df.head(frm + num -1 ).tail(num))
(6) 変わり種のgrep
grepの正規表現を覚えられないので(といいつつpythonの正規表現も覚えていないが...)
GNUのgrepか、BSD系のgrepかよくわからなくなる人なので。私。
# 変わり種のgrep ------------ def funnygrep(re_str, files, **kwargs): """ 正規表現を受け取って、 指定のCSVファイル中のどのカラムにマッチする値があるかを表示 grip = funnygrep grip('(?:2.5|1.8)', ['iris.csv']) """ d = {f: pd.read_csv(f, dtype=str, **kwargs).dropna(how='all',axis=1).fillna('') for f in files} for f, df in d.items(): df[df.apply(lambda s: any(s.str.contains(re_str)),axis=1)].pipe(print_ltsv) #print_ltsv参照
difflibのあいまいマッチングを利用したあいまいgrep
「こんにちわ」で「こんにちは」も検索してもいいじゃんというgrep。
表記揺れの統合のためのデータ整備(の計画)とかの場合。
def junkgrep(grepstr, files, ratio=1, **kwargs): """ difflibのあいまいマッチングを利用したあいまいgrep junkgrep('IrisSetosa', ['iris.csv'], ratio=0.8) """ d = {f: pd.read_csv(f, dtype=str, **kwargs).dropna(how='all', axis=1).fillna('') for f in files} from difflib import SequenceMatcher def _match(s,grepstr,ratio): smlist = [SequenceMatcher(isjunk=None,a=grepstr,b=_s) for _s in s] #ゲシュタルトパターンマッチング" return any([True if sm.ratio() >= ratio else False for sm in smlist]) for f, df in d.items(): df[df.apply(_match,axis=1,grepstr=grepstr,ratio=ratio)].pipe(print_ltsv) #print_ltsv参照
(7) 複数ファイルを一括で読み込んでいろいろレポートする
複数ファイルのカラム名の一覧など
head -1 *.csv とかでも良いのですが、「同じカラム名を持つファイル→ファイル間の関係を示唆」だろうとういうことで、
という反転させたレポート。
# 複数ファイルを一括で読み込んでいろいろレポートする ---------------- def desc_colnames(files): """ 複数ファイルを実際に読み込んで それらのカラム名を並べて表示 また、同じカラム名を持つファイル名をまとめて表示 files = ['iris.csv', 'train.csv'] print(desc_colnames(files)) """ work_ = {} # ファイル名:カラム一覧 for f in files: # このような用途の場合は、通常無駄な列が多くその切り分けに難儀する傾向があるので、ここでは値を持たない列は削除して対象から外す work_[f] = list(pd.read_csv(f, dtype=str).dropna(how='all',axis=1).columns.values) work2_ = {} # カラム名:そのカラム名を持つファイル名の一覧 for k, cols in work_.items(): for c in cols: if not work2_.get(c): work2_[c] = [] work2_[c].append(k) return work_,work2_
テキスト系CSVファイルをdescriptionしてみる
DataFrame(CSVファイル)のdescription関数のオレオレ版。最初の数行と全体では印象が違うデータもありますので。
DataFrame.descriptionは統計量が中心だが、こちらは文字列データの特徴を掴むために、各カラムの値のバリエーション数などを表示。
def desc_cardinality(files, **kwargs): """ DataFrameのdescription関数のオレオレ版 DataFrame.descriptionは統計量が中心だが、こちらは 文字列データの特徴を掴むために、各カラムの値のバリエーション数などを表示 """ import numpy as np cardinality_ = {} for f in files: cardinality_[f] = pd.read_csv(f,dtype=str,**kwargs).dropna(how='all',axis=1).fillna('').apply(lambda s: [len(set(s)),np.max(s),list(s.mode())[0] ]).to_dict() return cardinality_
出現単語数カウント(日本語テキスト)
def wordcount(files, **kwargs): """ 出現単語(日本語をイメージしているので、形態素解析とともに、語幹化などをある程度考慮した上で)のカウント ここまでの方針からインプットにはcsvファイルを想定しているが、そうでない関数にしておいてもよかったかもしれない。 (非csvファイルを扱う場合は、my_tknzr関数を利用すれば良い。) files = ['iris.csv', 'train.csv'] print(wordcount(files)) """ from janome.tokenizer import Tokenizer tokenizer = Tokenizer() import string def my_tknzr(text): """ janomeを使ったトーカナイザー In [16]: my_tknzr('すもももももももものうち ') Out[16]: ({ 'すもも': ['すもも', 'スモモ', 'スモモ', 'すもも'], 'もも': ['もも', 'モモ', 'モモ', 'もも'], 'うち': ['うち', 'ウチ', 'ウチ', 'うち'] }, ['すもも', 'もも', 'うち'] ) """ def _isnot_stopword(s): # 明らかなstopwordは取り除く(ための判定) stopwords = list(string.punctuation + string.whitespace + '『』{}「」()[]、。') if s in stopwords or re.compile('^[' + string.punctuation + string.whitespace + ']+$').match(s): # 「\」の扱いは怪しいかもしれない return False return True t = tokenizer.tokenize(text) words = {i.surface: [i.base_form, i.phonetic, i.reading, i.surface] for i in t if set(('助詞', '助動詞', '数')).isdisjoint(set(i. part_of_speech.split(','))) and _isnot_stopword(i.surface) } if words: return words, list(words.keys()) return {'': []}, [] import collections d = {} for f in files: _ = pd.read_csv(f, dtype=str, **kwargs).dropna(how='all', axis=1).fillna('').apply( \ lambda s: my_tknzr(' '.join(s.to_list()))[1], axis=1).to_list() # my_tknzrに強く依存 d[f] = collections.Counter(sum(_, [])) print(d[f]) return ''
複数ファイル名を与えて、ファイル中のnull項目に注目して事実を調査
def csv_notnull_rep(files, **kwargs): """ このファイルって、カラムAが主キーだよ...みたいなことを言われたのに、実際はカラムAにnullのものがあった(ぉいぉい)みたいなところを解き明かす ↓ csvのnot nullである列名とnullとなる値がある列を含む行のレポート files = ['iris.csv', 'train.csv'] csv_notnull_rep(files) """ d = {} for f in files: df = pd.read_csv(f, dtype=str, **kwargs).dropna(how='all', axis=1) #全行欠損値の列はそもそもターゲットにしない colcond = ~df.isnull().any(axis=0) print('# not nullである列名一覧') print('#',f,list(colcond[colcond == True].index)) null_included = df.isnull().any(axis=1) print('# null値を含む列の例' ) print('#',f,list(colcond[colcond == False].index)) print_ltsv(df[null_included])
おまえら本当に必要なファイル、関係あるファイルということを探る
def リレーション可能性調査(files, **kwargs): """ あるトランザクションに関する正規化された業務テーブルA〜EまでをそれぞれCSVファイルで抜き出したファイルを受領した。 シンプルなので、見れば分かると言われた言われたものの、怪しいので、少なくともこれらの間に結合できるような関係があるのかざっくり確認する。 ↓ 【アプローチ】 指定された複数ファイルの全カラムのカーディナリティなどを調べて互いに結合できるか大雑把にチェックする print(リレーション可能性調査(['iris.csv', 'iris.cp.csv'])) """ setdict = lambda df: df.apply(lambda s: set(s)).to_dict() # DataFrameの各カラムのユニークな値のsetを取得する d = {f: pd.read_csv(f, dtype=str, **kwargs).dropna(how='all', axis=1).fillna(''). \ pipe(setdict) for f in files} # 対象のファイルの列ごとのユニーク値のsetをdictに保持 comb = itertools.combinations(files, 2) # ファイル名のコンビネーション report = [] for i in comb: # 各ファイルについて x, y = i[0], i[1] # 値のバリエーションの一致の割合を求める for col_x, cat_x in d[x].items(): for col_y, cat_y in d[y].items(): all = cat_x | cat_y intsec = cat_x & cat_y repkey = (x,col_x,y,col_y) report.append([repkey,int(len(intsec)/len(all) * 100)]) return sorted(report,key=lambda _: _[1],reverse=True) # 一致度が高いものから降順にならべる
おまえら本当に必要なファイル、関係あるファイルということを探る2
# その他(あるテーブルとあるテーブルの釣り合いなどを確認)--------------- def read_csv_and_get_id_tuple(file, idcols=None, **kwargs): """ 貰い物のデータについて、お店名カラムがユニークキーになっていると聞いているけど、ホンマかいみたいなところを 掘り下げて確認するための関数 CSVを読み込んで、特定カラム(レコードの主キーとなるカラムなどを想定)のユニークな値の一覧を取得 (複合カラムに対応) """ df = pd.read_csv(file, dtype=str, **kwargs).fillna('') if not idcols: return set(list(df.iloc[:, [0]].apply(tuple, axis=1))) if any(type(i) is str for i in idcols): return set(list(df[idcols].apply(tuple, axis=1))) return set(list(df.iloc[:, idcols].apply(tuple, axis=1)))
類似のテーマの別記事
他の記事にも増して、随分と私的になってしまいましたが、個人的には満足です。
この他にも、同じようなテーマで何回か投稿しております。