V předchozím díle jsem se pokusil z velkého množství auditních záznamů vybrat takové, které se od ostatních významým způsobem odlišují, a mohou být tedy považované za anomálie. Zda taková anomálie znamená poruchu systému nebo jeho zneužití, to prozatím ponechme stranou. Při vyhledávání anomálií jsem se zaměřil na obsah (vlastnosti) každého záznamu samostatně.
Ony ty záznamy o událostech ale nevznikají náhodně. Už jen samotný výskyt události v kontextu ostatních nám může poskytnout docela zajímavé informace o chování systému a jeho uživatelů.
Opět bych se chtěl zaměřit na hledání anomálií v jinak běžném chování systémů. Protože pokud bych měl při dohledu běžících systémů něco řešit, pak jsou to právě výjimky z obvyklého chování.
A na to bych se chtěl v tomto pokračování podívat detailněji.
import random
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
import datetime
import pytz
import sqlite3
sns.set_style('darkgrid')
Stejně jako v předchozím článku budu načítat data ze stejné auditní databáze s omezením na jeden měsíc. To proto, že i tak jich je docela velké množství.
Výsledkem následujícího kroku je načtení a upravení dat do dataframe df.
SOURCE_DB = "/home/raska/tmp/audit.db"
with sqlite3.connect(SOURCE_DB) as con:
df = pd.read_sql_query("select * from audit", con, index_col='uuid', parse_dates=['ts'])
df.fillna(value=np.nan, inplace=True)
df.info()
<class 'pandas.core.frame.DataFrame'> Index: 370176 entries, 1ee1cac9-c84b-4b2d-a8f3-db6b7eed59fd to e31d4699-a65b-4f2b-84ff-bc2194396cf7 Data columns (total 7 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 ts 370176 non-null datetime64[ns] 1 event 370176 non-null object 2 org 370176 non-null object 3 usr1 21213 non-null object 4 usr2 40299 non-null object 5 usr3 1022 non-null object 6 peer 314766 non-null object dtypes: datetime64[ns](1), object(6) memory usage: 22.6+ MB
Tato databáze je poněkud zvláštní v tom, že obsahuje auditní záznamy z většího množství běžících systémů. To je ten sloupec org označující systém, který záznam vytvořil.
On se každý výše zmíněný systém v provozu chová trochu jinak. Jeho uživatelé využívají jiné služby, on pak sám poskytuje různé služby ostatním systémům. Proto bude asi lepší, když se podívám na každý systém odděleně.
Nejdříve si ale dále upravím data.
V tomto článku mne budou zajímat pouze informace o:
Datum a čas události si ořežu na rozlišení hodin, do většího detailu dnes nepůjdu. Pro přehlednější manipulaci s označením organizace použiji číslo kategorie namísto zdlouhavého UUID.
Takto tedy vypadá nový dataframe X, se kterým budu dále pracovat:
X = df[['event']].copy()
X['org'] = df['org'].astype('category').cat.codes
X['ts'] = df.ts.dt.floor('H')
X
event | org | ts | |
---|---|---|---|
uuid | |||
1ee1cac9-c84b-4b2d-a8f3-db6b7eed59fd | event_K | 6 | 2021–11–01 00:00:00 |
45c2d161–7c92–45c2–924d-5be83c7d7d5f | event_L | 29 | 2021–11–01 00:00:00 |
a56f86fd-20c9–4819–8eb7-bc2680e8fbb6 | event_L | 28 | 2021–11–01 00:00:00 |
3937d0f7-bc74–4bfb-9f03-aec98ce2e097 | event_L | 20 | 2021–11–01 00:00:00 |
b03e848f-4d5a-4e21–81a6-ae9a0d9957c5 | event_K | 6 | 2021–11–01 00:00:00 |
… | … | … | … |
7e8d7c92–90cc-41a1-b3d7–19f30e3004b0 | event_L | 20 | 2021–11–30 23:00:00 |
7c086771-b626–4ba8–86e1-a73a245edeae | event_L | 29 | 2021–11–30 23:00:00 |
1d19cd72-b04a-4bfc-b958–670d5e5674cc | event_L | 28 | 2021–11–30 23:00:00 |
1a2079b8-c28c-45cc-985c-01354cb49085 | event_K | 6 | 2021–11–30 23:00:00 |
e31d4699-a65b-4f2b-84ff-bc2194396cf7 | event_L | 20 | 2021–11–30 23:00:00 |
370176 rows × 3 columns
Nejdříve se zkusím podívat na to, jakými událostmí přispívají jednotlivé organizace do auditní databáze, a jak jsou aktivní.
Jako nejpřehlednější mně připadá zobrazení jako heatmap (překládat název do češtiny jako teplotní mapa mně připadá fakt divné). V x-sové souřadnici jsou označení systémů, v y-nové jsou typy událostí, no a barva obdélníčku vyjadřuje četnost událostí:
sns.displot(X, x="org", y="event", height=4, aspect=4, cbar=True)
plt.xticks(list(range(X.org.min(), X.org.max() + 1)))
plt.show()
Je vidět, že zdaleka ne všechny systémy generují všechny typy událostí. A několik z nich je výrazně aktivnějších než ty ostatní.
Zkusím se na ně tedy podívat blíže, ale tentokrát do pohledu zahrnu také časovou osu, kdy událost vznikla (v rozlišení na hodiny):
sns.displot(X, x="ts", hue="event", col="org", col_wrap=2, height=2, aspect=3).set_xticklabels(rotation=30)
plt.show()
Pro další bádání bude plně postačovat, pokud si vyberu nějakou jednu zajímavou organizaci, a zkusím se povrtat v jejich záznamech. Obdobně bych se mohl zabývat i těmi ostatními, ale to by asi byla dost velká nuda pro všechy, kdo vydrželi číst až do tohoto bodu.
Takže jsem si vybral jednu organizaci a vytvořil si nový dataframe D pro další bádání.
Nový dataframe je výrazně přeformátovaný. V indexu jsou již časové značky pro všechny hodiny ve vybraném intervalu. Sloupce pak představují zkratky pro jednotlivé typy událostí. No a hodnoty v tabulce jsou počty událostí daného typu, které vznikly v dané hodině.
Možná složitě napsáno, takže dále již jen ukázka:
ORG = 28
D = X[X.org == ORG][['event', 'ts']]
D = D.set_index('ts')
D = pd.get_dummies(D.event)
D = D.groupby('ts').aggregate('sum').astype(int).copy()
D
event_A | event_C | event_D | event_E | event_F | event_G | event_H | event_L | |
---|---|---|---|---|---|---|---|---|
ts | ||||||||
2021–11–01 00:00:00 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 13 |
2021–11–01 01:00:00 | 0 | 0 | 0 | 0 | 0 | 3 | 0 | 6 |
2021–11–01 02:00:00 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 5 |
2021–11–01 03:00:00 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 7 |
2021–11–01 04:00:00 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 2 |
… | … | … | … | … | … | … | … | … |
2021–11–30 19:00:00 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 54 |
2021–11–30 20:00:00 | 0 | 1 | 0 | 0 | 0 | 3 | 0 | 31 |
2021–11–30 21:00:00 | 0 | 4 | 0 | 6 | 0 | 7 | 0 | 53 |
2021–11–30 22:00:00 | 0 | 1 | 0 | 0 | 0 | 3 | 0 | 63 |
2021–11–30 23:00:00 | 0 | 2 | 0 | 0 | 0 | 0 | 0 | 31 |
718 rows × 8 columns
A nyní se zkusím podívat na časové průběhy pro všechny typy událostí zvláště:
for col in D.columns:
sns.relplot(data=D, x="ts", y=col, kind='line', height=4, aspect=4).set_xticklabels(rotation=30)
plt.show()
Jak jsem napsal v úvodu, budou mne zajímat anomálie v chování systému.
Co mám nyní k dispozici pro posouzení chování systému s označemím 28? Jsou to časové řady hustoty výskytu jednotlivých událostí v rámci sledovaného období.
Zkusím se tedy podívat na časovou řadu jedné události a pokusím se najít nějaké neobvyklé chování z pohledu počtu vytvořených událostí.
Jako vhodná událost se mně zdá event_L, neboť již od pohledu jsou v grafu vidět dvě až tři podívné špičky.
EVENT = "event_L"
Pro další experimentování si připravím nový dataframe A, ve kterém jsou indexem časové značky a hodnotou počet událostí v daném časovém intervalu. U indexu jsem musel zajistit, aby obsahoval všechny časové značky z celého intervalu, proto tak složitá konstrukce dataframe:
A = pd.DataFrame(index=pd.date_range(start=datetime.datetime(2021, 11, 1), end=datetime.datetime(2021, 11, 30, 23), freq='H'))
A = pd.concat([A, D[EVENT]], axis=1)
A.fillna(value=0, inplace=True)
A.index.name = 'ts'
A.columns = ['value']
A
value | |
---|---|
ts | |
2021–11–01 00:00:00 | 13.0 |
2021–11–01 01:00:00 | 6.0 |
2021–11–01 02:00:00 | 5.0 |
2021–11–01 03:00:00 | 7.0 |
2021–11–01 04:00:00 | 2.0 |
… | … |
2021–11–30 19:00:00 | 54.0 |
2021–11–30 20:00:00 | 31.0 |
2021–11–30 21:00:00 | 53.0 |
2021–11–30 22:00:00 | 63.0 |
2021–11–30 23:00:00 | 31.0 |
720 rows × 1 columns
Nejdříve vyzkouším rozklad časové řady na tři komponenty: trendy, sezónní část a zbytek
Použiji pro to funkci seasonal_decompose z knihovny statsmodels, která počítá rozklad pomocí plovoucího průměru:
from statsmodels.tsa.seasonal import seasonal_decompose
decomposed = seasonal_decompose(A, model='additive', extrapolate_trend='freq')
sns.relplot(data=decomposed.trend, kind='line', color='blue', height=4, aspect=4).set_xticklabels(rotation=30)
sns.relplot(data=decomposed.seasonal, kind='line', color='green', height=4, aspect=4).set_xticklabels(rotation=30)
sns.relplot(data=decomposed.resid, kind='line', color='red', height=4, aspect=4).set_xticklabels(rotation=30)
plt.show()
Mne v tomto okamžiku zajímá až ten poslední graf, a tedy rezidua po rozkladu. Je vidět, že mně tam vyskočily ty špičky, které jsem již dříve očekával.
Můžu si je zvýraznit:
sns.relplot(data=(decomposed.resid.abs() > decomposed.resid.std() * 3).astype(int), kind='line', color='black', linewidth=2, height=2, aspect=8).set_xticklabels(rotation=30)
plt.show()
Pochopitelně na tomto místě záleží, jak vysokou hladinu zbytku budu považovat za anomálii (já jsem v tomto případě zvolil trojnásobek směrodatné odchylky, takže cca. 5% z celkového rozložení zbytku).
Ještě bych rád upozornil na poslední špičku na konci. To není anomálie, že by těch událostí bylo neočekávaně hodně. Naopak je jich neočekávaně málo. Ale i to stojí za pozornost.
S tímto výsledkem mám ale jeden problém, a tím je ten první graf z trojice, představující tred používání služby. Když se na něj podíváte, tak asi úplně neodpovídá tomu, co bych pod tímto pojmem očekával. Za trend bych spíše považoval lineární vyjádření. I když porovnání rozmezí hodnot pro trend a zbytky až o tak zásadním problému nevypovídají.
Pokusím se na možné anomálie přijít poněkud odlišným postupem, a to je vyhodnocením přírůstků řady namísto absolutní velikosti. V podstatě jde o obdobný přístup jako v předchozím případě, kde mne zajímaly především zbytky po rozkladu.
V tomto případě si udělám řadu přírůstků a pokusím se v nich najít nějaké anomálie (přírůstky si doplním do dataframe A):
A['diff_1'] = A['value'].diff().fillna(0)
sns.relplot(data=A, x='ts', y='diff_1', kind='line', height=4, aspect=4).set_xticklabels(rotation=30)
sns.relplot(data=(A['diff_1'].abs() > A['diff_1'].std() * 3).astype(int), kind='line', color='black', linewidth=2, height=2, aspect=8)
plt.show()
Tyto grafy postupně zachycují přírůstky řady a místa v řadě, která se odlišují od běžného chování (opět jsem použil pro posouzení vyjímečnosti trojnásobek směrodatné odchylky).
Zdali je výsledek „lepší“ než ten předchozí, to neumím posoudit. V každém případě ty hlavní anomálie to zachytilo.
Zkusil jsem si pro legraci ještě udělat druhou úroveň přírůstků, zda to bude vypadat výrazně jinak:
A['diff_2'] = A['diff_1'].diff().fillna(0)
sns.relplot(data=A, x='ts', y='diff_2', kind='line', color='green', height=4, aspect=4).set_xticklabels(rotation=30)
sns.relplot(data=(A['diff_2'].abs() > A['diff_2'].std() * 3).astype(int), kind='line', color='brown', linewidth=2, height=2, aspect=8).set_xticklabels(rotation=30)
plt.show()
Tak to vypadá, že mně to nic zajímavého nepřineslo.
Další možností, jak se podívat na průběh časové řady a vytipovat případné anomálie, je vyhlazení křivky a její porovnání s původním průběhěm.
Pro vyhlazení křivky zkusím použít metodu LOWESS (Locally Weighted Regression), jejíž implementaci je možné najít v knihovně statsmodels.
from statsmodels.nonparametric.smoothers_lowess import lowess
Opět si připravím data pro jednu událost s doplněním všech chybějících hodnot (abych měl celý interval spojitý). Pro funkci vyhlazování budu ale potřebovat v časové ose čísla, takže si původní index převedu do sloupce.
C = pd.DataFrame(index=pd.date_range(start=datetime.datetime(2021, 11, 1), end=datetime.datetime(2021, 11, 30, 23), freq='H'))
C = pd.concat([C, D[EVENT]], axis=1)
C.fillna(value=0, inplace=True)
C.index.name = 'ts'
C.columns = ['value']
C = C.reset_index(level=0)
C.head()
ts | value | |
---|---|---|
0 | 2021–11–01 00:00:00 | 13.0 |
1 | 2021–11–01 01:00:00 | 6.0 |
2 | 2021–11–01 02:00:00 | 5.0 |
3 | 2021–11–01 03:00:00 | 7.0 |
4 | 2021–11–01 04:00:00 | 2.0 |
sns.relplot(data=C, x='ts', y='value', kind='line', color='blue', height=4, aspect=4).set_xticklabels(rotation=30)
plt.show()
No a nyní si křivky vyhladím pomocí funkce lowess() s faktorem 0.1. Výsledek si zapíšu do dataframe jako další sloupec. A takto to vypadá po vykreslení do grafu:
C['smoothen'] = lowess(C.value, C.index, frac=0.1)[:, 1]
sns.relplot(data=C, x='ts', y='smoothen', kind='line', color='green', height=4, aspect=4).set_xticklabels(rotation=30)
plt.show()
A nyní si výsledek vyhlazení porovnám s původní křivkou a zkusím tam najít největší rozdíly.
Takže vytvořím další sloupec resid jako rozdíl původní a vyhlazení křivky. No a dále pak sloupec anomaly, který nabývá hodnoty 1 v případě, že je resid větší než trojnásobek směrodatné odchylky.
Výsledky jsou opět vykresleny do grafu:
C['resid'] = C.value - C.smoothen
C['anomaly'] = (C['resid'].abs() > C['resid'].std() * 3).astype(int)
sns.relplot(data=C, x='ts', y='resid', kind='line', color='red', height=4, aspect=4).set_xticklabels(rotation=30)
sns.relplot(data=C, x='ts', y='anomaly', kind='line', color='black', height=2, aspect=8).set_xticklabels(rotation=30)
plt.show()
Takhle jsem se pokusil přeložit do češtiny „Clustering Based Unsupervised Approach“. On ten anglický název bude možná pochopitelnější než nepovedený překlad.
Jako výchozí metodu jsem se rozhodl použít Density Based Spatial Clustering of Applications with Noise (DBSCAN). Podstatou tohoto přístupu je to, že se pokusím spojit body do jednoho nebo několika clusterů na základě jejich minimální vzdálenosti (použitá metrika je eukleidovská). Všechny body, které se nepodařilo zahrnout do clusteru, jsou považovány za anomálie.
Opět příprava dat do dataframe E. Tentokráte budu mít data ve 2D, kde na x-ové ose je pořadové číslo v rámci časové řady a na y-ové ose je hodnota. Takto se mně bude lépe počítat vzdálenost mezi body.
E = pd.DataFrame(index=pd.date_range(start=datetime.datetime(2021, 11, 1), end=datetime.datetime(2021, 11, 30, 23), freq='H'))
E['id'] = np.arange(len(E))
E = pd.concat([E, D[EVENT]], axis=1)
E.fillna(value=0, inplace=True)
E.index.name = 'ts'
E.columns = ['id', 'value']
E
id | value | |
---|---|---|
ts | ||
2021–11–01 00:00:00 | 0 | 13.0 |
2021–11–01 01:00:00 | 1 | 6.0 |
2021–11–01 02:00:00 | 2 | 5.0 |
2021–11–01 03:00:00 | 3 | 7.0 |
2021–11–01 04:00:00 | 4 | 2.0 |
… | … | … |
2021–11–30 19:00:00 | 715 | 54.0 |
2021–11–30 20:00:00 | 716 | 31.0 |
2021–11–30 21:00:00 | 717 | 53.0 |
2021–11–30 22:00:00 | 718 | 63.0 |
2021–11–30 23:00:00 | 719 | 31.0 |
720 rows × 2 columns
A takto to vypadá po vynesení do grafu. Proti předchozím přístupům je rozdíl především v tom, že se na data nedívám jako na křivku, ale jako na samostatné body.
sns.relplot(data=E, x='id', y='value', color='blue', height=4, aspect=4)
plt.show()
Nyní si vytvořím model. V podstatě musím nastavit pouze dva parametry:
Nastavení konkrétních hodnot parametrů je potřeba doladit dle aktuálních dat.
from sklearn.cluster import DBSCAN
dbscan = DBSCAN(eps=40, min_samples=10)
dbscan.fit(E)
E['anomaly'] = dbscan.labels_ == -1
Body nezařazené do žádného clusteru (ono těch clusterů může být více) mají nastavenou hodnotu –1. Všechny ostatní v nějakém clusteru jsou. Proto si udělám do dataframe E ještě jeden sloupec anomaly, který bude příznakem zjištěné anomálie.
A takto vypadá výsledek po vynesení do grafu:
sns.relplot(data=E, x='id', y='value', hue='anomaly', height=4, aspect=4)
plt.show()
A na závěr opět ještě jedno prohlášení.
Že jsem některá data označil za anomálie ještě neznamená automaticky nějaký problém. To vždy záleží na posouzení konkrétních provozních podmínek a bližší znalosti aplikace a jejich funkcí.
Na druhou stranu by označení za anomálii mělo být pobídkou k bližšímu prověření, zda to není signálem k nějakému reálnému problému.
pracuje na pozici IT architekta. Poslední roky se zaměřuje na integrační a komunikační projekty ve zdravotnictví. Mezi jeho koníčky patří také paragliding a jízda na horském kole.
Přečteno 25 727×
Přečteno 25 724×
Přečteno 25 392×
Přečteno 23 617×
Přečteno 19 355×