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

В прошлой статье я обучил CatBoost отличать локальные части email-адресов, придуманные человеком, от сгенерированных. На ivan.ivanov@gmail.com модель выдаёт около 0.99, на xnjjeka.2.e.2.2@gmail.com - ноль. Теперь её надо встроить в CleanContact. Загвоздка в том, что обучение живёт в Python, а бэкенд у меня на Node.js.

Модель я выгрузил в ONNX и подключил через onnxruntime-node. Весит она пару мегабайт, так что файл просто лежит в репозитории. Деревья CatBoost внутри графа - это оператор TreeEnsembleClassifier.

Самая основная сложность в том, что модель ждёт не строку с адресом, а набор векторов с признаками. Все признаки пришлось переписать на TypeScript, и это оказалось основной работой. Причем, ошибка в признаке ничего не ломает - инференс запускается, но скоринг получается уже другой. HTTP-часть по сравнению с этим напротив, заняла совсем немного кода. Тут ничего удивительного, поскольку любой современный HTTP фреймворк берет на себя всю рутину.

TypeScript без сборки

Стек: Node.js 24, Hono с @hono/node-server и onnxruntime-node. Тесты на встроенных node:test и node:assert. TypeScript нужен только для проверки типов через tsc --noEmit, ничего не компилируется.

Node.js умеет запускать напрямую .ts сам без компиляции - он просто вырезает аннотации типов. Но только их. enum, namespace с исполняемым кодом и parameter properties так не работают, их надо транслировать в JS, поэтому в проекте их нет.

{
  "compilerOptions": {
    "target": "es2024",
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "strict": true,
    "noEmit": true,
    "allowImportingTsExtensions": true,
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true
  }
}

erasableSyntaxOnly ругается, если в коде появится что-то из списка выше. verbatimModuleSyntax заставляет писать import type для типов, чтобы они не утекали в рантайм.

{
  "type": "module",
  "scripts": {
    "start": "node src/server.ts",
    "dev": "node --watch src/server.ts",
    "test": "node --test 'test/**/*.test.ts'",
    "typecheck": "tsc --noEmit"
  }
}

Что переносим из Python-проекта

Кроме ONNX-файла нужны метаданные, частоты n-грамм и словари имён. Экстрактор признаков я разбил на несколько модулей:

email-human-ml-ts/
- models/
-- email-pattern-v1.onnx
-- email-pattern-v1.meta.json
-- email-pattern-v1.ngrams.json
-- first_names.txt
-- last_names.txt
- scripts/
-- sync-model.sh
- src/
-- normalize.ts
-- features/
-- model.ts
-- app.ts
-- server.ts
- test/
-- fixtures/parity.json
-- features.test.ts
-- model.test.ts
-- app.test.ts

Чтобы вручную не носить файлы, артефакты копирует scripts/sync-model.sh. Порядок признаков, а заодно имена входа и выхода модели лежат в meta.json (список признаков тут обрезан).

SRC="${ML_REPO:-../email-validator-ml}"
VERSION="${MODEL_VERSION:-email-pattern-v1}"

cp "$SRC/models/$VERSION.onnx" "$SRC/models/$VERSION.meta.json" "$SRC/models/$VERSION.ngrams.json" models/
cp "$SRC/data/dictionaries/first_names.txt" "$SRC/data/dictionaries/last_names.txt" models/
cp "$SRC/tests/fixtures/parity.json" test/fixtures/parity.json
{
  "version": "email-pattern-v1",
  "feature_order": ["length", "letter_count", "digit_count", "...", "longest_keyboard_run"],
  "input": { "name": "features", "dtype": "float32", "shape": ["N", 38] },
  "output": { "name": "probabilities", "shape": ["N", 2], "score_column": 1 },
  "files": { "ngrams": "email-pattern-v1.ngrams.json" }
}

TypeScript берёт feature_order из этого файла. Если перепутать два столбца, модель ничего не заметит и молча начнёт выдавать ерунду. В parity.json Python-проект кладёт пробные и синтетические адреса, их признаки и скоры модели. По этому набору я и сверял TypeScript-версию.

Где разошлись Python и JavaScript

Сами формулы простые: длина, число букв и цифр, энтропия, частоты n-грамм, поиск имён по словарям. Проблемы начались в мелочах, где языки ведут себя по-разному.

Длина строки

В Python len() считает кодовые точки Unicode, а .length в JavaScript - единицы UTF-16. Для "😀" получится 1 и 2. Поэтому строку сначала разворачиваю в массив [...local]:

