В этой статье мы рассмотрим простую задачу, которая используется одной компанией в качестве тестового задания для стажеров на позицию ML-engineer. Она включает обнаружение DGA-доменов — задача, решаемая с помощью базовых инструментов машинного обучения. Мы покажем, как с ней справиться, применяя самые простые методы. Знание сложных алгоритмов важно, но куда важнее — понимать базовые концепции и уметь применять их на практике, чтобы успешно демонстрировать свои навыки.
DGA (Domain Generation Algorithm) — это алгоритм, который автоматически генерирует доменные имена, часто используемые злоумышленниками для обхода блокировок и связи с командными серверами.
В техничесокм задании присутствовали тестовые данные, для которых нужно было сформировать предсказания, и валидационные данные, на которых нужно было продемонстрировать метрики в формате:
True Positive (TP):
False Positive (FP):
False Negative (FN):
True Negative (TN):
Accuracy:
Precision:
Recall:
F1 Score:
Иногда компании не предоставляют тренировочные данные и хотят оценить, насколько вы способны самостоятельно находить решения. Это включает:
Понимание проблемы: Четкое формулирование задачи.
Методология: Разработка плана действий и выбор методов.
Критическое мышление: Анализ данных и выдвижение гипотез.
Практические навыки: Применение базовых концепций машинного обучения.
Важно продемонстрировать инициативу и способность работать с ограниченной информацией. В нашем случае, домены существующих компаний можно найти на kaggle, а несуществующие домены нам необходимо будет сгенерировать самим.
Качественные и разнообразные данные позволяют алгоритмам выявлять закономерности, делать предсказания и принимать обоснованные решения. Поэтому без хороших данных невозможно достичь успешных результатов в машинном обучении. Важно создать качественные данные для обучения модели, чтобы обеспечить её эффективность и точность. Нам необходимо сосредоточиться на создании таких данных:
-
Напишем функции для генерации случайных строк и доменных имён. Функция
generate_random_string
генерирует строку заданной длины с буквами и, опционально, цифрами. Функцияgenerate_domain_names
создает список доменных имён с различными паттернами.def generate_random_string(length, use_digits=True): """ Генерирует случайную строку заданной длины, включающую буквы и опционально цифры. :param length: Длина строки :param use_digits: Включать ли цифры в строку :return: Случайная строка """ characters = string.ascii_lowercase if use_digits: characters += string.digits return ''.join(random.choice(characters) for _ in range(length)) def generate_domain_names(count): """ Генерирует список доменных имён с различными паттернами и TLD. :param count: Количество доменных имён для генерации :return: Список сгенерированных доменных имён """ tlds = ['.com', '.ru', '.net', '.org', '.de', '.edu', '.gov', '.io', '.shop', '.co', '.nl', '.fr', '.space', '.online', '.top', '.info'] def generate_domain_name(): tld = random.choice(tlds) patterns = [ lambda: generate_random_string(random.randint(5, 10), use_digits=False) + '-' + generate_random_string(random.randint(5, 10), use_digits=False), lambda: generate_random_string(random.randint(8, 12), use_digits=False), lambda: generate_random_string(random.randint(5, 7), use_digits=False) + '-' + generate_random_string(random.randint(2, 4), use_digits=True), lambda: generate_random_string(random.randint(4, 6), use_digits=False) + generate_random_string(random.randint(3, 5), use_digits=False), lambda: generate_random_string(random.randint(3, 5), use_digits=False) + '-' + generate_random_string(random.randint(3, 5), use_digits=False), ] domain_pattern = random.choice(patterns) return domain_pattern() + tld domain_list = [generate_domain_name() for _ in range(count)] return domain_list
-
Код загружает три CSV-файла, обрабатывает данные, удаляя столбец '1' и добавляя 'is_dga' со значением 0. Генерирует 1 миллион DGA-доменных имён, объединяет их с
part_df
и перемешивает итоговый DataFrame.try: logging.info('Загрузка данных') part_df = pd.read_csv('top-1m.csv') df_val = pd.read_csv('val_df.csv') df_test = pd.read_csv('test_df.csv') logging.info('Данные успешно загружены.') except Exception as e: logging.error(f'Ошибка при загрузке данных: {e}') logging.info('Обработка данных') part_df = part_df.drop('1', axis=1) part_df.rename(columns={'google.com': 'domain'}, inplace=True) part_df['is_dga'] = 0 list_dga = df_val[df_val.is_dga == 1].domain.tolist() generated_domains = generate_domain_names(1000000) part_df_dga = pd.DataFrame({ 'domain': generated_domains, 'is_dga': [1] * len(generated_domains) }) df = pd.concat([part_df, part_df_dga], ignore_index=True) df = df.sample(frac=1).reset_index(drop=True)
-
Исключаем домены из валидационного и тестового наборов, затем балансируем классы, выбирая по 500,000 примеров для каждого из них. Итоговый сбалансированный набор перемешивается и сбрасывает индексы
# Исключение доменов из валидационного и тестового наборов train_set = set(df.domain.tolist()) val_set = set(df_val.domain.tolist()) test_set = set(df_test.domain.tolist()) intersection_val = train_set.intersection(val_set) intersection_test = train_set.intersection(test_set) if intersection_val or intersection_test: df = df[~df['domain'].isin(intersection_val | intersection_test)] # Балансировка классов до одинакового числа примеров logging.info('Балансировка классов') df_train_0 = df[df['is_dga'] == 0] df_train_1 = df[df['is_dga'] == 1] num_samples_per_class = 500000 df_train_0_sampled = df_train_0.sample(n=num_samples_per_class, random_state=42) df_train_1_sampled = df_train_1.sample(n=num_samples_per_class, random_state=42) df_balanced = pd.concat([df_train_0_sampled, df_train_1_sampled]) df_train = df_balanced.sample(frac=1, random_state=42).reset_index(drop=True)
-
Создаем и обучаем модель, используя конвейер, который включает векторизацию с помощью
TfidfVectorizer
и логистическую регрессию. После обучения модель сохраняется в файлmodel_pipeline.pkl
logging.info('Создание и обучение модели') model_pipeline = Pipeline([ ("vectorizer", TfidfVectorizer(tokenizer=n_grams, token_pattern=None)), ("model", LogisticRegression(solver='saga', n_jobs=-1, random_state=12345)) ]) model_pipeline.fit(df_train['domain'], df_train['is_dga']) logging.info('Сохранение модели') joblib_file = "model_pipeline.pkl" joblib.dump(model_pipeline, joblib_file) logging.info(f'Модель сохранена в {joblib_file}')
Вся наша задача сводится к тому, что нам необходимо домены разбить на N-граммы и векторизовать их с помощью TF-IDF. N-грамма — это последовательность из N элементов (слов или символов) в тексте, но в нашей задаче мы применяем их к одному слову, чтобы выделять и анализировать слоги доменов. TF-IDF (Term Frequency-Inverse Document Frequency) — это метод, который помогает оценить важность слова в документе по сравнению с другими документами в коллекции.
Таким образом, комбинируя N-граммы и TF-IDF, мы можем эффективно анализировать домены и выявлять их ключевые характеристики. Рассмотрим на примере существующих доменов: texosmotr-auto.ru
и pokerdomru.ru
, разобьем их на 4-граммы, не беря во внимание родовой домен (.ru
)
Для
texosmotr-auto.ru
: "texo", "exos", "xosm", "osmo", "smot", "motr", "otr-", "r-au", "-aut", "auto"Для
pokerdomru.ru
: "poke", "oker", "kerd", "erdo", "domr", "omru"
Мы рассмотрели 4-граммы, но разве для всех доменов необходимо использовать фиксированные N-граммы? Конечно, нет. Для каждого домена создаются 3-мерные, 4-мерные и 5-мерные граммы, чтобы выявить различные языковые паттерны и особенности структуры. Такой подход позволяет лучше захватывать контекст и увеличивает возможность обнаружения уникальных характеристик, которые могут быть полезны для классификации.
-
код для созднания 3-мерных, 4-мерных и 5-мерных грамм для домена
def n_grams(domain): """ Генерирует n-граммы для доменного имени. :param domain: Доменное имя :return: Список n-грамм """ grams_list = [] # Размеры n-грамм n = [3, 4, 5] domain = domain.split('.')[0] for count_n in n: for i in range(len(domain)): if len(domain[i: count_n + i]) == count_n: grams_list.append(domain[i: count_n + i]) return grams_list
Все полученные N-граммы необходимо векторизовать, и в этом нам поможет вышеупомянутый метод TF-IDF. Этот подход позволяет оценить важность каждой N-граммы в контексте доменов, преобразуя текстовые данные в числовую форму. Векторизация с помощью TF-IDF учитывает частоту встречаемости N-грамм в каждом домене и их редкость в общем наборе.
Финальным этапом необходимо обучить нашу модель. Вы можете использовать различные алгоритмы, которые улучшают вашу метрику, однако я выбрал классическую логистическую регрессию (LR), потому что она проста в реализации, хорошо интерпретируется и часто дает неплохие результаты, например я получил следующие метрики на валидационном наборе данных:
True Positive (TP):
4605 False Positive (FP):
479 False Negative (FN):
413 True Negative (TN):
4503 Accuracy:
0.9108 Precision:
0.9058 Recall:
0.9177 F1 Score:
0.9117
Таким образом, понимание базовых концепций, таких как N-граммы и TF-IDF, откроет перед вами возможности для решения прикладных задач и позволит уверенно заявить о себе на стажировках. Эти навыки станут крепкой основой для вашего профессионального роста в области машинного обучения и анализа данных.
PS: Код, отправленный на проверку в компанию, предоставившую это тестовое задание, находится здесь.