Внедрение моделей TensorFlow с помощью ML.NET

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

Получить модель

Сначала мы собираемся пойти дальше и выбрать предварительно обученную модель. Существует несколько хороших источников предварительно обученных моделей, таких как Hugging Face и tfhub. Мы собираемся использовать модель tfhub, которая предсказывает достопримечательности Северной Америки по изображениям — https://tfhub.dev/google/on_device_vision/classifier/landmarks_classifier_north_america_V1/1.

Преобразуйте и оптимизируйте модель

Теоретически мы могли бы начать использовать модель как есть, но мы можем добиться большего. Модель TensorFlow непросто использовать на всех возможных платформах, таких как Linux, macOS и Windows, а также на всех архитектурах ЦП, таких как ARM64.

ONNX — это формат портативного представления моделей машинного обучения. Кроме того, модели ONNX можно легко оптимизировать и, таким образом, сделать их меньше и быстрее.

Самый простой способ преобразовать загруженную модель TensorFlow в модель ONNX — использовать инструмент tf2onnx из https://github.com/onnx/tensorflow-onnx.

Следуйте инструкциям по его установке (используйте контейнер разработки с Python 3.10, чтобы поддерживать чистоту вашего компьютера), а затем запустите эту команду:

python -m tf2onnx.convert --opset 16 --tflite lite-model_on_device_vision_classifier_landmarks_classifier_north_america_V1_1.tflite --output lite-model_on_device_vision_classifier_landmarks_classifier_north_america_V1_1.onnx

Теперь у вас должен быть файл с именем lite-model_on_device_vision_classifier_landmarks_classifier_north_america_V1_1.onnx, содержащий оптимизированную модель ONNX. Размер файла уменьшился с 50,9 МБ до 42,7 МБ. Хороший!

Если вы начали с модели ONNX, которую все еще хотите оптимизировать, вы можете использовать официальный инструмент оптимизатора ONNX https://github.com/onnx/optimizer.

Сделайте модель ONNX доступной для ML.NET

На этом этапе мы сообщаем ML.NET, каковы входные и выходные данные модели, и упаковываем модель таким образом, чтобы ML.NET мог с ней работать.

Входы и выходы

В документации уже сказано, что «входные данные должны представлять собой 3-канальные цветные изображения RGB размером 321 x 321, масштабированные до [0, 1]», а выходные данные — «вектор из 99424 оценок сходства».

Нам нужно узнать точные имена входных и выходных тензоров. Такой инструмент, как Нетрон, делает это очень простым. Откройте исходную модель .tflite и/или ONNX в Netron и нажмите кнопку Свойства модели в левом нижнем углу.

Для нашей модели входные данные называются uint8_image_input, а выходные — transpose_1. Мы примем это к сведению.

Модель пакета для ML.NET

Создайте новое консольное приложение, добавьте ссылки на эти пакеты и восстановите их.

<PackageReference Include="Microsoft.ML" Version="2.0.1" />
<PackageReference Include="Microsoft.ML.ImageAnalytics" Version="2.0.1" />
<PackageReference Include="Microsoft.ML.OnnxRuntime" Version="1.15.1" />
<PackageReference Include="Microsoft.ML.OnnxTransformer" Version="2.0.1" />

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

public static class LandmarkModelSettings
{
    public const string OnnxModelName = "lite-model_on_device_vision_classifier_landmarks_classifier_north_america_V1_1.onnx";
    public const string Input = "uint8_image_input";
    public const string Output = "transpose_1";

    public const string MlNetModelFileName = "landmark_classifier_onnx.zip";
    public const string LabelFileName = "landmarks_classifier_north_america_V1_label_map.csv";
}

public class LandmarkInput
{
    public const int ImageWidth = 321;
    public const int ImageHeight = 321;

    public LandmarkInput(Stream imagesStream)
    {
        Image = MLImage.CreateFromStream(imagesStream);
    }

    [ImageType(width: ImageWidth, height: ImageHeight)]
    public MLImage Image { get; }
}