export function lexicalFeatures(local: string): Record<string, number> {
  // кодовые точки, как len() в Python
  const chars = [...local];
  const n = chars.length;
  const denom = Math.max(n, 1);
  const letters = chars.filter((c) => LETTERS.includes(c)).length;
  const digits = chars.filter((c) => DIGITS.includes(c)).length;
  const unique = new Set(chars).size;
  // ...
}

\d в регулярках

В Python \d ловит любые десятичные цифры Unicode, в JavaScript - только 0-9. Для has_year я пишу [0-9] явно. На экзотике вроде арабско-индийских цифр версии всё равно разойдутся, если в Python остался \d.

Ключи словаря

Частоты n-грамм приходят из JSON. У Python-словаря сюрпризов нет, а у обычного объекта в JS есть прототип: table["constructor"] вернёт функцию. Биграмм и триграмм с такими именами нет, но проверка через Object.hasOwn стоит копейки:

function stats(grams: string[], table: Record<string, number>, unknown: number, rare: number) {
  if (grams.length === 0) return { avg: 0, min: 0, rareRatio: 0 };
  const values = grams.map((g) => (Object.hasOwn(table, g) ? table[g]! : unknown));
  return {
    avg: values.reduce((a, b) => a + b, 0) / values.length,
    min: Math.min(...values),
    rareRatio: values.filter((v) => v < rare).length / values.length,
  };
}

Порядок сложения в энтропии

С плавающей точкой от порядка слагаемых могут поплыть младшие разряды. В Python частоты считает Counter, он хранит символы в порядке первого появления. Map в JS делает то же самое, так что порядок совпадает:

export function entropy(chars: string[]): number {
  if (chars.length === 0) return 0;
  const counts = new Map<string, number>();
  for (const ch of chars) counts.set(ch, (counts.get(ch) ?? 0) + 1);
  const n = chars.length;
  let sum = 0;
  for (const c of counts.values()) sum += (c / n) * Math.log2(c / n);
  return -sum;
}

И одна общая ловушка

Это уже не разница между языками, а грабли, на которые можно наступить в обоих. "0123456789".includes("") возвращает true, и в Python "" in "0123456789" тоже True. Если у пустой строки взять первый символ как "", признак starts_with_digit станет единицей. Поэтому отсутствующий символ у меня - это undefined:

export const isIn = (charset: string, ch: string | undefined): boolean =>
  ch !== undefined && charset.includes(ch);

starts_with_digit: Number(isIn(DIGITS, first))

Собираем вектор и сверяем с Python

Экстрактор отдаёт признаки по именам, а extract раскладывает их в порядке из метаданных. Если в meta.json есть признак, которого экстрактор не знает, падаем сразу:

export function extract(local: string, ctx: FeatureContext, featureOrder: string[]): number[] {
  const values = extractDict(local, ctx);
  return featureOrder.map((name) => {
    const v = values[name];
    if (v === undefined) throw new Error(`unknown feature in meta.json: ${name}`);
    return v;
  });
}

Эталонные значения в parity.json уже приведены к float32, поэтому результат из JS перед сравнением прогоняю через Math.fround:

test("feature vectors match Python fixtures", () => {
  for (const c of fixtures.cases) {
    const got = extract(c.local, ctx, fixtures.feature_order);
    got.forEach((value, i) => {
      assert.equal(
        Math.fround(value),
        c.features[i],
        `${c.email}: ${fixtures.feature_order[i]} ts=${value} py=${c.features[i]}`,
      );
    });
  }
});

На текущем наборе всё совпало один в один. Итоговый скоринг проверяет отдельный тест с допуском 1e-5.

Загрузка ONNX и пакетный скоринг

При экспорте пришлось поправить выход модели. В конвертации был ZipMap, который отдаёт вероятности списком словарей. Я убрал его в Python-экспорте, и теперь на выходе обычный тензор с двумя вероятностями на адрес. Номер нужной колонки лежит в meta.output.score_column.

Модель и словари грузятся один раз при старте. Адреса собираю в тензор [N, 38] и прогоняю одним вызовом session.run:

export async function loadModel(dir: string, version: string): Promise<Model> {
  const meta = JSON.parse(readFileSync(join(dir, `${version}.meta.json`), "utf8")) as ModelMeta;
  const session = await ort.InferenceSession.create(join(dir, `${meta.version}.onnx`));
  const ctx: FeatureContext = {
    vocab: loadVocab(join(dir, meta.files.ngrams)),
    names: loadNames(dir),
  };
  const width = meta.feature_order.length;

  async function score(emails: string[]): Promise<ScoreResult[]> {
    if (emails.length === 0) return [];
    const locals = emails.map((e) => splitEmail(e)[0]);
    const data = new Float32Array(emails.length * width);
    locals.forEach((local, row) => data.set(extract(local, ctx, meta.feature_order), row * width));
    const input = new ort.Tensor("float32", data, [emails.length, width]);
    const output = await session.run({ [meta.input.name]: input });
    const probs = output[meta.output.name]!.data as Float32Array;
    return emails.map((email, row) => ({
      email,
      local: locals[row]!,
      score: probs[row * 2 + meta.output.score_column]!,
    }));
  }

  return { meta, ctx, score };
}

HTTP API на Hono

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

export function createApp(model: Model, maxBatch = 1000) {
  const app = new Hono();

  app.get("/health", (c) =>
    c.json({ status: "ok", model: model.meta.version, features: model.meta.feature_order.length }),
  );

  app.get("/score", async (c) => {
    const email = c.req.query("email");
    if (!email) return c.json({ error: "query parameter 'email' is required" }, 400);
    const [result] = await model.score([email]);
    return c.json({ ...result!, score: round(result!.score) });
  });

  app.post("/score", async (c) => {
    const body = await c.req.json().catch(() => null);
    const emails: unknown = body?.emails;
    if (!Array.isArray(emails) || !emails.every((e) => typeof e === "string" && e !== "")) {
      return c.json({ error: "body must be {\"emails\": string[]}" }, 400);
    }
    if (emails.length > maxBatch) {
      return c.json({ error: `at most ${maxBatch} emails per request` }, 400);
    }
    const results = await model.score(emails);
    return c.json({
      model: model.meta.version,
      results: results.map((r) => ({ ...r, score: round(r.score) })),
    });
  });

  return app;
}

GET /score берёт один адрес, POST /score - массив, по умолчанию до 1000 штук. Валидацию написал руками прямо в маршрутах - тащить библиотеку схем ради двух проверок не хотелось.

В server.ts порт начинает слушаться только после загрузки модели:

const port = Number(process.env.PORT ?? 3000);
const modelDir = process.env.MODEL_DIR ?? "models";
const modelVersion = process.env.MODEL_VERSION ?? "email-pattern-v1";
const maxBatch = Number(process.env.MAX_BATCH ?? 1000);

const model = await loadModel(modelDir, modelVersion);
const app = createApp(model, maxBatch);

serve({ fetch: app.fetch, port }, (info) => {
  console.log(`${model.meta.version} listening on http://localhost:${info.port}`);
});

Запускаем:

npm install
npm start
# email-pattern-v1 listening on http://localhost:3000
curl localhost:3000/health
# {"status":"ok","model":"email-pattern-v1","features":38}

curl "localhost:3000/score?email=ivan.ivanov@gmail.com"
# {"email":"ivan.ivanov@gmail.com","local":"ivan.ivanov","score":0.9942}

curl -X POST localhost:3000/score \
  -H 'content-type: application/json' \
  -d '{"emails":["xnjjeka.2.e.2.2@gmail.com","kenji.tanaka@yahoo.co.jp","hans.mueller@gmx.de"]}'
# {"model":"email-pattern-v1","results":[
#   {"email":"xnjjeka.2.e.2.2@gmail.com","local":"xnjjeka.2.e.2.2","score":0},
#   {"email":"kenji.tanaka@yahoo.co.jp","local":"kenji.tanaka","score":0.994},
#   {"email":"hans.mueller@gmx.de","local":"hans.mueller","score":0.9883}]}

Скорость

Замерял на ноутбуке с Apple M4, только признаки и инференс, без HTTP.

Сценарий

Время

Адресов в секунду

Один адрес

0,09 мс

около 11 500

Батч из 1000 адресов

25 мс

около 40 000

Загрузка модели и словарей

170 мс

-

Батч даёт примерно в 3,5 раза больше адресов в секунду. И больше всего времени съедает не модель, а признаки: поиск имён перебирает подстроки по словарю примерно из 130 тысяч записей. Так что если ускорять, то его.

Обновление модели

Когда появится email-pattern-v2:

MODEL_VERSION=email-pattern-v2 sh scripts/sync-model.sh
npm test
MODEL_VERSION=email-pattern-v2 npm start

Если в новой версии поменялись формулы признаков, сначала правлю экстрактор - иначе parity-тесты просто не пройдут.