Учимся отличать реальные email адреса от сгенерированных с CatBoost

—

от автора

Проблема

Уже достаточно давно я разрабатываю и поддерживаю собственный сервис валидации Email адресов Cleancontact. Сервис позволяет проверить email адрес на предмет реального существования и таким образом повысить качество базы данных пользователей и снизить процент недоставок Email, что очень важно для репутации рассыльщика. Но что если к нам на сайт приходят злоумышленники, которые выполняют массовые регистрации с использованием реальных почт, на которые доходят письма. Тема антифрода достаточно богатая для обсуждения, и с фродом сталкивались так или иначе все владельцы сайтов, где из регистрации можно извлечь хоть какую-то выгоду. И даже без выгоды, фродеры могу выполнять массовые регистрации чисто из спортивного интереса. Капчи так или иначе обходятся, пулы IP используются для массовых регистраций с разных IP, и в итоге при разработке системы антифрода часто приходишь к скорингу: когда нельзя однозначно сказать это злоумышленник или нет, но можно посчитать определенную вероятность этого и отсеивать пользователей по некоторому порогу.

Злоумышленники и спам-боты регистрируются на сервисах клиентов с адресами вроде:

xnjjeka.2.e.1.1@gmail.comdhduwbsb.wu.166@gmail.coma.l.eli.c.sal.los.2.71@gmail.comxjd82jd91@gmail.com

Такие адреса генерируются скриптами. Самый массовый приём на Gmail: взять один реальный ящик alicsallos271@gmail.com и расставить в нём точки. Gmail игнорирует точки, поэтому письма приходят на один и тот же ящик, а для сервиса каждый вариант выглядит как новый пользователь. SMTP проверка на все варианты честно отвечает “ящик существует”, и в целом фродеры часто используют именно валидные email адреса, на которые доходят сообщения. Да, можно было ввести логическую проверку на количество точек, и отсекать адреса вида xnjjeka.2.e.1.1@gmail.com, но это было бы локальное решение одной проблемы, а мы хотим сделать что-то более общее.

Визуально мы легко видим разницу между ivan.ivanov.88@gmail.com и xnjjeka.2.e.1.1@gmail.com. И хочется, чтобы ее видел и сервис, и выдавал числовой скоринг: насколько адрес визуально “человеческий”.

Важное ограничение, которое обозначим сразу — скоринг не превращает адрес в не-валидный. Странные адреса бывают настоящими. Это дополнительный сигнал риска рядом с SMTP проверкой, MX проверкой и чёрными DEA списками.

Решение и стек

Обучать модель удобно на Python, а работать она у нас будет внутри Node.js бэкенда без отдельного Python-процесса. Поэтому разделим реализацию:

Python модель

  • берем CSV файл по результатам прошлых проверок

  • используем CatBoost

  • экспортируем полученную модель в ONNX

Node.js / TypeScript бэкенд

  • подключаем файл модели

  • выделям признаки идентично python

  • используем onnxruntime-node

  • подключаем скоринг в антифроде

Стек тренировочной части:

  • Python 3.12, зависимости через uv.

  • CatBoost https://catboost.ai/ — open source библиотека разработанная Яндексом. Использует усовершенствованный алгоритм градиентного бустинг. Выбран за нативный экспорт в ONNX и устойчивость к дефолтным гиперпараметрам. CatBoost часто показывает отличные результаты на стандартных настройках, то есть работает из коробки.

  • pandas и pyarrow для датасета, scikit-learn для метрик.

  • onnx и onnxruntime для экспорта и проверки паритета.

  • используем старый добрый ML без каких либо LLM, все гораздо проще, быстрее, модель тренируется очень быстро, и API отвечает максимально быстро

  • признаки должны быть только числовыми и воспроизводимыми один в один на TypeScript

Реализация на Python по шагам

Шаг 1. Нормализация признаков

Всё, что модель видит, это первая часть адреса до собачки в нижнем регистре. Домен специально не используем: иначе модель быстро выучит, что например gmail.com хороший, а некий условный mail.ru плохой, а мы хотим оценивать только написание.

def split_email(email: str) -> tuple[str, str]:  email = email.strip().lower()  if "@" not in email:    return email, ""  local, domain = email.rsplit("@", 1)  return local, domaindef tokenize(local: str) -> list[str]:  return [t for t in re.split(r"[._-]", local) if t]

Буквы это [a-z], гласные aeiou, токены делятся по . _ -, энтропия считается по log2. Эти же правила реализует TypeScript экстрактор.

Шаг 2. Лексические признаки

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

def entropy(text: str) -> float:  if not text:    return 0.0  n = len(text)  return -sum((c / n) * math.log2(c / n) for c in Counter(text).values())def longest_run(text: str, charset: str) -> int:  best = cur = 0  for ch in text:    cur = cur + 1 if ch in charset else 0    best = max(best, cur)  return bestdef lexical_features(local: str) -> dict[str, float]:  n = len(local)  denom = max(n, 1)  letters = sum(ch in LETTERS for ch in local)  digits = sum(ch in DIGITS for ch in local)  dots, dashes, underscores = local.count("."), local.count("-"), local.count("_")  digit_runs = _DIGIT_RUN.findall(local)  first, last = local[:1], local[-1:]  return {    "length": n,    "letter_count": letters,    "digit_count": digits,    "digit_ratio": digits / denom,    "dot_count": dots,    "dash_count": dashes,    "underscore_count": underscores,    "separator_ratio": (dots + dashes + underscores) / denom,    "starts_with_digit": int(first in DIGITS and first != ""),    "ends_with_digit": int(last in DIGITS and last != ""),    "starts_with_letter": int(first in LETTERS and first != ""),    "ends_with_letter": int(last in LETTERS and last != ""),    "longest_digit_run": longest_run(local, DIGITS),    "longest_letter_run": longest_run(local, LETTERS),    "unique_char_count": len(set(local)),    "unique_char_ratio": len(set(local)) / denom,    "entropy": entropy(local),    "repeated_char_count": sum(local[i] == local[i - 1] for i in range(1, n)),    "has_year": int(any(len(r) == 4 and YEAR_MIN <= int(r) <= YEAR_MAX for r in digit_runs)),    "has_4_digit_number": int(any(len(r) >= 4 for r in digit_runs)),  }

Шаг 3. Первая проблема — результат SMTP проверки не годятся как разметка

Поскольку я разрабатываю сервис валидации, то первой идеей было посмотреть корреляцию, связаны ли реальность (доставляемость сообщений) на этот адрес и его внешний вид. Результаты показали AUC=0.51 на тестовой выборке. То есть модель была не лучше случайного выбора.

Разбор данных показал почему. Несуществующие адреса выглядят по-человечески: pollinakrasnova@gmail.com, ivanbekov@icloud.com. Часто встречаются опечатки домена вроде @mai.ru. А адреса с тремя и более точками на Gmail существуют в 95% случаев, потому что это как раз варианты реальных ящиков. Модель, обученная на существовании, давала xnjjeka.2.e.1.1@gmail.com скоринг 0.94. Что в какой-то степени было правдой, ведь gmail dot trick используют с реальными ящиками, но для тренировки модели — бесполезно.

Поэтому адрес существует и адрес выглядит человеческим это разные задачи. И остановимся только на второй.

Шаг 4. Разметка для “человечности”: реальные и синтетические негативы

Для позитивов берем подтверждённые SMTP адреса, из которых убраны все dot-trick варианты.

def _is_dot_variant(df: pd.DataFrame) -> pd.Series:  gmail = df["domain"].isin(("gmail.com", "googlemail.com"))  alias_local = df["aliasEmail"].fillna("").map(lambda e: split_email(e)[0])  flagged = (df["suspicious"] == "True") | (df["gmailDotTrick"] == "True")  aliased = (alias_local != "") & (alias_local == df["local"].str.replace(".", ""))  dotted = df["local"].str.count(r"\.") >= 2  return gmail & (flagged | (aliased & dotted))

Реальные негативы: эти самые dot-trick варианты. Их в моей тестовой выборке было около 7 тысяч, но это одно семейство фейков. Если учить только на нём, модель запомнит “много точек = плохо” и ничего больше.

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

  • random_letters — kqzmwpdhx

  • random_alnum — xjd82jd92

  • shuffled — перемешиваем буквы адреса

  • dot_trick — реальный адрес с 2–5 случайными точками

  • consonant_mash — qmxjwp82

  • keyboard_walk — qwerty123, asdfgh

