data_visualisation/practice4/Практическая_работа__4.ipynb
2023-11-11 22:55:21 +03:00

115 KiB
Raw Permalink Blame History

Практическая работа №4

Обнаружение злоумышленников в системе мобильных денежных переводов

  1. настройка окружения
In [12]:
%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)
In [1]:
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 

Загрузка данных

Возможно потребуется адаптировать под ваши условия.

In [2]:
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'
In [3]:
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, уберем часть лишних колонок, которые не имеют отношения к тем финансовым мошенничествам, которые есть в данных (по условию генерации данных)

In [4]:
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 преобрзованы в общий вид. Они используются только для проверки.

In [6]:
df.dtypes
Out[6]:
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

Статистика транзакций для каждого пользователя

Традиционно начнем со статистического анализа данных. Рекомендуется расширить число рассчитываемых статистик, например, включив показатели, характеризующие частоту транзакций. Для такого вида мошенничества как кража телефона изменение частоты снятий является характерным признаком.

In [3]:
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. Это больше, чем число уникальных отправителей и уникальных получателей, значит, какие то пользователи только отправляют деньги, а какие-то только получают.

In [4]:
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 - достаточно длительный процесс. В данном случае, была выбрана часть статистик и построила проекции пользователей. Анализируемые поля были выбраны на основе анализа свойств возможных финансовых аномалий (т.е. просто эвристически:)).

In [36]:
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

Мошенничество, связанное с заражением бот-сетью.

Согласно описанию сценария атаки: есть множество зараженных пользователей, которые переводят деньги какому-то пользователю ("ослу" или "мулу"), и уже он выполняет операции обналичивания денег. Рассмотрен простейщий вариант сценария: цепочка мулов состоит из одного звена.

In [20]:
#оставляем поля, связанные с переводами и снятиями и добавили число уникальных пользователей, это же бот сеть.

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
In [21]:
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.

Ниже график рассеивания для пары параметров, можно поисследовать возможные зависимости.

In [13]:
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.

In [6]:
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 выолняется достаточно медленно, поэтому предлагаем анализировать граф частями.

Настройка палитр и внешнего вида

In [7]:
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()
In [14]:
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)
}
In [40]:
draw_palette(TYPE_TO_COLOR, TYPE_TO_SHAPE, title="User type palette")

Настройка внешнего вида узлов графа

In [8]:
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'

In [33]:
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)
In [48]:
G = create_network_for_df(df_selected)
In [49]:
draw_network(G, "Структура мошенничества","Мобильный ботнет" )

Давайте опишем, что мы видим. Очевидно, что пользователи 'PN_EU_0_955', 'PN_EU_0_260', 'PN_EU_1_328', 'PN_0_1045' имеют одинаковый характер финансовых транзакций (постройте граф для каждого пользователя). Множество пользователей, которое одинаково для всех четырех пользователей, пересылают деньги выбранным 4 пользотелям, которые в последствии выполняют снятие электронных денег. Данная схема очень похожа на искомую схему заражения мобильным ботом. Множество пользователей - это зараженные узлы (ниже определим это множество), а получатели транзакций - очевидно подставные пользователи (мулы).

Обратите внимание, что увидеть взаимосвязи явно можно только с определенным способом укладки вершин графа. ПРоэкспериментируйте с различными алгоритмами укладки!

Проанализируйте самостоятельно поведение пользователя PN_EU_2_154, убедитесь, что у него качественно другое поведение, хотя он достаточно активен в системе.

Сформируем множество потенциально зараженных узлов.

In [34]:
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']

Оценка точности предположения

Теперь оценим точность нашего предположения. Внимание! Это можно сделать только, имея размеченные данные. В вашей лабораторной работы таких сведений нет.

In [54]:
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
In [40]:
#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']

In [37]:
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

Вновь отрисуем граф контактов пользователей, чьи устройства заражены мобильной бот сетью.

In [33]:
#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()
Out[33]:
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
In [38]:
n = draw_pyvis_network_for_df(df_selected, "Mobile botnet infection")
In [39]:
n.toggle_physics(False)
#можно настраивать силы притяженяи и отталкивания
n.show_buttons(filter_=['physics'])
n.show("PN_EU_0_955.html")
PN_EU_0_955.html
Out[39]:

Кража телефона

Обнаружение подозрительных транзакций

Ниже представлено альтернативное решение поиска случае кражи телефона, основанные на анализе финансовых транзакций: фильтруются все транзакции за один день и формируются множество транзакций, сумма переводов которых меньше средней суммы за день.

Подготовка данных

Признаки кражи:

  • Сумма мошеннических операций меньше, чем средняя сумма пользователей
  • Мошенник пытается снять деньги несколько раз в течение короткого промежутка времени
In [66]:
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] 
In [67]:
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
In [68]:
theft_df, criminals = get_theft_df(df_day)
theft_df.head()
Out[68]:
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

In [69]:
G = create_network_for_df(theft_df)
In [70]:
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

In [17]:
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()
Out[17]:
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
In [18]:
n = draw_pyvis_network_for_df(df_selected, "Mobile phone theft ")
In [19]:
n.toggle_physics(False)
#можно настраивать силы притяженяи и отталкивания
n.show_buttons(filter_=['physics'])
n.show("'PN_EU_0_77'.html")
'PN_EU_0_77'.html
Out[19]:

Что мы увидели на этом графе: пользователь PN_EU_0_77 обращается к 2 агентам для пополнения электронного кошелька, а к 3 агентам для снятия электронных денег (при чем к 2 агентам Ret1 и Ret 2 только для снятия). По сути мы не можем на основании этих данных делать какие либо выводы. Но предлагаю просмотреть логи транзакций между пользователем и этими агентами, оценить частоту (а заодно посмотреть на groundtruth).

In [7]:
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()
Out[7]:
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. Давайте предположим, что именно они являются жертвами кражи. (частоту транзакций не учитывам тут.)

In [16]:
# 
df_suspected_thefts = df.loc[((df["Amount of transaction"] < 10000) & (df['Type of transaction']=='Wl'))]
df_suspected_thefts.head()
Out[16]:
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
In [12]:
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). Предлагаю, продумать, как учесть данный параметр. Доработайте!

Тем не менее посмотрим график транзакций, которые потенциально могут быть опасными.

In [103]:
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 И вы увидете как расположились узлы: по внешнему радиусы узлы, которые связаны только с одним агентом, внутри круга - мулы из сценария заражением ботнетом, а вот небольшие группы узлов рядом с агентами как раз и есть пользователи, у которых были украдены телефоны.

In [56]:
n = draw_pyvis_network_for_df(df_suspected_thefts, "Mobile phone theft ")
In [57]:
n.toggle_physics(False)
#можно настраивать силы притяженяи и отталкивания
n.show_buttons(filter_=['physics'])
n.show("Mobile phone theft.html")
Mobile phone theft.html
Out[57]:

ДАвайте сформируем список пользователей, чьи телефоны могут быть украдены.

In [22]:
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']

Соглао нашим предположениям, у нас 137 жертв кражи телефона, но на самом деле их 61! Улучшите этот показатель, и приступайте к анализу вашего варианта.