Python pandas strアクセサによる文字列処理
概要
今週の 週刊 pandas
は文字列処理について。やたらと文字数が多くなったのだが、これはデータを都度表示しているせいであって自分の話がムダに長いわけではない、、、と思いたい。
今回はこちらの記事に書いた内容も使うので、適宜ご参照ください。
サンプルデータ
なんか適当な実データないかな?と探していたら 週間少年ジャンプの過去作品の連載作品 / ジャンルなどがまとめられているサイトをみつけた。これを pandas
で集計できる形まで整形することをゴールにしたい。
KTR's Comic Room: Weekly Jump Database
データの読み込み
上記リンクの "ジャンプ連載データ表" を、ファイル名 "jump_db.html" としてローカルに保存した。
補足 pd.read_html
では引数に URL を渡して 直接ネットワークからファイルを読むこともできる。が、今回は データ元サイトへの負荷をさけるため、ローカルの HTML ファイルを読むことにした。
依存パッケージのインストール
pd.read_html
を利用するためには 以下のパッケージが必要。インストールしていない場合はインストールする。
pip install lxml html5lib beautifulsoup4
準備
numpy
, pandas
をロードする。
# おまじない from __future__ import unicode_literals import numpy as np import pandas as pd # 表示する行数を指定 pd.options.display.max_rows = 10
HTML ファイルの読み込み
pd.read_html
では 対象の HTML ファイルに含まれる TABLE
要素 を DataFrame
として読み出す。ひとつの html ファイルには複数の TABLE
が含まれることもあるため、pd.read_html
の返り値は DataFrame
のリストになっている。
# read_htmlの返り値は DataFrame のリストになる dfs = pd.read_html('jump_db.html', header=0) type(dfs) # list # 最初のDataFrameを取り出す df = dfs[0] df # 開始 終了 回数 タイトル 作者 原作者・その他 ジャンル # 0 6801 7144 131 父の魂 貝塚ひろし NaN NaN # 1 6801 6811 11 くじら大吾 梅本さちお NaN NaN # 2 6804 6913 21 おれはカミカゼ 荘司としお NaN NaN # 3 6808 7030 60 漫画コント55号 榎本有也 NaN NaN # 4 6810 6919 21 男の条件 川崎のぼる 原作/梶原一騎 NaN # .. ... ... ... ... ... ... ... # 667 1442 連載中 12+ ハイファイクラスタ 後藤逸平 NaN 近未来,SF,刑事,一芸 # 668 1443 連載中 11+ Sporting Salt 久保田ゆうと NaN スポーツ医学,学園 # 669 1451 連載中 3+ 卓上のアゲハ 古屋樹 NaN 卓球,エロ,ミステリ # 670 1452 連載中 2+ E−ROBOT 山本亮平 NaN エロ,ロボット # 671 1501 連載中 1+ 学糾法廷 小畑健 原作/榎伸晃 学園,裁判 # # [672 rows x 7 columns] # 後続処理でのエラーチェックのため、最初のレコード数を保存しておく original_length = len(df)
最近の新連載はエロばっかりなの、、、??ちょっと心惹かれるがまあそれはいい。
各カラムのデータ型を確認するには df.dtypes
。文字列が含まれるカラムは object
型になっている。
# カラムのデータ型を表示 df.dtypes # 開始 float64 # 終了 object # 回数 object # タイトル object # 作者 object # 原作者・その他 object # ジャンル object # dtype: object
str
アクセサ
以降の処理は pandas
の str
アクセサを中心に行う。pandas
では内部のデータ型が文字列 ( str
もしくは unicode
) 型のとき、 str
アクセサを使って データの各要素に対して文字列メソッドを適用することができる。str
アクセサから使えるメソッドの一覧はこちら。
補足 v0.15.1 時点では str
アクセサからは使えない Python 標準の文字列メソッドもある。そんなときは apply
。apply
についてはこちら。
例えば、文字列を小文字化する lower
メソッドを各要素に対して適用する場合はこんな感じ。
# 各要素が大文字の Series を作成 d.Series(['AAA', 'BBB', 'CCC']) # 0 AAA # 1 BBB # 2 CCC # dtype: object # str アクセサを通じて、各要素に lower メソッドを適用 pd.Series(['AAA', 'BBB', 'CCC']).str.lower() # 0 aaa # 1 bbb # 2 ccc # dtype: object # str アクセサを使わないと AttributeError になり NG! pd.Series(['AAA', 'BBB', 'CCCC']).lower() # AttributeError: 'Series' object has no attribute 'lower'
また、str
アクセサに対するスライシングは各要素へのスライシングになる。各要素の最初の二文字を取り出す場合、
pd.Series(['AAA', 'BBB', 'CCC']).str[0:2] # 0 AA # 1 BB # 2 CC # dtype: object
データの処理
ここからサンプルデータへの処理を始める。
連載継続中かどうかのフラグ立て
元データでは、各作品が現在も連載中かどうかは以下いずれかでわかる。
- "終了" カラムの値が "連載中" である
- "回数" カラムの末尾に
+
がついている
# 回数 カラムの値を確認 df['回数'] # 0 131 # 1 11 # 2 21 # ... # 669 3+ # 670 2+ # 671 1+ # Name: 回数, Length: 672, dtype: object
が、このままでは機械的に扱いにくいため、連載中かどうかで bool
のフラグをたてたい。そのためには、以下どちらかの処理を行えばよい。
- "終了" カラムに対する論理演算を行う
- "回数" カラムの末尾を
str.endswith
で調べる
# 終了カラムの値で判別する場合 df['終了'] == '連載中' # 0 False # 1 False # 2 False # ... # 669 True # 670 True # 671 True # Name: 終了, Length: 672, dtype: bool # 回数カラムの値で判別する場合 df['回数'].str.endswith('+') # 0 False # 1 False # 2 False # ... # 669 True # 670 True # 671 True # Name: 回数, Length: 672, dtype: bool # 結果を 連載中 カラムとして代入 df['連載中'] = df['回数'].str.endswith('+')
連載回数のパース
続けて、現在は文字列 ( object
) 型として読み込まれている連載回数を数値型として扱いたい。が、上のとおり 連載中のレコードには末尾に +
, 確実な連載回数が取得できているレコードは [ ]
で囲われているため、そのままでは数値型へ変換できない。
# そのまま変換しようとすると NG! df['回数'].astype(float) # ValueError: invalid literal for float(): 1+
数値に変換するためには、文字列中の数値部分だけを切り出してから数値に変換すればよい。str.extract
を使うとデータに対して指定した正規表現とマッチする部分を抽出できる。抽出した数値部分に対して astype
を使って float
型へ変換する。
# 数値部分だけを抽出 (この時点では各要素は文字列) ['回数'].str.extract('([0-9]+)') # 0 131 # 1 11 # 2 21 # ... # 669 3 # 670 2 # 671 1 # Name: 回数, Length: 672, dtype: object # float 型へ変換 (表示の dtype 部分で型がわかる) df['回数'].str.extract('([0-9]+)').astype(float) # 0 131 # 1 11 # 2 21 # ... # 669 3 # 670 2 # 671 1 # Name: 回数, Length: 672, dtype: float64
補足 1件 元データが "??" のものがある。この文字列は数値を含まないため、str.extract
の結果は NaN
になる。NaN
を含むデータは int
型へは変換できない。
# NG! NaN があるため int 型へは変換できない df['回数'].str.extract('([0-9]+)').astype(int) # ValueError: cannot convert float NaN to integer
上記の結果を元のカラムに代入して上書きする。その後、dropna
で NaN
のデータを捨てる。dropna
についてはこちら。
# 結果を上書き df['回数'] = df['回数'].str.extract('([0-9]+)').astype(float) # 回数 が NaN のデータを捨てる df = df.dropna(axis=0, subset=['回数']) # 1レコードがフィルタされたことを確かめる assert len(df) + 1 == original_length df.shape # (671, 8)
連載開始時のタイムスタンプ取得
"開始" カラムの値から 年度, 週を取得。元データは、通常は "9650" のように年下二桁 + 週 の4桁、合併号では "9705.06" のようなドット区切りの値が入っている。そのため、read_heml
では float
型としてパースされている。これを datetime
型に変換したい。
ここでは N 号は その年の N 週 月曜に発行されたものとして日付を埋める。実際には号数と週番号はずれる / 合併号は発行曜日がずれる / 昔は発行曜日が違っていた、と様々な問題があるがここでは無視する。
素直にやるなら apply
を使ってこんな感じで書ける。pd.offsets
についてはこちら。
import datetime def parse_date(x): # 小数点を切り捨て x = np.floor(x) y, w = divmod(x, 100) # y が50より大きければ 1900年代, それ以外なら2000年代として扱う y += 1900 if y > 50 else 2000 d = datetime.datetime(int(y), 1, 1) # 週次のオフセットを追加 d += pd.offsets.Week(int(w), weekday=0) return d df['開始'].apply(parse_date) # 0 1968-01-08 # 1 1968-01-08 # 2 1968-01-29 # ... # 669 2014-12-22 # 670 2014-12-29 # 671 2015-01-05 # Name: 開始, Length: 671, dtype: datetime64[ns]
今回は文字列処理の記事なのであえて文字列として処理する。最初の手順は以下。
- 処理対象のカラムを
astype
で文字列型に変換し、適当な変数に代入する。 str.split
で小数点前後で文字列を分割。返り値をDataFrame
で受け取るためreturn_type='frame'
を指定。- 小数点の前の文字列のみ = 1列目のみ を処理対象の変数に代入しなおす。
# 処理対象のカラムを文字列型に変換 dt = df['開始'].astype(str) dt # 0 6801.0 # 1 6801.0 # 2 6804.0 # ... # 669 1451.0 # 670 1452.0 # 671 1501.0 # Name: 開始, Length: 671, dtype: object # str.split で小数点前後を分割 dt.str.split('.', return_type='frame') # 0 1 # 0 6801 0 # 1 6801 0 # 2 6804 0 # 3 6808 0 # 4 6810 0 # .. ... .. # 667 1442 0 # 668 1443 0 # 669 1451 0 # 670 1452 0 # 671 1501 0 # # [671 rows x 2 columns] # 小数点の前の文字列のみを処理対象の変数に代入 dt = dt.str.split('.', return_type='frame')[0] dt # 0 6801 # 1 6801 # 2 6804 # ... # 669 1451 # 670 1452 # 671 1501 # Name: 0, Length: 671, dtype: object
ここで、values
プロパティを使って内部の値を確認する。00年代の値は数値の桁数が異なるため、文字列長も異なっていることがわかる。このままではパースしにくいため、以下の処理を行う。
str.pad
で指定した長さまで空白文字でパディングする。str.replace
で空白文字を "0" に置き換える。
# 変数内部の値を確認 dt.values # array([u'6801', u'6801', u'6804', u'6808', u'6810', u'6811', u'6811', # ..... # u'9933', u'9934', u'9943', u'9943', u'9944', u'1', u'2', u'12', # u'13', u'23', u'24', u'32', u'33', u'34', u'38', u'47', u'48', # ..... # u'1451', u'1452', u'1501'], dtype=object) # str.pad で指定した長さまでパディング dt.str.pad(4).values # array([u'6801', u'6801', u'6804', u'6808', u'6810', u'6811', u'6811', # ..... # u'9933', u'9934', u'9943', u'9943', u'9944', u' 1', u' 2', # u' 12', u' 13', u' 23', u' 24', u' 32', u' 33', u' 34', # ..... # u'1441', u'1442', u'1443', u'1451', u'1452', u'1501'], dtype=object) # str.replace で空白文字を "0" に置換 dt.str.pad(4).str.replace(' ', '0').values # array([u'6801', u'6801', u'6804', u'6808', u'6810', u'6811', u'6811', # ..... # u'0012', u'0013', u'0023', u'0024', u'0032', u'0033', u'0034', # ..... # u'1441', u'1442', u'1443', u'1451', u'1452', u'1501'], dtype=object) # ここまでの処理結果を代入 dt = dt.str.pad(4).str.replace(' ', '0')
ここまでで、変数 dt
の中身は "年2桁 + 週2桁" の4文字からなる文字列になった。こいつに対して、
- スライシングによって文字列を 年, 週の部分文字列に分割する。
- 年については下2桁しかないため、
where
で場合分けして 西暦4桁の文字列にする。where
についてはこちら。これでpd.to_datetime
で処理できる形になる。 pd.to_datetime
で日付型にパース。入力は年のみのため、format='%Y'
を指定する。pd.to_datetime
についてはこちら。
# スライシングで年, 週に分割 year = dt.str[0:2] week = dt.str[2:4] # 西暦4桁の文字列を作成 year = ('19' + year).where(year > '50', '20' + year) year # 0 1968 # 1 1968 # 2 1968 # ... # 669 2014 # 670 2014 # 671 2015 # Name: 0, Length: 671, dtype: object # pd.to_datetime で日付型にパース year = pd.to_datetime(year, format='%Y') year # 0 1968-01-01 # 1 1968-01-01 # 2 1968-01-01 # ... # 669 2014-01-01 # 670 2014-01-01 # 671 2015-01-01 # Name: 0, Length: 671, dtype: datetime64[ns]
ここまでできれば、あと必要な処理は、
- 週の文字列から、
pd.offsets.Week
インスタンスを作る - 年から生成したタイムスタンプを
Week
分ずらす
# 週の文字列から、pd.offsets.Week インスタンスを作る week = [pd.offsets.Week(w, weekday=0) for w in week.astype(int)] week # [<Week: weekday=0>, # <Week: weekday=0>, # <4 * Weeks: weekday=0>, # ..... # <52 * Weeks: weekday=0>, # <Week: weekday=0>] # 10. 年から生成したタイムスタンプを 週番号分ずらす dt = [y + w for y, w in zip(year, week)] dt # [Timestamp('1968-01-08 00:00:00'), # Timestamp('1968-01-08 00:00:00'), # Timestamp('1968-01-29 00:00:00'), # ..... # Timestamp('2014-12-29 00:00:00'), # Timestamp('2015-01-05 00:00:00')] # 元の DataFrame に対して代入 df.loc[:, '開始'] = dt
原作有無でのフラグ立て
元データの "原作者・その他" カラムをみると、ここには原作ほか関係者 (監修とか) の名前も入っている。
df[~pd.isnull(df['原作者・その他'])]['原作者・その他'] # 4 原作/梶原一騎 # 9 原作/スドウテルオ # 28 原作/藤井冬木 # ... # 660 原作/成田良悟 # 661 原作/下山健人 # 671 原作/榎伸晃 # Name: 原作者・その他, Length: 111, dtype: object
"原作者・その他" カラムの値から 原作者がいる場合だけフラグを立てたい。具体的には、カラムに "原作/"を含む値が入っているときは 原作ありとして扱いたい。
そんな場合は str.contains
。
補足 元データでは 原作者は常に先頭に来ているので、str.startswith
でもよい。
df['原作者・その他'].str.contains('原作/') # 0 NaN # ... # 671 True # Name: 原作者・その他, Length: 672, dtype: object # 結果をカラムに代入 df['原作あり'] = df['原作者・その他'].str.contains('原作/')
重複データの削除
一部のシリーズでは 全体と 各部(第一部、二部など) で重複してデータが取得されている。目視でそれっぽいシリーズを抽出して表示してみる。
# 重複しているシリーズ候補 series = ['コブラ', 'ジョジョの奇妙な冒険', 'BASTARD!!', 'みどりのマキバオー', 'ONE PIECE', 'ボボボーボ・ボーボボ', 'DEATH NOTE', 'スティール・ボール・ラン', 'トリコ', 'NARUTO'] for s in series: # 重複しているシリーズ候補を タイトルに含むデータを取得 dup = df[df['タイトル'].str.contains(s)] print(dup[['開始', '回数', 'タイトル']]) # 開始 回数 タイトル # 299 8701.02 593 ジョジョの奇妙な冒険 # 300 8701.02 44 ジョジョの奇妙な冒険(第1部) # 314 8747.00 69 ジョジョの奇妙な冒険(第2部) # 335 8916.00 152 ジョジョの奇妙な冒険(第3部) # 378 9220.00 174 ジョジョの奇妙な冒険(第4部) # 433 9552.00 154 ジョジョの奇妙な冒険(第5部) # 489 1.00 [158] ジョジョの奇妙な冒険第6部・ストーンオーシャン # .....後略
上の例で言うと、"ジョジョの奇妙な冒険" シリーズの回数として 1部 〜 5部の回数分が入っているようだ。同様にシリーズ全体の連載回数に含まれていると思われるレコードを目視で抽出、削除。
# 削除対象タイトル dups = ['ジョジョの奇妙な冒険(第1部)', 'ジョジョの奇妙な冒険(第2部)', 'ジョジョの奇妙な冒険(第3部)', 'ジョジョの奇妙な冒険(第4部)', 'ジョジョの奇妙な冒険(第5部)', 'BASTARD!!〜暗黒の破壊神〜(闇の反逆軍団編)', 'BASTARD!!〜暗黒の破壊神〜(地獄の鎮魂歌編)', 'みどりのマキバオー(第1部)', 'みどりのマキバオー(第2部)', 'ONE PIECE《サバイバルの海 超新星編》', 'ONE PIECE《最後の海 新世界編》', 'ボボボーボ・ボーボボ(第1部)', '真説ボボボーボ・ボーボボ', 'DEATH NOTE(第1部)', 'DEATH NOTE(第2部)', 'スティール・ボール・ラン 1st Stage', 'スティール・ボール・ラン 2nd Stage', 'トリコ〜人間界編〜', 'トリコ〜グルメ界編〜', 'NARUTO(第一部)', 'NARUTO(第二部)'] # タイトルの値が dups に含まれるレコードを除外 df = df[~df['タイトル'].isin(dups)] # dups 分のレコードがフィルタされたことを確かめる assert len(df) + len(dups) + 1 == original_length df.shape # (652, 9)
ジャンルデータの作成
"ジャンル" カラムには複数のジャンルが カンマ区切りで含まれている。
df['ジャンル'] # 0 NaN # 1 NaN # 2 NaN # ... # 669 卓球,エロ,ミステリ # 670 エロ,ロボット # 671 学園,裁判 # Name: ジャンル, Length: 652, dtype: object
これを集計しやすい形にするため、各ジャンルに該当するかどうかを bool
でフラグ立てしたい。
このカラムには "秘密警察" とか "ゴム人間" なんて値も入っているため、ユニークな件数をカウントして上位ジャンルのみフィルタする。
まずは、カンマ区切りになっている値を分割して、ジャンル個々の値からなる Series
を作る。
# NaN をフィルタ genres = df['ジャンル'].dropna() # カンマ区切りの値を split して、一つのリストとして結合 genres = reduce(lambda x, y: x + y, genres.str.split(',')) # genres = pd.Series(genres) genres # 0 硬派 # 1 不良 # 2 アニメ化 # ... # 1358 ロボット # 1359 学園 # 1360 裁判 # Length: 1361, dtype: object
Series
内の要素がそれぞれいくつ含まれるかをカウントするには value_counts()
。
genres.value_counts() # ギャグ 76 # 学園 75 # アニメ化 74 # ... # 演歌 1 # 学園ショートギャグ 1 # スポーツ? 1 # Length: 426, dtype: int64 # 上位10件を表示 genres.value_counts()[:10] # ギャグ 76 # 学園 75 # アニメ化 74 # 格闘 36 # 一芸 32 # ファンタジー 32 # ゲーム化 30 # 変身 28 # ラブコメ 28 # 不良 23 # dtype: int64
とりあえず 以下の3ジャンルをみることにする。各レコードについてジャンルは複数あてはまりうる。そのため、それぞれのフラグについて dummy のカラムを作成し、ジャンルに該当する場合に True
を入れる。
genres = ['ギャグ', '格闘', 'ラブコメ'] # str アクセサを利用するため NaN 値をパディング df['ジャンル'] = df['ジャンル'].fillna('') for genre in genres: df[genre] = df['ジャンル'].str.contains(genre)
できあがり
# 必要なカラムのみにフィルタ df = df[['開始', '回数', 'タイトル', '連載中', '原作あり', 'ギャグ', '格闘', 'ラブコメ']] df
できたデータはこんな感じ。俺たちの分析はここからだ!
index | 開始 | 回数 | タイトル | 連載中 | 原作あり | ギャグ | 格闘 | ラブコメ |
---|---|---|---|---|---|---|---|---|
0 | 1968-01-08 | 131 | 父の魂 | False | NaN | False | False | False |
... | ... | ... | ... | ... | ... | ... | ... | ... |
667 | 2014-10-20 | 12 | ハイファイクラスタ | True | NaN | False | False | False |
668 | 2014-10-27 | 11 | Sporting Salt | True | NaN | False | False | False |
669 | 2014-12-22 | 3 | 卓上のアゲハ | True | NaN | False | False | False |
670 | 2014-12-29 | 2 | E−ROBOT | True | NaN | False | False | False |
671 | 2015-01-05 | 1 | 学糾法廷 | True | True | False | False | False |
まとめ
pandas
で文字列処理する場合は str
アクセサ。
補足 また、以下のサイトには毎週の掲載順位を含むデータがある。こちらのほうが興味深いが、前処理グレードがちょっと高かったのであきらめた。
おまけ: かんたんに集計
自分としては 想定の前処理ができた時点でもういいかって感じなのだが、簡単に集計してみる。
新連載数
各年 (年度ではなく calendar year) の新連載開始数を時系列でプロット。69年10月に週刊化されたらしく、直後の新連載数が特に多い。
import matplotlib.pyplot as plt summarised = df.groupby(df['開始'].dt.year)['タイトル'].count() ax = summarised.plot(figsize=(7, 3)) ax.set_title('新連載数')
連載回数
連載回数の頻度分布をみてみると、80年より前には10週以下で短期連載 or 連載終了した作品が比較的 多そう。80年代以降の分布が現在の感覚に近いと思う。
# 連載終了したデータのみにフィルタ fin = df[~df['連載中']] fin1 = fin[fin['開始'].dt.year < 1980] fin2 = fin[fin['開始'].dt.year >= 1980] fig, axes = plt.subplots(2, figsize=(7, 4)) bins = np.arange(0, 1000, 5) fin1['回数'].plot(kind='hist', ax=axes[0], bins=bins, xlim=(0, 100), label='80年より前に連載開始', legend=True) fin2['回数'].plot(kind='hist', ax=axes[1], bins=bins, xlim=(0, 100), label='80年以降に連載開始', legend=True, color='green') fig.suptitle('連載回数の頻度分布')
連載の継続率
連載の継続率 = 生存率とみて lifelines
を使って Kaplan-Meier 曲線を引く。横軸が時間経過 (週)、縦軸がその時点まで連載継続している確率になる。
lifelines
についてはこちら。
from lifelines import KaplanMeierFitter def to_pct(y, position): s = str(100 * y) return s + '%' from matplotlib.ticker import FuncFormatter pctformatter = FuncFormatter(to_pct) df2 = df[df['開始'].dt.year >= 1980] kmf = KaplanMeierFitter() kmf.fit(df2['回数'], event_observed=~df2['連載中']) ax = kmf.plot(figsize=(7, 3), xlim=(0, 200)) ax.yaxis.set_major_formatter(pctformatter)
50週 = 1年 連載継続できるのは 30 % くらいか、、、厳しい世界だホント。
ジャンル別の連載の継続率
上と同じ、でジャンル別。
ax = None for genre in genres: group = df2[df2[genre]] kmf = KaplanMeierFitter() kmf.fit(group['回数'], event_observed=~group['連載中'], label='ジャンル=' + genre) # 描画する Axes を指定。None を渡すとエラーになるので場合分け if ax is None: ax = kmf.plot(figsize=(7, 3), xlim=(0, 200), ci_show=False) else: ax = kmf.plot(ax=ax, figsize=(7, 3), xlim=(0, 200), ci_show=False) ax.yaxis.set_major_formatter(pctformatter)
もう少し偏るかと思ったがそうでもなかった。ラブコメは 150 回くらいに壁がありそう。ニセコイがこの壁を乗り越えてくれることを切に願う。
Pythonによるデータ分析入門 ―NumPy、pandasを使ったデータ処理
- 作者: Wes McKinney,小林儀匡,鈴木宏尚,瀬戸山雅人,滝口開資,野上大介
- 出版社/メーカー: オライリージャパン
- 発売日: 2013/12/26
- メディア: 大型本
- この商品を含むブログ (9件) を見る