はてだBlog(仮称)

私的なブログど真ん中のつもりでしたが、気づけばWebサイト系のアプリケーション開発周りで感じたこと寄りの自分メモなどをつれづれ述べています。2020年6月現在、Elasticsearch、pandas、CMSなどに関する話題が多めです。...ですが、だんだんとより私的なプログラムのスニペット置き場になりつつあります。ブログで述べている内容は所属組織で販売している製品などに関するものではなく、また所属する組織の見解を代表するものではありません。

Webサイトのアクセスログのよくあるかもしれない集計パターンの自分用覚書(Python/Pandas関連)

Webサイトのアクセスログの集計のワンライナーのようなものについては、かつてはいろいろ思うところがありました。 思うというか苦しめられておりました。

しかし、いまや 自分が語るには、この世界が発展・複雑化しすぎており、世界の片隅でのひとりごととしても、それほど特別なことは言えないなあ、特に出番はないなあというのが最近の状況でした。

一方、さらに直近の出来事で、かつて自分が苦労したようなところの地味テクが、いまだにしばしば発生するような本気調査・分析前のゼロ次の切り分けなどの微妙なハザマの用途にピッタリはまったという経験に立て続けに遭遇しました。

ということで、ちょっと気を良くして、かつての地味テクのいくつかを、ちょいとデフォルメして、2020年の覚書としてまとめてみました。つまるところ毎度の個人的な覚書です。

なお、Pandas/Pythonを活用しています。Pandasは、便利メソッドのちょっとした実行例になっているので、たまたまこのページにたどり着いた方のために、ちょい解説と公式リファレンスにポツポツとリンクしてあります。

[前提] この例で共通的にimportしているモジュール

※全部の例で全部を使っているわけではありません。

import pandas as pd
import sys
import urllib.parse
import collections
import re
import numpy as np

API reference — pandas 1.0.4 documentation

(1) よくある(かもしれない)アクセスログの分類(ラベル付け)

ログのイメージ と pandas.read_csvでの読み込み

pandas.read_csv — pandas 1.0.4 documentation

"""
# acc.log のイメージ(タブ区切り)
url cnt
https:// example.com/search/b/?q=アクセスログ&source=lnms&tbm=nws&sa=X&ved=2ahUKEwj-3YKHTHtBjcQ_AUoAnoECA0QBA&biw=1810&bih=1487&dpr=2   7
https:// example.com/search/a/?q=アクセスログ&tbm=vid&source=lnms&sa=X&ved=0ahUKEwia9tCY3fTpAhUBv0Q_AUIECgD&biw=1810&bih=1487&dpr=2 10
https:// example.com/search/b/?q=アクセスログ&tbm=vid&source=lnms&sa=X&ved=CY3fTpAhUByIsBHcDKDv0Q_AUIECgD&biw=1810&bih=1487&dpr=2   24
https:// example.com/search/c/d/e?q=アクセスログ&tbm=vid&source=lnms&sa=X&ved=0fTpAhUByIsBHcDKDv0Q_AUIECgD&biw=1810&bih=1487&dpr=2  5
https:// example.com/search/a/f/?q=アクセスログ&tbm=vid&source=lnms&sa=X&ved=0ahUKEhUByIsBHcDKDv0Q_AUIECgD&biw=1810&bih=1487&dpr=2  8
https:// example.com/search/g/h?q=アクセスログ&tbm=vid&source=lnms&sa=X&ved=0ahUKEwHcDKDv0Q_AECgD&biw=1810&bih=1487&dpr=2   7
...
...
"""

d = pd.read_csv('acc.log', sep='\t')

