Подключаем ONNX-модель скоринга email-адресов к HTTP API на TypeScript

—

от автора

Часть 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 installnpm 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.shnpm testMODEL_VERSION=email-pattern-v2 npm start

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

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