public class LandmarkOutput
{
    [ColumnName(LandmarkModelSettings.Output)]
    public float[] Prediction { get; set; }
}

Код относительно понятен. Я хотел бы отметить, что на странице tfhub, откуда мы скачали модель, также есть файл .csv с метками для каждого результата прогнозирования. Я сохранил его локально с именем из константы LabelFileName.

Теперь мы готовы описать входные и выходные данные ML.NET, мы также загружаем модель ONNX и сохраняем ее в собственном формате ML.NET.

// Configure ML model
var mlCtx = new MLContext();

var pipeline = mlCtx
    .Transforms
    // Adjust the image to the required model input size
    .ResizeImages(
        inputColumnName: nameof(LandmarkInput.Image),
        imageWidth: LandmarkInput.ImageWidth,
        imageHeight: LandmarkInput.ImageHeight,
        outputColumnName: "resized"
    )
    // Extract the pixels form the image as a 1D float array, but keep them in the same order as they appear in the image.
    .Append(mlCtx.Transforms.ExtractPixels(
        inputColumnName: "resized",
        interleavePixelColors: true,
        outputAsFloatArray: false,
        outputColumnName: LandmarkModelSettings.Input)
    )
    // Perform the estimation
    .Append(mlCtx.Transforms.ApplyOnnxModel(
            modelFile: "./" + LandmarkModelSettings.OnnxModelName,
            inputColumnName: LandmarkModelSettings.Input,
            outputColumnName: LandmarkModelSettings.Output
        )
    );

// Save ml model
var transformer = pipeline.Fit(mlCtx.Data.LoadFromEnumerable(new List<LandmarkInput>()));

mlCtx.Model.Save(transformer, null, LandmarkModelSettings.MlNetModelFileName);

Давайте пройдемся по коду.

Сначала мы говорим ML.NET изменить размер любого получаемого изображения до размера, ожидаемого загруженной моделью; в данном случае 321х321 пикселей. Изображение с измененным размером должно быть помещено в столбец «Измененный размер». Из столбца с измененным размером мы извлекаем пиксели изображения в одномерный массив чисел с плавающей запятой и выводим эти данные в столбец transpose_1, потому что именно этого ожидает модель. . На последнем шаге мы вызываем модель для прогнозирования.

Наконец, модель сохраняется под именем landmark_classifier_onnx.zip. Теперь он еще больше сократился до 39,6 МБ.

Загрузите модель ML.NET и сделайте прогноз

Прежде чем продолжить, нам следует убедиться, что модель ML.NET действительно работает так, как ожидалось. Для этого мы загружаем модель из файла landmark_classifier_onnx.zip и передаем ей файл .jpg со статуей свободы. Прогноз должен содержать несколько записей, но той, которая имеет наибольшую вероятность, должна быть наша статуя свободы.

// Load ml model
var mlCtx2 = new MLContext();
var loadedModel = mlCtx2.Model.Load(LandmarkModelSettings.MlNetModelFileName, out var _);
var predictionEngine = mlCtx2.Model.CreatePredictionEngine<LandmarkInput, LandmarkOutput>(loadedModel);

// Predict 
var sw = new Stopwatch();
sw.Start();
await using var imagesStream = File.Open("Landmarks/Statue_of_Liberty_7.jpg", FileMode.Open);
var prediction = predictionEngine.Predict(new LandmarkInput(imagesStream));
Console.WriteLine($"Prediction took: {sw.ElapsedMilliseconds}ms");

// Labels start from the second line and each contains the 0-based index, a comma and a name.
var labels = await File.ReadAllLinesAsync(LandmarkModelSettings.LabelFileName)
    .ContinueWith(lineTask =>
    {
        var lines = lineTask.Result;
        return lines
            .Skip(1)
            .Select(line => line.Split(",").Last())
            .ToArray();
    });