ディレクトリの第一階層(https://a.b.c.jp/d/e/?f=xxx&g=yyy ... でいうところの「d」)ごとにグループ化してカウント

ディレクトリの階層を軸にカウントする。あるあるですね。

Pandasであれば、strアクセサ のいくつかのメソッドを活用すると、場合によってはループで小難しい処理をしなければならないものも、宣言的に取り扱えます。

また、通常、URLでは、ディレクトリの階層数や、クエリパラメータの数や並び順が悩ましいのですが、str.splitの「expand」オプションをよろしく利用することとしています。

pandas.Series.str.split — pandas 1.0.4 documentation

URLCOL = 'url'
VALCOL = 'cnt'
PATH_DEPTH = 1
PATH_COL = PATH_DEPTH + 2  # https://a.b.c.jp/d/e/ の形式のカラムを「/」でsplitして、「d」より深いディレクトリ部分を取得する都合。

## ディレクトリの第一階層(https://a.b.c.jp/d/e/?f=xxx&g=yyy ... でいうところの「d」)ごとにグループ化してカウント
d2 = pd.concat([d, d[URLCOL].str.split('?').str[0].str.split('/', expand=True).fillna('')], axis=1)
wrk = d2[[VALCOL, PATH_COL]].groupby(PATH_COL)[VALCOL].apply(sum)
wrk.to_csv(sys.stdout,sep='\t')

グループ化をもう少し複雑な条件で行う。ここでは、クエリパラメータのつき具合で分類し、「label」に格納。

URLのパラメータやパスの特徴から分類してそれごとに集計したい場合がありますが、このような場合、まずは、URLを解析する関数でパターンごとにラベルづけした列を追加してやるという方法があると思います。

以後は、もともといろいろな集計関数があるので、ラベルの中身でグループ化して集計してやることで、少し深いことが可能です。

## グループ化をもう少し複雑な条件で行う。ここでは、クエリパラメータのつき具合で分類し、「label」に格納。
def label(url):
    REGX = re.compile("(act=|oq=|uact=)")
    if m := REGX.search(url):
        lb = url[m.span()[0]:m.span()[1]]
        return lb
    return 'others'
    
d['label'] = d[URLCOL].apply(label)  #ひとまず例はラベルづけするところまで

グループ化のラベルづけその2

集計というレベルでは、前項のほぼ再掲ですが、Python正規表現ではこんなこともできるのねという今更の気づきがあったのでその例です。

具体的には、reモジュールによる正規表現グループ化において後方参照ができますが、この際、正規表現のマッチしたその部分そのものではなくて、パターン名を名付けられるのでした。

へー、面白いですね。以下例では、わざわざこれを名付けして分類するのかという程度の例ですが、同じパターン名で抽象化しておくことで、多少実際のパターン内訳がかわっても、出力形式を変えなくて済みますし、パターン名そのものでもともと何に注目して分類しているのか分かりにくいこの類のアクセスログ分類の観点に名付けを行うことができますね。

re --- 正規表現操作 — Python 3.8.3 ドキュメント

(?P<name>...) 構文 と呼ばれるようです。(本当は、「<>」は半角の「<>」)

## 前項のグループ化について、この度、パターン名を定義できることを知ったのでそれを使う。
def label2(url):
    REGX = re.compile(
        r"("
        r"(?P<パターンA>act=)|"
        r"(?P<パターンB>oq=)|"
        r"(?P<パターンC>uact=)"    
        r")"
    )
    if m := REGX.search(url):
        return list(m.groupdict().keys())[0]
    return 'others'        

d['label'] = d[URLCOL].apply(label2)  #ひとまず例はラベルづけするところまで

(2) クエリパラメータに注目

ディレクトリのパスの次に多いであろう集計グループですが、クエリパラメータ部分によるものかと思います。

最近のPythonでは標準の urllibモジュールが便利です。

urllibモジュールのメソッドで、クエリパラメータをよろしく分解してやって、それをもとに前項のようなラベルづけ〜集計を行うということが比較的容易に可能になると思います。 (例では、urllibでクエリパラメータを分解してやるきっかけ部分まででとどめています。)

urllib.parse --- URL を解析して構成要素にする — Python 3.8.3 ドキュメント

# クエリパラメータに注目

d['q'] = d.url.apply(lambda u: list(urllib.parse.parse_qs(urllib.parse.urlparse(u).query).keys()))

## クエリパラメータのパラメータ名自体のバリエーションを把握する
collections.Counter(sum(d['q'].values, [])).most_common()

## クエリパラメータの組み合わせのバリエーションを把握する
set([','.join(i) for i in list(d['q'].values)])

(3) ネストしたグループ化

次に多いかなと思われるのが、複数のラベル(条件)でグループ化して集計するというものです。

3つ以上のグループ化となるとそもそももっとがっつり分析できるしかけを整える必要があるかもしれませんが、2つぐらいまではボチボチ遭遇するかなと思います。

また、本気ツールなどを使ってがっつり分析する方向にシフトチェンジするかどうかの判断のために、2次ネストぐらいまでのグループ化での集計は、手元でさらっとやってしまいたいというのもまたよくある例でしょう。

ちなみに、この時、外のグループか内側のグループかはともかく、それぞれの合計、小計などをおりまぜてソートしたデータが欲しいという場合がしばしばあるように思います。

例えば、iOSAndroidか、PC系のブラウザのUserAgentのうち、アクセスログが一番多いものから、接続元IPアドレスの述べ件数順に並べて集計を俯瞰したいという場合があります。

f1 f2 f3
A x 10
A x 9
A y 3
B x 15
B y 1
B y 1
C x 7
C y 8
C z 9

 ↓ こうしたい

  f1 f2  f1f2g_sum
  C  z          9
  C  y          8
  C  x          7
  A  x         19
  A  y          3
  B  x         15
  B  y          2

このような例でやっかいなの(≒EXCELではちょっとやりにくいかなと筆者が思うところとして)は、集計数は、UserAgent A と 接続元IPアドレス B のグループごとですが、一番大外の並び順は、UserAgent単位の合計が多いもの順というところです。

Pandasでは、この類の用途には、transformと呼ばれるメソッドが便利です。

また、drop_duplicatesというメソッドも、合わせて利用することが多いのではと思われます。

実際には例を見てもらえば良いのですが、transformで、同じグループ内のすべてのレコードに、そのグループの合計値となる同じ値をラベルづけします。

その後、drop_duplicatesで、グループの先頭のレコードだけ残して、同じグループの他のレコードを除外します。

すると、不思議なことに、グループ名とそのグループの集計値が得られるという演算方法です。

pandas.DataFrame.transform — pandas 1.0.4 documentation

ログの読み込み

# ちょっとしたネストありのグループ化。サブグループごとのグループ化した合計値を得たい。
# サブグループ内はそのサブグループ内の合計順。値を得たいが、ならび順序は上位グループごとの合計値としたい
import io
dummy = io.StringIO("""
f1 f2 f3
A x 10
A x 9
A y 3
B x 15
B y 1
B y 1
C x 7
C y 8
C z 9
""" )

ネストされたグループの集計(ちょっとひねったソートあり)

lg = pd.read_csv(dummy, sep=' ')

lg['f1g_sum'] = lg.groupby(['f1'])[['f3']].transform(np.sum)
lg['f1f2g_sum'] = lg.groupby(['f1','f2'])[['f3']].transform(np.sum)

lg.sort_values(['f1g_sum','f1','f1f2g_sum','f2'],ascending=[False,False,False,False]).drop_duplicates(keep='first', subset=['f1', 'f2'])[['f1', 'f2', 'f1f2g_sum']]   

"""
In [139]: lg.sort_values(['f1g_sum','f1','f1f2g_sum','f2'],ascending=[False,False,False,False])                                                                                                                                                                                           
Out[139]: 
  f1 f2  f3  f1g_sum  f1f2g_sum
8  C  z   8       24          8
7  C  y   8       24          8
6  C  x   8       24          8
0  A  x  10       22         19
1  A  x   9       22         19
2  A  y   3       22          3
3  B  x  15       17         15
4  B  y   1       17          2
5  B  y   1       17          2

"""

(4) 時系列・時間単位数

本来これは、このテーマの中では一番あとに回してはダメなぐらい頻出パターンです。 時系列での集計です。

Pandasをつかっていいならいろいろなやり方があると思いますが、時間が入っているカラムをread_csvなどで読み込み時に明示的に指名して、DataFrameのインデックスに指定してやる(DatetimeIndex型とする)と今回のような軽くいろいろ試してみるには良いでしょう。

pandas.DatetimeIndex — pandas 1.0.4 documentation

時間軸ログの例〜read_csvでparse_datesなどを指定して読み込み

## 擬似ログデータ
_tlg = """time,cnt
2017/03/15 10:03:24,18
2017/03/16 10:03:24,24
2017/03/17 10:03:24,431
2017/03/18 10:03:24,31
2017/03/19 10:03:24,41
2017/03/20 10:03:24,13
2017/03/21 10:03:24,41
2017/03/22 10:03:24,3
2017/03/22 12:03:24,431
2017/03/22 12:03:39,31
2017/03/22 12:13:24,32
2017/03/22 13:03:24,86
2017/03/22 15:23:24,238
2017/03/23 10:03:24,41
2017/03/24 10:03:24,31
2017/03/25 10:03:24,432
2017/03/26 10:03:24,41
2017/03/27 10:03:24,13
2017/03/28 10:03:24,141
2017/03/29 10:03:24,3
2017/03/30 10:03:24,1
2017/03/31 10:03:24,3
2017/04/01 10:03:24,4
2017/04/02 10:03:24,14
"""

tlg = pd.read_csv(io.StringIO(_tlg), sep=',',index_col='time',parse_dates=True)

単位時刻単位にグループ化して集計1

日毎、年ごと、曜日ごとというグループ化が宣言的な方法で実現できます。

## 単位時刻単位にグループ化して集計

### DatetimeIndexにしてしまえば、dtアクセサのように、日付のデータのかゆいところが取り扱える。 またグループ化にも使える。
### 日付でグループ化
tlg.groupby(lambda x: x.date)['cnt'].apply(sum)
### 曜日でグループ化
tlg.groupby(lambda x: x.weekday)['cnt'].apply(sum)

単位時刻単位にグループ化して集計2

resampleを使えば、1週間単位、月単位というような「単位」を指定したグループ化も可能

pandas.DataFrame.resample — pandas 1.0.4 documentation

### resampleを使えば、1週間単位、月単位というようなグループ化も可能
tlg.resample('W')['cnt'].sum()
tlg.resample('W').sum()  # このデータ例の場合は上記と同じ

合計件数が(おおよそ)一定数になる間の期間を取得

前の例は、グループ化する時間の単位を指定するタイプのものでした。

一方、たまにですが、ある集計値を時系列順のログの前の方から数えていって、累計がある値を超えたところで、時刻(前の区切りからの経過時間)をプロットしたいというような場合もあります。

閑散期は、カウンターリセットされる周期よりもトラフィック量の総計が少ないが、繁忙期はカウンターリセットされるよりも早く器が逼迫する傾向があるね...みたいなものを見てとりたい場合という例もあるでしょう。

このような場合に、qcutとcumsumを使うと良さそうです。(qcutとcumsumはそれはそれでもっとシンプルな用途、逆により複雑な用途にも対応できると思いますが、私の身近な例では、以下に挙げた例が一番よく使う気がします。)

pandas.qcut — pandas 1.0.4 documentation

## 合計件数が(おおよそ)一定数になる間の期間を取得
### qcutを利用。 
tlg['cumsum'] = tlg.cumsum().cnt # 他にも一発でできる方法があるかもしれないが、cumsumで累計値をいったんストックするところがポイント
c = tlg.cumsum().cnt
step = int ( (c.max() - c.min())/ 500 ) # なぜか知っているおおよそ500刻み
bins = pd.qcut(tlg['cumsum'], step) # stepで指定のbin数に分割
tlg_gb = tlg.groupby(bins) # binsはgroupbyに使うことができる
pd.concat([
    tlg_gb.sum(),
    tlg_gb.apply(lambda x: [x.index.min(), x.index.max()])],
    axis=1)


## 個人的には、移動平均はあまり使わないが、もちろん、window、rollingを用いたテクニックもある。
###   今回は略