Часть 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-тесты просто не пройдут.

