[Перевод] Реализуем машинное обучение на сервере с помощью Swift

В этом руководстве я покажу вам, как работать с моделью машинного обучения на сервере Vapor с помощью Swift.

Ни для кого не секрет, что Apple стремится двигать свою экосистему в сторону извлечения максимальной выгоды путем переноса мощных процессов машинного обучения на устройства пользователей. Core ML предлагает молниеносную производительность и упрощает интеграцию моделей машинного обучения в приложения — от создания до обучения и развертывания моделей. Чтобы лучше понять тонкости работы фреймворка Core ML, рекомендую вам ознакомиться с этим руководством.

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

Core ML Model Deployment dashboard only allows ML models smaller than 50 MB

Дашборд Core ML Model Deployment (официальный способ развертывания Core ML моделей) допускает только ML-модели, размер архива которых не превышает 50 МБ.

Улучшения моделей машинного обучения уже вошли в категорию ежедневных новостей, которые мы видим в наших социальных сетях. Компания Apple неоднократно становилась фигурантом слухов о возможных разработках и инвестициях в области искусственного интеллекта. Ну, а мы оказались в ситуации, когда способность разбираться в моделях становится все более важным навыком.

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

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

Прежде чем мы начнем

Чтобы следовать этому руководству, вам понадобится хорошее понимание Swift, Core ML, Vision, Vapor и немного знаний о командах терминала.

Коротко о Vapor: это веб-фреймворк, который позволяет писать веб-приложения, Rest API и HTTP-серверы на Swift.

В частности, он разработан с учетом трех принципов:

  • В нем используется язык Swift и все его преимущества.

  • Он построен на базе SwiftNIO с прицелом на неблокирующую событийно-управляемую архитектуру.

  • Он позиционирует себя как выразительный, протокольно-ориентированный и типобезопасный фреймворк.

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

Если вы хотите узнать больше о Vapor или лучше разобраться в его архитектуре, то я рекомендую вам почитать официальную, вполне исчерпывающую и содержащую несколько практических примеров документацию.

А теперь давайте перейдем непосредственно к туториалу.

Шаг 1: Установите Vapor и создайте проект

Чтобы установить и использовать последнюю версию Vapor на macOS, вам понадобятся:

  • Swift 5.6 или более поздний версии

  • Установленный Homebrew, чтобы установить Vapor и настроить серверный проект

Если у вас уже установлен Homebrew, откройте приложение Terminal и введите следующую команду, которая установит Vapor:

$ brew install vapor

Чтобы убедиться, что Vapor был установлен правильно, попробуйте выполнить команду help.

$ vapor --help

Когда все необходимые зависимости будут установлены, мы можем создать проект, используя наш Mac в качестве локального хоста. Для этого перейдите в папку, в которой вы хотите создать проект (в данном руководстве мы создаем проект на рабочем столе), и инициализируйте проект в CLI Vapor с помощью следующей команды:

$ vapor new  -n

Из соображений простоты мы выбрали для нашего серверного проекта имя «server» и дальше в этом руководстве так и будем к нему обращаться, но вы можете назвать его как вам заблагорассудится.

Если вам нужен пустой базовый шаблон, который вполне подходит для наших целей, вы можете указать флаг -n, благодаря которому будут автоматически выбраны варианты ответа «нет» на все вопросы, задаваемые в процессе настройки. Если вы уберете этот флаг, Vapor CLI спросит вас, хотите ли вы установить Fluent (ORM) и Leaf (шаблонизацию). Если что-либо из этого вам все-таки понадобится в вашем проекте, то лучше не использовать флаг -n, а специально указать, нужен или нет вам этот плагин, соответственно с помощью флагов --fluent/--no-fluent или --leaf/--no-leaf.

Давайте приступим к работе, открыв проект в Xcode. Перейдите в только что созданную папку и откройте файл Package.swift:

$ cd 
$ open Package.swift

Теперь настало время настроить проект и добавить ML-модель.

Шаг 2: Настройка проекта в Xcode и подготовка ML-модели

Для начала вам потребуется модель Core ML. В этом туториале мы будем использовать классификатор изображений MobileNetV2, который представляет собой предварительно обученную модель, доступную на странице с моделями машинного обучения Core ML. Однако не стесняйтесь экспериментировать с любыми другими моделями, если они зацепят ваш взгляд.

Начнем с создания новой папки «MLModel» в корне пакета server и поместим в нее файл MobileNetV2.mlmodel. Далее создайте новую папку «Resources» в /Sources/App/.

Screenshot of the Vapor project on Xcode showing the location of the folders MLModel and Resources on the project navigator

Поскольку нам нужно скомпилировать модель и создать ее Swift-класс, мы переходим с помощью терминала в каталог /server (где находится файл Package.swift) и вводим следующую команду:

$ cd MLModel && \\
	xcrun coremlcompiler compile MobileNetV2.mlmodel ../Sources/App/Resources && \\
	xcrun coremlcompiler generate MobileNetV2.mlmodel ../Sources/App/Resources --language Swift

Компиляция завершена, и вы должны увидеть в папке Resources новый Swift-класс и скомпилированные файлы модели.

Screenshot of the Vapor project on Xcode showing the location of the compiled files of the machine learning model

Ссылаться на ресурс скомпилированной ML-модели мы еще не можем, поскольку нам нужно добавить его в пакет в качестве исполняемого файла. Поэтому убедитесь, что файл Package.swift включает его следующим образом:

.executableTarget(
    name: "App",
    dependencies: [
        .product(name: "Vapor", package: "vapor"),
    ],
    resources: [
        .copy("Resources/MobileNetV2.mlmodelc"),
    ]
)

Последний шаг по настройке этого серверного проекта — сделать его обнаруживаемым и доступным в сети (пока это будет localhost), изменив схему приложения. Зайдите в редактор схем, проследовав по пути Product→Scheme→Edit Scheme…→Run→Arguments→Arguments Passed On Launch, и добавьте туда serve --hostname 0.0.0.0 следующим образом:

Screenshot of Xcode showing where the Screenshot of Xcode showing where to add the arguments passed on launch for the application

Чтобы сразу избавиться от ряда ошибок, переключите пункт Run Destination проекта на «My Mac».

Screenshot of Xcode showing where to change the run destination of the project

Теперь проект настроен, и мы можем приступить к реализации на нашем сервере Rest API для обработки задач машинного обучения.

Шаг 3: Создание задач классификации и маршрута запросов для ML-модели

Итак, как нам структурировать задачи машинного обучения, которые будут вызываться через Rest API из клиентского приложения?

На этом шаге мы определим структуру MobileNetV2Manager, которая будет управлять выполнением задач машинного обучения, в новом файле, который нужно будет добавить в папку /Sources/App/.

Мы также определим структуру ClassificationResult для хранения результатов классификации изображений, так как нам нужно преобразовать результаты, чтобы передать их клиентскому приложению в формате JSON: в качестве результата такие классификаторы обычно генерируют метку и уровень точности (обозначаемый как «confidence»).

Кроме того, нам нужно добавить функцию, которая будет классифицировать загруженное изображение, и определить типы ошибок, которые могут возникнуть при его загрузке. В результате мы получим нечто, что будет выглядеть следующим образом:

import Vapor
import CoreImage
import Vision

struct MobileNetV2Manager {
    
    enum MLError: Error {
        case modelNotFound
        case noResults
    }
    
    func classify(image: CIImage) throws -> [ClassificationResult] {
        
        // Создание инстанса модели MobileNetV2
        let url = Bundle.module.url(forResource: "MobileNetV2", withExtension: "mlmodelc")!
        guard let model = try? VNCoreMLModel(for: MobileNetV2(contentsOf: url, configuration: MLModelConfiguration()).model) else {
            throw MLError.modelNotFound
        }
        
        // Создание запроса с изображением для анализа
        let request = VNCoreMLRequest(model: model)
        
        // Создание обработчика, обрабатывающего этот запрос
        let handler = VNImageRequestHandler(ciImage: image)
        try? handler.perform([request])
        
        guard let results = request.results as? [VNClassificationObservation] else {
            throw MLError.noResults
        }
        
        // Преобразование результатов для возврата [ClassificationResult]
        let classificationResults = results.map { result in
            ClassificationResult(label: result.identifier, confidence: result.confidence)
        }
        
        return classificationResults
    }
}

struct ClassificationResult: Encodable, Content {
    var label: String
    var confidence: Float
}

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

Здесь мы определяем структуру RequestContent для хранения загруженного содержимого, которое мы будем получать через URL-запросы от клиента.

struct RequestContent: Content {
    var file: File
}

Мы определяем маршрут app.post("mobilenetv2"), по которому клиентское приложение будет загружать изображения, подлежащие классификации. В этом маршруте мы преобразуем присланный нам контент и создаем из него инстанс CoreImage.

И наконец мы передаем изображение в mobileNetV2.classify(image:). Вот как должен выглядеть файл routes.swift в итоге:

import Vapor
import CoreImage

func routes(_ app: Application) throws {
    app.post("mobilenetv2") { req -> [ClassificationResult] in
        
        // Преобразование содержимого запроса, которое было загружено
        let requestContent = try req.content.decode(RequestContent.self)
        let fileData = requestContent.file.data
        
        // Получение данных из файла
        guard let imageData = fileData.getData(at: fileData.readerIndex, length: fileData.readableBytes),
              let ciImage = CIImage(data: imageData) else {
            throw DataFormatError.wrongDataFormat
        }
        
        // Создание инстанса MobileNetV2Manager
        let mobileNetV2 = MobileNetV2Manager()
        
        // Здесь происходит классификация
        do {
            return try mobileNetV2.classify(image: ciImage)
        } catch {
            print(error.localizedDescription)
            return []
        }
    }
}

