Gunosyデータ分析部アルバイトの鈴木です。今回は密度比を利用したバージョンリリースにおける異常検知
について学んだことをまとめたいと思います。
やりたいこと
ニュースパス(Gunosyの提供するプロダクトの一つ)をバージョンアップした時に、もし異常があればユーザーアクションログからその兆候を見つけてslackなどに通知できるようにすることが目標です。
(QA項目以外でのログ欠損やアップデートによる予期せぬユーザ行動の検知をするためです。)
現在Gunosyでは、バージョンアップ時に異常がないかどうか調査するために人手を割いています。しかし、もし自動で異常を確実に見つけられるようになれば社員さんの負担も軽くできますよね?こういったことが異常検知のモチベーションです。
超長期的にやりたいこと
唐突ですが最良のシステムって何でしょう?エラーのないシステムか、エラーを想定しそれを自己解決できるシステムか。僕は後者だと思います。ある社員さん曰く、異常を自動で検知し自動で修正できるプロダクトを作ることが最終目標だそうです。まさに人工知能ですね笑。ニュースパス人工知能化計画の一端を担っていると考えると少しワクワクします。
密度比を用いた異常検知のイメージ
密度比 は次のように定義されます
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()
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()
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)
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()
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()
ヒストグラムでは分からなかった異常が、密度比のグラフにすると一目瞭然ですね!
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)
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
は、ありえなくはない値である、くらいしか言えませんね笑。上手に閾値を設定してください。