// Merge the prediction array with the labels. Produce tuples of landmark name and its probability.
var predictions = prediction.Prediction
        .Select((val, index) => (index, probabiliy: val))
        .Where(pair => pair.probabiliy > 0.55f)
        .Select(pair => (name: labels[pair.index], pair.probabiliy))
        .GroupBy(pair => pair.name)
        .Select(group => (name: group.Key, probability: group.Select((p) => p.probabiliy).Max()))
        .OrderByDescending(pair => pair.probability)
    ;

// Output
var predictionsString = string.Join(Environment.NewLine, predictions.Select(pair => $"name: {pair.name}, probability: {pair.probability}"));
Console.WriteLine(string.Join(Environment.NewLine, predictionsString));

Как вы могли заметить, у меня в папке Ориентиры было изображение под названием Statue_of_Liberty_7.jpg.

Особенностью этой модели является то, что прогноз содержит дубликаты, т. е. выходной массив чисел с плавающей запятой содержит несколько записей для одного и того же ориентира. На странице документации tfhub говорится, что нужно просто использовать прогноз ориентира, который имеет наибольшую вероятность. В зависимости от выбранной вами модели вам может не потребоваться это делать, и может быть достаточно просто присвоить метку каждой позиции из выходных данных.

На моем Macbook Pro M1 Pro результат выглядит так

Prediction took: 108ms
name: Liberty Island, probability: 0,9176943
name: New York Harbor, probability: 0,798547
name: Liberty State Park, probability: 0,7981717
name: The Terminal Tower Residences, probability: 0,6972256

Поздравляем 🎉, вы готовы использовать модель! Продолжайте читать, если вы хотите интегрировать его в приложение AspNet.Core.

Предоставьте его как веб-API

Создайте новый проект веб-API AspNet.Core и добавьте следующие ссылки на пакеты.

<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="7.0.9"/>
<PackageReference Include="Microsoft.Extensions.ML" Version="2.0.1" />
<PackageReference Include="Microsoft.ML" Version="2.0.1" />
<PackageReference Include="Microsoft.ML.OnnxRuntime" Version="1.15.1" />
<PackageReference Include="Microsoft.ML.OnnxTransformer" Version="2.0.1" />

Последние необходимы только во время выполнения. Если вы пропустите их, вы получите такие ошибки, как

System.Reflection.TargetInvoctionException: исключение было создано целью вызова.
— -> System.IO.FileNotFoundException: не удалось загрузить файл или сборку «Microsoft.ML.OnnxTransformer, Version=1.0.0.0, Culture» =нейтрально, PublicKeyToken=cc7b13ffcd2ddd51'. Система не может найти указанный файл.

Имя файла: «Microsoft.ML.OnnxTransformer, Version=1.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51»

or

