Проект выполнила Мария Еремина
На данный момент существует множество онлайн-сервисов для определения подозрительных и опасных сайтов, достаточно лишь вбить адрес сайта в строку поиска. Большинство таких сервисов обращаются в крупные базы черных списков: Яндекс, VirusTotal, Роскомнадзор (да-да, там тоже ищут), Google, из которых в каждом более 500000 адресов. Другие же сервисы дополнительно сканируют сам сайт на вирусы, взлом и недобросовестную рекламу, а также проводят поведенческий анализ, имитируя пользователей. Но проблема в том, что мошеннические URL генерируются каждый день, и пока такой сайт "дойдет" до блэк-листов, он может навредить не одному пользователю, а проверка на вредоносный код - рисковая и технически затратная процедура.
Довольно часто можно визуально определить ссылку на подозрительный сайт - домен может быть слишком длинным, содержать множество несвязанных символов и т.д. Попробуем решить задачу определения опасного сайта только по его URL.
Данные взяты из репозитория на Github. Автор собирал адреса из открытых списков опасных и безопасных сайтов.
Набор данных состоит из двух столбцов:
'url' - строка с адресом сайта (не содержит названия протокола)
'label' - значение целевой переменной, содержит метки 'good' и 'bad', определяющие безопасен сайт или нет.
Будем решать задачу бинарной классификации.
Для начала импортируем необходимые библиотеки:
import numpy as np
import pandas as pd
from scipy.sparse import *
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import train_test_split, cross_val_score, StratifiedKFold, validation_curve, GridSearchCV
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import roc_auc_score
from sklearn.feature_extraction.text import TfidfVectorizer
from matplotlib import pyplot as plt
import seaborn as sns
%matplotlib inline
# библиотеки для дальнейшей обработки URL
from urllib.parse import urlparse
import tldextract
import ipaddress as ip
import re, string
# игнорируем warnings
import warnings
warnings.filterwarnings("ignore")
from pylab import rcParams
rcParams['figure.figsize'] = 12, 9
rcParams['axes.titlesize'] = 16
sns.set(style='darkgrid', palette='deep', color_codes=True, font_scale=1.5)
Далее загрузим наши данные, посмотрим на размер набора и первые 10 строк.
data_df = pd.read_csv("data/data.csv")
print('Полная выборка содержит {} строк.'.format(data_df.shape[0]))
data_df.head(10)
Посмотрим, есть ли пропуски в данных и дублирующиеся строки. Если повторы есть, выведем число дублей, а затем удалим все, кроме первого вхождения.
count_duplicates = data_df.shape[0] - data_df.drop_duplicates().shape[0]
data_df = data_df.drop_duplicates().reset_index(drop=True)
print('Полная выборка содержит {} дублирующихся строк.'.format(count_duplicates))
print('Количество пропусков:')
data_df.isnull().sum()
Отлично, пропусков в данных нет, и мы удалили все повторы.
data_df['label'] = data_df['label'].map({'bad': 1, 'good': 0})
Посмотрим на распределение целевого класса label
.
data_df['label'].value_counts()
Видим несбалансированность классов - "плохих" строк в 5 раз меньше, чем "хороших".
Также на этом этапе выполним небольшую предобработку - поделим все наши данные на обучающую и тестовую выборки - 70% и 30% данных соответственно. Используем для этого метод train_test_split
со следующими параметрами:
train, test = train_test_split(data_df, random_state = 17,
test_size = 0.3, shuffle = True,
stratify = data_df['label'])
print('Тренировочная выборка:')
print('Класс 0 - {}, класс 1 - {}.'.format(train['label'].value_counts()[0], train['label'].value_counts()[1]))
print('Тестовая выборка:')
print('Класс 0 - {}, класс 1 - {}.'.format(test['label'].value_counts()[0], test['label'].value_counts()[1]))
Так как на данном этапе у нас всего лишь один признак - строка URL. Попробуем выделить из нее следующие вещи:
urlparse(train['url'].iloc[56])
tldextract.extract(train['url'].iloc[56])
# метод для выяснения, является ли строка IP-адресом
def is_ip_address(domain):
try:
if ip.ip_address(domain):
return 1
except:
return 0
# метод, принимающий строку и возвращающий количество элементов,
# разделенных точкой (количество субдоменов)
def count_subdomains(subdomain):
if subdomain.count('.') > 0:
return subdomain.count('.') + 1
elif len(subdomain) > 0:
return 1
else:
return 0
# заведем новый DataFrame и скопируем в него train
new_df = train.copy()
# заведем 2 столбца для хранения URL после парсинга
new_df['extract'] = new_df['url'].apply(tldextract.extract)
new_df['urlparse'] = new_df['url'].apply(urlparse)
# посчитаем длину домена и самого URL
new_df['url_length'] = new_df['url'].apply(len)
new_df['domain_length'] = new_df['extract'].apply(lambda x: len(x.domain))
# число цифр в домене
new_df['count_digits_in_domain'] = new_df['extract'].apply(lambda x: len(re.findall('(\d+)', x.domain)))
# число субдоменов
new_df['count_subdomains'] = new_df['extract'].apply(lambda x: count_subdomains(x.subdomain))
new_df['domain_is_ip'] = new_df['extract'].apply(lambda x: is_ip_address(x.domain))
# подсчитаем число символов '@', '/', '//'
new_df['count_slash'] = new_df['url'].apply(lambda x: x.count('/'))
new_df['count_d_slash'] = new_df['url'].apply(lambda x: x.count('//'))
new_df['count_@'] = new_df['url'].apply(lambda x: x.count('@'))
# есть ли query
new_df['have_query'] = new_df['urlparse'].apply(lambda x: 0 if x.query == '' else 1)
# буквы в верхнем регистре в домене
caps = re.compile('[A-Z]+')
new_df['have_caps'] = new_df['extract'].apply(lambda x: 1 if len(caps.findall(x.domain)) > 0 else 0)
sns.kdeplot(new_df[new_df['label'] == 1]['url_length'], shade=True, color='r')
sns.kdeplot(new_df[new_df['label'] == 0]['url_length'], shade=True)
plt.title("Длина URL", fontsize=15)
plt.legend(['Класс 1', 'Класс 0']);
sns.kdeplot(new_df[new_df['label'] == 1]['domain_length'], shade=True, color='r')
sns.kdeplot(new_df[new_df['label'] == 0]['domain_length'], shade=True)
plt.title("Длина домена", fontsize=15)
plt.legend(['Класс 1', 'Класс 0']);
sns.kdeplot(new_df[new_df['label'] == 1]['count_subdomains'], shade=True, color='r')
sns.kdeplot(new_df[new_df['label'] == 0]['count_subdomains'], shade=True)
plt.title("Количество субдоменов", fontsize=15)
plt.legend(['Класс 1', 'Класс 0']);
sns.kdeplot(new_df[new_df['label'] == 1]['count_digits_in_domain'], shade=True)
sns.kdeplot(new_df[new_df['label'] == 0]['count_digits_in_domain'], shade=True)
plt.title("Количество цифр в домене", fontsize=15)
plt.legend(['Класс 1', 'Класс 0']);
sns.kdeplot(new_df[new_df['label'] == 1]['count_slash'], shade=True, color='r')
sns.kdeplot(new_df[new_df['label'] == 0]['count_slash'], shade=True)
plt.title("Количество '/'", fontsize=15)
plt.legend(['Класс 1', 'Класс 0']);
sns.factorplot(x='label', y='count_d_slash', data=new_df, kind='bar', size=6)
plt.title("Количество перенаправлений ('//')", fontsize=15);
sns.factorplot(x='label', y='count_@', data=new_df, kind='bar', size=6)
plt.title("Количество символов @", fontsize=15);
sns.kdeplot(new_df[new_df['label'] == 1]['count_digits_in_domain'], shade=True, color='r')
sns.kdeplot(new_df[new_df['label'] == 0]['count_digits_in_domain'], shade=True)
plt.title("Количество цифр в домене", fontsize=15)
plt.legend(['Класс 1', 'Класс 0']);
sns.factorplot(x='label', y='domain_is_ip', data=new_df, kind='bar', size=6)
plt.title("Домен - IP", fontsize=15);
sns.factorplot(x='label', y='have_query', data=new_df, kind='bar', size=6)
plt.title("Наличие query", fontsize=15);
sns.factorplot(x='label', y='have_caps', data=new_df, kind='bar', size=6)
plt.title("Верхний регистр в домене", fontsize=15);
sns.stripplot(x=train['label'], y=train['url'].apply(len), size=8)
plt.title('URL length')
На графике видно, что чем больше URL, тем вероятнее, что он ведет на подозрительный сайт. Размер самой длинной строки - почти 1700 символов.
Для оценки качества алгоритма подойдет метрика ROC-AUC - площадь (Area Under Curve) под кривой ошибок (Receiver Operating Characteristic curve). Эта метрика не очень хорошо обобщается для многоклассовой классификации, а для бинарной классификации подходит. Также она зависит от предсказанных вероятностей классов, а не от «жёсткой» дискретной классификации, что хорошо для выборок с дисбалансом классов как в случае этой задачи.
В части 3 уже были рассмотрены 9 признаков и возможно было бы использовать деревья решений или их обобщения (случайный лес и градиентный бустинг). Но URL - текстовое поле, поэтому целесообразней преобразовать адреса при помощи Tfidf и обучить логистическую регрессию, а уже после добавлять выявленные признаки и смотреть на качество на кросс-валидации.Убедимся в этом на примере. Разделим new_df на обучающую и валидационную выборки в размере 70% и 30%, соблюдая стратификацию, выделим целевой признак, а затем обучим случайный лес, зафиксировав random_state
= 17. Оценим качество на валидационной выборке.
new_df.drop(['url', 'extract', 'urlparse'], axis=1, inplace=True)
X_train, X_valid, y_train, y_valid = train_test_split(new_df.drop('label', axis=1), new_df['label'],
test_size=0.3, random_state=17, stratify=new_df['label'])
tree = RandomForestClassifier(random_state=17).fit(X_train, y_train)
y_tree_pred = tree.predict(X_valid)
print('Случайный лес. ROC-AUC: {}'.format(roc_auc_score(y_valid, y_tree_pred)))
Теперь обучим логистическую регрессию на тех же данных. Среди фич есть неотмасштабированные признаки, что скажется на качестве.
logit = LogisticRegression(random_state=17).fit(X_train, y_train)
y_logit_pred = logit.predict(X_valid)
print('Логистическая регрессия на 9 признаках. ROC-AUC: {}'.format(roc_auc_score(y_valid, y_logit_pred)))
Как и ожидалось, результат чуть лучше случайного выбора. Преобразуем URL-адреса в матрицу Tfidf и повторим разделение данных, обучение и оценку качества логистической регрессии.
X_train, X_valid, y_train, y_valid = train_test_split(train['url'], new_df['label'],
test_size=0.3, random_state=17, stratify=new_df['label'])
# обучим Tfidf с униграммами на тренировочной выборке, а затем преобразуем тренировочную и валидационную выборки
tfidf = TfidfVectorizer(ngram_range=(1, 1), max_features=50000)
X_train_tfidf = tfidf.fit_transform(X_train)
X_valid_tfidf = tfidf.transform(X_valid)
logit = LogisticRegression(random_state=17).fit(X_train_tfidf, y_train)
y_tfidf_pred = logit.predict(X_valid_tfidf)
print('Логистическая регрессия на Tfidf. ROC-AUC: {}'.format(roc_auc_score(y_valid, y_tfidf_pred)))
Как видно, качество повысилось на 20% в сравнении со случайным лесом. И это без подбора гиперпараметров Tfidf и логистической регресиии.
Tfidf учитывает вклады слов, но не учитывает их порядок. Это не имеет значения в контексте предложения, но для URL порядок составных частей важен.
Поделим URL на 4 части - имя хоста, доменное имя, suffix и путь.
url_df = pd.DataFrame()
# парсим строку как и в предыдущий раз
url_df['extract'] = train['url'].apply(tldextract.extract)
url_df['urlparse'] = train['url'].apply(urlparse)
# достаем 4 текстовых признака
url_df['hostname'] = url_df['urlparse'].apply(lambda x: x.path.split('/')[0])
url_df['suffix'] = url_df['extract'].apply(lambda x: x.suffix.replace('.', ' '))
url_df['path'] = url_df['urlparse'].apply(lambda x: ''.join([s + ' ' for s in x.path.split('/')[1:]]).strip())
url_df['domain'] = url_df['extract'].apply(lambda x: x.domain)
Посмотрим первые 10 строк.
url_df.drop(['extract', 'urlparse'], axis=1).head(10)
Поделим выборки на обучающую и валидационную.
X_train, X_valid, y_train, y_valid = train_test_split(url_df.drop(['extract', 'urlparse'], axis=1),
new_df['label'],
test_size=0.3,
random_state=17,
stratify=new_df['label'])
Обучим на четырех получившихся признака обучающей выборки Tfidf и преобразуем валидационную, затем соберем все в sparse_matrix.
tfidf = TfidfVectorizer(ngram_range=(1, 1), max_features=50000)
train_1 = tfidf.fit_transform(X_train['hostname'])
valid_1 = tfidf.transform(X_valid['hostname'])
train_2 = tfidf.fit_transform(X_train['suffix'])
valid_2 = tfidf.transform(X_valid['suffix'])
train_3 = tfidf.fit_transform(X_train['path'])
valid_3 = tfidf.transform(X_valid['path'])
train_4 = tfidf.fit_transform(X_train['domain'])
valid_4 = tfidf.transform(X_valid['domain'])
X_train_sparse = csr_matrix(hstack([train_1, train_2, train_3, train_4]))
X_valid_sparse = csr_matrix(hstack([valid_1, valid_2, valid_3, valid_4]))
Обучим логистическую регрессию с параметрами по умолчанию и проверим качество.
logit = LogisticRegression(random_state=17).fit(X_train_sparse, y_train)
y_tfidf_4_pred = logit.predict(X_valid_sparse)
print('Логистическая регрессия на Tfidf с 4 признаками. ROC-AUC: {}'.format(roc_auc_score(y_valid, y_tfidf_4_pred)))
Качество повысилось.
Количество данных и распределение целевого класса в тренировочной выборке.
url_df.drop(['extract', 'urlparse'], axis=1, inplace=True)
train['label'].shape
train['label'].value_counts()
Также на этом этапе выделим отложенную выборку, отдадим ей 30% строк, и метки классов поместим в отдельную переменную.
X, y = url_df, train['label']
X_train, X_holdout, y_train, y_holdout = train_test_split(X, y, test_size=0.2,
random_state=17,
stratify=y)
Размеры отложенной и обучающей выборок после разбиения.
X_train.shape, X_holdout.shape
Заново обучим Tfidf на 4 признаках тренировочной выборки и соединим их в разреженные матрицы.
tfidf = TfidfVectorizer(ngram_range=(1, 1), max_features=50000)
train_1 = tfidf.fit_transform(X_train['hostname'])
holdout_1 = tfidf.transform(X_holdout['hostname'])
train_2 = tfidf.fit_transform(X_train['suffix'])
holdout_2 = tfidf.transform(X_holdout['suffix'])
train_3 = tfidf.fit_transform(X_train['path'])
holdout_3 = tfidf.transform(X_holdout['path'])
train_4 = tfidf.fit_transform(X_train['domain'])
holdout_4 = tfidf.transform(X_holdout['domain'])
X_train_sparse = csr_matrix(hstack([train_1, train_2, train_3, train_4]))
X_holdout_sparse = csr_matrix(hstack([holdout_1, holdout_2, holdout_3, holdout_4]))
При разбиении на фолды необходимо сохранить распределение классов. Это особенно важно, если в классах изначально есть сильный дисбаланс. Для это используем sklearn.model_selection.StratifiedKFold
. Разбиение будет на 3 фолда. Обязательно фиксируем значение seed для воспроизводимости результата.
skf = StratifiedKFold(random_state=17, n_splits=3, shuffle=True)
Для кросс-валидации подойдет метод cross_val_score
с параметрами X=X_train_sparse
, y=y_train
, cv=skf
, scoring='roc_auc'
.
Для настройки гиперпараметров понадобится GridSearchCV
. Подбирать будем параметр C
из np.logspace(-3, 3, 5)
- силу регуляризации, чем больше коэффициент, тем меньше регуляризация. Помимио этого, нужно будет выставить параметр class_weight
= 'balanced'. Random_state
оставим равным 17. Сначала проведем кросс-валидацию с параметрами по умолчанию, затем приступим к настройке, для сравнимости результатов.
np.mean(cross_val_score(logit, X=X_train_sparse, y=y_train, cv=skf, scoring='roc_auc'))
logit = LogisticRegression(random_state=17, class_weight='balanced')
params = {'C': np.logspace(-3, 3, 5)}
grid = GridSearchCV(logit, param_grid=params, cv=skf, scoring='roc_auc').fit(X_train_sparse, y_train)
print('Качество на кросс-валидации: {}'.format(grid.best_score_))
print('Оптимальное значение: {}'.format(grid.best_params_))
Видно яное переобучение, так как для проведения кросс-валидации было отобрано мало данных, а именно объектов с прогнозируемым классом (их вообще мало). Проверим качество на отложенной выборке.
logit = LogisticRegression(random_state=17, class_weight='balanced', C=31.0).fit(X_train_sparse, y_train)
roc_auc_score(y_holdout, logit.predict(X_holdout_sparse))
Настроим параметры ngram
= униграммы и биграммы, max_features
= range(50000, 100001, 10000). Качество посмотрим на отложенной выборке.
scores = []
for n in [1, 2]:
for max_feat in range(50000, 100001, 10000):
tfidf = TfidfVectorizer(ngram_range=(1, n), max_features=max_feat)
train_1 = tfidf.fit_transform(X_train['hostname'])
holdout_1 = tfidf.transform(X_holdout['hostname'])
train_2 = tfidf.fit_transform(X_train['suffix'])
holdout_2 = tfidf.transform(X_holdout['suffix'])
train_3 = tfidf.fit_transform(X_train['path'])
holdout_3 = tfidf.transform(X_holdout['path'])
train_4 = tfidf.fit_transform(X_train['domain'])
holdout_4 = tfidf.transform(X_holdout['domain'])
X_train_sparse_cv = csr_matrix(hstack([train_1, train_2, train_3, train_4]))
X_holdout_sparse_cv = csr_matrix(hstack([holdout_1, holdout_2, holdout_3, holdout_4]))
logit = LogisticRegression(random_state=17, class_weight='balanced', C=31.0).fit(X_train_sparse_cv, y_train)
scores.append(roc_auc_score(y_holdout, logit.predict(X_holdout_sparse_cv)))
scores
np.argmax(scores)
Лучшее значение было достигнуто с параметрами ngram
= (1, 2) и max_features
= 70000.
# метод для преобразования тренировочной и отложенной выборки
def to_tfidf(train_x, test_x):
tfidf = TfidfVectorizer(ngram_range=(1, 2), max_features=70000)
train_1 = tfidf.fit_transform(train_x['hostname'])
holdout_1 = tfidf.transform(test_x['hostname'])
train_2 = tfidf.fit_transform(train_x['suffix'])
holdout_2 = tfidf.transform(test_x['suffix'])
train_3 = tfidf.fit_transform(train_x['path'])
holdout_3 = tfidf.transform(test_x['path'])
train_4 = tfidf.fit_transform(train_x['domain'])
holdout_4 = tfidf.transform(test_x['domain'])
X_train_sparse_cv = csr_matrix(hstack([train_1, train_2, train_3, train_4]))
X_holdout_sparse_cv = csr_matrix(hstack([holdout_1, holdout_2, holdout_3, holdout_4]))
return X_train_sparse_cv, X_holdout_sparse_cv
Процесс создания и логическое обоснование приведено в части 3. Будем добавлять бинарные признаки по одному к нашей выборке.
X, y = pd.concat([url_df, new_df], axis=1), train['label']
X_train, X_holdout, y_train, y_holdout = train_test_split(X, y, test_size=0.2,
random_state=17,
stratify=y)
X_new_train, X_new_holdout = to_tfidf(X_train, X_holdout)
Метрика без новых признаков.
logit = LogisticRegression(random_state=17, class_weight='balanced', C=31.0).fit(X_train, y_train)
roc_auc_score(y_holdout, logit.predict(X_holdout))
Tfidf + Domain is IP
full_X_train = csr_matrix(hstack([X_new_train, X_train[['domain_is_ip']]]))
full_X_test = csr_matrix(hstack([X_new_holdout, X_holdout[['domain_is_ip']]]))
logit = LogisticRegression(random_state=17, class_weight='balanced', C=31.0).fit(full_X_train, y_train)
roc_auc_score(y_holdout, logit.predict(full_X_test))
Tfidf + Верхний регистр
full_X_train = csr_matrix(hstack([X_new_train, X_train[['have_caps']]]))
full_X_test = csr_matrix(hstack([X_new_holdout, X_holdout[['have_caps']]]))
logit = LogisticRegression(random_state=17, class_weight='balanced', C=31.0).fit(full_X_train, y_train)
roc_auc_score(y_holdout, logit.predict(full_X_test))
Tfidf + Наличие query в строке
full_X_train = csr_matrix(hstack([X_new_train, X_train[['have_query']]]))
full_X_test = csr_matrix(hstack([X_new_holdout, X_holdout[['have_query']]]))
logit = LogisticRegression(random_state=17, class_weight='balanced', C=31.0).fit(full_X_train, y_train)
roc_auc_score(y_holdout, logit.predict(full_X_test))
Можно заметить, что добавление новых признаков либо ухудшает модель, либо улучшает на десятитысячные доли, поэтому можно оставить так, как есть.
Для начала приведем тестовую и тренировочные выборки к нужному виду.
# преобразовываем train
train['extract'] = train['url'].apply(tldextract.extract)
train['urlparse'] = train['url'].apply(urlparse)
train['hostname'] = train['urlparse'].apply(lambda x: x.path.split('/')[0])
train['suffix'] = train['extract'].apply(lambda x: x.suffix.replace('.', ' '))
train['path'] = train['urlparse'].apply(lambda x: ''.join([s + ' ' for s in x.path.split('/')[1:]]).strip())
train['domain'] = train['extract'].apply(lambda x: x.domain)
y_train = train['label']
# преобразовываем test
test['extract'] = test['url'].apply(tldextract.extract)
test['urlparse'] = test['url'].apply(urlparse)
test['hostname'] = test['urlparse'].apply(lambda x: x.path.split('/')[0])
test['suffix'] = test['extract'].apply(lambda x: x.suffix.replace('.', ' '))
test['path'] = test['urlparse'].apply(lambda x: ''.join([s + ' ' for s in x.path.split('/')[1:]]).strip())
test['domain'] = test['extract'].apply(lambda x: x.domain)
y_test = test['label']
train, test = to_tfidf(train, test)
logit = LogisticRegression(random_state=17, class_weight='balanced', C=31.0).fit(train, y_train)
print('Результат ROC_AUC на тестовой выборке: {}'.format(roc_auc_score(y_test, logit.predict(test))))
Метрика ROC-AUC для данного алгоритма на тестововой выборке - 0.95. Скор на тесте выше, чем на отложенной выборке.
На кросс-валидации модель переобучилась, но показала хороший скор на тестовой выборке.
Что в дальнейшем может улучшить решение данной задачи: