Пишем сервис инференса ML-модели на go, на примере BERT-а

Привет, на связи команда аналитиков Х5 Tech. В статье пишем сервис инференс ML-NLP модели на go. Допустим, вам нужно внедрить ML-модель (разработанную/обученную на Рython-фреймворке) в сервис в вашей инфраструктуре. По какой-то причине (не важно какой) этот сервис должен быть на golang-е. Здесь покажем, как это можно сделать, используя ONNX.

Если вы это читаете, то, вероятно, или вы знакомы с обучением ML-моделей на Рython, библиотекой моделей huggingface, языковыми моделями BERT, или вы являетесь бэкенд разработчиком на golang.

В качестве примера будем использовать модель из библиотеки huggingface seara/rubert-tiny2-russian-sentiment, которая классифицирует сантимент текста.

Пара фраз про ONNX

Вот Гугл перевод с официального сайта.

Open Neural Network Exchange — это открытый формат, созданный для представления моделей машинного обучения. ONNX определяет общий набор операторов — строительных блоков моделей машинного и глубокого обучения — и общий формат файлов, позволяющий разработчикам ИИ использовать модели с различными платформами, инструментами, средами выполнения и компиляторами.

45bed38be1f416d685f6a51ce189bc89.png

С ONNX на стадии деплоя пропадает зависимость от фреймворка модели. Сервис, выдающий предсказания, должен теперь уметь работать с сетками, сохранёнными в формате ONNX, какой при этом модель может быть как Яндексовый Catboost или sbert из библиотеки хагингфейса или какое-нибудь каффе2.

Постановка задачи

Есть модель классификации из текстов rubert-tiny2-russian-sentiment  на 3 класса:

0: neutral
1: positive
2: negative

Есть чудесный код на Python для получений предсказаний принадлежности комментария к классу.

from transformers import AutoTokenizer, AutoModelForSequenceClassification

model_name = "seara/rubert-tiny2-russian-sentiment"

tokenizer = AutoTokenizer.from_pretrained(model_name)
model = AutoModelForSequenceClassification.from_pretrained(model_name)

input_text = ["Привет, ты мне нравишься!", "Ах ты черт мерзкий"]
tokenized_text = tokenizer(
	input_text,
	return_tensors="pt",
	padding=True,
	truncation=True,
	add_special_tokens=True,
)
outputs = model(**tokenized_text)
predicted = outputs.logits.softmax(-1)
print(predicted)



#[[0.0571, 0.9399, 0.0030],
# [0.1483, 0.1615, 0.6902]]

Хотим научиться писать не менее чудесный код получения предсказаний на go при помощи ONNX.

Что будем использовать

— github.com/yalue/onnxruntime_go
обёртка для microsoft/onnxruntime

— github.com/daulet/tokenizers
токенайзер будет нам кодировать текст в вектора

Ещё есть github.com/knights-analytics/hugot — реализация пайплайнов Huggingface на go. В README репы уже есть решение нашей задачи.

Пользоваться этим мы, конечно, не будем.

А всё напишем сами, чтобы разобраться.

Подготовка среды

— Для daulet/tokenizers нужно сбилдить бинарник libtokenizers.a и добавить путь до него в переменную окружения CGO_LDFLAGS или разместить её в /usr/lib/libtokenizers.a

# кладём бинарь для токенайзера /usr/lib/libtokenizers.a

RUN wget https://github.com/daulet/tokenizers/releases/download/v1.20.2/libtokenizers.linux-amd64.tar.gz \
	&& tar -C /usr/lib -xzf libtokenizers.linux-amd64.tar.gz

Для onnxruntime_go необходимо наличие libonnxruntime.so. Её скачаем отсюда https://github.com/microsoft/onnxruntime/releases/ и положим в путь /usr/lib/libonnxruntime.so

Всё вышесказанное в виде докерфайла:

ARG BUILD_PLATFORM=linux/amd64

FROM --platform=${BUILD_PLATFORM} golang:1.23.3-bullseye as runtime

WORKDIR /tmp
# кладём бинарь для onnxruntime в /usr/lib/libonnxruntime.so
RUN wget https://github.com/microsoft/onnxruntime/releases/download/v1.20.0/onnxruntime-linux-x64-1.20.0.tgz \
	&& tar -xzf onnxruntime-linux-x64-1.20.0.tgz \
	&& mv ./onnxruntime-linux-x64-1.20.0/lib/libonnxruntime.so.1.20.0 /usr/lib/libonnxruntime.so


# кладём бинарь для токенайзера /usr/lib/libtokenizers.a
RUN wget https://github.com/daulet/tokenizers/releases/download/v1.20.2/libtokenizers.linux-amd64.tar.gz \
	&& tar -C /usr/lib -xzf libtokenizers.linux-amd64.tar.gz

Экспорт модели в ONNX

По ссылке есть замечательный гайд как выгрузить модель из хаггингфейса

Нужна будет библиотека для экспорта:

pip install 'optimum[exporters]'

Далее загрузим модель в память. Сохраним в папку модель и токенайзер:

from optimum.onnxruntime import ORTModelForSequenceClassification
from transformers import AutoTokenizer

model_checkpoint = "seara/rubert-tiny2-russian-sentiment"

save_directory = "./data/rubert-tiny2-russian-sentiment-onnx"

# Load a model from transformers and export it to ONNX
ort_model = ORTModelForSequenceClassification.from_pretrained(
	model_checkpoint, export=True
)
tokenizer = AutoTokenizer.from_pretrained(model_checkpoint)

# Save the onnx model and tokenizer
ort_model.save_pretrained(save_directory)
tokenizer.save_pretrained(save_directory)

Получаем что-то вроде такого:

ls ./data/rubert-tiny2-russian-sentiment-onnx
-rw-r--r--  1 dmitrij  staff   922B Nov 23 00:03 config.json
-rw-r--r--  1 dmitrij  staff   111M Nov 23 00:03 model.onnx
-rw-r--r--  1 dmitrij  staff   695B Nov 23 00:03 special_tokens_map.json
-rw-r--r--  1 dmitrij  staff   2.3M Nov 23 00:03 tokenizer.json
-rw-r--r--  1 dmitrij  staff   1.3K Nov 23 00:03 tokenizer_config.json
-rw-r--r--  1 dmitrij  staff   1.0M Nov 23 00:03 vocab.txt

— model.onnx — бинарник модели. Внезапно самый большой файл в папке. Путь до него будет указываться как путь до модели в go.
— tokenizer.json — словарь токенов, содержит также конфигурационную информацию (паддинги, специальные токены и прочее). Путь до него будем сообщать токенайзеру.

Параметры модели вход/выход

Как узнать из питона

Далее небольшой кусочек кода, чтобы посмотреть названия входных слоёв и выходных:

from onnx import load, shape_inference

model = load("./data/rubert-tiny2-russian-sentiment-onnx/model.onnx")

inferred_model = shape_inference.infer_shapes(model)
print(inferred_model.graph.output)

output =[node.name for node in model.graph.output]

input_all = [node.name for node in model.graph.input]
input_initializer =  [node.name for node in model.graph.initializer]
net_feed_input = list(set(input_all)  - set(input_initializer))

print('Inputs: ', net_feed_input)
#Inputs:  ['attention_mask', 'input_ids', 'token_type_ids']

print('Outputs: ', output)
# Outputs:  ['logits']

print(model.graph.output[0])
'''
name: "logits"
type {
  tensor_type {
	elem_type: 1
	shape {
  	dim {
    	dim_param: "batch_size"
  	}
  	dim {
    	dim_value: 3
  	}
	}
  }
}
'''

Фиксируем, что:
— входные слои 3 штуки: input_ids, attention_mask, token_type_ids
— выходной слой logits размером 3

Метод в ГО

Ту же самую информацию можно получить в go, используя GetInputOutputInfo. Здесь мы узнаем ещё и тип данных, который ожидается на вход. В данном примере — INT64.

import (
	"fmt"
	ort "github.com/yalue/onnxruntime_go"
)

    ...

	input, output, err := ort.GetInputOutputInfo("/data/rubert-tiny2-russian-sentiment-onnx/model.onnx")
	fmt.Println(input)
	// [{input_ids ONNX_TYPE_TENSOR [-1 -1] ONNX_TENSOR_ELEMENT_DATA_TYPE_INT64} {attention_mask ONNX_TYPE_TENSOR [-1 -1] ONNX_TENSOR_ELEMENT_DATA_TYPE_INT64} {token_type_ids ONNX_TYPE_TENSOR [-1 -1] ONNX_TENSOR_ELEMENT_DATA_TYPE_INT64}]
    
	fmt.Println(output)
	// [{logits ONNX_TYPE_TENSOR [-1 3] ONNX_TENSOR_ELEMENT_DATA_TYPE_FLOAT}]

Эта информация нам понадобится при настройке входных/выходных параметров в модели в go.

Пишем вызов токенайзера

Проинициализируем объект токенайзера, указав путь до tokenizer.json, который ранее получили при экспорте.

package main

import (
	"fmt"
	"github.com/daulet/tokenizers"
)