enum DataFormatError: Error {
    case wrongDataFormat
}

struct RequestContent: Content {
    var file: File
}

Осталось увеличить максимальный размер загружаемого контента для поддержки более тяжелых изображений. Сделать это можно в файле configure.swift с помощью следующей строки кода:

public func configure(_ app: Application) async throws {
    
    app.routes.defaultMaxBodySize = "20mb"
    
    try routes(app)
}

Вот и все! Наш серверный проект готов! У нас есть потрясающий сервер, на котором работает модель MobileNetV2, классифицирующая загруженные изображения.

Нам осталось создать клиентское приложение, которое будет запускать эти задачи машинного обучения посредством URL-запросов. Мы почти подошли к концу!

Шаг 4: Создание клиентского приложения, которое может запрашивать ваш бэкэнд

Запрос и получение этой информации в iOS-приложении — финальный этап этого руководства, и это самая интуитивно понятная часть, если вы уже работали с вызовами API. Все, что нам нужно сделать, — это загрузить изображение, которое мы хотим классифицировать, на IP-адрес сервера, используя маршрут mobilenetv2, который мы определили выше, и приготовиться преобразовать результаты выполнения задачи классификации изображений, которые будут отправлены обратно на клиент.

Давайте создадим новый проект Xcode и выберем в качестве таргета iOS.

Поскольку нам нужно загрузить изображение в тело запроса в правильном формате, необходимо создать метод, который преобразует его в multipart/form-data Content-Type.

Создайте новый Swift-файл Extensions.swift и добавьте в него расширение для типа Data. В этом расширении создайте метод, который будет отвечать за добавление строк в объект Data.

// Функция для добавления данных в тело multipart/form-data URL-запросов
extension Data {
    mutating func append(_ string: String) {
        if let data = string.data(using: .utf8) {
            append(data)
        }
    }
}

Создайте новый Swift-файл под названием APIManager.swift.

Затем создайте класс APIManager, единственный общий инстанс которого будет отвечать за взаимодействие с сервером через URL-запросы. Определите метод для преобразования данных изображения в тело multipart/form-data следующим образом:

import Foundation
import UIKit

class APIManager {
    
    static let shared = APIManager() // Общий инстанс
    
    private init() {}
    
    // Создание тело multipart/form-data с данными изображения
    private func createMultipartFormDataBody(imageData: Data, boundary: String, fileName: String) -> Data {
        var body = Data()
        
        // Добавление данных изображения к первичным данным http-запроса
        body.append("\\r\\n--\\(boundary)\\r\\n")
        body.append("Content-Disposition: form-data; name=\\"file\\"; filename=\\"\\(fileName)\\"\\r\\n")
        body.append("Content-Type: image/jpeg\\r\\n\\r\\n")
        body.append(imageData)
        body.append("\\r\\n")
        
        // Добавление закрывающей границы
        body.append("\\r\\n--\\(boundary)--\\r\\n")
        return body
    }
}

Далее внутри класса APIManager определите структуру ClassificationResult, которая поможет преобразовать результаты классификации, поступающие с нашего сервера, следующим образом:

import Foundation
import UIKit

class APIManager {
    
    static let shared = APIManager() // Общий инстанс
    
    private init() {}
    
    // Создание тела multipart/form-data с данными изображения
    private func createMultipartFormDataBody(imageData: Data, boundary: String, fileName: String) -> Data {
        ...
    }
    
    // Структура для преобразования результатов с сервера
    struct ClassificationResult: Identifiable, Decodable, Equatable {
        let id: UUID = UUID()
        var label: String
        var confidence: Float
        
        private enum CodingKeys: String, CodingKey {
            case label
            case confidence
        }
        
        init(from decoder: Decoder) throws {
            let container = try decoder.container(keyedBy: CodingKeys.self)
            let label = try container.decodeIfPresent(String.self, forKey: .label)
            self.label = label ?? "default"
            let confidence = try container.decodeIfPresent(Float.self, forKey: .confidence)
            self.confidence = confidence ?? 0
        }
    }
}

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

Мы использовали http://localhost:8080/mobilenetv2 в качестве URL для нашей серверной службы, поскольку мы развертывали ее только на MacBook, на котором мы сейчас работаем.

В этом туториале мы тестируем этот механизм на нашем MacBook, выступающем в качестве локального хоста, но не забудьте изменить IP-адрес, когда будете запускать серверное приложение на реальном хосте.