При выполнении запроса произошло необработанное исключение.
System.Reflection.TargetInvocationException: Исключение было создано целью вызова.
— -> System.Reflection.TargetInvocationException: Исключение было создано объектом вызова. цель вызова.
— -> System.TypeInitializationException: инициализатор типа для «Microsoft.ML.OnnxRuntime.NativeMethods» выдал исключение.
— —> System.DllNotFoundException: невозможно загрузить общую библиотеку ' onnxruntime» или одну из его зависимостей. Чтобы диагностировать проблемы с загрузкой, рассмотрите возможность установки переменной среды DYLD_PRINT_LIBRARIES:
dlopen(

Вы могли бы ожидать, что загрузка модели будет выглядеть точно так же, как мы делали раньше, когда делали наш первый прогноз, но Microsoft рекомендует нечто иное. Поскольку PredictionEngine не является потокобезопасным и его создание требует больших затрат, нам следует использовать PredictionEnginePoolссылку на документы.

Убедитесь, что константы, типы ввода и вывода доступны, и добавьте эту строку в файл Program.cs.

builder.Services
    .AddPredictionEnginePool<LandmarkInput, LandmarkOutput>()
    .FromFile(Path.Combine(
        Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!,
        LandmarkModelSettings.MlNetModelFileName
    ));

Он добавляет PredictionEnginePool для наших типов ввода и вывода для модели ML.NET по указанному пути.

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

internal class NorthAmericanLabelProvider : INorthAmericanLabelProvider
{
    private Lazy<string[]>? _lazyLabels;

    public string[] GetLabels()
    {
        _lazyLabels ??= new Lazy<string[]>(() =>
        {
            var labelFilePath = Path.Combine(
                Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!,
                LandmarkModelSettings.LabelFileName);
            var labelLines = File.ReadAllLines(labelFilePath);
            return labelLines.Skip(1)
                .Select(line => line.Split(","))
                .Select(lineTokens => (Index: int.Parse(lineTokens[0]), LandmarkName: lineTokens[1]))
                .OrderBy(tuple => tuple.Index)
                .Select(tuple => tuple.LandmarkName)
                .ToArray();
        });

        return _lazyLabels.Value;
    }
}

Он считывает содержимое файла, анализирует и сортирует его только один раз, используя Lazy‹T›.

Затем я создал сервис под названием NorthAmericanLandmarkPredictor, который выполняет фактическое прогнозирование. Он использует PredictionEnginePool, который мы зарегистрировали ранее, и INorthAmericanLabelProvider.

internal class NorthAmericanLandmarkPredictor : INorthAmericanLandmarkPredictor
{
    private readonly PredictionEnginePool<LandmarkInput, LandmarkOutput> _predictionEnginePool;
    private readonly INorthAmericanLabelProvider _northAmericanLabelProvider;

    public NorthAmericanLandmarkPredictor(PredictionEnginePool<LandmarkInput, LandmarkOutput> predictionEnginePool, INorthAmericanLabelProvider northAmericanLabelProvider)
    {
        _predictionEnginePool = predictionEnginePool;
        _northAmericanLabelProvider = northAmericanLabelProvider;
    }

    public List<LandmarkPrediction> PredictLandmark(Stream imageStream)
    {
        var labels = _northAmericanLabelProvider.GetLabels();

        // Make prediction
        // Post process prediction - the output contains duplicates, so we should group by label and take the entry with the highest probability.
        // Docs - https://tfhub.dev/google/on_device_vision/classifier/landmarks_classifier_north_america_V1/1
        var landmarkOutput = _predictionEnginePool.Predict(new LandmarkInput(imageStream));
        return landmarkOutput.Prediction
            .Zip(labels, (probability, landmarkName) => (LandmarkName: landmarkName, Probability: probability))
            .GroupBy(tuple => tuple.LandmarkName)
            .Select(group => new LandmarkPrediction(
                group.Key,
                group.MaxBy(tuple => tuple.Probability).Probability
            ))
            .OrderByDescending(prediction => prediction.Probability)
            .ToList();
    }
}

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

[ApiController]
[Route("[controller]")]
public class LandmarkPredictionController : ControllerBase
{
    private readonly INorthAmericanLandmarkPredictor _northAmericanLandmarkPredictor;

    public LandmarkPredictionController(INorthAmericanLandmarkPredictor northAmericanLandmarkPredictor)
    {
        _northAmericanLandmarkPredictor = northAmericanLandmarkPredictor;
    }

    [HttpPost("NorthAmerica")]
    public async Task<List<LandmarkPrediction>> Get(IFormFile image)
    {
        var prediction = _northAmericanLandmarkPredictor.PredictLandmark(image.OpenReadStream());
        return prediction;
    }
}

Во встроенном пользовательском интерфейсе Swagger это выглядит так

Заключение

Вот и все, ребята!

Теперь вы готовы реализовать множество различных моделей машинного обучения с помощью ML.NET и представить их в удобном веб-API.

Вы можете найти весь исходный код по адресу https://github.com/mariusmuntean/Operationalize.ML.NET

Он находится под лицензией MIT, поэтому вы можете использовать его по своему усмотрению.

Если хотите купить мне кофе, напишите мне в Twitter/X 😁