func main() {

	text := "Привет, ты мне нравишься!"
	tk, err := tokenizers.FromFile("/data/rubert-tiny2-russian-sentiment-onnx/tokenizer.json")
	if err != nil {
    	panic(err)
	}
	defer tk.Close()

Далее настроим токенайзер. Наша модель ждёт три слоя:   input_ids, attention_mask, token_type_ids. В EncodeOption укажем, что нужно вернуть attention_mask и token_type_ids. Также выставим addSpecialTokens=true аналогично с кодом на Питоне.

	encodeOptions := []tokenizers.EncodeOption{
    	tokenizers.WithReturnTypeIDs(),
    	tokenizers.WithReturnAttentionMask(),
	}
	// здесь 2ой параметр addSpecialTokens=true
	encodingResponse := tk.EncodeWithOptions(text, true, encodeOptions...)

	fmt.Printf("IDs=%v\n", encodingResponse.IDs)
	fmt.Printf("AttentionMask=%v\n", encodingResponse.AttentionMask)
	fmt.Printf("TypeIDs=%v\n", encodingResponse.TypeIDs)
	// IDs=[2 51343 16 23101 20284 30100 64940 5 3]
	// AttentionMask=[1 1 1 1 1 1 1 1 1]
	// TypeIDs=[0 0 0 0 0 0 0 0 0]

Получили токены для нашего текста и сравним их с тем, что выдаёт аналогичный код на Питоне.

In []: : input_text = "Привет, ты мне нравишься!"
	...: tokenized_text = tokenizer(
	...: 	input_text,
	...: 	return_tensors="pt",
	...: 	padding=True,
	...: 	truncation=True,
	...: 	add_special_tokens=True,
	...: )
	...: print(tokenized_text)
{'input_ids': tensor([[	2, 51343,	16, 23101, 20284, 30100, 64940, 	5, 	3]]), 'token_type_ids': tensor([[0, 0, 0, 0, 0, 0, 0, 0, 0]]), 'attention_mask': tensor([[1, 1, 1, 1, 1, 1, 1, 1, 1]])}

Ура! Значения совпадают. Едем дальше.

Инициализация модели и предикт

По дефолту libonnxruntime.so ищется в /usr/lib/, но на всякий случай явно укажем.

	ort.SetSharedLibraryPath("/usr/lib/libonnxruntime.so")

	err = ort.InitializeEnvironment()
	if err != nil {
    	panic(err)
	}
	defer ort.DestroyEnvironment()

Далее создадим сессию.

func NewDynamicAdvancedSession(onnxFilePath string, inputNames,
	outputNames []string, options *SessionOptions) (*DynamicAdvancedSession,
	error)

— onnxFilePath — путь до нашей модели с расширением .onnx
— inputNames, outputNames — названия входных и выходных слоев соответственно

Получается примерно такое:

	session, err := ort.NewDynamicAdvancedSession(
    	"/data/rubert-tiny2-russian-sentiment-onnx/model.onnx",
    	[]string{"input_ids", "token_type_ids", "attention_mask"},
    	[]string{"logits"},
    	nil,
	)
	if err != nil {
    	panic(err)
	}
	defer session.Destroy()

Далее для получение прогноза нужно передать в сессию наши токены. Для этого нам понадобится небольшая функция, которая приведёт массив, полученный из токенайзера, к нужному типу данных для модели []uint32 → ort.Tensor[int64]

func makeTensor(x []uint32) *ort.Tensor[int64] {
	inputShape := ort.NewShape(1, int64(len(x)))
	inputData := make([]int64, len(x))
	for i, v := range x {
    	inputData[i] = int64(v)
	}

	inputTensor, err := ort.NewTensor(inputShape, inputData)
	if err != nil {
    	panic(err)
	}
	return inputTensor
}

Также нам потребуется создать выходной тензор для получения прогноза модели, куда будут записаны значения из слоя logits c размерностью (n x 3).

	outputTensor, err := ort.NewEmptyTensor[float32](ort.NewShape(1, 3))
	defer outputTensor.Destroy()
	if err != nil {
    	panic(err)
	}

Получения предсказания модели выглядят так:

	err = session.Run([]ort.Value{
    	makeTensor(encodingResponse.IDs),
    	makeTensor(encodingResponse.TypeIDs),
    	makeTensor(encodingResponse.AttentionMask),
	}, []ort.Value{outputTensor})
	if err != nil {
    	panic(err)
	}

	outputData := outputTensor.GetData()
	fmt.Println(outputData)
	// [-0.010913536 2.7902415 -2.9452484]

Сравним с тем, что выдаёт нам аналогичный код на Питоне:

outputs = model(**tokenized_text)
print(outputs.logits)
# tensor([[-0.0109,  2.7902, -2.9452]], grad_fn=)

Аутпуты совпадают. Ура!

Полную версию скриптов можно глянуть в репе:
https://github.com/dsyubaev/onnx_bert/tree/main

Итого

Мы научились на примере языковой модели BERT:
— делать экспорт модели в ONNX;
— смотреть мета-информацию модели о её входах и выходах;
— написали программу на go получения предсказаний модели.

Теперь вы можете писать сервисы на golang, которые работают с ML-моделями, поддерживающими импорт/экспорт в ONNX формате.

© Habrahabr.ru