FAMILIES = {  "random_letters": 0.20,   # kqzmwpdhx  "random_alnum": 0.20,     # xjd82jd92, dhduwbsb.wu.177  "shuffled": 0.25,         # буквы реального адреса перемешаны  "dot_trick": 0.15,        # реальный адрес с 2–5 случайными точками  "consonant_mash": 0.10,   # qmxjwp82  "keyboard_walk": 0.10,    # qwerty123, asdfgh}def random_letters(rng: random.Random, pool: list[str]) -> str:  return _rand_str(rng, LETTERS, rng.randint(5, 14))def random_alnum(rng: random.Random, pool: list[str]) -> str:  parts = []  for _ in range(rng.randint(1, 3)):    alphabet = LETTERS + DIGITS if rng.random() < 0.7 else DIGITS    parts.append(_rand_str(rng, alphabet, rng.randint(1, 8)))  return rng.choice([".", "_", "-", ""]).join(parts)def shuffled(rng: random.Random, pool: list[str]) -> str:  for _ in range(20):    chars = list(rng.choice(pool))    if sum(c in LETTERS for c in chars) >= 6:      rng.shuffle(chars)      return "".join(chars)  return random_letters(rng, pool)def dot_trick(rng: random.Random, pool: list[str]) -> str:  base = rng.choice(pool).replace(".", "")  if len(base) < 4:    base = random_letters(rng, pool)  slots = sorted(rng.sample(range(1, len(base)), min(rng.randint(2, 5), len(base) - 1)))  out, prev = [], 0  for s in slots:    out.append(base[prev:s])    prev = s  out.append(base[prev:])  return ".".join(out)def consonant_mash(rng: random.Random, pool: list[str]) -> str:  n = rng.randint(5, 12)  alphabet = CONSONANTS if rng.random() < 0.6 else CONSONANTS + VOWELS[:2]  s = _rand_str(rng, alphabet, n)  return s + (_rand_str(rng, DIGITS, rng.randint(1, 3)) if rng.random() < 0.5 else "")def keyboard_walk(rng: random.Random, pool: list[str]) -> str:  parts = []  for _ in range(rng.randint(1, 3)):    row = rng.choice(KEYBOARD_ROWS)    start = rng.randint(0, len(row) - 3)    parts.append(row[start : start + rng.randint(3, len(row) - start)])  return "".join(parts)

Семейство shuffled особенно важно. Перемешанный ivanivanov сохраняет длину, долю цифр и энтропию оригинала, но ломает последовательности букв. Оно заставляет модель опираться на n-граммы, а не на статистику символов.

Синтетические негативы генерируются для каждого split отдельно и только из позитивов этого split.

Шаг 5. N-граммы

N-граммы используются, чтобы определить, насколько последовательности букв похожи на те, которые обычно встречаются в человеческих именах и email-адресах. Частоты буквенных биграмм и триграмм считаются по позитивам train выборки и сохраняются в JSON. Для адреса считается средняя и минимальная log-частота его n-грамм и доля редких.

def build_vocab(train_locals: list[str], rare_quantile: float = 0.1) -> NgramVocab:  bi, tri = Counter(), Counter()  for local in train_locals:    text = letters_only(local)    bi.update(ngrams(text, 2))    tri.update(ngrams(text, 3))  bi_table, bi_unknown = _logfreq_table(bi, sum(bi.values()))  tri_table, tri_unknown = _logfreq_table(tri, sum(tri.values()))  return NgramVocab(    bigram_logfreq=bi_table,    trigram_logfreq=tri_table,    rare_bigram_threshold=_quantile(list(bi_table.values()), rare_quantile),    rare_trigram_threshold=_quantile(list(tri_table.values()), rare_quantile),    unknown_bigram_logfreq=bi_unknown,    unknown_trigram_logfreq=tri_unknown,  )def _stats(grams, table, unknown, rare):  values = [table.get(g, unknown) for g in grams]  return (    sum(values) / len(values),    min(values),    sum(v < rare for v in values) / len(values),  )

В итоге rare_trigram_ratio оказался самым важным признаком модели, его доля около 30%. Для ivanov триграммы iva, van, ano, nov частые. Русскоязычные фамилии вообще достаточно однообразные внешне, и их легче опознавать. Для dhduwbsb почти все редкие.

Шаг 6. Проблема с иностранными именами

Наши данные на 80% состоят из русскоязычного транслита. Словарь n-грамм унаследовал это. hans.mueller@gmx.de получал 0.41, а kenji.tanaka@yahoo.co.jp 0.005. То есть японское имя для модели выглядело как случайная строка.

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

Сначала я добавил словари имён. Использовал два открытых источника: базу имён с разбивкой по 55 регионам, включая Россию, Китай, Индию и арабские страны, и базу фамилий из 20 стран. После нормализации в [a-z] получилось 44 тысячи имён и 86 тысяч фамилий. По ним считаются признаки: есть ли в адресе имя или фамилия, является ли первый токен именем, какую долю букв покрывает самое длинное найденное имя.

def name_features(local: str, names: NameDict) -> dict[str, float]:  segments = letter_segments(local)  letters = sum(len(s) for s in segments)  tokens = [t.strip("0123456789") for t in tokenize(local)]  is_name = lambda t: t in names.first or t in names.last  longest_first = _longest_match(segments, names.first)  longest_last = _longest_match(segments, names.last)  return {    "contains_first_name": int(longest_first > 0),    "contains_last_name": int(longest_last > 0),    "first_token_is_name": int(bool(tokens) and is_name(tokens[0])),    "name_token_count": sum(is_name(t) for t in tokens),    "name_char_ratio": max(longest_first, longest_last) / letters if letters else 0.0,  }

При этом эти же словари можно использовать на вход расчета n-граммы. Триграммы mue, ell, ken, nji перестали быть редкими. Только с name-признаками kenji.tanaka оставался на 0.005, а с триграммами поднялся до 0.99.

Заодно обнаружилась странная вещь. В списке фамилий были qwert, erty, yuiop. Из-за них qwerty123 получал 0.98 как “фамилия”. Это нужно фильтровать заранее, при подготовке данных словаря. Такие записи теперь фильтруются, а в модель добавлен признак longest_keyboard_run: самая длинная подстрока, совпадающая с рядом клавиатуры.

Шаг 7. Обучение модели

После подготовки признаков обучение через CatBoost очень компактное и занимает двадцать строк.

model = CatBoostClassifier(  iterations=1500,  depth=6,  learning_rate=0.05,  loss_function="Logloss",  eval_metric="AUC",  random_seed=42,)model.fit(  X["train"], y["train"],  eval_set=(X["valid"], y["valid"]),  early_stopping_rounds=300,)

Также был забавный момент с early stopping (когда качество долго не улучшается CatBoost может решить что дальше пробовать бессмысленно и останавливается). Из-за разбиения по времени реальные dot-trick негативы почти целиком попали в train, и валидационная кривая получилась немонотонной. Пик на 25-й итерации, провал, затем медленный рост. Увеличение early_stopping_rounds до 300 позволило CatBoost переждать этот провал и продолжить обучение. В итоге AUC оказался примерно на 0.02 выше.

При этом общего AUC здесь недостаточно. Стоит отдельно смотреть AUC на реальных негативах. Синтетические данные всё-таки синтетические (масло масляное), поэтому хорошая метрика на них ещё не означает, что модель нормально работает на реальном трафике. Также смотрим калибровку по децилям (а точнее по бинам 0-0.1 и 0.9-1.0) и проверяем важность признаков.

Итог для финальной модели на тестовой выборке:

показатель

значение

AUC общий

0.967

AUC на реальных dot-trick негативах

0.941

AUC на keyboard_walk

0.977

бин скоринга 0.0–0.1, доля позитивов

5.6%

бин скоринга 0.9–1.0, доля позитивов

98.8%

Требования к CSV-файлам

Тренировка работает на выгрузке из сервиса валидации email адресов:

колонка

назначение

email

адрес; строки с NULL пропускаются

status

Good / Bad / Unknown

detail

текст результата; позитив это Email address exists

createdAt

время проверки с таймзоной, по нему делается split

aliasEmail

каноническая форма адреса, если сервис её вычислил

gmailDotTrick

True / False

suspicious

True / False

Значения в кавычках, NULL как строка, булевы как True и False. Несколько файлов можно подавать вместе, в том числе из разных баз: дедупликация идёт по адресу, а createdAt приводится к UTC, поэтому разные таймзоны не мешают.

Опционально подаётся второй тип CSV с колонкой fakeReason: из него берутся строки, помеченные как alias-группы или сгенерированные, как дополнительные реальные негативы.

Запуск тренировки

uv sync# подключаем словари именuv run python -m email_ml.dictionaries# 2. готовим датасет. Разметка, дедупликация, split по времени, синтетические негативыuv run python -m email_ml.dataset \  --input data/raw/emails-*.csv \  --fake-csv data/raw/data-with-fake-reason.csv \  --output data/processed/naturalness.parquet# 3. обучение, набор признаков v4, словари в корпусе n-граммuv run python -m email_ml.train \  --dataset data/processed/naturalness.parquet \  --feature-set v4 --ngram-names \  --out models/nat-v4n

train печатает AUC по каждому типу негативов, калибровку, важность признаков и скоринг пробного списка из 23 адресов, а рядом с моделью сохраняет nat-v4n.train.json с теми же цифрами и nat-v4n.ngrams.json со словарём n-грамм.

Наборы признаков версионированы: v1 лексика, v2 плюс структура, v3 плюс n-граммы, v4 плюс имена и клавиатурные ряды. Это позволяет повторить всю историю экспериментов одной сменой флага.

Проверка на новых адресах

Теперь, когда модель готова, хочется проверить а как она вообще работает. Получилось ли хоть что-то полезное? Для этого вызовем ее через uv run python -m email_ml.predict

uv run python -m email_ml.predict ivan.ivanov@gmail.com xnjjeka.2.e.1.1@gmail.com dmitry@bask.ws stanislav85@mail.ru vmifvhbgti@rambler.ru satoshi.nakamoto@mail.com geohot@icloud.me mafiaboy@yahoo.com 0.994  ivan.ivanov@gmail.com0.000  xnjjeka.2.e.1.1@gmail.com0.943  dmitry@bask.ws1.000  stanislav85@mail.ru0.000  vmifvhbgti@rambler.ru0.998  satoshi.nakamoto@mail.com0.206  geohot@icloud.me0.812  mafiaboy@yahoo.com

Экспорт в ONNX

Далее мы хотим подключить модель в Typescript, а для этого нам нужно выгрузить ее в ONNX

uv run python -m email_ml.export --model models/nat-v4n.cbm --out models/email-pattern-v1uv run pytest tests/test_onnx_parity.pyuv run python -m email_ml.predict --model models/email-pattern-v1.onnx ivan.ivanov@gmail.com

CatBoost экспортирует в ONNX одной строкой, но с подвохом. В граф добавляется оператор ZipMap, и выход probabilities получает тип seq(map(int64, float)). JavaScript API onnxruntime-node работает только с тензорами. Поэтому экспорт вырезает ZipMap и выставляет наружу сырой тензор вероятностей [N, 2]:

def strip_zipmap(model: onnx.ModelProto) -> onnx.ModelProto:  graph = model.graph  zipmap = next((n for n in graph.node if n.op_type == "ZipMap"), None)  if zipmap is None:    return model  tree = next(n for n in graph.node if zipmap.input[0] in n.output)  graph.node.remove(zipmap)  for out in list(graph.output):    if out.name == zipmap.output[0]:      graph.output.remove(out)  tree.output[list(tree.output).index(zipmap.input[0])] = "probabilities"  graph.output.append(    helper.make_tensor_value_info("probabilities", TensorProto.FLOAT, ["N", 2])  )  onnx.checker.check_model(model)  return model

Экспорт создаёт четыре артефакта:

  • email-pattern-v1.onnx

  • email-pattern-v1.meta.json — порядок признаков, имена входа и выхода, пути к словарям, метрики. TypeScript читает порядок признаков отсюда и не хардкодит его.

  • email-pattern-v1.ngrams.json — словарь n-грамм.

  • tests/fixtures/parity.json — мы хотим убедиться, что наш будущий TS код коррекно воспроизводит векторы. Поэтому сюда кладем адреса с векторами признаков и скорингом. TypeScript-экстрактор обязан воспроизвести векторы точно, а скоринг с точностью до пятого знака.

Экспорт сам сверяет onnxruntime с CatBoost и падает при расхождении больше 1e-5.

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

Исходный код проекта https://github.com/baskakov/email-human-ml/

ссылка на оригинал статьи https://habr.com/ru/articles/1091696/