前回までのあらすじ
前回の立志篇では、インド進出日系企業リスト-2018 [1] の PDFをCSVに変換、データ化する作業をいつも通り Acorbat DC Pro でこなした GGCS は、これ、Python でもできるんじゃないかと思い立ち tabula-py [2] を使って PDF データ化を実験、思いのほかスイスイ作業が進み、これは Python で楽勝だ!記事にしてみんなに自慢しよう貢献するんだ!と勇躍執筆に取りかかった。
しかし、その影には思わぬ伏兵が潜んでいたのだった…。
News 2020年08月31日に在インド日本大使館のサイトで「インド進出日系企業リスト-2019」(2019_co_list_jp.pdf)[3] が公表されました。👉 2019版対応 script をこちらで公開しました。
死闘篇の能書き
詳細をトレースできる Jupyter Notebook (*1) は下の おまけ のところに置いておきましたが、説明がやや長いのでここでは細かい説明は省いて、ポイントとなる script をシェアするだけにしました。
説明はほとんど省略しているので、何をやっているかわかりにくいかもしれませんが、そのままコピペして上から順に実行していけば、PDFをCSVに変換 することができます(ただし、最終仕上げ前の CSV なので、一部データの修正作業が残っています)。
繰り返しが処理が多いため見た目が若干複雑ですが、骨組みは単純ですので少し手直しすれば他の PDFをCSVに変換 する作業にも流用できます。
(*1) Google Colab (Python 3.6.9)上で動作確認済。手元の PC は猛暑でヘタっているのでお昼寝中。実証していません。
死闘篇に登場する伏兵たち
- オリジナル原稿の Excel で「セル内改行」が行われている場合、PDF を 正しく parse できず、表の 1 行(row)が複数行に、しかもあまり法則性なく、バラバラに分割されてしまうことがあります。
解決法: 言葉で説明するより script をみた方が早いので以下をご覧ください(そもそも「セル内改行禁止」と言う抜本的な対策もありますが)。 - 今回のような名簿的なリストの場合には、仮に怪しい Value や NaN があったとしても、そのデータを row (record) ごと捨てるなどの処理(dropna とか 電話番号の平均値を取るとか)するわけにはいきません。そこで、NaN に match させて何らかの処理する必要が生じますが、これが意外に難物(foo == NaN, np.nan, ‘nan’, ‘naan’, None, ”, False、みたいなのは全部ダメ )。
解決法: 対象データが、NaN と string だけで構成されている場合には、type(foo) == float で match させることができます。数値と NaN が混在しているときには使えない手ですが、使える時には使うと便利です。 - list から NaN 以外を取り出してつなげる時、filter() を使わないで list comprehension を使った方がスッキリ行く場合がある。
解決法: これは文字通りです。ちょっとダサいとかいう批判は却下😅
今回の作業の流れ
- Google Colab / Drive の設定
前回の最後の状態から再開です。 - Script を実行して PDFをCSVに変換
Script 0 – 9_rev をこの順番で実行します。
それでは始めます。
作業準備
- 前提1: tabula-py が Google Drive の
~/My Drive/Colab Notebooks/my-modules
にインストールされていること。 - 前提2: 読み込み元の PDF が、Google Drive の
~/My Drive/pdf_project/data/2018_co_list_jp_r.pdf
に置かれていること。 - もし上記の前提が満たされていないようでしたら、立志篇を参照の上、準備をお願いします。
Script 0
'''以下はすべて Google Colab / Drive が前提となっているので、 local machine で実験する時には適当に path などを変えてください。''' # Google Drive のマウント。 from google.colab import drive drive.mount('/content/drive') # Modules の import. import os import sys # PATH を通す(Python に modules の場所を教える) # local machine で実験するときは不要。 MODULE_PATH = '/content/drive/My Drive/Colab Notebooks/my-modules' sys.path.append(MODULE_PATH) import tabula # Module の場所を教えたので、import. import pandas as pd # 念のため明示的に import しておく。 # ディレクトリ構造を定義する。 # local machine で実験するときは、 # PROJECT_ROOT_PATH = '.' などと適当に変えて使います。 PROJECT_ROOT_PATH = '/content/drive/My Drive/pdf_project' DATA_PATH = os.path.join(PROJECT_ROOT_PATH, 'data') print('準備完了 🍻')
PDF ファイルの読み込み
Script 1
%%time # PDF ファイルの読み込み(30 秒程度掛かります) # WARNING が出ますが、今回の目的との関係では無視して差し支えありません。 df_list = tabula.read_pdf(os.path.join(DATA_PATH, '2018_co_list_jp_r.pdf'), pages='5-112')
Column Names の変更
Script 2
# Rename columns. new_column_names = ['state_code', 'location', 'sc_num', 'com_in', 'com_jp', 'biz_type'] # 108 個の DataFrame の column name を統一する。 for each_df in df_list: each_df.columns = new_column_names
Non-48-row-DF の整形
- 108 個の DF のうち、ほとんど(104 個)は 48 rows * 6 columns (最後の DF は 6 rows * 6 columns)で既にきれいに揃っているので、これに下手に整形操作を加えて壊しちゃうと嫌過ぎる。
- そこで、問題のある 4 個の DF を含んだリストを隔離(list_df_non_48)し、これに対してだけ修正操作を行うことにします。
Script 3
# Isolate non_48-row type DFs as df_non_48. list_df_non_48 = [each_df for each_df in df_list if len(each_df) > 48] list_df_48 = [each_df for each_df in df_list if len(each_df) <= 48]
Script 4
def akb_48(bad_df): '''Educate and transform non_48 DataFrames to akb_48 DataFrames''' # Create a 'good_df' for output. good_df = bad_df.copy() bad_df.reset_index() # Preparation / 念のため for i in range(1, len(bad_df) - 1): # Find NaN-str-NaN (float-str-float) patterns ('triad' streaches) # in the column 0. if ( (type(bad_df.iat[i-1, 0]) == float) & (type(bad_df.iat[i , 0]) == str) & (type(bad_df.iat[i+1, 0]) == float)): # Target triads in the columns (clm) 3, 4, and 5 clm_3_triad = [bad_df.iat[i-1, 3], bad_df.iat[i, 3], bad_df.iat[i+1, 3]] clm_4_triad = [bad_df.iat[i-1, 4], bad_df.iat[i, 4], bad_df.iat[i+1, 4]] clm_5_triad = [bad_df.iat[i-1, 5], bad_df.iat[i, 5], bad_df.iat[i+1, 5]] # Merge each triad into one, remove NaN (NaN: dtype = float). new_clm_3 = ', '.join([x for x in clm_3_triad if not type(x) == float]) new_clm_4 = ', '.join([x for x in clm_4_triad if not type(x) == float]) new_clm_5 = ', '.join([x for x in clm_5_triad if not type(x) == float]) # Refresh the columns 3, 4, 5 of the good_df (output DF). good_df.iat[i, 3] = new_clm_3 good_df.iat[i, 4] = new_clm_4 good_df.iat[i, 5] = new_clm_5 # Drop gargabe rows good_df.dropna(subset= ['sc_num'], inplace=True) # After dropping rows, reset the index! good_df.reset_index(drop=True, inplace=True) return good_df if __name__ == '__main__': # Create an empty list. list_df_yes_48 = [] # Cleansing for bad_df in list_df_non_48: good_df = akb_48(bad_df) list_df_yes_48.append(good_df)
DF List > Single DF
- まだ問題が残っていることを重々承知の上で、これを放置して次に進みます(👈急いては事を仕損じるタイプ)。
- df_non_48 を修正した list_df_yes_48 と、取り分けて温存しておいた df_48 とを合体して復元。
- 復元した df_list_tmp の中身(108 個の DF)を 1 本の単独 DF に concatenate する(つなげる)。
- これまでの操作を加えた後で ‘state_code’ が NaN の row は row ごと drop しても大丈夫なので、dropna しておく。
- Row を drop したあとは reset_index() して気分一新。
Script 5
# 1. Reconstruct the DF list. df_list_tmp = list_df_yes_48 + list_df_48 # 2. Concatenate 108 DFs to produce an intermediate DF. df_all_intermed = pd.concat(df_list_tmp) # 3. Remove garvage rows. df_all_intermed.dropna(subset = ['state_code'], inplace=True) # 4. Reset the index of the DF. df_all_intermed.reset_index(drop=True, inplace=True)
中間レビュー
- 中間産物の describe() を取って、仕上がり具合を確認します(TABLE 5)。
- オリジナル PDF の表は複数ページに分かれており、それぞれに表頭が付いています(Microsoft Word でいうと「タイトル行の繰り返し」状態)。このため、df_all_intermed には余分の表頭が含まれています。
- その結果、例えば、TABLE 5 で state_code (基礎知識: インドの州の略記。英字 2 文字。本資料作成時点で 33 種類)の unique が 70 (本来なら 33 のはず)となっているのは、state_code のところに表頭の文字列が入ってしまっているためと推測されます(このことは head() method などを使って簡単に確認できます)。
- com_in (現地拠点)の count が 5106 となっているのは良い知らせです(オリジナル PDF の最初に書いてあるように、日系企業の調査時点における現地拠点数は 5102 か所です)。
- ‘sc_num’ column(州ごとに振ってある拠点番号。当然 integer でなければならない)が decimal っぽくなっています。これは宜しくないのでいずれかのタイミングで修正します。
Script 6
# TABLE 5: Description of the intermediate DF df_all_intermed.describe()
- ‘state_code’ column に 英字2文字の state code 以外のものが入ってる rows は簡単に drop することができるので、いまは Review 中ですがちょっと作業をして、サクッと drop しておくことにします(👈計画性の欠如)。
- 結果(TABLE 6)を見ると、’state_code’ の unique もちゃんと 33 になったし、
- 他の columns も概ね 5102(資料作成時点の現地拠点数)と一致しており、ゴールは近い!
Script 7
for i in range(len(df_all_intermed) - 1): if not len(df_all_intermed.at[i, 'state_code']) == 2: df_all_intermed.drop(i, inplace=True) # Don't forget to reset the index. df_all_intermed.reset_index(drop=True, inplace=True)
Script 8
# TABLE 6: Description of the intermediate DF df_all_intermed.describe()
☕︎ここで休憩
ここまでの作業結果を、CSV に保存します(Script を走らせると、Google Drive に作った data フォルダの中に ‘df_all_intermed.csv’という 名前で保存されます)。
Script 9
# 以下で説明する問題が発覚したため、いったんコメントアウトします。 #df_all_intermed.to_csv(os.path.join(DATA_PATH, 'df_all_intermed.csv'), index=False)
重大事実の発覚😱
Google Drive から df_all_intermed.csv を local に落として、適当なソフトで開いてみると…。
- イイね、イイね、完璧じゃん、ほぼ出来上がり😸
- …と思いきや、途中から変なところで row が折り返され、shape がメチャメチャになってしまう事案が多発?なんなの、コレ?😱
- こりゃ何か変な事をやらかしちゃったかな?と、script 修正を数回試みるも症状は変わらず😭
原因及び対策
- しばし茫然自失としていたら、どこからか神様の声が…。
r だよ、r. \r, r’\r’, CR !
- ああ、思い出した。自分でも「Column Names の確認」のところで、「Column name に全角文字やスペース、改行文字(\r)などが含まれている。」って書いてるじゃん?
- そこで問題の箇所を確認すると、原稿の Excel で「セル内改行」が行われていたとおぼしきところに \r が挿入されていました。
- というわけで、やり直し。改行文字(\r)を差し障りのなさそうな文字列 <br> に replace してから保存することにします。
Script 9_rev
# regex で \r を <br> に replace df_all_intermed.replace(r'\r', r'<br>', inplace=True, regex=True) # CSV に保存 df_all_intermed.to_csv(os.path.join(DATA_PATH, 'df_all_intermed.csv'), index=False)
Excel で保存したら大丈夫だった
- もしかしたら、CSV じゃなくて Excel なら Microsoft 同士だから忖度してくれるんじゃないか、と思って、\r を <br> に replace する前の DF で試したみたところ、
- 予想に違わず、\r が混入した DF も何の問題もなく期待した形式の表として Excel Book に保存できました(ヤレヤレ…)。
\r 退治はもっと早い段階でやっておいた方が良かったみたいです。このようなヒトクセある「文字」は、陰に陽に悪さをしでかします。今回ももう少し早い段階で \r 退治 を行っていれば、Script 4 あたりの大部分の処理が不要になっていた可能性があります(と言いつつ、検証は行わないのであった 😅)
次回計画
- sc_num の data type を int に直す(cf. 中間レビュー 3.)
- これは本来、CSV に落とす前にやっておくべきですが、実は思わぬ伏兵が潜んでいるので次回のお楽しみに。
- 表全体の並び順をオリジナルに戻す(現状ではTriad 退治の影響で並び順が乱れている)。
- 次回以降の予定
- ‘biz_type’ の正規化。現状で unique 値が 56 個ですが、これは本当の数の倍ぐらいに増えちゃっています(Triad 退治などの影響)。
- 正規化: ‘com_in’, ‘com_jp’, ‘biz_type’ は別の table に分けて foreign key で引っ張ってくるようにした方が良さそう。
おまけ
上記の script に説明を加えた Jupyter Notebook を pdf_to_csv_prep.ipynb_.zip [9 KB] (Preview)に置いておきました。Colab に upload すればそのまま使えます。
[1] 在インド日本国大使館, (独)日本貿易振興機構 (2019). インド進出日系企業リスト-2018
[2] Aki Ariga, tabula-py (PyPI)
[3] 在インド日本国大使館, (独)日本貿易振興機構 (2020). インド進出日系企業リスト-2019