ココンの情報をいつでも、どこでも。ココントコ。

エンジニア 2018.05.24

ログデータのクラスタリングについて

AI戦略室の坂本です。
前回に引き続き、時系列データに対する機械学習について書こうと思います。
今回のエントリは、実際のプログラムコードを元にした技術的な解説になります。時系列データの取り扱いについての基本的な考え方は、前回のエントリを参考にしてください。

目次

ログデータのクラスタリング

機械学習による分析を必要とする時系列データとしては、Webやアプリ等のログデータが代表的なものですが、ここではログデータのクラスタリングについて考えてみます。
ログデータのクラスタリングとは一口に言えば、多種多様なユーザーの行動ログから、行動傾向毎にユーザーを分析する、というものになります。
分析の目的としては異常検出やゲームのチート行為の検出、ユーザーの分類によるマーケ分析などが挙げられますね。
特に、異常値について、「これが異常データだ」という異常系が予め定義出来ない場合など、「他のみんなとは違うことをやっている」ユーザーを発見することで、クラッキング行為を行っている(あるいは行おうと調査している)ユーザーを発見したり、仕様の穴を付いて開発側の想定していない使い方をしているユーザーを発見したり出来る訳です。

実際には、ここで紹介するような単純な分散表現+クラスタリングだけではなく、もっと多面的にデータ解析を行い、ブラック/ホワイトリストのロジックを組み合わせて全体としての解析モデルを作成するので、ここではあくまでニューラルネットワークによるログデータの可視化について、動作原理を紹介する程度となります。

○サンプルデータの入手

データのサンプルとして、UCI Machine Learning Repositoryにある、Amazon Accessサンプルデータセットを使用します。
https://archive.ics.uci.edu/ml/datasets/Amazon+Access+Samples
このデータは、Amazonのサービスにおけるアクセス権の要求ログを匿名化し、機械学習による分析用にフリーで公開しているものです(もちろん、Amazonの全ログではなく、一部のみを取りだしたものですが)。
このデータセットには二つのファイルが含まれていて、「amzn-anon-access-samples-history-2.0.csv」には、ユーザーのアクセス権要求の履歴が保存されていて、「amzn-anon-access-samples-2.0.csv」には、それぞれのユーザーが持っている権限が保存されています。つまり、この二つのファイルを組み合わせれば、「実際には持っていない権限を要求してきたユーザー」等を見つけることも出来る訳です。
ここでは分析アルゴリズムを紹介するだけなので、権限の比較などは行わずに、アクセス権の要求ログから、アクセス権の追加と削除の要求を時系列的に並べて(面倒なので時間間隔やセッションについても無視します)、何らかの形でデータを分離できないか、試してみることにします。

import numpy as np
import pandas as pd
df = pd.read_csv('amzn-anon-access-samples-history-2.0.csv')
users = np.unique(df.LOGIN.values)

まずはデータの確認から。上のようにpandasでデータを読み込み、

logdata = {}
for u in users: 
 	logdata[u] = []
 	for v in df[df.LOGIN==u].ACTION.values:
 		d = 0 if v == 'add_access' else 1
 		logdata[u].append(d)

ユーザーID毎に「add_access」か「remove_access」かの履歴からなるディクショナリを作成します。

