Gunosyデータ分析ブログ

Gunosyで働くデータエンジニアが知見を共有するブログです。

アプリログの自動異常検知を試してみた~密度比による異常検知入門~

Gunosyデータ分析部アルバイトの鈴木です。今回は密度比を利用したバージョンリリースにおける異常検知について学んだことをまとめたいと思います。

やりたいこと

ニュースパス(Gunosyの提供するプロダクトの一つ)をバージョンアップした時に、もし異常があればユーザーアクションログからその兆候を見つけてslackなどに通知できるようにすることが目標です。
(QA項目以外でのログ欠損やアップデートによる予期せぬユーザ行動の検知をするためです。)
現在Gunosyでは、バージョンアップ時に異常がないかどうか調査するために人手を割いています。しかし、もし自動で異常を確実に見つけられるようになれば社員さんの負担も軽くできますよね?こういったことが異常検知のモチベーションです。

超長期的にやりたいこと

唐突ですが最良のシステムって何でしょう?エラーのないシステムか、エラーを想定しそれを自己解決できるシステムか。僕は後者だと思います。ある社員さん曰く、異常を自動で検知し自動で修正できるプロダクトを作ることが最終目標だそうです。まさに人工知能ですね笑。ニュースパス人工知能化計画の一端を担っていると考えると少しワクワクします。

密度比を用いた異常検知のイメージ

密度比  r は次のように定義されます

 r = \dfrac{P_{normal} }{P_{abnormal}}

2つの分布が一致する場合、r = 1 となります。

ユーザが特定の行動(記事クリックなど)をする確率の分布を利用して異常検知を行います。 前提として、この分布には規則性があり、アプリの新バージョンリリースがバグなく行われれば、概ね似た分布が形成されると考えます。

もし何かしらのバグがあればこの分布の形状が歪みます。これを利用すると、過去のリリース時の分布と、最新リリース時の分布を比較することで、そのリリースにバグがあるかどうか測ることができるのではないか、というのが密度比を用いた異常検知の考え方です。
ところで、
なぜ分布の密度比なのか?
ユーザーのアクション数の平均や中央値ではダメなの? と疑問に思われる方もいるのではないでしょうか。結論から言うと、ダメです。
(500, 9500)と( 5000,5000)の平均は共に5000という例からもわかるように、そもそも平均という指標は変化を見つけることに長けていません。加えてログの異常検知の場合、大量の正常なログの中から少量の異常なログを見つけ出す必要があるわけですが、少量の異常なログが混入しただけでは平均はさほど変化しません。中央値も同様です。微小な変化でも異常を見つけ出す場合、密度比を利用するのがベターだと判断しました。(具体例としてダミーデータでの実装例2を参照してください)

ダミーデータでの実装例1

今回試したやり方

密度比推定をせず、個別の分布を求めてから、それらを割り算し、密度比を求めました。 密度比が1だと分布が一致している、ということなので、
(1 - 密度比)の二乗の平均
を異常度としました。

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
# ちょっと分散の違う正規分布
normal_data = np.random.normal(100, 10, 10000)
abnormal_data = np.random.normal(100, 9, 10000)
plt.hist(abnormal_data, bins=25, alpha=0.3, color='r', range=[80,130])
plt.hist(normal_data, bins=25, alpha=0.3, color='b', range=[80,130])
plt.show()

f:id:sugarsatou:20171128164450p:plain

normal_hist = plt.hist(normal_data, bins=25, alpha=0.3, color='b', range=[80,130])
abnormal_hist = plt.hist(abnormal_data, bins=25, alpha=0.3, color='b', range=[80,130])


x_range_list = list(normal_hist[1] )
true_x_range_list = []
"""
plt.hist()[1]は植木算における木の棒、plt.hist()[0]は木の間隔なので長さが1違う!
だから len(x_range_list)-1
"""
for i in range(0, len(x_range_list) - 1):
    true_x_range_list.append((x_range_list[i] + x_range_list[i+1])/2)

normal_data_df = pd.DataFrame()
abnormal_data_df = pd.DataFrame()

normal_data_df['action_count'] = true_x_range_list
normal_data_df['frequency'] = normal_hist[0]/len(normal_data)

abnormal_data_df['action_count'] = true_x_range_list
abnormal_data_df['frequency'] = abnormal_hist[0]/len(abnormal_data)

abnormal_data_df

action_count frequency
81 0.0133
83 0.0170
85 0.0269
87 0.0341
89 0.0441
91 0.0553
93 0.0602
95 0.0693
97 0.0763
99 0.0795
101 0.0787
plt.plot(true_x_range_list, normal_data_df['frequency']/abnormal_data_df['frequency'])
plt.xlabel('action_count')
plt.ylabel('density ratio')
plt.show()

f:id:sugarsatou:20171128164744p:plain 1に近いほど正常で、離れているほど異常と考えます。

abnormality = np.mean((1 - normal_data_df['frequency']/abnormal_data_df['frequency'])**2)
abnormality
>>> 1.576268672846534

この値が大きいのか小さいのかという判断は自分で設定しないといけません。これが難しいところです。

今後試していくやり方

densratio_pyパッケージを利用します。個別の分布を求めずに直接密度比を推定するやり方です。 前述した個別の分布を求めてから密度比を計算するやり方にはデメリットがあります。確率変数同士の割り算は誤差がとても大きくなる可能性があることです。一方直接密度比を推定できればこの問題は発生しません。 断然直接求めてみたいですよね?笑

