115 KiB
Практическая работа №4
Обнаружение злоумышленников в системе мобильных денежных переводов¶
- настройка окружения
%pip install --user scipy==1.8.1 %pip install --user networkx==2.7.0 #uncomment when running in Google Collab #!apt install python3-dev graphviz libgraphviz-dev pkg-config #!pip install pygraphviz %pip install pyvis import zipfile import itertools import tempfile import math from functools import reduce from pyvis import network as net import pandas as pd import numpy as np import networkx as nx import plotly.express as px import plotly.graph_objects as go from plotly.offline import iplot from IPython.display import display, HTML #for Jupiter notebooks import plotly.io as pio #comment for Google collab pio.renderers.default='notebook'#comment for Google collab
Requirement already satisfied: scipy==1.8.1 in c:\users\женька\appdata\roaming\python\python310\site-packages (1.8.1) Requirement already satisfied: numpy<1.25.0,>=1.17.3 in c:\programdata\anaconda3\lib\site-packages (from scipy==1.8.1) (1.23.5) Requirement already satisfied: networkx==2.7.0 in c:\users\женька\appdata\roaming\python\python310\site-packages (2.7) Defaulting to user installation because normal site-packages is not writeable Requirement already satisfied: pyvis in c:\users\женька\appdata\roaming\python\python310\site-packages (0.3.2) Requirement already satisfied: jinja2>=2.9.6 in c:\programdata\anaconda3\lib\site-packages (from pyvis) (3.1.2) Requirement already satisfied: ipython>=5.3.0 in c:\programdata\anaconda3\lib\site-packages (from pyvis) (8.10.0) Requirement already satisfied: networkx>=1.11 in c:\users\женька\appdata\roaming\python\python310\site-packages (from pyvis) (2.7) Requirement already satisfied: jsonpickle>=1.4.1 in c:\users\женька\appdata\roaming\python\python310\site-packages (from pyvis) (3.0.1) Requirement already satisfied: backcall in c:\programdata\anaconda3\lib\site-packages (from ipython>=5.3.0->pyvis) (0.2.0) Requirement already satisfied: jedi>=0.16 in c:\programdata\anaconda3\lib\site-packages (from ipython>=5.3.0->pyvis) (0.18.1) Requirement already satisfied: matplotlib-inline in c:\programdata\anaconda3\lib\site-packages (from ipython>=5.3.0->pyvis) (0.1.6) Requirement already satisfied: pygments>=2.4.0 in c:\programdata\anaconda3\lib\site-packages (from ipython>=5.3.0->pyvis) (2.11.2) Requirement already satisfied: pickleshare in c:\programdata\anaconda3\lib\site-packages (from ipython>=5.3.0->pyvis) (0.7.5) Requirement already satisfied: prompt-toolkit<3.1.0,>=3.0.30 in c:\programdata\anaconda3\lib\site-packages (from ipython>=5.3.0->pyvis) (3.0.36) Requirement already satisfied: stack-data in c:\programdata\anaconda3\lib\site-packages (from ipython>=5.3.0->pyvis) (0.2.0) Requirement already satisfied: traitlets>=5 in c:\programdata\anaconda3\lib\site-packages (from ipython>=5.3.0->pyvis) (5.7.1) Requirement already satisfied: decorator in c:\programdata\anaconda3\lib\site-packages (from ipython>=5.3.0->pyvis) (5.1.1) Requirement already satisfied: colorama in c:\programdata\anaconda3\lib\site-packages (from ipython>=5.3.0->pyvis) (0.4.6) Requirement already satisfied: MarkupSafe>=2.0 in c:\programdata\anaconda3\lib\site-packages (from jinja2>=2.9.6->pyvis) (2.1.1) Requirement already satisfied: parso<0.9.0,>=0.8.0 in c:\programdata\anaconda3\lib\site-packages (from jedi>=0.16->ipython>=5.3.0->pyvis) (0.8.3) Requirement already satisfied: wcwidth in c:\programdata\anaconda3\lib\site-packages (from prompt-toolkit<3.1.0,>=3.0.30->ipython>=5.3.0->pyvis) (0.2.5) Requirement already satisfied: executing in c:\programdata\anaconda3\lib\site-packages (from stack-data->ipython>=5.3.0->pyvis) (0.8.3) Requirement already satisfied: pure-eval in c:\programdata\anaconda3\lib\site-packages (from stack-data->ipython>=5.3.0->pyvis) (0.2.2) Requirement already satisfied: asttokens in c:\programdata\anaconda3\lib\site-packages (from stack-data->ipython>=5.3.0->pyvis) (2.0.5) Requirement already satisfied: six in c:\programdata\anaconda3\lib\site-packages (from asttokens->stack-data->ipython>=5.3.0->pyvis) (1.16.0)
import zipfile import itertools import tempfile import math from functools import reduce #from pyvis import network as net import pandas as pd import numpy as np import networkx as nx import plotly.express as px import plotly.graph_objects as go from plotly.offline import iplot from IPython.display import display, HTML pd.options.plotting.backend = "plotly" #for jupiter notebook from plotly.offline import init_notebook_mode #for Google collab comment these two lines init_notebook_mode(connected=True) #for Google collab comment these two lines
Загрузка данных¶
Возможно потребуется адаптировать под ваши условия.
zip_filepath='C:\Practice\data.zip' with zipfile.ZipFile(zip_filepath) as z: print(z.namelist()) for name in z.namelist(): with open(name, 'wb') as f: f.write(z.read(name))
--------------------------------------------------------------------------- FileNotFoundError Traceback (most recent call last) Cell In[2], line 3 1 zip_filepath='C:\Practice\data.zip' ----> 3 with zipfile.ZipFile(zip_filepath) as z: 4 print(z.namelist()) 5 for name in z.namelist(): File /usr/lib64/python3.11/zipfile.py:1283, in ZipFile.__init__(self, file, mode, compression, allowZip64, compresslevel, strict_timestamps, metadata_encoding) 1281 while True: 1282 try: -> 1283 self.fp = io.open(file, filemode) 1284 except OSError: 1285 if filemode in modeDict: FileNotFoundError: [Errno 2] No such file or directory: 'C:\\Practice\\data.zip'
def pyvis_deepnote_show(nt): tmp_output_filename = tempfile.NamedTemporaryFile(suffix='.html').name nt.save_graph(tmp_output_filename) f = open(tmp_output_filename, "r") display(HTML(f.read()))
Чтение и предобработка данных¶
В тестовом примере мы рассмотрим данные с метками аномалий. Сначала преобрзуем в dataframe, уберем часть лишних колонок, которые не имеют отношения к тем финансовым мошенничествам, которые есть в данных (по условию генерации данных)
df = pd.read_csv('./FinFraud_Labelled.csv', sep='|', parse_dates=[16, 17, 22]) # в файлах с вариантом задания, разделитель - ";" df.columns = [ 'Groundtruth', 'User ID (sender)', 'User ID (receiver)', 'User account ID (sender)', 'User account ID (receiver)', 'Amount of transaction', 'Type of transaction', 'State of operation', 'Balance before (sender)', 'Balance after (sender)', 'Balance after (receiver)', 'Balance before (receiver)', 'Not used', 'Not used', 'Not used', 'Not used', 'Transaction timestamp (sender)', 'Transaction timestamp (receiver)', 'Sender account ID', 'Not used', 'Not used', 'Not used', 'Transaction timestamp', 'Sender type', 'Receiver type' ] df = df.loc[:, ~df.columns.str.contains('^Not used', case=False)].sort_values('Transaction timestamp') df = df.drop('State of operation', axis=1) df = df.drop('Sender account ID', axis=1) df = df.drop('Transaction timestamp (sender)', axis=1) df = df.drop('Transaction timestamp (receiver)', axis=1) df = df.drop('Balance before (sender)', axis=1) df = df.drop('Balance after (sender)', axis=1) df = df.drop('Balance before (receiver)', axis=1) df = df.drop('Balance after (receiver)', axis=1) df['Groundtruth'] = df['Groundtruth'].str.replace('-', '_') df.describe(include='all').fillna('')
--------------------------------------------------------------------------- TypeError Traceback (most recent call last) Cell In[4], line 44 38 df = df.drop('Balance after (receiver)', axis=1) 43 df['Groundtruth'] = df['Groundtruth'].str.replace('-', '_') ---> 44 df.describe(include='all', datetime_is_numeric=True).fillna('') TypeError: NDFrame.describe() got an unexpected keyword argument 'datetime_is_numeric'
Описание набора данных¶
Название столбца | Возможные значения | Описание |
---|---|---|
Groundtruth | N_RegC2C N_RegDep N_Reg_RC N_RegWith N_Reg_Merch F_bot F_Mule_With F_SevWith |
N_RegC2C – легитимные денежные транзакцииN_RegDep – пополнение электронного кошелькаN_Reg_RC – пополнение баланса мобильной связиN_RegWith – снятие денег с электронного кошелька N_Reg_Merch – оплата услуг и товаров Мошенничества: F_bot – транзакции, выполняемые вредоносном ПО, установленном на телефоне, перевод выполняется подставлному лицу ("ослу" )", который затем обналичивает деньгиF_Mule_With – снятия денежных средств подставлным лицомF_SevWith – транзакции, выполняемые воров после кражи телефона |
User ID (transaction sender) | Generated ID | |
User ID (transaction receiver) | Generated ID | |
User account ID (transaction sender) | Generated ID | |
User account ID (transaction receiver) | Generated ID | |
Amount of transaction | Number | |
Type of transaction | Ind Dt ArRC Wl Merchant |
Тип транзакции Ind – денежный перевод между пользователями системы Dt – пополнение электронного кошелька (отправитель агент, а получатель - пользователь системы)ArRC – пополнение счета мобильной связи (перевод от пользователя системы к оператору мобильной связи )Wl – снятие электронных денег (отправитель - пользователь системы, получатель - оператор)Merchant – перевод от пользователя поставщику услуг или товаров |
State of operation | SU |
SU – успешно |
Balance before (transaction sender) | Number | |
Balance before (transaction receiver) | Number | |
Balance after (transaction sender) | Number | |
Balance after (transaction receiver) | Number | |
Transaction timestamp (sender) | Datetime | |
Transaction timestamp (receiver) | Datetime | |
Sender account ID | Generated ID | |
Transaction timestamp | Datetime | |
Sender type | EU RET |
|
Receiver type | EU operator RET MER |
Поскольку поле State of operation
всегда имеет значение (SU
) для всех транзакций, данный столбец предлагается удалить.
Столбцы Sender account ID
и User ID (transaction sender)
идентичны, также столбцы Transaction timestamp (sender)
и Transaction timestamp (receiver)
идентичны стобцу Transaction timestamp
, поэтому данные стобцы удалются (остается только Transaction timestamp
). Также удаляюся столбцы с балансом, т.к. в текущей версии набора данных они не задействованы.
Значения поля Groundtruth
преобрзованы в общий вид. Они используются только для проверки.
df.dtypes
Groundtruth object User ID (sender) object User ID (receiver) object User account ID (sender) object User account ID (receiver) object Amount of transaction float64 Type of transaction object Transaction timestamp datetime64[ns] Sender type object Receiver type object dtype: object
Статистика транзакций для каждого пользователя¶
Традиционно начнем со статистического анализа данных. Рекомендуется расширить число рассчитываемых статистик, например, включив показатели, характеризующие частоту транзакций. Для такого вида мошенничества как кража телефона изменение частоты снятий является характерным признаком.
def init_stat_dict(): stat_dict = dict() transaction_types = {"Ind", "Wl", "Dt", "Merchant", "ArRC"} for tran_type in transaction_types: amount_name = f"Sent_amount_{tran_type}" amount_median = f"Sent_amount_{tran_type}_median" amount_min = f"Sent_amount_{tran_type}_min" amount_max = f"Sent_amount_{tran_type}_max" tran_count = f"Sent_{tran_type}_count" rec_amount_name = f"Received_amount_{tran_type}" rec_amount_median = f"Received_amount_{tran_type}_median" rec_amount_min = f"Received_amount_{tran_type}_min" rec_amount_max = f"Received_amount_{tran_type}_max" rec_tran_count = f"Received_{tran_type}_count" stat_dict[amount_name] = 0 stat_dict[amount_median] = 0 stat_dict[amount_min] = 0 stat_dict[amount_max] = 0 stat_dict[tran_count] = 0 stat_dict[rec_amount_name] = 0 stat_dict[rec_amount_median] = 0 stat_dict[rec_amount_min] = 0 stat_dict[rec_amount_max] = 0 stat_dict[rec_tran_count] = 0 return stat_dict def get_stat_df(df): sent_unique_users = df["User ID (sender)"].unique() received_unique_users = df["User ID (receiver)"].unique() unique_users = np.unique(np.concatenate((sent_unique_users,received_unique_users),0)) #unique_users = pd.concat(sent_unique_users,received_unique_users).drop_duplicates().reset_index(drop=True) print(unique_users) stat_df = pd.DataFrame() stat_dict = init_stat_dict() transaction_types = {"Ind", "Wl", "Dt", "Merchant", "ArRC"} for user in unique_users: stat_dict = init_stat_dict() stat_dict["User ID"] = user user_df = df.loc[(df["User ID (sender)"] == user)] if (not user_df.empty): #stat_dict["User ID"] = user stat_dict["Unique_receivers"] = len(user_df["User ID (receiver)"].unique()) stat_dict["User type"] = user_df["Sender type"].unique()[0] for tran_type in transaction_types: amount_name = f"Sent_amount_{tran_type}" amount_median = f"Sent_amount_{tran_type}_median" amount_min = f"Sent_amount_{tran_type}_min" amount_max = f"Sent_amount_{tran_type}_max" tran_count = f"Sent_{tran_type}_count" stat_dict[amount_name] = (user_df.loc[user_df["Type of transaction"]==tran_type])["Amount of transaction"].sum() stat_dict[amount_median] = (user_df.loc[user_df["Type of transaction"]==tran_type])["Amount of transaction"].mean() stat_dict[amount_min] = (user_df.loc[user_df["Type of transaction"]==tran_type])["Amount of transaction"].min() stat_dict[amount_max] = (user_df.loc[user_df["Type of transaction"]==tran_type])["Amount of transaction"].max() stat_dict[tran_count] = (user_df.loc[user_df["Type of transaction"]==tran_type])["Amount of transaction"].count() else: stat_dict["User type"] = (df.loc[(df["User ID (receiver)"]==user)])["Receiver type"].unique()[0] user_df = df.loc[(df["User ID (receiver)"] == user)] if (not user_df.empty): stat_dict["Unique_senders"] = len(user_df["User ID (sender)"].unique()) for tran_type in transaction_types: rec_amount_name = f"Received_amount_{tran_type}" rec_amount_median = f"Received_amount_{tran_type}_median" rec_amount_min = f"Received_amount_{tran_type}_min" rec_amount_max = f"Received_amount_{tran_type}_max" rec_tran_count = f"Received_{tran_type}_count" stat_dict[rec_amount_name] = (user_df.loc[user_df["Type of transaction"]==tran_type])["Amount of transaction"].sum() stat_dict[rec_amount_median] = (user_df.loc[user_df["Type of transaction"]==tran_type])["Amount of transaction"].median() stat_dict[rec_amount_min] = (user_df.loc[user_df["Type of transaction"]==tran_type])["Amount of transaction"].min() stat_dict[rec_amount_max] = (user_df.loc[user_df["Type of transaction"]==tran_type])["Amount of transaction"].max() stat_dict[rec_tran_count] = (user_df.loc[user_df["Type of transaction"]==tran_type])["Amount of transaction"].count() df_temp = pd.DataFrame([stat_dict]) #df_temp.head() stat_df = pd.concat([stat_df, df_temp]) stat_df = stat_df.fillna(0) return stat_df
Кстати, обратите внимание уникальных пользователей в системе 2009. Это больше, чем число уникальных отправителей и уникальных получателей, значит, какие то пользователи только отправляют деньги, а какие-то только получают.
stat_df = get_stat_df(df) print(stat_df.shape)
['PN_EU_0_0' 'PN_EU_0_1' 'PN_EU_0_10' ... 'PN_Ret5' 'PN_Ret6' 'operator'] (2009, 54)
Получив статистику по пользователям, вы можете выполнить операцию кластеризации, чтобы посмотреть, какие группы пользователей есть. И уже выполнять анализ данных по группам пользователей. Обработка и построение графиков к Google Collab - достаточно длительный процесс. В данном случае, была выбрана часть статистик и построила проекции пользователей. Анализируемые поля были выбраны на основе анализа свойств возможных финансовых аномалий (т.е. просто эвристически:)).
from pandas.plotting import scatter_matrix from sklearn.preprocessing import StandardScaler from sklearn.preprocessing import LabelEncoder from sklearn.decomposition import PCA from matplotlib.ticker import FormatStrFormatter import plotly.express as px
Мошенничество, связанное с заражением бот-сетью.¶
Согласно описанию сценария атаки: есть множество зараженных пользователей, которые переводят деньги какому-то пользователю ("ослу" или "мулу"), и уже он выполняет операции обналичивания денег. Рассмотрен простейщий вариант сценария: цепочка мулов состоит из одного звена.
#оставляем поля, связанные с переводами и снятиями и добавили число уникальных пользователей, это же бот сеть. MobileBot_labels = ['Unique_receivers','Unique_receivers','Sent_Ind_count' ,'Sent_Wl_count', 'Received_Ind_count'] # а по этим полям будем пробовать найти пользователей с кражей телефона. MobileTheft_labels = ['Sent_amount_Wl', 'Sent_amount_Wl_median', 'Sent_amount_Wl_min', 'Sent_amount_Wl_max', 'Sent_Wl_count'] x = stat_df[MobileBot_labels].values # нормализуем значения x = StandardScaler().fit_transform(x) pca = PCA(n_components=3) principalComponents = pca.fit_transform(x) print(f'Explained variance: {pca.explained_variance_ratio_}\tSum: {pca.explained_variance_ratio_.sum()}')
Explained variance: [0.40133876 0.3262758 0.19799872] Sum: 0.9256132760462193
principalDf = pd.DataFrame(data=principalComponents , columns=['PC1', 'PC2', 'PC3']) fig = px.scatter(principalDf, x="PC1", y="PC2", color=stat_df['User type'], size=stat_df['Unique_senders'].apply(lambda x: 1 if x == 0 else math.log(x,10) ), hover_name=stat_df['User ID'], opacity = 0.5, color_discrete_map={ 'EU': '#377eb8', 'operator': '#e41a1c', 'RET': '#4daf4a', 'MER': '#984ea3', }) #for GOogle colab uncomment pio.renderers.default = 'iframe' #comment this lines for GOogle colab fig.show()
Проанализировав график рассеивания, можно увидеть группу пользователей с большим числом уникальных отправителей. Они могут быть "мулами" через которые отмываются деньги, проверим эту гипотезу. Рассмотрим пользователей: 'PN_EU_0_955', 'PN_EU_0_260', 'PN_EU_1_328', 'PN_0_1045'. ('PN_EU_0_260' и 'PN_0_1045' накладываются друг на друга, не хватает jitter или возможности перемещать объекты). А еще есть подозрительный пользователь PN_EU_2_154.
Ниже график рассеивания для пары параметров, можно поисследовать возможные зависимости.
fig = px.scatter(stat_df, x='Sent_amount_Wl_max', y='Sent_amount_Wl_median', color=stat_df['User type'], size=stat_df['Unique_senders'].apply(lambda x: 1 if x == 0 else math.log(x,10) ), hover_name=stat_df['User ID'], opacity = 0.5, color_discrete_map={ 'EU': '#377eb8', 'operator': '#e41a1c', 'RET': '#4daf4a', 'MER': '#984ea3', }) fig.show()
Граф сети¶
Создание графа на основе данных с помощью библиотеки Networkx. вершины добавлена некоторая статистическая информация. Вы можете модифицировать ее, брать ее из дата ферйма stat_df.
def create_network_for_df(df): G = nx.MultiDiGraph() for _, row in df.iterrows(): sender_node_id = row["User ID (sender)"] receiver_node_id = row["User ID (receiver)"] sent_tran_num = dict({'Ind': 0, 'Wl': 0, 'Dt':0, 'ArRC':0, 'Merchant':0}) received_tran_num = dict({'Ind': 0, 'Wl': 0, 'Dt':0, 'ArRC':0, 'Merchant':0}) sent_tran_amount = dict({'Ind': 0, 'Wl': 0, 'Dt':0, 'ArRC':0, 'Merchant':0}) received_tran_amount = dict({'Ind': 0, 'Wl': 0, 'Dt':0, 'ArRC':0, 'Merchant':0}) tran_type = row["Type of transaction"] if sender_node_id in G.nodes().keys(): G.nodes[sender_node_id]['sent_transaction_num'][tran_type]+=1 G.nodes[sender_node_id]['sent_transaction_amount'][tran_type]+=row["Amount of transaction"] else: sent_tran_num[tran_type]=1 sent_tran_amount[tran_type] = row["Amount of transaction"] G.add_node( sender_node_id, type=row["Sender type"], account_id=row["User account ID (sender)"], sent_transaction_num = sent_tran_num, sent_transaction_amount = sent_tran_amount, received_transaction_num = dict({'Ind': 0, 'Wl': 0, 'Dt':0, 'ArRC':0, 'Merchant':0}), received_transaction_amount = dict({'Ind': 0, 'Wl': 0, 'Dt':0, 'ArRC':0, 'Merchant':0}) ) if receiver_node_id in G.nodes().keys(): G.nodes[receiver_node_id]['received_transaction_num'][tran_type]+=1 G.nodes[sender_node_id]['received_transaction_amount'][tran_type]+=row["Amount of transaction"] else: received_tran_num[tran_type]=1 received_tran_amount[tran_type]=row["Amount of transaction"] G.add_node( receiver_node_id, type=row["Receiver type"], account_id=row["User account ID (receiver)"], transaction_num = 1, sent_transaction_num = dict({'Ind': 0, 'Wl': 0, 'Dt':0, 'ArRC':0, 'Merchant':0}), sent_transaction_amount = dict({'Ind': 0, 'Wl': 0, 'Dt':0, 'ArRC':0, 'Merchant':0}), received_transaction_num = received_tran_num, received_transaction_amount = received_tran_amount ) G.add_edge( row["User ID (sender)"], row["User ID (receiver)"], groundtruth=row["Groundtruth"], amount=row["Amount of transaction"], timestamp=row["Transaction timestamp"], type=row["Type of transaction"], ) return G
Отображения графа с помощью библиотеки Plotly¶
Отрисовка в Google Collab выолняется достаточно медленно, поэтому предлагаем анализировать граф частями.
Настройка палитр и внешнего вида
def draw_palette(colors, shapes=None, title=""): if shapes: assert len(colors) == len(shapes) x = list(colors.keys()) y = [1 for _ in x] color = list(colors.values()) shape = None if shapes: shape = list(shapes.values()) else: shape = ['circle' for _ in x] fig = go.Figure( data=[ go.Scatter( mode='markers', x=x, y=y, marker=dict(size=40, color=color, symbol=shape, line=dict(width=0, color='black'))) ], layout=dict( title_text=title, yaxis=dict(visible=False, showticklabels=False) ) ) fig.show()
GROUNDTRUTH_TO_COLOR = { 'N_RegC2C': '#666666', 'N_RegDep': '#666666', 'N_Reg_RC': '#666666', 'N_RegWith': '#666666', 'N_Reg_Merch': '#666666', 'F_Mule_With': '#990099', 'F_bot': '#EECA3B', 'F_SevWith': '#EF553B', } #палитра set 1 из ColorBrewer #e41a1c красный #377eb8 синий #4daf4a зеленый #984ea3 фиолетовый TYPE_TO_COLOR = { 'EU': '#377eb8', 'operator': '#e41a1c', 'RET': '#4daf4a', 'MER': '#984ea3', } TYPE_TO_SHAPE = { 'EU': 'circle', 'operator': 'square', 'RET': 'diamond', 'MER': 'star', } IS_CRIMINAL_TO_COLOR_SIZE_NODE = { True: ('#EF553B', 15, 3), False: ('gray', 15, 0) } IS_CRIMINAL_TO_COLOR_SIZE_EDGE = { True: ('#EF553B', 2), False: ('#666666', 1) }
draw_palette(TYPE_TO_COLOR, TYPE_TO_SHAPE, title="User type palette")
Настройка внешнего вида узлов графа
def make_node(layout, node, data, is_criminal): x, y = layout[node] type_t = data['type'] sent_tran_num = sum(data["sent_transaction_num"].values()) received_tran_num = sum(data["received_transaction_num"].values()) sent_tran_amount = sum(data["sent_transaction_amount"].values()) received_tran_amount = sum(data["received_transaction_amount"].values()) color, size, width = IS_CRIMINAL_TO_COLOR_SIZE_NODE[is_criminal] user = px.scatter( x=[x, None], y=[y, None], text = [f"{node}", None], hover_name=[f"{node}<br>" #f"<b>Criminal:</b> {is_criminal}<br>" f"Type: {type_t}<br>" f"Sent transactions (num)):{sent_tran_num}<br>" f"Received transactions (num):{received_tran_num}<br>" f"<b>Sent transactions (amount):</b>{sent_tran_amount}<br>" f"<b>Received transactions (amount):</b>{received_tran_amount}<br>" ,None] ) user.update_traces( marker=dict( color=TYPE_TO_COLOR[type_t], size=size, line=dict( width=width, color=color ), symbol=TYPE_TO_SHAPE[type_t] ), textposition='bottom center', textfont_size=8 ) return user # def make_edge(layout, node_id1, node_id2, G, criminals): x0, y0 = layout[node_id1] x1, y1 = layout[node_id2] node1 = G.nodes[node_id1] node2 = G.nodes[node_id2] is_sender_criminal = True if node_id1 in criminals else False is_receiver_criminal = True if node_id2 in criminals else False color, size = IS_CRIMINAL_TO_COLOR_SIZE_EDGE[is_sender_criminal] # Edge trace = px.line( x=[x0, x1, None], y=[y0, y1, None], hover_data=None, ) trace.update_traces( line_color=color, line_width=size, hovertemplate = None, hoverinfo = "skip", ) node1_trace = make_node(layout, node_id1, node1, is_sender_criminal) node2_trace = make_node(layout, node_id2, node2, is_receiver_criminal) return trace.data, (node1_trace.data + node2_trace.data) def draw_network(G, title, crime_type="", criminals=set()): layout = nx.nx_agraph.graphviz_layout(G, prog = 'twopi') #полезными могут быть укладки dot и twopi (параметр prog) #Поэкспериментируйте с разными раскладками #layout = nx.spring_layout(G) #layout = nx.circular_layout(G) #layout = nx.kamada_kawai_layout(G, scale = 10 ) edges_data = () nodes_data = () for node1, node2, _ in G.edges(data=True): edge, nodes = make_edge(layout, node1, node2, G, criminals) edges_data += edge nodes_data += nodes all_fig = go.Figure((*edges_data, *nodes_data)) all_fig.update_layout(autosize=True, width=900, height=900) all_fig.write_html(f"{crime_type}_{title}_plotly.html") all_fig.show() def draw_network_for_df(df, title, crime_type="", criminals=set()): G = create_network_for_df(df) draw_network(G, title, crime_type, criminals)
Построим граф контактов для выбранных ранее пользователей 'PN_EU_0_955', 'PN_EU_0_260', 'PN_EU_1_328'
df = df.sort_values(by=['Transaction timestamp'], ascending=True) suspected_users = ['PN_EU_0_955', 'PN_EU_0_260', 'PN_EU_1_328', 'PN_0_1045'] #suspected_users = ['PN_EU_2_154'] # постройте граф контактов для подозрительного пользователя #suspected_users = ['PN_EU_0_955'] # постройте граф контактов для мула и сравните с подозрительным df_selected = df.loc[((df["User ID (sender)"].isin(suspected_users)) | (df["User ID (receiver)"].isin(suspected_users)))] print(df_selected.shape)
(1207, 10)
G = create_network_for_df(df_selected)
draw_network(G, "Структура мошенничества","Мобильный ботнет" )
Давайте опишем, что мы видим. Очевидно, что пользователи 'PN_EU_0_955', 'PN_EU_0_260', 'PN_EU_1_328', 'PN_0_1045' имеют одинаковый характер финансовых транзакций (постройте граф для каждого пользователя). Множество пользователей, которое одинаково для всех четырех пользователей, пересылают деньги выбранным 4 пользотелям, которые в последствии выполняют снятие электронных денег. Данная схема очень похожа на искомую схему заражения мобильным ботом. Множество пользователей - это зараженные узлы (ниже определим это множество), а получатели транзакций - очевидно подставные пользователи (мулы).
Обратите внимание, что увидеть взаимосвязи явно можно только с определенным способом укладки вершин графа. ПРоэкспериментируйте с различными алгоритмами укладки!
Проанализируйте самостоятельно поведение пользователя PN_EU_2_154, убедитесь, что у него качественно другое поведение, хотя он достаточно активен в системе.
Сформируем множество потенциально зараженных узлов.
infected_users = (df.loc[((df["User ID (receiver)"].isin(suspected_users)) & (df['Type of transaction']=='Ind'))]["User ID (sender)"]).unique() print(infected_users)
['PN_EU_0_468' 'PN_EU_0_143' 'PN_EU_0_1209' 'PN_EU_0_1166' 'PN_EU_0_248' 'PN_EU_1_211' 'PN_EU_0_741' 'PN_EU_0_1256' 'PN_EU_0_761' 'PN_EU_0_704' 'PN_EU_0_227' 'PN_EU_0_501' 'PN_EU_0_1032' 'PN_EU_0_870' 'PN_EU_0_687' 'PN_EU_0_5' 'PN_EU_0_767' 'PN_EU_0_668' 'PN_EU_1_8' 'PN_EU_1_352' 'PN_EU_2_134' 'PN_EU_0_789' 'PN_EU_0_1113' 'PN_EU_1_508' 'PN_EU_2_57' 'PN_EU_0_49' 'PN_EU_0_19' 'PN_EU_3_5' 'PN_EU_0_298' 'PN_EU_1_99' 'PN_EU_0_87' 'PN_EU_2_51' 'PN_EU_1_495' 'PN_EU_0_379' 'PN_EU_2_128' 'PN_EU_1_437' 'PN_EU_0_888' 'PN_EU_0_779' 'PN_EU_0_476' 'PN_EU_1_114' 'PN_EU_1_449' 'PN_EU_0_122' 'PN_EU_1_167' 'PN_EU_1_26' 'PN_EU_3_29']
Оценка точности предположения¶
Теперь оценим точность нашего предположения. Внимание! Это можно сделать только, имея размеченные данные. В вашей лабораторной работы таких сведений нет.
def check_accuracy(df_suspected, df_groundtruth, columns_to_check=[]): is_criminal_mapping = { 'N_RegC2C': False, 'N_RegDep': False, 'N_Reg_RC': False, 'N_RegWith': False, 'N_Reg_Merch': False, 'F_Mule_With': True, 'F_bot': True, 'F_SevWith': True, } df_with_required_columns = df_groundtruth[df_groundtruth['Groundtruth'].isin(columns_to_check)].copy() df_all = pd.concat([ df_with_required_columns,df_suspected]) true_count = df_all.duplicated(keep='first').sum() #возможно это не самый лучший вариант # accuracy = true_count/df_with_required_columns.shape[0] fpr = (df_suspected.shape[0] - true_count)/(df_groundtruth.shape[0]-df_with_required_columns.shape[0]) return accuracy, fpr
#infected accounts df_infected_transactions = df.loc[((df["User ID (receiver)"].isin(suspected_users)) & (df['Type of transaction']=='Ind'))] accuracy, fpr = check_accuracy(df_infected_transactions, df, ['F_bot']) print(f"Infected accounts detection: accuracy = {accuracy}, FPR = {fpr}" ) df_infected_withdrawals = df.loc[((df["User ID (sender)"].isin(suspected_users)) & (df['Type of transaction']=='Wl'))] accuracy, fpr = check_accuracy(df_infected_withdrawals, df, ['F_Mule_With']) print(f"Mule accounts detection: accuracy = {accuracy}, FPR = {fpr}")
Infected accounts detection: accuracy = 0.7614424410540915, FPR = 0.00025865095054224325 Mule accounts detection: accuracy = 0.7629009762900977, FPR = 7.3894810736916e-05
Точность обнаружения не высока, это означает, обнаружили не все транзакции, но структуру бота обнаружили верно. Проверить это можно, используя уже groundtruth и визуализировать не обнаруженные транзакции.
Попробуйте это сделать, настроив внешний вид узлов графа соответствующим образом.
Отображения графа с помощью библиотеки Pyvis¶
Предыдущие визуализации графа были неплохи, но они не интерактивны, иногда очень хотелось сдвинуть вершины. Это можно сделать с помощью библиотеки PyVis. Построим с помощью этой библиотеку графы контактов пользователей мулов и подозрительно пользователя
suspected_users = ['PN_EU_2_154']¶
suspected_users = ['PN_EU_0_955']¶
def draw_pyvis_network_for_df(df, title): nt = net.Network( height='900px', width='100%', heading=f"{title}", directed=True, notebook=True, cdn_resources='remote' ) for _, row in df.iterrows(): nt.add_node( row["User ID (sender)"], label=row["User ID (sender)"], shape=TYPE_TO_SHAPE[row["Sender type"]], #имхо избыточно color=TYPE_TO_COLOR[row["Sender type"]], title=row["User ID (sender)"], ) nt.add_node( row["User ID (receiver)"], label=row["User ID (receiver)"], shape=TYPE_TO_SHAPE[row["Receiver type"]], color= TYPE_TO_COLOR[row["Receiver type"]], #title=f"{'Criminal' if is_receiver_criminal else None}", ) # можно поэкспериментировать с цветом ребра в зависимости от размера перевода nt.add_edge( row["User ID (sender)"], row["User ID (receiver)"], color='#666666', width=1, title=f'Amount: {row["Amount of transaction"]}', label=f"{row['Type of transaction']}: {row['Amount of transaction']}" ) return nt
Вновь отрисуем граф контактов пользователей, чьи устройства заражены мобильной бот сетью.
#suspected_users = ['PN_EU_2_154'] suspected_users = ['PN_EU_0_955'] #suspected_users = ['PN_EU_0_955', 'PN_EU_0_260', 'PN_EU_1_328', 'PN_0_1045'] df_selected = df.loc[((df["User ID (sender)"].isin(suspected_users)) | (df["User ID (receiver)"].isin(suspected_users)))] df_selected.head()
Groundtruth | User ID (sender) | User ID (receiver) | User account ID (sender) | User account ID (receiver) | Amount of transaction | Type of transaction | Transaction timestamp | Sender type | Receiver type | |
---|---|---|---|---|---|---|---|---|---|---|
13105 | F_Mule_With | PN_EU_0_955 | PN_Ret1 | EUAcc0_955 | RAcc1 | 1776.92 | Wl | 2011-01-07 20:34:22 | EU | RET |
13130 | F_bot | PN_EU_0_1209 | PN_EU_0_955 | EUAcc0_1209 | EUAcc0_955 | 15852.87 | Ind | 2011-01-07 22:31:14 | EU | EU |
41283 | F_Mule_With | PN_EU_0_955 | PN_Ret6 | EUAcc0_955 | RAcc6 | 12042.49 | Wl | 2011-01-09 12:23:21 | EU | RET |
13532 | F_Mule_With | PN_EU_0_955 | PN_Ret3 | EUAcc0_955 | RAcc3 | 15694.34 | Wl | 2011-02-07 20:22:03 | EU | RET |
13552 | F_bot | PN_EU_0_761 | PN_EU_0_955 | EUAcc0_761 | EUAcc0_955 | 15528.15 | Ind | 2011-02-07 21:30:22 | EU | EU |
n = draw_pyvis_network_for_df(df_selected, "Mobile botnet infection")
n.toggle_physics(False) #можно настраивать силы притяженяи и отталкивания n.show_buttons(filter_=['physics']) n.show("PN_EU_0_955.html")
PN_EU_0_955.html
Кража телефона¶
Обнаружение подозрительных транзакций¶
Ниже представлено альтернативное решение поиска случае кражи телефона, основанные на анализе финансовых транзакций: фильтруются все транзакции за один день и формируются множество транзакций, сумма переводов которых меньше средней суммы за день.
Подготовка данных¶
Признаки кражи:
- Сумма мошеннических операций меньше, чем средняя сумма пользователей
- Мошенник пытается снять деньги несколько раз в течение короткого промежутка времени
df = df.sort_values(by=['Transaction timestamp'], ascending=True) dfs_by_day = [g for _, g in df.groupby(df['Transaction timestamp'].dt.date)] df_day = dfs_by_day[17]
def get_theft_df(df): mean_amount = df["Amount of transaction"].mean() median_df = ( df[['User ID (sender)', 'Type of transaction', 'Sender type']] .groupby(['User ID (sender)', 'Type of transaction', 'Sender type']) .size() .groupby(['Type of transaction', 'Sender type']) .median() .to_frame(name='Median number of transactions') .reset_index() .sort_values(by=['Sender type'], ascending=True)) median_wl = median_df.loc[median_df['Type of transaction'] == 'Wl', 'Median number of transactions'].iloc[0] # Filter withdrawals with amount less than mean_amount theft_df = df[(df['Type of transaction'] == 'Wl') & (df["Amount of transaction"] < mean_amount)].copy() # Select users, who made more than median_number_of_transactions_by_type withdrawals suspected_users = theft_df.groupby(['User ID (sender)'])['User ID (sender)'].transform('count') > median_wl theft_df.insert(0, 'Suspected', False) theft_df.loc[:, 'Suspected'] = suspected_users suspected_senders_set = set(theft_df.loc[theft_df['Suspected'] == True, 'User ID (sender)']) suspected_receivers_set = set(theft_df.loc[theft_df['Suspected'] == True, 'User ID (receiver)']) criminals = (suspected_senders_set | suspected_receivers_set) return theft_df, criminals
theft_df, criminals = get_theft_df(df_day) theft_df.head()
Suspected | Groundtruth | User ID (sender) | User ID (receiver) | User account ID (sender) | User account ID (receiver) | Amount of transaction | Type of transaction | Transaction timestamp | Sender type | Receiver type | |
---|---|---|---|---|---|---|---|---|---|---|---|
1018 | False | N_RegWith | PN_EU_1_72 | PN_Ret6 | EUAcc1_72 | RAcc6 | 21279.29 | Wl | 2011-05-06 04:15:47 | EU | RET |
1075 | False | N_RegWith | PN_EU_1_105 | PN_Ret2 | EUAcc1_105 | RAcc2 | 16831.24 | Wl | 2011-05-06 11:04:29 | EU | RET |
1083 | False | N_RegWith | PN_EU_3_4 | PN_Ret6 | EUAcc3_4 | RAcc6 | 36518.17 | Wl | 2011-05-06 11:45:31 | EU | RET |
1140 | False | F_Mule_With | PN_EU_1_328 | PN_Ret4 | EUAcc1_328 | RAcc4 | 23505.74 | Wl | 2011-05-06 16:12:54 | EU | RET |
1168 | False | F_Mule_With | PN_EU_0_1045 | PN_Ret5 | EUAcc0_1045 | RAcc5 | 4703.50 | Wl | 2011-05-06 19:11:16 | EU | RET |
Graph¶
G = create_network_for_df(theft_df)
draw_network(G, "Структура мошенничества","Кража мобильного телефона" )
А можно проанализровать структурные связи пользователей, которые формируют небольшую группу, которые обнаружили при построении проекции данных для атрибутов: MobileTheft_labels = ['Sent_amount_Wl', 'Sent_amount_Wl_median', 'Sent_amount_Wl_min', 'Sent_amount_Wl_max', 'Sent_Wl_count']. Выберем, пользователя PN_EU_0_77
suspected_theft = ['PN_EU_0_77'] df_selected = df.loc[((df["User ID (sender)"].isin(suspected_theft)) | (df["User ID (receiver)"].isin(suspected_theft)))] df_selected.head()
Groundtruth | User ID (sender) | User ID (receiver) | User account ID (sender) | User account ID (receiver) | Amount of transaction | Type of transaction | Transaction timestamp | Sender type | Receiver type | |
---|---|---|---|---|---|---|---|---|---|---|
13714 | N_RegDep | PN_Ret5 | PN_EU_0_77 | RAcc5 | EUAcc0_77 | 28793.31 | Dt | 2011-03-07 05:32:34 | RET | EU |
42151 | N_RegC2C | PN_EU_2_0 | PN_EU_0_77 | EUAcc2_0 | EUAcc0_77 | 26474.59 | Ind | 2011-03-09 10:56:12 | EU | EU |
43105 | N_RegDep | PN_Ret5 | PN_EU_0_77 | RAcc5 | EUAcc0_77 | 70363.71 | Dt | 2011-05-09 13:45:51 | RET | EU |
23305 | N_RegDep | PN_Ret6 | PN_EU_0_77 | RAcc6 | EUAcc0_77 | 103681.73 | Dt | 2011-07-24 03:40:14 | RET | EU |
25522 | N_RegDep | PN_Ret5 | PN_EU_0_77 | RAcc5 | EUAcc0_77 | 142838.85 | Dt | 2011-07-28 21:30:11 | RET | EU |
n = draw_pyvis_network_for_df(df_selected, "Mobile phone theft ")
n.toggle_physics(False) #можно настраивать силы притяженяи и отталкивания n.show_buttons(filter_=['physics']) n.show("'PN_EU_0_77'.html")
'PN_EU_0_77'.html
Что мы увидели на этом графе: пользователь PN_EU_0_77 обращается к 2 агентам для пополнения электронного кошелька, а к 3 агентам для снятия электронных денег (при чем к 2 агентам Ret1 и Ret 2 только для снятия). По сути мы не можем на основании этих данных делать какие либо выводы. Но предлагаю просмотреть логи транзакций между пользователем и этими агентами, оценить частоту (а заодно посмотреть на groundtruth).
df_suspected_transactions = df.loc[((df["User ID (sender)"] == "PN_EU_0_77") & df['User ID (receiver)'].isin(["PN_Ret1","PN_Ret2", "PN_Ret4"]))] df_suspected_transactions.head()
Groundtruth | User ID (sender) | User ID (receiver) | User account ID (sender) | User account ID (receiver) | Amount of transaction | Type of transaction | Transaction timestamp | Sender type | Receiver type | |
---|---|---|---|---|---|---|---|---|---|---|
2634 | F_SevWith | PN_EU_0_77 | PN_Ret2 | EUAcc0_77 | RAcc2 | 5229.26 | Wl | 2011-09-06 16:56:51 | EU | RET |
2636 | F_SevWith | PN_EU_0_77 | PN_Ret4 | EUAcc0_77 | RAcc4 | 382.89 | Wl | 2011-09-06 17:00:20 | EU | RET |
2638 | F_SevWith | PN_EU_0_77 | PN_Ret1 | EUAcc0_77 | RAcc1 | 2682.01 | Wl | 2011-09-06 17:03:12 | EU | RET |
2640 | F_SevWith | PN_EU_0_77 | PN_Ret4 | EUAcc0_77 | RAcc4 | 1058.97 | Wl | 2011-09-06 17:06:10 | EU | RET |
Обратите внимание на временные метки этих транзакций: все 4 транзакции осуществляются в течение 10 минут! Следовательно, именно частота операций снятия должна стать основным признаком для выявления данной аномалии.
Поиск всех краж телефона¶
Проанализировав графы, построенные для ряда пользователей из группы (PN_EU_0_720, PN_EU_0_77, PN_EU_0_472), можно увидеть, что для этих пользователей характерно большое число связей с агентами (зеленые ромбы), а также небольшие суммы транзакций не больше 10000. Давайте предположим, что именно они являются жертвами кражи. (частоту транзакций не учитывам тут.)
# df_suspected_thefts = df.loc[((df["Amount of transaction"] < 10000) & (df['Type of transaction']=='Wl'))] df_suspected_thefts.head()
Groundtruth | User ID (sender) | User ID (receiver) | User account ID (sender) | User account ID (receiver) | Amount of transaction | Type of transaction | Transaction timestamp | Sender type | Receiver type | |
---|---|---|---|---|---|---|---|---|---|---|
147 | N_RegWith | PN_EU_3_8 | PN_Ret6 | EUAcc3_8 | RAcc6 | 5601.24 | Wl | 2011-01-06 18:56:20 | EU | RET |
12759 | N_RegWith | PN_EU_2_41 | PN_Ret5 | EUAcc2_41 | RAcc5 | 5601.31 | Wl | 2011-01-07 02:36:41 | EU | RET |
12905 | F_Mule_With | PN_EU_1_328 | PN_Ret2 | EUAcc1_328 | RAcc2 | 6059.96 | Wl | 2011-01-07 10:31:04 | EU | RET |
12930 | F_Mule_With | PN_EU_0_1045 | PN_Ret3 | EUAcc0_1045 | RAcc3 | 2175.82 | Wl | 2011-01-07 11:39:25 | EU | RET |
13105 | F_Mule_With | PN_EU_0_955 | PN_Ret1 | EUAcc0_955 | RAcc1 | 1776.92 | Wl | 2011-01-07 20:34:22 | EU | RET |
accuracy, fpr = check_accuracy(df_suspected_thefts, df, ['F_SevWith']) print(f"Infected accounts detection: accuracy = {accuracy}, FPR = {fpr}" )
--------------------------------------------------------------------------- NameError Traceback (most recent call last) Cell In[12], line 1 ----> 1 accuracy, fpr = check_accuracy(df_suspected_thefts, df, ['F_SevWith']) 2 print(f"Infected accounts detection: accuracy = {accuracy}, FPR = {fpr}" ) NameError: name 'check_accuracy' is not defined
Полученная точность очень низкая, потому что не был учтен такой параметр как частота снятий, следовательно, необходимо отслеживать данный параметр, не учтено число уникальных агентов (Ret). Предлагаю, продумать, как учесть данный параметр. Доработайте!
Тем не менее посмотрим график транзакций, которые потенциально могут быть опасными.
G = create_network_for_df(df_suspected_thefts) draw_network(G, "Структура мошенничества","Кража мобильного телефона" )
Внимательно рассмотрев этот рисунок, можно исключить тех пользователей, которые снимают деньги только у одного агента, подозрительными транзакциями будут у тех пользвоателей, которые связаны операцией снятий сразу с несколькими агентами. Кстати на этом графике хорошо видно 4 пользователя-мула из предыдущего мошенничества. Кражи не столь очевидны. Теперь построим граф в Pyvis и поэкспериментируем с настройками компоновки узлов графа на основе сил. Попробуйте задать следующим параметры алгоритма: centralGravity: 0.2 springLength: 295 springConstant: 0 nodeDistance: 100 damping: 0.09 maxVelocity: 60 minVelocity: 0.07 solver:repulsion И вы увидете как расположились узлы: по внешнему радиусы узлы, которые связаны только с одним агентом, внутри круга - мулы из сценария заражением ботнетом, а вот небольшие группы узлов рядом с агентами как раз и есть пользователи, у которых были украдены телефоны.
n = draw_pyvis_network_for_df(df_suspected_thefts, "Mobile phone theft ")
n.toggle_physics(False) #можно настраивать силы притяженяи и отталкивания n.show_buttons(filter_=['physics']) n.show("Mobile phone theft.html")
Mobile phone theft.html
ДАвайте сформируем список пользователей, чьи телефоны могут быть украдены.
victims = df_suspected_thefts["User ID (sender)"].unique() print(f'Число пользователей, у которых были украдены телефоны: {len(victims)}. В их число входят: {victims}')
Число пользователей, у которых были украдены телефоны: 137. В их число входят: ['PN_EU_3_8' 'PN_EU_2_41' 'PN_EU_1_328' 'PN_EU_0_1045' 'PN_EU_0_955' 'PN_EU_0_260' 'PN_EU_1_169' 'PN_EU_1_74' 'PN_EU_1_86' 'PN_EU_1_123' 'PN_EU_1_35' 'PN_EU_2_59' 'PN_EU_2_39' 'PN_EU_2_108' 'PN_EU_1_118' 'PN_EU_2_88' 'PN_EU_2_38' 'PN_EU_2_71' 'PN_EU_2_116' 'PN_EU_2_136' 'PN_EU_1_41' 'PN_EU_1_183' 'PN_EU_0_25' 'PN_EU_0_1226' 'PN_EU_0_176' 'PN_EU_0_652' 'PN_EU_0_373' 'PN_EU_0_624' 'PN_EU_1_124' 'PN_EU_0_935' 'PN_EU_0_171' 'PN_EU_3_15' 'PN_EU_2_155' 'PN_EU_0_763' 'PN_EU_0_590' 'PN_EU_3_36' 'PN_EU_1_366' 'PN_EU_1_49' 'PN_EU_0_441' 'PN_EU_2_137' 'PN_EU_3_0' 'PN_EU_0_1093' 'PN_EU_0_720' 'PN_EU_0_1173' 'PN_EU_1_10' 'PN_EU_2_63' 'PN_EU_0_57' 'PN_EU_2_115' 'PN_EU_0_137' 'PN_EU_3_10' 'PN_EU_1_462' 'PN_EU_0_898' 'PN_EU_0_105' 'PN_EU_0_672' 'PN_EU_0_71' 'PN_EU_0_173' 'PN_EU_0_1156' 'PN_EU_0_1235' 'PN_EU_2_111' 'PN_EU_1_197' 'PN_EU_1_435' 'PN_EU_0_747' 'PN_EU_1_363' 'PN_EU_0_931' 'PN_EU_0_1262' 'PN_EU_0_1044' 'PN_EU_0_658' 'PN_EU_1_182' 'PN_EU_3_32' 'PN_EU_2_51' 'PN_EU_2_40' 'PN_EU_1_42' 'PN_EU_1_144' 'PN_EU_2_101' 'PN_EU_1_78' 'PN_EU_2_107' 'PN_EU_3_14' 'PN_EU_2_118' 'PN_EU_2_75' 'PN_EU_0_76' 'PN_EU_1_141' 'PN_EU_1_160' 'PN_EU_1_24' 'PN_EU_2_27' 'PN_EU_1_195' 'PN_EU_3_6' 'PN_EU_1_209' 'PN_EU_2_26' 'PN_EU_1_173' 'PN_EU_1_71' 'PN_EU_0_101' 'PN_EU_2_157' 'PN_EU_1_62' 'PN_EU_2_49' 'PN_EU_2_21' 'PN_EU_2_152' 'PN_EU_1_146' 'PN_EU_0_26' 'PN_EU_3_4' 'PN_EU_0_50' 'PN_EU_3_17' 'PN_EU_1_147' 'PN_EU_2_44' 'PN_EU_1_208' 'PN_EU_1_158' 'PN_EU_0_1183' 'PN_EU_0_77' 'PN_EU_2_161' 'PN_EU_0_472' 'PN_EU_1_391' 'PN_EU_1_149' 'PN_EU_0_876' 'PN_EU_1_377' 'PN_EU_0_1114' 'PN_EU_0_16' 'PN_EU_3_26' 'PN_EU_0_751' 'PN_EU_0_1135' 'PN_EU_0_1128' 'PN_EU_0_90' 'PN_EU_1_156' 'PN_EU_1_179' 'PN_EU_3_22' 'PN_EU_1_19' 'PN_EU_1_106' 'PN_EU_1_57' 'PN_EU_2_52' 'PN_EU_0_128' 'PN_EU_2_45' 'PN_EU_3_1' 'PN_EU_0_58' 'PN_EU_1_108' 'PN_EU_1_4' 'PN_EU_1_115' 'PN_EU_0_131' 'PN_EU_2_65' 'PN_EU_2_35']
Соглаcно нашим предположениям, у нас 137 жертв кражи телефона, но на самом деле их 61! Улучшите этот показатель, и приступайте к анализу вашего варианта.