>>> logdata.keys()
dict_keys([0, 65538, 65540, 65546, 76461, 65552・・・
>>> logdata[0]
[0, 0, 0, 0, 0]
>>> logdata[65538]
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
>>> logdata[65540]
[0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]

中身はこんな感じです。

使用するニューラルネットワーク

ここでは、上の0と1からなる時系列データから、ニューラルネットワークを使用してユーザー毎の分散表現を作成する事になります。
使用するニューラルネットワークは、RNNを使用したEncoder-Decoderモデルとします。今回もフレームワークとしてChainerを使用しました。

# RNNの定義をするクラス
class Encoder_Decoder_Model(chainer.Chain):

	def __init__(self):
		super(Encoder_Decoder_Model, self).__init__()
		with self.init_scope():
			self.hidden1 = L.Linear(4, 6)
			self.encoder = L.StatefulGRU(6, 2)
			self.hidden2 = L.Linear(4, 6)
			self.decoder = L.StatefulGRU(6, 2)
			self.output = L.Linear(2, 4)

	def reset_state(self):
		self.encoder.reset_state()
		self.decoder.reset_state()

	def encode_one(self, x):
		h1 = F.tanh(self.hidden1(x))
		y = self.encoder(h1)
		return y

	def decode_one(self, x):
		h1 = F.tanh(self.hidden2(x))
		h2 = F.tanh(self.decoder(h1))
		y = self.output(h2)
		return y

内容は単純なもので、0と1の二つに開始記号、終端記号を含めた4単語を扱うEncoder-Decoderモデルです。RNN層としてはノード数2のGRUを、隠れ層としてノード数6の全結合層を使用し、エンコーダー側とデコーダー側とは別のパラメーターを学習させます。

Encoder-Decoderモデルの学習

Encoder-Decoderモデルの学習はカスタムのUpdaterクラスを作成し、update_core関数内で一つ一つデータをエンコーダーに入力し、デコーダーからの出力をsoftmax_cross_entropyにかけます。エンコーダー側の入力とデコーダー側の出力とが逆順になっているのは、Encoder-Decoderモデルを使用する際の基本的なテクニックで、学習効果の向上効果があります。詳しくは拙著「Chainerで作るコンテンツ自動生成AIプログラミング入門」を参照してください。

# カスタムUpdaterのクラス
class RNNUpdater(training.StandardUpdater):

	def __init__(self, optimizer):
		super(RNNUpdater, self).__init__(
			None,
			optimizer,
			device=-1
		)
		self.loss_log = []
		self.n_iter = 0

	# イテレーターがNoneなのでエラーが出ないようにオーバライドする
	@property
	def epoch(self):
		return 0

	@property
	def epoch_detail(self):
		return 0.0

	@property
	def previous_epoch_detail(self):
		return 0.0

	@property
	def is_new_epoch(self):
		return False
		
	def finalize(self):
		pass

	def update_core(self):
		# Optimizerを取得
		optimizer = self.get_optimizer('main')
		# ニューラルネットワークを取得
		model = optimizer.target

		key = random.choice(list(logdata.keys()))

		# RNNのステータスをリセットする
		model.reset_state()

		# 開始文字を入力
		y = model.encode_one(one_hot(2))
		# 逆順にエンコーダーに入力
		for w in logdata[key][::-1]:
			# 一つRNNを実行
			y = model.encode_one(one_hot(w))
		# 終了文字を入力
		y = model.encode_one(one_hot(3))
		
		# ステータスを引き継ぐ
		model.decoder.set_state(model.encoder.h)
		
		loss = 0

		# 開始文字を入力
		y = model.decode_one(one_hot(3))
		loss += F.softmax_cross_entropy(y, np.array([logdata[key][0]]))
		# 文の長さ分だけ
		for i in range(1, len(logdata[key])):
			# 一つ前の出力をRNNに入力
			y = model.decode_one(y)
			loss += F.softmax_cross_entropy(y, np.array([logdata[key][i]]))

		# 重みデータを一旦リセットする
		optimizer.target.cleargrads()
		# 誤差関数から逆伝播する
		loss.backward()
		# 新しい重みデータでアップデートする
		optimizer.update()
		self.n_iter += 1
		
		self.loss_log.append(np.mean(loss.data))
		if len(self.loss_log) == log_interval:
			print('%d iter, loss = %f'%(self.n_iter,np.mean(self.loss_log)))
			self.loss_log = []

引き継いだステータスを経由してエンコーダー側にも逆伝播がかかるので、このまま学習させることが出来ます。
Adamアルゴリズムで、ランダムに選択したデータを100000回学習させています。

# ニューラルネットワークの作成
model = Encoder_Decoder_Model()

# 機械学習を実行する
import os.path
if not os.path.isfile('logmap.npz'):
	print('start training')
	# 誤差逆伝播法アルゴリズムを選択
	optimizer = optimizers.Adam()
	optimizer.setup(model)
	updater = RNNUpdater(optimizer)
	trainer = training.Trainer(updater, (100000, 'iteration'), out="result")
	trainer.run()
	chainer.serializers.save_npz( 'logmap.npz', model )
else:
	chainer.serializers.load_npz( 'logmap.npz', model )

結果の確認

最後に、全てのデータに対して学習済みのモデルを適用して、結果を取得します。
RNN層のノード数を2にしているので、取得できる分散表現の次元数も2となり、ちょうど散布図にして可視化することが出来ます。下のコードはそれぞれのデータをX,Yとして、元のデータの文字列表現をDとして保存します。

# 全てのログに対して実行
print('make result')
X = []
Y = []
XY = []
D = []
for key in logdata.keys():
	# RNNのステータスをリセットする
	model.reset_state()
	# 開始文字を入力
	y = model.encode_one(one_hot(2))
	# 逆順にエンコーダーに入力
	for w in logdata[key][::-1]:
		# 一つRNNを実行
		y = model.encode_one(one_hot(w))
	# 終了文字を入力
	y = model.encode_one(one_hot(3))
		
	# ステータスを取得
	state = model.encoder.h
	X.append(state.data[0][0])
	Y.append(state.data[0][1])
	XY.append(state.data[0])
	D.append(' '.join(list(map(str,logdata[key]))))
	if len(XY) % log_interval == 0:
		print('%d / %d'%(len(XY),len(logdata.keys())))

それを、Scikit-learnのAgglomerativeClusteringでクラスタリングし、matplotlibで散布図にして保存します。また、結果をまとめてresult.csvとして保存します。

# 結果を散布図にして保存
import matplotlib.pyplot as plt
from sklearn import cluster
kmean = cluster.AgglomerativeClustering(n_clusters=2, linkage='average')
C = kmean.fit_predict(XY)
df = pd.DataFrame({'x': X,'y': Y, 'c':C, 'd':D})
df.to_csv('result.csv')
df.plot(kind='scatter', x='x', y='y', c=C, colormap='cool')
plt.savefig('result.png')
plt.clf()

出来上がった散布図は、以下のようになりました。
図の形から、大きく分けて二つの系統が作成されていることが解ります。また、クラスタリングのアルゴリズムはそれらの系統を正しく分離できています。

では、分離された二つの系統の中身はどうなっているでしょうか。
保存されたresult.csvを読み込んで、元データの中身を確認してみます。


>>> df = pd.read_csv('result.csv')
>>> df[df.c==0].d.head()
2     0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 ...
3     0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 ...
13    0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 ...
14    0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 ...
15    0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 ...
Name: d, dtype: object
>>> df[df.c==1].d.head()
0                                            0 0 0 0 0
1                                0 0 0 0 0 0 0 0 0 0 0
4                                                  0 0
5    0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 ...
6    0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 ...
Name: d, dtype: object

これだけでは良く解らないので、時系列データ内の数値をUniqしてみます。


>>> set([' '.join(list(set(i.split()))) for i in df[df.c==0].d.values])
{'1 0'}
>>> set([' '.join(list(set(i.split()))) for i in df[df.c==1].d.values])
{'0'}

系統1(クラスタ0)にはデータの1と0(「remove_access」と「add_access」)が含まれている一方、系統2(クラスタ1)には0のみ(「add_access」のみ)が含まれていることが解ります。
このことから、データに「remove_access」が含まれているかどうかで、大きな系統の差が作成されていることが解ります。

まとめ

今回は単純な1と0の羅列からなる時系列データを分散表現にしただけですが、それでもデータの傾向によるクラスタリング(「remove_access」が含まれているかどうかのクラスタ)を発見することが出来ました。
もちろん、単にそれだけを判断するのであれば、ニューラルネットワークなど使わずに単純なIf文で事足りるのですが、データ内にどのような傾向が潜在しているのか解らない状態から、教師なし学習により何らかの傾向を発見し可視化することで、データ内に潜在している傾向を発見することが出来るのだ、という一連の流れのサンプルとして捉えて頂ければと思います。
そのほかにも、系統の中での座標の差が何を反映しているのかを分析することも出来ますが、今回はここまでとします。
今回作成したプログラムは、以下のURLにて公開しています。
https://github.com/cocon-ai-group/amznlog-cluster-sample

繰り返しになりますが、実際のログ分析ではもっと多面的なデータ解析が必要で、ニューラルネットワークを使えば全てのパターンで異常を検出することが出来る訳ではありません。
また、異常検出や類似データの発見にはSimilarity Learning(類似学習)の手法を使う方が良い場合が多いようですが、それについてはまた別の機会にしたいと思います。

イベントインタビュー広報 2020.01.31

「OpenID Summit Tokyo 2020」参加レポート

広報の馬場です。4年に一度のOpenIDの祭典「OpenID Summit Tokyo 2020」が渋谷ストリーム・ホールにて1月2…

イベントインタビュー広報 2019.11.11

世界のハッカーが競う「DEF CON CTF Finals 2019」参加者インタビュー

広報の馬場です。 DEF CONの人気コンテンツの一つである「DEF CON CTF」は、熾烈な予選を勝ち抜いたチーム…

エンジニア 2019.08.23

危険すぎると話題の文章生成モデル「GPT-2」で会社紹介文を生成してみました

ココングループのAI TOKYO LABの細川です。AI、特にディープラーニング系のホットな技術を実際に動かすと…