Ve třetím pokračování svého povídání o sledování událostí v aplikaci jsem vytvářel auditní záznamy a ty pak zapisoval do SQL databáze. Je to asi jeden z obvyklých způsobů, jak s nimi nakládat. Vzniká tak docela velká databáze auditních záznamů, které mohou velice rychle dosáhnout stovek tisíc či miliónů řádků.
Databázi ale nedělám, abych data pouze sbíral. Potřebuji s nimi i následně pracovat.
Jeden z možných způsobů, jak mohu takovou databázi využívat, je zpětné sledování konkrétních aktivit realizovaných v aplikaci.
Tak například bych mohl hledat, co mně dělal uživatel František Vonásek od středy odpoledne do pátečního rána. Nebo bych mohl chtít vyhledat, kdo se díval na záznamy o našem řediteli v posledních třech týdnech.
Tyto příklady mají jedno společné. Potřebujete relativně přesné zadání, jaké auditní záznamy vás zajímají. Obvykle se jedná o reakci na nějaké konkrétní problémy, které se objevily při užívání aplikace (stěžovali si uživatelé, stěživali si zákazníci nebo někdo další).
Nabízí se ale ještě jedna možnost, jak s takovými daty pracovat. Co kdybych se pokusil aktivně dopředu vytipovat možné problémy při užívání aplikace, a to ještě dříve, než si někdo začne stěžovat. Že bych to dělal manuálně, to nepřipadá v úvahu s ohledem na rozsah dat. Takže další možnost je vytvořit nějaký automatizovaný postup, který se mně pokusí zachytit problémové události.
Tak, a tady jsem u další překážky. Co to je ta „problémová událost“? Lehká otázka a o to těžší odpověď.
Na základě znalosti aplikace a jejího předpokládaného používání bych se mohl pokusit vytvořit nějaké podmínky, jejíchž překročení bych mohl považovat za „divné chování“ a provést jejich manuální kontrolu. No a na základě kontroly pak případně podmínky upravit tak, aby lépe odpovídaly tomu, jak se aplikace skutečně používá. Je to dost pracné a musel bych neustále vyhodnocovat a upravovat podmínky.
Nebo se nabízí další možnost, a to aplikovat na data algoritmy pro strojové učení s cílem odhalit data vymykající se běžnému chování. Nepokouším se tedy stanovit podmínkami, jak se má aplikace a její uživatelé podle mých představ chovat. Vycházím z toho, jak se aplikace a její uživatele skutečně chovají, a pokouším se najít výjimky.
V oblasti strojového učení se těmto technikám obvykle říká Anomaly Detection a existuje velké množství algoritmů pro tento účel použitelných. Já se budu zaměřovat především na tzv. Unisupervised algortimy, protože nejsem dopředu schopen říci, co je běžné a nebo vyjímečné chování.
A to je tedy náplň pro tento článek. Pokusím se na příkladu konkrétní auditní databáze vyzkoušet některé z těchto algoritmů a ověřit jejich výsledky.
Jak jsem již předeslal, vyzkouším si některé z těchto postupů na reálné auditní databázi. Vzhledem k tomu, že se jedná o data z používaného systému, nebudu moci prezentovat ukázky konkrétních záznamů, ale pouze jejich souhrny. Pro posouzení vhodnosti postupů a práce se záznamy to snad bude dostačující.
Auditní databáze, kterou mám k dispozici pro analýzu, obsahuje milióny záznamů. To je příliš velké číslo pro tuto práci, takže si z databáze vyberu data za jeden měsíc. Navíc budu muset také omezit rozsah sloupců (features), které k analýze použiji.
Takto tedy nazískám základní zdrojová data:
import random
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
import sqlite3
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)
Auditní databáze je v mém případě uložena v SQLite databázi.
Záznamy jsem před použitím ještě upravil dle požadavků vlastníka dat tak, aby nebyly nikterak poškozovány jeho zájmy. Takže abyste se nedivili, udělal jsem na databázi tyto úpravy:
Záznamy vytahuji pouze z jedné tabulky, a tou je audit. Každý záznam má svůj unikátní identifikátor uuid, který dále používám jako index do DataFrame. Každý záznam má také položku ts, což je datum a čas vzniku auditního záznamu.
Rozsah načtených řádků je omezen na události vzniklé v listopadu loňského roku.
A takto vypadá načtený dataframe:
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
K dispozici mám více jak 370 tisíc záznamů.
Kromě časové značky ts jsou všechny sloupce typu řetězec znaků. Zkusím se na ně tedy podívat trochu blíže:
category_columns = df.dtypes[df.dtypes == 'object'].index.tolist()
n_cols = 3
n_rows = len(category_columns) // n_cols + int(len(category_columns) % n_cols != 0)
fig, axes = plt.subplots(nrows=n_rows, ncols=n_cols, figsize=(14, 6), constrained_layout=True)
for i, column in enumerate(category_columns):
g = sns.histplot(df, x=column, ax=axes[i // n_cols, i % n_cols])
g.set_title(column)
g.set(xlabel=None)
g.set(xticklabels=[])
Obsahuje typ události, který vedl k vytvoření auditního záznamu. Jedná se tedy o jeden ze zásadních údajů, který budu muset při analýze zohlednit.
Zkusím se na něj podívat trochu detailněji:
print(df['event'].value_counts())
event_G 130172 event_L 128575 event_K 42866 event_D 19232 event_C 17059 event_F 10888 event_E 10861 event_H 10321 event_I 92 event_J 92 event_A 14 event_B 4 Name: event, dtype: int64
Je vidět, že některé typy událostí jsou párové typu požadavek-odpověď. Jiné události představující požadavek na službu mají za následek více odpovědí. V databázi nemám k dispozici identifikátor transakce, takže není možné provést přímé párování událostí.
Sloupec obsahuje jednoznačný identifikátor organizace, která událost vytvořila. Z toho vyplývá, že do databáze přispívá více systémů současně, a toto je tedy ten rozlišovací údaj.
Zde se jedná o údaje o uživateli, který událost inicioval ve své aplikaci. V některých případech nemusí být tento uživatel vyplněn, neboť se jedná o automaticky iniciované události. Také záleží na tom, jakým způsobem byl uživatel ve svém systému ověřen. Proto jsou údaje ve soupcích rozdílné, i když by se na první pohled mohlo zdát, že by měly být identické pro jednu osobu.
Obdobně jako u sloupce identifikujícho organizaci iniciující údálost, zde se jedná o údaj popisující organizaci, která je partnerem výměny dat. Opět se jedná o unikátní identifikátor organizace.
A takto tedy vypadá výsledný dataframe po všech úpravách:
df
ts | event | org | usr1 | usr2 | usr3 | peer | |
---|---|---|---|---|---|---|---|
uuid | |||||||
1ee1cac9-c84b-4b2d-a8f3-db6b7eed59fd | 2021–11–01 00:03:13 | event_K | uuid:2e020304-f74d-4b63–836e-0f164e5f1a6b | NaN | NaN | NaN | NaN |
45c2d161–7c92–45c2–924d-5be83c7d7d5f | 2021–11–01 00:03:13 | event_L | uuid:cf1ce9e3-ff18–4dce-8fd5-f6d2e98ae09d | NaN | NaN | NaN | uuid:2e020304-f74d-4b63–836e-0f164e5f1a6b |
a56f86fd-20c9–4819–8eb7-bc2680e8fbb6 | 2021–11–01 00:03:13 | event_L | uuid:cdcd1808-f026–4834-aff9–5f3b51b95e28 | NaN | NaN | NaN | uuid:2e020304-f74d-4b63–836e-0f164e5f1a6b |
3937d0f7-bc74–4bfb-9f03-aec98ce2e097 | 2021–11–01 00:03:13 | event_L | uuid:8ec6da3c-8547–4c52-bf45–158dcc4dc5f0 | NaN | NaN | NaN | uuid:2e020304-f74d-4b63–836e-0f164e5f1a6b |
b03e848f-4d5a-4e21–81a6-ae9a0d9957c5 | 2021–11–01 00:04:34 | event_K | uuid:2e020304-f74d-4b63–836e-0f164e5f1a6b | NaN | NaN | NaN | NaN |
… | … | … | … | … | … | … | … |
7e8d7c92–90cc-41a1-b3d7–19f30e3004b0 | 2021–11–30 23:58:28 | event_L | uuid:8ec6da3c-8547–4c52-bf45–158dcc4dc5f0 | NaN | NaN | NaN | uuid:2e020304-f74d-4b63–836e-0f164e5f1a6b |
7c086771-b626–4ba8–86e1-a73a245edeae | 2021–11–30 23:58:43 | event_L | uuid:cf1ce9e3-ff18–4dce-8fd5-f6d2e98ae09d | NaN | NaN | NaN | uuid:2e020304-f74d-4b63–836e-0f164e5f1a6b |
1d19cd72-b04a-4bfc-b958–670d5e5674cc | 2021–11–30 23:58:43 | event_L | uuid:cdcd1808-f026–4834-aff9–5f3b51b95e28 | NaN | NaN | NaN | uuid:2e020304-f74d-4b63–836e-0f164e5f1a6b |
1a2079b8-c28c-45cc-985c-01354cb49085 | 2021–11–30 23:58:43 | event_K | uuid:2e020304-f74d-4b63–836e-0f164e5f1a6b | NaN | NaN | NaN | NaN |
e31d4699-a65b-4f2b-84ff-bc2194396cf7 | 2021–11–30 23:58:43 | event_L | uuid:8ec6da3c-8547–4c52-bf45–158dcc4dc5f0 | NaN | NaN | NaN | uuid:2e020304-f74d-4b63–836e-0f164e5f1a6b |
370176 rows × 7 columns
V oblasti strojového učení vzniklo hodně postupů, jak hledat v datech anomálie. Cílem je obvykle odstranit tyto excesy z dat před jejich použitím pro učení. V mém případě naopak tyto anomálie chci najít proto, abych je mohl dále řešit. Ať je ten cíl jakýkoliv, mohu se pokusit tyto algoritmy použít a uvidím, jaký bude výsledek.
Hodně z těchto algoritmů je implementováno v knihovně PyOD, proto ji budu dále používat.
Výhodou využití této knihovny je ve sjednocení API pro přístup k jednotlivým algoritmům. To mně usnadní otestování několika přístupů nad stejnými daty. Pokud si chcete udělat představu, jaké algoritmy jsou v rámci knihovny k dispozici, pak zde je jejich přehled v souhrnné tabulce.
Všechny algoritmy pro detekci anomálii předpokládají, že feature ve vstupních datech mají číselnou hodnotu. V mém případě je to ale problém, protože já mám ve zdrojových datech všechny sloupce typu řetězec znaků až na jeden, a to je časová značka. Navíc jsou všechny textové sloupce nominální.
Takže v následujícím kroku budu muset převést všechny hodnoty na číselnou reprezentaci.
Pro posouzení vyjímečnosti záznamu bude pro mne lepší, když si časovou značku převedu na vlastnosti den, hodina, minuta a den v týdnu. Budu tak moci lépe vybrat události v závislosti na okamžiku jejich vzniku.
X = pd.concat({'day':df.ts.dt.day, 'hour':df.ts.dt.hour, 'minute':df.ts.dt.minute, 'weekday':df.ts.dt.weekday}, axis=1)
Textové sloupce budu v cílovém datovém setu reprezentovat jako kategorie, přesněji řečeno kódy techto kategorii. To mně umožní, abych každý sloupec reprezentoval pomocí jedné vlastnosti.
Nevýhodou tohoto řešení je to, že s kategoriemi automaticky zavádím pořadí do vlastností, které ze své podstaty pořadí nemají. Na tohle musím dát pozor při výběru algoritmů, který pro vyhodnocení použiji. Pokud je algoritmus postaven na vyhodnocování metrik v n-rozměrném prostoru, pak budu muset použít reprezentaci jinou (ale to až dále).
for col in df.dtypes[df.dtypes == object].index:
X[col] = df[col].astype('category').cat.codes
A takto vypadají připravená trénovací data:
X
day | hour | minute | weekday | event | org | usr1 | usr2 | usr3 | peer | |
---|---|---|---|---|---|---|---|---|---|---|
uuid | ||||||||||
1ee1cac9-c84b-4b2d-a8f3-db6b7eed59fd | 1 | 0 | 3 | 0 | 10 | 6 | –1 | –1 | –1 | –1 |
45c2d161–7c92–45c2–924d-5be83c7d7d5f | 1 | 0 | 3 | 0 | 11 | 29 | –1 | –1 | –1 | 6 |
a56f86fd-20c9–4819–8eb7-bc2680e8fbb6 | 1 | 0 | 3 | 0 | 11 | 28 | –1 | –1 | –1 | 6 |
3937d0f7-bc74–4bfb-9f03-aec98ce2e097 | 1 | 0 | 3 | 0 | 11 | 20 | –1 | –1 | –1 | 6 |
b03e848f-4d5a-4e21–81a6-ae9a0d9957c5 | 1 | 0 | 4 | 0 | 10 | 6 | –1 | –1 | –1 | –1 |
… | … | … | … | … | … | … | … | … | … | … |
7e8d7c92–90cc-41a1-b3d7–19f30e3004b0 | 30 | 23 | 58 | 1 | 11 | 20 | –1 | –1 | –1 | 6 |
7c086771-b626–4ba8–86e1-a73a245edeae | 30 | 23 | 58 | 1 | 11 | 29 | –1 | –1 | –1 | 6 |
1d19cd72-b04a-4bfc-b958–670d5e5674cc | 30 | 23 | 58 | 1 | 11 | 28 | –1 | –1 | –1 | 6 |
1a2079b8-c28c-45cc-985c-01354cb49085 | 30 | 23 | 58 | 1 | 10 | 6 | –1 | –1 | –1 | –1 |
e31d4699-a65b-4f2b-84ff-bc2194396cf7 | 30 | 23 | 58 | 1 | 11 | 20 | –1 | –1 | –1 | 6 |
370176 rows × 10 columns
Je to algoritmus postavený na binárních vyhledávacích stromech.
Vyhledávací strom se vytváří tak, že se náhodně vybere vlastnost a v rámci rozsahu jejich hodnot pak náhodně hodnota pro rozdělení na dva podstromy. A to vše tak dlouho, dokud se nedostaneme jednoznačně k jednomu záznamu.
Takových stromů je vytvořeno hned několik, což je stanoveno parametrem n_estimators=100.
Při vyhodnocení, jak vyjímečný je zadaný záznam, se zohledňuje délka cesty ve stromu. Pokud je délka hledání krátká, pak je záznam hodně odlišný od zbytku a je vyhodnocen jako anomálie.
Nadefinuji si tedy model a nechám jej vytvořit svou reprezentaci stromů na základě všech trénovacích dat. Parametr CONTAMINATION je odhadem, jaké procento z dat předpokládám být anomální (má vliv pouze při vyhodnocení).
from pyod.models.iforest import IForest
CONTAMINATION = 0.01
random.seed(0)
np.random.seed(0)
iforest = IForest(contamination=CONTAMINATION)
iforest.fit(X.values)
IForest(behaviour='old', bootstrap=False, contamination=0.01, max_features=1.0, max_samples='auto', n_estimators=100, n_jobs=1, random_state=None, verbose=0)
Model budu aplikovat na stejný vzorek dat.
Hledat budu hodnoty pro tzv. decision function, což je hodnocení každého záznamu z pohledu jeho vyjímečnosti (čím větší hodnota, tím výce vyjímečný vzorek). A dále také predikci anomálie, která vychází z dřívě zjištěné decision function a nastaveného parametru CONTAMINATION (výsledek je 0/1 podle toho, zda je záznam považován za anomální).
decision = iforest.decision_function(X.values)
prediction = iforest.predict(X.values)
Y = pd.DataFrame({'decision': decision, 'prediction': prediction}, index=X.index)
Y[Y['decision'] >= -0.015]
decision | prediction | |
---|---|---|
uuid | ||
fa953f08-c0f1–4013–9190-eaae552f8f0d | –0.011319 | 0 |
7d3af8fa-8763–4610–9718-ba243c3f9950 | 0.010992 | 1 |
26c6b696-e41c-4681-b8e0-ddce2c48b0dc | –0.005078 | 0 |
8b0550df-b26a-4240–9e11–1a82ddcb7523 | –0.014065 | 0 |
0be80014–5929–45bb-be5f-0510c504a589 | –0.001699 | 0 |
… | … | … |
2c31d4f1-d053–453e-b1c4–5bedfe068972 | 0.008053 | 1 |
54ed50c6–0eae-4f24–9298–56fb22912008 | –0.010144 | 0 |
52ba91ac-a443–4e5d-9254–8f6610dc17b2 | –0.010144 | 0 |
ce7553a7-bbda-4243–8c09-e197f23a775a | 0.000404 | 1 |
1872bb19-ada0–46b0–9ba3-b9daa0cb7409 | –0.010677 | 0 |
7859 rows × 2 columns
Takto vypadá histogram pro decision function:
g = sns.displot(Y, x='decision', height=4, aspect=3.5)
A můžu se ještě podívat na konec rozložení, kde jsou vyznačeny záznamy vyhodnocené jako anomální (a to v závislosti na velikosti parametru CONTAMINATION):
g = sns.displot(Y[Y['decision'] > -0.02], x='decision', hue='prediction', height=4, aspect=3.5)
Jedná se o metodu původně určenou pro redukci dimenzí, která se ale hodí i pro potřeby detekce anomálií. Tato metoda spadá do skupiny Linear Model.
Vychází z kovarianční matice, pro kterou zjišťuje vlastní vektory a hodnoty. Vyberou se pouze ty nejvýznamnější vlastní vektory (podle jejich hodnoty), které se dále použijí jako výchozí vícerozměrná plocha. Anomálie se pak vyhodnocují podle velikosti jejich projekce do této vícerozměrné plochy.
Vytvořím tedy model a pošlu do něj připravený dataset jako trénovací data:
from pyod.models.pca import PCA
random.seed(0)
np.random.seed(0)
CONTAMINATION = 0.01
pca = PCA(n_components='mle', contamination=CONTAMINATION, svd_solver='full')
pca.fit(X.values)
PCA(contamination=0.01, copy=True, iterated_power='auto', n_components='mle', n_selected_components=None, random_state=None, standardization=True, svd_solver='full', tol=0.0, weighted=True, whiten=False)
Model budu aplikovat na stejný vzorek dat.
Opět budu hledat hodnoty pro tzv. decision function, což je hodnocení každého záznamu z pohledu jeho vyjímečnosti (čím větší hodnota, tím výce vyjímečný vzorek). A dále také predikci anomálie, která vychází z dřívě zjištěné decision function a nastaveného parametru CONTAMINATION (výsledek je 0/1 podle toho, zda je záznam považován za anomální).
decision = pca.decision_function(X.values)
prediction = pca.predict(X.values)
Y = pd.DataFrame({'decision': decision, 'prediction': prediction}, index=X.index)
A krátký pohled na výsledek:
Y[Y['decision'] >= 600]
decision | prediction | |
---|---|---|
uuid | ||
7d3af8fa-8763–4610–9718-ba243c3f9950 | 602.186458 | 0 |
0be80014–5929–45bb-be5f-0510c504a589 | 638.100215 | 1 |
4ad1ccaf-abf9–4181–9df4-c17e9757e4f3 | 631.250766 | 1 |
9e43c2ef-cfbd-4fcf-ba1e-91e3f96ab37d | 631.250766 | 1 |
e2650bb6–3c12–4f1e-866d-b74c36cfdf00 | 605.868749 | 0 |
… | … | … |
5cf8fda0–5287–493f-acf1-a90f166cb886 | 606.462697 | 0 |
efbace11–2d6d-4da0-bbf6–962e9830a628 | 604.825210 | 0 |
f581ac53–1e58–43fe-adb6–887b819e21ff | 605.433871 | 0 |
ae052972–98fd-4412–8463-d3f00a05f122 | 606.797618 | 0 |
2c31d4f1-d053–453e-b1c4–5bedfe068972 | 607.552376 | 0 |
6320 rows × 2 columns
Takto mně vyšla predikace pro všechny řádky:
Y['prediction'].value_counts()
0 366474 1 3702 Name: prediction, dtype: int64
A ještě grafické zobrazení pro decision function a její výseč se zobrazením predikce anomálie:
g = sns.displot(Y, x='decision', height=4, aspect=3.5)
g = sns.displot(Y[Y['decision'] >= 500], x='decision', hue='prediction', height=4, aspect=3.5)
Poslední metodu, kterou dnes vyzkouším, je Auto Encoder ze skupiny Neural Networks.
Ideou vychází z obdobného pohledu na problematiku jako PCA. Jedná se tedy o metodu založenou na redukci dimezní vstupních dat, a následně vypočítání chyby rekonstrukce pro každý vzorek dat. Vzorky s velkou chybou rekonstrukce jsou považovány za anomálie.
Pro vytvoření sítě se používá knihovna Keras.
Vytvořím si model se dvěma skytými vrstvami a pustím do něj celý datový set:
from pyod.models.auto_encoder import AutoEncoder
random.seed(0)
np.random.seed(0)
CONTAMINATION = 0.01
ae = AutoEncoder(hidden_neurons=[10, 8, 8, 10], epochs=5)
ae.fit(X.values)
Model: "sequential" _________________________________________________________________ Layer (type) Output Shape Param # ================================================================= dense (Dense) (None, 10) 110 dropout (Dropout) (None, 10) 0 dense_1 (Dense) (None, 10) 110 dropout_1 (Dropout) (None, 10) 0 dense_2 (Dense) (None, 10) 110 dropout_2 (Dropout) (None, 10) 0 dense_3 (Dense) (None, 8) 88 dropout_3 (Dropout) (None, 8) 0 dense_4 (Dense) (None, 8) 72 dropout_4 (Dropout) (None, 8) 0 dense_5 (Dense) (None, 10) 90 dropout_5 (Dropout) (None, 10) 0 dense_6 (Dense) (None, 10) 110 ================================================================= Total params: 690 Trainable params: 690 Non-trainable params: 0 _________________________________________________________________ None Epoch 1/5 10412/10412 [==============================] - 44s 4ms/step - loss: 1.0539 - val_loss: 0.9925 Epoch 2/5 10412/10412 [==============================] - 34s 3ms/step - loss: 0.9993 - val_loss: 0.9889 Epoch 3/5 10412/10412 [==============================] - 36s 3ms/step - loss: 0.9717 - val_loss: 0.9405 Epoch 4/5 10412/10412 [==============================] - 40s 4ms/step - loss: 0.9585 - val_loss: 0.9493 Epoch 5/5 10412/10412 [==============================] - 36s 4ms/step - loss: 0.9536 - val_loss: 0.9510
AutoEncoder(batch_size=32, contamination=0.1, dropout_rate=0.2, epochs=5, hidden_activation='relu', hidden_neurons=[10, 8, 8, 10], l2_regularizer=0.1, loss=<function mean_squared_error at 0x7fbc80fde710>, optimizer='adam', output_activation='sigmoid', preprocessing=True, random_state=None, validation_size=0.1, verbose=1)
A nyní si nechám spočítat decision function a predikci obdobně jako u předchozích metod:
decision = ae.decision_function(X.values)
prediction = ae.predict(X.values)
Y = pd.DataFrame({'decision': decision, 'prediction': prediction}, index=X.index)
A takto vypadá přehled záznamů, které jsou detekovány jako anomálie:
Y['prediction'].value_counts()
0 333158 1 37018 Name: prediction, dtype: int64
A ještě pohled na histogram decision function, a také na jeho část s vyznačemím anomálii:
g = sns.displot(Y, x='decision', height=4, aspect=3.5)
g = sns.displot(Y[Y['decision'] >= 3], x='decision', hue='prediction', height=4, aspect=3.5)
Výše vyzkoušené metody vám neřeknou, jestli vybrané auditní záznamy jsou skutečně problémové a je potřeba je nějak řešit. To není možné vyhodnotit bez hluboké znalosti produkčních systémů, které auditní záznamy vytvořily, a za jakých provozních podmínek se tak stalo.
Cílem článku bylo vyzkoušet metody, jak z velkého množství auditních záznamů vybrat ty, které jsou významně odlišné od ostatních a tedy si „zaslouží“ detailnější kontrolu.
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 505×
Přečteno 23 489×
Přečteno 22 749×
Přečteno 19 213×
Přečteno 18 008×