StatsFragments

Python, R, Rust, 統計, 機械学習とか

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 アクセサ

以降の処理は pandasstr アクセサを中心に行う。pandas では内部のデータ型が文字列 ( str もしくは unicode ) 型のとき、 str アクセサを使って データの各要素に対して文字列メソッドを適用することができる。str アクセサから使えるメソッドの一覧はこちら

補足 v0.15.1 時点では str アクセサからは使えない Python 標準の文字列メソッドもある。そんなときは applyapply についてはこちら

例えば、文字列を小文字化する 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

上記の結果を元のカラムに代入して上書きする。その後、dropnaNaN のデータを捨てる。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('新連載数')

f:id:sinhrks:20141206231815p:plain

連載回数

連載回数の頻度分布をみてみると、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('連載回数の頻度分布')

f:id:sinhrks:20141206231925p:plain

連載の継続率

連載の継続率 = 生存率とみて 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 % くらいか、、、厳しい世界だホント。

f:id:sinhrks:20141206232325p:plain

ジャンル別の連載の継続率

上と同じ、でジャンル別。

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 回くらいに壁がありそう。ニセコイがこの壁を乗り越えてくれることを切に願う。

f:id:sinhrks:20141206232543p:plain

Pythonによるデータ分析入門 ―NumPy、pandasを使ったデータ処理

Pythonによるデータ分析入門 ―NumPy、pandasを使ったデータ処理