from numpy import random
from scipy.stats import norm
from densratio import densratio

x = np.random.normal(100, 10, 10000)
y = np.random.normal(100, 9, 10000)
result = densratio(x, y)
print(result)

f:id:sugarsatou:20171128173138p:plain

result.compute_density_ratio(y)
abnormality = - np.log(result.compute_density_ratio(y))

abnormality
>>> array([ 0.00788939, -0.00016233, -0.00328684, ..., -0.00290122,
       -0.00187464,  0.01139688])

np.mean(abnormality)
>>> 0.0038648076997555504

以上が最少二乗密度比推定法を用いた異常度の計算の流れです。 詳しい理論は以下の参考資料を参照してください。

ダミーデータでの実装例2

より実際の異常に近いダミーデータを用いて異常検知してみます。新しいバージョンのアプリをリリースしたばかりの時ありがちなバグとしては、おかしなログが入る (特定のログが複数回とばされるなど)があります。この時、アクションの分布は多峰性のある分布になります。
そこで、多峰性のある分布についても密度比を求めてみたいと思います。

密度比の平均二乗誤差を用いる場合
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
# 正規分布+平均の違う正規分布で多峰な分布を作る
normal_data = np.random.normal(100, 10, 10000)
abnormal_data = np.append(np.random.normal(100, 10, 10000), np.random.normal(130, 1, 10))
plt.hist(abnormal_data, bins=25, alpha=0.3, color='r', range=[80,150])
plt.hist(normal_data, bins=25, alpha=0.3, color='b', range=[80,150])
plt.show()

f:id:sugarsatou:20171201104619p:plain x=130近辺に異常なデータを10個追加しましたが、ヒストグラムで見る限りでは、分布の異常は見当たらないです。

normal_hist = plt.hist(normal_data, bins=25, alpha=0.3, color='b', range=[80,normal_data.max()])
abnormal_hist = plt.hist(abnormal_data, bins=25, alpha=0.3, color='b', range=[80,normal_data.max()])


x_range_list = list(normal_hist[1] )
true_x_range_list = []
for i in range(0, len(x_range_list) - 1):
    true_x_range_list.append((x_range_list[i] + x_range_list[i+1])/2)

normal_data_df = pd.DataFrame()
abnormal_data_df = pd.DataFrame()

normal_data_df['action_count'] = true_x_range_list
normal_data_df['frequency'] = normal_hist[0]/len(normal_data)

abnormal_data_df['action_count'] = true_x_range_list
abnormal_data_df['frequency'] = abnormal_hist[0]/len(abnormal_data)

normal_data_df

action_count frequency
81.21 0.0168
83.62 0.0232
86.03 0.0370
88.44 0.0495
90.85 0.0598
93.26 0.0798
95.67 0.0821
98.08 0.0960
100.49 0.0994
102.90 0.0924
105.31 0.0858
107.72 0.0689
110.13 0.0580
112.54 0.0407
114.96 0.0305
117.37 0.0250
119.78 0.0148
122.19 0.0083
124.60 0.0042
127.01 0.0022
129.42 0.0014
131.83 0.0007
134.24 0.0004
136.65 0.0003
139.06 0.0002

abnormal_data_df

action_count frequency
81.21 0.015684
83.62 0.026773
86.03 0.032967
88.44 0.051548
90.85 0.063237
93.26 0.072428
95.67 0.089311
98.08 0.089910
100.49 0.098202
102.90 0.090509
105.31 0.084615
107.72 0.069530
110.13 0.060539
112.54 0.044356
114.96 0.034466
117.37 0.020080
119.78 0.014286
122.19 0.008991
124.60 0.003796
127.01 0.002897
129.42 0.001798
131.83 0.000799
134.24 0.000200
136.65 0.000100
139.06 0.000100
plt.plot(true_x_range_list, normal_data_df['frequency']/abnormal_data_df['frequency'])
plt.xlabel('action_count')
plt.ylabel('density ratio')
plt.show()

f:id:sugarsatou:20171201104851p:plain ヒストグラムでは分からなかった異常が、密度比のグラフにすると一目瞭然ですね!

abnormality = np.mean((1 - normal_data_df['frequency']/abnormal_data_df['frequency'])**2)
abnormality
>>> 0.25229624295868402

異常度0.25となりました。この値が適切なのか否か。。。難しい判断になりそうですね笑

直接密度比推定する場合
from numpy import random
from scipy.stats import norm
from densratio import densratio

x = np.random.normal(100, 10, 10010)
y = np.append(np.random.normal(100, 10, 10000), np.random.normal(130, 1, 10))

result = densratio(x, y)
print(result)

f:id:sugarsatou:20171201111512p:plain

result.compute_density_ratio(y)
abnormality = - np.log(result.compute_density_ratio(y))

abnormality
>>> array([-0.02102919, -0.02234817,  0.05280343, ...,  0.1220773 ,
        0.12499095,  0.12314303])
  
np.mean(abnormality)
>>> 0.0015096024596322134

このやり方で計算すると異常度のオーダーが小さいですね。分散を変えた時の異常度が0.0038648076997555504だったので、0.0015096024596322134は、ありえなくはない値である、くらいしか言えませんね笑。上手に閾値を設定してください。

参考資料