class APIManager {
    
    static let shared = APIManager() // Общий инстанс
    
    private init() {}
    
    func classifyImage(_ image: UIImage) async throws -> [ClassificationResult] {
        
        // Это URL IP-адреса вашего хоста: в данный момент, чтобы не усложнять пример, мы используем localhost, но не забудьте изменить его на соответствующий IP-адрес хоста, когда будете запускать приложение на реальном хосте
        guard let requestURL = URL(string: "") else {
            throw URLError(.badURL)
        }
        
        // Преобразование изображения в JPEG со значением compressionQuality, равным 1 (0 - наилучшее качество)
        guard let imageData = image.jpegData(compressionQuality: 1) else {
            throw URLError(.unknown)
        }
        
        // Граничная строка с UUID для загрузки изображения в URLRequest
        let boundary = "Boundary-\\(UUID().uuidString)"
        
        // Инстанс POST URLRequest
        var request = URLRequest(url: requestURL)
        
        request.httpMethod = "POST"
    
        request.setValue("multipart/form-data; boundary=\\(boundary)", forHTTPHeaderField: "Content-Type")
        
        // Создание тела multipart/form-data
        let body = createMultipartFormDataBody(imageData: imageData, boundary: boundary, fileName: "photo.jpg")
        
        // Загрузка данных в URL на основе указанного URL-запроса и получение результатов классификации
        let (data, response) = try await URLSession.shared.upload(for: request, from: body)
        
        // Проверка URL-ответа, кода состояния и генерация исключения по результатам
        guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
            throw URLError(.badServerResponse)
        }
        
        // Преобразование данных в массив ClassificationResult
        return try JSONDecoder().decode([ClassificationResult].self, from: data)
    }
    
    // Создание тела multipart/form-data с данными изображения
    private func createMultipartFormDataBody(imageData: Data, boundary: String, fileName: String) -> Data {
        ...
    }
    
    // Структура для преобразования результатов с сервера
    struct ClassificationResult: Identifiable, Decodable, Equatable {
        ...
    }
}

Наконец, чтобы вызвать метод APIManager для классификации изображений, которые мы будем получать из Image Picker, мы создали объект ViewModel (чтобы не перегружать View этими задачами).

В этом проекте мы по классике использовали UIKit Image Picker, но если вы хотите использовать новые API Apple для выбора изображения из Photos Library с помощью SwiftUI, то рекомендую вам ознакомиться с этим руководством.

@Observable
class ViewModel {
    
    private var apiManager = APIManager.shared
    var results: [APIManager.ClassificationResult] = []
    
    // Функция, вызывающая менеджер, который отправляет URL-запрос
    func classifyImage(_ image: UIImage) async {
        do {
            results = try await apiManager.classifyImage(image)
        } catch {
            print(error.localizedDescription)
        }
    }
}

И вот, наконец, сама классификация изображения из SwiftUI View по нажатию одной кнопки.

Button("Classify Image") {
	Task {
		await viewModel.classifyImage(image)
	}
}

Конечный результат

После выполнения всех этих шагов у вас должны быть запущены два разных проекта: серверный проект Vapor и клиентское приложение под iOS. Приложение будет формировать и отправлять запрос с изображением на анализ, а сервер будет скармливать его ML-модели и возвращать ответ клиенту.

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

Ознакомиться с полным кодом проекта можно в этом репозитории. Также напомню, что вы всегда можете поставить звезду, если он вам пригодился.

Что дальше?

До сих пор для размещения серверного проекта мы использовали локальную машину, но как перенести его на удаленный сервер для работы в продакшене? Ведь чем производительнее ваш сервер, тем лучше Core ML будет обрабатывать большие модели и использовать большие временные файлы.

Например, мы можем выбрать для нашего сервера множество различных хостинг-провайдеров. Самыми популярными из них являются: AWS, Heroku, DigitalOcean…

Еще одна очень важная тема, на которую стоит потратить время, — это установка инстанса NGINX в качестве прокси. Это очень удобно для того, чтобы требовать от клиента использования TLS (HTTPS), ограничивать скорость запросов, или даже обслуживать публичные файлы без обращения к вашему Vapor-приложению (и многое другое). Подробнее об этом вы можете узнать здесь.

Одна из лучших практик — использовать образ Docker. Если вы хотите узнать об этом больше, то я рекомендую вам следующий ресурс.

Среди других сервисов, позволяющих управлять моделями машинного обучения, с которыми вам, возможно, стоило бы познакомиться поближе, можно выделить Azure ML, Amazon SageMaker и IBM Watson Machine Learning.

Материал подготовлен в рамках практического онлайн-курса «iOS Developer. Professional».

© Habrahabr.ru