Pandasを使ってCSV形式のログファイルを解析する時に特定のログメッセージの行だけを抽出する方法

シナリオ

以下のようなCSV形式のバッチ処理のログから、任意のメッセージが記された行をカウントしたい。

log.csv カラム構成

No column type
1 date date
2 time time
3 level string
4 message string

f:id:massox:20200626105840p:plain
あるメッセージが記された行をカウントしたい

方法

  1. 結論
  2. pandas.Series.str.contains() を使うとできそうだ
  3. 試しに動かしてみる
  4. 思わぬ落とし穴―引数regex
  5. 最終的な答え

1. 結論

やりたいことを実現するには、以下のコードを書けば終わり。

import pandas as pd

df_log = pd.read_csv('log.csv')

# 今回の結論
target_str = 'カウントしたいログメッセージ文字列'
df_log_extracted = df_log[df_log['message'].str.contains(target_str, na=False, regex=False)]

print('カウント結果:', df_log_extracted.shape[0])

今回の学び

  • pandas.Series.str.containsメソッドで指定文字列に部分一致する行を抽出できる
  • 引数naで、NaNの行をTrueにするかFalseにするか決める。NaNがある場合、この設定がないとValueErrorになる
  • 引数regexで、指定文字列を正規表現として解釈するか、ただの文字列として解釈するか決める。デフォルトでは正規表現として解釈される。
  • 指定文字列に正規表現として意味を成す記号(例えば「()」など)がある場合、regex引数の明示的な指定が必要になる

2. pandas.Series.str.contains() を使うとできそうだ

「pandas 文字列 部分一致 行抽出」などで検索すると、

高確率でこのメソッドに行き当たるだろう。

pandas.Series.str.contains — pandas 1.0.5 documentation

ざっくり説明すると、

  1. pd.Series.str.contains('TARGET_STR')によりTARGET_STRを含む行をTrue、それ以外をFalseとしたSeriesが生成される
  2. 上記1で取得したBooleanのSeriesをDataFrameのインデクスに与えることでTrueの行だけを抽出できる

といった流れ

めっちゃ簡単やん。Pandas先輩さすがっす。

3. 試しに動かしてみる

テストデータを作って、試してみよう。

まずは、適当なテストデータを作る

import pandas as pd

df_test = pd.DataFrame({'A': [0, 1, 2], 'msg': ['normal', 'normal', 'warning']})
print(df_test)

f:id:massox:20200626130610p:plain
テスト用のテーブル

次に、msgカラムが「normal」である行を抽出する。

部分一致であることを確認する為、あえて検索文字列は「norm」にする。

df_normal = df_test[df_test['msg'].str.contains('norm')] 
print(df_normal)

結果は、この通り

f:id:massox:20200626131020p:plain
msg == normal の行だけ抽出した

4. 思わぬ落とし穴―引数naとregex

実は、これだけでは上手く行かないケースがある。

import pandas as pd
import numpy as np

# NaN, () を含むDataFrameを作る
df_test2 = pd.DataFrame(
    {'A': [0, 1, 2, 3, 4],
    'msg': ['normal', 'normal', 'warning(Code:2)', '', 'warning(Code:1)']}
  )
df_test2 = df_test2.replace('', np.nan)
print(df_test2)

f:id:massox:20200626132921p:plain
デフォルト引数だと失敗してしまうDataFrame

4.1. 引数naの役割を知る

試しに先ほどと同じ処理を実行してみる。

df_normal2 = df_test2[df_test2['msg'].str.contains('norm')] 

以下のような例外が出る。

# ...
ValueError: Cannot mask with non-boolean array containing NA / NaN values

どうやら、NAやNaNを含む、NotBooleanな列はmask処理が出来ないということらしい。

では、NaNを含むようなテーブルにどう対処すればいいかというと……

公式ドキュメントにはちゃんと書かれている。

pandas.Series.str.contains — pandas 1.0.5 documentation

f:id:massox:20200626133417p:plain
pandas.Series.str.contains

今回のケースは、指定した文字列を含むものだけがTrueになってほしい。

したがって、NaNの行はFalse(=抽出しない)という挙動になってもらいたい。

では、これを改善してもう一度チャレンジ

# 引数naをFalseに設定
df_normal2 = df_test2[df_test2['msg'].str.contains('norm', na=False)] 

f:id:massox:20200626133706p:plain
na引数を設定して抽出できた

成功した。

4.2. 引数regexの役割を知る

次は、msgカラムが「warning(Code:1)」の行だけを抽出してみる。

df_warning_c1 = df_test2[df_test2['msg'].str.contains('warning(Code:1)', na=False)] 

すると、こんなWarningが出てくる

UserWarning: This pattern has match groups. To actually get the groups, use str.extract.
  return func(self, *args, **kwargs)

「このパターンだとたくさんのグループにマッチした。

それらのグループを実際に取得するには、str.extractを使え」とのことだ。

「たくさんのグループにマッチした」 という点がよく分からない。

ちなみに、抽出結果のDataFrameは空である。

f:id:massox:20200626134728p:plain

参考になるサイトはこちら。

pandasで特定の文字列を含む行を抽出(完全一致、部分一致) | note.nkmk.me

結論は、指定した第一引数の文字列が正規表現として解釈されていることが問題。

検索文字列に含まれる「()」が問題の箇所だ。

公式のリファレンスを見たら分かるように、デフォルトで第一引数の文字列は正規表現として解釈される。

f:id:massox:20200626134543p:plain
引数regex

今回は、普通の検索ワードとして判断してほしいので、引数regexをFalseに設定すると良さそうだ。

以下のように改善しよう。

df_warning_c1 = df_test2[df_test2['msg'].str.contains('warning(Code:1)', na=False, regex=False)] 

期待通りの挙動を実現することが出来た。

f:id:massox:20200626134838p:plain

5. 最終的な答え

冒頭の結論に書いた通り。